@holmes-lab/holmes-kit 0.4.0 → 0.4.1

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/CHANGELOG.md CHANGED
@@ -4,6 +4,59 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+ <!-- @implements A-SPEC-209 -->
8
+ ## [0.4.1] - 2026-09-02
9
+
10
+ Three shipped defects, all found by chasing measurements that did not add up. Two of them made the
11
+ harness lie about its own evidence; the third kept the fix for the second from ever loading.
12
+
13
+ ### Fixed
14
+
15
+ - **Governance was decided by the environment, not by the argument** (REQ-514):
16
+ `isGovernedProject` opened with `if (env.HOLMES_SPECS) return true`, discarding the `specsDir` it
17
+ was given. `holmes-kit init` writes `HOLMES_SPECS=.ax/specs` — the DEFAULT value, and therefore
18
+ not a governance signal — into `.mcp.json`, so every unspecced project read as governed and the
19
+ gate's error path denied where A-SPEC-144 says it must allow ("inventing a denial is its own
20
+ failure"). The judgement now takes `{ configured }` from the caller, which is the only party that
21
+ still holds the unresolved spelling; the parameter type changed so an old call fails to compile
22
+ rather than quietly reading ambient state. **Projects with specs, and projects wired to a
23
+ non-default spec root, are unaffected.**
24
+
25
+ - **The evidence runner inherited the environment it was judging** (REQ-514): every git subprocess
26
+ in this package was already scrubbed, and the test runner was not. Measured, the MCP server's
27
+ `HOLMES_SPECS` reached jest and turned a green commit red twice. `cleanTestEnv` now wraps all six
28
+ test spawns (jest, pytest, cargo, gradle, dotnet, go), removing Holmes secrets plus
29
+ `HOLMES_SPECS`, `HOLMES_GATE_BYPASS` and `HOLMES_MCP_*` — an explicit list, never a prefix sweep,
30
+ so a project's own variables are never taken.
31
+
32
+ - **A green `test_run` returned 1.4MB** (REQ-515): `tailOf` truncated by LINE, and jest `--json`
33
+ emits one line, so "the last 6 lines" was the whole document — 99.2% of the tool result. Failure
34
+ paths gave six clean lines and success paths gave everything, exactly backwards. `tail` is now
35
+ capped at 4,000 characters (keeping the END, where a runner's conclusion is), marks itself when
36
+ truncated, and a passing jest run reports `Test Suites: … / Tests: …` assembled from the JSON
37
+ rather than the JSON itself. **Measured: 1,421,327 characters → 71.**
38
+
39
+ - **A large reply permanently disabled the MCP autoreload** (REQ-516): JSON-RPC puts `"id"` at the
40
+ END of a reply, so a 1.4MB response arrives as ~22 chunks of which the first begins with `{` but
41
+ carries no `"id"` and the last carries `"id"` but does not begin with `{`. The supervisor counted
42
+ replies per chunk, so neither matched, `inflight` never returned to zero, and the child was never
43
+ replaced again — measured live, a server three commits behind across four rebuilds. The root was
44
+ an asymmetry in one file: the stdin direction already buffered into lines and only stdout did not.
45
+ Counting is now line-based via a pure, exported `createResponseCounter`, using two bits and a
46
+ three-character tail rather than buffering the line. This defect was self-reinforcing: the
47
+ oversized reply above is what jammed the counter, and the jammed counter is what kept that reply's
48
+ fix from loading.
49
+
50
+ ### Added
51
+
52
+ - **Taint vocabulary is language-aware** (REQ-513): the source/sink/sanitizer lists were JS-centric,
53
+ so Python could only ever report zero — measured on a 27,000-file repository, `subprocess.run`,
54
+ `os.environ` and `input(` were all unmatched. Vocabulary is now a per-language table selected by
55
+ file extension and never unioned (a union would let Python's `input(` taint an ordinary
56
+ TypeScript `input`), and `taint_scan { dataFlow: true }` routes Python through the flow-sensitive
57
+ lane. The TypeScript list is value-identical to the shipped one, which a test compares rather
58
+ than asserts. The language matrix moves Python from ○ to ● on the taint row.
59
+
7
60
  <!-- @implements A-SPEC-209 -->
8
61
  ## [0.4.0] - 2026-09-02
9
62
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 3e5ef00-mtiwan92
1
+ 2b427b6-mtj2vv29
@@ -56,7 +56,9 @@ function runCiGate(targetDir, options) {
56
56
  }
57
57
  const specsDirName = options?.specsDir ?? (0, pre_tool_use_1.wiredSpecsDir)(process.argv, process.env);
58
58
  const specsDir = path.resolve(root, specsDirName);
59
- if (!(0, pre_tool_use_1.isGovernedProject)(specsDir, process.env)) {
59
+ // @implements A-SPEC-514.1 — compare the SPELLING, before it was resolved against the root: an
60
+ // absolute path can never equal `.ax/specs`, so the comparison has to happen here.
61
+ if (!(0, pre_tool_use_1.isGovernedProject)(specsDir, { configured: specsDirName !== pre_tool_use_1.DEFAULT_SPECS_DIR })) {
60
62
  return {
61
63
  ok: true,
62
64
  violations: [],
@@ -21,7 +21,7 @@ exports.renderLanguageSupport = renderLanguageSupport;
21
21
  const ast_store_1 = require("./ast-store");
22
22
  const cfg_1 = require("./cfg");
23
23
  const language_capability_1 = require("../language-capability");
24
- const language_parser_1 = require("../language-parser");
24
+ const taint_vocabulary_1 = require("../../rtm/taint-vocabulary");
25
25
  const test_runner_1 = require("../../review/test-runner");
26
26
  exports.LAYERS = ['relations', 'ast', 'cfg', 'ddg', 'cdg', 'runner', 'taint'];
27
27
  /** The language families holmes-kit advertises, each with the probe inputs the derivation needs. */
@@ -70,10 +70,18 @@ function runnerCell(sampleFile) {
70
70
  : { support: 'none', basis: 'no test-runner adapter — execution evidence unavailable' };
71
71
  }
72
72
  function taintCell(astLang) {
73
- const has = (0, language_parser_1.hasDataFlowWalk)(astLang);
74
- return has
75
- ? { support: 'full', basis: 'data-flow walk present (taint_scan analyses this language)' }
76
- : { support: 'none', basis: 'no data-flow walk — taint_scan does not analyse this language' };
73
+ // @implements A-SPEC-513.1 derived from the two things a taint verdict actually needs: a
74
+ // VOCABULARY for the language and a control/data-flow graph to judge it on. Before this the cell
75
+ // read `hasDataFlowWalk`, which described only the older facts extractor and therefore understated
76
+ // Python once its CFG/DDG landed.
77
+ const hasVocab = taint_vocabulary_1.TAINT_LANGUAGES.has(astLang) || (astLang === 'tsx' && taint_vocabulary_1.TAINT_LANGUAGES.has('typescript'));
78
+ const hasFlow = cfg_1.CFG_LANGUAGES.has(astLang) || (astLang === 'typescript' && cfg_1.CFG_LANGUAGES.has('tsx'));
79
+ if (hasVocab && hasFlow) {
80
+ return { support: 'full', basis: 'taint_scan { dataFlow: true } analyses this language (vocabulary + CFG/DDG)' };
81
+ }
82
+ if (hasVocab)
83
+ return { support: 'partial', basis: 'vocabulary present but no CFG/DDG to judge flow on' };
84
+ return { support: 'none', basis: 'no taint vocabulary for this language — taint_scan does not analyse it' };
77
85
  }
78
86
  // @implements A-SPEC-510.6
79
87
  /** Derive the whole matrix. Pure, cheap, and impossible to drift from its sources. */
@@ -1202,7 +1202,11 @@ function extractDataFlowFromTree(tree, lang = 'typescript') {
1202
1202
  args.push(exprOf(a));
1203
1203
  }
1204
1204
  }
1205
- calls.push({ fn: enclosing(node), callee, args, line: node.startPosition.row + 1 });
1205
+ // @implements A-SPEC-512.3 the callee AS WRITTEN, so a sink decision can read the
1206
+ // RECEIVER: `re.exec` and `child_process.exec` share a bare name but not a meaning.
1207
+ const calleeNode = node.childForFieldName('function');
1208
+ const calleeText = calleeNode ? calleeNode.text : callee;
1209
+ calls.push({ fn: enclosing(node), callee, calleeText, args, line: node.startPosition.row + 1 });
1206
1210
  }
1207
1211
  }
1208
1212
  }
