@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.
- package/CHANGELOG.md +47 -0
- package/README.md +2 -1
- package/bin/gsd-t-fallback-detect.cjs +31 -1
- package/bin/gsd-t-file-disjointness.cjs +11 -0
- package/bin/gsd-t-graph-use-gate.cjs +5 -2
- package/bin/gsd-t-logging-envelope-check.cjs +104 -13
- package/bin/gsd-t-migrate-logging.cjs +31 -5
- package/bin/gsd-t-testplan-halt.cjs +439 -0
- package/bin/gsd-t-testplan-lint.cjs +437 -0
- package/bin/gsd-t-testplan-rows.cjs +114 -0
- package/bin/gsd-t-traceability-gate.cjs +154 -21
- package/bin/gsd-t.js +42 -0
- package/commands/cpua.md +20 -2
- package/commands/gsd-t-help.md +9 -0
- package/commands/gsd-t-migrate-logging.md +1 -0
- package/commands/gsd-t-test-plan.md +85 -0
- package/commands/gsd.md +2 -1
- package/docs/requirements.md +18 -0
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +2 -0
- package/templates/TestPlan-spec.md +78 -0
- package/templates/demo-videos/scripts/walkthrough-mux.mjs +8 -2
- package/templates/demo-videos/scripts/walkthrough-normalise.mjs +8 -2
- package/templates/demo-videos/scripts/walkthrough-trim.mjs +19 -6
- package/templates/demo-videos/scripts/walkthrough-voice-check.mjs +8 -2
- package/templates/demo-videos/scripts/walkthrough-voice-ensure.mjs +6 -2
- package/templates/demo-videos/scripts/walkthrough-voice.mjs +30 -50
- package/templates/prompts/test-plan-enumerator-subagent.md +230 -0
- package/templates/prompts/test-plan-evidence-classifier.md +106 -0
- package/templates/workflows/gsd-t-verify.workflow.js +126 -0
|
@@ -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 };
|