Render a Markdown table as a table

b538c2b8b072 · AtlantisPleb · · parent cf1861c9cbd2

Render a Markdown table as a table

A table came out as its own source: a row of pipes for every row, and a line of
dashes in the middle. Of all the shapes Markdown has, a table is the one that is
harder to read unrendered than the prose it replaced, and the model reaches for
one whenever it compares two things — which, with two delegation lanes to report
on, is often.

A table is taken as a block rather than a line at a time, because that is what
it is: a header, a rule, and the rows that follow it. Columns are sized to their
widest cell, the rule's `:---:` and `---:` decide alignment, and a table too
wide for the terminal shrinks its widest column first, since one that overflows
wraps into something worse than the source it came from.

Cells keep their inline markup, and that is where the care went. A cell written
with backticks is eight columns and ten characters, so sizing it by its source
cuts text that fits, and sizing it by its styled string counts escape bytes that
occupy no columns and leaves the column too wide. Both were written and both
were wrong before the third: measure and cut on the spans, which is the only
form that knows what a reader sees.

A line of pipes that no rule follows is still a sentence with a pipe in it, and
a table inside a code fence is still source.

478 tests pass, eight of them on tables.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified docs/assure-repo/false-green-candidates.v1.json
  • modified docs/assure-repo/surface-inventory.v1.json
  • modified packages/openagents-cli/src/coder-markdown.ts
  • added packages/openagents-cli/test/coder-markdown-tables.test.ts

Diff

4 files changed, +239 -4

docs/assure-repo/false-green-candidates.v1.json modified +1 -1

@@ -4,7 +4,7 @@

4 4
  "note": "Heuristic false-green LEADS, not findings. A finding requires a demonstrated reproduction (surviving mutation via mutation-runner). Do not treat a candidate as a confirmed false green. Coverage-theater leads may include tests that delegate their assertion to a custom helper the classifier does not recognise; verify before acting.",
5 5
  "sourceDigest": "sha256:dd810dd48c5bdbc9becd7fcc01dd41a4ca2abf0b2d6f6a545907247f6e3e8361",
