From 6c01d00a3f0dd1b6a808f1b005f7427b94b6fe27 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 15 Oct 2026 22:33:29 +0000 Subject: [PATCH] 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 --- deps/agent-document-renderer | 2 +- internal/docgen/comparison.go | 437 +++++++++++++++++- internal/docgen/docgen_test.go | 35 +- .../docgen/renderer_identity_generated.go | 2 +- internal/docgen/renderer_integration_test.go | 210 +++++++-- internal/tools/exec.go | 7 +- internal/tools/ooxmlgate.go | 16 + internal/tools/ooxmlgate_test.go | 63 +++ 8 files changed, 717 insertions(+), 55 deletions(-) diff --git a/deps/agent-document-renderer b/deps/agent-document-renderer index d5cd8c1..0d1c512 160000 --- a/deps/agent-document-renderer +++ b/deps/agent-document-renderer @@ -1 +1 @@ -Subproject commit d5cd8c126045a27d5a5cc5da23f64d3c737e116a +Subproject commit 0d1c5121d137be817db5f98d3c13320dfa919c8a diff --git a/internal/docgen/comparison.go b/internal/docgen/comparison.go index 8aca2d2..ad48558 100644 --- a/internal/docgen/comparison.go +++ b/internal/docgen/comparison.go @@ -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 { diff --git a/internal/docgen/docgen_test.go b/internal/docgen/docgen_test.go index 1cd5228..67dae8d 100644 --- a/internal/docgen/docgen_test.go +++ b/internal/docgen/docgen_test.go @@ -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) } } diff --git a/internal/docgen/renderer_identity_generated.go b/internal/docgen/renderer_identity_generated.go index f997072..cae3eec 100644 --- a/internal/docgen/renderer_identity_generated.go +++ b/internal/docgen/renderer_identity_generated.go @@ -2,6 +2,6 @@ package docgen -const rendererRevision = "d5cd8c126045a27d5a5cc5da23f64d3c737e116a" +const rendererRevision = "0d1c5121d137be817db5f98d3c13320dfa919c8a" const RendererIdentity = "agent-document-renderer@" + rendererRevision diff --git a/internal/docgen/renderer_integration_test.go b/internal/docgen/renderer_integration_test.go index ca670cb..d7d4074 100644 --- a/internal/docgen/renderer_integration_test.go +++ b/internal/docgen/renderer_integration_test.go @@ -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:val="inlineCode"`, `w:val="link"`, `w:val="quote"`, `w:val="code"`, ``, ``} { + for _, fragment := range []string{ + ``, ``, ``, `w:val="inlineCode"`, `w:val="link"`, + `w:val="quote"`, `w:val="code"`, ``, ``, + `☑ `, `☐ `, + ``, + ``, ``, + } { 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{ + ``, ``, ``, + ``, + } { + 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(``), 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() +} diff --git a/internal/tools/exec.go b/internal/tools/exec.go index a582435..3784bb2 100644 --- a/internal/tools/exec.go +++ b/internal/tools/exec.go @@ -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) } diff --git a/internal/tools/ooxmlgate.go b/internal/tools/ooxmlgate.go index e0cf467..292e995 100644 --- a/internal/tools/ooxmlgate.go +++ b/internal/tools/ooxmlgate.go @@ -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 { diff --git a/internal/tools/ooxmlgate_test.go b/internal/tools/ooxmlgate_test.go index b6da748..b858435 100644 --- a/internal/tools/ooxmlgate_test.go +++ b/internal/tools/ooxmlgate_test.go @@ -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 := `` + pkg := zipOf(t, map[string]string{ + "[Content_Types].xml": contentTypes, + "word/document.xml": ``, + }) + 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) +}