@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
package/CHANGELOG.md CHANGED
@@ -4,6 +4,94 @@ 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.5.0] - 2026-09-02
9
+
10
+ The seven-language goal (GOAL-7langs) starts shipping: the L1 substrate now parses every
11
+ officially supported language, and Go becomes the third language with a full, corpus-validated
12
+ control- and data-flow story. Grades in the language matrix are still measurements, not goals —
13
+ a cell moves only when a real corpus proves it.
14
+
15
+ ### Added
16
+
17
+ - **L1 AST for all seven languages** (REQ-518): five more grammars join the vendored wasm
18
+ substrate — go 0.25.0, rust 0.24.0, java 0.23.5, c-sharp 0.23.5, cpp 0.23.4 — each ABI-probed
19
+ against web-tree-sitter 0.27.0 before pinning (5/5 load+parse). The expansion is five pins and
20
+ five extension mappings; no code changed, which was the design claim being tested. All 30
21
+ language fixtures parse with zero span violations. `.h` maps to the C++ grammar (near-superset;
22
+ a mis-parse surfaces as errorCount>0 and every layer above refuses on it).
23
+ - **Go CFG/DDG/CDG** (REQ-519): Go joins the shared lowering with its semantics pinned by intent,
24
+ written from a full statement census over two corpora (2,637 functions, 22 statement types)
25
+ rather than from the grammar docs. No implicit switch fallthrough (an explicit `fallthrough`
26
+ links to the next case); a bare `for {}` is INFINITE — no phantom exhaustion edge behind the
27
+ `for { select {…} }` daemon idiom; a `select` without `default` blocks; `defer` is a
28
+ registration-point statement (its arguments evaluate there, per the Go spec); `goto` refuses
29
+ the whole function, fail-closed. Receivers and NAMED RESULTS are parameters.
30
+ **Measured over gin+cobra: 2,637/2,637 functions lowered, zero refusals, zero dominator/DDG/CDG
31
+ invariant violations, 19,975 REACHING_DEF and 4,633 CDG edges (parse 337ms, build 1.5s).**
32
+
33
+ ### Fixed
34
+
35
+ - **The language matrix could contradict itself** (REQ-517): the missing-edge list in a
36
+ relations cell was a hardcoded literal next to a derived present-list, so after REQ-511 the
37
+ generated doc read "calls, inherits (no imports/inherits)" — inherits present and absent in one
38
+ breath. Both the missing list and the full/partial judgement now derive from
39
+ EXTRACTABLE_RELATIONS by set difference, and a property test bans the contradiction SHAPE
40
+ itself (no edge may appear on both sides, any language, any future widening).
41
+
42
+ <!-- @implements A-SPEC-209 -->
43
+ ## [0.4.1] - 2026-09-02
44
+
45
+ Three shipped defects, all found by chasing measurements that did not add up. Two of them made the
46
+ harness lie about its own evidence; the third kept the fix for the second from ever loading.
47
+
48
+ ### Fixed
49
+
50
+ - **Governance was decided by the environment, not by the argument** (REQ-514):
51
+ `isGovernedProject` opened with `if (env.HOLMES_SPECS) return true`, discarding the `specsDir` it
52
+ was given. `holmes-kit init` writes `HOLMES_SPECS=.ax/specs` — the DEFAULT value, and therefore
53
+ not a governance signal — into `.mcp.json`, so every unspecced project read as governed and the
54
+ gate's error path denied where A-SPEC-144 says it must allow ("inventing a denial is its own
55
+ failure"). The judgement now takes `{ configured }` from the caller, which is the only party that
56
+ still holds the unresolved spelling; the parameter type changed so an old call fails to compile
57
+ rather than quietly reading ambient state. **Projects with specs, and projects wired to a
58
+ non-default spec root, are unaffected.**
59
+
60
+ - **The evidence runner inherited the environment it was judging** (REQ-514): every git subprocess
61
+ in this package was already scrubbed, and the test runner was not. Measured, the MCP server's
62
+ `HOLMES_SPECS` reached jest and turned a green commit red twice. `cleanTestEnv` now wraps all six
63
+ test spawns (jest, pytest, cargo, gradle, dotnet, go), removing Holmes secrets plus
64
+ `HOLMES_SPECS`, `HOLMES_GATE_BYPASS` and `HOLMES_MCP_*` — an explicit list, never a prefix sweep,
65
+ so a project's own variables are never taken.
66
+
67
+ - **A green `test_run` returned 1.4MB** (REQ-515): `tailOf` truncated by LINE, and jest `--json`
68
+ emits one line, so "the last 6 lines" was the whole document — 99.2% of the tool result. Failure
69
+ paths gave six clean lines and success paths gave everything, exactly backwards. `tail` is now
70
+ capped at 4,000 characters (keeping the END, where a runner's conclusion is), marks itself when
71
+ truncated, and a passing jest run reports `Test Suites: … / Tests: …` assembled from the JSON
72
+ rather than the JSON itself. **Measured: 1,421,327 characters → 71.**
73
+
74
+ - **A large reply permanently disabled the MCP autoreload** (REQ-516): JSON-RPC puts `"id"` at the
75
+ END of a reply, so a 1.4MB response arrives as ~22 chunks of which the first begins with `{` but
76
+ carries no `"id"` and the last carries `"id"` but does not begin with `{`. The supervisor counted
77
+ replies per chunk, so neither matched, `inflight` never returned to zero, and the child was never
78
+ replaced again — measured live, a server three commits behind across four rebuilds. The root was
79
+ an asymmetry in one file: the stdin direction already buffered into lines and only stdout did not.
80
+ Counting is now line-based via a pure, exported `createResponseCounter`, using two bits and a
81
+ three-character tail rather than buffering the line. This defect was self-reinforcing: the
82
+ oversized reply above is what jammed the counter, and the jammed counter is what kept that reply's
83
+ fix from loading.
84
+
85
+ ### Added
86
+
87
+ - **Taint vocabulary is language-aware** (REQ-513): the source/sink/sanitizer lists were JS-centric,
88
+ so Python could only ever report zero — measured on a 27,000-file repository, `subprocess.run`,
89
+ `os.environ` and `input(` were all unmatched. Vocabulary is now a per-language table selected by
90
+ file extension and never unioned (a union would let Python's `input(` taint an ordinary
91
+ TypeScript `input`), and `taint_scan { dataFlow: true }` routes Python through the flow-sensitive
92
+ lane. The TypeScript list is value-identical to the shipped one, which a test compares rather
93
+ than asserts. The language matrix moves Python from ○ to ● on the taint row.
94
+
7
95
  <!-- @implements A-SPEC-209 -->
8
96
  ## [0.4.0] - 2026-09-02
9
97
 
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 3e5ef00-mtiwan92
1
+ 8f00a9a-mtjc55io
@@ -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: [],
@@ -1,7 +1,7 @@
1
1
  /** The honest language envelope: exactly what the vendored grammars cover — nothing more. */
2
2
  export declare const AST_LANGUAGES: ReadonlyArray<{
3
3
  ext: RegExp;
4
- lang: 'typescript' | 'tsx' | 'python';
4
+ lang: 'typescript' | 'tsx' | 'python' | 'go' | 'rust' | 'java' | 'csharp' | 'cpp';
5
5
  wasm: string;
6
6
  }>;
7
7
  export interface AstNodeRow {
@@ -67,6 +67,14 @@ exports.AST_LANGUAGES = [
67
67
  { ext: /\.(ts|mts|cts)$/, lang: 'typescript', wasm: 'tree-sitter-typescript.wasm' },
68
68
  { ext: /\.(js|mjs|cjs|jsx)$/, lang: 'tsx', wasm: 'tree-sitter-tsx.wasm' }, // TS grammar family parses JS (decision 3: no separate JS grammar)
69
69
  { ext: /\.py$/, lang: 'python', wasm: 'tree-sitter-python.wasm' },
70
+ // @implements A-SPEC-518.1 — the five languages the goal opens. `.h` goes to the C++ grammar on
71
+ // purpose (H-SPEC-518): C++ is a near-superset of C and a mis-parse surfaces as errorCount>0,
72
+ // which every layer above refuses on — revisited against the P2-C++ corpus.
73
+ { ext: /\.go$/, lang: 'go', wasm: 'tree-sitter-go.wasm' },
74
+ { ext: /\.rs$/, lang: 'rust', wasm: 'tree-sitter-rust.wasm' },
75
+ { ext: /\.java$/, lang: 'java', wasm: 'tree-sitter-java.wasm' },
76
+ { ext: /\.cs$/, lang: 'csharp', wasm: 'tree-sitter-c_sharp.wasm' },
77
+ { ext: /\.(cpp|cc|cxx|hpp|h)$/, lang: 'cpp', wasm: 'tree-sitter-cpp.wasm' },
70
78
  ];
71
79
  const GRAMMARS_DIR = (() => {
72
80
  // src/holmes/cpg/foundation → three up is the package root; identical depth from dist.
@@ -10,6 +10,8 @@ exports.FUNCTION_TYPES = new Set([
10
10
  'generator_function', 'arrow_function', 'method_definition', 'function',
11
11
  // @implements A-SPEC-510.7 — Python's function node joins the same census.
12
12
  'function_definition', 'lambda',
13
+ // @implements A-SPEC-519.1 — Go: methods and function literals (closures/goroutine bodies).
14
+ 'method_declaration', 'func_literal',
13
15
  ]);
14
16
  const TS_SIMPLE = new Set([
15
17
  'expression_statement', 'lexical_declaration', 'variable_declaration', 'debugger_statement',
@@ -46,8 +48,29 @@ const PY_RULES = {
46
48
  ]),
47
49
  block: 'block',
48
50
  };
51
+ // @implements A-SPEC-519.1 — Go, classified from the FULL statement census over two corpora
52
+ // (2,637 functions, 22 types — evidence REQ-519), not from reading the grammar's docs.
53
+ // `defer`/`go` are pass-through statements (defer's args evaluate at registration per the Go
54
+ // spec; goroutine bodies are func_literals that functionsIn owns separately). `goto` is absent
55
+ // on purpose: zero corpus occurrences, and fail-closed refusal is the honest treatment.
56
+ const GO_SIMPLE = new Set([
57
+ 'expression_statement', 'short_var_declaration', 'assignment_statement', 'var_declaration',
58
+ 'const_declaration', 'type_declaration', 'inc_statement', 'dec_statement', 'send_statement',
59
+ 'defer_statement', 'go_statement', 'empty_statement',
60
+ ]);
61
+ const GO_RULES = {
62
+ simple: GO_SIMPLE,
63
+ handled: new Set([
64
+ ...GO_SIMPLE,
65
+ 'block', 'statement_list', 'if_statement', 'for_statement',
66
+ 'expression_switch_statement', 'type_switch_statement', 'select_statement',
67
+ 'labeled_statement', 'break_statement', 'continue_statement', 'return_statement',
68
+ 'fallthrough_statement',
69
+ ]),
70
+ block: 'block',
71
+ };
49
72
  const LANG_RULES = {
50
- typescript: TS_RULES, tsx: TS_RULES, python: PY_RULES,
73
+ typescript: TS_RULES, tsx: TS_RULES, python: PY_RULES, go: GO_RULES,
51
74
  };
52
75
  // @implements A-SPEC-510.6
53
76
  /**
@@ -91,6 +114,7 @@ function cfgOf(ast, fn, source) {
91
114
  }
92
115
  const rules = LANG_RULES[ast.lang];
93
116
  const isPython = ast.lang === 'python';
117
+ const isGo = ast.lang === 'go';
94
118
  const kids = childrenIndex(ast);
95
119
  // Points: one per lowered statement (block ids after coalescing). 0=ENTRY, 1=EXIT.
96
120
  const stmtsOf = [[], []];
@@ -151,6 +175,8 @@ function cfgOf(ast, fn, source) {
151
175
  switch (t) {
152
176
  case 'statement_block':
153
177
  case 'block':
178
+ case 'statement_list':
179
+ // Go's block carries one extra statement_list layer (probed shape) — both are containers.
154
180
  return lowerSeq(stmtChildren(i), ctx);
155
181
  case 'labeled_statement': {
156
182
  const [labelNode, inner] = [kids.of(i)[0], stmtChildren(i).at(-1)];
@@ -164,9 +190,18 @@ function cfgOf(ast, fn, source) {
164
190
  // @implements A-SPEC-510.7 — Python spells the arms as fields (`consequence`,
165
191
  // `alternative` = elif_clause | else_clause), TS as positional children. Reading the
166
192
  // fields where they exist keeps ONE lowering for both grammars.
167
- const cons = isPython ? (kids.of(i).find((k) => ast.nodes[k].type === 'block') ?? named[1]) : named[1];
193
+ // @implements A-SPEC-519.1 Go: consequence is the FIRST block child; the alternative
194
+ // (an else block or an else-if chain's if_statement) is the last named child when it is
195
+ // distinct from the consequence. The header point owns the init (:=) and condition.
196
+ const goCons = isGo ? kids.of(i).find((k) => ast.nodes[k].type === 'block') : undefined;
197
+ const cons = isGo ? goCons : isPython ? (kids.of(i).find((k) => ast.nodes[k].type === 'block') ?? named[1]) : named[1];
168
198
  let alt;
169
- if (isPython) {
199
+ if (isGo) {
200
+ const last = named.at(-1);
201
+ if (last !== goCons && ['block', 'if_statement'].includes(ast.nodes[last].type))
202
+ alt = last;
203
+ }
204
+ else if (isPython) {
170
205
  const clause = kids.of(i).find((k) => ['elif_clause', 'else_clause'].includes(ast.nodes[k].type));
171
206
  if (clause !== undefined) {
172
207
  alt = ast.nodes[clause].type === 'elif_clause'
@@ -202,9 +237,14 @@ function cfgOf(ast, fn, source) {
202
237
  ? kids.of(i).filter((k) => ast.nodes[k].type === 'else_clause')
203
238
  .flatMap((k) => stmtChildren(k)).find((k) => ast.nodes[k].type === 'block')
204
239
  : undefined;
205
- const bodyIdx = isPython
240
+ const bodyIdx = (isPython || isGo)
206
241
  ? kids.of(i).filter((k) => ast.nodes[k].type === 'block').at(0)
207
242
  : stmtChildren(i).at(-1);
243
+ // @implements A-SPEC-519.1 — a bare Go `for {}` (no named non-block child) is INFINITE:
244
+ // no exhaustion edge. `for { select {…} }` is the daemon idiom, and a phantom false edge
245
+ // would hang spurious control dependence on everything after the loop. A for_clause with
246
+ // an empty condition keeps the edge (documented over-approximation).
247
+ const goInfinite = isGo && !kids.of(i).some((k) => ast.nodes[k].named && ast.nodes[k].type !== 'block' && ast.nodes[k].type !== 'comment');
208
248
  const myBreak = { label, target: -1 }; // ONE owner: labeled and bare breaks share it
209
249
  ctx.breakT.push({ label, owner: myBreak }, { label: null, owner: myBreak });
210
250
  ctx.contT.push({ label, target: header }, { label: null, target: header });
@@ -220,7 +260,7 @@ function cfgOf(ast, fn, source) {
220
260
  link(header, ef.entry, 'false'); // exhaustion path enters `else`
221
261
  exits.push(...ef.exits);
222
262
  }
223
- else {
263
+ else if (!goInfinite) {
224
264
  exits.push({ from: header, kind: 'false' });
225
265
  }
226
266
  // break targets resolved lazily: edges recorded with target -1 fixed by caller — instead
@@ -282,6 +322,98 @@ function cfgOf(ast, fn, source) {
282
322
  throw new Unsupported('with_statement without a block');
283
323
  return lowerStmt(body, ctx);
284
324
  }
325
+ // @implements A-SPEC-519.1 — Go's two switch forms. NO implicit fallthrough (each case
326
+ // leaves the switch), an explicit fallthrough_statement links to the NEXT case's entry,
327
+ // and a missing default leaves a header escape (no case matched). The binding of a type
328
+ // switch (`switch v := x.(type)`) lives on the header point, where the DDG collects it.
329
+ case 'expression_switch_statement':
330
+ case 'type_switch_statement': {
331
+ const p = newPoint(i);
332
+ const myBreak = { label, target: -1 };
333
+ ctx.breakT.push({ label, owner: myBreak }, { label: null, owner: myBreak });
334
+ const caseNodes = kids.of(i).filter((k) => ['expression_case', 'type_case', 'default_case'].includes(ast.nodes[k].type));
335
+ let sawDefault = false;
336
+ const exits = [];
337
+ let pendingFalls = [];
338
+ caseNodes.forEach((c, ci) => {
339
+ if (ast.nodes[c].type === 'default_case')
340
+ sawDefault = true;
341
+ const bodyList = kids.of(c).find((k) => ast.nodes[k].type === 'statement_list');
342
+ const stmts = bodyList === undefined ? [] : stmtChildren(bodyList);
343
+ const fallIdx = stmts.filter((k) => ast.nodes[k].type === 'fallthrough_statement');
344
+ const rest = stmts.filter((k) => ast.nodes[k].type !== 'fallthrough_statement');
345
+ const cf = lowerSeq(rest, ctx);
346
+ link(p, cf.entry, ast.nodes[c].type === 'default_case' ? 'default' : 'case');
347
+ for (const f of pendingFalls)
348
+ link(f, cf.entry, 'seq');
349
+ pendingFalls = [];
350
+ if (fallIdx.length > 0) {
351
+ // The census shows fallthrough only as a case's LAST statement; a next case must
352
+ // exist (its absence is a Go compile error) — refuse honestly if it does not.
353
+ if (ci === caseNodes.length - 1)
354
+ throw new Unsupported('fallthrough in last case');
355
+ const fp = newPoint(fallIdx[0]);
356
+ for (const e of cf.exits)
357
+ link(e.from, fp, e.kind);
358
+ pendingFalls.push(fp);
359
+ }
360
+ else {
361
+ exits.push(...cf.exits);
362
+ }
363
+ });
364
+ if (!sawDefault)
365
+ exits.push({ from: p, kind: 'default' });
366
+ ctx.breakT.splice(-2);
367
+ for (const b of claimBreaks(myBreak))
368
+ exits.push({ from: b.point, kind: 'break' });
369
+ return { entry: p, exits };
370
+ }
371
+ // @implements A-SPEC-519.1 — select: each communication case is a branch off the header;
372
+ // WITHOUT a default the statement BLOCKS until some case fires, so there is no "no arm
373
+ // matched" escape (the opposite of Python's match).
374
+ case 'select_statement': {
375
+ const p = newPoint(i);
376
+ const myBreak = { label, target: -1 };
377
+ ctx.breakT.push({ label, owner: myBreak }, { label: null, owner: myBreak });
378
+ let sawDefault = false;
379
+ const exits = [];
380
+ for (const c of kids.of(i)) {
381
+ const cty = ast.nodes[c].type;
382
+ if (cty !== 'communication_case' && cty !== 'default_case')
383
+ continue;
384
+ if (cty === 'default_case')
385
+ sawDefault = true;
386
+ const comm = kids.of(c).find((k) => ['receive_statement', 'send_statement', 'expression_statement'].includes(ast.nodes[k].type));
387
+ const bodyList = kids.of(c).find((k) => ast.nodes[k].type === 'statement_list');
388
+ const stmts = bodyList === undefined ? [] : stmtChildren(bodyList);
389
+ let entry;
390
+ let head;
391
+ if (comm !== undefined) {
392
+ const cp = newPoint(comm);
393
+ entry = cp;
394
+ head = { entry: cp, exits: [{ from: cp, kind: 'seq' }] };
395
+ }
396
+ else {
397
+ head = undefined;
398
+ entry = -1;
399
+ }
400
+ const bf = lowerSeq(stmts, ctx);
401
+ if (head !== undefined) {
402
+ for (const e of head.exits)
403
+ link(e.from, bf.entry, e.kind);
404
+ }
405
+ else {
406
+ entry = bf.entry;
407
+ }
408
+ link(p, entry, cty === 'default_case' ? 'default' : 'case');
409
+ exits.push(...bf.exits);
410
+ }
411
+ if (sawDefault) { /* a ready default gives an immediate path — covered by its case edge */ }
412
+ ctx.breakT.splice(-2);
413
+ for (const b of claimBreaks(myBreak))
414
+ exits.push({ from: b.point, kind: 'break' });
415
+ return { entry: p, exits };
416
+ }
285
417
  case 'raise_statement': {
286
418
  const p = newPoint(i);
287
419
  link(p, ctx.catchT.at(-1) ?? EXIT, 'throw');
@@ -6,12 +6,16 @@ exports.ddgViolations = ddgViolations;
6
6
  const cfg_1 = require("./cfg");
7
7
  /** Identifier positions that are DEF sites, keyed by the parent node type. */
8
8
  const PATTERN_TYPES = new Set(['object_pattern', 'array_pattern', 'shorthand_property_identifier_pattern',
9
- 'pair_pattern', 'rest_pattern', 'assignment_pattern']);
9
+ 'pair_pattern', 'rest_pattern', 'assignment_pattern',
10
+ // @implements A-SPEC-519.1 — Go: an expression_list on a binding LHS carries the def context
11
+ // through to each identifier (`a, b := …`, `for i, v := range …`).
12
+ 'expression_list']);
10
13
  function defUseOf(ast, cfg, fn, source) {
11
14
  const kids = (0, cfg_1.childrenIndex)(ast);
12
15
  const text = (i) => source.slice(ast.nodes[i].start, ast.nodes[i].end);
13
16
  // ── parameters: ENTRY defs (and part of the local namespace).
14
17
  const isPython = ast.lang === 'python';
18
+ const isGo = ast.lang === 'go';
15
19
  const params = new Map();
16
20
  const bareArrow = ast.nodes[fn.nodeIndex].type === 'arrow_function'
17
21
  && !kids.of(fn.nodeIndex).some((k) => ast.nodes[k].type === 'formal_parameters');
@@ -19,7 +23,11 @@ function defUseOf(ast, cfg, fn, source) {
19
23
  // Only formal_parameters — a declaration's NAME identifier is not a parameter (measured: the
20
24
  // early break on the name swallowed every real parameter of named functions).
21
25
  // @implements A-SPEC-510.7 — Python's parameter list node is `parameters`.
22
- const isParamList = ast.nodes[k].type === 'formal_parameters' || ast.nodes[k].type === 'parameters';
26
+ // @implements A-SPEC-519.1 Go's list node is `parameter_list`, and it appears up to THREE
27
+ // times on one declaration: receiver, parameters, NAMED RESULTS. All of them bind names at
28
+ // ENTRY, so Go must not break after the first (the probe pinned the second-list shape).
29
+ const isParamList = ast.nodes[k].type === 'formal_parameters' || ast.nodes[k].type === 'parameters'
30
+ || ast.nodes[k].type === 'parameter_list';
23
31
  if (!isParamList && !(bareArrow && ast.nodes[k].type === 'identifier'))
24
32
  continue;
25
33
  const collect = (n) => {
@@ -32,7 +40,7 @@ function defUseOf(ast, cfg, fn, source) {
32
40
  collect(c);
33
41
  };
34
42
  collect(k);
35
- if (isParamList)
43
+ if (isParamList && !isGo)
36
44
  break;
37
45
  }
38
46
  const defs = new Map();
@@ -65,7 +73,97 @@ function defUseOf(ast, cfg, fn, source) {
65
73
  }
66
74
  if (ty === 'property_identifier' || ty === 'statement_identifier' || ty === 'type_identifier')
67
75
  return;
76
+ // @implements A-SPEC-519.1 — Go: a selector's field, a package qualifier and a label are
77
+ // names in other namespaces, never local value reads.
78
+ if (ty === 'field_identifier' || ty === 'package_identifier' || ty === 'label_name')
79
+ return;
68
80
  switch (ty) {
81
+ // @implements A-SPEC-519.1 — Go's binding shapes, written from the probed grammar.
82
+ case 'short_var_declaration': {
83
+ // `a, b := e1, e2` — every LHS identifier is a def; the RHS list is uses.
84
+ const named = kids.of(n).filter((c) => ast.nodes[c].named);
85
+ if (named[0] !== undefined)
86
+ walk(named[0], true);
87
+ for (const r of named.slice(1))
88
+ walk(r, false);
89
+ return;
90
+ }
91
+ case 'assignment_statement': {
92
+ // `x = e` defs x; `x += e` defs AND uses it. No fields survive persistence, so the
93
+ // operator is read from the unnamed token between the two expression_lists.
94
+ const all = kids.of(n);
95
+ const named = all.filter((c) => ast.nodes[c].named);
96
+ const op = all.find((c) => !ast.nodes[c].named && text(c).endsWith('='));
97
+ const compound = op !== undefined && text(op) !== '=';
98
+ const lhs = named[0];
99
+ if (lhs !== undefined) {
100
+ for (const el of ast.nodes[lhs].type === 'expression_list' ? kids.of(lhs).filter((c) => ast.nodes[c].named) : [lhs]) {
101
+ if (ast.nodes[el].type === 'identifier') {
102
+ add(defs, stmt, text(el));
103
+ if (compound)
104
+ add(uses, stmt, text(el));
105
+ }
106
+ else
107
+ walk(el, false); // a.b / a[i] mutation: uses only
108
+ }
109
+ }
110
+ for (const r of named.slice(1))
111
+ walk(r, false);
112
+ return;
113
+ }
114
+ case 'var_spec':
115
+ case 'const_spec': {
116
+ // `var x, y T = e1, e2` — identifier children are defs, the value list is uses.
117
+ for (const c of kids.of(n)) {
118
+ if (ast.nodes[c].type === 'identifier')
119
+ add(defs, stmt, text(c));
120
+ else
121
+ walk(c, false);
122
+ }
123
+ return;
124
+ }
125
+ case 'inc_statement':
126
+ case 'dec_statement': {
127
+ const arg = kids.of(n).find((c) => ast.nodes[c].named);
128
+ if (arg !== undefined && ast.nodes[arg].type === 'identifier') {
129
+ add(defs, stmt, text(arg));
130
+ add(uses, stmt, text(arg));
131
+ }
132
+ else if (arg !== undefined)
133
+ walk(arg, false);
134
+ return;
135
+ }
136
+ case 'range_clause':
137
+ case 'receive_statement': {
138
+ // `for i, v := range xs` / `case v := <-ch` — LHS binds, RHS is read. With `=` instead
139
+ // of `:=` the LHS identifiers are still (re)defined; the same treatment holds.
140
+ const named = kids.of(n).filter((c) => ast.nodes[c].named);
141
+ if (named.length >= 2) {
142
+ walk(named[0], true);
143
+ for (const r of named.slice(1))
144
+ walk(r, false);
145
+ }
146
+ else {
147
+ for (const r of named)
148
+ walk(r, false); // bare `for range xs` / `<-ch`
149
+ }
150
+ return;
151
+ }
152
+ case 'type_switch_statement': {
153
+ // `switch v := x.(type)` — v defs at the header; everything else is uses.
154
+ const named = kids.of(n).filter((c) => ast.nodes[c].named);
155
+ const hasBind = kids.of(n).some((c) => !ast.nodes[c].named && text(c) === ':=');
156
+ if (hasBind && named[0] !== undefined && ast.nodes[named[0]].type === 'expression_list') {
157
+ walk(named[0], true);
158
+ for (const r of named.slice(1))
159
+ walk(r, false);
160
+ }
161
+ else {
162
+ for (const r of named)
163
+ walk(r, false);
164
+ }
165
+ return;
166
+ }
69
167
  // @implements A-SPEC-510.7 — Python's assignment shapes.
70
168
  case 'assignment': {
71
169
  const named = kids.of(n).filter((c) => ast.nodes[c].named);
@@ -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. */
@@ -43,9 +43,16 @@ function relationsCell(ext) {
43
43
  if (resolved.length === 0)
44
44
  return { support: 'none', basis: 'nothing reaches the graph' };
45
45
  // A-SPEC-286: the claim is made in the graph-resolved column, never the scanner's raw output.
46
- return resolved.length >= 3
46
+ // @implements A-SPEC-517.1 the MISSING list is derived from the same authority as the present
47
+ // list. It used to be the literal `(no imports/inherits)` next to a derived present-list, so when
48
+ // REQ-511 widened graphResolved the doc read "calls, inherits (no imports/inherits)" — inherits
49
+ // present and absent in one breath. The `>= 3` magic number was the same sin (a hardcoded "the
50
+ // full set has three kinds"); judging by set difference keeps both halves true if
51
+ // EXTRACTABLE_RELATIONS ever widens.
52
+ const missing = language_capability_1.EXTRACTABLE_RELATIONS.filter((r) => !resolved.includes(r));
53
+ return missing.length === 0
47
54
  ? { support: 'full', basis: `graph edges: ${resolved.join(', ')}` }
48
- : { support: 'partial', basis: `graph edges: ${resolved.join(', ')} (no imports/inherits)` };
55
+ : { support: 'partial', basis: `graph edges: ${resolved.join(', ')} (no ${missing.join('/')})` };
49
56
  }
50
57
  function astCell(astLang) {
51
58
  // The TS grammar family covers tsx/js too — AST_LANGUAGES is the authority on which wasm applies.
@@ -70,10 +77,18 @@ function runnerCell(sampleFile) {
70
77
  : { support: 'none', basis: 'no test-runner adapter — execution evidence unavailable' };
71
78
  }
72
79
  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' };
80
+ // @implements A-SPEC-513.1 derived from the two things a taint verdict actually needs: a
81
+ // VOCABULARY for the language and a control/data-flow graph to judge it on. Before this the cell
82
+ // read `hasDataFlowWalk`, which described only the older facts extractor and therefore understated
83
+ // Python once its CFG/DDG landed.
84
+ const hasVocab = taint_vocabulary_1.TAINT_LANGUAGES.has(astLang) || (astLang === 'tsx' && taint_vocabulary_1.TAINT_LANGUAGES.has('typescript'));
85
+ const hasFlow = cfg_1.CFG_LANGUAGES.has(astLang) || (astLang === 'typescript' && cfg_1.CFG_LANGUAGES.has('tsx'));
86
+ if (hasVocab && hasFlow) {
87
+ return { support: 'full', basis: 'taint_scan { dataFlow: true } analyses this language (vocabulary + CFG/DDG)' };
88
+ }
89
+ if (hasVocab)
90
+ return { support: 'partial', basis: 'vocabulary present but no CFG/DDG to judge flow on' };
91
+ return { support: 'none', basis: 'no taint vocabulary for this language — taint_scan does not analyse it' };
77
92
  }
78
93
  // @implements A-SPEC-510.6
79
94
  /** 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,