@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,439 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-testplan-halt — the A5 non-convergence halt.
4
+ *
5
+ * Contract: .gsd-t/contracts/test-plan-first-contract.md §4 (exit codes/envelope/module
6
+ * shape), §5 (verb name), §6 (loop-ledger reuse, READ-ONLY).
7
+ *
8
+ * WHY THIS EXISTS
9
+ * ---------------
10
+ * A test plan's open rows (`GAP` / `GAP:CONTRADICTION` in column 6, §2.1) get closed by
11
+ * asking the human questions. Two ways that loop can go wrong instead of converging:
12
+ *
13
+ * [RULE] enumeration-loop-cap-three — three question rounds pass and rows are still
14
+ * open. Continuing to ask is not the failure mode this catches; FILLING those rows
15
+ * with something plausible after round three is. So round three HALTS instead.
16
+ *
17
+ * [RULE] same-symptom-twice-halts — the same failure signature recurs across two
18
+ * consecutive rounds. A repeat means the belief behind the last fix was wrong, not
19
+ * that the fix needs a third attempt — so this halts toward re-examining the belief,
20
+ * not toward trying again.
21
+ *
22
+ * Both are a HALT, never a fallback: on non-convergence this tool refuses to continue and
23
+ * says so. It never fills an open row and it never silently passes a repeated failure
24
+ * through on a third try.
25
+ *
26
+ * ONE HALT POINT
27
+ * --------------
28
+ * `checkConvergence` runs its steps (read the doc, parse it, ask the loop ledger) without
29
+ * a try/catch of its own — a thrown error from any step propagates untouched. Exactly one
30
+ * try/catch exists, at the CLI entry point (`main`). Its catch ends the process with the
31
+ * same `halt(...)` envelope as every ordinary bad-input path — a stated refusal to
32
+ * continue, never a guessed value, and never a bare stack trace.
33
+ *
34
+ * READ-ONLY REUSE (§6, non-negotiable)
35
+ * -------------------------------------
36
+ * The repeated-signature cap is entirely the loop ledger's (`bin/gsd-t-loop-ledger.cjs`)
37
+ * existing job — `computeSignature` / `appendCycle` / `readExitState`. This file maps an
38
+ * enumeration round onto one ledger cycle and reads the ledger's own verdict; it does not
39
+ * reimplement signature comparison. The ledger is imported, never edited, never forked.
40
+ *
41
+ * Deterministic, zero LLM judgment. Never throws past the CLI boundary — bad input HALTS
42
+ * with exitCode 64.
43
+ *
44
+ * Input: --doc <path> --round <n> [--milestone <name>]
45
+ * [--assertion <text> --surface <text> --fileClass <text>] (repeated-symptom cap;
46
+ * when omitted, the signature is derived from the doc's own open-row set so the
47
+ * cap still works from --doc alone)
48
+ * [--projectDir <path>]
49
+ * Output: JSON envelope { ok, exitCode, doc, round, openRows, halted, haltReason, violations }
50
+ * Exit: 0 clean (not halted) · 4 halted (non-convergence) · 64 bad input — a HALT, not a pass
51
+ *
52
+ * module.exports: { checkConvergence, parseOpenRows, roundToCycleSignature }
53
+ */
54
+
55
+ "use strict";
56
+
57
+ const fs = require("fs");
58
+ const { walkSections, parseRows, rowState, tableName, REQUIRED_COLUMN_COUNT } = require("./gsd-t-testplan-rows.cjs");
59
+ const path = require("path");
60
+ const loopLedger = require("./gsd-t-loop-ledger.cjs");
61
+
62
+ /** [RULE] enumeration-loop-cap-three — this many rounds without closure halts. */
63
+ const ROUND_CAP = 3;
64
+
65
+ /**
66
+ * [RULE] same-symptom-twice-halts — this many consecutive occurrences of the SAME
67
+ * signature halts. Read from the ledger's own `cycles` count on every `appendCycle` call
68
+ * (never from the ledger's own `halted` flag, which fires at ITS fixed internal threshold
69
+ * of 3 — a different cap for a different rule, per the ledger's debug-loop use case). This
70
+ * is still full delegation, not a fork: the ledger computes and persists the signature and
71
+ * the count; this constant only says how many of THIS gate's own occurrences is "twice".
72
+ */
73
+ const SYMPTOM_REPEAT_CAP = 2;
74
+
75
+ /** Column-6 gap markers, matched case-sensitively per contract §2.2. */
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Row parsing — §2.1 sequence-table schema, read-only interpretation
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Parse every sequence table in a test-plan Markdown document and return the rows still
83
+ * `open` (column 6 carries a `GAP` or `GAP:CONTRADICTION` marker).
84
+ *
85
+ * This reads the frozen §2 schema; it does not lint it (that is `testplan-lint`'s job,
86
+ * owned by `deterministic-gates`) — a malformed row is simply not recognised as closed,
87
+ * which is the conservative direction (a row this cannot parse is never silently treated
88
+ * as answered).
89
+ *
90
+ * @param {string} text — the document's raw content
91
+ * @returns {{ table: string, seq: string, reason: string }[]}
92
+ */
93
+ function parseOpenRows(text) {
94
+ // Through the ONE shared plan reader and the ONE classifier. Everything that is
95
+ // not a settled answer is OPEN — the halting direction: a gap, a cell that looks
96
+ // like a marker but is not one (`GAPX:`), a row with no Seq (nothing can cite or
97
+ // name it), a row that is not six cells. Red Team M115 run 6: a blank-Seq gap
98
+ // was silently dropped and the round cap never saw it.
99
+ const openRows = [];
100
+ for (const sec of walkSections(text)) {
101
+ const currentTable = tableName(sec.heading);
102
+ if (!currentTable) continue;
103
+ for (const row of parseRows(sec.lines, sec.startLine)) {
104
+ const seq = row.cells[0] && row.cells[0].trim() ? row.cells[0].trim() : "(blank Seq)";
105
+ if (row.width !== REQUIRED_COLUMN_COUNT) {
106
+ openRows.push({ table: currentTable, seq, source: `MALFORMED-ROW (${row.width} cells, expected ${REQUIRED_COLUMN_COUNT})`, line: row.line });
107
+ continue;
108
+ }
109
+ const source = row.cells[5];
110
+ const st = rowState(source);
111
+ if (seq === "(blank Seq)") { openRows.push({ table: currentTable, seq, source: `BLANK-SEQ (${source})`, line: row.line }); continue; }
112
+ if (st === "gap") openRows.push({ table: currentTable, seq, source, line: row.line });
113
+ else if (st === "malformed") openRows.push({ table: currentTable, seq, source: `UNKNOWN-MARKER (${source})`, line: row.line });
114
+ else if (st === "empty") openRows.push({ table: currentTable, seq, source: "BLANK-SOURCE", line: row.line });
115
+ }
116
+ }
117
+ return openRows;
118
+ }
119
+
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Round → ledger-cycle mapping (§6, D3-T1) — teaches the ledger what a round is
123
+ // ---------------------------------------------------------------------------
124
+
125
+ /**
126
+ * Build the ledger's `computeSignature` inputs for one enumeration round, running in
127
+ * `--after` mode (i.e. after the round's questions were asked and answered).
128
+ *
129
+ * What identifies a round, by contract: which rows are still open, and what the failing
130
+ * signature is with those rows unresolved. When the caller supplies an explicit
131
+ * assertion/surface/fileClass, that identity is used as given (an explicit input, not a
132
+ * fallback). Only when NONE is supplied does the signature come from the doc's own open-row
133
+ * set: two rounds with the SAME open rows are the same symptom; a round that closed even
134
+ * one row, or opened a different one, is a different symptom — mirroring the ledger's own
135
+ * variant-spawning rule that closing one thing while a new thing opens still does not repeat.
136
+ *
137
+ * @param {{ doc: string, openRows: {table:string, seq:string}[], assertion?: string,
138
+ * surface?: string, fileClass?: string }} opts
139
+ * @returns {{ assertion: string, surface: string, fileClass: string }}
140
+ */
141
+ function roundToCycleSignature({ doc, openRows, assertion, surface, fileClass }) {
142
+ let openRowSignature = "no-open-rows";
143
+ if (openRows.length > 0) {
144
+ const sortedIds = openRows.map((r) => `${r.table}::${r.seq}`).sort();
145
+ openRowSignature = sortedIds.join("|");
146
+ }
147
+
148
+ // The ledger keys on assertion + fileClass and DROPS surface on purpose
149
+ // (R-LOOP-1), so two plans with the same open-row set collided on one
150
+ // signature — plan B's first round halted on plan A's history (code-review
151
+ // M115 run 6). The doc is therefore part of the ASSERTION.
152
+ const docKey = path.resolve(String(doc || ""));
153
+ let effectiveAssertion = `${docKey}::${openRowSignature}`;
154
+ if (assertion) effectiveAssertion = `${docKey}::${assertion}`;
155
+
156
+ let effectiveSurface = doc;
157
+ if (surface) effectiveSurface = surface;
158
+
159
+ let effectiveFileClass = "testplan";
160
+ if (fileClass) effectiveFileClass = fileClass;
161
+
162
+ return { assertion: effectiveAssertion, surface: effectiveSurface, fileClass: effectiveFileClass };
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // The two caps
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Build the exitCode-64 halt envelope for bad/unreadable input. This is the "gate cannot
171
+ * decide" branch of contract §4 — a stated refusal, not a value standing in for a result.
172
+ * Named `halt` (not a generic helper name) so it reads, structurally and at a glance, as
173
+ * the same halt-shape as `deny`/`fail`/`block`/`abort` elsewhere in this codebase.
174
+ */
175
+ function halt(reason, extra) {
176
+ const envelope = {
177
+ ok: false,
178
+ exitCode: 64,
179
+ reason,
180
+ openRows: [],
181
+ halted: false,
182
+ haltReason: null,
183
+ violations: [],
184
+ };
185
+ Object.assign(envelope, extra);
186
+ return envelope;
187
+ }
188
+
189
+ /**
190
+ * Run both convergence caps for one round against one test-plan document.
191
+ *
192
+ * Runs no try/catch of its own — see "ONE HALT POINT" above. Any step here that cannot
193
+ * proceed (missing --doc, bad --round, an unreadable doc, a rejecting ledger call) returns
194
+ * an explicit `halt(...)` envelope; nothing here throws on the ordinary bad-input path, so
195
+ * `main`'s single outer catch exists only for a truly unanticipated error.
196
+ *
197
+ * @param {object} opts
198
+ * @param {string} opts.docPath
199
+ * @param {number} opts.round — 1-indexed round number just completed
200
+ * @param {string} [opts.milestone]
201
+ * @param {string} [opts.assertion]
202
+ * @param {string} [opts.surface]
203
+ * @param {string} [opts.fileClass]
204
+ * @param {string} [opts.projectDir]
205
+ * @returns {{ ok, exitCode, doc, round, openRows, halted, haltReason, violations }}
206
+ */
207
+ // Per-project record of which round last fed each signature to the ledger.
208
+ // Read fail-closed: corrupt → null (the caller HALTS), never an empty object.
209
+ function roundsRecordPath(projectDir) {
210
+ return path.join(path.resolve(projectDir || "."), ".gsd-t", "testplan-halt-rounds.json");
211
+ }
212
+ function readRoundsRecord(projectDir) {
213
+ const p = roundsRecordPath(projectDir);
214
+ if (!fs.existsSync(p)) return {};
215
+ let parsed;
216
+ try { parsed = JSON.parse(fs.readFileSync(p, "utf8")); } catch (_e) { return null; }
217
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
218
+ return parsed;
219
+ }
220
+ function writeRoundsRecord(projectDir, record) {
221
+ const p = roundsRecordPath(projectDir);
222
+ try {
223
+ fs.mkdirSync(path.dirname(p), { recursive: true });
224
+ const tmp = `${p}.${process.pid}.tmp`;
225
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2) + "\n");
226
+ fs.renameSync(tmp, p);
227
+ return { ok: true };
228
+ } catch (e) {
229
+ return { ok: false, error: e && e.message ? e.message : String(e) };
230
+ }
231
+ }
232
+
233
+ function checkConvergence(opts) {
234
+ const o = opts || {};
235
+ const docPath = o.docPath;
236
+ const round = o.round;
237
+ const milestone = o.milestone;
238
+ const assertion = o.assertion;
239
+ const surface = o.surface;
240
+ const fileClass = o.fileClass;
241
+ const projectDir = o.projectDir;
242
+
243
+ const docPathIsGiven = !!docPath && typeof docPath === "string";
244
+ if (!docPathIsGiven) {
245
+ return halt("--doc is required", { doc: docPath, round: round });
246
+ }
247
+
248
+ // A valueless `--round` arrives as boolean true and Number(true) is 1, which
249
+ // would pass a numeric check and defeat the three-round cap (code-review M115,
250
+ // important). Only a digit string (or an actual integer from a module caller)
251
+ // counts.
252
+ const roundStr = typeof round === "number" ? String(round) : round;
253
+ const roundIsPositiveInteger = typeof roundStr === "string" && /^[1-9][0-9]*$/.test(roundStr);
254
+ const roundNum = roundIsPositiveInteger ? Number(roundStr) : NaN;
255
+ if (!roundIsPositiveInteger) {
256
+ return halt("--round must be a positive integer", { doc: docPath, round: round });
257
+ }
258
+
259
+ const docExists = fs.existsSync(docPath);
260
+ if (!docExists) {
261
+ return halt(`cannot read doc: no such file: ${docPath}`, { doc: docPath, round: roundNum });
262
+ }
263
+ const stat = fs.statSync(docPath);
264
+ const docIsFile = stat.isFile();
265
+ if (!docIsFile) {
266
+ return halt(`cannot read doc: not a file: ${docPath}`, { doc: docPath, round: roundNum });
267
+ }
268
+
269
+ const text = fs.readFileSync(docPath, "utf8");
270
+ const openRows = parseOpenRows(text);
271
+
272
+ // --- Cap 1: enumeration-loop-cap-three -----------------------------------
273
+ const roundCapFired = roundNum >= ROUND_CAP && openRows.length > 0;
274
+ if (roundCapFired) {
275
+ const namedOpenRows = openRows
276
+ .map((r) => `${r.table} Seq ${r.seq} (${r.source})`)
277
+ .join("; ");
278
+ return {
279
+ ok: false,
280
+ exitCode: 4,
281
+ doc: docPath,
282
+ round: roundNum,
283
+ openRows: openRows,
284
+ halted: true,
285
+ haltReason:
286
+ `enumeration-loop-cap-three: ${roundNum} question rounds have passed and ` +
287
+ `${openRows.length} row(s) are still open. HALT — blocked-needs-human. ` +
288
+ `Still-open: ${namedOpenRows}`,
289
+ violations: openRows.map((r) => ({
290
+ kind: "enumeration-loop-cap-three",
291
+ detail: `${r.table} Seq ${r.seq}: ${r.source}`,
292
+ })),
293
+ };
294
+ }
295
+
296
+ // --- Cap 2: same-symptom-twice-halts (delegates to the loop ledger) ------
297
+ // Gated on open rows remaining: a "failure signature" presumes a failure. A round
298
+ // that closed everything has nothing to repeat, so it is not fed to the ledger at
299
+ // all — neither to check a repeat nor to accumulate a cycle count. This is what lets
300
+ // a converged plan pass through untouched no matter how many times it is re-checked.
301
+ const roundHasOpenRows = openRows.length > 0;
302
+ if (roundHasOpenRows) {
303
+ const sig = roundToCycleSignature({ doc: docPath, openRows: openRows, assertion: assertion, surface: surface, fileClass: fileClass });
304
+ // The ledger counts CALLS. A re-check of the same round is not a new round
305
+ // (code-review M115 run 6: re-running round 2 fired the repeat cap). This
306
+ // tool keeps its own record of which round last fed each signature; a
307
+ // repeat of that round re-uses the recorded count instead of appending.
308
+ const roundsRecord = readRoundsRecord(projectDir);
309
+ if (roundsRecord === null) {
310
+ return halt("testplan-halt rounds record is corrupt — fix or remove .gsd-t/testplan-halt-rounds.json (a silent reset would hide a loop)", { doc: docPath, round: roundNum });
311
+ }
312
+ const sigKey = `${sig.assertion}\u0000${sig.fileClass}`;
313
+ const prior = roundsRecord[sigKey];
314
+ let ledgerResult;
315
+ if (prior && prior.lastRound === roundNum) {
316
+ ledgerResult = { ok: true, cycles: prior.cycles, signature: prior.signature };
317
+ } else {
318
+ const ledgerCallOpts = { assertion: sig.assertion, surface: sig.surface, fileClass: sig.fileClass, projectDir: projectDir, milestone: milestone };
319
+ ledgerResult = loopLedger.appendCycle(ledgerCallOpts);
320
+ const ledgerAccepted = !!ledgerResult && ledgerResult.ok === true;
321
+ if (!ledgerAccepted) {
322
+ let ledgerError = "unknown error";
323
+ if (ledgerResult && ledgerResult.error) ledgerError = ledgerResult.error;
324
+ return halt(`loop-ledger rejected the cycle: ${ledgerError}`, { doc: docPath, round: roundNum });
325
+ }
326
+ roundsRecord[sigKey] = { lastRound: roundNum, cycles: ledgerResult.cycles, signature: ledgerResult.signature };
327
+ const written = writeRoundsRecord(projectDir, roundsRecord);
328
+ if (written.ok !== true) return halt(`could not write the rounds record: ${written.error}`, { doc: docPath, round: roundNum });
329
+ }
330
+
331
+ const symptomRepeated = ledgerResult.cycles >= SYMPTOM_REPEAT_CAP;
332
+ if (symptomRepeated) {
333
+ return {
334
+ ok: false,
335
+ exitCode: 4,
336
+ doc: docPath,
337
+ round: roundNum,
338
+ openRows: openRows,
339
+ halted: true,
340
+ haltReason:
341
+ `same-symptom-twice-halts: the same failure signature has appeared ` +
342
+ `${ledgerResult.cycles} times running. The belief behind the fix is wrong, not the ` +
343
+ `fix insufficient — re-examine the premise, do not attempt a third fix. ` +
344
+ `signature=${ledgerResult.signature}`,
345
+ violations: [
346
+ {
347
+ kind: "same-symptom-twice-halts",
348
+ detail: `signature ${ledgerResult.signature} repeated ${ledgerResult.cycles} times`,
349
+ },
350
+ ],
351
+ };
352
+ }
353
+ }
354
+
355
+ // --- Converged for this round: neither cap fired -------------------------
356
+ return {
357
+ ok: true,
358
+ exitCode: 0,
359
+ doc: docPath,
360
+ round: roundNum,
361
+ openRows: openRows,
362
+ halted: false,
363
+ haltReason: null,
364
+ violations: [],
365
+ };
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // module.exports (§4 module shape — testable before front-door-wiring registers it)
370
+ // ---------------------------------------------------------------------------
371
+
372
+ module.exports = { checkConvergence, parseOpenRows, roundToCycleSignature };
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // CLI entry point — `node bin/gsd-t-testplan-halt.cjs check ...`
376
+ // ---------------------------------------------------------------------------
377
+
378
+ function parseFlags(argv) {
379
+ const flags = {};
380
+ for (let i = 0; i < argv.length; i++) {
381
+ const a = argv[i];
382
+ if (a.startsWith("--")) {
383
+ const key = a.slice(2);
384
+ const next = argv[i + 1];
385
+ const hasValue = next !== undefined && !next.startsWith("--");
386
+ if (hasValue) {
387
+ flags[key] = next;
388
+ i++;
389
+ } else {
390
+ flags[key] = true;
391
+ }
392
+ }
393
+ }
394
+ return flags;
395
+ }
396
+
397
+ function dispatch(argv) {
398
+ const subcommand = argv[0];
399
+ const rest = argv.slice(1);
400
+ const isCheck = subcommand === "check";
401
+ if (!isCheck) {
402
+ let reason = "Subcommand required: check";
403
+ if (subcommand) reason = `Unknown subcommand: ${subcommand}. Valid: check`;
404
+ return { ok: false, exitCode: 64, reason: reason, violations: [] };
405
+ }
406
+ const flags = parseFlags(rest);
407
+ let projectDir = process.cwd();
408
+ if (flags.projectDir) projectDir = flags.projectDir;
409
+ return checkConvergence({
410
+ docPath: flags.doc,
411
+ round: flags.round,
412
+ milestone: flags.milestone,
413
+ assertion: flags.assertion,
414
+ surface: flags.surface,
415
+ fileClass: flags.fileClass,
416
+ projectDir: projectDir,
417
+ });
418
+ }
419
+
420
+ /** Write the one JSON envelope and stop the process with its exit code. */
421
+ function emitAndExit(res) {
422
+ process.stdout.write(JSON.stringify(res) + "\n");
423
+ process.exit(res.exitCode);
424
+ }
425
+
426
+ /**
427
+ * The single halt point for this file (see "ONE HALT POINT" above). One try/catch; the
428
+ * catch itself ends the process with the same `halt(...)` envelope shape — never lets an
429
+ * escaped error surface as a bare stack trace.
430
+ */
431
+ function main() {
432
+ try {
433
+ emitAndExit(dispatch(process.argv.slice(2)));
434
+ } catch (e) {
435
+ emitAndExit(halt(`gate-error: ${e && e.message}`, {}));
436
+ }
437
+ }
438
+
439
+ if (require.main === module) main();