spine-rigc 2.47.1 → 2.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/guide.ts ADDED
@@ -0,0 +1,438 @@
1
+ /**
2
+ * `rigc guide` — read exactly the row of the guide a failure line names, or one
3
+ * numbered section of it, from the shipped `docs/AUTHORING.md` at the moment of
4
+ * the call (issue #1397, the form shared word for word with rig-c/parts#343).
5
+ *
6
+ * ⭐ **The guide stays one ledger.** Nothing here keeps a copy, an index or a
7
+ * generated split of it: every read is a slice of the file's text as it is on
8
+ * disk when the command runs, so there is nothing to drift. The one other file
9
+ * read is the one a code's row lives in when that is not AUTHORING — `ingest`'s
10
+ * finding codes, whose table is `docs/INGEST.md`'s *Every finding code* — and
11
+ * the output names the file it read.
12
+ *
13
+ * Pure: the texts come in as arguments and the lines go out as values, so the
14
+ * CLI owns the reading of the files and the selftest can plant on a text.
15
+ *
16
+ * 📐 What counts as a **rule row** is grammar, not a hand-kept list of tables: a
17
+ * table row (outside a code fence) whose first cell is one code span naming a
18
+ * rule — `` `A20_MESH_WEIGHTS_COHERENT` ``, optionally after its web anchor. In
19
+ * AUTHORING a rule name carries an underscore, which is what tells a rule from
20
+ * the other upper-case words tables there key on (the `BLOCK`/`JUDGE`/`LOSS`
21
+ * gutters, a picture's `REFERENCE` pane); in INGEST's finding-code table every
22
+ * row is a rule, `DURATION` included. A row whose code holds a `<WORD>` is a
23
+ * **family** — `ATTACHMENT_<TYPE>` — and answers for every code it spells
24
+ * (`ATTACHMENT_POINT`) that has no row of its own.
25
+ *
26
+ * 📐 A section is addressed the way the shared form fixes it (HQ's ruling on
27
+ * rig-c/parts#343): `§<n>` is the `## <n>.` heading, and `§<n>.<m>` is the m-th
28
+ * `###` under it, **counted** when the guide is read — never a number read off
29
+ * the heading. AUTHORING also writes the number into each `###`, so the two
30
+ * must agree, and `numberingFaults` is what says where they do not.
31
+ */
32
+
33
+ /** The command a pointer names — `rigc` here, `rig-parts` in the sibling package. */
34
+ export const GUIDE_BIN = 'rigc';
35
+
36
+ /** The guide, relative to the package root — what every read is a slice of. */
37
+ export const GUIDE_FILE = 'docs/AUTHORING.md';
38
+
39
+ /**
40
+ * The shipped documents a rule's row may live in other than the guide, and the
41
+ * heading that opens the one table of rules each holds. Only a code with no row
42
+ * in the guide is looked for here (issue #1397, change 7).
43
+ */
44
+ export const GUIDE_RULE_TABLES_ELSEWHERE: ReadonlyArray<{ file: string; heading: string }> = [
45
+ { file: 'docs/INGEST.md', heading: '#### Every finding code' },
46
+ ];
47
+
48
+ /**
49
+ * The end of every failure line that names a rule: ` (see rigc guide <RULE>)` —
50
+ * that text, that spacing. The one writer of it; `failLine` and the finding
51
+ * line are the two callers that put it on a line.
52
+ */
53
+ export function guidePointer(rule: string): string {
54
+ return ` (see ${GUIDE_BIN} guide ${rule})`;
55
+ }
56
+
57
+ /**
58
+ * The section a compile error points at (HQ's ruling on rig-c/parts#343): no compile error names a rule — 0 of
59
+ * the 463 throw sites carry a code, and §5.1's tables are keyed on the message — and §5.1 has no #### a thrower
60
+ * could name, so every one points at the section its row is in. When §5.1 grows #### families a thrower can
61
+ * name, this is where `§5.1.<k>` goes.
62
+ */
63
+ export const COMPILE_ERROR_SECTION = '§5.1';
64
+
65
+ /** A compile error as the CLI prints it: the prefix, the message, and the pointer to §5.1. */
66
+ export function compileErrorLine(message: string): string {
67
+ return `rigc compile error: ${message}${guidePointer(COMPILE_ERROR_SECTION)}`;
68
+ }
69
+
70
+ /** A refusal naming its rule, unindented, as `guide` prints its own: `FAIL <RULE>: <detail> (see rigc guide <RULE>)`. */
71
+ export function refusalLine(rule: string, detail: string): string {
72
+ return `FAIL ${rule}: ${detail}${guidePointer(rule)}`;
73
+ }
74
+
75
+ /** A failure line as every gate prints it: the report's two-space gutter, then the refusal line. */
76
+ export function failLine(rule: string, detail: string): string {
77
+ return ` ${refusalLine(rule, detail)}`;
78
+ }
79
+
80
+ /** One heading outside a code fence: its level, its text, the line it is on (1-based) and its byte offset. */
81
+ export interface GuideHeading {
82
+ level: number;
83
+ title: string;
84
+ line: number;
85
+ offset: number;
86
+ }
87
+
88
+ const ENCODER = new TextEncoder();
89
+
90
+ /** A string's length in UTF-8 bytes — the size a reader's context pays for it. No `Buffer`: nothing here is Node's. */
91
+ function utf8Length(text: string): number {
92
+ return ENCODER.encode(text).length;
93
+ }
94
+
95
+ /** A fenced block's opening, and the run of the same character that closes it. */
96
+ const FENCE = /^ {0,3}(`{3,}|~{3,})/;
97
+
98
+ /** Whether `line` closes the block `fence` opened: a run of the same character, at least as long, and nothing after it. */
99
+ function closesFence(line: string, fence: string): boolean {
100
+ const run = /^ {0,3}(`+|~+)\s*$/.exec(line);
101
+ return run !== null && run[1][0] === fence[0] && run[1].length >= fence.length;
102
+ }
103
+
104
+ /**
105
+ * Every heading of a markdown text that is a heading — a `#` line inside a
106
+ * fenced block is a shell comment or an example, and AUTHORING carries
107
+ * hundreds of them.
108
+ */
109
+ export function guideHeadings(text: string): GuideHeading[] {
110
+ const out: GuideHeading[] = [];
111
+ let fence: string | null = null;
112
+ let offset = 0;
113
+ const lines = text.split('\n');
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const line = lines[i];
116
+ const opens = FENCE.exec(line);
117
+ if (opens !== null) {
118
+ if (fence === null) fence = opens[1];
119
+ else if (closesFence(line, fence)) fence = null;
120
+ } else if (fence === null) {
121
+ const heading = /^(#{1,6}) (.*)$/.exec(line);
122
+ if (heading !== null) out.push({ level: heading[1].length, title: heading[2].trim(), line: i + 1, offset });
123
+ }
124
+ offset += utf8Length(line) + 1;
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** One addressable section: its address, its heading, and the byte and line span it covers. */
130
+ export interface GuideSection {
131
+ /** `5` or `5.2` — what follows `§`. */
132
+ address: string;
133
+ heading: GuideHeading;
134
+ /** Byte offsets into the text: the heading's line up to the next heading of its level or above. */
135
+ start: number;
136
+ end: number;
137
+ /** Line numbers, 1-based and inclusive. */
138
+ firstLine: number;
139
+ lastLine: number;
140
+ }
141
+
142
+ /** The number a `## ` heading carries before its first `.` — `5` for `5. Reading a failure`. */
143
+ function chapterNumber(title: string): string | null {
144
+ const m = /^(\d+)\. /.exec(title);
145
+ return m === null ? null : m[1];
146
+ }
147
+
148
+ /**
149
+ * Every section `§<n>` and `§<n>.<m>` addresses, in the order the text gives
150
+ * them. `§<n>.<m>` is the m-th `###` under `## <n>.`, counted here.
151
+ */
152
+ export function guideSections(text: string): GuideSection[] {
153
+ const heads = guideHeadings(text);
154
+ const total = utf8Length(text);
155
+ const lineCount = text.split('\n').length;
156
+ const span = (k: number): { start: number; end: number; firstLine: number; lastLine: number } => {
157
+ const h = heads[k];
158
+ const next = heads.slice(k + 1).find((x) => x.level <= h.level);
159
+ return {
160
+ start: h.offset,
161
+ end: next === undefined ? total : next.offset,
162
+ firstLine: h.line,
163
+ lastLine: next === undefined ? lineCount : next.line - 1,
164
+ };
165
+ };
166
+ const out: GuideSection[] = [];
167
+ let chapter: string | null = null;
168
+ let m = 0;
169
+ let sub = 0;
170
+ for (let k = 0; k < heads.length; k++) {
171
+ const h = heads[k];
172
+ if (h.level <= 2) {
173
+ chapter = h.level === 2 ? chapterNumber(h.title) : null;
174
+ m = 0;
175
+ sub = 0;
176
+ if (chapter !== null) out.push({ address: chapter, heading: h, ...span(k) });
177
+ } else if (h.level === 3 && chapter !== null) {
178
+ m++;
179
+ sub = 0;
180
+ out.push({ address: `${chapter}.${m}`, heading: h, ...span(k) });
181
+ } else if (h.level === 4 && chapter !== null && m > 0) {
182
+ // §<n>.<m>.<k>: the k-th #### under the m-th ### (HQ's ruling on rig-c/parts#343, S-A). A #### above the
183
+ // chapter's first ### belongs to no ###, so nothing addresses it.
184
+ sub++;
185
+ out.push({ address: `${chapter}.${m}.${sub}`, heading: h, ...span(k) });
186
+ }
187
+ }
188
+ return out;
189
+ }
190
+
191
+ /**
192
+ * Every `###` and `####` whose written number is not the position it is counted
193
+ * at — the guide writes `### 5.2 …` and `#### 4.5.1 …`, and `§5.2` is the second
194
+ * `###` under `## 5.`, `§4.5.1` the first `####` under `§4.5`, so a heading
195
+ * inserted, moved or misnumbered would make the printed number lie about what
196
+ * `rigc guide` prints for it. Named by heading and line. A heading that writes
197
+ * no number is counted and not checked; one that writes a number its counted
198
+ * address is not — a lettered `10.6b`, a four-part `3.5.2.1` at `####` — is a
199
+ * fault, because nothing could address it by it.
200
+ */
201
+ export function numberingFaults(text: string, file: string = GUIDE_FILE): string[] {
202
+ const faults: string[] = [];
203
+ for (const section of guideSections(text)) {
204
+ if (section.heading.level < 3) continue;
205
+ const written = /^(\d+(?:\.\d+)*[a-z]?(?:\.\d+)*)\s/.exec(section.heading.title);
206
+ if (written === null) continue;
207
+ if (written[1] !== section.address) {
208
+ const parts = section.address.split('.');
209
+ const level = '#'.repeat(section.heading.level);
210
+ const parent = section.heading.level === 3 ? `"## ${parts[0]}."` : `§${parts.slice(0, -1).join('.')}`;
211
+ faults.push(
212
+ `${file}:${section.heading.line} "${level} ${section.heading.title}" writes ${written[1]} and is the ` +
213
+ `${ordinal(Number(parts[parts.length - 1]))} ${level} under ${parent}, so ${GUIDE_BIN} guide §${section.address} prints it — ` +
214
+ `renumber it ${section.address}, or make it a ${level}# of the ${level} above it`,
215
+ );
216
+ }
217
+ }
218
+ return faults;
219
+ }
220
+
221
+ function ordinal(n: number): string {
222
+ const tens = n % 100;
223
+ const suffix = tens >= 11 && tens <= 13 ? 'th' : n % 10 === 1 ? 'st' : n % 10 === 2 ? 'nd' : n % 10 === 3 ? 'rd' : 'th';
224
+ return `${n}${suffix}`;
225
+ }
226
+
227
+ /** A rule row: the rule its first cell names, the row's text, where it is, and the table it sits in. */
228
+ export interface GuideRow {
229
+ rule: string;
230
+ /** The row as written. */
231
+ text: string;
232
+ file: string;
233
+ line: number;
234
+ /** The table's header row and its separator, so the cells can be read by their column. */
235
+ header: string[];
236
+ /** The nearest heading above the row. */
237
+ heading: GuideHeading | null;
238
+ /** The anchor id the first cell opens with, or `null` when it carries none. */
239
+ anchor: string | null;
240
+ }
241
+
242
+ /** A rule name as a row spells it: upper-case words joined by `_`, a `<WORD>` standing for a family's member. */
243
+ const RULE_CELL = /^\|\s*(?:<a id="([^"]*)"><\/a>)?`([A-Z][A-Z0-9]*(?:<[A-Z]+>)?(?:_(?:[A-Z0-9]+|<[A-Z]+>))*)`\s*\|/;
244
+
245
+ /**
246
+ * Every rule row of a text. With `section` the rows are those under that
247
+ * heading, up to the next heading of its level or above — the one table of
248
+ * rules a document other than the guide holds; without it, every row of the
249
+ * text whose code carries an underscore (the module comment says why).
250
+ */
251
+ export function guideRows(text: string, file: string, section?: string): GuideRow[] {
252
+ const heads = guideHeadings(text);
253
+ const lines = text.split('\n');
254
+ let from = 1;
255
+ let to = lines.length;
256
+ if (section !== undefined) {
257
+ const at = heads.findIndex((h) => `${'#'.repeat(h.level)} ${h.title}`.startsWith(section));
258
+ if (at < 0) return [];
259
+ const next = heads.slice(at + 1).find((h) => h.level <= heads[at].level);
260
+ from = heads[at].line + 1;
261
+ to = next === undefined ? lines.length : next.line - 1;
262
+ }
263
+ const out: GuideRow[] = [];
264
+ let fence: string | null = null;
265
+ let header: string[] = [];
266
+ let h = 0;
267
+ let heading: GuideHeading | null = null;
268
+ for (let i = 0; i < lines.length; i++) {
269
+ const line = lines[i];
270
+ while (h < heads.length && heads[h].line <= i + 1) heading = heads[h++];
271
+ const opens = FENCE.exec(line);
272
+ if (opens !== null) {
273
+ if (fence === null) fence = opens[1];
274
+ else if (closesFence(line, fence)) fence = null;
275
+ continue;
276
+ }
277
+ if (fence !== null) continue;
278
+ if (line.startsWith('|') && i + 1 < lines.length && /^\|\s*:?-{3,}/.test(lines[i + 1])) header = [line, lines[i + 1]];
279
+ if (i + 1 < from || i + 1 > to) continue;
280
+ const row = RULE_CELL.exec(line);
281
+ if (row === null) continue;
282
+ if (section === undefined && !row[2].includes('_')) continue;
283
+ out.push({ rule: row[2], text: line, file, line: i + 1, header, heading, anchor: row[1] ?? null });
284
+ }
285
+ return out;
286
+ }
287
+
288
+ /** Whether a family row's code (`ATTACHMENT_<TYPE>`) spells `rule`. */
289
+ export function familySpells(family: string, rule: string): boolean {
290
+ if (!family.includes('<')) return false;
291
+ const pattern = family.split(/<[A-Z]+>/).map((part) => part.replace(/[^A-Z0-9_]/g, '')).join('[A-Z0-9]+');
292
+ return new RegExp(`^${pattern}$`).test(rule);
293
+ }
294
+
295
+ /** The documents `guide` reads, as their texts: the guide, and each other document a rule table lives in. */
296
+ export interface GuideTexts {
297
+ guide: string;
298
+ /** By file, as `GUIDE_RULE_TABLES_ELSEWHERE` names them; a file that could not be read is absent. */
299
+ elsewhere: Readonly<Record<string, string>>;
300
+ }
301
+
302
+ /** Every rule row `guide` can print: the guide's, then each other document's table. */
303
+ export function allRuleRows(texts: GuideTexts): GuideRow[] {
304
+ const rows = guideRows(texts.guide, GUIDE_FILE);
305
+ for (const table of GUIDE_RULE_TABLES_ELSEWHERE) {
306
+ const text = texts.elsewhere[table.file];
307
+ if (text !== undefined) rows.push(...guideRows(text, table.file, table.heading));
308
+ }
309
+ return rows;
310
+ }
311
+
312
+ /**
313
+ * The rows that answer for `rule`: its own rows, in the guide first; or, where
314
+ * it has none anywhere, the family rows that spell it. Empty when nothing does.
315
+ */
316
+ export function rowsFor(rule: string, rows: readonly GuideRow[]): GuideRow[] {
317
+ const own = rows.filter((row) => row.rule === rule);
318
+ if (own.length > 0) return own;
319
+ return rows.filter((row) => familySpells(row.rule, rule));
320
+ }
321
+
322
+ /**
323
+ * The `n` known names nearest to `asked`, case aside: a name it is the start of
324
+ * (or, from three characters, a part of) first; then by edit distance less the
325
+ * prefix the two share, so `A00_MODEL_READ` is nearer `A00_MODEL_REGIONS_ON_PAGES`
326
+ * than a short name a few edits away; then by name.
327
+ */
328
+ export function nearestNames(asked: string, known: readonly string[], n = 3): string[] {
329
+ const key = asked.toUpperCase();
330
+ const shared = (name: string): number => {
331
+ let k = 0;
332
+ while (k < key.length && k < name.length && key[k] === name[k]) k++;
333
+ return k;
334
+ };
335
+ const score = (name: string): number =>
336
+ name.toUpperCase().startsWith(key) || (key.length >= 3 && name.toUpperCase().includes(key)) ? -Infinity : editDistance(key, name.toUpperCase()) - shared(name.toUpperCase());
337
+ return [...new Set(known)]
338
+ .map((name) => ({ name, d: score(name) }))
339
+ .sort((a, b) => a.d - b.d || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
340
+ .slice(0, n)
341
+ .map((x) => x.name);
342
+ }
343
+
344
+ function editDistance(a: string, b: string): number {
345
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
346
+ for (let i = 1; i <= a.length; i++) {
347
+ const cur = [i];
348
+ for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
349
+ prev = cur;
350
+ }
351
+ return prev[b.length];
352
+ }
353
+
354
+ /**
355
+ * The two rules `guide` itself refuses by (HQ's ruling on rig-c/parts#343, the printed form): a name no row
356
+ * carries, and an address no section has. Each has its row in the guide, like every other rule a line can name.
357
+ */
358
+ export const GUIDE_RULE_KNOWN = 'GUIDE_RULE_KNOWN';
359
+ export const GUIDE_SECTION_KNOWN = 'GUIDE_SECTION_KNOWN';
360
+ export const GUIDE_RULES: readonly string[] = [GUIDE_RULE_KNOWN, GUIDE_SECTION_KNOWN];
361
+
362
+ /** What `guide` answers: the lines to print, or the refusal's rule and detail (stderr, exit 1). */
363
+ export type GuideAnswer = { ok: true; lines: string[] } | { ok: false; rule: string; detail: string };
364
+
365
+ function kb(bytes: number): string {
366
+ return `${(bytes / 1024).toFixed(1)} KB`;
367
+ }
368
+
369
+ /** The `§` address of the section a line of the guide sits in — its deepest addressed heading; `null` outside every numbered section. */
370
+ function addressOfLine(sections: readonly GuideSection[], line: number): string | null {
371
+ let best: GuideSection | null = null;
372
+ for (const s of sections) if (s.firstLine <= line && line <= s.lastLine && (best === null || s.heading.level > best.heading.level)) best = s;
373
+ return best === null ? null : best.address;
374
+ }
375
+
376
+ /** A section's children — the addresses one level below it, in the order the guide gives them; `null` is the guide itself. */
377
+ function childrenOf(sections: readonly GuideSection[], address: string | null): GuideSection[] {
378
+ const depth = address === null ? 0 : address.split('.').length;
379
+ return sections.filter((s) => s.address.split('.').length === depth + 1 && (address === null || s.address.startsWith(`${address}.`)));
380
+ }
381
+
382
+ /**
383
+ * `rigc guide <RULE>` and `rigc guide §<n>[.<m>[.<k>]]`, over the texts as read, in the printed form HQ fixed for
384
+ * both packages on rig-c/parts#343:
385
+ *
386
+ * - a rule: per row, `docs/AUTHORING.md §<addr>, line <N> — under "<heading>"`, then the row verbatim;
387
+ * - a section: `docs/AUTHORING.md §<addr> "<title>", lines <a>–<b>, <X.X> KB`, then — when it has children — one
388
+ * `§<addr> <X.X KB> <title>` line per child, then the section verbatim;
389
+ * - an unknown rule or section: a refusal by `GUIDE_RULE_KNOWN` / `GUIDE_SECTION_KNOWN`.
390
+ */
391
+ export function guideAnswer(asked: string, texts: GuideTexts): GuideAnswer {
392
+ const sections = guideSections(texts.guide);
393
+ if (asked.startsWith('§')) {
394
+ const address = asked.slice(1);
395
+ const section = sections.find((s) => s.address === address);
396
+ if (section === undefined) {
397
+ // The deepest section the address names a prefix of, and its sections: §5.9 -> §5 and §5.1…§5.4.
398
+ const parts = address.split('.');
399
+ let deepest: GuideSection | null = null;
400
+ for (let n = parts.length - 1; n >= 1 && deepest === null; n--) deepest = sections.find((s) => s.address === parts.slice(0, n).join('.')) ?? null;
401
+ const kids = childrenOf(sections, deepest === null ? null : deepest.address).map((s) => `§${s.address}`);
402
+ const where = deepest === null ? `the guide itself, ${GUIDE_FILE}` : `§${deepest.address} "${deepest.heading.title}"`;
403
+ return {
404
+ ok: false,
405
+ rule: GUIDE_SECTION_KNOWN,
406
+ detail: `"${asked}" — no such section; the deepest that exists is ${where}, whose sections are: ${kids.length === 0 ? 'none' : kids.join(', ')}`,
407
+ };
408
+ }
409
+ const body = texts.guide.split('\n').slice(section.firstLine - 1, section.lastLine).join('\n').replace(/\n+$/, '');
410
+ const lines = [`${GUIDE_FILE} §${section.address} "${section.heading.title}", lines ${section.firstLine}–${section.lastLine}, ${kb(section.end - section.start)}`];
411
+ // A chapter lists its ###, a ### its #### — each with the address that prints it, its size and its title.
412
+ const subs = childrenOf(sections, section.address);
413
+ const wide = Math.max(0, ...subs.map((sub) => sub.address.length + 1));
414
+ const wideKb = Math.max(0, ...subs.map((sub) => kb(sub.end - sub.start).length));
415
+ for (const sub of subs) lines.push(`${`§${sub.address}`.padEnd(wide)} ${kb(sub.end - sub.start).padStart(wideKb)} ${sub.heading.title}`);
416
+ lines.push(body);
417
+ return { ok: true, lines };
418
+ }
419
+ const rows = allRuleRows(texts);
420
+ const found = rowsFor(asked, rows);
421
+ if (found.length === 0) {
422
+ return {
423
+ ok: false,
424
+ rule: GUIDE_RULE_KNOWN,
425
+ // The files actually searched, named (HQ on rig-c/parts#343): the guide, then each other document a rule table lives in.
426
+ detail: `"${asked}" — no row in ${[GUIDE_FILE, ...GUIDE_RULE_TABLES_ELSEWHERE.map((t) => t.file)].join(' or ')} names it; the nearest: ${nearestNames(asked, rows.map((r) => r.rule)).join(', ')}`,
427
+ };
428
+ }
429
+ const lines: string[] = [];
430
+ for (const row of found) {
431
+ const address = row.file === GUIDE_FILE ? addressOfLine(sections, row.line) : null;
432
+ const where = `${row.file}${address === null ? '' : ` §${address}`}, line ${row.line}`;
433
+ lines.push(`${where} — under "${row.heading === null ? '(no heading)' : row.heading.title}"`);
434
+ if (row.rule !== asked) lines.push(`${asked} has no row of its own; the row for the family ${row.rule}, which spells it, answers for it`);
435
+ lines.push(row.text);
436
+ }
437
+ return { ok: true, lines };
438
+ }
package/src/ingest.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  SPINE_VERSION,
53
53
  } from './compile.ts';
54
54
  import { CompileError } from './errors.ts';
55
+ import { guidePointer } from './guide.ts';
55
56
  import { physicsDrives } from './core/constraints_physics.ts';
56
57
  import { CHANNELS_BY_KIND } from './timelines.ts';
57
58
  import {
@@ -149,6 +150,17 @@ export const INGEST_GUTTERS: Record<IngestFindingKind, string> = {
149
150
  lossy: 'LOSS',
150
151
  };
151
152
 
153
+ /**
154
+ * One finding as `ingest` and `repack` print it: the gutter padded to one width
155
+ * so the codes line up, the code, where, the detail, and the pointer to the
156
+ * code's row (issue #1397) — the one writer of the line, so both commands print
157
+ * the same one.
158
+ */
159
+ export function findingLine(finding: IngestFinding): string {
160
+ const width = Math.max(...Object.values(INGEST_GUTTERS).map((gutter) => gutter.length));
161
+ return ` ${INGEST_GUTTERS[finding.kind].padEnd(width)} ${finding.code}: ${finding.where} — ${finding.detail}${guidePointer(finding.code)}`;
162
+ }
163
+
152
164
  export interface IngestFinding {
153
165
  /** Stable code, so a table can count them and a doc can name one. */
154
166
  code: string;