From 6d7e6277835448bcdc830d00ebced2ff76a74c92 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:04:08 +0800 Subject: [PATCH] fix(cli): preserve literal evidence in Markdown output --- src/cli/cliOutputFormatter.ts | 26 ++++++--- tests/boundary/cli/cliOutput.test.ts | 6 +-- .../boundary/cli/filteredArrayOutput.test.ts | 4 +- .../boundary/cli/markdownLiteralCells.test.ts | 53 +++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/cli/cliOutputFormatter.ts b/src/cli/cliOutputFormatter.ts index 2854c72286..cb05a6d822 100644 --- a/src/cli/cliOutputFormatter.ts +++ b/src/cli/cliOutputFormatter.ts @@ -66,6 +66,19 @@ const escapeTableCell = (value: string): string => .replace(/[\\|`*[\]~]|_+/g, escapeInlineSyntax) .replace(/\r\n|\r|\n/g, "
"); +/** Heading and paragraph facts are literal data, including block syntax. */ +const escapeMarkdownText = (value: string): string => + escapeTableCell(value) + .replace(/#/g, "\\#") + .replace(/^(\s*)([-+=])/, "$1\\$2") + .replace(/^(\s*\d{1,9})([.)])(?=\s)/, "$1\\$2") + .replace(/^[ \t]+/, (indent) => + indent.replace(/ /g, " ").replace(/\t/g, " "), + ); + +const markdownHeading = (path: readonly string[]): string => + `## ${escapeMarkdownText(path.join("."))}\n\n`; + const renderTable = ( headers: readonly string[], rows: readonly string[][], @@ -138,13 +151,13 @@ const formatMarkdown = ( ): string => { if (value == null && path.length === 0) return ""; if (isScalar(value)) { - if (path.length === 0) return String(value); - return `## ${path.join(".")}\n\n${String(value)}`; + if (path.length === 0) return escapeMarkdownText(String(value)); + return `${markdownHeading(path)}${escapeMarkdownText(String(value))}`; } if (Array.isArray(value)) { if (isObjectArray(value)) { const table = formatObjectTable(value); - return path.length === 0 ? table : `## ${path.join(".")}\n\n${table}`; + return path.length === 0 ? table : `${markdownHeading(path)}${table}`; } return formatMarkdown(markdownCell(value), path); } @@ -163,8 +176,9 @@ const formatMarkdown = ( return entries .map(([key, child]) => { const childPath = [...path, key]; - const heading = `## ${childPath.join(".")}\n\n`; - if (isScalar(child)) return `${heading}${String(child)}`; + const heading = markdownHeading(childPath); + if (isScalar(child)) + return `${heading}${escapeMarkdownText(String(child))}`; if (isObjectArray(child)) return `${heading}${formatObjectTable(child)}`; if ( typeof child === "object" && @@ -176,7 +190,7 @@ const formatMarkdown = ( ? `${heading}${formatKeyValueTable(nested)}` : formatMarkdown(nested, childPath); } - return `${heading}${markdownCell(child)}`; + return `${heading}${escapeMarkdownText(markdownCell(child))}`; }) .join("\n\n"); }; diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 568b6c3eb7..3c1e84c53a 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -35,10 +35,10 @@ describe("CLI output argument boundary", () => { }); it("formats arrays of empty objects without column keys as JSON in Markdown", () => { - expect(renderCliDocument([{}], "md", false)).toBe("[{}]\n"); - expect(renderCliDocument([{}, {}], "md", false)).toBe("[{},{}]\n"); + expect(renderCliDocument([{}], "md", false)).toBe("\\[{}\\]\n"); + expect(renderCliDocument([{}, {}], "md", false)).toBe("\\[{},{}\\]\n"); expect(renderCliDocument({ empty: [{}] }, "md", false)).toBe( - "## empty\n\n[{}]\n", + "## empty\n\n\\[{}\\]\n", ); }); diff --git a/tests/boundary/cli/filteredArrayOutput.test.ts b/tests/boundary/cli/filteredArrayOutput.test.ts index a345449ca4..60dae51548 100644 --- a/tests/boundary/cli/filteredArrayOutput.test.ts +++ b/tests/boundary/cli/filteredArrayOutput.test.ts @@ -100,7 +100,7 @@ it("formats arrays of empty objects without columns as JSON in Markdown", async const json = await runFilteredCli(value, "json"); expect(JSON.parse(json)).toEqual(value); const markdown = await runFilteredCli(value, "md"); - expect(markdown).toBe("[{},{}]\n"); + expect(markdown).toBe("\\[{},{}\\]\n"); }); it.each( @@ -375,7 +375,7 @@ it.each(["json", "jsonl", "yaml", "toon", "md"] as const)( .split("\n") .map((line) => JSON.parse(line)), ).toEqual([1, null, null]); - else if (format === "md") expect(output).toBe("[1,null,null]\n"); + else if (format === "md") expect(output).toBe("\\[1,null,null\\]\n"); else expect(output).toBe(`${Formatter.format(selected, format)}\n`); expect(value).toEqual([{ selected: 1 }, {}, { selected: null }]); }, diff --git a/tests/boundary/cli/markdownLiteralCells.test.ts b/tests/boundary/cli/markdownLiteralCells.test.ts index 44271ad030..528ba53928 100644 --- a/tests/boundary/cli/markdownLiteralCells.test.ts +++ b/tests/boundary/cli/markdownLiteralCells.test.ts @@ -103,3 +103,56 @@ cliTest( ).toEqual(expectedCells); }, ); + +cliTest( + "preserves literal scalar, array, and heading Evidence facts", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-markdown-literal-text-"); + const path = join(root, "bundle.json"); + const literal = "**literal** [source](wrong) ©"; + const heading = "**key**\n# injected"; + const result = { + [heading]: literal, + list: [literal], + bullet: "- literal", + numbered: "1. literal", + rule: "---", + indented: " literal\n- next\n# heading", + nested: { value: 1 }, + }; + const record = createEvidence( + undefined, + { id: "fixture", name: "Fixture", version: "1" }, + { operation: "ordinary-json", parameters: {}, result }, + ); + await writeFile(path, JSON.stringify(createEvidenceBundle([record]))); + const base = [ + "inspect-evidence-bundle", + JSON.stringify({ path, detail: "complete" }), + "--filter-output", + "records.0.normalized_result", + ]; + const markdown = await cli.run({ arguments: [...base, "--format", "md"] }); + const json = await cli.run({ arguments: [...base, "--json"] }); + expect(markdown.exitCode, markdown.stderr).toBe(0); + expect(json.exitCode, json.stderr).toBe(0); + expect(JSON.parse(json.stdout)).toEqual({ + records: { "0": { normalized_result: result } }, + }); + const renderer = await createMarkdownRenderer(root, { linkify: false }); + for (const document of [ + markdown.stdout, + renderCliDocument(result, "md", false), + ]) { + const html = renderer.render(document); + expect(html).toContain( + "**literal** [source](wrong) <T> &copy;", + ); + expect(html).toContain("**key**
# injected"); + expect(html).toContain("literal
- next
# heading"); + expect(html).not.toMatch(/<(?:strong|em|ul|ol|li|T|pre|hr)(?:\s|>)/u); + expect(html).not.toContain('href="wrong"'); + expect(html).not.toContain("