@holmes-lab/holmes-kit 0.4.0 → 0.5.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 (39) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/ci-gate.js +3 -1
  4. package/dist/holmes/cpg/foundation/ast-store.d.ts +1 -1
  5. package/dist/holmes/cpg/foundation/ast-store.js +8 -0
  6. package/dist/holmes/cpg/foundation/cfg.js +137 -5
  7. package/dist/holmes/cpg/foundation/ddg.js +101 -3
  8. package/dist/holmes/cpg/foundation/language-matrix.js +22 -7
  9. package/dist/holmes/cpg/language-parser-walk.js +5 -1
  10. package/dist/holmes/cpg/language-parser.d.ts +1 -0
  11. package/dist/holmes/hooks/pre-tool-use.d.ts +17 -2
  12. package/dist/holmes/hooks/pre-tool-use.js +21 -4
  13. package/dist/holmes/mcp/handlers.d.ts +17 -5
  14. package/dist/holmes/mcp/handlers.js +98 -1
  15. package/dist/holmes/mcp/supervisor.d.ts +24 -0
  16. package/dist/holmes/mcp/supervisor.js +63 -6
  17. package/dist/holmes/mcp/tool-schemas.js +8 -1
  18. package/dist/holmes/project/root.d.ts +1 -0
  19. package/dist/holmes/project/root.js +30 -0
  20. package/dist/holmes/review/test-runner.d.ts +15 -0
  21. package/dist/holmes/review/test-runner.js +87 -9
  22. package/dist/holmes/rtm/dataflow-taint.js +5 -1
  23. package/dist/holmes/rtm/flow-sensitive-taint.d.ts +41 -0
  24. package/dist/holmes/rtm/flow-sensitive-taint.js +109 -0
  25. package/dist/holmes/rtm/reaching-def-filter.d.ts +44 -0
  26. package/dist/holmes/rtm/reaching-def-filter.js +167 -0
  27. package/dist/holmes/rtm/sink-matching.d.ts +26 -0
  28. package/dist/holmes/rtm/sink-matching.js +73 -0
  29. package/dist/holmes/rtm/taint-benchmark.d.ts +13 -0
  30. package/dist/holmes/rtm/taint-benchmark.js +97 -9
  31. package/dist/holmes/rtm/taint-vocabulary.d.ts +24 -0
  32. package/dist/holmes/rtm/taint-vocabulary.js +58 -0
  33. package/grammars/manifest.json +30 -0
  34. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  35. package/grammars/tree-sitter-cpp.wasm +0 -0
  36. package/grammars/tree-sitter-go.wasm +0 -0
  37. package/grammars/tree-sitter-java.wasm +0 -0
  38. package/grammars/tree-sitter-rust.wasm +0 -0
  39. package/package.json +1 -1
