@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.
- package/CHANGELOG.md +88 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/cli/ci-gate.js +3 -1
- package/dist/holmes/cpg/foundation/ast-store.d.ts +1 -1
- package/dist/holmes/cpg/foundation/ast-store.js +8 -0
- package/dist/holmes/cpg/foundation/cfg.js +137 -5
- package/dist/holmes/cpg/foundation/ddg.js +101 -3
- package/dist/holmes/cpg/foundation/language-matrix.js +22 -7
- package/dist/holmes/cpg/language-parser-walk.js +5 -1
- package/dist/holmes/cpg/language-parser.d.ts +1 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +17 -2
- package/dist/holmes/hooks/pre-tool-use.js +21 -4
- package/dist/holmes/mcp/handlers.d.ts +17 -5
- package/dist/holmes/mcp/handlers.js +98 -1
- package/dist/holmes/mcp/supervisor.d.ts +24 -0
- package/dist/holmes/mcp/supervisor.js +63 -6
- package/dist/holmes/mcp/tool-schemas.js +8 -1
- package/dist/holmes/project/root.d.ts +1 -0
- package/dist/holmes/project/root.js +30 -0
- package/dist/holmes/review/test-runner.d.ts +15 -0
- package/dist/holmes/review/test-runner.js +87 -9
- package/dist/holmes/rtm/dataflow-taint.js +5 -1
- package/dist/holmes/rtm/flow-sensitive-taint.d.ts +41 -0
- package/dist/holmes/rtm/flow-sensitive-taint.js +109 -0
- package/dist/holmes/rtm/reaching-def-filter.d.ts +44 -0
- package/dist/holmes/rtm/reaching-def-filter.js +167 -0
- package/dist/holmes/rtm/sink-matching.d.ts +26 -0
- package/dist/holmes/rtm/sink-matching.js +73 -0
- package/dist/holmes/rtm/taint-benchmark.d.ts +13 -0
- package/dist/holmes/rtm/taint-benchmark.js +97 -9
- package/dist/holmes/rtm/taint-vocabulary.d.ts +24 -0
- package/dist/holmes/rtm/taint-vocabulary.js +58 -0
- package/grammars/manifest.json +30 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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:
|
|
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,
|
|
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:
|
|
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,
|
|
340
|
+
function isGovernedProject(specsDir, opts = {}) {
|
|
327
341
|
try {
|
|
328
|
-
if (
|
|
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
|
-
|
|
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,
|