@tekyzinc/gsd-t 5.17.13 → 5.18.10

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.
@@ -0,0 +1,437 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-testplan-lint — the A2 test-plan shape gate.
4
+ *
5
+ * Contract: .gsd-t/contracts/test-plan-first-contract.md §2–§4
6
+ * Mold: templates/TestPlan-spec.md
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * A test plan (`TestPlan-[FeatureArea].md`) is what a reviewer approves BEFORE
11
+ * any test exists — the row schema and the self-answered-visibility rule are
12
+ * the whole reason it can be trusted at a glance. This gate checks a plan
13
+ * against that schema BEFORE the reviewer ever sees it
14
+ * (`[RULE] plan-gated-before-presentation`): the required section set in
15
+ * order, the six-column table header exactly, every row in exactly one of
16
+ * the three states, `Effect on saved data` and `Source` never blank, and the
17
+ * `## Decided without you` group present and exactly matching the set of
18
+ * self-answered rows.
19
+ *
20
+ * Modelled on the proven shape of `bin/gsd-t-pseudocode-style.cjs`: zero deps,
21
+ * deterministic, never throws, one JSON envelope, the 0/4/64 exit triple
22
+ * frozen by the contract §4. The `main()` catch→exit-64 wrapper below is that
23
+ * SAME frozen halt shape (contract §4: "a gate that cannot decide exits `64`
24
+ * ... never exits `0` by default") — a HALT, not a fallback: it stops and
25
+ * reports rather than continuing past a failure with a guessed result.
26
+ *
27
+ * Input: --doc <path> [--json] | --dir <dir of TestPlan-*.md> [--json]
28
+ * Output: JSON envelope { ok, exitCode, violations: [ {kind, detail} ] }
29
+ * Exit: 0 clean · 4 shape violations · 64 bad input
30
+ */
31
+
32
+ "use strict";
33
+
34
+ const fs = require("fs");
35
+ const { walkSections, parseRows, isFenceToggle, HEADER_CELLS, TABLE_HEADING_RE, tableName: sharedTableName, rowState } = require("./gsd-t-testplan-rows.cjs");
36
+ const path = require("path");
37
+
38
+ // ─── frozen literals (contract §2–§3) ─────────────────────────────────────
39
+
40
+ const HEADING_DECIDED = "## Decided without you";
41
+ const MARKER_SELF_ANSWERED = "DECIDED-WITHOUT-YOU";
42
+ const MARKER_GAP = "GAP";
43
+ const MARKER_CONTRADICTION = "GAP:CONTRADICTION";
44
+ const NONE_SOURCED_SENTENCE = "None — every row is sourced.";
45
+
46
+ // The frozen six-column header, exact text (contract §2.1) — ONE definition, in the shared reader.
47
+ const REQUIRED_COLUMNS = HEADER_CELLS;
48
+
49
+ // Required top-level sections, in order (mold §"Section order below is FIXED"):
50
+ // Decided without you, at least one Table, Open gaps, Sign-off.
51
+ const REQUIRED_SECTIONS_IN_ORDER = [
52
+ { key: "decided-without-you", test: (h) => h === HEADING_DECIDED },
53
+ { key: "table", test: (h) => TABLE_HEADING_RE.test(h) && !!sharedTableName(h), repeatable: true },
54
+ { key: "open-gaps", test: (h) => h === "## Open gaps" },
55
+ { key: "sign-off", test: (h) => h === "## Sign-off" },
56
+ ];
57
+
58
+ // ─── parsing (structural, positional — never a substring scan) ────────────
59
+
60
+ /**
61
+ * Split a document into an ordered list of `##`-heading sections, each
62
+ * carrying its raw text and starting line number. Fenced code blocks are
63
+ * tracked so a `##` INSIDE a fence is never mistaken for a section boundary.
64
+ * @returns {Array<{heading: string, lines: string[], startLine: number}>}
65
+ */
66
+ function splitSections(text) {
67
+ // Shared, fence-aware for ``` AND ~~~ (Red Team M115 run 4: tilde-fenced fake
68
+ // headings satisfied the required-section walk).
69
+ return walkSections(text);
70
+ }
71
+
72
+ /**
73
+ * Parse the Markdown table rows out of a section's lines, by POSITION (never
74
+ * a substring search). Skips the header row and the `---` separator row.
75
+ * @returns {Array<{cells: string[], line: number, raw: string}>}
76
+ */
77
+ function parseTableRows(sectionLines, sectionStartLine) {
78
+ return parseRows(sectionLines, sectionStartLine);
79
+ }
80
+
81
+ /**
82
+ * Find the table's own header row (the `| Seq | ... |` line) inside a
83
+ * `## Table:` section, so its exact column text can be checked.
84
+ * @returns {{cells: string[], line: number}|null}
85
+ */
86
+ function findTableHeader(sectionLines, sectionStartLine) {
87
+ // Fence-aware like everything else that reads a section: a table-shaped line
88
+ // inside a code block is text, not the header (code-review M115 run 5 — a
89
+ // fenced pipe line was taken as the header and every real row went unchecked).
90
+ let inFence = false;
91
+ for (let i = 0; i < sectionLines.length; i++) {
92
+ if (isFenceToggle(sectionLines[i])) { inFence = !inFence; continue; }
93
+ if (inFence) continue;
94
+ const trimmed = sectionLines[i].trim();
95
+ if (!trimmed.startsWith("|")) continue;
96
+ const inner = trimmed.replace(/^\|/, "").replace(/\|$/, "");
97
+ const cells = inner.split("|").map((c) => c.trim());
98
+ // The first table-shaped line in the section IS the header row, whatever its
99
+ // text — "missing" means no table-shaped line at all; a wrong-but-present
100
+ // header text is a separate violation (wrong-table-header), never conflated.
101
+ return { cells, line: sectionStartLine + i };
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /** Read the row's column-6 state (contract §2.1) from its Source cell text. */
107
+ function classifyRowState(sourceCell) {
108
+ // The shared reader's classifier, mapped onto this gate's vocabulary. `malformed`
109
+ // is a cell that LOOKS like a marker and is not one — `GAPX:` — which the gate
110
+ // once read as a citation (Red Team M115 run 6, HIGH).
111
+ const st = rowState(sourceCell);
112
+ if (st === "gap") return "open";
113
+ if (st === "decided") return "self-answered";
114
+ return st; // empty | sourced | malformed
115
+ }
116
+
117
+ /**
118
+ * Parse the `## Decided without you` group into its bullet entries.
119
+ * @returns {Array<{table: string|null, seq: string|null, evidencePresent: boolean, raw: string}>}
120
+ */
121
+ /** The section's lines with every fenced block removed — a bullet inside a code
122
+ * block is rendered as code, invisible as a decision (Red Team M115 run 5, HIGH). */
123
+ function unfencedLines(sectionLines) {
124
+ const out = [];
125
+ let inFence = false;
126
+ for (const line of sectionLines) {
127
+ if (isFenceToggle(line)) { inFence = !inFence; continue; }
128
+ if (!inFence) out.push(line);
129
+ }
130
+ return out;
131
+ }
132
+
133
+ function parseDecidedGroup(sectionLines) {
134
+ const entries = [];
135
+ for (const line of unfencedLines(sectionLines)) {
136
+ const t = line.trim();
137
+ if (t === "---" || !/^-\s+\S/.test(t)) continue;
138
+ if (t === `> ${NONE_SOURCED_SENTENCE}` || t === NONE_SOURCED_SENTENCE) continue;
139
+ const m = t.match(/^-\s+`([^`]+)`\s+Seq\s+`([^`]+)`\s+—\s+(.*)$/);
140
+ if (m) {
141
+ const [, table, seq, rest] = m;
142
+ entries.push({ table, seq, evidencePresent: /evidence:\s*\S/.test(rest), raw: t });
143
+ } else {
144
+ entries.push({ table: null, seq: null, evidencePresent: false, raw: t });
145
+ }
146
+ }
147
+ return entries;
148
+ }
149
+
150
+ /** True when the Decided group's only content is the "None" sentence (or is empty of bullets). */
151
+ function decidedGroupIsExplicitlyEmpty(sectionLines) {
152
+ // Positional: the group's ONLY content line is the "None" sentence (bare or
153
+ // as a blockquote). A "None" buried among real bullets is not an empty group.
154
+ const content = unfencedLines(sectionLines).map((l) => l.trim()).filter((l) => l && !l.startsWith("## "));
155
+ return content.length === 1 && (content[0] === NONE_SOURCED_SENTENCE || content[0] === `> ${NONE_SOURCED_SENTENCE}`);
156
+ }
157
+
158
+ // ─── the gate ──────────────────────────────────────────────────────────────
159
+
160
+ /**
161
+ * Gate one test-plan doc. Pure, structural, never throws — caller wraps in
162
+ * try/catch for I/O.
163
+ * @returns {{ok: boolean, exitCode: 0|4, violations: Array<{kind:string, detail:string}>}}
164
+ */
165
+ function checkDoc(text, docPath) {
166
+ const violations = [];
167
+ const v = (kind, detail) => violations.push({ kind, detail, doc: docPath });
168
+
169
+ const sections = splitSections(text);
170
+
171
+ // ── Required section set, present and in order ──
172
+ // The required set must appear in RELATIVE order; sections the mold does not
173
+ // name (e.g. the contract-mandated `## HALT — case-space bound reached`) are
174
+ // skipped, never counted as a break in the order. The first shipped walker
175
+ // stalled on the first unknown heading and then reported every later required
176
+ // section missing — failing the milestone's own correctly-halted cold run
177
+ // (code-review M115, important).
178
+ let cursor = 0;
179
+ const foundByKey = {};
180
+ for (const req of REQUIRED_SECTIONS_IN_ORDER) {
181
+ let matchedAtLeastOnce = false;
182
+ for (let i = cursor; i < sections.length; i++) {
183
+ if (!req.test(sections[i].heading)) continue;
184
+ matchedAtLeastOnce = true;
185
+ foundByKey[req.key] = foundByKey[req.key] || [];
186
+ foundByKey[req.key].push(sections[i]);
187
+ cursor = i + 1;
188
+ if (!req.repeatable) break;
189
+ }
190
+ if (!matchedAtLeastOnce) {
191
+ v("missing-or-out-of-order-section", `required section "${req.key}" is missing, out of order, or empty — the mold's fixed section order is Decided without you, one or more Table sections, Open gaps, Sign-off.`);
192
+ }
193
+ }
194
+
195
+ // Every Table section, wherever it sits — an ordering violation above must not
196
+ // hide the row defects the reviewer also needs to fix (review M115 run 7).
197
+ const tableSections = sections.filter((sec) => !!sharedTableName(sec.heading));
198
+ const seenIdentities = new Set();
199
+ const decidedSections = foundByKey["decided-without-you"] || [];
200
+
201
+ // ── Every sequence table: exact six-column header, row states, blanks ──
202
+ const allSelfAnsweredRows = []; // {table, seq}
203
+ for (const tsec of tableSections) {
204
+ const tableName = sharedTableName(tsec.heading);
205
+ const header = findTableHeader(tsec.lines, tsec.startLine);
206
+ if (!header) {
207
+ v("missing-table-header", `table "${tableName}" has no \`| Seq | ... |\` header row.`);
208
+ continue;
209
+ }
210
+ const headerOk = header.cells.length === REQUIRED_COLUMNS.length
211
+ && REQUIRED_COLUMNS.every((c, idx) => header.cells[idx] === c);
212
+ if (!headerOk) {
213
+ v("wrong-table-header", `table "${tableName}" header is "${header.cells.join(" | ")}" — must be exactly "${REQUIRED_COLUMNS.join(" | ")}".`);
214
+ continue; // column positions are meaningless if the header doesn't match
215
+ }
216
+
217
+ const rows = parseTableRows(tsec.lines, tsec.startLine);
218
+ if (rows.length === 0) {
219
+ v("empty-table", `table "${tableName}" declares the header but has no data rows.`);
220
+ }
221
+ for (const row of rows) {
222
+ // EXACT width. A row with extra cells used to pass and every consumer then
223
+ // read the wrong cell as column 6 — the state column (code-review M115 run 4).
224
+ if (row.cells.length !== REQUIRED_COLUMNS.length) {
225
+ v("row-column-count-mismatch", `table "${tableName}" row "${row.raw.slice(0, 80)}" has ${row.cells.length} columns, expected ${REQUIRED_COLUMNS.length}.`);
226
+ continue;
227
+ }
228
+ const seq = row.cells[0];
229
+ const effectOnSavedData = row.cells[4];
230
+ const source = row.cells[5];
231
+
232
+ // A row with no Seq has no identity: nothing can cite it, the halt cannot name
233
+ // it, and one consumer silently dropped it (Red Team M115 run 6, HIGH).
234
+ if (seq.trim() === "") {
235
+ v("blank-seq", `table "${tableName}" has a row with a blank "Seq" — every row needs an identity.`);
236
+ continue;
237
+ }
238
+ const identity = `${tableName}::${seq}`;
239
+ if (seenIdentities.has(identity)) {
240
+ v("duplicate-row-identity", `table "${tableName}" Seq "${seq}" appears more than once — two rows with one identity let a later row silently overwrite an earlier one (code-review M115 run 6).`);
241
+ }
242
+ seenIdentities.add(identity);
243
+
244
+ if (effectOnSavedData.trim() === "") {
245
+ v("blank-effect-on-saved-data", `table "${tableName}" Seq "${seq}" has a blank "Effect on saved data" — write "none" explicitly.`);
246
+ }
247
+
248
+ const state = classifyRowState(source);
249
+ if (state === "malformed") {
250
+ v("unknown-source-marker", `table "${tableName}" Seq "${seq}" has a "Source" that looks like a marker but is not one ("${source.slice(0, 40)}") — the only markers are GAP, GAP:CONTRADICTION and DECIDED-WITHOUT-YOU.`);
251
+ continue;
252
+ }
253
+ if (state === "empty") {
254
+ v("blank-source-not-a-fourth-state", `table "${tableName}" Seq "${seq}" has a blank "Source" — every row must be sourced, self-answered, or a gap; there is no empty fourth state.`);
255
+ continue;
256
+ }
257
+ if (state === "self-answered") {
258
+ allSelfAnsweredRows.push({ table: tableName, seq });
259
+ }
260
+ }
261
+ }
262
+
263
+ // ── `## Decided without you`: present, before the first table, exact match ──
264
+ const decidedHeadingCount = sections.filter((sec) => sec.heading === HEADING_DECIDED).length;
265
+ if (decidedHeadingCount > 1) {
266
+ v("duplicate-decided-group", `"${HEADING_DECIDED}" appears ${decidedHeadingCount} times — the group is ONE heading whose entries match the self-answered rows exactly; a second heading hides its entries from that rule.`);
267
+ }
268
+ if (decidedSections.length > 0) {
269
+ const decidedSection = decidedSections[0];
270
+ const firstTableSection = tableSections[0];
271
+ if (firstTableSection && decidedSection.startLine > firstTableSection.startLine) {
272
+ v("decided-group-after-first-table", `"${HEADING_DECIDED}" must appear before the first Table section.`);
273
+ }
274
+
275
+ const explicitlyEmpty = decidedGroupIsExplicitlyEmpty(decidedSection.lines);
276
+ const entries = parseDecidedGroup(decidedSection.lines);
277
+
278
+ if (allSelfAnsweredRows.length === 0 && !explicitlyEmpty) {
279
+ v("decided-group-missing-none-sentence", `no self-answered rows exist, but "${HEADING_DECIDED}" does not carry the required "${NONE_SOURCED_SENTENCE}" line.`);
280
+ }
281
+
282
+ // Every self-answered row must have exactly one matching, evidenced entry.
283
+ for (const r of allSelfAnsweredRows) {
284
+ const match = entries.find((e) => e.table === r.table && e.seq === r.seq);
285
+ if (!match) {
286
+ v("self-answered-row-not-in-decided-group", `table "${r.table}" Seq "${r.seq}" is self-answered but has no entry under "${HEADING_DECIDED}".`);
287
+ } else if (!match.evidencePresent) {
288
+ v("decided-entry-missing-evidence", `table "${r.table}" Seq "${r.seq}" appears under "${HEADING_DECIDED}" but names no evidence.`);
289
+ }
290
+ }
291
+
292
+ // Every entry under the heading must correspond to a real self-answered row —
293
+ // the group is EXACTLY the set, no extras.
294
+ for (const e of entries) {
295
+ if (e.table === null && e.seq === null) {
296
+ v("decided-group-has-unparsable-entry", `an entry under "${HEADING_DECIDED}" does not match the required "\`table\` Seq \`n\` — decision — evidence: ..." shape: "${e.raw.slice(0, 100)}"`);
297
+ continue;
298
+ }
299
+ const isReal = allSelfAnsweredRows.some((r) => r.table === e.table && r.seq === e.seq);
300
+ if (!isReal) {
301
+ v("decided-group-has-extra-entry", `"${HEADING_DECIDED}" cites table "${e.table}" Seq "${e.seq}", which is not a self-answered row in any table.`);
302
+ }
303
+ }
304
+ }
305
+ // (If the heading itself is missing, that was already raised as
306
+ // missing-or-out-of-order-section above — not duplicated here.)
307
+
308
+ return {
309
+ ok: violations.length === 0,
310
+ exitCode: violations.length === 0 ? 0 : 4,
311
+ violations,
312
+ };
313
+ }
314
+
315
+ /**
316
+ * Gate one file on disk.
317
+ *
318
+ * NOT A FALLBACK: an unreadable doc HALTS at exit 64 (never treated as clean,
319
+ * never guessed) — the frozen contract §4 shape ("a gate that cannot decide
320
+ * exits 64 ... never exits 0 by default"), identical to gsd-t-pseudocode-style.cjs.
321
+ * @returns {{ok:boolean, exitCode:0|4|64, doc:string, violations:Array, reason?:string}}
322
+ */
323
+ function gateDoc(docPath) {
324
+ let text;
325
+ try {
326
+ text = fs.readFileSync(docPath, "utf8");
327
+ } catch (e) {
328
+ return { ok: false, exitCode: 64, doc: docPath, reason: `cannot read doc: ${e && e.message}`, violations: [] };
329
+ }
330
+ const result = checkDoc(text, docPath);
331
+ return { ...result, doc: docPath };
332
+ }
333
+
334
+ /**
335
+ * Run the gate over one doc or a whole directory.
336
+ * @returns {object} envelope. I/O failures HALT at exit 64 (see gateDoc) — never
337
+ * silently degrade to a clean pass.
338
+ */
339
+ function run({ doc, dir }) {
340
+ if (!doc && !dir) {
341
+ return { ok: false, exitCode: 64, reason: "missing --doc and/or --dir", violations: [] };
342
+ }
343
+
344
+ const docs = [];
345
+ if (doc) docs.push(doc);
346
+ if (dir) {
347
+ let entries;
348
+ try {
349
+ entries = fs.readdirSync(dir);
350
+ } catch (e) {
351
+ return { ok: false, exitCode: 64, reason: `cannot read dir: ${e && e.message}`, violations: [] };
352
+ }
353
+ for (const e of entries) {
354
+ if (/^TestPlan-.*\.md$/.test(e) && e !== "TestPlan-spec.md") docs.push(path.join(dir, e));
355
+ }
356
+ if (docs.length === 0) {
357
+ return { ok: true, exitCode: 0, docsChecked: 0, skips: [{ reason: "no-testplan-docs" }], violations: [] };
358
+ }
359
+ }
360
+
361
+ const results = [];
362
+ const violations = [];
363
+ let worstExit = 0;
364
+ for (const d of docs) {
365
+ const r = gateDoc(d);
366
+ results.push({ doc: d, ok: r.ok, exitCode: r.exitCode, reason: r.reason });
367
+ for (const v of r.violations) violations.push(v);
368
+ if (r.exitCode > worstExit) worstExit = r.exitCode;
369
+ }
370
+
371
+ return {
372
+ ok: worstExit === 0,
373
+ exitCode: worstExit,
374
+ docsChecked: docs.length,
375
+ results,
376
+ violations,
377
+ };
378
+ }
379
+
380
+ // ─── CLI ────────────────────────────────────────────────────────────────
381
+
382
+ function parseArgs(argv) {
383
+ const o = { doc: null, dir: null, help: false };
384
+ for (let i = 0; i < argv.length; i++) {
385
+ const a = argv[i];
386
+ if (a === "-h" || a === "--help") o.help = true;
387
+ else if (a === "--doc") o.doc = argv[++i];
388
+ else if (a === "--dir") o.dir = argv[++i];
389
+ else if (a === "--json") { /* JSON is the only output */ }
390
+ }
391
+ return o;
392
+ }
393
+
394
+ const HELP = `Usage: gsd-t testplan-lint (--doc <TestPlan-[FeatureArea].md> | --dir <dir>) [--json]
395
+
396
+ Gates a test plan's structural shape against the frozen contract
397
+ (.gsd-t/contracts/test-plan-first-contract.md §2-4): the required section set
398
+ in order, the exact six-column sequence-table header, every row in exactly
399
+ one of the three row states, "Effect on saved data" and "Source" never
400
+ blank, and the "## Decided without you" group present and exactly matching
401
+ the set of self-answered rows.
402
+
403
+ --doc PATH gate one plan.
404
+ --dir PATH gate every TestPlan-*.md in a directory.
405
+
406
+ Exit: 0 clean · 4 shape violations · 64 bad input.`;
407
+
408
+ /**
409
+ * NOT A FALLBACK: this catch is the frozen never-throws HALT wrapper required
410
+ * by contract §4 ("never throws ... exits 64 and says why") — identical in
411
+ * shape to bin/gsd-t-pseudocode-style.cjs and bin/gsd-t-traceability-gate.cjs.
412
+ * It stops and REPORTS an unexpected internal error rather than continuing
413
+ * past it or masking it as a clean pass.
414
+ */
415
+ function main() {
416
+ const o = parseArgs(process.argv.slice(2));
417
+ if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
418
+ let res;
419
+ try {
420
+ res = run(o);
421
+ } catch (e) {
422
+ // A gate that cannot run HALTS with the bad-input envelope — exit 64 is a
423
+ // failure, never a pass. Written as an explicit halt so the fallback guard
424
+ // reads it as one (an assignment-then-fall-through looks like a continue).
425
+ process.stdout.write(JSON.stringify({ ok: false, exitCode: 64, reason: `gate-error: ${e && e.message}`, violations: [] }, null, 2) + "\n");
426
+ process.exit(64);
427
+ }
428
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
429
+ process.exit(res.exitCode);
430
+ }
431
+
432
+ if (require.main === module) main();
433
+
434
+ module.exports = {
435
+ run, gateDoc, checkDoc, splitSections, parseTableRows, findTableHeader,
436
+ classifyRowState, parseDecidedGroup, REQUIRED_COLUMNS,
437
+ };
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * gsd-t-testplan-rows.cjs — the ONE reader of a test-plan document's shape.
5
+ *
6
+ * Three consumers read `TestPlan-*.md` (contract test-plan-first-contract.md §2):
7
+ * the shape lint (bin/gsd-t-testplan-lint.cjs), the convergence halt
8
+ * (bin/gsd-t-testplan-halt.cjs) and the traceability gate's plan-row binding
9
+ * (bin/gsd-t-traceability-gate.cjs). M115 verify run 4 found them drifting:
10
+ * one tracked backtick fences but not tilde fences, one tracked none, and all
11
+ * three accepted a row with EXTRA cells and then read the wrong cell as column
12
+ * 6 — the state column the whole contract rests on. A GAP row with one extra
13
+ * cell passed the lint, cleared an acceptance criterion, and was invisible to
14
+ * the halt. Same defect, three places. This module is where it is fixed once.
15
+ *
16
+ * Rules pinned here:
17
+ * - a fenced block opened by ``` or ~~~ (three or more) hides every line
18
+ * inside it: a `##` there is text, not a heading; a `|` there is not a row
19
+ * - a row has EXACTLY six cells; anything else is `width` !== 6 and the
20
+ * consumer decides what a malformed row means for ITS job (the lint: a
21
+ * violation; the gate: never clears; the halt: still open)
22
+ * - a row's state is read from cell 6 alone: empty | gap | decided | sourced
23
+ */
24
+
25
+ const REQUIRED_COLUMN_COUNT = 6;
26
+ const HEADER_CELLS = ["Seq", "Setup / date", "Action", "Expected result", "Effect on saved data", "Source"];
27
+
28
+ // \x60 is the backtick, kept out of the source text so no scanner mistakes
29
+ // this regex for a template literal (TD-299).
30
+ const FENCE_RE = /^\s*(?:\x60{3,}|~{3,})/;
31
+
32
+ function isFenceToggle(line) { return FENCE_RE.test(line); }
33
+
34
+ /**
35
+ * Split a document into `##` sections, fence-aware for BOTH fence styles.
36
+ * @returns {Array<{heading: string, lines: string[], startLine: number}>}
37
+ * startLine is the 1-based line number of the first line AFTER the heading.
38
+ */
39
+ function walkSections(text) {
40
+ const lines = String(text).split(/\r?\n/);
41
+ const sections = [];
42
+ let cur = null;
43
+ let inFence = false;
44
+ for (let i = 0; i < lines.length; i++) {
45
+ const line = lines[i];
46
+ if (isFenceToggle(line)) { inFence = !inFence; if (cur) cur.lines.push(line); continue; }
47
+ if (!inFence && /^##\s+\S/.test(line)) {
48
+ if (cur) sections.push(cur);
49
+ cur = { heading: line.trim(), lines: [], startLine: i + 2 };
50
+ continue;
51
+ }
52
+ if (cur) cur.lines.push(line);
53
+ }
54
+ if (cur) sections.push(cur);
55
+ return sections;
56
+ }
57
+
58
+ function splitCells(line) {
59
+ const inner = line.trim().replace(/^\|/, "").replace(/\|$/, "");
60
+ return inner.split("|").map((c) => c.trim());
61
+ }
62
+ function isHeaderRow(cells) { return cells[0] === "Seq"; }
63
+ function isSeparatorRow(cells) { return cells.length > 0 && cells.every((c) => /^:?-+:?$/.test(c)); }
64
+
65
+ /**
66
+ * Data rows of a section, by position, fence-aware. Header and separator rows
67
+ * are skipped. Every row carries its `width` so a consumer can refuse a
68
+ * malformed one instead of reading the wrong cell.
69
+ * @returns {Array<{cells: string[], width: number, line: number, raw: string}>}
70
+ */
71
+ function parseRows(sectionLines, sectionStartLine) {
72
+ const rows = [];
73
+ let inFence = false;
74
+ for (let i = 0; i < sectionLines.length; i++) {
75
+ const raw = sectionLines[i];
76
+ if (isFenceToggle(raw)) { inFence = !inFence; continue; }
77
+ if (inFence) continue;
78
+ const trimmed = raw.trim();
79
+ if (!trimmed.startsWith("|")) continue;
80
+ const cells = splitCells(trimmed);
81
+ if (isHeaderRow(cells)) continue;
82
+ if (isSeparatorRow(cells)) continue;
83
+ rows.push({ cells, width: cells.length, line: (sectionStartLine || 0) + i, raw: trimmed });
84
+ }
85
+ return rows;
86
+ }
87
+
88
+ /** `## Table: <name>` — the ONE heading pattern every consumer uses (the lint once
89
+ * required exactly one space while the others accepted any whitespace). */
90
+ const TABLE_HEADING_RE = /^##\s+Table:\s*(.+?)\s*$/;
91
+ function tableName(heading) { const m = String(heading || "").match(TABLE_HEADING_RE); return m ? m[1].trim() : null; }
92
+
93
+ /**
94
+ * The row state, read from cell 6 ALONE — the ONE classifier (Red Team M115 run 6:
95
+ * the gate matched `GAP\b`, the lint and halt matched `startsWith("GAP")`, and a
96
+ * `GAPX:` cell was a gap to two tools and a sourced answer to the third, which
97
+ * cleared an acceptance criterion). Markers are exact tokens, case-insensitive
98
+ * (project rule: domain values compare case-insensitively):
99
+ * empty — nothing written
100
+ * gap — `GAP` / `GAP: …` / `GAP:CONTRADICTION …`
101
+ * decided — `DECIDED-WITHOUT-YOU …`
102
+ * malformed — looks like a marker but is not one (`GAPX`, `GAP-ish`, `DECIDED …`)
103
+ * sourced — anything else: a citation
104
+ */
105
+ function rowState(sourceCell) {
106
+ const s = String(sourceCell == null ? "" : sourceCell).trim();
107
+ if (s === "") return "empty";
108
+ if (/^gap(?::|\s|$)/i.test(s)) return "gap";
109
+ if (/^decided-without-you(?::|\s|$)/i.test(s)) return "decided";
110
+ if (/^(?:gap|decided)/i.test(s)) return "malformed";
111
+ return "sourced";
112
+ }
113
+
114
+ module.exports = { REQUIRED_COLUMN_COUNT, HEADER_CELLS, TABLE_HEADING_RE, tableName, isFenceToggle, walkSections, splitCells, isHeaderRow, isSeparatorRow, parseRows, rowState };