@tekyzinc/gsd-t 5.7.10 → 5.9.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,624 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-fallback-detect.cjs
4
+ *
5
+ * M106-D1 — Fallback detector (stages 1 + 2: detect, then check approval).
6
+ *
7
+ * [RULE] fallback-detect-halts-never-allows-on-error
8
+ * [RULE] fallback-detect-matches-by-shape-not-line
9
+ * [RULE] fallback-detect-trace-then-continue-is-a-fallback
10
+ *
11
+ * A fallback is anything that CONTINUES AFTER A FAILURE. This tool finds those
12
+ * branches and checks each against the project's approval file
13
+ * (.gsd-t/fallbacks.json). It makes no judgement about whether a fallback is
14
+ * warranted — that is stage 3 (the judge), and ultimately the user's call.
15
+ *
16
+ * ─── Usage ──────────────────────────────────────────────────────────────────
17
+ * node gsd-t-fallback-detect.cjs --text "<source>" --file <path> [--project <dir>]
18
+ * node gsd-t-fallback-detect.cjs --file <path> [--project <dir>]
19
+ * node gsd-t-fallback-detect.cjs --scan [--project <dir>] # whole codebase
20
+ * node gsd-t-fallback-detect.cjs --plan <path> [--project <dir>] # plan/contract prose
21
+ *
22
+ * ─── Exit codes ─────────────────────────────────────────────────────────────
23
+ * 0 clean — no unapproved fallbacks
24
+ * 4 unapproved fallback(s) found
25
+ * 64 bad input (unreadable file, malformed approval file)
26
+ *
27
+ * Exit 64 is a HALT, not a pass. A detector that cannot decide must never
28
+ * report "clean" — that would itself be the banned pattern.
29
+ *
30
+ * Zero dependencies. Deterministic. No LLM.
31
+ */
32
+
33
+ "use strict";
34
+
35
+ const fs = require("fs");
36
+ const path = require("path");
37
+
38
+ const EXIT_CLEAN = 0;
39
+ const EXIT_FOUND = 4;
40
+ const EXIT_BAD_INPUT = 64;
41
+
42
+ const SOURCE_EXT = new Set([".js", ".cjs", ".mjs", ".jsx", ".ts", ".tsx"]);
43
+
44
+ const SKIP_DIRS = new Set([
45
+ "node_modules", ".git", "dist", "build", "coverage", ".next",
46
+ ".claude", "vendor", "__pycache__", ".venv",
47
+ ]);
48
+
49
+ // Test files are exempt. A swallowed error in a test is deliberate setup or
50
+ // teardown; it never ships and never feeds a wrong value to a user. Flagging
51
+ // them buries the real findings.
52
+ const TEST_PATH_RE = /(?:^|[\\/])(?:test|tests|__tests__|spec|e2e|fixtures?)[\\/]|\.(?:test|spec)\.[cm]?[jt]sx?$/i;
53
+
54
+ // ─── Detection rules ────────────────────────────────────────────────────────
55
+ //
56
+ // Each rule names a SHAPE, not a phrasing. A shape cannot be evaded by
57
+ // rewording; that is the whole point. Every rule errs toward flagging — a
58
+ // false flag costs one approval, a missed one costs days of debugging.
59
+
60
+ const RULES = [
61
+ {
62
+ id: "catch-continues",
63
+ what: "a catch block that does not rethrow, exit, or return a failure",
64
+ // Handled structurally in scanCatchBlocks (needs brace matching).
65
+ structural: true,
66
+ },
67
+ {
68
+ id: "or-default",
69
+ what: "a default supplied where the left side can FAIL (not merely be absent)",
70
+ // Only a CALL on the left counts — `find()`, `get()`, `parse()` can fail.
71
+ // `opts.dir || '.'` is reading an optional property, which is not a failure
72
+ // and must not be flagged; over-flagging trains bypassing.
73
+ // Calls that cannot fail meaningfully (string/array/number helpers, and
74
+ // reading a caught error's message) are excluded by CALL_NOT_A_FAILURE.
75
+ re: /\b(\w+(?:\.\w+)*)\s*\(([^()\n]*)\)\s*(?:\|\||\?\?)\s*(?!\s*(?:$|;))(?:['"`]|\{|\[|\d|\w+\b)/,
76
+ guard: (m) => !CALL_NOT_A_FAILURE.test(m[1]),
77
+ },
78
+ {
79
+ id: "shell-or-true",
80
+ what: "|| true swallowing a command failure",
81
+ re: /\|\|\s*true\b/,
82
+ },
83
+ {
84
+ id: "trace-then-continue",
85
+ what: "a failure written to a log or trace, then execution continues",
86
+ // console.error / logger.warn / trace(...) NOT followed by throw/return/exit.
87
+ structural: true,
88
+ },
89
+ {
90
+ id: "substituted-value",
91
+ what: "a stand-in value used when a lookup found nothing",
92
+ // The Marla shape: `if (!author) { return lastKnownSeller; }` — a missing
93
+ // value replaced by a guess. Covers return, assignment, and the ternary
94
+ // form. Returning null/undefined/false is a halt, not a substitution.
95
+ structural: true,
96
+ },
97
+ {
98
+ id: "retry-then-proceed",
99
+ what: "a retry loop that gives up and carries on",
100
+ structural: true,
101
+ },
102
+ ];
103
+
104
+ // Shapes that are HALTS, not fallbacks — these end the failing path.
105
+ // `deny(...)`, `halt(...)`, `fail(...)`, `block(...)` and `abort(...)` are the
106
+ // named form of the same thing: the caller stops and says why. Treating those
107
+ // as fallbacks would flag the very code that enforces this rule.
108
+ const HALT_RE = /\b(?:throw\b|process\.exit\b|return\s+(?:null|false|undefined|\{\s*ok\s*:\s*false)|reject\(|assert\b|exitCode\s*=\s*[1-9]|(?:deny|halt|fail|block|abort|bail)\s*\()/;
109
+
110
+ // Calls whose "empty" result is a normal value, not a failure. A default after
111
+ // one of these is not a fallback — `str.trim() || "none"` hides nothing.
112
+ // `match` returns null on no-match by design, `includes`/`indexOf` return a
113
+ // plain boolean/number — none of these is a failure, so a default after them
114
+ // hides nothing.
115
+ const CALL_NOT_A_FAILURE =
116
+ /(?:^|\.)(?:trim|toString|String|Number|parseInt|parseFloat|join|slice|substring|substr|replace|replaceAll|toLowerCase|toUpperCase|padStart|padEnd|concat|filter|map|split|charAt|repeat|normalize|valueOf|toFixed|keys|values|entries|basename|dirname|extname|relative|resolve|now|match|matchAll|includes|indexOf|lastIndexOf|search|test|exec|closest|querySelector|querySelectorAll|getAttribute|trimStart|trimEnd|flat|flatMap|at|pop|shift)$/;
117
+
118
+ // `return allow()` / `return skip()` hand back a DECISION, not a stand-in
119
+ // value. A hook that decides "this write is not mine to judge" has invented
120
+ // nothing — flagging it would bury the real findings.
121
+ const DECISION_CALL_RE = /\breturn\s+(?:allow|skip|proceed|next|noop|pass)\s*\(\s*\)/;
122
+
123
+ // A `catch` that only cleans up or reports, in a place where continuing IS the
124
+ // correct behavior, still needs approval — but these host shapes are where the
125
+ // process is ALREADY ending, so continuing cannot hide anything downstream.
126
+ const CATCH_IN_TEARDOWN = /\b(?:process\.on|addEventListener|finally|beforeExit|SIGINT|SIGTERM|unref)\b/;
127
+
128
+ // ─── Approval file ──────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Read .gsd-t/fallbacks.json. Absent file = no approvals (the normal state).
132
+ * A malformed file is a HALT — we must never treat "unreadable" as "approved",
133
+ * and never as "empty" either, since both would silently change the verdict.
134
+ *
135
+ * @returns {{ ok: true, entries: object[] } | { ok: false, error: string }}
136
+ */
137
+ function readApprovals(projectDir) {
138
+ const p = path.join(projectDir, ".gsd-t", "fallbacks.json");
139
+ if (!fs.existsSync(p)) return { ok: true, entries: [] };
140
+ let raw;
141
+ try {
142
+ raw = fs.readFileSync(p, "utf8");
143
+ } catch (e) {
144
+ return { ok: false, error: `cannot read ${p}: ${e.message}` };
145
+ }
146
+ let parsed;
147
+ try {
148
+ parsed = JSON.parse(raw);
149
+ } catch (e) {
150
+ return { ok: false, error: `${p} is not valid JSON: ${e.message}` };
151
+ }
152
+ const entries = Array.isArray(parsed) ? parsed : parsed && parsed.fallbacks;
153
+ if (!Array.isArray(entries)) {
154
+ return { ok: false, error: `${p} must be an array, or an object with a "fallbacks" array` };
155
+ }
156
+ const required = ["id", "location", "whatFails", "whyNotHalt", "whatItDoesInstead", "approvedBy"];
157
+ for (const e of entries) {
158
+ if (!e || typeof e !== "object") {
159
+ return { ok: false, error: `${p} contains a non-object entry` };
160
+ }
161
+ const missing = required.filter((k) => !e[k]);
162
+ if (missing.length) {
163
+ return { ok: false, error: `${p} entry "${e.id || "(no id)"}" is missing: ${missing.join(", ")}` };
164
+ }
165
+ }
166
+ return { ok: true, entries };
167
+ }
168
+
169
+ /**
170
+ * Read the one-time baseline of fallbacks that already existed when the gate
171
+ * was adopted. Absent = no baseline (the correct state for a new project).
172
+ * Unreadable is treated as EMPTY, deliberately: that makes the gate STRICTER
173
+ * (pre-existing findings become live), never more permissive.
174
+ */
175
+ function readBaseline(projectDir) {
176
+ const p = path.join(projectDir, ".gsd-t", "fallbacks-baseline.json");
177
+ if (!fs.existsSync(p)) return [];
178
+ try {
179
+ const parsed = JSON.parse(fs.readFileSync(p, "utf8"));
180
+ return Array.isArray(parsed) ? parsed : (parsed.entries || []);
181
+ } catch (_) {
182
+ return [];
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Match a finding to an approval by LOCATION + SHAPE, never by line number —
188
+ * an ordinary edit above the fallback must not re-trigger it. Moving the
189
+ * fallback to a different file DOES re-trigger, deliberately.
190
+ */
191
+ function isApproved(finding, entries) {
192
+ const rel = finding.file.replace(/\\/g, "/");
193
+ return entries.some((e) => {
194
+ const loc = String(e.location || "").replace(/\\/g, "/");
195
+ if (!loc) return false;
196
+ const [locFile, locSymbol] = loc.split("#");
197
+ if (!rel.endsWith(locFile)) return false;
198
+ if (e.rule && e.rule !== finding.rule) return false;
199
+ if (locSymbol && finding.symbol && locSymbol !== finding.symbol) return false;
200
+ return true;
201
+ });
202
+ }
203
+
204
+ // ─── Source scanning ────────────────────────────────────────────────────────
205
+
206
+ function stripCommentsAndStrings(src) {
207
+ // Blank out comments and string bodies so their text can't produce a match.
208
+ // Length is preserved so line numbers stay correct.
209
+ let out = "";
210
+ let i = 0;
211
+ const n = src.length;
212
+ let state = null; // "line" | "block" | "'" | '"' | "`"
213
+ while (i < n) {
214
+ const c = src[i];
215
+ const c2 = src[i + 1];
216
+ if (state === null) {
217
+ if (c === "/" && c2 === "/") { state = "line"; out += " "; i += 2; continue; }
218
+ if (c === "/" && c2 === "*") { state = "block"; out += " "; i += 2; continue; }
219
+ if (c === "'" || c === '"' || c === "`") { state = c; out += c; i += 1; continue; }
220
+ out += c; i += 1; continue;
221
+ }
222
+ if (state === "line") {
223
+ if (c === "\n") { state = null; out += "\n"; } else { out += " "; }
224
+ i += 1; continue;
225
+ }
226
+ if (state === "block") {
227
+ if (c === "*" && c2 === "/") { state = null; out += " "; i += 2; continue; }
228
+ out += c === "\n" ? "\n" : " "; i += 1; continue;
229
+ }
230
+ // inside a string
231
+ if (c === "\\") { out += " "; i += 2; continue; }
232
+ if (c === state) { state = null; out += c; i += 1; continue; }
233
+ out += c === "\n" ? "\n" : " "; i += 1;
234
+ }
235
+ return out;
236
+ }
237
+
238
+ function lineOf(src, index) {
239
+ let line = 1;
240
+ for (let i = 0; i < index && i < src.length; i++) if (src[i] === "\n") line++;
241
+ return line;
242
+ }
243
+
244
+ /**
245
+ * Name of the function that ENCLOSES the given index.
246
+ *
247
+ * Walks brace depth backwards so a finding is attributed to the function it is
248
+ * actually inside, not merely the last one declared above it. The naive
249
+ * "nearest declaration above" reading attributes everything to whichever small
250
+ * helper happens to sit closest, which would bind an approval to the wrong
251
+ * place and silently approve a fallback somewhere else.
252
+ */
253
+ function symbolAt(src, index) {
254
+ let depth = 0;
255
+ for (let i = index; i >= 0; i--) {
256
+ const c = src[i];
257
+ if (c === "}") depth++;
258
+ else if (c === "{") {
259
+ if (depth > 0) { depth--; continue; }
260
+ // An unmatched `{` going backwards — this opens our enclosing block.
261
+ const head = src.slice(Math.max(0, i - 400), i);
262
+ const m =
263
+ head.match(/function\s*\*?\s*(\w+)\s*\([^)]*\)\s*$/) ||
264
+ head.match(/(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s+)?(?:function\s*\*?\s*)?\([^)]*\)\s*(?:=>\s*)?$/) ||
265
+ head.match(/(?:async\s+)?(\w+)\s*\([^)]*\)\s*$/);
266
+ if (m && !["if", "for", "while", "switch", "catch", "try", "else", "do"].includes(m[1])) {
267
+ return m[1];
268
+ }
269
+ // A block that is not a function (if/for/try) — keep walking outwards.
270
+ }
271
+ }
272
+ return "";
273
+ }
274
+
275
+ /** Extract the body of a block starting at the given brace index. */
276
+ function blockBody(src, braceIndex) {
277
+ let depth = 0;
278
+ for (let i = braceIndex; i < src.length; i++) {
279
+ if (src[i] === "{") depth++;
280
+ else if (src[i] === "}") {
281
+ depth--;
282
+ if (depth === 0) return src.slice(braceIndex + 1, i);
283
+ }
284
+ }
285
+ return src.slice(braceIndex + 1);
286
+ }
287
+
288
+ /**
289
+ * Find catch blocks that swallow the failure, and log-then-continue shapes.
290
+ * Both need brace matching, so they can't be plain regexes.
291
+ */
292
+ function scanStructural(clean, file, findings) {
293
+ // catch (e) { ... } with no halt inside
294
+ const catchRe = /\bcatch\s*(?:\([^)]*\))?\s*\{/g;
295
+ let m;
296
+ while ((m = catchRe.exec(clean)) !== null) {
297
+ const braceIdx = clean.indexOf("{", m.index);
298
+ if (braceIdx === -1) continue;
299
+ const body = blockBody(clean, braceIdx);
300
+ if (HALT_RE.test(body)) continue; // it halts — allowed
301
+
302
+ // Teardown/shutdown handlers: the process is already ending, so continuing
303
+ // cannot feed a wrong value to anything downstream.
304
+ const around = clean.slice(Math.max(0, m.index - 200), m.index);
305
+ if (CATCH_IN_TEARDOWN.test(around)) continue;
306
+
307
+ if (!body.trim()) {
308
+ findings.push({
309
+ rule: "catch-continues",
310
+ what: "an empty catch block — the failure disappears entirely",
311
+ file, line: lineOf(clean, m.index), symbol: symbolAt(clean, m.index),
312
+ snippet: "catch { }",
313
+ });
314
+ continue;
315
+ }
316
+
317
+ // The dangerous shape is a catch that HANDS BACK A VALUE the caller will
318
+ // trust. A bare `return;` / `continue;` / `break;` abandons the work — it
319
+ // fabricates nothing, so it is not flagged.
320
+ // Recording the failure so it gets reported is the OPPOSITE of hiding it.
321
+ // A push onto a list literally named for undelivered/failed/missing items
322
+ // is how a loud report gets built.
323
+ const recordsTheFailure =
324
+ /\b(?:notDelivered|failures?|errors?|missing|unrepairable|problems?|skipped)\b[^;\n]*\.push\s*\(/i.test(body);
325
+
326
+ const producesValue = !recordsTheFailure && (
327
+ (/\breturn\s+(?!;)[\w'"`[{(]/.test(body) && !DECISION_CALL_RE.test(body)) || // return <something>
328
+ /\b\w+(?:\.\w+)*\s*=\s*(?!null\b|undefined\b)[\w'"`[{(]/.test(body) || // assigns a stand-in
329
+ /\b\w+\.push\s*\(/.test(body)); // appends a stand-in
330
+ const logs = /\b(?:console\.\w+|logger?\.\w+|trace\w*|warn|debug)\s*\(/.test(body);
331
+
332
+ if (!producesValue && !logs) continue; // abandons the work, invents nothing
333
+
334
+ findings.push({
335
+ rule: logs && !producesValue ? "trace-then-continue" : "catch-continues",
336
+ what: logs && !producesValue
337
+ ? "the failure is written to a log, then execution continues"
338
+ : "a catch block that hands back a value instead of failing",
339
+ file, line: lineOf(clean, m.index), symbol: symbolAt(clean, m.index),
340
+ snippet: body.trim().split("\n")[0].slice(0, 80),
341
+ });
342
+ }
343
+
344
+ // The Marla shape: a missing value replaced by a guess.
345
+ // if (!author) { return lastKnownSeller; }
346
+ // if (author === null) { author = defaultSeller; }
347
+ // const a = findAuthor() || lastKnownSeller; (covered by or-default)
348
+ // Returning null/undefined/false/throwing is a HALT — not flagged.
349
+ const emptyCheckRe = /\bif\s*\(\s*(?:!\s*(\w+(?:\.\w+)*)|(\w+(?:\.\w+)*)\s*={2,3}\s*(?:null|undefined)|typeof\s+(\w+)\s*={2,3}\s*['"]undefined['"])\s*\)\s*\{/g;
350
+ while ((m = emptyCheckRe.exec(clean)) !== null) {
351
+ const varName = m[1] || m[2] || m[3] || "";
352
+ const braceIdx = clean.indexOf("{", m.index);
353
+ if (braceIdx === -1) continue;
354
+ const body = blockBody(clean, braceIdx);
355
+ if (HALT_RE.test(body)) continue; // stops loudly — allowed
356
+
357
+ // A substitution stands in for THE MISSING THING. It is either returned, or
358
+ // assigned to the very variable that was found empty. Anything else in the
359
+ // block — logging, an early return of nothing, unrelated work — is a guard
360
+ // clause, not a fallback.
361
+ const returnsValue = /\breturn\s+(?!null\b|undefined\b|false\b|\[\s*\]|\{\s*\})[\w'"`[{]/.test(body);
362
+ const assignsValue = varName
363
+ ? new RegExp(`\\b${varName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*=\\s*(?!=)\\s*(?!null\\b|undefined\\b)[\\w'"\`[{]`).test(body)
364
+ : false; // with no named variable we cannot tell a substitution from ordinary work
365
+ if (!returnsValue && !assignsValue) continue;
366
+
367
+ findings.push({
368
+ rule: "substituted-value",
369
+ what: varName
370
+ ? `"${varName}" was missing, so a stand-in value is used instead`
371
+ : "a stand-in value is used when a lookup found nothing",
372
+ file, line: lineOf(clean, m.index), symbol: symbolAt(clean, m.index),
373
+ snippet: body.trim().split("\n")[0].slice(0, 80),
374
+ });
375
+ }
376
+
377
+ // retry loop that gives up and proceeds
378
+ const retryRe = /\b(?:for|while)\s*\([^)]*(?:attempt|retry|retries|tries)\b[^)]*\)\s*\{/gi;
379
+ while ((m = retryRe.exec(clean)) !== null) {
380
+ const braceIdx = clean.indexOf("{", m.index);
381
+ if (braceIdx === -1) continue;
382
+ const body = blockBody(clean, braceIdx);
383
+ const after = clean.slice(braceIdx + body.length, braceIdx + body.length + 200);
384
+ if (HALT_RE.test(after)) continue; // gives up loudly — allowed
385
+ findings.push({
386
+ rule: "retry-then-proceed",
387
+ what: "a retry loop that gives up and carries on",
388
+ file, line: lineOf(clean, m.index), symbol: symbolAt(clean, m.index),
389
+ snippet: clean.slice(m.index, m.index + 60).replace(/\s+/g, " "),
390
+ });
391
+ }
392
+ }
393
+
394
+ /** Run every rule over one file's source. */
395
+ function scanText(src, file) {
396
+ const findings = [];
397
+ if (TEST_PATH_RE.test(String(file).replace(/\\/g, "/"))) return findings;
398
+ const clean = stripCommentsAndStrings(src);
399
+ scanStructural(clean, file, findings);
400
+
401
+ for (const rule of RULES) {
402
+ if (rule.structural || !rule.re) continue;
403
+ const re = new RegExp(rule.re.source, rule.re.flags.includes("g") ? rule.re.flags : rule.re.flags + "g");
404
+ let m;
405
+ while ((m = re.exec(clean)) !== null) {
406
+ if (typeof rule.guard === "function" && !rule.guard(m)) continue;
407
+ const line = lineOf(clean, m.index);
408
+ // Don't double-report the same line from two rules.
409
+ if (findings.some((f) => f.line === line && f.file === file)) continue;
410
+ findings.push({
411
+ rule: rule.id,
412
+ what: rule.what,
413
+ file,
414
+ line,
415
+ symbol: symbolAt(clean, m.index),
416
+ snippet: m[0].replace(/\s+/g, " ").slice(0, 80),
417
+ });
418
+ }
419
+ }
420
+ return findings;
421
+ }
422
+
423
+ // ─── Plan / contract prose scanning ─────────────────────────────────────────
424
+
425
+ const PROSE_RE = [
426
+ { re: /\bfall(?:ing)?[ -]?back\b/i, what: "the plan describes a fallback" },
427
+ { re: /\bif (?:it |this )?fails?,? (?:then )?(?:we |just )?(?:use|try|default|substitute|assume|continue|proceed)\b/i, what: "the plan continues after a failure" },
428
+ { re: /\bdefaults? (?:to|back to)\b/i, what: "the plan substitutes a default" },
429
+ { re: /\botherwise,? (?:use|assume|default|substitute)\b/i, what: "the plan substitutes a value" },
430
+ { re: /\bgracefully degrad\w+/i, what: "the plan degrades instead of stopping" },
431
+ { re: /\bbest[ -]effort\b/i, what: "the plan accepts a partial result" },
432
+ { re: /\bskip(?:s|ping)? (?:the |that )?(?:failed|missing|bad)\b/i, what: "the plan skips failed items and continues" },
433
+ { re: /\bpartial (?:result|success|invoice|record)\b/i, what: "the plan returns a partial result" },
434
+ { re: /\blog (?:it |the error )?and continue\b/i, what: "the plan logs a failure and continues" },
435
+ ];
436
+
437
+ function scanPlan(text, file) {
438
+ const findings = [];
439
+ const lines = text.split(/\r?\n/);
440
+ lines.forEach((line, i) => {
441
+ for (const p of PROSE_RE) {
442
+ if (p.re.test(line)) {
443
+ findings.push({
444
+ rule: "plan-describes-fallback",
445
+ what: p.what,
446
+ file,
447
+ line: i + 1,
448
+ symbol: "",
449
+ snippet: line.trim().slice(0, 100),
450
+ });
451
+ break;
452
+ }
453
+ }
454
+ });
455
+ return findings;
456
+ }
457
+
458
+ // ─── Directory walk ─────────────────────────────────────────────────────────
459
+
460
+ function walk(dir, out, root) {
461
+ let entries;
462
+ try {
463
+ entries = fs.readdirSync(dir, { withFileTypes: true });
464
+ } catch (_) {
465
+ return;
466
+ }
467
+ for (const e of entries) {
468
+ if (e.name.startsWith(".") && e.name !== ".gsd-t") {
469
+ if (SKIP_DIRS.has(e.name)) continue;
470
+ }
471
+ if (SKIP_DIRS.has(e.name)) continue;
472
+ const full = path.join(dir, e.name);
473
+ if (e.isDirectory()) walk(full, out, root);
474
+ else if (SOURCE_EXT.has(path.extname(e.name))) out.push(full);
475
+ }
476
+ }
477
+
478
+ // ─── CLI ────────────────────────────────────────────────────────────────────
479
+
480
+ function parseArgs(argv) {
481
+ const args = { project: process.cwd() };
482
+ for (let i = 2; i < argv.length; i++) {
483
+ const a = argv[i];
484
+ if (a === "--scan") args.scan = true;
485
+ else if (a === "--json") args.json = true;
486
+ else if (a === "--baseline") args.baseline = true;
487
+ else if (a === "--text") args.text = argv[++i];
488
+ else if (a === "--file") args.file = argv[++i];
489
+ else if (a === "--plan") args.plan = argv[++i];
490
+ else if (a === "--project") args.project = argv[++i];
491
+ }
492
+ return args;
493
+ }
494
+
495
+ function main() {
496
+ const args = parseArgs(process.argv);
497
+ const projectDir = path.resolve(args.project);
498
+
499
+ const approvals = readApprovals(projectDir);
500
+ if (!approvals.ok) {
501
+ // A malformed approval file is a HALT. Never assume "empty" or "approved".
502
+ process.stdout.write(JSON.stringify({
503
+ ok: false, exitCode: EXIT_BAD_INPUT, error: approvals.error,
504
+ halt: "Cannot determine which fallbacks are approved. Fix .gsd-t/fallbacks.json.",
505
+ }, null, 2) + "\n");
506
+ process.exit(EXIT_BAD_INPUT);
507
+ }
508
+
509
+ let findings = [];
510
+ let scanned = 0;
511
+
512
+ try {
513
+ if (args.plan) {
514
+ const p = path.resolve(args.plan);
515
+ findings = scanPlan(fs.readFileSync(p, "utf8"), path.relative(projectDir, p));
516
+ scanned = 1;
517
+ } else if (args.scan) {
518
+ const files = [];
519
+ walk(projectDir, files, projectDir);
520
+ for (const f of files) {
521
+ try {
522
+ findings.push(...scanText(fs.readFileSync(f, "utf8"), path.relative(projectDir, f)));
523
+ scanned++;
524
+ } catch (_) { /* unreadable single file — counted below as not scanned */ }
525
+ }
526
+ } else if (typeof args.text === "string") {
527
+ const rel = args.file ? path.relative(projectDir, path.resolve(args.file)) : "(stdin)";
528
+ const ext = path.extname(rel);
529
+ findings = (ext === ".md" ? scanPlan : scanText)(args.text, rel);
530
+ scanned = 1;
531
+ } else if (args.file) {
532
+ const p = path.resolve(args.file);
533
+ const rel = path.relative(projectDir, p);
534
+ const src = fs.readFileSync(p, "utf8");
535
+ findings = (path.extname(p) === ".md" ? scanPlan : scanText)(src, rel);
536
+ scanned = 1;
537
+ } else {
538
+ process.stdout.write(JSON.stringify({
539
+ ok: false, exitCode: EXIT_BAD_INPUT,
540
+ error: "nothing to check — pass --file, --text, --plan, or --scan",
541
+ }, null, 2) + "\n");
542
+ process.exit(EXIT_BAD_INPUT);
543
+ }
544
+ } catch (e) {
545
+ process.stdout.write(JSON.stringify({
546
+ ok: false, exitCode: EXIT_BAD_INPUT, error: e.message,
547
+ halt: "Could not read the input. Not reporting clean — that would hide a real finding.",
548
+ }, null, 2) + "\n");
549
+ process.exit(EXIT_BAD_INPUT);
550
+ }
551
+
552
+ // --baseline: record everything already in the codebase as pre-existing, so
553
+ // the gate governs NEW work only. Seeded ONCE — a re-seed would let a fresh
554
+ // fallback slip in under cover of the baseline, which is the very thing this
555
+ // tool exists to stop.
556
+ if (args.baseline) {
557
+ const outPath = path.join(projectDir, ".gsd-t", "fallbacks-baseline.json");
558
+ if (fs.existsSync(outPath)) {
559
+ process.stdout.write(JSON.stringify({
560
+ ok: false, exitCode: EXIT_BAD_INPUT,
561
+ error: `${outPath} already exists`,
562
+ halt: "The baseline is seeded once, never re-seeded. Delete it deliberately if you truly mean to reset.",
563
+ }, null, 2) + "\n");
564
+ process.exit(EXIT_BAD_INPUT);
565
+ }
566
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
567
+ fs.writeFileSync(outPath, JSON.stringify({
568
+ seededAt: new Date().toISOString(),
569
+ note: "Fallbacks already present when the gate was adopted. Not approved — merely pre-existing. Fix them over time; the gate blocks NEW ones.",
570
+ entries: findings.map((f) => ({ file: f.file, rule: f.rule, symbol: f.symbol, snippet: f.snippet })),
571
+ }, null, 2) + "\n");
572
+ process.stdout.write(JSON.stringify({
573
+ ok: true, exitCode: EXIT_CLEAN, baselineWritten: outPath, recorded: findings.length,
574
+ }, null, 2) + "\n");
575
+ process.exit(EXIT_CLEAN);
576
+ }
577
+
578
+ // Pre-existing findings are excluded from the verdict but still counted, so
579
+ // the debt stays visible instead of disappearing.
580
+ // Pre-existing findings are matched on file + rule + the code itself, NOT on
581
+ // the enclosing function name. A function rename, or an improvement to how
582
+ // the name is derived, must not resurrect 500 old findings as if they were
583
+ // new work — that would bury the handful that actually are.
584
+ const baseline = readBaseline(projectDir);
585
+ const isPreExisting = (f) => baseline.some(
586
+ (b) => b.file === f.file && b.rule === f.rule && b.snippet === f.snippet
587
+ );
588
+
589
+ const preExisting = findings.filter(isPreExisting).length;
590
+ const live = findings.filter((f) => !isPreExisting(f));
591
+ const unapproved = live.filter((f) => !isApproved(f, approvals.entries));
592
+ const approved = live.length - unapproved.length;
593
+
594
+ const result = {
595
+ ok: unapproved.length === 0,
596
+ exitCode: unapproved.length === 0 ? EXIT_CLEAN : EXIT_FOUND,
597
+ filesScanned: scanned,
598
+ found: findings.length,
599
+ preExisting,
600
+ approved,
601
+ unapproved: unapproved.length,
602
+ findings: unapproved,
603
+ };
604
+
605
+ if (args.json || !process.stdout.isTTY) {
606
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
607
+ } else {
608
+ if (result.ok) {
609
+ process.stdout.write(`No unapproved fallbacks (${scanned} file(s) checked).\n`);
610
+ } else {
611
+ process.stdout.write(`${unapproved.length} unapproved fallback(s):\n\n`);
612
+ for (const f of unapproved) {
613
+ process.stdout.write(` ${f.file}:${f.line}${f.symbol ? ` in ${f.symbol}` : ""}\n`);
614
+ process.stdout.write(` ${f.what}\n`);
615
+ process.stdout.write(` ${f.snippet}\n\n`);
616
+ }
617
+ }
618
+ }
619
+ process.exit(result.exitCode);
620
+ }
621
+
622
+ if (require.main === module) main();
623
+
624
+ module.exports = { scanText, scanPlan, readApprovals, isApproved, stripCommentsAndStrings };