Close remaining Task 10 renderer review findings

Why: Direct artifact registration trusted filename extensions, the shared renderer parity test used a reduced fixture, and migration comparison omitted canonical list/task/rule/alignment properties plus their Word presentation.
Evidence: go test -count=1 ./...; go test -race ./internal/docgen ./internal/tools ./internal/runmgr; go vet ./...; go build ./...; PYTHONPATH=. python3 -m unittest tests.test_housestyle; git diff --check.
Impact: Reject renamed WordprocessingML packages while preserving non-Word artifacts, pin the reviewed renderer semantics, and report canonical and DOCX presentation regressions as accidental differences.
Refs: Task 10; /tmp/task10-backend-final-review-last.md
This commit is contained in:
weichen
2026-10-15 22:33:29 +00:00
parent fec2d16cc6
commit 6c01d00a3f
8 changed files with 717 additions and 55 deletions
+432 -5
View File
@@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"strconv"
"strings"
"agent-document-renderer/model"
@@ -29,6 +30,36 @@ type comparisonBlock struct {
Rows [][]string `json:"rows,omitempty"`
}
type comparisonList struct {
Depth int `json:"depth"`
Ordered bool `json:"ordered"`
Start int `json:"start,omitempty"`
Items []comparisonListItem `json:"items"`
}
type comparisonListItem struct {
Text string `json:"text"`
Checked string `json:"checked,omitempty"`
}
type comparisonTask struct {
Text string `json:"text"`
Checked string `json:"checked"`
}
type comparisonSemantics struct {
Lists []comparisonList `json:"lists"`
Tasks []comparisonTask `json:"tasks"`
ThematicBreaks int `json:"thematicBreaks"`
TableAlignments [][][]string `json:"tableAlignments"`
}
type docxNumbering struct {
Level int `json:"level"`
Format string `json:"format"`
Start int `json:"start"`
}
type docxComparison struct {
Paragraphs []string `json:"paragraphs"`
PageSize map[string]string `json:"pageSize"`
@@ -39,6 +70,10 @@ type docxComparison struct {
ExternalLinks int `json:"externalLinks"`
ForbiddenObject bool `json:"forbiddenObject"`
Semantics map[string]bool `json:"semantics"`
Numbering []docxNumbering `json:"numbering"`
Tasks []comparisonTask `json:"tasks"`
ThematicRules int `json:"thematicRules"`
TableAlignments [][][]string `json:"tableAlignments"`
}
func compareLegacyAndShared(title string, source []byte, canonical model.CanonicalDocument, legacy, shared []byte) ComparisonReceipt {
@@ -46,12 +81,23 @@ func compareLegacyAndShared(title string, source []byte, canonical model.Canonic
legacyStructure := legacyComparisonStructure(string(source))
sharedStructure := canonicalComparisonStructure(canonical)
receipt.Dimensions = append(receipt.Dimensions, compareExactDimension("canonical-structure", legacyStructure, sharedStructure, ""))
legacySemantics := sourceComparisonSemantics(string(source))
sharedSemantics := canonicalComparisonSemantics(canonical)
receipt.Dimensions = append(receipt.Dimensions,
compareExactDimension("list-structure", legacySemantics.Lists, sharedSemantics.Lists, ""),
compareExactDimension("task-state", legacySemantics.Tasks, sharedSemantics.Tasks, ""),
compareExactDimension("thematic-break", legacySemantics.ThematicBreaks, sharedSemantics.ThematicBreaks, ""),
compareExactDimension("table-alignment", legacySemantics.TableAlignments, sharedSemantics.TableAlignments, ""),
)
legacyDOCX, legacyErr := inspectComparisonDOCX(legacy)
sharedDOCX, sharedErr := inspectComparisonDOCX(shared)
if legacyErr != nil || sharedErr != nil {
detail := fmt.Sprintf("legacy=%v shared=%v", legacyErr, sharedErr)
for _, name := range []string{"visible-text", "page-layout", "header-footer", "node-bookmarks", "safe-links", "security-objects", "word-semantics"} {
for _, name := range []string{
"visible-text", "page-layout", "header-footer", "node-bookmarks", "safe-links", "security-objects", "word-semantics",
"word-numbering", "word-task-state", "word-thematic-rule", "word-table-alignment",
} {
receipt.Dimensions = append(receipt.Dimensions, ComparisonDimension{Name: name, Status: "unassessed", Detail: detail})
}
return finishComparison(receipt)
@@ -106,6 +152,10 @@ func compareLegacyAndShared(title string, source []byte, canonical model.Canonic
receipt.Dimensions = append(receipt.Dimensions,
compareExactDimension("security-objects", legacyDOCX.ForbiddenObject, sharedDOCX.ForbiddenObject, ""),
compareExactDimension("word-semantics", legacyDOCX.Semantics, sharedDOCX.Semantics, ""),
compareExactDimension("word-numbering", legacyDOCX.Numbering, sharedDOCX.Numbering, ""),
compareExactDimension("word-task-state", legacyDOCX.Tasks, sharedDOCX.Tasks, ""),
compareExactDimension("word-thematic-rule", legacyDOCX.ThematicRules, sharedDOCX.ThematicRules, ""),
compareExactDimension("word-table-alignment", legacyDOCX.TableAlignments, sharedDOCX.TableAlignments, ""),
)
return finishComparison(receipt)
}
@@ -205,6 +255,7 @@ func canonicalComparisonStructure(document model.CanonicalDocument) []comparison
}
entry.Rows = append(entry.Rows, values)
}
case model.NodeThematicBreak:
default:
continue
}
@@ -230,6 +281,192 @@ func visibleLegacyRuns(runs []run) string {
func normalizeComparisonText(value string) string { return strings.Join(strings.Fields(value), " ") }
func sourceComparisonSemantics(source string) comparisonSemantics {
document := parseMarkdown(source)
result := comparisonSemantics{}
for _, block := range document.blocks {
switch block.kind {
case bHR:
result.ThematicBreaks++
case bTable:
rows := make([][]string, len(block.rows))
for rowIndex, row := range block.rows {
rows[rowIndex] = make([]string, len(row))
for cellIndex := range row {
alignment := "left"
if cellIndex < len(block.align) && block.align[cellIndex] != "" {
alignment = block.align[cellIndex]
}
rows[rowIndex][cellIndex] = alignment
}
}
result.TableAlignments = append(result.TableAlignments, rows)
}
}
type openList struct {
indent int
ordered bool
index int
}
var stack []openList
inFence := false
fence := ""
for _, rawLine := range strings.Split(strings.ReplaceAll(source, "\r\n", "\n"), "\n") {
trimmed := strings.TrimSpace(rawLine)
if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
marker := trimmed[:3]
if !inFence {
inFence, fence = true, marker
} else if marker == fence {
inFence, fence = false, ""
}
stack = nil
continue
}
if inFence {
continue
}
line, found := parseComparisonListLine(rawLine)
if !found {
stack = nil
continue
}
for len(stack) > 0 && stack[len(stack)-1].indent > line.indent {
stack = stack[:len(stack)-1]
}
if len(stack) > 0 && stack[len(stack)-1].indent == line.indent && stack[len(stack)-1].ordered != line.ordered {
stack = stack[:len(stack)-1]
}
if len(stack) == 0 || stack[len(stack)-1].indent != line.indent {
start := 0
if line.ordered {
start = line.start
}
result.Lists = append(result.Lists, comparisonList{Depth: len(stack), Ordered: line.ordered, Start: start})
stack = append(stack, openList{indent: line.indent, ordered: line.ordered, index: len(result.Lists) - 1})
}
list := &result.Lists[stack[len(stack)-1].index]
text := normalizeComparisonText(visibleLegacyRuns(document.parseInline(line.text)))
list.Items = append(list.Items, comparisonListItem{Text: text, Checked: line.checked})
if line.checked != "" {
result.Tasks = append(result.Tasks, comparisonTask{Text: text, Checked: line.checked})
}
}
return result
}
type comparisonListLine struct {
indent int
ordered bool
start int
checked string
text string
}
func parseComparisonListLine(raw string) (comparisonListLine, bool) {
line := strings.TrimRight(raw, " \t")
indent := 0
for indent < len(line) && line[indent] == ' ' {
indent++
}
if indent < len(line) && line[indent] == '\t' {
return comparisonListLine{}, false
}
rest := line[indent:]
parsed := comparisonListLine{indent: indent, start: 1}
for _, marker := range []string{"- ", "* ", "+ "} {
if strings.HasPrefix(rest, marker) {
parsed.text = strings.TrimSpace(strings.TrimPrefix(rest, marker))
if len(parsed.text) >= 3 && parsed.text[0] == '[' && parsed.text[2] == ']' && (parsed.text[1] == ' ' || parsed.text[1] == 'x' || parsed.text[1] == 'X') {
parsed.checked = strconv.FormatBool(parsed.text[1] == 'x' || parsed.text[1] == 'X')
parsed.text = strings.TrimSpace(parsed.text[3:])
}
return parsed, true
}
}
digits := 0
for digits < len(rest) && rest[digits] >= '0' && rest[digits] <= '9' {
digits++
}
if digits == 0 || digits > 9 || digits+1 >= len(rest) || (rest[digits] != '.' && rest[digits] != ')') || rest[digits+1] != ' ' {
return comparisonListLine{}, false
}
value, err := strconv.Atoi(rest[:digits])
if err != nil {
return comparisonListLine{}, false
}
parsed.start = value
parsed.ordered = true
parsed.text = strings.TrimSpace(rest[digits+2:])
return parsed, true
}
func canonicalComparisonSemantics(document model.CanonicalDocument) comparisonSemantics {
result := comparisonSemantics{}
var addList func(model.Node, int)
addList = func(list model.Node, depth int) {
ordered := nodePropertyValue(list, "ordered") == "true"
start := 0
if ordered {
start = 1
if value := nodePropertyValue(list, "start"); value != "" {
if parsed, err := strconv.Atoi(value); err == nil {
start = parsed
}
}
}
summary := comparisonList{Depth: depth, Ordered: ordered, Start: start}
for _, item := range list.Children {
checked := nodePropertyValue(item, "checked")
text := normalizeComparisonNodeText(item)
summary.Items = append(summary.Items, comparisonListItem{Text: text, Checked: checked})
if checked != "" {
result.Tasks = append(result.Tasks, comparisonTask{Text: text, Checked: checked})
}
}
result.Lists = append(result.Lists, summary)
for _, item := range list.Children {
for _, child := range item.Children {
if child.Kind == model.NodeList {
addList(child, depth+1)
}
}
}
}
for _, node := range document.Root.Children {
switch node.Kind {
case model.NodeList:
addList(node, 0)
case model.NodeThematicBreak:
result.ThematicBreaks++
case model.NodeTable:
rows := make([][]string, len(node.Children))
for rowIndex, row := range node.Children {
rows[rowIndex] = make([]string, len(row.Children))
for cellIndex, cell := range row.Children {
alignment := nodePropertyValue(cell, "alignment")
if alignment == "" {
alignment = "left"
}
rows[rowIndex][cellIndex] = alignment
}
}
result.TableAlignments = append(result.TableAlignments, rows)
}
}
return result
}
func nodePropertyValue(node model.Node, name string) string {
for _, property := range node.Properties {
if property.Name == name {
return property.Value
}
}
return ""
}
func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
@@ -255,6 +492,17 @@ func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
result := docxComparison{PageSize: map[string]string{}, Margins: map[string]string{}, Semantics: map[string]bool{}}
decoder := xml.NewDecoder(bytes.NewReader(document))
var paragraph strings.Builder
type rawNumberedParagraph struct {
Level int
NumID string
Text string
}
var numbered []rawNumberedParagraph
inParagraph, inText, inCell, inParagraphBorder := false, false, false, false
paragraphLevel, paragraphNumID, cellAlignment := 0, "", ""
paragraphRule := false
var table [][]string
var row []string
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
@@ -266,6 +514,40 @@ func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
switch value := token.(type) {
case xml.StartElement:
switch value.Name.Local {
case "p":
inParagraph = true
paragraph.Reset()
paragraphLevel, paragraphNumID, paragraphRule = 0, "", false
case "t":
if inParagraph {
inText = true
}
case "numId":
if inParagraph {
paragraphNumID = xmlAttributeValue(value.Attr, "val")
}
case "ilvl":
if inParagraph {
paragraphLevel, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
}
case "pBdr":
if inParagraph {
inParagraphBorder = true
}
case "bottom":
if inParagraphBorder {
paragraphRule = true
}
case "tbl":
table = nil
case "tr":
row = nil
case "tc":
inCell, cellAlignment = true, ""
case "jc":
if inCell {
cellAlignment = xmlAttributeValue(value.Attr, "val")
}
case "pgSz":
copyAttributes(result.PageSize, value.Attr, "w", "h", "orient")
case "pgMar":
@@ -274,16 +556,71 @@ func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
result.Bookmarks++
}
case xml.CharData:
paragraph.Write(value)
if inText {
paragraph.Write(value)
}
case xml.EndElement:
if value.Name.Local == "p" {
if text := normalizeComparisonText(paragraph.String()); text != "" {
switch value.Name.Local {
case "t":
inText = false
case "pBdr":
inParagraphBorder = false
case "p":
text := normalizeComparisonText(paragraph.String())
if text != "" {
result.Paragraphs = append(result.Paragraphs, text)
}
paragraph.Reset()
if paragraphNumID != "" {
numbered = append(numbered, rawNumberedParagraph{Level: paragraphLevel, NumID: paragraphNumID, Text: text})
switch {
case strings.HasPrefix(text, "☑"):
result.Tasks = append(result.Tasks, comparisonTask{Text: normalizeComparisonText(strings.TrimSpace(strings.TrimPrefix(text, "☑"))), Checked: "true"})
case strings.HasPrefix(text, "☐"):
result.Tasks = append(result.Tasks, comparisonTask{Text: normalizeComparisonText(strings.TrimSpace(strings.TrimPrefix(text, "☐"))), Checked: "false"})
}
}
if paragraphRule {
result.ThematicRules++
}
inParagraph = false
case "tc":
if cellAlignment == "" {
cellAlignment = "left"
}
row = append(row, cellAlignment)
inCell = false
case "tr":
table = append(table, row)
case "tbl":
result.TableAlignments = append(result.TableAlignments, table)
}
}
}
if len(numbered) > 0 {
numbering, found := parts["word/numbering.xml"]
if !found {
return docxComparison{}, errors.New("word/numbering.xml missing")
}
definitions, instances, err := inspectDOCXNumbering(numbering)
if err != nil {
return docxComparison{}, err
}
for _, paragraph := range numbered {
instance, found := instances[paragraph.NumID]
if !found {
return docxComparison{}, fmt.Errorf("numbering instance %s missing", paragraph.NumID)
}
level := definitions[instance.AbstractID][paragraph.Level]
start := level.Start
if start == 0 {
start = 1
}
if override, found := instance.Overrides[paragraph.Level]; found {
start = override
}
result.Numbering = append(result.Numbering, docxNumbering{Level: paragraph.Level, Format: level.Format, Start: start})
}
}
for name, content := range parts {
lower := strings.ToLower(name)
if strings.HasPrefix(lower, "word/header") && strings.HasSuffix(lower, ".xml") {
@@ -310,6 +647,96 @@ func inspectComparisonDOCX(raw []byte) (docxComparison, error) {
return result, nil
}
type docxNumberingLevel struct {
Format string
Start int
}
type docxNumberingInstance struct {
AbstractID string
Overrides map[int]int
}
func inspectDOCXNumbering(raw []byte) (map[string]map[int]docxNumberingLevel, map[string]docxNumberingInstance, error) {
definitions := map[string]map[int]docxNumberingLevel{}
instances := map[string]docxNumberingInstance{}
decoder := xml.NewDecoder(bytes.NewReader(raw))
currentAbstract, currentInstance := "", ""
currentLevel, currentOverride := -1, -1
for {
token, err := decoder.Token()
if errors.Is(err, io.EOF) {
return definitions, instances, nil
}
if err != nil {
return nil, nil, err
}
switch value := token.(type) {
case xml.StartElement:
switch value.Name.Local {
case "abstractNum":
currentAbstract = xmlAttributeValue(value.Attr, "abstractNumId")
definitions[currentAbstract] = map[int]docxNumberingLevel{}
case "lvl":
if currentAbstract != "" {
currentLevel, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "ilvl"))
}
case "start":
if currentAbstract != "" && currentLevel >= 0 {
level := definitions[currentAbstract][currentLevel]
level.Start, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
definitions[currentAbstract][currentLevel] = level
}
case "numFmt":
if currentAbstract != "" && currentLevel >= 0 {
level := definitions[currentAbstract][currentLevel]
level.Format = xmlAttributeValue(value.Attr, "val")
definitions[currentAbstract][currentLevel] = level
}
case "num":
currentInstance = xmlAttributeValue(value.Attr, "numId")
instances[currentInstance] = docxNumberingInstance{Overrides: map[int]int{}}
case "abstractNumId":
if currentInstance != "" {
instance := instances[currentInstance]
instance.AbstractID = xmlAttributeValue(value.Attr, "val")
instances[currentInstance] = instance
}
case "lvlOverride":
if currentInstance != "" {
currentOverride, _ = strconv.Atoi(xmlAttributeValue(value.Attr, "ilvl"))
}
case "startOverride":
if currentInstance != "" && currentOverride >= 0 {
instance := instances[currentInstance]
instance.Overrides[currentOverride], _ = strconv.Atoi(xmlAttributeValue(value.Attr, "val"))
instances[currentInstance] = instance
}
}
case xml.EndElement:
switch value.Name.Local {
case "lvl":
currentLevel = -1
case "abstractNum":
currentAbstract = ""
case "lvlOverride":
currentOverride = -1
case "num":
currentInstance = ""
}
}
}
}
func xmlAttributeValue(attributes []xml.Attr, name string) string {
for _, attribute := range attributes {
if attribute.Name.Local == name {
return attribute.Value
}
}
return ""
}
func copyAttributes(target map[string]string, attributes []xml.Attr, names ...string) {
wanted := make(map[string]bool, len(names))
for _, name := range names {
+20 -15
View File
@@ -9,37 +9,42 @@ import (
"testing"
)
// 样例正文:会议纪要形态(标题/表格/列表/任务项/引用/链接/删除线/分隔线)。
// 样例正文:会议纪要形态,也是 legacy/shared 唯一完整语义奇偶性夹具。
// 行内代码用 \x60(反引号)拼接——raw string 里不能直接写字面反引号。
const sampleMinutes = "# 研发周会纪要\n" + `
## 一、基本信息
| 项目 | 内容 |
|---|---|
| 时间 | 2026-09-30 14:00 |
| 地点 | 3F 会议室 |
| 主持人 | 陈威 |
正文含 **粗体**、*强调*、~~删除~~、` + "\x60inline\x60" + ` 和 [安全标签](https://example.com/private-target)。
## 二、讨论要点
- **架构**:docx 生成走后端转换,见 [方案](https://example.com/plan)
- 排版要求:标题层级、表格、列表
- 二级要点:交付物卡片
- **架构**:共享渲染器
- 排版要求
1. 有序子项
- 深层无序子项
- [x] 已完成:预览装配
- [ ] 待办:样式打磨
> 会议结论:按周五节点交付。
` + "\x60\x60\x60go" + `
fmt.Println("backtick")
` + "\x60\x60\x60" + `
~~~go
fmt.Println("tilde")
~~~~
## 三、决议
1. 采用 write_docx 工具
2. 对话内出交付物卡片
` + "\x60write_docx\x60 与 ~~write_artifact~~ 的分工见上文。" + `
3. 第三项
4. 第四项
---
后续:周报合并。
| 左 | 中 | 右 | 默认 |
| :--- | :---: | ---: | --- |
| L | C | R | D |
`
func buildSample(t *testing.T) []byte {
@@ -166,7 +171,7 @@ func TestBuildHyperlinkRels(t *testing.T) {
rc.Close()
}
}
if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/plan") {
if !strings.Contains(rels, `Id="rIdH1"`) || !strings.Contains(rels, "https://example.com/private-target") {
t.Errorf("超链接 relationship 缺失: %s", rels)
}
}
@@ -2,6 +2,6 @@
package docgen
const rendererRevision = "d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
const rendererRevision = "0d1c5121d137be817db5f98d3c13320dfa919c8a"
const RendererIdentity = "agent-document-renderer@" + rendererRevision
+178 -32
View File
@@ -60,23 +60,6 @@ func workspaceRequest(markdown []byte) Request {
}
}
const semanticMarkdown = `# **Semantic** report
Paragraph with **bold**, *italic*, ~~strike~~, ` + "`inline code`" + `, and [safe **label**](https://example.com/private-target).
> Quoted **evidence** with *emphasis*.
` + "```go" + `
fmt.Println("safe")
` + "```" + `
- **bold item**
| Item | Meaning |
|---|---|
| ` + "`code cell`" + ` | [table label](#semantic) |
`
func TestGenerateProducesDeterministicHashedDeliveryPackage(t *testing.T) {
request := workspaceRequest([]byte(representativeMarkdown))
first, err := Generate(context.Background(), request)
@@ -207,14 +190,35 @@ func TestGeneratePersistsCanonicalMarkdownWithStableNodeParity(t *testing.T) {
}
}
func TestGeneratePreservesAllSharedSemanticMarkdownWithoutLinkTargetLeak(t *testing.T) {
request := workspaceRequest([]byte(semanticMarkdown))
request.Title = "Semantic report"
func TestGeneratePreservesCompleteLegacyMarkdownContract(t *testing.T) {
request := workspaceRequest([]byte(sampleMinutes))
delivery, err := Generate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
var quote, code *model.Node
replayed, err := markdownrenderer.Parse(delivery.Markdown.Bytes, markdownrenderer.Options{
MaxBytes: 2 << 20, MaxNodes: 10_000, MaxDepth: 64,
})
if err != nil || !reflect.DeepEqual(delivery.Document, replayed.Document) {
t.Fatalf("complete legacy fixture did not replay canonically: err=%v", err)
}
wantKinds := []model.NodeKind{
model.NodeHeading, model.NodeHeading, model.NodeParagraph,
model.NodeHeading, model.NodeList, model.NodeQuote,
model.NodeCodeBlock, model.NodeCodeBlock, model.NodeHeading,
model.NodeList, model.NodeThematicBreak, model.NodeTable,
}
gotKinds := make([]model.NodeKind, len(delivery.Document.Root.Children))
for index := range delivery.Document.Root.Children {
gotKinds[index] = delivery.Document.Root.Children[index].Kind
}
if !reflect.DeepEqual(gotKinds, wantKinds) {
t.Fatalf("canonical block kinds = %#v, want %#v", gotKinds, wantKinds)
}
var quote *model.Node
var codes []*model.Node
var hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink bool
var walkInline func([]model.Inline)
walkInline = func(inlines []model.Inline) {
@@ -240,7 +244,7 @@ func TestGeneratePreservesAllSharedSemanticMarkdownWithoutLinkTargetLeak(t *test
quote = node
}
if node.Kind == model.NodeCodeBlock {
code = node
codes = append(codes, node)
}
walkInline(node.Inlines)
for index := range node.Children {
@@ -248,23 +252,71 @@ func TestGeneratePreservesAllSharedSemanticMarkdownWithoutLinkTargetLeak(t *test
}
}
walk(&delivery.Document.Root)
if quote == nil || code == nil || !hasStrong || !hasEmphasis || !hasStrike || !hasInlineCode || !hasLink {
t.Fatalf("semantic canonical document incomplete: quote=%v code=%v strong=%v emphasis=%v strike=%v inlineCode=%v link=%v", quote != nil, code != nil, hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink)
if quote == nil || len(codes) != 2 || !hasStrong || !hasEmphasis || !hasStrike || !hasInlineCode || !hasLink {
t.Fatalf("semantic canonical document incomplete: quote=%v codes=%d strong=%v emphasis=%v strike=%v inlineCode=%v link=%v", quote != nil, len(codes), hasStrong, hasEmphasis, hasStrike, hasInlineCode, hasLink)
}
if codes[0].Text != "fmt.Println(\"backtick\")\n" || codes[1].Text != "fmt.Println(\"tilde\")\n" {
t.Fatalf("fenced code text = %q / %q", codes[0].Text, codes[1].Text)
}
unordered := delivery.Document.Root.Children[4]
orderedChild := unordered.Children[1].Children[0]
deepBullet := orderedChild.Children[0].Children[0]
if nodePropertyValue(unordered, "ordered") != "false" ||
nodePropertyValue(orderedChild, "ordered") != "true" ||
nodePropertyValue(deepBullet, "ordered") != "false" {
t.Fatalf("nested list semantics = %#v / %#v / %#v", unordered.Properties, orderedChild.Properties, deepBullet.Properties)
}
if nodePropertyValue(unordered.Children[2], "checked") != "true" || nodePropertyValue(unordered.Children[3], "checked") != "false" {
t.Fatalf("task states = %#v / %#v", unordered.Children[2].Properties, unordered.Children[3].Properties)
}
ordered := delivery.Document.Root.Children[9]
if nodePropertyValue(ordered, "ordered") != "true" || nodePropertyValue(ordered, "start") != "3" {
t.Fatalf("ordered-list start = %#v, want 3", ordered.Properties)
}
table := delivery.Document.Root.Children[11]
wantAlignments := []string{"left", "center", "right", ""}
for rowIndex, row := range table.Children {
for cellIndex, cell := range row.Children {
if got := nodePropertyValue(cell, "alignment"); got != wantAlignments[cellIndex] {
t.Fatalf("row %d cell %d alignment = %q, want %q", rowIndex, cellIndex, got, wantAlignments[cellIndex])
}
}
}
documentXML := docxPart(t, delivery.DOCX.Bytes, "word/document.xml")
for _, fragment := range []string{`<w:b/>`, `<w:i/>`, `<w:strike/>`, `w:val="inlineCode"`, `w:val="link"`, `w:val="quote"`, `w:val="code"`, `<w:numPr>`, `<w:tbl>`} {
for _, fragment := range []string{
`<w:b/>`, `<w:i/>`, `<w:strike/>`, `w:val="inlineCode"`, `w:val="link"`,
`w:val="quote"`, `w:val="code"`, `<w:numPr>`, `<w:tbl>`,
`<w:t xml:space="preserve">☑ </w:t>`, `<w:t xml:space="preserve">☐ </w:t>`,
`<w:pBdr><w:bottom w:val="single"`, `<w:jc w:val="left"/>`,
`<w:jc w:val="center"/>`, `<w:jc w:val="right"/>`,
} {
if !strings.Contains(documentXML, fragment) {
t.Errorf("Word output missing %s", fragment)
}
}
numberingXML := docxPart(t, delivery.DOCX.Bytes, "word/numbering.xml")
for _, fragment := range []string{
`<w:ilvl w:val="0"/>`, `<w:ilvl w:val="1"/>`, `<w:ilvl w:val="2"/>`,
`<w:lvlOverride w:ilvl="0"><w:startOverride w:val="3"/></w:lvlOverride>`,
} {
if !strings.Contains(documentXML+numberingXML, fragment) {
t.Errorf("Word numbering missing %s", fragment)
}
}
if bytes.Contains(delivery.DOCX.Bytes, []byte("https://example.com/private-target")) || strings.Contains(documentXML, "TargetMode=\"External\"") {
t.Fatal("safe link target leaked into DOCX package")
}
replayed := request
replayed.Markdown = delivery.Markdown.Bytes
second, err := Generate(context.Background(), replayed)
if err != nil || !reflect.DeepEqual(delivery.Document, second.Document) {
t.Fatalf("semantic Markdown did not replay canonically: err=%v", err)
legacy, err := legacyBuild(request.Title, sampleMinutes)
if err != nil {
t.Fatal(err)
}
receipt := compareLegacyAndShared(request.Title, []byte(sampleMinutes), delivery.Document, legacy, delivery.DOCX.Bytes)
for _, name := range []string{"list-structure", "task-state", "thematic-break", "table-alignment"} {
if dimension := comparisonDimension(t, receipt, name); dimension.Status != "match" {
t.Errorf("complete fixture comparison dimension %s = %#v, want match", name, dimension)
}
}
}
@@ -308,7 +360,7 @@ func TestPersistedControlledMetadataRoutesBeforeDefaultProfileLoad(t *testing.T)
}
func TestRendererIdentityMatchesPublishedPin(t *testing.T) {
const want = "agent-document-renderer@d5cd8c126045a27d5a5cc5da23f64d3c737e116a"
const want = "agent-document-renderer@0d1c5121d137be817db5f98d3c13320dfa919c8a"
if RendererIdentity != want {
t.Fatalf("RendererIdentity = %q, want %q", RendererIdentity, want)
}
@@ -555,7 +607,7 @@ func TestLegacyComparisonIsExplicitAndDoesNotReplaceSharedOutput(t *testing.T) {
}
func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *testing.T) {
const common = "# 研发周会纪要\n\n正文有 **粗体**、*斜体*、~~删除~~、`代码` 和 [链接](https://example.com/private)。\n\n> 引用内容。\n\n```go\nfmt.Println(1)\n```\n\n## 要点\n\n- 统一解析入口\n- 保留 Markdown\n\n| 项目 | 状态 |\n|---|---|\n| 解析器 | 统一 |\n"
const common = "# 研发周会纪要\n\n正文有 **粗体**、*斜体*、~~删除~~、`代码` 和 [链接](https://example.com/private)。\n\n> 引用内容。\n\n```go\nfmt.Println(1)\n```\n\n## 要点\n\n- [x] 已完成\n- [ ] 待办\n\n1. 第一项\n2. 第二项\n\n---\n\n| 左 | 中 | 右 |\n| :--- | :---: | ---: |\n| L | C | R |\n"
request := workspaceRequest([]byte(common))
delivery, err := generateWithOptions(context.Background(), request, RenderOptions{MigrationComparison: true}, defaultGeneratorDependencies())
if err != nil {
@@ -575,6 +627,54 @@ func TestRepresentativeLegacyComparisonRecordsOnlyIntentionalDifferences(t *test
t.Errorf("comparison omitted reviewed exception %s: %#v", id, delivery.Comparison.Dimensions)
}
}
for _, name := range []string{
"list-structure", "task-state", "thematic-break", "table-alignment",
"word-numbering", "word-task-state", "word-thematic-rule", "word-table-alignment",
} {
dimension := comparisonDimension(t, delivery.Comparison, name)
if dimension.Status != "match" {
t.Errorf("comparison dimension %s = %#v, want match", name, dimension)
}
}
}
func TestComparisonDetectsCanonicalAndDOCXTableAlignmentLoss(t *testing.T) {
const aligned = "# Alignment\n\n| Left | Center | Right | Default |\n| :--- | :---: | ---: | --- |\n| L | C | R | D |\n"
request := workspaceRequest([]byte(aligned))
request.Title = "Alignment"
delivery, err := Generate(context.Background(), request)
if err != nil {
t.Fatal(err)
}
legacy, err := legacyBuild(request.Title, aligned)
if err != nil {
t.Fatal(err)
}
canonicalWithoutAlignment := delivery.Document.Clone()
for rowIndex := range canonicalWithoutAlignment.Root.Children[1].Children {
for cellIndex := range canonicalWithoutAlignment.Root.Children[1].Children[rowIndex].Children {
canonicalWithoutAlignment.Root.Children[1].Children[rowIndex].Children[cellIndex].Properties = nil
}
}
canonicalLoss := compareLegacyAndShared(request.Title, []byte(aligned), canonicalWithoutAlignment, legacy, delivery.DOCX.Bytes)
if dimension := comparisonDimension(t, canonicalLoss, "table-alignment"); dimension.Status != "accidental" {
t.Fatalf("canonical table-alignment loss = %#v, want accidental", dimension)
}
docxWithoutAlignment := rewriteDOCXPart(t, delivery.DOCX.Bytes, "word/document.xml", func(content []byte) []byte {
for _, value := range []string{"left", "center", "right"} {
content = bytes.ReplaceAll(content, []byte(`<w:jc w:val="`+value+`"/>`), nil)
}
return content
})
docxLoss := compareLegacyAndShared(request.Title, []byte(aligned), delivery.Document, legacy, docxWithoutAlignment)
if docxLoss.Assessment != "differences" {
t.Fatalf("DOCX alignment loss assessment = %q, want differences", docxLoss.Assessment)
}
if dimension := comparisonDimension(t, docxLoss, "word-table-alignment"); dimension.Status != "accidental" {
t.Fatalf("DOCX table-alignment loss = %#v, want accidental", dimension)
}
}
func TestLegacyComparisonReportsAccidentalAndUnassessedDimensions(t *testing.T) {
@@ -660,3 +760,49 @@ func docxPart(t *testing.T, raw []byte, name string) string {
t.Fatalf("DOCX part %s missing", name)
return ""
}
func comparisonDimension(t *testing.T, receipt ComparisonReceipt, name string) ComparisonDimension {
t.Helper()
for _, dimension := range receipt.Dimensions {
if dimension.Name == name {
return dimension
}
}
t.Fatalf("comparison dimension %s missing: %#v", name, receipt.Dimensions)
return ComparisonDimension{}
}
func rewriteDOCXPart(t *testing.T, raw []byte, name string, rewrite func([]byte) []byte) []byte {
t.Helper()
reader, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
writer := zip.NewWriter(&output)
for _, file := range reader.File {
stream, err := file.Open()
if err != nil {
t.Fatal(err)
}
content, err := io.ReadAll(stream)
stream.Close()
if err != nil {
t.Fatal(err)
}
if file.Name == name {
content = rewrite(content)
}
target, err := writer.Create(file.Name)
if err != nil {
t.Fatal(err)
}
if _, err := target.Write(content); err != nil {
t.Fatal(err)
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
return output.Bytes()
}
+6 -1
View File
@@ -28,6 +28,8 @@ const (
execSetting = "runtime.exec"
)
var errDirectWordRegistration = errors.New("register_artifact: direct Word registration is forbidden; use render_document so Markdown, renderer provenance, manifest, and delivery receipt are preserved")
// execHardOff 启动级硬关闭(组合根依启动参数设置)。runtime.exec 是用户可写的
// 业务设置,多用户部署时任何人都能把它改回 true;硬关闭不读设置,设置无法覆盖。
var execHardOff atomic.Bool
@@ -122,7 +124,7 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error)
return "", fmt.Errorf("register_artifact: %w", err)
}
if isWordBinaryName(abs) {
return "", errors.New("register_artifact: direct Word registration is forbidden; use render_document so Markdown, renderer provenance, manifest, and delivery receipt are preserved")
return "", errDirectWordRegistration
}
st, err := os.Stat(abs)
if err != nil {
@@ -138,6 +140,9 @@ func execRegisterArtifact(rc ToolExecCtx, args json.RawMessage) (string, error)
if err != nil {
return "", err
}
if isWordprocessingMLPackage(b) {
return "", errDirectWordRegistration
}
if err := ooxmlStructuralCheck(filepath.Base(abs), b); err != nil {
return "", fmt.Errorf("register_artifact: %s 结构自检未通过:%v。请用 housestyle kit 重建(见 $AGENT_SKILLS_DIR/house-style/SKILL.md),不要用 zipfile 手工拼 OOXML", a.Path, err)
}
+16
View File
@@ -44,6 +44,22 @@ func ooxmlStructuralCheck(name string, content []byte) error {
}
}
// isWordprocessingMLPackage identifies Word OPC packages from their mandatory
// main document part instead of trusting the caller-controlled filename. The
// part is shared by document/template and macro-enabled variants.
func isWordprocessingMLPackage(content []byte) bool {
zr, err := zip.NewReader(bytes.NewReader(content), int64(len(content)))
if err != nil {
return false
}
for _, file := range zr.File {
if strings.EqualFold(strings.TrimPrefix(file.Name, "/"), "word/document.xml") {
return true
}
}
return false
}
// ooxmlCheckParts 校验容器与必备正文部件存在、XML 合法、命名空间前缀齐备
// (docx/xlsx 底线)。
func ooxmlCheckParts(content []byte, parts ...string) error {
+63
View File
@@ -3,6 +3,7 @@ package tools
import (
"archive/zip"
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -349,3 +350,65 @@ func TestRegisterArtifactStructuralGate(t *testing.T) {
t.Fatalf("valid pptx must register: %s %s", res.Status, res.Output)
}
}
func TestRegisterArtifactRejectsRenamedWordprocessingMLPackages(t *testing.T) {
r := NewRegistry()
if err := RegisterExecTools(r); err != nil {
t.Fatal(err)
}
root := t.TempDir()
rc := &execFake{root: root, owner: "o1", conv: "c1", execOn: true}
sandbox := filepath.Join(root, "o1", "c1")
if err := os.MkdirAll(sandbox, 0o755); err != nil {
t.Fatal(err)
}
wordTypes := []struct {
name string
contentType string
}{
{"report.zip", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"},
{"macro.bin", "application/vnd.ms-word.document.macroEnabled.main+xml"},
{"template.data", "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml"},
{"template.docx ", "application/vnd.ms-word.template.macroEnabledTemplate.main+xml"},
}
for _, test := range wordTypes {
t.Run(test.name, func(t *testing.T) {
contentTypes := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/document.xml" ContentType="` + test.contentType + `"/></Types>`
pkg := zipOf(t, map[string]string{
"[Content_Types].xml": contentTypes,
"word/document.xml": `<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>`,
})
if err := os.WriteFile(filepath.Join(sandbox, test.name), pkg, 0o644); err != nil {
t.Fatal(err)
}
res := interpCall(t, r, rc, "register_artifact", `{"path":`+mustJSON(t, test.name)+`}`)
if res.Status != "error" || !strings.Contains(res.Output, "direct Word registration is forbidden") {
t.Fatalf("renamed WordprocessingML package must be refused: %s %s", res.Status, res.Output)
}
})
}
allowed := map[string][]byte{
"dataset.zip": zipOf(t, map[string]string{"data/payload.json": `{"ok":true}`}),
"payload.bin": {0x00, 0x01, 0x02, 0xff},
}
for name, content := range allowed {
if err := os.WriteFile(filepath.Join(sandbox, name), content, 0o644); err != nil {
t.Fatal(err)
}
res := interpCall(t, r, rc, "register_artifact", `{"path":`+mustJSON(t, name)+`}`)
if res.Status != "ok" || !strings.Contains(res.Output, "已登记交付物") {
t.Fatalf("non-Word artifact %s must register: %s %s", name, res.Status, res.Output)
}
}
}
func mustJSON(t *testing.T, value string) string {
t.Helper()
raw, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return string(raw)
}