@tekyzinc/gsd-t 4.7.10 → 4.8.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 CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [4.8.10] - 2026-06-22 (M91 — PseudoCode Source-of-Truth, merged M87+M88 — minor)
6
+
7
+ ### Added — the intention-first PseudoCode behavior map becomes the milestone source-of-truth
8
+
9
+ A `PseudoCode-[Title].md` authored BEFORE the build (a two-altitude behavior map: high-level approach → detailed per-step intention+mechanism) is now the milestone source-of-truth, enforced by deterministic gates. Eight file-disjoint domains across three waves, single-session build; full suite 2131 / 2127 pass / 0 fail / 4 skip.
10
+
11
+ **M87 core (Waves 1–2):**
12
+ - **Guard-map verify gate** (`bin/gsd-t-guard-map.cjs`) — parses the `[RULE …]` guard map (3 grammar forms, side-agnostic, derives `R-<SLUG>-<NN>` ids), gates a build→rule map (exit 0 / 4-names-the-rule / 64); wired FAIL-blocking into `gsd-t-verify.workflow.js` before the triad, with §7 doc+map discovery and distinct logged skips (`no-build-map` / `no-pseudocode-docs`).
13
+ - **Section-citation traceability** (`bin/gsd-t-traceability-gate.cjs` extended) — tasks cite `**PseudoCode-Section**: <Title>#<anchor>`; a section with no citing task is a structural coverage gap; slug-as-slug resolution, never substring. Plus a milestone→domains scoping fix (`--domains`; zero-prefix-match no longer falls back to all — exit 64 `milestone-scope-unresolved`) and a path-containment guard (`domain-path-escape`).
14
+ - **Two-altitude milestone flow** (`commands/gsd-t-milestone.md` + `gsd-t-phase.workflow.js`) — high-level approach sign-off precedes the detailed doc; competition solution-space probe shifts up to the approach altitude. Keep-or-supersede prompt protocol writes a `⚠ Divergence` flag on supersede.
15
+ - **Template + doc-ripple** — `templates/PseudoCode-spec.md` mold; `PseudoCode-[Title].md` joins the Living Documents ripple set (region-scoped A4 drift lint).
16
+
17
+ **M88 deterministic gates (Wave 3):**
18
+ - **Sign-off state** (`bin/gsd-t-milestone-state.cjs`) — `<!-- signed-off: … -->` marker + `isDefined(docs)` predicate; unsigned ≠ DEFINED; skip is a logged decision, never silent.
19
+ - **Build→map derivation** (`bin/gsd-t-guard-map-derive.cjs`) — mechanical evidence→map seam (imports the guard-map parser), end-to-end derive→gate test.
20
+ - **Triad-consumption seam** (`bin/gsd-t-rule-consume.cjs` + ingest directives in qa/red-team prompts) — deterministic seam-check, no live triad.
21
+ - **Divergence grammar** (`bin/gsd-t-divergence-grammar.cjs`) — `parseDivergence`/`formatDivergence` byte-stable round-trip + `countDivergences`.
22
+
23
+ Every gate meets the deterministic-gate bar: deterministic code, zero LLM judgment, structural-not-substring, killing test vs byte-verbatim fixtures, fail-closed. Verify: QA PASS (mutation-tested), Red Team GRUDGING-PASS (one path-traversal MEDIUM fixed in-verify; fence-awareness MEDIUM/LOW deferred to backlog #45, all fail-closed). Contract: `pseudocode-source-of-truth-contract.md` v1.1.5 STABLE.
24
+
25
+ ## [4.7.11] - 2026-06-22 (backlog #40 — deterministic domain archive+sweep — patch)
26
+
27
+ ### Fixed — complete-milestone now deterministically archives + sweeps a milestone's domains
28
+
29
+ `complete-milestone` Step 7 was prose-only ("archive domains → clear `.gsd-t/domains/`") with no enforcement, so a Level-3 autonomous agent skipped or partial-did it for ~30 milestones — accumulating 77 stale domain dirs that polluted the file-disjointness oracle (surfaced + manually pruned during M90). Root-cause fix:
30
+
31
+ - **`bin/gsd-t-archive-domains.cjs`** (new) + `gsd-t archive-domains` dispatch: copies an EXPLICIT set of the completing milestone's domain dirs → `<archive>/domains/<name>/`, then removes them from `.gsd-t/domains/`. Idempotent (re-running is a no-op), containment-guarded (refuses any name with path separators / dot-segments or resolving outside `.gsd-t/domains/` — [[feedback_destructive_path_ops_containment]]), fail-closed (a bad name aborts the whole batch, no partial sweep). Domains of other still-active milestones are left untouched (not a blanket wipe).
32
+ - `commands/gsd-t-complete-milestone.md` Step 7: prose → the deterministic helper call.
33
+ - Propagated via PROJECT_BIN_TOOLS + GLOBAL_BIN_TOOLS. 5 tests (sweep-exactly / idempotent / dry-run / containment / bad-input). Suite 2003 / 1999 pass / 0 fail.
34
+
5
35
  ## [4.7.10] - 2026-06-22 (M90 — The Unproven-Assumption Doctrine — minor)
6
36
 
7
37
  ### Added — a self-governing doctrine that stops the system building on unproven assumptions
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v4.7.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v4.8.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * gsd-t-archive-domains.cjs
3
+ *
4
+ * Backlog #40 — Deterministic archive+sweep of a completed milestone's domain dirs.
5
+ *
6
+ * complete-milestone Step 7 was prose-only ("archive domains → clear .gsd-t/domains/") with no
7
+ * enforcement, so a Level-3 agent skipped/partial-did the clear for ~30 milestones and 77 stale
8
+ * domain dirs accumulated, polluting the file-disjointness oracle. This helper makes the sweep a
9
+ * deterministic, idempotent, containment-guarded operation.
10
+ *
11
+ * Behavior — for an EXPLICIT set of the completing milestone's domains (NOT a blanket wipe; a
12
+ * later still-active milestone may legitimately have live domains, e.g. M90 completing while
13
+ * M87/M88 are queued):
14
+ * 1. Copy each domain dir → <archiveDir>/domains/<name>/ (skip if already archived — idempotent)
15
+ * 2. Remove it from .gsd-t/domains/ (skip if already gone — idempotent)
16
+ * Domains NOT in the set are left untouched.
17
+ *
18
+ * Containment guard ([[feedback_destructive_path_ops_containment]]): every removed path MUST resolve
19
+ * INSIDE .gsd-t/domains/ AND NOT equal it. Predicate: resolved.startsWith(domainsRoot + sep) &&
20
+ * resolved !== domainsRoot. Any violation aborts the whole run (fail-closed, no partial sweep).
21
+ *
22
+ * House style: { ok:true, ... } | { ok:false, error }; bad input → non-zero CLI exit; Node
23
+ * built-ins only; sync APIs; zero deps.
24
+ *
25
+ * Usage:
26
+ * gsd-t archive-domains --domains d-a,d-b,d-c --archive .gsd-t/milestones/mNN-name-DATE [--projectDir .] [--dry-run] [--json]
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ const DOMAINS_SUBPATH = path.join('.gsd-t', 'domains');
35
+
36
+ /**
37
+ * Archive + sweep the named domains.
38
+ *
39
+ * @param {object} opts
40
+ * @param {string[]} opts.domains — explicit domain dir names (the completing milestone's set)
41
+ * @param {string} opts.archiveDir — milestone archive dir (domains land under <archiveDir>/domains/)
42
+ * @param {string} [opts.projectDir] — project root (default cwd)
43
+ * @param {boolean} [opts.dryRun] — compute the plan, write nothing
44
+ * @returns {{ok:true, archived:string[], removed:string[], skipped:string[], dryRun:boolean}
45
+ * | {ok:false, error:string}}
46
+ */
47
+ function archiveDomains({ domains, archiveDir, projectDir, dryRun = false } = {}) {
48
+ if (!Array.isArray(domains) || domains.length === 0) {
49
+ return { ok: false, error: 'archive-domains requires a non-empty --domains list (the completing milestone\'s domain set)' };
50
+ }
51
+ if (!archiveDir || typeof archiveDir !== 'string' || !archiveDir.trim()) {
52
+ return { ok: false, error: 'archive-domains requires --archive <milestone archive dir>' };
53
+ }
54
+
55
+ const root = path.resolve(projectDir || process.cwd());
56
+ const domainsRoot = path.resolve(root, DOMAINS_SUBPATH);
57
+ const archiveRootAbs = path.isAbsolute(archiveDir) ? archiveDir : path.resolve(root, archiveDir);
58
+ const archiveDomainsDir = path.join(archiveRootAbs, 'domains');
59
+
60
+ // Validate every target up front (fail-closed: no partial sweep on a bad name).
61
+ const plan = [];
62
+ for (const name of domains) {
63
+ if (!name || typeof name !== 'string' || name.includes('/') || name.includes('\\') || name === '.' || name === '..') {
64
+ return { ok: false, error: `invalid domain name (no path separators / dot-segments allowed): ${JSON.stringify(name)}` };
65
+ }
66
+ const srcAbs = path.resolve(domainsRoot, name);
67
+ // CONTAINMENT GUARD: must resolve strictly INSIDE .gsd-t/domains/ (not outside, not equal).
68
+ if (!(srcAbs.startsWith(domainsRoot + path.sep) && srcAbs !== domainsRoot)) {
69
+ return { ok: false, error: `containment violation: ${name} resolves to ${srcAbs}, outside or equal to ${domainsRoot} — refusing` };
70
+ }
71
+ plan.push({ name, srcAbs, destAbs: path.join(archiveDomainsDir, name) });
72
+ }
73
+
74
+ const archived = [];
75
+ const removed = [];
76
+ const skipped = [];
77
+
78
+ for (const { name, srcAbs, destAbs } of plan) {
79
+ const srcExists = fs.existsSync(srcAbs);
80
+ const destExists = fs.existsSync(destAbs);
81
+
82
+ // IDEMPOTENT: nothing live and already archived → skip silently.
83
+ if (!srcExists && destExists) { skipped.push(name); continue; }
84
+ // Nothing live and not archived → the domain doesn't exist at all → skip (not an error;
85
+ // re-running after a manual prune is valid).
86
+ if (!srcExists && !destExists) { skipped.push(name); continue; }
87
+
88
+ if (dryRun) {
89
+ if (!destExists) archived.push(name);
90
+ removed.push(name);
91
+ continue;
92
+ }
93
+
94
+ // 1. Archive (copy) unless already archived.
95
+ if (!destExists) {
96
+ fs.mkdirSync(archiveDomainsDir, { recursive: true });
97
+ fs.cpSync(srcAbs, destAbs, { recursive: true });
98
+ archived.push(name);
99
+ }
100
+ // 2. Remove from live domains (containment re-checked at delete time).
101
+ if (!(srcAbs.startsWith(domainsRoot + path.sep) && srcAbs !== domainsRoot)) {
102
+ return { ok: false, error: `containment re-check failed at delete: ${srcAbs}` };
103
+ }
104
+ fs.rmSync(srcAbs, { recursive: true, force: true });
105
+ removed.push(name);
106
+ }
107
+
108
+ return { ok: true, archived, removed, skipped, dryRun };
109
+ }
110
+
111
+ module.exports = { archiveDomains };
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // CLI
115
+ // ---------------------------------------------------------------------------
116
+ if (require.main === module) {
117
+ const argv = process.argv.slice(2);
118
+ const flags = {};
119
+ for (let i = 0; i < argv.length; i++) {
120
+ const a = argv[i];
121
+ if (a === '--dry-run') flags.dryRun = true;
122
+ else if (a === '--json') flags.json = true;
123
+ else if (a.startsWith('--') && i + 1 < argv.length) flags[a.slice(2)] = argv[++i];
124
+ }
125
+
126
+ const domains = (flags.domains || '').split(',').map((s) => s.trim()).filter(Boolean);
127
+ const result = archiveDomains({
128
+ domains,
129
+ archiveDir: flags.archive,
130
+ projectDir: flags.projectDir || process.cwd(),
131
+ dryRun: !!flags.dryRun,
132
+ });
133
+
134
+ process.stdout.write(JSON.stringify(result) + '\n');
135
+ process.exit(result.ok ? 0 : 1);
136
+ }
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * gsd-t-divergence-grammar — M88 G4 (M91 Wave 3)
5
+ *
6
+ * The deterministic round-trip over the `⚠ Divergence` flag grammar.
7
+ *
8
+ * Contract: .gsd-t/contracts/pseudocode-source-of-truth-contract.md §4 — the
9
+ * SINGLE source of truth for the grammar. This module IMPLEMENTS that spec; it
10
+ * does NOT redefine it. The canonical §4 form is, EXACTLY:
11
+ *
12
+ * ⚠ Divergence: <RULE-ID or section> — supersedes shipped <what>. Reason: <user intention>.
13
+ *
14
+ * ORIGIN: M87's D3 (`keep-or-supersede-subagent.md`) ships the keep-or-supersede
15
+ * ASK and the prose WRITING of a `⚠ Divergence` flag on every supersede. That
16
+ * makes the flag a string IN the doc — but not yet a code-checkable artifact.
17
+ * This module closes that gap: a `⚠ Divergence` line round-trips
18
+ * format→parse→format BYTE-STABLE, a malformed flag FAILS (named, no throw), and
19
+ * the divergence COUNT over a doc is emitted as a checkable JSON integer that can
20
+ * feed D1's guard-map rule set.
21
+ *
22
+ * Hard rules (contract §4 + domain constraints):
23
+ * - Zero external runtime deps.
24
+ * - Never throws. A parse failure is a RETURNED, NAMED error, not an exception
25
+ * (fail-closed).
26
+ * - Pure. parseDivergence / formatDivergence / countDivergences have no I/O and
27
+ * no side effects (the CLI wrapper at the bottom is the only I/O).
28
+ * - Round-trip is byte-stable: formatDivergence(parseDivergence(line).value)
29
+ * === line for every valid flag.
30
+ * - Structural marker parse, never a prose substring scan
31
+ * (feedback_coverage_check_structural_not_substring): countDivergences only
32
+ * counts lines that PARSE as a well-formed §4 flag, not lines that merely
33
+ * CONTAIN the substring "⚠ Divergence".
34
+ *
35
+ * CLI:
36
+ * --parse "<line>" --json → { ok, value|error }
37
+ * --count <docPath> --json → { ok, count } (count is the checkable artifact)
38
+ *
39
+ * Exit: 0 ok · 4 parse failure / count error · 64 bad input.
40
+ */
41
+
42
+ // --- §4 grammar literals (the EXACT canonical form — single source is the contract) ---
43
+ const PREFIX = "⚠ Divergence: "; // "⚠ Divergence: "
44
+ const SEP_SUPERSEDES = " — supersedes shipped "; // " — supersedes shipped " (em-dash U+2014)
45
+ const SEP_REASON = ". Reason: ";
46
+ const SUFFIX = ".";
47
+
48
+ /**
49
+ * Parse a single line as a §4 `⚠ Divergence` flag.
50
+ *
51
+ * Structural, anchored parse — NOT a substring scan. The line MUST be EXACTLY
52
+ * `⚠ Divergence: <ref> — supersedes shipped <what>. Reason: <reason>.` with no
53
+ * leading/trailing slop. Each field MUST be non-empty.
54
+ *
55
+ * @param {string} line
56
+ * @returns {{ok: true, value: {ref: string, supersedes: string, reason: string}}
57
+ * | {ok: false, error: string}} never throws.
58
+ */
59
+ function parseDivergence(line) {
60
+ if (typeof line !== "string") {
61
+ return { ok: false, error: "not-a-string" };
62
+ }
63
+ // Anchored: must START with the prefix (no leading slop — structural, not substring).
64
+ if (!line.startsWith(PREFIX)) {
65
+ return { ok: false, error: "missing-prefix" };
66
+ }
67
+ // Must END with the trailing period.
68
+ if (!line.endsWith(SUFFIX)) {
69
+ return { ok: false, error: "missing-trailing-period" };
70
+ }
71
+
72
+ // ref ... " — supersedes shipped " ... <what>
73
+ const afterPrefix = line.slice(PREFIX.length);
74
+ const supIdx = afterPrefix.indexOf(SEP_SUPERSEDES);
75
+ if (supIdx === -1) {
76
+ return { ok: false, error: "missing-supersedes-clause" };
77
+ }
78
+ const ref = afterPrefix.slice(0, supIdx);
79
+ const afterSup = afterPrefix.slice(supIdx + SEP_SUPERSEDES.length);
80
+
81
+ // <what> ". Reason: " <reason> "."
82
+ // Use the LAST ". Reason: " as the boundary so a <what> that itself contains
83
+ // ". Reason: " can never steal the reason field (round-trip stays exact).
84
+ const reasonIdx = afterSup.lastIndexOf(SEP_REASON);
85
+ if (reasonIdx === -1) {
86
+ return { ok: false, error: "missing-reason-clause" };
87
+ }
88
+ const supersedes = afterSup.slice(0, reasonIdx);
89
+ // reason is everything between ". Reason: " and the final ".", which we have
90
+ // already confirmed via endsWith(SUFFIX).
91
+ const reasonWithDot = afterSup.slice(reasonIdx + SEP_REASON.length);
92
+ const reason = reasonWithDot.slice(0, reasonWithDot.length - SUFFIX.length);
93
+
94
+ // Every field MUST be non-empty (an empty field is a malformed flag).
95
+ if (ref.length === 0) return { ok: false, error: "empty-ref" };
96
+ if (supersedes.length === 0) return { ok: false, error: "empty-supersedes" };
97
+ if (reason.length === 0) return { ok: false, error: "empty-reason" };
98
+
99
+ return { ok: true, value: { ref, supersedes, reason } };
100
+ }
101
+
102
+ /**
103
+ * Format a {ref, supersedes, reason} triple back into the canonical §4 line.
104
+ *
105
+ * @param {{ref: string, supersedes: string, reason: string}} obj
106
+ * @returns {{ok: true, value: string} | {ok: false, error: string}} never throws.
107
+ */
108
+ function formatDivergence(obj) {
109
+ if (obj === null || typeof obj !== "object") {
110
+ return { ok: false, error: "not-an-object" };
111
+ }
112
+ const { ref, supersedes, reason } = obj;
113
+ if (typeof ref !== "string" || ref.length === 0) {
114
+ return { ok: false, error: "empty-ref" };
115
+ }
116
+ if (typeof supersedes !== "string" || supersedes.length === 0) {
117
+ return { ok: false, error: "empty-supersedes" };
118
+ }
119
+ if (typeof reason !== "string" || reason.length === 0) {
120
+ return { ok: false, error: "empty-reason" };
121
+ }
122
+ const value = PREFIX + ref + SEP_SUPERSEDES + supersedes + SEP_REASON + reason + SUFFIX;
123
+ return { ok: true, value };
124
+ }
125
+
126
+ /**
127
+ * Count the VALID `⚠ Divergence` flags in a document.
128
+ *
129
+ * Structural: a line is counted ONLY if it PARSES as a well-formed §4 flag — a
130
+ * line that merely contains the substring "⚠ Divergence" but is malformed is NOT
131
+ * counted (feedback_coverage_check_structural_not_substring). The returned count
132
+ * is a deterministic integer (the checkable artifact that can feed D1's rule map).
133
+ *
134
+ * @param {string} docText
135
+ * @returns {number} count of valid flags (0 on non-string input — never throws).
136
+ */
137
+ function countDivergences(docText) {
138
+ if (typeof docText !== "string") return 0;
139
+ // Split on any newline style; trailing/leading whitespace on a line is slop,
140
+ // so a flag line with surrounding whitespace will fail the anchored parse and
141
+ // not be counted (matches the byte-stable round-trip invariant).
142
+ const lines = docText.split(/\r\n|\r|\n/);
143
+ let count = 0;
144
+ for (const line of lines) {
145
+ if (parseDivergence(line).ok) count++;
146
+ }
147
+ return count;
148
+ }
149
+
150
+ // --------------------------------------------------------------------------
151
+ // CLI wrapper (the only I/O; the three functions above are pure).
152
+ // --------------------------------------------------------------------------
153
+
154
+ const HELP = [
155
+ "gsd-t-divergence-grammar — §4 ⚠ Divergence flag round-trip + count",
156
+ "",
157
+ "Usage:",
158
+ ' gsd-t-divergence-grammar --parse "<line>" --json',
159
+ " gsd-t-divergence-grammar --count <docPath> --json",
160
+ "",
161
+ "Exit: 0 ok · 4 parse failure / count error · 64 bad input.",
162
+ ].join("\n");
163
+
164
+ function parseArgs(argv) {
165
+ const o = { json: false, help: false, parse: null, count: null };
166
+ for (let i = 0; i < argv.length; i++) {
167
+ const a = argv[i];
168
+ if (a === "--help" || a === "-h") o.help = true;
169
+ else if (a === "--json") o.json = true;
170
+ else if (a === "--parse") o.parse = argv[++i];
171
+ else if (a === "--count") o.count = argv[++i];
172
+ }
173
+ return o;
174
+ }
175
+
176
+ function main() {
177
+ // Lazy require of fs ONLY inside the CLI path — the module export surface
178
+ // stays pure/dep-free.
179
+ const o = parseArgs(process.argv.slice(2));
180
+ if (o.help) {
181
+ process.stdout.write(HELP + "\n");
182
+ process.exit(0);
183
+ }
184
+
185
+ if (o.parse != null) {
186
+ const res = parseDivergence(o.parse);
187
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
188
+ process.exit(res.ok ? 0 : 4);
189
+ }
190
+
191
+ if (o.count != null) {
192
+ let text;
193
+ try {
194
+ text = require("fs").readFileSync(o.count, "utf8");
195
+ } catch (e) {
196
+ process.stdout.write(
197
+ JSON.stringify({ ok: false, error: "read-failed", path: o.count }, null, 2) + "\n"
198
+ );
199
+ process.exit(4);
200
+ }
201
+ const count = countDivergences(text);
202
+ // The count is a CHECKABLE JSON integer artifact, not prose.
203
+ process.stdout.write(JSON.stringify({ ok: true, count }, null, 2) + "\n");
204
+ process.exit(0);
205
+ }
206
+
207
+ process.stdout.write(JSON.stringify({ ok: false, error: "no-mode" }, null, 2) + "\n");
208
+ process.exit(64);
209
+ }
210
+
211
+ if (require.main === module) main();
212
+
213
+ module.exports = { parseDivergence, formatDivergence, countDivergences };
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * gsd-t-guard-map-derive — M88 G2 (map-derivation-seam)
5
+ *
6
+ * The mechanical build→rule-map DERIVATION seam. Where M87 D1
7
+ * (`bin/gsd-t-guard-map.cjs`) GATES a build→rule map, this module DERIVES that
8
+ * map from two inputs — instead of hand-authoring it:
9
+ *
10
+ * 1. a PseudoCode doc → the doc's derived RULE-ID set (via D1's parser,
11
+ * IMPORTED, never re-implemented — single source of truth for the grammar).
12
+ * 2. a build-evidence manifest → which test assertions / files back (or
13
+ * contradict) which RULE-ID(s).
14
+ *
15
+ * Output: `{ rules: { <id>: { backedBy:[<refs>], contradicted:bool } } }`
16
+ * keyed EXACTLY to the doc's derived RULE-ID set — EVERY doc rule present.
17
+ * A rule with no evidence → `backedBy:[]` (PRESENT, unbacked) — NOT an omitted
18
+ * key. Omitting it would re-open the map-side vacuous pass D1's gate closed
19
+ * (the gate iterates the DOC's id set; an absent key is treated as unbacked, but
20
+ * a faithful derivation must still emit every doc rule so the map is a complete,
21
+ * gate-ready artifact).
22
+ *
23
+ * Evidence manifest shape (the convention this seam defines):
24
+ * {
25
+ * "evidence": [
26
+ * { "ref": "test/foo.test.js:42", "backs": ["R-PAYPAL-03", "R-PAYPAL-05"] },
27
+ * { "ref": "test/bar.test.js:7", "contradicts": ["R-PAYPAL-07"] }
28
+ * ]
29
+ * }
30
+ * - `ref` (string, required): a stable evidence reference (file:line, file::name, …).
31
+ * - `backs` (string[], optional): RULE-IDs this evidence BACKS.
32
+ * - `contradicts` (string[], optional): RULE-IDs this evidence CONTRADICTS
33
+ * (build/test asserts the negation of the invariant). Any contradiction on a
34
+ * rule sets its `contradicted:true` in the derived map.
35
+ * An evidence entry that references a RULE-ID NOT in the doc's derived set is
36
+ * IGNORED (the doc is the source of truth; phantom refs never invent map keys).
37
+ *
38
+ * Hard engineering bar (mirror D1 + M83): zero deps (Node built-ins only), never
39
+ * throws (bad input → exitCode 64, never an uncaught throw), pure/read-only
40
+ * (writes nothing). Deterministic — same doc bytes + same manifest → same map.
41
+ *
42
+ * Input: --doc <PseudoCode-[Title].md> --evidence <manifest.json> [--json]
43
+ * Output: the derived map JSON ({ rules: {...} }) on stdout.
44
+ * Exit: 0 derivation succeeded · 64 bad input (unreadable doc, bad manifest,
45
+ * doc parse failure).
46
+ */
47
+
48
+ const fs = require("node:fs");
49
+
50
+ // IMPORT D1's parser + RULE-ID derivation — the single source of truth for the
51
+ // `[RULE]` grammar. We consume `parseRules(md, docPath)`; we do NOT re-implement
52
+ // the grammar (a re-impl would drift from the gate that reads our output).
53
+ const { parseRules } = require("./gsd-t-guard-map.cjs");
54
+
55
+ // ─── derivation (pure) ─────────────────────────────────────────────────────
56
+
57
+ /**
58
+ * Derive the build→rule map from a doc's derived RULE-ID set + an evidence
59
+ * manifest. Doc-keyed: EVERY doc rule is emitted; an unbacked rule is
60
+ * `backedBy:[]` present. Phantom evidence refs (RULE-IDs not in the doc) are
61
+ * ignored. Pure — no I/O, never throws.
62
+ *
63
+ * @param {Array<{id:string}>} rules - parseRules().rules (doc-derived id set)
64
+ * @param {{evidence?: Array<{ref?:string, backs?:string[], contradicts?:string[]}>}} manifest
65
+ * @returns {{ rules: Object<string, {backedBy:string[], contradicted:boolean}> }}
66
+ */
67
+ function deriveMap(rules, manifest) {
68
+ // Seed every doc-derived id with an empty, unbacked, not-contradicted entry.
69
+ // Iterating the DOC's id set (not the manifest) is what preserves doc-keyed
70
+ // non-vacuity: a rule with zero evidence is PRESENT as backedBy:[].
71
+ const out = { rules: {} };
72
+ const docIds = new Set();
73
+ for (const r of Array.isArray(rules) ? rules : []) {
74
+ if (r && typeof r.id === "string") {
75
+ docIds.add(r.id);
76
+ if (!out.rules[r.id]) out.rules[r.id] = { backedBy: [], contradicted: false };
77
+ }
78
+ }
79
+
80
+ const evidence = (manifest && Array.isArray(manifest.evidence)) ? manifest.evidence : [];
81
+ for (const item of evidence) {
82
+ if (!item || typeof item !== "object") continue;
83
+ const ref = typeof item.ref === "string" ? item.ref : null;
84
+ const backs = Array.isArray(item.backs) ? item.backs : [];
85
+ const contradicts = Array.isArray(item.contradicts) ? item.contradicts : [];
86
+
87
+ for (const id of backs) {
88
+ // Only attach to a DOC-derived id (doc is the source of truth; phantom
89
+ // ids never invent a key). A backing without a `ref` is meaningless — skip.
90
+ if (typeof id === "string" && docIds.has(id) && ref) {
91
+ const entry = out.rules[id];
92
+ if (!entry.backedBy.includes(ref)) entry.backedBy.push(ref);
93
+ }
94
+ }
95
+ for (const id of contradicts) {
96
+ if (typeof id === "string" && docIds.has(id)) {
97
+ out.rules[id].contradicted = true;
98
+ }
99
+ }
100
+ }
101
+
102
+ return out;
103
+ }
104
+
105
+ // ─── driver ────────────────────────────────────────────────────────────────
106
+
107
+ /**
108
+ * Run the derivation over a doc + evidence manifest. Never throws — bad input
109
+ * returns an envelope with exitCode 64. On success, `map` is the derived
110
+ * build→rule map (gate-ready, consumable by gsd-t-guard-map.cjs unchanged).
111
+ *
112
+ * @param {{doc?:string, evidence?:string}} o
113
+ * @returns {{ ok:boolean, exitCode:number, map?:object, derivedIds?:string[], ... }}
114
+ */
115
+ function runDerive(o) {
116
+ const { doc, evidence } = (o && typeof o === "object") ? o : {};
117
+ if (!doc || !evidence || typeof doc !== "string" || typeof evidence !== "string") {
118
+ return { ok: false, exitCode: 64, reason: "missing --doc and/or --evidence", doc: doc || null, evidence: evidence || null };
119
+ }
120
+
121
+ let md;
122
+ try {
123
+ md = fs.readFileSync(doc, "utf8");
124
+ } catch (e) {
125
+ return { ok: false, exitCode: 64, reason: `cannot read doc: ${e && e.message}`, doc };
126
+ }
127
+
128
+ let manifest;
129
+ try {
130
+ manifest = JSON.parse(fs.readFileSync(evidence, "utf8"));
131
+ } catch (e) {
132
+ return { ok: false, exitCode: 64, reason: `cannot read/parse evidence JSON: ${e && e.message}`, evidence };
133
+ }
134
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
135
+ return { ok: false, exitCode: 64, reason: "evidence JSON must be an object with an `evidence` array", evidence };
136
+ }
137
+
138
+ const { rules, parseErrors } = parseRules(md, doc);
139
+
140
+ // A doc parse failure (empty-invariant rule) is bad input — the same severity
141
+ // D1's gate assigns it. We cannot derive a faithful map from a malformed doc.
142
+ if (parseErrors.length > 0) {
143
+ return {
144
+ ok: false,
145
+ exitCode: 64,
146
+ reason: `doc parse failure: ${parseErrors.length} rule line(s) yielded an empty invariant`,
147
+ parseErrors,
148
+ doc,
149
+ };
150
+ }
151
+
152
+ const map = deriveMap(rules, manifest);
153
+ const derivedIds = rules.map((r) => r.id);
154
+
155
+ return { ok: true, exitCode: 0, doc, evidence, derivedIds, ruleCount: derivedIds.length, map };
156
+ }
157
+
158
+ // ─── CLI ─────────────────────────────────────────────────────────────────
159
+
160
+ function parseArgs(argv) {
161
+ const o = { doc: null, evidence: null, help: false };
162
+ for (let i = 0; i < argv.length; i++) {
163
+ const a = argv[i];
164
+ if (a === "--help" || a === "-h") o.help = true;
165
+ else if (a === "--doc") o.doc = argv[++i];
166
+ else if (a === "--evidence") o.evidence = argv[++i];
167
+ else if (a === "--json") {/* default output is JSON */}
168
+ }
169
+ return o;
170
+ }
171
+
172
+ const HELP = `Usage: gsd-t guard-map-derive --doc <PseudoCode-[Title].md> --evidence <manifest.json> [--json]
173
+
174
+ The M88 build→rule-map DERIVATION seam. Parses every [RULE] from a PseudoCode
175
+ doc (via the M87 guard-map parser — single source of truth), then DERIVES the
176
+ build→rule map gsd-t-guard-map.cjs gates, attaching each evidence ref to the
177
+ RULE-ID(s) it backs/contradicts. The map is keyed EXACTLY to the doc's derived
178
+ id set — every doc rule present; an unbacked rule is backedBy:[] (never omitted).
179
+ The derived map prints to stdout and is valid --map input to the gate unchanged.
180
+
181
+ --doc PATH the PseudoCode-[Title].md guard-map doc.
182
+ --evidence PATH the build-evidence manifest:
183
+ { "evidence": [ { "ref": "test/x.test.js:42",
184
+ "backs": ["R-FOO-01"],
185
+ "contradicts": ["R-FOO-02"] } ] }
186
+
187
+ Exit: 0 derivation succeeded (map on stdout) · 64 bad input (unreadable doc,
188
+ bad manifest, or doc parse failure).`;
189
+
190
+ function main() {
191
+ const o = parseArgs(process.argv.slice(2));
192
+ if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
193
+ let res;
194
+ try {
195
+ res = runDerive(o);
196
+ } catch (e) {
197
+ // Defense in depth — runDerive is written never to throw, but the contract
198
+ // mandates the module never throws, so bad input maps to 64 even here.
199
+ res = { ok: false, exitCode: 64, reason: `derive-error: ${e && e.message}` };
200
+ }
201
+ // On success print the derived MAP (the artifact); on failure print the
202
+ // diagnostic envelope. Either way, valid JSON to stdout.
203
+ const payload = res.ok ? res.map : res;
204
+ process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
205
+ process.exit(res.exitCode);
206
+ }
207
+
208
+ if (require.main === module) main();
209
+
210
+ module.exports = { runDerive, deriveMap };