@@ -54,6 +54,7 @@ export interface DataFlowFacts {
54
54
  calls: {
55
55
  fn: string;
56
56
  callee: string;
57
+ calleeText?: string;
57
58
  args: Expr[];
58
59
  line: number;
59
60
  }[];
@@ -103,11 +103,26 @@ export declare function decideOnGateError(opts: {
103
103
  };
104
104
  /**
105
105
  * Is this project governed? Computed on the ERROR path, so it must not re-run the logic that just
106
- * failed: presence of a configured spec root or a non-empty spec directory, nothing more. Any
106
+ * failed: an explicitly configured spec root, or a non-empty spec directory, nothing more. Any
107
107
  * problem reading it means NOT governed — the conservative direction here is the one that keeps an
108
108
  * unrelated failure from bricking a project that never asked for gating.
109
+ *
110
+ * IT DOES NOT READ THE ENVIRONMENT. It used to open with `if (env.HOLMES_SPECS) return true`, which
111
+ * discarded `specsDir` entirely and answered from ambient state. A-SPEC-144 sealed the rule as
112
+ * `specsDir !== DEFAULT_SPECS_DIR || the directory holds a file`, and the drift is traceable:
113
+ * A-SPEC-191 §17(a) started passing a RESOLVED ABSOLUTE path, which made the literal comparison
114
+ * always true — a dead condition — and the env short-circuit took its place. `holmes-kit init`
115
+ * writes `HOLMES_SPECS=.ax/specs` (the default value, i.e. NOT a governance signal) into .mcp.json,
116
+ * so every unspecced project read as governed and the error path denied where A-SPEC-144 says it
117
+ * must allow.
118
+ *
119
+ * Only the CALLER knows whether its spelling is the default, so the first disjunct arrives as
120
+ * `configured`. The parameter type changed from `ProcessEnv` deliberately: an old call now fails to
121
+ * compile instead of quietly reading ambient state.
109
122
  */
110
- export declare function isGovernedProject(specsDir: string, env?: NodeJS.ProcessEnv): boolean;
123
+ export declare function isGovernedProject(specsDir: string, opts?: {
124
+ configured?: boolean;
125
+ }): boolean;
111
126
  /**
112
127
  * The whole error-path decision, assembled from an environment.
113
128
  *
@@ -317,15 +317,29 @@ function decideOnGateError(opts) {
317
317
  + ' → `holmes-kit doctor`로 설치를 점검하세요. 운영자 판단으로 통과시키려면 환경에 HOLMES_GATE_BYPASS=1을 설정하십시오(세션이 스스로 설정할 수 없는 대역외 채널).',
318
318
  };
319
319
  }
320
+ // @implements A-SPEC-514.1
320
321
  /**
321
322
  * Is this project governed? Computed on the ERROR path, so it must not re-run the logic that just
322
- * failed: presence of a configured spec root or a non-empty spec directory, nothing more. Any
323
+ * failed: an explicitly configured spec root, or a non-empty spec directory, nothing more. Any
323
324
  * problem reading it means NOT governed — the conservative direction here is the one that keeps an
324
325
  * unrelated failure from bricking a project that never asked for gating.
326
+ *
327
+ * IT DOES NOT READ THE ENVIRONMENT. It used to open with `if (env.HOLMES_SPECS) return true`, which
328
+ * discarded `specsDir` entirely and answered from ambient state. A-SPEC-144 sealed the rule as
329
+ * `specsDir !== DEFAULT_SPECS_DIR || the directory holds a file`, and the drift is traceable:
330
+ * A-SPEC-191 §17(a) started passing a RESOLVED ABSOLUTE path, which made the literal comparison
331
+ * always true — a dead condition — and the env short-circuit took its place. `holmes-kit init`
332
+ * writes `HOLMES_SPECS=.ax/specs` (the default value, i.e. NOT a governance signal) into .mcp.json,
333
+ * so every unspecced project read as governed and the error path denied where A-SPEC-144 says it
334
+ * must allow.
335
+ *
336
+ * Only the CALLER knows whether its spelling is the default, so the first disjunct arrives as
337
+ * `configured`. The parameter type changed from `ProcessEnv` deliberately: an old call now fails to
338
+ * compile instead of quietly reading ambient state.
325
339
  */
326
- function isGovernedProject(specsDir, env = process.env) {
340
+ function isGovernedProject(specsDir, opts = {}) {
327
341
  try {
328
- if (env.HOLMES_SPECS)
342
+ if (opts.configured === true)
329
343
  return true;
330
344
  const stack = [specsDir];
331
345
  while (stack.length) {
@@ -365,7 +379,10 @@ function gateErrorDecision(message, env = process.env, argv = process.argv, proj
365
379
  const wired = wiredSpecsDir(argv, env);
366
380
  const specsDir = path.isAbsolute(wired) || projectRoot === undefined ? wired : path.join(projectRoot, wired);
367
381
  return decideOnGateError({
368
- governed: isGovernedProject(specsDir, env),
382
+ // @implements A-SPEC-514.1 — the caller answers "did the operator point somewhere OTHER than
383
+ // the default?", because only the caller still holds the unresolved spelling. `.ax/specs` is
384
+ // what `init` writes; equal to the default means no governance intent was expressed.
385
+ governed: isGovernedProject(specsDir, { configured: wired !== exports.DEFAULT_SPECS_DIR }),
369
386
  // Environment ONLY. The session authors the payload and the operator authors the environment,
370
387
  // so an agent cannot grant itself relief from a gate it happens to be able to crash.
371
388
  bypass: !!env.HOLMES_GATE_BYPASS,
@@ -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;