@event4u/agent-config 14.17.0 → 14.18.0

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.
Files changed (74) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +170 -79
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/merge.md +35 -44
  7. package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
  8. package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
  9. package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
  10. package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
  11. package/dist/agent-src/skills/design-review/SKILL.md +2 -0
  12. package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
  13. package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
  14. package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
  15. package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
  16. package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
  17. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
  18. package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
  19. package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/cli-delegate/cmd_conformance.js +335 -659
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +15 -15
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +1 -1
  26. package/dist/discovery/dormancy-report.md +1 -1
  27. package/dist/discovery/orphan-report.md +1 -1
  28. package/dist/discovery/packs.json +2 -2
  29. package/dist/discovery/trust-report.md +1 -1
  30. package/dist/discovery/workspaces.json +2 -2
  31. package/dist/hooks/dispatch.js +959 -1304
  32. package/dist/mcp/registry-manifest.json +2 -2
  33. package/dist/mcp/server.json +2 -2
  34. package/dist/mcp/server.mjs +671 -1002
  35. package/dist/shared/settingsConsent.js +1 -1
  36. package/package.json +1 -1
  37. package/src/config/conformance-claim-baseline.json +9 -0
  38. package/src/config/gate-violation-baselines.json +3 -3
  39. package/src/config/release-gate-locality.yml +129 -0
  40. package/src/scripts/_lib/release_highlights.ts +172 -1
  41. package/src/scripts/_lib/release_material.ts +66 -0
  42. package/src/scripts/_lib/repeated_run.ts +77 -0
  43. package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
  44. package/src/scripts/_lib/unified_diff.ts +121 -0
  45. package/src/scripts/check_finding_dispositions.ts +179 -3
  46. package/src/scripts/check_installed_hooks_fresh.ts +381 -0
  47. package/src/scripts/check_release_highlights.ts +25 -13
  48. package/src/scripts/check_suppression_hygiene.ts +11 -0
  49. package/src/scripts/code_graph/build.ts +230 -33
  50. package/src/scripts/code_graph/extract.ts +168 -33
  51. package/src/scripts/code_graph/types.ts +37 -2
  52. package/src/scripts/code_graph/validate.ts +12 -1
  53. package/src/scripts/conformance_scan.ts +2 -2
  54. package/src/scripts/detect_silent_catch.ts +403 -0
  55. package/src/scripts/detect_verification_tampering.ts +538 -0
  56. package/src/scripts/git_authorization_hook.ts +153 -17
  57. package/src/scripts/hook_manifest.json +1 -1
  58. package/src/scripts/hook_manifest.yaml +33 -19
  59. package/src/scripts/hooks/block_no_verify.ts +3 -3
  60. package/src/scripts/hooks/concern_registry.ts +2 -2
  61. package/src/scripts/hooks/evidence_independence.ts +21 -2
  62. package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
  63. package/src/scripts/hooks/push_settle_hook.ts +250 -0
  64. package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
  65. package/src/scripts/install-hooks.sh +134 -1
  66. package/src/scripts/language_mirror_hook.ts +27 -5
  67. package/src/scripts/release.ts +31 -143
  68. package/src/scripts/release_drill.ts +71 -1
  69. package/src/scripts/release_publication.ts +243 -3
  70. package/src/scripts/release_verify.ts +220 -0
  71. package/src/scripts/schemas/review-findings.schema.json +1 -1
  72. package/src/scripts/schemas/skill.schema.json +11 -1
  73. package/src/scripts/self_review_gate.ts +10 -1
  74. package/src/shared/settingsConsent.ts +1 -1
