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> ©",
+ );
+ 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("