@@ -312,15 +312,27 @@ declare function makeRawHandlers(store: SpecStore, opts?: ElicitOpts): {
312
312
  */
313
313
  taint_scan(a: {
314
314
  root: string;
315
+ dataFlow?: boolean;
315
316
  }): Promise<{
317
+ kind: string;
318
+ limits: ("name-based matching" | "no def-use" | "no sanitizers" | "call-edge-only")[];
319
+ maxPaths: number | undefined;
320
+ truncated: number;
321
+ pairs: import("../rtm/taint").TaintPair[];
322
+ } | {
316
323
  ok: boolean;
317
324
  reason: string;
318
- kind?: undefined;
319
- limits?: undefined;
320
- maxPaths?: undefined;
321
- truncated?: undefined;
322
- pairs?: undefined;
323
325
  } | {
326
+ dataFlow: {
327
+ findings: Record<string, unknown>[];
328
+ /** Findings the reaching-definition evidence positively refuted (dead or fully sanitized). */
329
+ refutedByReachingDefs: number;
330
+ limits: ("no field sensitivity (obj.a and obj.b are one value)" | "no aliasing" | "no path sensitivity" | "no container/element tracking" | "no reflection or dynamic dispatch" | "sink matching is by callee NAME with no receiver type — RE.exec(s) is indistinguishable from child_process.exec(s)" | "no anonymous-function parameters (arrow/function-expression params are not bound)" | "not statement-order sensitive — a variable tainted anywhere in a function is tainted throughout it" | "reports reachability of tainted data to a sink, never exploitability")[];
331
+ converged: boolean;
332
+ truncated: number;
333
+ /** Which languages this run could actually judge, so a zero is readable. */
334
+ languagesAnalysed: (string | null)[];
335
+ };
324
336
  kind: string;
325
337
  limits: ("name-based matching" | "no def-use" | "no sanitizers" | "call-edge-only")[];
326
338
  maxPaths: number | undefined;
@@ -1688,7 +1688,104 @@ function makeRawHandlers(store, opts) {
1688
1688
  (0, rtm_builder_1.buildRtm)(await store.list(), scanned, g);
1689
1689
  const cfg = taint_1.DEFAULT_TAINT_CONFIG;
1690
1690
  const { pairs, truncated } = (0, taint_1.taintReachability)(g, cfg);
1691
- return { kind: 'call-reachability', limits: [...taint_1.TAINT_LIMITS], maxPaths: cfg.maxPaths, truncated, pairs };
1691
+ const base = { kind: 'call-reachability', limits: [...taint_1.TAINT_LIMITS], maxPaths: cfg.maxPaths, truncated, pairs };
1692
+ if (a.dataFlow !== true)
1693
+ return base;
1694
+ // @implements A-SPEC-512.2
1695
+ // OPT-IN def-use lane. Measured 2026-09-02: extracting data-flow facts over this
1696
+ // repository costs 7.6s on top of the scan, which is why this follows the `cpg_scan
1697
+ // lints:true` precedent instead of running by default. Before this slice the engine
1698
+ // (A-SPEC-140.1, with its own 13-case benchmark) had NO caller at all — capability
1699
+ // present, unreachable. Findings are filtered by reaching definitions: inter-procedural
1700
+ // arrival is preserved, and only what the DDG positively refutes is dropped.
1701
+ const { TreeSitterTsParser } = require('../cpg/language-parser');
1702
+ const { taintDataFlow, DATAFLOW_LIMITS } = require('../rtm/dataflow-taint');
1703
+ const { filterFindingsForFile } = require('../rtm/reaching-def-filter');
1704
+ // @implements A-SPEC-513.1 — vocabulary is SELECTED per file language, never unioned:
1705
+ // Python's `input(` must not taint a TypeScript `input`, and `req.body` means nothing in
1706
+ // Python. Measured on jarvis: the shipped (JS) list missed `subprocess.run`, `os.environ`
1707
+ // and `input(` entirely, so a Python repository could only ever report zero.
1708
+ const { vocabularyFor } = require('../rtm/taint-vocabulary');
1709
+ const parser = new TreeSitterTsParser();
1710
+ const factSet = [];
1711
+ const pythonFiles = [];
1712
+ for (const f of scanned) {
1713
+ const vocab = vocabularyFor(f.sourcePath);
1714
+ if (!vocab)
1715
+ continue;
1716
+ if (/\.py$/i.test(f.sourcePath)) {
1717
+ pythonFiles.push(f.sourcePath);
1718
+ continue;
1719
+ }
1720
+ let src;
1721
+ try {
1722
+ src = fs.readFileSync(path.join(root, f.sourcePath), 'utf8');
1723
+ }
1724
+ catch {
1725
+ continue;
1726
+ }
1727
+ const facts = parser.extractDataFlow(src, f.sourcePath.endsWith('x') ? 'tsx' : 'typescript');
1728
+ if (facts)
1729
+ factSet.push({ file: f.sourcePath, facts });
1730
+ }
1731
+ const tsVocab = vocabularyFor('x.ts');
1732
+ const raw = taintDataFlow(factSet, tsVocab);
1733
+ const byFile = new Map();
1734
+ for (const fnd of raw.findings)
1735
+ byFile.set(fnd.file, [...(byFile.get(fnd.file) ?? []), fnd]);
1736
+ const kept = [];
1737
+ let refuted = 0;
1738
+ for (const [rel, fs2] of byFile) {
1739
+ const out = await filterFindingsForFile(root, rel, fs2, tsVocab);
1740
+ kept.push(...out.kept.map((f) => ({ ...f, lane: 'facts+reaching-defs' })));
1741
+ refuted += out.removed.length;
1742
+ }
1743
+ // @implements A-SPEC-513.1 — Python takes the flow-sensitive lane directly: its facts
1744
+ // extractor has no Python walk, but its CFG/DDG do (S-510.7), and the judgement above
1745
+ // them is language-agnostic.
1746
+ const pyVocab = vocabularyFor('x.py');
1747
+ if (pythonFiles.length > 0) {
1748
+ const { parseAst } = require('../cpg/foundation/ast-store');
1749
+ const { cfgOf, functionsIn } = require('../cpg/foundation/cfg');
1750
+ const { ddgOf } = require('../cpg/foundation/ddg');
1751
+ const { flowSensitiveTaint } = require('../rtm/flow-sensitive-taint');
1752
+ for (const rel of pythonFiles) {
1753
+ let src;
1754
+ try {
1755
+ src = fs.readFileSync(path.join(root, rel), 'utf8');
1756
+ }
1757
+ catch {
1758
+ continue;
1759
+ }
1760
+ const ast = await parseAst(src, rel);
1761
+ if (!ast || ast.errorCount > 0)
1762
+ continue;
1763
+ for (const fn of functionsIn(ast)) {
1764
+ const fcfg = cfgOf(ast, fn, src);
1765
+ if ('unsupported' in fcfg)
1766
+ continue;
1767
+ const fddg = ddgOf(ast, fcfg, fn, src);
1768
+ if ('unsupported' in fddg)
1769
+ continue;
1770
+ const r = flowSensitiveTaint({ ast, cfg: fcfg, ddg: fddg, source: src, fnName: rel, config: pyVocab });
1771
+ for (const f of r.findings)
1772
+ kept.push({ ...f, file: rel, lane: 'flow-sensitive' });
1773
+ }
1774
+ }
1775
+ }
1776
+ return {
1777
+ ...base,
1778
+ dataFlow: {
1779
+ findings: kept,
1780
+ /** Findings the reaching-definition evidence positively refuted (dead or fully sanitized). */
1781
+ refutedByReachingDefs: refuted,
1782
+ limits: [...DATAFLOW_LIMITS],
1783
+ converged: raw.converged,
1784
+ truncated: raw.truncated,
1785
+ /** Which languages this run could actually judge, so a zero is readable. */
1786
+ languagesAnalysed: [...new Set([factSet.length > 0 ? 'typescript' : null, pythonFiles.length > 0 ? 'python' : null].filter(Boolean))],
1787
+ },
1788
+ };
1692
1789
  }
1693
1790
  finally {
1694
1791
  g.close();
@@ -15,6 +15,30 @@ export declare function autoreloadEnabled(env: NodeJS.ProcessEnv): boolean;
15
15
  * question, which is worse than the staleness it was fixing.
16
16
  */
17
17
  export declare function shouldSwap(loaded: string, disk: string | undefined, inflight: number): boolean;
18
+ export interface ResponseCounter {
19
+ /** How many JSON-RPC replies COMPLETED in this chunk. */
20
+ push(chunk: string): number;
21
+ }
22
+ /**
23
+ * Counts JSON-RPC replies by LINE, because a chunk is not a line.
24
+ *
25
+ * The previous rule — `line.startsWith('{') && line.includes('"id"')` applied to each stdout chunk —
26
+ * held only while replies were small. JSON-RPC puts `"id"` at the END of a reply object, so a 1.4MB
27
+ * response arrives as ~22 chunks of which the first begins with `{` but carries no `"id"`, and the
28
+ * last carries `"id"` but does not begin with `{`. Measured: zero decrements, `inflight` stuck at 1
29
+ * forever, and the child never replaced again — live, the server ran three commits behind across
30
+ * four rebuilds while the isolated supervisor swapped correctly every time.
31
+ *
32
+ * The root was an asymmetry inside this one file: the stdin direction already buffers into lines and
33
+ * only this direction did not. It is fixed here rather than by buffering the line, because holding a
34
+ * 1.4MB string purely to count it would allocate a second copy of something already relayed. Two
35
+ * bits and a three-character tail are all the decision needs.
36
+ *
37
+ * Pure and exported for the same reason `shouldSwap` is: the sibling of this bug (incrementing
38
+ * before deciding, A-SPEC-162) also passed every unit test and was caught only by a live probe.
39
+ * The predicate was testable and its caller was not, which is exactly where both defects lived.
40
+ */
41
+ export declare function createResponseCounter(): ResponseCounter;
18
42
  /**
19
43
  * Runs the real server as a child and relays stdio, replacing the child when the build changes.
20
44
  *
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.Supervisor = exports.AUTORELOAD_ENV = void 0;
37
37
  exports.autoreloadEnabled = autoreloadEnabled;
38
38
  exports.shouldSwap = shouldSwap;
39
+ exports.createResponseCounter = createResponseCounter;
39
40
  // @implements A-SPEC-162
40
41
  const node_child_process_1 = require("node:child_process");
41
42
  const path = __importStar(require("node:path"));
@@ -66,6 +67,59 @@ function shouldSwap(loaded, disk, inflight) {
66
67
  return false;
67
68
  return inflight === 0;
68
69
  }
70
+ const ID_NEEDLE = '"id"';
71
+ // @implements A-SPEC-516.1
72
+ /**
73
+ * Counts JSON-RPC replies by LINE, because a chunk is not a line.
74
+ *
75
+ * The previous rule — `line.startsWith('{') && line.includes('"id"')` applied to each stdout chunk —
76
+ * held only while replies were small. JSON-RPC puts `"id"` at the END of a reply object, so a 1.4MB
77
+ * response arrives as ~22 chunks of which the first begins with `{` but carries no `"id"`, and the
78
+ * last carries `"id"` but does not begin with `{`. Measured: zero decrements, `inflight` stuck at 1
79
+ * forever, and the child never replaced again — live, the server ran three commits behind across
80
+ * four rebuilds while the isolated supervisor swapped correctly every time.
81
+ *
82
+ * The root was an asymmetry inside this one file: the stdin direction already buffers into lines and
83
+ * only this direction did not. It is fixed here rather than by buffering the line, because holding a
84
+ * 1.4MB string purely to count it would allocate a second copy of something already relayed. Two
85
+ * bits and a three-character tail are all the decision needs.
86
+ *
87
+ * Pure and exported for the same reason `shouldSwap` is: the sibling of this bug (incrementing
88
+ * before deciding, A-SPEC-162) also passed every unit test and was caught only by a live probe.
89
+ * The predicate was testable and its caller was not, which is exactly where both defects lived.
90
+ */
91
+ function createResponseCounter() {
92
+ let startsWithBrace = null;
93
+ let sawId = false;
94
+ let carry = '';
95
+ const reset = () => { startsWithBrace = null; sawId = false; carry = ''; };
96
+ return {
97
+ push(chunk) {
98
+ let counted = 0;
99
+ const parts = chunk.split('\n');
100
+ parts.forEach((part, i) => {
101
+ if (startsWithBrace === null) {
102
+ const firstChar = part.trimStart().charAt(0);
103
+ if (firstChar !== '')
104
+ startsWithBrace = firstChar === '{';
105
+ }
106
+ // The carry covers a needle torn across the boundary; without it a reply whose `"id"` lands
107
+ // on a chunk seam is invisible to both chunks.
108
+ if (!sawId && (carry + part).includes(ID_NEEDLE))
109
+ sawId = true;
110
+ if (i < parts.length - 1) {
111
+ if (startsWithBrace === true && sawId)
112
+ counted++;
113
+ reset(); // the line ended; nothing carries into the next
114
+ }
115
+ else {
116
+ carry = part.slice(-(ID_NEEDLE.length - 1));
117
+ }
118
+ });
119
+ return counted;
120
+ },
121
+ };
122
+ }
69
123
  /**
70
124
  * Runs the real server as a child and relays stdio, replacing the child when the build changes.
71
125
  *
@@ -160,15 +214,18 @@ class Supervisor {
160
214
  });
161
215
  child.stderr.on('data', (d) => process.stderr.write(d));
162
216
  this.child = child;
217
+ // @implements A-SPEC-516.1 — one counter per child: a half-read line from the process being
218
+ // replaced must not be finished by its successor's first chunk.
219
+ const counter = createResponseCounter();
163
220
  child.stdout.on('data', (d) => {
164
221
  const text = d.toString();
165
- // Count replies out so `inflight` returns to zero; the id itself does not matter here, only
166
- // that a response arrived for something that was counted in.
167
- for (const line of text.split('\n')) {
168
- if (line.trim().startsWith('{') && line.includes('"id"') && this.inflight > 0)
169
- this.inflight--;
170
- }
222
+ // Relay FIRST. Counting is an observation and must never delay or alter the bytes.
171
223
  stdout.write(text);
224
+ // Count replies out so `inflight` returns to zero; the id itself does not matter here, only
225
+ // that a response arrived for something that was counted in. Never below zero — a negative
226
+ // count would let a swap happen mid-request, which is the mis-delivered answer this whole
227
+ // mechanism defers swaps to avoid.
228
+ this.inflight = Math.max(0, this.inflight - counter.push(text));
172
229
  });
173
230
  }
174
231
  stop() {
@@ -182,7 +182,14 @@ exports.TOOL_SCHEMAS = {
182
182
  description: 'Call-graph taint REACHABILITY screen: source-named functions (getenv/argv/req.body/…) that reach sink-named functions (exec/eval/query/…) through call edges. Returns { kind:"call-reachability", limits, maxPaths, truncated, pairs } where each pair is a source→sink candidate call path. This is a SCREENING signal that routes a security review — NOT a data-flow proof and NOT a vulnerability verdict (no def-use, no sanitizers, name-based matching, call-edge-only, as the limits state).',
183
183
  inputSchema: {
184
184
  type: 'object',
185
- properties: { root: ROOT },
185
+ properties: {
186
+ root: ROOT,
187
+ // @implements A-SPEC-512.2 — opt-in def-use lane (measured +7.6s on a 500-file repo), the
188
+ // same shape `cpg_scan lints` uses. Adds `dataFlow: { findings, refutedByReachingDefs,
189
+ // limits }`: source→sink flows recovered from definitions and uses, with findings the
190
+ // reaching-definition evidence refutes (dead or fully sanitized values) removed.
191
+ dataFlow: { type: 'boolean', description: 'Also run the def-use data-flow lane (slower). Adds a `dataFlow` section with reaching-definition-filtered findings.' },
192
+ },
186
193
  required: ['root'],
187
194
  },
188
195
  },
@@ -1,4 +1,5 @@
1
1
  export declare function cleanSubprocessEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
2
+ export declare function cleanTestEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
2
3
  /**
3
4
  * Where a project begins, for a harness that must not require version control.
4
5
  *
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.canonicalPath = exports.MARKER = void 0;
37
37
  exports.cleanSubprocessEnv = cleanSubprocessEnv;
38
+ exports.cleanTestEnv = cleanTestEnv;
38
39
  exports.resolveProjectRoot = resolveProjectRoot;
39
40
  // @implements A-SPEC-205
40
41
  // @implements A-SPEC-128
@@ -56,6 +57,35 @@ function cleanSubprocessEnv(env = process.env) {
56
57
  }
57
58
  return cleaned;
58
59
  }
60
+ // @implements A-SPEC-514.1
61
+ /**
62
+ * The environment a TEST subprocess gets — the secret scrub plus the configuration that steers a
63
+ * project's own judgement.
64
+ *
65
+ * `cleanSubprocessEnv` removes secrets, and configuration is not a secret, which is exactly how
66
+ * `HOLMES_SPECS` reached jest. Measured 2026-09-02: `test_run` reported 1 failed / 2142 passed
67
+ * twice on a commit whose full suite was green, because the MCP server's `HOLMES_SPECS` reached
68
+ * the suite and `isGovernedProject` short-circuited on it. The recorded lesson until then was
69
+ * contamination masking RED as GREEN; this is the same root pointing the other way. Evidence that
70
+ * depends on the shell it was measured in is not evidence.
71
+ *
72
+ * The list is EXPLICIT rather than a `/^HOLMES_/` sweep. A prefix sweep would pass every test here
73
+ * and still take a variable some project's own suite needs — the same false red, from the other
74
+ * side.
75
+ *
76
+ * `HOLMES_GATE_BYPASS` is scrubbed too: the operator's out-of-band escape hatch leaking into an
77
+ * evidence run would disarm every test that verifies a gate, and that contamination masks red as
78
+ * green — the worse direction of the two.
79
+ */
80
+ const TEST_SCRUB_KEYS = new Set(['HOLMES_SPECS', 'HOLMES_GATE_BYPASS', 'HOLMES_MCP_AUTORELOAD', 'HOLMES_MCP_PROFILE']);
81
+ function cleanTestEnv(env = process.env) {
82
+ const cleaned = cleanSubprocessEnv(env);
83
+ for (const k of Object.keys(cleaned)) {
84
+ if (TEST_SCRUB_KEYS.has(k.toUpperCase()))
85
+ delete cleaned[k];
86
+ }
87
+ return cleaned;
88
+ }
59
89
  /** The marker directory that makes a directory a Holmes-Kit project. */
60
90
  exports.MARKER = '.ax';
61
91
  /**
@@ -160,3 +160,18 @@ export declare function runGo(files: string[], mode: TestRunPlan['mode'], cwd: s
160
160
  * gate that ran part of its scope has verified less than it reports.
161
161
  */
162
162
  export declare function runTestScope(scope: TestScope, cwd: string): TestRunResult;
163
+ export declare function tailOf(s: string, opts?: number | {
164
+ lines?: number;
165
+ maxChars?: number;
166
+ }): string;
167
+ /**
168
+ * The summary a green jest run always had — it was inside the `--json` payload rather than absent.
169
+ *
170
+ * jest writes its human summary to stderr, and the success branch reads stdout only, so the tail of
171
+ * a passing run carried no counts at all. The counts are right there in the JSON; this assembles
172
+ * them into the two lines a reader actually wanted.
173
+ *
174
+ * Returns null on anything it cannot read, and the caller falls back to the truncated original:
175
+ * a failed summary must not turn partial information into none.
176
+ */
177
+ export declare function summarizeJestJson(stdout: string): string | null;
@@ -46,11 +46,16 @@ exports.runGradle = runGradle;
46
46
  exports.runDotnet = runDotnet;
47
47
  exports.runGo = runGo;
48
48
  exports.runTestScope = runTestScope;
49
+ exports.tailOf = tailOf;
50
+ exports.summarizeJestJson = summarizeJestJson;
49
51
  // @implements A-SPEC-102.1
50
52
  const node_child_process_1 = require("node:child_process");
51
53
  const fs = __importStar(require("node:fs"));
52
54
  const os = __importStar(require("node:os"));
53
55
  const path = __importStar(require("node:path"));
56
+ // @implements A-SPEC-514.1 — every TEST spawn goes out through a cleaned environment. The
57
+ // `--version` probes above stay as they are: they do not judge anything.
58
+ const root_1 = require("../project/root");
54
59
  const python_env_1 = require("./python-env");
55
60
  // Runner stdout ceiling. Measured 2026-09-01 on this repo: a full `jest --json` for 289 suites /
56
61
  // 4568 tests emits >2MB, and execFileSync's DEFAULT maxBuffer (1MB) kills the child with ENOBUFS —
@@ -239,8 +244,11 @@ function runJest(files, mode, cwd) {
239
244
  ? [...pre, '--silent', '--json', '--runTestsByPath', '--', ...safeFiles]
240
245
  : [...pre, '--silent', '--json'];
241
246
  try {
242
- const out = (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
243
- return { passed: true, tail: tailOf(out), executed: parseExecutedCounts(out, cwd) };
247
+ const out = (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
248
+ // @implements A-SPEC-515.1 the green path says what happened instead of handing back the
249
+ // whole `--json` document. `tailOf` wraps the result too, so an unexpectedly long summary is
250
+ // still bounded: a cap that any one path can escape is not a cap.
251
+ return { passed: true, tail: tailOf(summarizeJestJson(out) ?? out), executed: parseExecutedCounts(out, cwd) };
244
252
  }
245
253
  catch (e) {
246
254
  const err = e;
@@ -277,7 +285,7 @@ function runPytest(files, mode, cwd) {
277
285
  }
278
286
  };
279
287
  try {
280
- const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
288
+ const out = (0, node_child_process_1.execFileSync)(python, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
281
289
  return { passed: true, tail: tailOf(out), executed: read() };
282
290
  }
283
291
  catch (e) {
@@ -332,7 +340,7 @@ function runCargo(files, _mode, cwd, opts) {
332
340
  let out = '';
333
341
  let ok = true;
334
342
  try {
335
- out = (0, node_child_process_1.execFileSync)('cargo', ['test'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
343
+ out = (0, node_child_process_1.execFileSync)('cargo', ['test'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
336
344
  }
337
345
  catch (e) {
338
346
  const err = e;
@@ -425,7 +433,7 @@ function runGradle(files, _mode, cwd, opts) {
425
433
  }
426
434
  let ok = true;
427
435
  try {
428
- (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
436
+ (0, node_child_process_1.execFileSync)(cmd, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
429
437
  }
430
438
  catch {
431
439
  ok = false;
@@ -457,7 +465,7 @@ function runDotnet(files, _mode, cwd, opts) {
457
465
  const report = path.join(dir, 'junit.xml');
458
466
  let ok = true;
459
467
  try {
460
- (0, node_child_process_1.execFileSync)('dotnet', ['test', '--logger', `junit;LogFilePath=${report}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
468
+ (0, node_child_process_1.execFileSync)('dotnet', ['test', '--logger', `junit;LogFilePath=${report}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
461
469
  }
462
470
  catch {
463
471
  ok = false;
@@ -504,7 +512,7 @@ function runGo(files, mode, cwd, opts) {
504
512
  const runDir = (dirArg, creditFiles) => {
505
513
  let out = '';
506
514
  try {
507
- out = (0, node_child_process_1.execFileSync)('go', ['test', '-json', dirArg], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER });
515
+ out = (0, node_child_process_1.execFileSync)('go', ['test', '-json', dirArg], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: RUNNER_MAX_BUFFER, env: (0, root_1.cleanTestEnv)() });
508
516
  }
509
517
  catch (e) {
510
518
  const err = e;
@@ -617,6 +625,76 @@ function runTestScope(scope, cwd) {
617
625
  executedByFile: {}, unsupported, ranWith: [],
618
626
  };
619
627
  }
620
- function tailOf(s, n = 6) {
621
- return s.trim().split('\n').slice(-n).join('\n');
628
+ // @implements A-SPEC-515.1
629
+ /**
630
+ * The last few lines of a runner's output, bounded by SIZE as well as by line count.
631
+ *
632
+ * A line budget is only a size budget while the lines are short, and jest `--json` breaks that
633
+ * assumption completely: it emits one line. Measured 2026-09-02 — a GREEN `test_run` returned
634
+ * 1,432,092 characters, of which this field was 1,421,327 (99.2%) across "6 lines" whose longest
635
+ * was 1,420,959. The usefulness was inverted: a red run gave six clean lines of stderr summary,
636
+ * and a green run gave the whole document.
637
+ *
638
+ * The END is what survives a cut. A runner's conclusion is always at the bottom.
639
+ */
640
+ const TAIL_MAX_CHARS = 4000;
641
+ function tailOf(s, opts = {}) {
642
+ // The numeric spelling is the one the go adapter uses (`tailOf(err.stderr, 2)`); keeping it means
643
+ // this change cannot silently alter a caller that only ever wanted fewer lines.
644
+ const { lines = 6, maxChars = TAIL_MAX_CHARS } = typeof opts === 'number' ? { lines: opts } : opts;
645
+ const picked = s.trim().split('\n').slice(-lines).join('\n');
646
+ if (picked.length <= maxChars)
647
+ return picked;
648
+ const dropped = picked.length - maxChars;
649
+ return `[…앞부분 ${dropped}자 생략…]\n${picked.slice(-maxChars)}`;
650
+ }
651
+ // @implements A-SPEC-515.1
652
+ /**
653
+ * The summary a green jest run always had — it was inside the `--json` payload rather than absent.
654
+ *
655
+ * jest writes its human summary to stderr, and the success branch reads stdout only, so the tail of
656
+ * a passing run carried no counts at all. The counts are right there in the JSON; this assembles
657
+ * them into the two lines a reader actually wanted.
658
+ *
659
+ * Returns null on anything it cannot read, and the caller falls back to the truncated original:
660
+ * a failed summary must not turn partial information into none.
661
+ */
662
+ function summarizeJestJson(stdout) {
663
+ // The payload is not the whole of stdout. A suite that logs anything prints it BEFORE the JSON,
664
+ // and this repository's own suite does: measured, the first live run after this function landed
665
+ // still returned 1.4MB because `JSON.parse(stdout)` threw on the console noise and the fallback
666
+ // handed back the raw document. `parseExecutedCounts` already locates the payload from the first
667
+ // `{`; using a second, stricter rule for the same string is how the two quietly disagree.
668
+ const start = stdout.indexOf('{');
669
+ if (start < 0)
670
+ return null;
671
+ let j;
672
+ try {
673
+ j = JSON.parse(stdout.slice(start));
674
+ }
675
+ catch {
676
+ return null;
677
+ }
678
+ if (j === null || typeof j !== 'object')
679
+ return null;
680
+ const num = (k) => (typeof j[k] === 'number' ? j[k] : null);
681
+ const totalSuites = num('numTotalTestSuites');
682
+ const totalTests = num('numTotalTests');
683
+ if (totalSuites === null || totalTests === null)
684
+ return null;
685
+ const line = (label, failed, skipped, passed, total) => {
686
+ const parts = [];
687
+ if (failed !== null && failed > 0)
688
+ parts.push(`${failed} failed`);
689
+ if (skipped !== null && skipped > 0)
690
+ parts.push(`${skipped} skipped`);
691
+ if (passed !== null)
692
+ parts.push(`${passed} passed`);
693
+ parts.push(`${total} total`);
694
+ return `${label}${parts.join(', ')}`;
695
+ };
696
+ return [
697
+ line('Test Suites: ', num('numFailedTestSuites'), num('numPendingTestSuites'), num('numPassedTestSuites'), totalSuites),
698
+ line('Tests: ', num('numFailedTests'), num('numPendingTests'), num('numPassedTests'), totalTests),
699
+ ].join('\n');
622
700
  }
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DATAFLOW_LIMITS = void 0;
4
4
  exports.taintDataFlow = taintDataFlow;
5
+ const sink_matching_1 = require("./sink-matching");
5
6
  /**
6
7
  * What this pass does NOT model. Carried in every result, for the same reason REQ-138 carries
7
8
  * `TAINT_LIMITS`: a screening signal that does not state its blind spots is read as a proof.
@@ -226,7 +227,10 @@ function taintDataFlow(factSet, cfg) {
226
227
  // SINK — reported after the fixpoint, so a finding always reflects the complete taint state.
227
228
  const all = [];
228
229
  for (const c of calls) {
229
- if (!matchesName(c.callee, cfg.sinks))
230
+ // @implements A-SPEC-512.3 — the ONE sink decision, receiver-aware: an exact name match that
231
+ // is not a RegExp method on a regex receiver. 16 of this repository's 19 findings were
232
+ // `re.exec(...)` before this delegation.
233
+ if (!(0, sink_matching_1.isSinkCall)(c.calleeText ?? c.callee, c.callee, cfg.sinks))
230
234
  continue;
231
235
  for (let i = 0; i < c.args.length; i++) {
232
236
  const from = carrier(c.args[i], fid(c.file, c.fn));
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The CPG foundation's FIRST consumer: taint judged on REACHING DEFINITIONS instead of on
3
+ * "was a sanitizer mentioned anywhere in this function".
4
+ *
5
+ * The rules do not change — sources, sinks and sanitizers come from the same config the
6
+ * flow-insensitive engine uses. What changes is what those rule matches are COMBINED over: a
7
+ * tainted definition is dangerous only if it actually REACHES the sink's use. Two measured false
8
+ * positives fall out of that automatically:
9
+ * `let c = req.body.cmd; c = "ls"; exec(c);` → the constant redefinition kills the taint
10
+ * `if (s) { c = clean(c); } else { c = clean(c); }` → every path redefines, so nothing tainted arrives
11
+ * and one thing deliberately does NOT change: when only ONE branch sanitizes, the tainted
12
+ * definition still reaches, so the finding stands. This is a may-analysis and stays one —
13
+ * trading a false positive for a false negative would not be an improvement (REQ-512).
14
+ */
15
+ import type { PersistedAst } from '../cpg/foundation/ast-store';
16
+ import type { Cfg } from '../cpg/foundation/cfg';
17
+ import type { Ddg } from '../cpg/foundation/ddg';
18
+ import type { DataFlowTaintConfig } from './dataflow-taint';
19
+ export interface FlowTaintFinding {
20
+ fn: string;
21
+ callee: string;
22
+ line: number;
23
+ argIndex: number;
24
+ argText: string;
25
+ /** Why this is reported: the tainted definition that reaches this sink use. */
26
+ taintedDef: string;
27
+ }
28
+ export interface FlowTaintResult {
29
+ findings: FlowTaintFinding[];
30
+ mode: 'flow-sensitive';
31
+ /** Per-statement classification, exposed so a test can prove the DDG was actually consulted. */
32
+ taintedDefStmts: number[];
33
+ }
34
+ export declare function flowSensitiveTaint(input: {
35
+ ast: PersistedAst;
36
+ cfg: Cfg;
37
+ ddg: Ddg;
38
+ source: string;
39
+ fnName: string;
40
+ config: DataFlowTaintConfig;
41
+ }): FlowTaintResult;