@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.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +170 -79
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/merge.md +35 -44
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +2 -0
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +27 -1
- package/dist/agent-src/skills/code-intelligence/SKILL.md +60 -12
- package/dist/agent-src/skills/complexity-first-planning/SKILL.md +27 -0
- package/dist/agent-src/skills/design-review/SKILL.md +2 -0
- package/dist/agent-src/skills/feature-planning/SKILL.md +34 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +37 -12
- package/dist/agent-src/skills/git-workflow/references/push-closes-its-loop.md +176 -0
- package/dist/agent-src/skills/laravel-mail/SKILL.md +44 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +40 -0
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +12 -0
- package/dist/agent-src/skills/verify-repair-loop/SKILL.md +53 -8
- package/dist/agent-src/skills/worktree-lifecycle/SKILL.md +3 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +335 -659
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +15 -15
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +959 -1304
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +671 -1002
- package/dist/shared/settingsConsent.js +1 -1
- package/package.json +1 -1
- package/src/config/conformance-claim-baseline.json +9 -0
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/release-gate-locality.yml +129 -0
- package/src/scripts/_lib/release_highlights.ts +172 -1
- package/src/scripts/_lib/release_material.ts +66 -0
- package/src/scripts/_lib/repeated_run.ts +77 -0
- package/src/scripts/_lib/tamper_vocabulary.ts +242 -0
- package/src/scripts/_lib/unified_diff.ts +121 -0
- package/src/scripts/check_finding_dispositions.ts +179 -3
- package/src/scripts/check_installed_hooks_fresh.ts +381 -0
- package/src/scripts/check_release_highlights.ts +25 -13
- package/src/scripts/check_suppression_hygiene.ts +11 -0
- package/src/scripts/code_graph/build.ts +230 -33
- package/src/scripts/code_graph/extract.ts +168 -33
- package/src/scripts/code_graph/types.ts +37 -2
- package/src/scripts/code_graph/validate.ts +12 -1
- package/src/scripts/conformance_scan.ts +2 -2
- package/src/scripts/detect_silent_catch.ts +403 -0
- package/src/scripts/detect_verification_tampering.ts +538 -0
- package/src/scripts/git_authorization_hook.ts +153 -17
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +33 -19
- package/src/scripts/hooks/block_no_verify.ts +3 -3
- package/src/scripts/hooks/concern_registry.ts +2 -2
- package/src/scripts/hooks/evidence_independence.ts +21 -2
- package/src/scripts/hooks/{block_unauthorized_git.ts → git_command_classifier.ts} +25 -369
- package/src/scripts/hooks/push_settle_hook.ts +250 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +1 -1
- package/src/scripts/install-hooks.sh +134 -1
- package/src/scripts/language_mirror_hook.ts +27 -5
- package/src/scripts/release.ts +31 -143
- package/src/scripts/release_drill.ts +71 -1
- package/src/scripts/release_publication.ts +243 -3
- package/src/scripts/release_verify.ts +220 -0
- package/src/scripts/schemas/review-findings.schema.json +1 -1
- package/src/scripts/schemas/skill.schema.json +11 -1
- package/src/scripts/self_review_gate.ts +10 -1
- 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
|
-
|
|
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
|
-
|
|
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([
|
|
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
|
-
* +
|
|
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/
|
|
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
|
+
}
|