6 6
  "summary": {
7
    "filesScanned": 2442,
7
    "filesScanned": 2443,
8 8
    "candidateCount": 16,
9 9
    "byMode": {
10 10
      "false_green_coverage_theater": 15,
docs/assure-repo/surface-inventory.v1.json modified +2 -2

@@ -1,7 +1,7 @@

1 1
{
2 2
  "schemaVersion": "1",
3 3
  "repository": "OpenAgentsInc/openagents",
4
  "sourceDigest": "sha256:b15da4879a7876b8e73ec175e353423a0fc9b08a85f85dbfb0c175ceccccf8dd",
4
  "sourceDigest": "sha256:404ce9178c4741df800b17631cbec51daf2ade40b83b90db9de37456bd309154",
5 5
  "surfaces": [
6 6
    {
7 7
      "id": "app:@openagentsinc/acceptance-runner",

@@ -1876,7 +1876,7 @@

1876 1876
      "oracles": [
1877 1877
        {
1878 1878
          "type": "test",
1879
          "ref": "packages/openagents-cli (42 tracked test files)"
1879
          "ref": "packages/openagents-cli (43 tracked test files)"
1880 1880
        },
1881 1881
        {
1882 1882
          "type": "behavior-contract",
packages/openagents-cli/src/coder-markdown.ts modified +152 -1

@@ -70,9 +70,30 @@ export function renderMarkdown(

70 70
  /** The fence marker that opened the current code block, if one is open. */
71 71
  let fence: string | undefined;
72 72
73
  for (const line of text.split("\n")) {
73
  const source = text.split("\n");
74
  for (let at = 0; at < source.length; at += 1) {
75
    const line = source[at] ?? "";
74 76
    const fenced = /^\s*(```+|~~~+)/.exec(line);
75 77
78
    // A table is a block, not a run of lines, so it is taken whole before the
79
    // line-at-a-time path sees it as seven paragraphs of pipes.
80
    if (
81
      fence === undefined &&
82
      fenced === null &&
83
      isTableRow(line) &&
84
      isTableRule(source[at + 1] ?? "")
85
    ) {
86
      const block: string[] = [line, source[at + 1] ?? ""];
87
      let end = at + 2;
88
      while (end < source.length && isTableRow(source[end] ?? "")) {
89
        block.push(source[end] ?? "");
90
        end += 1;
91
      }
92
      rows.push(...tableRows(block, width));
93
      at = end - 1;
94
      continue;
95
    }
96
76 97
    if (fence !== undefined) {
77 98
      // A fence closes only on its own marker, so a ``` inside a ~~~ block is
78 99
      // content rather than a terminator.

@@ -99,6 +120,136 @@ export function renderMarkdown(

99 120
}
100 121
101 122
/** One non-fenced source line as one or more rendered rows. */
123
124
/** A row of cells, as written between pipes. */
125
const tableCells = (line: string): ReadonlyArray<string> =>
126
  line
127
    .trim()
128
    .replace(/^\|/, "")
129
    .replace(/\|$/, "")
130
    .split("|")
131
    .map((cell) => cell.trim());
132
133
/** True for the `| --- | :--: |` line that makes the row above a header. */
134
const isTableRule = (line: string): boolean => {
135
  const cells = tableCells(line);
136
  return cells.length > 0 && cells.every((cell) => /^:?-{1,}:?$/.test(cell));
137
};
138
139
/** True for anything that could be a row of a table. */
140
const isTableRow = (line: string): boolean => line.trim().startsWith("|");
141
142
type Alignment = "left" | "right" | "center";
143
144
const alignments = (rule: string): ReadonlyArray<Alignment> =>
145
  tableCells(rule).map((cell) => {
146
    const left = cell.startsWith(":");
147
    const right = cell.endsWith(":");
148
    if (left && right) return "center";
149
    return right ? "right" : "left";
150
  });
151
152
/** Pad a styled cell to a column, by its visible width rather than its bytes. */
153
const padCell = (cell: string, room: number, how: Alignment): string => {
154
  const slack = Math.max(0, room - visibleWidth(cell));
155
  if (how === "right") return `${" ".repeat(slack)}${cell}`;
156
  if (how === "center") {
157
    const left = Math.floor(slack / 2);
158
    return `${" ".repeat(left)}${cell}${" ".repeat(slack - left)}`;
159
  }
160
  return `${cell}${" ".repeat(slack)}`;
161
};
162
163
/**
164
 * Render a Markdown table as aligned columns.
165
 *
166
 * It was rendered as its source: seven lines of pipes and a row of dashes,
167
 * which is the one shape of Markdown that is harder to read unrendered than
168
 * any prose. Cells keep their inline markup, so a code span in a cell is still
169
 * a code span.
170
 *
171
 * Columns are sized to their widest cell and then shrunk together if the whole
172
 * is too wide, because a table that overflows the terminal wraps into
173
 * something worse than the source it came from.
174
 */
175
function tableRows(lines: ReadonlyArray<string>, width: number): ReadonlyArray<string> {
176
  const [header, rule, ...body] = lines;
177
  if (header === undefined || rule === undefined) return lines.map((line) => line);
178
179
  const how = alignments(rule);
180
  const headings = tableCells(header);
181
  const cells = body.map((line) => tableCells(line));
182
  const columns = Math.max(headings.length, ...cells.map((row) => row.length));
183
184
  /**
185
   * Style a cell, and cut it to a column if it does not fit.
186
   *
187
   * Cut on the spans rather than on either the source or the styled string.
188
   * The source counts markup a reader never sees — a cell written `` `the` ``
189
   * is eight columns and ten characters — and the styled string counts escape
190
   * bytes. Both make a cell that fits look like one that does not.
191
   */
192
  const styled = (text: string, style: string, room = Number.POSITIVE_INFINITY) => {
193
    const spans = scan(text, style);
194
    const total = spans.reduce((sum, span) => sum + [...span.text].length, 0);
195
    const trim = total > room;
196
    let left = trim ? Math.max(1, room - 1) : room;
197
    const out: string[] = [];
198
199
    for (const span of spans) {
200
      if (left <= 0) break;
201
      const glyphs = [...span.text];
202
      const take = glyphs.slice(0, left).join("");
203
      left -= [...take].length;
204
      out.push(`${span.style}${take}${span.style === "" ? "" : RESET}`);
205
    }
206
207
    return `${out.join("")}${trim ? "…" : ""}`;
208
  };
209
210
  // Measured on the text a reader sees, not on the styled string: markup adds
211
  // escape bytes that occupy no columns, and sizing by them makes every column
212
  // that contains a code span too wide.
213
  const plain = (text: string) => visibleWidth(styled(text, ""));
214
215
216
  const widths: number[] = [];
217
  for (let column = 0; column < columns; column += 1) {
218
    const widest = cells.reduce(
219
      (most, row) => Math.max(most, plain(row[column] ?? "")),
220
      plain(headings[column] ?? ""),
221
    );
222
    widths.push(widest);
223
  }
224
225
  // Two spaces between columns. If that does not fit, the widest column gives
226
  // way first, and keeps giving way until it does.
227
  const gap = 2;
228
  let total = () => widths.reduce((sum, room) => sum + room, 0) + gap * (columns - 1);
229
  while (total() > width && widths.some((room) => room > 4)) {
230
    const widest = widths.indexOf(Math.max(...widths));
231
    widths[widest] = Math.max(4, (widths[widest] ?? 4) - 1);
232
  }
233
234
  const line = (values: ReadonlyArray<string>, style: string) =>
235
    Array.from({ length: columns }, (_unused, column) =>
236
      padCell(
237
        styled(values[column] ?? "", style, widths[column] ?? 0),
238
        widths[column] ?? 0,
239
        how[column] ?? "left",
240
      ),
241
    )
242
      .join(" ".repeat(gap))
243
      .trimEnd();
244
245
  const out = [line(headings, BOLD)];
246
  out.push(
247
    `${DIM}${widths.map((room) => "─".repeat(room)).join("─".repeat(gap))}${RESET}`,
248
  );
249
  for (const row of cells) out.push(line(row, ""));
250
  return out;
251
}
252
102 253
function blockRows(line: string, width: number): ReadonlyArray<string> {
103 254
  if (line.trim().length === 0) return [""];
104 255
packages/openagents-cli/test/coder-markdown-tables.test.ts added +84

@@ -0,0 +1,84 @@

1
import { describe, expect, it } from "vitest";
2
3
import { renderMarkdown } from "../src/coder-markdown.js";
4
5
const ESCAPE = String.fromCharCode(27);
6
7
const plain = (rows: ReadonlyArray<string>): ReadonlyArray<string> =>
8
  rows.map((row) => row.split(new RegExp(`${ESCAPE}\\[[0-9;]*m`)).join(""));
9
10
describe("rendering a Markdown table", () => {
11
  const table = [
12
    "| Metric | Value |",
13
    "| --- | ---: |",
14
    "| Bytes | 43 |",
15
    "| Longest word | `jumps` |",
16
    "| Most frequent word | `the` (×2) |",
17
  ].join("\n");
18
19
  it("lays the cells out in columns instead of printing the source", () => {
20
    const rows = plain(renderMarkdown(table, 78));
21
22
    // It used to render as its own source: rows of pipes and a line of dashes,
23
    // which is the one shape of Markdown harder to read unrendered than prose.
24
    expect(rows[0]).toBe("Metric                 Value");
25
    expect(rows.some((row) => row.includes("|"))).toBe(false);
26
    expect(rows.some((row) => row.includes("---"))).toBe(false);
27
  });
28
29
  it("honours the alignment the rule asks for", () => {
30
    const rows = plain(renderMarkdown(table, 78));
31
32
    // `---:` is a right-aligned column, and the values line up on their right.
33
    expect(rows[2]).toBe("Bytes                     43");
34
    expect(rows[4]).toBe("Most frequent word  the (×2)");
35
  });
36
37
  it("sizes a column by what a reader sees, not by the markup", () => {
38
    const rows = plain(renderMarkdown(table, 78));
39
40
    // A cell written with backticks is eight columns and ten characters.
41
    // Sizing by the source cut it; sizing by the styled string over-widened it.
42
    expect(rows[4]).toContain("the (×2)");
43
    expect(rows[3]).toContain("jumps");
44
  });
45
46
  it("keeps a code span in a cell styled", () => {
47
    expect(renderMarkdown(table, 78)[3]).toContain(`${ESCAPE}[36m`);
48
  });
49
50
  it("shrinks the widest column first when the table will not fit", () => {
51
    const wide = [
52
      "| Lane | Repository identified | Observation |",
53
      "|---|---|---|",
54
      "| ox-alpha | OpenAgentsInc/openagents.com | Phoenix app with a forge |",
55
    ].join("\n");
56
57
    const rows = plain(renderMarkdown(wide, 28));
58
59
    // A table that overflows wraps into something worse than its source.
60
    for (const row of rows) expect(row.length).toBeLessThanOrEqual(28);
61
    expect(rows[0]).toContain("Lane");
62
  });
63
64
  it("fills a row that is short of cells rather than dropping the row", () => {
65
    const ragged = ["| A | B | C |", "| :-: | --- | ---: |", "| x |", "| 1 | 2 | 3 |"].join("\n");
66
67
    const rows = plain(renderMarkdown(ragged, 40));
68
69
    expect(rows).toContain("x");
70
    expect(rows).toContain("1  2  3");
71
  });
72
73
  it("leaves a line of pipes that is not a table alone", () => {
74
    // A table needs its rule; without one this is a sentence with a pipe in it.
75
    expect(plain(renderMarkdown("a | b not a table", 40))).toEqual(["a | b not a table"]);
76
  });
77
78
  it("leaves a table inside a code fence as source", () => {
79
    const fenced = ["```", "| a | b |", "| --- | --- |", "```"].join("\n");
80
    const rows = plain(renderMarkdown(fenced, 40));
81
82
    expect(rows.some((row) => row.includes("| a | b |"))).toBe(true);
83
  });
84
});

This page updates live while a promote is in flight · changelog