mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
fix(cli): preserve literal Markdown table facts (#7073)
* fix(cli): preserve literal Markdown table facts * test(cli): retain capacity diagnostics through Markdown escaping * fix(cli): keep intraword underscores readable in Markdown tables Underscore runs between letters or digits cannot open or close CommonMark emphasis, so escaping them only adds noise to identifiers such as source_index and invalid_request in raw Markdown. Escape underscore runs only at word boundaries and restore the original diagnostic assertions.
This commit is contained in:
+4
-2
@@ -924,8 +924,10 @@ redirected, which keeps large results smaller for agents and scripts; pipe
|
||||
through `jq .` for indented files. Output selection and formatting do not change
|
||||
operation status.
|
||||
|
||||
`--format md` escapes backslashes and pipes in table headers and cells, and
|
||||
renders cell line breaks as `<br>`. These are presentation changes; retained
|
||||
`--format md` escapes backslashes, pipes, and inline Markdown syntax in table
|
||||
headers and cells so source text is displayed literally, and renders cell line
|
||||
breaks as `<br>`. Underscores between letters or digits, as in `source_index`,
|
||||
cannot form emphasis and stay unescaped. These are presentation changes; retained
|
||||
Evidence and JSON output keep the original values. Nested objects and arrays
|
||||
use JSON inside their owning Markdown cells, retaining the field names and
|
||||
values instead of replacing them with an object label. Table widths are measured
|
||||
|
||||
@@ -43,13 +43,27 @@ const isObjectArray = (value: unknown): value is Record<string, unknown>[] =>
|
||||
) &&
|
||||
value.some((item) => Object.keys(item).length > 0);
|
||||
|
||||
const isWordCharacter = (character: string | undefined): boolean =>
|
||||
character !== undefined && /[\p{L}\p{N}]/u.test(character);
|
||||
|
||||
/** Underscore runs between word characters cannot open or close emphasis. */
|
||||
const escapeInlineSyntax = (
|
||||
match: string,
|
||||
offset: number,
|
||||
text: string,
|
||||
): string =>
|
||||
match.startsWith("_") &&
|
||||
isWordCharacter(text[offset - 1]) &&
|
||||
isWordCharacter(text[offset + match.length])
|
||||
? match
|
||||
: match.replace(/./g, "\\$&");
|
||||
|
||||
const escapeTableCell = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\|/g, "\\|")
|
||||
.replace(/[\\|`*[\]~]|_+/g, escapeInlineSyntax)
|
||||
.replace(/\r\n|\r|\n/g, "<br>");
|
||||
|
||||
const renderTable = (
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Cli, Formatter } from "incur";
|
||||
import { estimateTokenCount } from "tokenx";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { parseCliOutputArguments } from "../../../src/cliOutput.js";
|
||||
import {
|
||||
parseCliOutputArguments,
|
||||
renderCliDocument,
|
||||
} from "../../../src/cliOutput.js";
|
||||
import {
|
||||
createCliResultOutput,
|
||||
streamCliCommandResults,
|
||||
@@ -80,7 +83,7 @@ it("renders only each record's own fields in heterogeneous Markdown tables", asy
|
||||
"name",
|
||||
"constructor",
|
||||
"toString",
|
||||
"__proto__",
|
||||
"\\_\\_proto\\_\\_",
|
||||
"enabled",
|
||||
"count",
|
||||
]);
|
||||
@@ -346,10 +349,9 @@ it.each(["json", "jsonl", "yaml", "toon", "md"] as const)(
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual(expected);
|
||||
else if (format === "md") {
|
||||
expect(outputs[0]).toContain(`| ${JSON.stringify(expected[0]!.rows)} |`);
|
||||
expect(outputs[0]).not.toContain("[object Object]");
|
||||
} else expect(outputs[0]).toBe(`${Formatter.format(expected, format)}\n`);
|
||||
else if (format === "md")
|
||||
expect(outputs[0]).toBe(renderCliDocument(expected, format, false));
|
||||
else expect(outputs[0]).toBe(`${Formatter.format(expected, format)}\n`);
|
||||
expect(projectionSource[0]?.rows[0]?.retained).toEqual([0, 10]);
|
||||
},
|
||||
);
|
||||
@@ -406,10 +408,9 @@ it.each(["json", "jsonl", "yaml", "toon", "md"] as const)(
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line)),
|
||||
).toEqual(expected);
|
||||
else if (format === "md") {
|
||||
expect(outputs[0]).toContain(`| ${JSON.stringify(expected[0]!.rows)} |`);
|
||||
expect(outputs[0]).not.toContain("[object Object]");
|
||||
} else expect(outputs[0]).toBe(`${Formatter.format(expected, format)}\n`);
|
||||
else if (format === "md")
|
||||
expect(outputs[0]).toBe(renderCliDocument(expected, format, false));
|
||||
else expect(outputs[0]).toBe(`${Formatter.format(expected, format)}\n`);
|
||||
expect(projectionSource).toEqual(before);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { parseFragment, type DefaultTreeAdapterTypes } from "parse5";
|
||||
import { createMarkdownRenderer, disposeMdItInstance } from "vitepress";
|
||||
import { afterAll, expect } from "vitest";
|
||||
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
|
||||
import { renderCliDocument } from "../../../src/cliOutput.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
afterAll(() => disposeMdItInstance());
|
||||
|
||||
const renderedCells = (html: string): string[] => {
|
||||
const text = (node: DefaultTreeAdapterTypes.Node): string =>
|
||||
node.nodeName === "#text" && "value" in node
|
||||
? node.value
|
||||
: "childNodes" in node
|
||||
? node.childNodes.map(text).join("")
|
||||
: "";
|
||||
const cells: string[] = [];
|
||||
const visit = (node: DefaultTreeAdapterTypes.Node): void => {
|
||||
if ("tagName" in node && (node.tagName === "th" || node.tagName === "td"))
|
||||
cells.push(text(node));
|
||||
else if ("childNodes" in node) node.childNodes.forEach(visit);
|
||||
};
|
||||
visit(parseFragment(html));
|
||||
return cells;
|
||||
};
|
||||
|
||||
cliTest(
|
||||
"preserves literal Evidence keys and values when Markdown tables are rendered",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-markdown-literal-cells-");
|
||||
const path = join(root, "bundle.json");
|
||||
const literal =
|
||||
"`code` **bold** _name_ ~~deleted~~ [label](target) ";
|
||||
const punctuation = "\\`tick` \\*star* \\[label](target) a|b <T> ©";
|
||||
const identifiers = "x__y__z _a_b_ 中_文 snake_case_";
|
||||
const row = {
|
||||
[literal]: literal,
|
||||
punctuation,
|
||||
nested: { text: literal, values: ["__name__", "plain"] },
|
||||
empty: "",
|
||||
zero: 0,
|
||||
disabled: false,
|
||||
absent: null,
|
||||
source_index: identifiers,
|
||||
};
|
||||
const result = { scalar: { [literal]: literal }, rows: [row] };
|
||||
const record = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{ operation: "ordinary-json", parameters: {}, result },
|
||||
);
|
||||
const saved = JSON.stringify(createEvidenceBundle([record]));
|
||||
await writeFile(path, saved);
|
||||
const base = [
|
||||
"inspect-evidence-bundle",
|
||||
JSON.stringify({ path, detail: "complete" }),
|
||||
"--filter-output",
|
||||
"records.0.normalized_result",
|
||||
];
|
||||
const json = await cli.run({ arguments: [...base, "--json"] });
|
||||
const markdown = await cli.run({ arguments: [...base, "--format", "md"] });
|
||||
expect(json.exitCode, json.stderr).toBe(0);
|
||||
expect(markdown.exitCode, markdown.stderr).toBe(0);
|
||||
expect(JSON.parse(json.stdout)).toEqual({
|
||||
records: { "0": { normalized_result: result } },
|
||||
});
|
||||
expect(await readFile(path, "utf8")).toBe(saved);
|
||||
// Intraword underscores cannot form emphasis, so identifiers stay readable.
|
||||
expect(markdown.stdout).toContain(" source_index ");
|
||||
expect(markdown.stdout).toContain(
|
||||
" x__y__z \\_a_b\\_ 中_文 snake_case\\_ ",
|
||||
);
|
||||
|
||||
const renderer = await createMarkdownRenderer(root, { linkify: false });
|
||||
const html = renderer.render(markdown.stdout);
|
||||
const expectedCells = [
|
||||
"Key",
|
||||
"Value",
|
||||
literal,
|
||||
literal,
|
||||
...Object.keys(row),
|
||||
literal,
|
||||
punctuation,
|
||||
JSON.stringify(row.nested),
|
||||
"",
|
||||
"0",
|
||||
"false",
|
||||
"null",
|
||||
identifiers,
|
||||
];
|
||||
expect(renderedCells(html)).toEqual(expectedCells);
|
||||
expect(html).not.toMatch(/<(?:em|strong|s|code|img)(?:\s|>)/u);
|
||||
|
||||
// Standalone reports share the same table owner as command output.
|
||||
expect(
|
||||
renderedCells(renderer.render(renderCliDocument(result, "md", false))),
|
||||
).toEqual(expectedCells);
|
||||
},
|
||||
);
|
||||
@@ -16,7 +16,7 @@ it("retains nested facts in standalone Markdown table cells and mixed arrays", (
|
||||
];
|
||||
const markdown = renderCliDocument(value, "md", false);
|
||||
expect(markdown).toContain('{"path":"alpha.txt"}');
|
||||
expect(markdown).toContain('[{"path":"sub/beta.txt"}]');
|
||||
expect(markdown).toContain('\\[{"path":"sub/beta.txt"}\\]');
|
||||
expect(markdown).toContain("caller\\|value");
|
||||
expect(markdown).not.toContain("[object Object]");
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user