@tekyzinc/gsd-t 4.7.11 → 4.9.11

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,236 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * gsd-t-milestone-state — M88 G1 (m88-signoff-state)
5
+ *
6
+ * A machine-checkable milestone sign-off STATE + the `isDefined(milestone)`
7
+ * predicate. Today "DEFINED" is prose an LLM writes into `.gsd-t/progress.md` —
8
+ * NOT a code-readable marker. This module reads a CONCRETE, structured sign-off
9
+ * marker at the head of the detailed `PseudoCode-[Title].md` and decides, with
10
+ * ZERO LLM judgment, whether a milestone's pseudocode set is signed off.
11
+ *
12
+ * The marker is a STRUCTURED front-matter HTML comment at the doc head:
13
+ *
14
+ * <!-- signed-off: <ISO-date> by <author> -->
15
+ *
16
+ * It is PARSED by its structural form (regex on the marker shape), never by a
17
+ * prose substring scan (`feedback_coverage_check_structural_not_substring`).
18
+ * An absent OR malformed marker is FAIL-CLOSED → `signed:false` (never a crash,
19
+ * never a vacuous pass). A skip is NEVER a silent default-off — `recordSkip`
20
+ * emits an ASSERTABLE, greppable logged decision (`feedback_no_silent_degradation`).
21
+ *
22
+ * Contract: .gsd-t/contracts/pseudocode-source-of-truth-contract.md §1
23
+ * (doc anatomy — the two altitudes; High-Level signed off FIRST, then Detailed).
24
+ *
25
+ * ── INTEGRATION NOTE (for a later doc-ripple pass — NOT done here) ──────────
26
+ * This domain ships the predicate ONLY. It does NOT edit `commands/gsd-t-milestone.md`
27
+ * (D3-owned). When that doc-ripple pass runs, the milestone command's "DEFINED"
28
+ * emission should call `isDefined(<the milestone's detailed PseudoCode doc paths>)`
29
+ * and refuse to flip a milestone to DEFINED until the predicate returns true —
30
+ * replacing the prose "DEFINED" an LLM writes into progress.md with this
31
+ * code-readable gate. A deliberate skip MUST route through `recordSkip` so the
32
+ * decision is logged + greppable, never a silent default-off.
33
+ *
34
+ * Hard engineering bar (mirror M87 D1 guard-map): zero deps (Node built-ins
35
+ * only), never throws (bad input → exitCode 64, never an uncaught throw),
36
+ * pure/read-only (writes nothing). Deterministic — ZERO LLM judgment.
37
+ *
38
+ * CLI:
39
+ * --doc <path> [--json] → readSignoff over one detailed doc
40
+ * --docs <comma-list> [--json] → isDefined over a doc set
41
+ * --skip <milestone> --reason <text> → recordSkip (assertable decision)
42
+ *
43
+ * Exit: 0 signed / defined / skip-recorded · 4 unsigned / NOT-defined · 64 bad input.
44
+ */
45
+
46
+ const fs = require("node:fs");
47
+
48
+ // ─── sign-off marker grammar (structural, NOT prose substring) ─────────────
49
+ //
50
+ // `<!-- signed-off: <ISO-date> by <author> -->`
51
+ // <ISO-date> : YYYY-MM-DD, optionally with a time/zone tail (ISO 8601-ish).
52
+ // <author> : a non-empty author token/phrase (everything up to the `-->`).
53
+ //
54
+ // Anchored to the marker FORM, not to a "signed-off" substring anywhere in
55
+ // prose: the keyword must sit inside an `<!-- ... -->` HTML comment AND be
56
+ // followed by a structurally-valid date + ` by ` + non-empty author. A bare
57
+ // `<!-- signed-off: -->` (no date) does NOT match → fail-closed (unsigned).
58
+ const SIGNOFF_RE =
59
+ /<!--\s*signed-off:\s*(\d{4}-\d{2}-\d{2}(?:[T ][0-9:.+\-Z]*)?)\s+by\s+(\S.*?)\s*-->/i;
60
+
61
+ /**
62
+ * Parse the sign-off marker from a detailed doc's text. Structural-only.
63
+ * @param {string} md - the doc text (may be null/undefined → unsigned).
64
+ * @returns {{signed: boolean, date: (string|null), author: (string|null)}}
65
+ */
66
+ function parseSignoff(md) {
67
+ const text = md == null ? "" : String(md);
68
+ const m = text.match(SIGNOFF_RE);
69
+ if (!m) return { signed: false, date: null, author: null };
70
+ const date = (m[1] || "").trim();
71
+ const author = (m[2] || "").trim();
72
+ // Defense in depth: both date AND a non-empty author are required. An empty
73
+ // author (regex can't capture one) means malformed → fail-closed.
74
+ if (!date || !author) return { signed: false, date: null, author: null };
75
+ return { signed: true, date, author };
76
+ }
77
+
78
+ /**
79
+ * Read the sign-off marker from a detailed PseudoCode doc on disk.
80
+ * Never throws — an unreadable/missing path is fail-closed (unsigned).
81
+ * @param {string} docPath
82
+ * @returns {{signed: boolean, date: (string|null), author: (string|null), doc: (string|null), error?: string}}
83
+ */
84
+ function readSignoff(docPath) {
85
+ if (!docPath || typeof docPath !== "string") {
86
+ return { signed: false, date: null, author: null, doc: null, error: "no doc path" };
87
+ }
88
+ let md;
89
+ try {
90
+ md = fs.readFileSync(docPath, "utf8");
91
+ } catch (e) {
92
+ // Missing / unreadable doc = NOT signed (fail-closed), never a throw.
93
+ return { signed: false, date: null, author: null, doc: docPath, error: `cannot read doc: ${e && e.message}` };
94
+ }
95
+ const parsed = parseSignoff(md);
96
+ return { ...parsed, doc: docPath };
97
+ }
98
+
99
+ /**
100
+ * The `isDefined` predicate. A milestone's pseudocode is DEFINED IFF EVERY
101
+ * detailed doc in the set carries a valid sign-off marker. ANY unsigned (or
102
+ * absent/malformed) doc → NOT-DEFINED (false). An EMPTY set is NOT defined
103
+ * (there is nothing signed off to define it — fail-closed, never vacuous-true).
104
+ * @param {string[]} milestoneDocs - detailed PseudoCode doc paths.
105
+ * @returns {{defined: boolean, total: number, signed: number, docs: Array, unsigned: string[]}}
106
+ */
107
+ function isDefined(milestoneDocs) {
108
+ const docs = Array.isArray(milestoneDocs) ? milestoneDocs : [];
109
+ const results = docs.map((d) => readSignoff(d));
110
+ const unsigned = results.filter((r) => !r.signed).map((r) => r.doc);
111
+ const signedCount = results.length - unsigned.length;
112
+ // Empty set → NOT defined (no signed docs ⇒ nothing defines the milestone).
113
+ const defined = results.length > 0 && unsigned.length === 0;
114
+ return {
115
+ defined,
116
+ total: results.length,
117
+ signed: signedCount,
118
+ docs: results.map((r) => ({ doc: r.doc, signed: r.signed, date: r.date, author: r.author })),
119
+ unsigned,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Record a deliberate skip of the sign-off gate as an ASSERTABLE, greppable
125
+ * logged decision — NEVER a silent default-off. Returns a structured object
126
+ * whose `decision` line is a single greppable string containing the milestone
127
+ * AND the reason (so a test / grep can prove the skip is observable).
128
+ * @param {string} milestone - the milestone id being skipped.
129
+ * @param {string} reason - the human-supplied justification (required).
130
+ * @returns {{ok: boolean, kind: "milestone-signoff-skip", milestone: (string|null), reason: (string|null), decision: string}}
131
+ */
132
+ function recordSkip(milestone, reason) {
133
+ const ms = milestone == null ? "" : String(milestone).trim();
134
+ const rs = reason == null ? "" : String(reason).trim();
135
+ // A skip with no reason is itself fail-closed: still emitted + assertable, but
136
+ // flagged ok:false so the gate cannot treat a reasonless skip as a clean pass.
137
+ const ok = ms.length > 0 && rs.length > 0;
138
+ const decision = `[GSD-T SIGNOFF-SKIP] milestone=${ms || "<none>"} reason=${rs || "<none>"}`;
139
+ return { ok, kind: "milestone-signoff-skip", milestone: ms || null, reason: rs || null, decision };
140
+ }
141
+
142
+ // ─── CLI ────────────────────────────────────────────────────────────────────
143
+
144
+ function parseArgs(argv) {
145
+ const o = { doc: null, docs: null, skip: null, reason: null, help: false };
146
+ for (let i = 0; i < argv.length; i++) {
147
+ const a = argv[i];
148
+ if (a === "--help" || a === "-h") o.help = true;
149
+ else if (a === "--doc") o.doc = argv[++i];
150
+ else if (a === "--docs") o.docs = argv[++i];
151
+ else if (a === "--skip") o.skip = argv[++i];
152
+ else if (a === "--reason") o.reason = argv[++i];
153
+ else if (a === "--json") {/* default output is JSON */}
154
+ }
155
+ return o;
156
+ }
157
+
158
+ const HELP = `Usage:
159
+ gsd-t milestone-state --doc <PseudoCode-[Title].md> [--json] read one doc's sign-off
160
+ gsd-t milestone-state --docs <p1,p2,...> [--json] isDefined over a set
161
+ gsd-t milestone-state --skip <milestone> --reason <text> record an assertable skip
162
+
163
+ The M88 milestone sign-off state. A milestone is NOT DEFINED until every detailed
164
+ PseudoCode-[Title].md carries a structured sign-off marker:
165
+
166
+ <!-- signed-off: <ISO-date> by <author> -->
167
+
168
+ Parsing is structural (marker form), not a prose substring scan. An absent or
169
+ malformed marker is fail-closed (unsigned). A skip is a LOGGED, greppable
170
+ decision, never a silent default-off.
171
+
172
+ Exit: 0 signed/defined/skip-recorded · 4 unsigned/NOT-defined · 64 bad input.`;
173
+
174
+ /**
175
+ * Run the CLI for one parsed arg set. Never throws — bad input → exitCode 64.
176
+ * @param {object} o - parsed args.
177
+ * @returns {{ok: boolean, exitCode: number, ...}}
178
+ */
179
+ function runCli(o) {
180
+ const opts = o && typeof o === "object" ? o : {};
181
+
182
+ // recordSkip mode
183
+ if (opts.skip != null) {
184
+ const res = recordSkip(opts.skip, opts.reason);
185
+ // A reasonless skip is bad input (64) — the decision is still emitted so it
186
+ // stays assertable, but the gate must not treat it as a clean exit.
187
+ return { ...res, exitCode: res.ok ? 0 : 64 };
188
+ }
189
+
190
+ // isDefined mode (a set of docs)
191
+ if (opts.docs != null) {
192
+ const list = String(opts.docs)
193
+ .split(",")
194
+ .map((s) => s.trim())
195
+ .filter((s) => s.length > 0);
196
+ if (list.length === 0) {
197
+ return { ok: false, exitCode: 64, reason: "--docs given but resolved to an empty list" };
198
+ }
199
+ const res = isDefined(list);
200
+ return { ok: res.defined, exitCode: res.defined ? 0 : 4, ...res };
201
+ }
202
+
203
+ // readSignoff mode (one doc)
204
+ if (opts.doc != null) {
205
+ const res = readSignoff(opts.doc);
206
+ return { ok: res.signed, exitCode: res.signed ? 0 : 4, ...res };
207
+ }
208
+
209
+ return { ok: false, exitCode: 64, reason: "no mode: pass --doc, --docs, or --skip" };
210
+ }
211
+
212
+ function main() {
213
+ const o = parseArgs(process.argv.slice(2));
214
+ if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
215
+ let res;
216
+ try {
217
+ res = runCli(o);
218
+ } catch (e) {
219
+ // Defense in depth — runCli is written never to throw; the contract mandates
220
+ // the module never throws, so any unexpected error maps to 64.
221
+ res = { ok: false, exitCode: 64, reason: `state-error: ${e && e.message}` };
222
+ }
223
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
224
+ process.exit(res.exitCode);
225
+ }
226
+
227
+ if (require.main === module) main();
228
+
229
+ module.exports = {
230
+ readSignoff,
231
+ parseSignoff,
232
+ isDefined,
233
+ recordSkip,
234
+ runCli,
235
+ _internal: { SIGNOFF_RE },
236
+ };
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * gsd-t-rule-consume — M88 G3 (triad-consumption seam)
5
+ *
6
+ * The DETERMINISTIC half of the A5 reframe. A5 once read "the `[RULE]` set is
7
+ * consumed by verify's QA + Red Team frames" — a non-deterministic LIVE-triad
8
+ * observation. This module reframes the CONSUMER side as a pure, deterministic
9
+ * seam: given the guard-map JSON (`{rules:{<id>:...}}` from gsd-t-guard-map.cjs),
10
+ * it surfaces the RULE-ID set that each verify frame must ingest.
11
+ *
12
+ * The seam invariant (A5): EVERY rule id in the map surfaces in BOTH frames —
13
+ * - `qa` → the QA contract-compliance frame (each rule = a compliance assertion)
14
+ * - `redTeam` → the Red Team attack-surface frame (each rule = an invariant to attack)
15
+ * A rule that reaches the map but is DROPPED from either frame breaks the seam
16
+ * (the rule set failed to reach the whole triad). The killing test asserts this.
17
+ *
18
+ * Hard engineering bar (mirror gsd-t-guard-map.cjs): zero deps (Node built-ins
19
+ * only), NEVER throws, pure/read-only (writes nothing). Fail-closed: an
20
+ * empty/malformed map yields EMPTY frame sets (`{qa:[], redTeam:[]}`), never a
21
+ * throw and never a phantom id.
22
+ *
23
+ * Input: --map <path> [--json]
24
+ * Output: JSON envelope { ok, exitCode, qa, redTeam, ... }.
25
+ * Exit: 0 ok (sets surfaced, possibly empty) · 64 bad input (cannot read map).
26
+ */
27
+
28
+ const fs = require("node:fs");
29
+
30
+ /**
31
+ * Extract the ordered, de-duplicated RULE-ID set from a guard-map object.
32
+ * Pure. Never throws. A non-object, a missing/non-object `rules`, or a
33
+ * malformed shape → empty array (fail-closed — no phantom ids).
34
+ * @param {*} map - parsed guard-map object ({ rules: { <id>: {...} } })
35
+ * @returns {string[]} the rule ids in stable insertion order, de-duplicated
36
+ */
37
+ function ruleIds(map) {
38
+ if (!map || typeof map !== "object") return [];
39
+ const rules = map.rules;
40
+ if (!rules || typeof rules !== "object") return [];
41
+ const seen = new Set();
42
+ const ids = [];
43
+ for (const key of Object.keys(rules)) {
44
+ const id = String(key);
45
+ if (id.length === 0 || seen.has(id)) continue;
46
+ seen.add(id);
47
+ ids.push(id);
48
+ }
49
+ return ids;
50
+ }
51
+
52
+ /**
53
+ * Surface the rule-id set into BOTH verify frames. Pure. The seam: every id
54
+ * appears in `qa` AND `redTeam` — same set, two frame views. A dropped id in
55
+ * either frame is the failure the seam-check catches.
56
+ * @param {*} map - parsed guard-map object
57
+ * @returns {{ qa: string[], redTeam: string[] }}
58
+ */
59
+ function consume(map) {
60
+ const ids = ruleIds(map);
61
+ // Independent copies so a mutation of one frame can never silently alias the other.
62
+ return { qa: ids.slice(), redTeam: ids.slice() };
63
+ }
64
+
65
+ // ─── driver ────────────────────────────────────────────────────────────────
66
+
67
+ /**
68
+ * Run the consumer over a map path. Never throws — bad input → exitCode 64
69
+ * with EMPTY frame sets (fail-closed).
70
+ * @param {{ map: string|null }} o
71
+ * @returns {{ ok, exitCode, qa, redTeam, ... }}
72
+ */
73
+ function runConsume(o) {
74
+ const { map } = (o && typeof o === "object") ? o : {};
75
+ if (!map || typeof map !== "string") {
76
+ return { ok: false, exitCode: 64, reason: "missing --map", qa: [], redTeam: [], map: map || null };
77
+ }
78
+
79
+ let mapObj;
80
+ try {
81
+ mapObj = JSON.parse(fs.readFileSync(map, "utf8"));
82
+ } catch (e) {
83
+ // Fail-closed: unreadable / unparseable map → empty sets, no throw.
84
+ return { ok: false, exitCode: 64, reason: `cannot read/parse map JSON: ${e && e.message}`, qa: [], redTeam: [], map };
85
+ }
86
+
87
+ const frames = consume(mapObj);
88
+ return {
89
+ ok: true,
90
+ exitCode: 0,
91
+ map,
92
+ ruleCount: frames.qa.length,
93
+ qa: frames.qa,
94
+ redTeam: frames.redTeam,
95
+ };
96
+ }
97
+
98
+ // ─── CLI ─────────────────────────────────────────────────────────────────
99
+
100
+ function parseArgs(argv) {
101
+ const o = { map: null, help: false };
102
+ for (let i = 0; i < argv.length; i++) {
103
+ const a = argv[i];
104
+ if (a === "--help" || a === "-h") o.help = true;
105
+ else if (a === "--map") o.map = argv[++i];
106
+ else if (a === "--json") {/* default output is JSON */}
107
+ }
108
+ return o;
109
+ }
110
+
111
+ const HELP = `Usage: gsd-t rule-consume --map <guard-map.json> [--json]
112
+
113
+ The M88 G3 triad-consumption seam (A5). Reads the guard-map JSON and surfaces
114
+ the RULE-ID set into both verify frames — every id appears in BOTH the QA
115
+ contract-compliance frame and the Red Team attack-surface frame. A dropped id
116
+ breaks the seam (caught by test/m88-triad-consumption-seam.test.js).
117
+ Deterministic, zero LLM judgment, fail-closed (empty/malformed map → empty sets).
118
+
119
+ --map PATH the guard-map JSON ({ "rules": { "<id>": {...} } }).
120
+
121
+ Output: { "ok", "exitCode", "qa": [...ids], "redTeam": [...ids] }.
122
+ Exit: 0 ok (sets surfaced) · 64 bad input (map unreadable → empty sets).`;
123
+
124
+ function main() {
125
+ const o = parseArgs(process.argv.slice(2));
126
+ if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
127
+ let res;
128
+ try {
129
+ res = runConsume(o);
130
+ } catch (e) {
131
+ // Defense in depth — runConsume never throws, but the contract mandates the
132
+ // module never throws, so any escape maps to 64 with empty sets.
133
+ res = { ok: false, exitCode: 64, reason: `consume-error: ${e && e.message}`, qa: [], redTeam: [] };
134
+ }
135
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
136
+ process.exit(res.exitCode);
137
+ }
138
+
139
+ if (require.main === module) main();
140
+
141
+ module.exports = { runConsume, consume, ruleIds };
@@ -0,0 +1,255 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * gsd-t-shrink-metric — M92 D2 (shrink-verdict, the keystone move)
5
+ *
6
+ * A deterministic leanness readout for a change. Today verify's `overallVerdict`
7
+ * is a pure AND of additive gates — the schema literally CANNOT say "we made it
8
+ * smaller." This module measures the change's net size from a `git diff --numstat`
9
+ * envelope so a NET-NEGATIVE (leaner) change becomes a first-class, ORTHOGONAL
10
+ * success dimension surfaced ALONGSIDE the pass/fail enum (never folded into it).
11
+ *
12
+ * MEASURED, not attested ([[feedback_measure_dont_claim]]): leanness comes from
13
+ * `git diff --numstat`, never an LLM judgment.
14
+ *
15
+ * Output: { filesAdded, filesRemoved, filesModified, insertions, deletions, netLoc, leaner }
16
+ * netLoc = insertions - deletions
17
+ * leaner = netLoc <= 0 (removed at least as much as it added)
18
+ *
19
+ * Input — EITHER:
20
+ * --numstat <path|-> parse a PRE-CAPTURED `git diff --numstat` string (testable
21
+ * with no repo; `-` reads stdin).
22
+ * --range <base>..<head> --project-dir <p> compute LIVE (runs git itself).
23
+ *
24
+ * numstat grammar (porcelain `git diff --numstat`): one line per path,
25
+ * <insertions>\t<deletions>\t<path>
26
+ * where a BINARY file emits `-\t-\t<path>` (no textual LOC — counted as a modified
27
+ * file with 0 loc contribution). File add/remove/modify is inferred from the
28
+ * `--numstat` line alone WITHOUT a name-status pass:
29
+ * - deletions>0 AND insertions==0 → a file with only removals → filesRemoved
30
+ * - insertions>0 AND deletions==0 → a file with only additions → filesAdded
31
+ * - both>0, OR a binary line → filesModified
32
+ * (This is a heuristic over numstat: a wholly-new file shows insertions/0, a wholly-
33
+ * deleted file shows 0/deletions. It is deterministic and sufficient for the leanness
34
+ * readout; netLoc/leaner — the load-bearing signal — are EXACT regardless of the
35
+ * add/remove/modify split.)
36
+ *
37
+ * Hard engineering bar (mirror gsd-t-guard-map.cjs): zero deps (Node built-ins
38
+ * only), never throws (bad input → exitCode 64, never an uncaught throw),
39
+ * pure parsing. Deterministic — ZERO LLM judgment.
40
+ *
41
+ * Exit: 0 metric computed · 64 bad input.
42
+ */
43
+
44
+ const fs = require("node:fs");
45
+ const { execFileSync } = require("node:child_process");
46
+
47
+ // ─── numstat parsing (pure) ────────────────────────────────────────────────
48
+
49
+ /**
50
+ * Parse a `git diff --numstat` string into the shrink metric. Pure; never throws.
51
+ * @param {string} numstat - raw numstat text
52
+ * @returns {{ filesAdded, filesRemoved, filesModified, insertions, deletions, netLoc, leaner, files }}
53
+ */
54
+ function parseNumstat(numstat) {
55
+ const text = String(numstat == null ? "" : numstat);
56
+ const lines = text.split(/\r?\n/);
57
+
58
+ let filesAdded = 0;
59
+ let filesRemoved = 0;
60
+ let filesModified = 0;
61
+ let insertions = 0;
62
+ let deletions = 0;
63
+ let files = 0;
64
+
65
+ for (const raw of lines) {
66
+ const line = raw.replace(/\r$/, "");
67
+ if (line.trim() === "") continue;
68
+ // numstat columns are TAB-separated: <ins>\t<del>\t<path>. Be tolerant of
69
+ // runs of whitespace (git uses a single tab, but stay robust).
70
+ const m = line.match(/^\s*(-|\d+)\s+(-|\d+)\s+(.+)$/);
71
+ if (!m) {
72
+ // A non-empty line that is not a numstat row → malformed input.
73
+ return { _malformed: true, badLine: line.slice(0, 200) };
74
+ }
75
+ files += 1;
76
+ const insTok = m[1];
77
+ const delTok = m[2];
78
+ const binary = insTok === "-" || delTok === "-";
79
+
80
+ if (binary) {
81
+ // A binary change: counted as a modified file, 0 textual loc contribution.
82
+ filesModified += 1;
83
+ continue;
84
+ }
85
+
86
+ const ins = Number(insTok);
87
+ const del = Number(delTok);
88
+ // Number() of a \d+ token is always a finite non-negative integer here.
89
+ insertions += ins;
90
+ deletions += del;
91
+
92
+ if (del > 0 && ins === 0) filesRemoved += 1;
93
+ else if (ins > 0 && del === 0) filesAdded += 1;
94
+ else filesModified += 1; // both>0, or 0/0 (an empty/mode-only change)
95
+ }
96
+
97
+ const netLoc = insertions - deletions;
98
+ return {
99
+ filesAdded,
100
+ filesRemoved,
101
+ filesModified,
102
+ insertions,
103
+ deletions,
104
+ netLoc,
105
+ leaner: netLoc <= 0,
106
+ files,
107
+ };
108
+ }
109
+
110
+ // ─── live git (only on the --range path) ───────────────────────────────────
111
+
112
+ /**
113
+ * Capture `git diff --numstat <range>` for a project. Returns { ok, numstat } or
114
+ * { ok:false, reason }. Never throws.
115
+ * @param {string} range - e.g. "<base>..HEAD"
116
+ * @param {string} projectDir
117
+ */
118
+ function captureNumstat(range, projectDir) {
119
+ // SECURITY (M92 Red Team BUG-1, [[feedback_defense_in_depth_at_adapters]]):
120
+ // `execFileSync` blocks SHELL injection but NOT git-ARGUMENT injection — a range
121
+ // beginning with `-` is parsed by git as an OPTION (e.g. `--output=<path>` makes
122
+ // git OVERWRITE an arbitrary file). The range is LLM-derived upstream (verify
123
+ // computes the base via a haiku agent), so it is untrusted. Reject any
124
+ // dash-leading / non-string / empty range BEFORE the git call. The trailing `--`
125
+ // is defense-in-depth (NOT sufficient alone — `--output` is honored before `--`).
126
+ if (typeof range !== "string" || range === "" || range.startsWith("-")) {
127
+ return { ok: false, reason: `invalid range (refused: must be a non-empty, non-option string): ${JSON.stringify(range)}` };
128
+ }
129
+ try {
130
+ const out = execFileSync("git", ["diff", "--numstat", range, "--"], {
131
+ cwd: projectDir || ".",
132
+ encoding: "utf8",
133
+ stdio: ["ignore", "pipe", "pipe"],
134
+ });
135
+ return { ok: true, numstat: out };
136
+ } catch (e) {
137
+ return { ok: false, reason: `git diff failed: ${(e && e.message) || "unknown"}` };
138
+ }
139
+ }
140
+
141
+ function readNumstatFile(p) {
142
+ // `-` → stdin (fd 0). Otherwise a path.
143
+ if (p === "-") return fs.readFileSync(0, "utf8");
144
+ return fs.readFileSync(p, "utf8");
145
+ }
146
+
147
+ // ─── driver ─────────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Compute the shrink metric from options. Never throws — bad input → exitCode 64.
151
+ * @param {{ numstat?:string, range?:string, projectDir?:string }} o
152
+ * @returns {{ ok, exitCode, ... }}
153
+ */
154
+ function runMetric(o) {
155
+ const opt = (o && typeof o === "object") ? o : {};
156
+ const hasNumstat = typeof opt.numstat === "string" && opt.numstat.length > 0;
157
+ const hasRange = typeof opt.range === "string" && opt.range.length > 0;
158
+
159
+ if (!hasNumstat && !hasRange) {
160
+ return { ok: false, exitCode: 64, reason: "need --numstat <path|-> OR --range <base>..<head>" };
161
+ }
162
+ if (hasNumstat && hasRange) {
163
+ return { ok: false, exitCode: 64, reason: "give EITHER --numstat OR --range, not both" };
164
+ }
165
+
166
+ let numstatText;
167
+ let source;
168
+ if (hasNumstat) {
169
+ source = `numstat:${opt.numstat}`;
170
+ try {
171
+ numstatText = readNumstatFile(opt.numstat);
172
+ } catch (e) {
173
+ return { ok: false, exitCode: 64, reason: `cannot read --numstat input: ${(e && e.message) || "unknown"}`, source };
174
+ }
175
+ } else {
176
+ source = `range:${opt.range}`;
177
+ const cap = captureNumstat(opt.range, opt.projectDir);
178
+ if (!cap.ok) {
179
+ // A git failure on the live path is BAD INPUT (unresolvable range / not a repo)
180
+ // → exit 64. The verify workflow turns this into a logged skip-with-reason,
181
+ // never a fabricated metric.
182
+ return { ok: false, exitCode: 64, reason: cap.reason, source };
183
+ }
184
+ numstatText = cap.numstat;
185
+ }
186
+
187
+ const parsed = parseNumstat(numstatText);
188
+ if (parsed._malformed) {
189
+ return { ok: false, exitCode: 64, reason: `malformed numstat line: "${parsed.badLine}"`, source };
190
+ }
191
+
192
+ return {
193
+ ok: true,
194
+ exitCode: 0,
195
+ source,
196
+ filesAdded: parsed.filesAdded,
197
+ filesRemoved: parsed.filesRemoved,
198
+ filesModified: parsed.filesModified,
199
+ insertions: parsed.insertions,
200
+ deletions: parsed.deletions,
201
+ netLoc: parsed.netLoc,
202
+ leaner: parsed.leaner,
203
+ files: parsed.files,
204
+ };
205
+ }
206
+
207
+ // ─── CLI ─────────────────────────────────────────────────────────────────
208
+
209
+ function parseArgs(argv) {
210
+ const o = { numstat: null, range: null, projectDir: ".", help: false };
211
+ for (let i = 0; i < argv.length; i++) {
212
+ const a = argv[i];
213
+ if (a === "--help" || a === "-h") o.help = true;
214
+ else if (a === "--numstat") o.numstat = argv[++i];
215
+ else if (a === "--range") o.range = argv[++i];
216
+ else if (a === "--project-dir" || a === "--projectDir") o.projectDir = argv[++i];
217
+ else if (a === "--json") {/* default output is JSON */}
218
+ }
219
+ return o;
220
+ }
221
+
222
+ const HELP = `Usage:
223
+ gsd-t shrink-metric --numstat <path|-> [--json]
224
+ gsd-t shrink-metric --range <base>..<head> --project-dir <p> [--json]
225
+
226
+ The M92 shrink-metric (D2). Computes a deterministic leanness readout from a
227
+ \`git diff --numstat\` envelope: { filesAdded, filesRemoved, filesModified,
228
+ insertions, deletions, netLoc, leaner } where netLoc = insertions - deletions and
229
+ leaner = netLoc <= 0. MEASURED, zero LLM judgment.
230
+
231
+ --numstat PATH parse a pre-captured \`git diff --numstat\` string (\`-\` = stdin).
232
+ --range R compute live: runs \`git diff --numstat <R>\` in --project-dir.
233
+ --project-dir P cwd for the live --range git call (default ".").
234
+
235
+ Exit: 0 metric computed · 64 bad input (no source, both sources, unreadable, or
236
+ malformed numstat / unresolvable range).`;
237
+
238
+ function main() {
239
+ const o = parseArgs(process.argv.slice(2));
240
+ if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
241
+ let res;
242
+ try {
243
+ res = runMetric(o);
244
+ } catch (e) {
245
+ // Defense in depth — runMetric is written never to throw, but the contract
246
+ // mandates the module never throws, so any escape maps to 64.
247
+ res = { ok: false, exitCode: 64, reason: `metric-error: ${e && e.message}` };
248
+ }
249
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
250
+ process.exit(res.exitCode);
251
+ }
252
+
253
+ if (require.main === module) main();
254
+
255
+ module.exports = { runMetric, parseNumstat, captureNumstat };