@@ -21,7 +21,20 @@ export interface CodeNode {
21
21
  /** Path-qualified from day one: `<relpath>#<symbol>` (collision-free). */
22
22
  id: string;
23
23
  label: string;
24
- kind: 'file' | 'class' | 'interface' | 'trait' | 'function' | 'method' | 'skipped';
24
+ /**
25
+ * `constant` / `type` / `enum` were added by
26
+ * `road-to-the-graph-that-lies-confidently` 2.1: the v2 benchmark's
27
+ * `references` class probes a const (`EXT_LANG`) and a type alias
28
+ * (`SettingsClass`), and neither had a node to resolve to. `interface` is
29
+ * reused rather than forked — a TS interface and a PHP interface are the
30
+ * same thing to a caller.
31
+ *
32
+ * The kind is also a CAPABILITY claim the build pass reads: a `constant` is
33
+ * never callable and never constructible, so a bare `foo()` may not resolve
34
+ * to one by name. `const Widget = class {}` is emitted as `class` for
35
+ * exactly that reason.
36
+ */
37
+ kind: 'file' | 'class' | 'interface' | 'trait' | 'function' | 'method' | 'constant' | 'type' | 'enum' | 'skipped';
25
38
  source_file: string;
26
39
  /** 1-based [startLine, startCol, endLine, endCol]; [] for synthetic nodes. */
27
40
  source_location: readonly number[];
@@ -74,11 +87,33 @@ export interface CodeGraph {
74
87
  grammar_abi: number;
75
88
  /** per-confidence-class edge counts, so the honesty split is visible. */
76
89
  edge_confidence_counts: Record<EdgeConfidence, number>;
90
+ /**
91
+ * Edges the build pass REFUSED to emit, by reason. Present so a smaller
92
+ * edge total cannot be read as a better graph without saying what was
93
+ * dropped: an engine that quietly deletes its worst edges publishes a
94
+ * flattering confidence ratio and no evidence.
95
+ *
96
+ * `dynamic_no_candidate` — a dynamic member call (`x.push()`) whose method
97
+ * name matches NO method declared in this repository. The edge could only
98
+ * ever have pointed at a `symbol:` pseudo-node, which is not a node, so no
99
+ * query verb could reach it: `affected` and `path` resolve their seeds
100
+ * against real nodes. It was 257 of 660 edges on this engine's own source.
101
+ */
102
+ suppressed_edge_counts: { dynamic_no_candidate: number };
77
103
  nodes: CodeNode[];
78
104
  edges: CodeEdge[];
79
105
  }
80
106
 
81
- export const SCHEMA_VERSION = 1;
107
+ /**
108
+ * Bumped 1 → 2 by `road-to-the-graph-that-lies-confidently`: `CodeNode.kind`
109
+ * gained three members, `CodeGraph` gained `suppressed_edge_counts`, and the
110
+ * per-file extract sidecar gained the import specifier. A cached sidecar
111
+ * written at v1 carries raw edges with no `moduleSpecifier`, which would make
112
+ * `--update` resolve imports the old (name-lookup) way for every unchanged
113
+ * file — a silently mixed graph. `readSidecar` refuses a version mismatch, so
114
+ * the bump is what makes that impossible rather than merely unlikely.
115
+ */
116
+ export const SCHEMA_VERSION = 2;
82
117
 
83
118
  /** Deterministic per-file byte cap — files above this become a SKIPPED node. */
84
119
  export const MAX_FILE_BYTES = 1_000_000;
@@ -6,7 +6,18 @@ import type { CodeGraph } from './types.js';
6
6
 
7
7
  const RELATIONS = new Set(['calls', 'imports', 'uses', 'inherits', 'member']);
8
8
  const CONFIDENCES = new Set(['EXTRACTED', 'INFERRED', 'AMBIGUOUS']);
9
- const KINDS = new Set(['file', 'class', 'interface', 'trait', 'function', 'method', 'skipped']);
9
+ const KINDS = new Set([
10
+ 'file',
11
+ 'class',
12
+ 'interface',
13
+ 'trait',
14
+ 'function',
15
+ 'method',
16
+ 'constant',
17
+ 'type',
18
+ 'enum',
19
+ 'skipped',
20
+ ]);
10
21
 
11
22
  export interface ValidateResult {
12
23
  ok: boolean;
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * language-pin ← language_mirror_hook.classify
15
15
  * git-authorization ← git_authorization_hook.classifyAuthorization
16
- * + block_unauthorized_git.commandOp / BLOCK_OPS
16
+ * + git_command_classifier.commandOp / BLOCK_OPS
17
17
  * vacuous-evidence ← before_complete_hook.isVacuousOutput / isCiPoll / pendingCount
18
18
  * evidence-steering ← evidence_independence.isEvaluationPrompt / preloadedVerdict
19
19
  * completion-claim ← turn_end_gate_hook.detectCompletionClaim (round 7)
@@ -54,7 +54,7 @@ import { entryText, isSidechain } from "./_lib/transcript_entry.js";
54
54
  import { classify } from "./language_mirror_hook.js";
55
55
  import { isSyntheticPrompt } from "./_lib/prompt_shape.js";
56
56
  import { classifyAuthorization, type GitOp } from "./git_authorization_hook.js";
57
- import { BLOCK_OPS, commandOp } from "./hooks/block_unauthorized_git.js";
57
+ import { BLOCK_OPS, commandOp } from "./hooks/git_command_classifier.js";
58
58
  import { isVacuousOutput, isCiPoll, pendingCount } from "./before_complete_hook.js";
59
59
  // Round 7 § 1.5 — the SAME predicate `turn-end-gate` refuses on, imported rather
60
60
  // than reimplemented, so the measured rate and the gate cannot disagree.
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * detect_silent_catch — a newly introduced error swallow, decided from the diff.
4
+ *
5
+ * Prose coverage for this failure already exists in three places —
6
+ * `error-handling-patterns`, `ai-code-blindspots`, and
7
+ * `testing-anti-patterns/process-anti-patterns.md` — and nothing decided it:
8
+ * `ls src/scripts/ | grep -icE 'swallow|bare_except'` returned 0 when this was
9
+ * written. Prose that nothing checks is a preference.
10
+ *
11
+ * SCOPE — the lines the diff ADDS.
12
+ *
13
+ * A pre-existing empty catch is debt this check does not own. The block must
14
+ * be wholly contained in added lines; a block whose body reaches into context
15
+ * lines is skipped. `neg-preexisting-empty-catch` in the corpus pins that — the
16
+ * same empty catch as unchanged context beside one unrelated added line must
17
+ * stay silent. If it ever fires, this has quietly become a whole-tree sweep,
18
+ * which is a different tool with a different false-positive budget.
19
+ *
20
+ * TWO SHAPES, both unambiguous.
21
+ *
22
+ * `catch-empty` no statements at all in the block
23
+ * `catch-discards-error` statements, but the caught value is never used,
24
+ * logged, or re-raised
25
+ *
26
+ * A COMMENT IS NOT A STATEMENT. That is deliberate and it is the roadmap's
27
+ * risk register answered directly: "a `pass # intentional` comment satisfies
28
+ * a naive shape check while changing nothing, so the detector teaches evasion
29
+ * rather than discipline". Comments and `pass` / `...` are stripped before the
30
+ * block is judged, so neither rescues an empty block.
31
+ *
32
+ * NO ESCAPE-HATCH MARKER, deliberately.
33
+ *
34
+ * Every other suppression in this tree is a declared, reasoned marker. This
35
+ * check has none, because it is WARN-ONLY: it blocks nothing, so a marker
36
+ * would buy no unblocking and would be exactly the one-token evasion above,
37
+ * wearing the house convention's clothes. Revisit together with promotion —
38
+ * a blocking version owes a hatch, a warning one does not.
39
+ *
40
+ * WARN-FIRST, and what that means for the exit code.
41
+ *
42
+ * Exit 0 on findings by default: this is a REPORT. `--strict` turns findings
43
+ * into exit 1 and exists so `--self-test` can prove the detection discriminates
44
+ * at all — a report-mode gate cannot be turned red by a fixture, which is the
45
+ * same shape `lint_consolidation_lineage` records in `gate-coverage.yml`.
46
+ *
47
+ * CLI:
48
+ * detect_silent_catch --diff <file> [--strict] [--json]
49
+ * detect_silent_catch --range <base>..<head> # count over merged history
50
+ * detect_silent_catch --corpus | --self-test
51
+ *
52
+ * Exit codes: 0 clean or report-mode findings · 1 findings under `--strict`,
53
+ * or a corpus/self-test failure · 2 usage error or crash.
54
+ */
55
+ import { execFileSync } from 'node:child_process';
56
+ import * as fs from 'node:fs';
57
+ import * as path from 'node:path';
58
+ import process from 'node:process';
59
+ import { fileURLToPath, pathToFileURL } from 'node:url';
60
+
61
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
62
+ import { parseUnifiedDiff, type DiffFile, type DiffLine } from './_lib/unified_diff.js';
63
+
64
+ const _HERE = fileURLToPath(import.meta.url);
65
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
66
+ const CORPUS = path.join(REPO_ROOT, 'tests/fixtures/silent-catch-corpus');
67
+ const SELF_TEST_MIN_CASES = 12;
68
+ const SELF_TEST_MIN_REJECT = 6;
69
+
70
+ export type SilentCatchId = 'catch-empty' | 'catch-discards-error';
71
+
72
+ export interface SilentCatchFinding {
73
+ id: SilentCatchId;
74
+ file: string;
75
+ line: number;
76
+ evidence: string;
77
+ }
78
+
79
+ /** Brace-language opener: `catch (e) {`, `catch {`, `} catch (\Throwable $e) {`. */
80
+ const BRACE_CATCH_RE = /\bcatch\s*(?:\(([^)]*)\))?\s*\{/;
81
+ /** Python opener: `except:` / `except X:` / `except X as e:`. */
82
+ const PY_EXCEPT_RE = /^\s*except\b([^:]*):\s*$/;
83
+
84
+ /** The caught value reached something that observes it. */
85
+ const RETHROW_RE = /\b(?:throw|raise|panic|rethrow)\b/;
86
+ const LOG_RE =
87
+ /\b(?:log|logger|logging|console|Log|Logger|report|captureException|Sentry|trace|warn|error|debug|emit)\b/i;
88
+
89
+ /**
90
+ * Strip a trailing line comment.
91
+ *
92
+ * Load-bearing, and it was missing on the first implementation: without it
93
+ * `pass # intentional` is not equal to `pass`, so the block read as
94
+ * non-empty and the check stayed silent. That is the roadmap's Risk 3
95
+ * verbatim — "a `pass # intentional` comment satisfies a naive shape check
96
+ * while changing nothing" — reproduced by the detector that exists to close
97
+ * it. Caught by its own test, fixed here, pinned by a fixture.
98
+ *
99
+ * Narrow by construction: truncating a line that merely contains `//` inside
100
+ * a string can only matter if what remains is exactly `pass`, `...`, or a
101
+ * brace, and no real statement reduces to those.
102
+ */
103
+ function stripTrailingComment(text: string): string {
104
+ return text.replace(/\s*(?:#|\/\/).*$/, '');
105
+ }
106
+
107
+ /** A line that is only a comment, or only a no-op statement. */
108
+ function isNonStatement(text: string): boolean {
109
+ const t = stripTrailingComment(text).trim();
110
+ if (t === '') return true;
111
+ if (t === 'pass' || t === '...' || t === '{' || t === '}' || t === '};') return true;
112
+ if (t.startsWith('//') || t.startsWith('#') || t.startsWith('/*') || t.startsWith('*')) return true;
113
+ return false;
114
+ }
115
+
116
+ /** Identifier bound by `catch (e)` / `except X as e` — `null` when unbound. */
117
+ export function boundName(header: string): string | null {
118
+ const m = /(?:^|[\s(])(?:[\w\\.$]+\s+)?\$?([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(header.trim());
119
+ if (header.trim() === '') return null;
120
+ const asMatch = /\bas\s+([A-Za-z_][A-Za-z0-9_]*)/.exec(header);
121
+ if (asMatch !== null) return asMatch[1] ?? null;
122
+ return m?.[1] ?? null;
123
+ }
124
+
125
+ /**
126
+ * Judge one catch body.
127
+ *
128
+ * `null` when the body is fine. Comments and `pass` are already excluded by
129
+ * {@link isNonStatement}, so an "explanatory" comment cannot make an empty
130
+ * block non-empty.
131
+ */
132
+ export function judgeBody(bound: string | null, body: readonly string[]): SilentCatchId | null {
133
+ const statements = body.filter((l) => !isNonStatement(l));
134
+ if (statements.length === 0) return 'catch-empty';
135
+ const joined = statements.join('\n');
136
+ if (RETHROW_RE.test(joined)) return null;
137
+ if (LOG_RE.test(joined)) return null;
138
+ if (bound !== null && new RegExp(`\\b${bound}\\b`).test(joined)) return null;
139
+ if (bound === null) return null; // unbound catch with real statements: not decidable by shape
140
+ return 'catch-discards-error';
141
+ }
142
+
143
+ interface AddedBlock {
144
+ lines: DiffLine[];
145
+ }
146
+
147
+ /** Runs of consecutive added lines — the only region this check reads. */
148
+ function addedRuns(file: DiffFile): AddedBlock[] {
149
+ const runs: AddedBlock[] = [];
150
+ for (const hunk of file.hunks) {
151
+ let current: DiffLine[] = [];
152
+ for (const l of hunk.lines) {
153
+ if (l.side === 'added') {
154
+ current.push(l);
155
+ } else if (current.length > 0) {
156
+ runs.push({ lines: current });
157
+ current = [];
158
+ }
159
+ }
160
+ if (current.length > 0) runs.push({ lines: current });
161
+ }
162
+ return runs;
163
+ }
164
+
165
+ function scanBraceRun(filePath: string, run: readonly DiffLine[]): SilentCatchFinding[] {
166
+ const out: SilentCatchFinding[] = [];
167
+ for (let i = 0; i < run.length; i += 1) {
168
+ const line = run[i];
169
+ if (line === undefined) continue;
170
+ const m = BRACE_CATCH_RE.exec(line.text);
171
+ if (m === null) continue;
172
+ const bound = boundName(m[1] ?? '');
173
+
174
+ // Single-line form: the body is whatever sits between the braces.
175
+ const after = line.text.slice((m.index ?? 0) + m[0].length);
176
+ if (after.includes('}')) {
177
+ const inner = after.slice(0, after.indexOf('}'));
178
+ const verdict = judgeBody(bound, [inner]);
179
+ if (verdict !== null) {
180
+ out.push({ id: verdict, file: filePath, line: line.line, evidence: line.text.trim() });
181
+ }
182
+ continue;
183
+ }
184
+
185
+ // Multi-line form: walk added lines only, tracking brace depth.
186
+ let depth = 1;
187
+ const body: string[] = [];
188
+ let closed = false;
189
+ for (let j = i + 1; j < run.length; j += 1) {
190
+ const inner = run[j];
191
+ if (inner === undefined) break;
192
+ for (const ch of inner.text) {
193
+ if (ch === '{') depth += 1;
194
+ else if (ch === '}') depth -= 1;
195
+ }
196
+ if (depth <= 0) { closed = true; break; }
197
+ body.push(inner.text);
198
+ }
199
+ // Not closed inside the added run → the block reaches pre-existing
200
+ // lines and is out of scope. Silence is the correct answer.
201
+ if (!closed) continue;
202
+ const verdict = judgeBody(bound, body);
203
+ if (verdict !== null) {
204
+ out.push({ id: verdict, file: filePath, line: line.line, evidence: line.text.trim() });
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ function indentOf(text: string): number {
211
+ return text.length - text.replace(/^\s*/, '').length;
212
+ }
213
+
214
+ function scanPythonRun(filePath: string, run: readonly DiffLine[]): SilentCatchFinding[] {
215
+ const out: SilentCatchFinding[] = [];
216
+ for (let i = 0; i < run.length; i += 1) {
217
+ const line = run[i];
218
+ if (line === undefined) continue;
219
+ const m = PY_EXCEPT_RE.exec(line.text);
220
+ if (m === null) continue;
221
+ const bound = boundName(m[1] ?? '');
222
+ const openIndent = indentOf(line.text);
223
+ const body: string[] = [];
224
+ for (let j = i + 1; j < run.length; j += 1) {
225
+ const inner = run[j];
226
+ if (inner === undefined) break;
227
+ if (inner.text.trim() !== '' && indentOf(inner.text) <= openIndent) break;
228
+ body.push(inner.text);
229
+ }
230
+ const verdict = judgeBody(bound, body);
231
+ if (verdict !== null) {
232
+ out.push({ id: verdict, file: filePath, line: line.line, evidence: line.text.trim() });
233
+ }
234
+ }
235
+ return out;
236
+ }
237
+
238
+ const PY_RE = /\.py$/;
239
+
240
+ export function scanDiff(diffText: string): SilentCatchFinding[] {
241
+ const out: SilentCatchFinding[] = [];
242
+ for (const file of parseUnifiedDiff(diffText)) {
243
+ if (file.deleted) continue;
244
+ for (const run of addedRuns(file)) {
245
+ out.push(
246
+ ...(PY_RE.test(file.path)
247
+ ? scanPythonRun(file.path, run.lines)
248
+ : scanBraceRun(file.path, run.lines)),
249
+ );
250
+ }
251
+ }
252
+ return out;
253
+ }
254
+
255
+ export function render(findings: readonly SilentCatchFinding[], strict: boolean): string {
256
+ if (findings.length === 0) return 'no newly introduced silent catch in this diff';
257
+ const lines = findings.map(
258
+ (f) => `${strict ? 'ERROR' : 'WARN '} ${f.id} ${f.file}:${String(f.line)} ${f.evidence}`,
259
+ );
260
+ lines.push(
261
+ `\n${String(findings.length)} finding(s). Warn-first: this check blocks nothing until its ` +
262
+ 'false-positive rate over merged history is measured and written down.',
263
+ );
264
+ return lines.join('\n');
265
+ }
266
+
267
+ /* ----------------------------------------------------------------- corpus */
268
+
269
+ interface Fixture {
270
+ id: string;
271
+ diff: string;
272
+ kind: 'positive' | 'negative';
273
+ expect: string[];
274
+ why: string;
275
+ }
276
+
277
+ export function loadCorpus(root: string = CORPUS): Fixture[] {
278
+ const raw = fs.readFileSync(path.join(root, 'manifest.json'), 'utf-8');
279
+ return (JSON.parse(raw) as { fixtures: Fixture[] }).fixtures;
280
+ }
281
+
282
+ function runCorpus(write: (s: string) => void): number {
283
+ const fixtures = loadCorpus();
284
+ let failed = 0;
285
+ for (const f of fixtures) {
286
+ const got = scanDiff(fs.readFileSync(path.join(CORPUS, f.diff), 'utf-8'))
287
+ .map((x) => x.id)
288
+ .sort();
289
+ const want = [...f.expect].sort();
290
+ const ok = JSON.stringify(got) === JSON.stringify(want);
291
+ if (!ok) failed += 1;
292
+ write(
293
+ `${ok ? 'OK ' : 'FAIL'} ${f.kind.padEnd(8)} ${f.id.padEnd(32)} ` +
294
+ `expect [${want.join(', ')}] got [${got.join(', ')}]\n`,
295
+ );
296
+ }
297
+ write(`\nscanned: ${String(fixtures.length)}\n`);
298
+ write(`${String(fixtures.length - failed)}/${String(fixtures.length)} fixtures behaved\n`);
299
+ return failed > 0 ? 1 : 0;
300
+ }
301
+
302
+ /* ------------------------------------------------------------------ range */
303
+
304
+ /**
305
+ * Count findings over a recorded commit range — the baseline step 3.2 requires
306
+ * before promotion is discussed at all.
307
+ */
308
+ function runRange(range: string, write: (s: string) => void): number {
309
+ const revs = execFileSync('git', ['rev-list', '--no-merges', range], {
310
+ cwd: REPO_ROOT,
311
+ encoding: 'utf-8',
312
+ maxBuffer: 256 * 1024 * 1024,
313
+ })
314
+ .split('\n')
315
+ .filter((l) => l.trim() !== '');
316
+ let total = 0;
317
+ const perCommit: Array<{ sha: string; n: number }> = [];
318
+ for (const sha of revs) {
319
+ const diff = execFileSync('git', ['show', '--format=', '--unified=3', sha], {
320
+ cwd: REPO_ROOT,
321
+ encoding: 'utf-8',
322
+ maxBuffer: 256 * 1024 * 1024,
323
+ });
324
+ const n = scanDiff(diff).length;
325
+ total += n;
326
+ if (n > 0) perCommit.push({ sha: sha.slice(0, 9), n });
327
+ }
328
+ for (const c of perCommit) write(`${c.sha} ${String(c.n)} finding(s)\n`);
329
+ write(`\nrange: ${range}\n`);
330
+ write(`scanned: ${String(revs.length)}\n`);
331
+ write(`commits with at least one finding: ${String(perCommit.length)}\n`);
332
+ write(`total findings: ${String(total)}\n`);
333
+ return 0;
334
+ }
335
+
336
+ /* -------------------------------------------------------------- self-test */
337
+
338
+ function selfTest(): number {
339
+ const rel = 'src/scripts/detect_silent_catch.ts';
340
+ const cases: SelfTestCase[] = loadCorpus().map((f) => ({
341
+ name: `${f.kind}/${f.id}`,
342
+ expect: f.kind === 'positive' ? 'reject' : 'accept',
343
+ // `--strict` is what makes a report-mode check provable: without it a
344
+ // planted violation could never turn it red, and a check that cannot
345
+ // go red proves nothing.
346
+ run: () => runGateCli(REPO_ROOT, rel, ['--diff', path.join(CORPUS, f.diff), '--strict'], REPO_ROOT),
347
+ }));
348
+ return runSelfTest({
349
+ gate: 'detect_silent_catch',
350
+ cases,
351
+ minCases: SELF_TEST_MIN_CASES,
352
+ minRejectCases: SELF_TEST_MIN_REJECT,
353
+ });
354
+ }
355
+
356
+ /* ------------------------------------------------------------------- main */
357
+
358
+ function usage(): never {
359
+ process.stderr.write(
360
+ 'usage: detect_silent_catch --diff <file> [--strict] [--json]\n' +
361
+ ' detect_silent_catch --range <base>..<head>\n' +
362
+ ' detect_silent_catch --corpus | --self-test\n',
363
+ );
364
+ process.exit(2);
365
+ }
366
+
367
+ export function main(argv: readonly string[]): number {
368
+ if (argv.includes('--self-test')) return selfTest();
369
+ if (argv.includes('--corpus')) return runCorpus((s) => process.stdout.write(s));
370
+
371
+ let diffPath: string | null = null;
372
+ let range: string | null = null;
373
+ let strict = false;
374
+ let json = false;
375
+ for (let i = 0; i < argv.length; i += 1) {
376
+ const a = argv[i];
377
+ if (a === '--diff') { diffPath = argv[++i] ?? null; }
378
+ else if (a === '--range') { range = argv[++i] ?? null; }
379
+ else if (a === '--strict') { strict = true; }
380
+ else if (a === '--json') { json = true; }
381
+ else usage();
382
+ }
383
+ if (range !== null) return runRange(range, (s) => process.stdout.write(s));
384
+ if (diffPath === null) usage();
385
+
386
+ const findings = scanDiff(fs.readFileSync(diffPath, 'utf-8'));
387
+ process.stdout.write(
388
+ (json ? JSON.stringify(findings, null, 2) : render(findings, strict)) + '\n',
389
+ );
390
+ return strict && findings.length > 0 ? 1 : 0;
391
+ }
392
+
393
+ const invokedDirectly =
394
+ process.argv[1] !== undefined && pathToFileURL(process.argv[1]).href === import.meta.url;
395
+ if (invokedDirectly) {
396
+ try {
397
+ process.exit(main(process.argv.slice(2)));
398
+ } catch (err) {
399
+ // Never swallowed — this file's whole subject is the swallow.
400
+ process.stderr.write(`detect_silent_catch crashed: ${String(err)}\n`);
401
+ process.exit(2);
402
+ }
403
+ }