@holmes-lab/holmes-kit 0.3.11 → 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 +94 -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 +49 -0
- package/dist/holmes/cpg/foundation/ast-store.js +209 -0
- package/dist/holmes/cpg/foundation/cdg.d.ts +31 -0
- package/dist/holmes/cpg/foundation/cdg.js +83 -0
- package/dist/holmes/cpg/foundation/cfg.d.ts +60 -0
- package/dist/holmes/cpg/foundation/cfg.js +617 -0
- package/dist/holmes/cpg/foundation/ddg.d.ts +41 -0
- package/dist/holmes/cpg/foundation/ddg.js +394 -0
- package/dist/holmes/cpg/foundation/language-envelope.d.ts +29 -0
- package/dist/holmes/cpg/foundation/language-envelope.js +131 -0
- package/dist/holmes/cpg/foundation/language-matrix.d.ts +57 -0
- package/dist/holmes/cpg/foundation/language-matrix.js +142 -0
- package/dist/holmes/cpg/foundation/substrate-census.d.ts +36 -0
- package/dist/holmes/cpg/foundation/substrate-census.js +135 -0
- package/dist/holmes/cpg/language-capability.js +21 -3
- package/dist/holmes/cpg/language-parser-walk.js +137 -6
- 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 +23 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,100 @@ 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
|
+
|
|
60
|
+
<!-- @implements A-SPEC-209 -->
|
|
61
|
+
## [0.4.0] - 2026-09-02
|
|
62
|
+
|
|
63
|
+
The code property graph gets a real foundation, and "officially supported language" becomes a
|
|
64
|
+
checkable fact instead of a sentence. Minor bump: new analysis layers ship, and the language
|
|
65
|
+
capability table widens — no existing behaviour changes.
|
|
66
|
+
|
|
67
|
+
### Added
|
|
68
|
+
|
|
69
|
+
- **CPG foundation, layered** (REQ-510): a tree-sitter (wasm) substrate replaces ad-hoc parsing
|
|
70
|
+
for the analysis layers, and four layers land on top of it — **L1** lossless-span AST with a
|
|
71
|
+
basis-checked cache, **L2** control-flow graphs with Cooper–Harvey–Kennedy dominator and
|
|
72
|
+
post-dominator trees, **L3** reaching definitions (`REACHING_DEF`) and control dependence
|
|
73
|
+
(`CDG`, Ferrante–Ottenstein–Warren); PDG is their union. Vocabulary follows the Joern CPG
|
|
74
|
+
specification so external comparison stays apples-to-apples. **TypeScript/JavaScript and
|
|
75
|
+
Python** are lowered; every other language refuses honestly rather than returning a partial
|
|
76
|
+
graph, because a partial control-flow graph is a wrong one.
|
|
77
|
+
Dogfooded: 10,929 functions in this repository and 1,209 in an external Python corpus build
|
|
78
|
+
with **zero invariant violations** (34,946 REACHING_DEF and 15,090 CDG edges here).
|
|
79
|
+
Python's own semantics are pinned, not approximated: a loop's `else` runs only on exhaustion
|
|
80
|
+
(a `break` bypasses it), `match` has no fallthrough, and `try/else` runs only when nothing
|
|
81
|
+
was raised.
|
|
82
|
+
- **Layer × language support matrix** (A-SPEC-510.6): `docs/language-support.md` is now
|
|
83
|
+
GENERATED from the modules that own each capability — a test pins it byte-identical, so the
|
|
84
|
+
table cannot drift from the code. It states plainly what was previously unwritten: `taint_scan`
|
|
85
|
+
analyses the TypeScript family only, and C++ has no test-runner adapter.
|
|
86
|
+
- **Inheritance for five more languages** (REQ-511): Java (`extends` + `implements`), C#
|
|
87
|
+
(`base_list`), C++ (`base_class_clause`), Rust (`impl Trait for`) and Go (**struct embedding**)
|
|
88
|
+
now produce `inherits` edges that reach the RTM graph — so impact traversal finally sees a base
|
|
89
|
+
type's dependants in those languages. Go's interface satisfaction stays unrecovered by design:
|
|
90
|
+
it is implicit and structural, and guessing it would be worse than declaring the gap.
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- **`test_run` false red on large suites**: every runner spawn used `execFileSync`'s default 1MB
|
|
95
|
+
buffer, so a full `jest --json` (>2MB here) died with ENOBUFS and a GREEN suite reported as
|
|
96
|
+
failed with no failing test named. All six runner adapters now share a 256MB ceiling.
|
|
97
|
+
- **pre-push gate**: `core.hooksPath` is honored (installing into `.git/hooks` was a silent no-op
|
|
98
|
+
where that config is set), and tag refs are no longer judged — a tag names an existing commit
|
|
99
|
+
rather than advancing a baseline.
|
|
100
|
+
|
|
7
101
|
<!-- @implements A-SPEC-209 -->
|
|
8
102
|
## [0.3.11] - 2026-09-01
|
|
9
103
|
|
package/dist/.build-id
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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: [],
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** The honest language envelope: exactly what the vendored grammars cover — nothing more. */
|
|
2
|
+
export declare const AST_LANGUAGES: ReadonlyArray<{
|
|
3
|
+
ext: RegExp;
|
|
4
|
+
lang: 'typescript' | 'tsx' | 'python';
|
|
5
|
+
wasm: string;
|
|
6
|
+
}>;
|
|
7
|
+
export interface AstNodeRow {
|
|
8
|
+
/** tree-sitter node type (grammar vocabulary). */
|
|
9
|
+
type: string;
|
|
10
|
+
/** Byte-offset span into the source: 0 <= start <= end <= sourceLen. */
|
|
11
|
+
start: number;
|
|
12
|
+
end: number;
|
|
13
|
+
/** Index of the parent row in the flat array (-1 for the root). Pre-order layout. */
|
|
14
|
+
parent: number;
|
|
15
|
+
/** tree-sitter "named" node (true) vs anonymous token (false). */
|
|
16
|
+
named: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface PersistedAst {
|
|
19
|
+
lang: string;
|
|
20
|
+
grammarVersion: string;
|
|
21
|
+
runtimeVersion: string;
|
|
22
|
+
sourceHash: string;
|
|
23
|
+
sourceLen: number;
|
|
24
|
+
/** ERROR + missing nodes seen during the parse — partial parses are recorded, never hidden. */
|
|
25
|
+
errorCount: number;
|
|
26
|
+
nodes: AstNodeRow[];
|
|
27
|
+
}
|
|
28
|
+
/** Exposed for tests/tools that want to pay the load cost up front. */
|
|
29
|
+
export declare function initSubstrate(): Promise<void>;
|
|
30
|
+
export declare function languageFor(relPath: string): {
|
|
31
|
+
lang: string;
|
|
32
|
+
wasm: string;
|
|
33
|
+
} | null;
|
|
34
|
+
/** Parse one source into the flat lossless-span form. Throws only on substrate failure. */
|
|
35
|
+
export declare function parseAst(source: string, relPath: string): Promise<PersistedAst | null>;
|
|
36
|
+
export declare const AST_CACHE_DIR: string;
|
|
37
|
+
/** Observable for tests: how many astOf calls were served without a parse. */
|
|
38
|
+
export declare const cacheStats: {
|
|
39
|
+
hits: number;
|
|
40
|
+
misses: number;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Basis-checked persisted AST for a repo file. Hit only when sourceHash AND grammar AND runtime
|
|
44
|
+
* versions all match (GraphBasis doctrine: any moved field means reparse); a corrupt cache file
|
|
45
|
+
* degrades to a miss, never a crash. The cache directory is delete-safe — everything regenerates.
|
|
46
|
+
*/
|
|
47
|
+
export declare function astOf(root: string, relPath: string): Promise<PersistedAst | null>;
|
|
48
|
+
/** The span invariants of REQ-510 L1 — violations by name, [] when the contract holds. */
|
|
49
|
+
export declare function spanViolations(ast: PersistedAst): string[];
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.cacheStats = exports.AST_CACHE_DIR = exports.AST_LANGUAGES = void 0;
|
|
37
|
+
exports.initSubstrate = initSubstrate;
|
|
38
|
+
exports.languageFor = languageFor;
|
|
39
|
+
exports.parseAst = parseAst;
|
|
40
|
+
exports.astOf = astOf;
|
|
41
|
+
exports.spanViolations = spanViolations;
|
|
42
|
+
// @implements A-SPEC-510.2
|
|
43
|
+
/**
|
|
44
|
+
* L1 of the CPG foundation: lossless-span AST persistence on the tree-sitter(wasm) substrate
|
|
45
|
+
* (decided by the S-510.1 five-axis probe; grammar provenance sealed by scripts/sync-grammars.js).
|
|
46
|
+
*
|
|
47
|
+
* LOSSLESS is a span contract, not a text copy: every node carries byte offsets into the source,
|
|
48
|
+
* the root covers [0, sourceLen], children nest inside their parent, and a node's text is
|
|
49
|
+
* reconstructible as source.slice(start, end). The tree is stored as a flat parent-indexed array —
|
|
50
|
+
* compact, order-preserving (pre-order), and cheap for the CFG/PDG layers to walk.
|
|
51
|
+
*
|
|
52
|
+
* Vocabulary note (2026-09-01 survey): this layer corresponds to the `AST` edge partition of the
|
|
53
|
+
* Joern CPG specification (cpg.joern.io); CFG/CDG/REACHING_DEF/DOMINATE names are reserved for
|
|
54
|
+
* .3+ so the differential-comparison harness stays apples-to-apples.
|
|
55
|
+
*
|
|
56
|
+
* NEVER imported by hooks/gates (pinned by test): loading a wasm runtime inside the gate path
|
|
57
|
+
* would violate REQ-510's zero-cost constraint. Cache invalidation is basis-checked, file-hash
|
|
58
|
+
* driven (the batch-scan consumption pattern) — editor-style incremental edit() is deliberately
|
|
59
|
+
* not used here.
|
|
60
|
+
*/
|
|
61
|
+
const fs = __importStar(require("node:fs"));
|
|
62
|
+
const path = __importStar(require("node:path"));
|
|
63
|
+
const node_crypto_1 = require("node:crypto");
|
|
64
|
+
/** The honest language envelope: exactly what the vendored grammars cover — nothing more. */
|
|
65
|
+
exports.AST_LANGUAGES = [
|
|
66
|
+
{ ext: /\.tsx$/, lang: 'tsx', wasm: 'tree-sitter-tsx.wasm' },
|
|
67
|
+
{ ext: /\.(ts|mts|cts)$/, lang: 'typescript', wasm: 'tree-sitter-typescript.wasm' },
|
|
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
|
+
{ ext: /\.py$/, lang: 'python', wasm: 'tree-sitter-python.wasm' },
|
|
70
|
+
];
|
|
71
|
+
const GRAMMARS_DIR = (() => {
|
|
72
|
+
// src/holmes/cpg/foundation → three up is the package root; identical depth from dist.
|
|
73
|
+
const root = path.resolve(__dirname, '..', '..', '..', '..');
|
|
74
|
+
return path.join(root, 'grammars');
|
|
75
|
+
})();
|
|
76
|
+
let manifestCache = null;
|
|
77
|
+
function manifest() {
|
|
78
|
+
if (!manifestCache) {
|
|
79
|
+
manifestCache = JSON.parse(fs.readFileSync(path.join(GRAMMARS_DIR, 'manifest.json'), 'utf8'));
|
|
80
|
+
}
|
|
81
|
+
return manifestCache;
|
|
82
|
+
}
|
|
83
|
+
let parserP = null;
|
|
84
|
+
function substrate() {
|
|
85
|
+
if (!parserP) {
|
|
86
|
+
parserP = (async () => {
|
|
87
|
+
const { Parser, Language } = require('web-tree-sitter');
|
|
88
|
+
// BYTES, not paths: handing the runtime its core wasm and each grammar as buffers keeps
|
|
89
|
+
// web-tree-sitter's internal fetch/dynamic-import loader entirely out of the picture —
|
|
90
|
+
// measured 2026-09-01: the path-based loader dynamic-imports, which jest's CJS VM refuses
|
|
91
|
+
// (--experimental-vm-modules), and an env flag would silently red every spawned jest
|
|
92
|
+
// (test_run evidence runs) that doesn't carry it.
|
|
93
|
+
const runtimeWasm = fs.readFileSync(path.join(path.dirname(require.resolve('web-tree-sitter', { paths: [GRAMMARS_DIR] })), 'web-tree-sitter.wasm'));
|
|
94
|
+
await Parser.init({ wasmBinary: runtimeWasm });
|
|
95
|
+
const languages = new Map();
|
|
96
|
+
for (const wasm of new Set(exports.AST_LANGUAGES.map((l) => l.wasm))) {
|
|
97
|
+
languages.set(wasm, await Language.load(fs.readFileSync(path.join(GRAMMARS_DIR, wasm))));
|
|
98
|
+
}
|
|
99
|
+
return { parser: new Parser(), languages };
|
|
100
|
+
})();
|
|
101
|
+
}
|
|
102
|
+
return parserP;
|
|
103
|
+
}
|
|
104
|
+
/** Exposed for tests/tools that want to pay the load cost up front. */
|
|
105
|
+
async function initSubstrate() { await substrate(); }
|
|
106
|
+
function languageFor(relPath) {
|
|
107
|
+
const base = path.basename(relPath);
|
|
108
|
+
for (const { ext, lang, wasm } of exports.AST_LANGUAGES)
|
|
109
|
+
if (ext.test(base))
|
|
110
|
+
return { lang, wasm };
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
/** Parse one source into the flat lossless-span form. Throws only on substrate failure. */
|
|
114
|
+
async function parseAst(source, relPath) {
|
|
115
|
+
const langOf = languageFor(relPath);
|
|
116
|
+
if (!langOf)
|
|
117
|
+
return null; // honest envelope: ts/tsx/js/py only
|
|
118
|
+
const { parser, languages } = await substrate();
|
|
119
|
+
parser.setLanguage(languages.get(langOf.wasm));
|
|
120
|
+
const tree = parser.parse(source);
|
|
121
|
+
const nodes = [];
|
|
122
|
+
let errorCount = 0;
|
|
123
|
+
const walk = (node, parent) => {
|
|
124
|
+
const idx = nodes.length;
|
|
125
|
+
if (node.type === 'ERROR' || node.isMissing)
|
|
126
|
+
errorCount++;
|
|
127
|
+
nodes.push({ type: node.type, start: node.startIndex, end: node.endIndex, parent, named: node.isNamed });
|
|
128
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
129
|
+
const c = node.child(i);
|
|
130
|
+
if (c)
|
|
131
|
+
walk(c, idx);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
walk(tree.rootNode, -1);
|
|
135
|
+
tree.delete();
|
|
136
|
+
const gramEntry = manifest().grammars.find((g) => g.file === langOf.wasm);
|
|
137
|
+
return {
|
|
138
|
+
lang: langOf.lang,
|
|
139
|
+
grammarVersion: gramEntry?.packageVersion ?? 'unknown',
|
|
140
|
+
runtimeVersion: manifest().webTreeSitter,
|
|
141
|
+
sourceHash: (0, node_crypto_1.createHash)('sha256').update(source).digest('hex'),
|
|
142
|
+
sourceLen: source.length,
|
|
143
|
+
errorCount,
|
|
144
|
+
nodes,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
exports.AST_CACHE_DIR = path.join('.ax', 'cpg_cache', 'ast');
|
|
148
|
+
const cacheFileFor = (root, relPath) => path.join(root, exports.AST_CACHE_DIR, `${(0, node_crypto_1.createHash)('sha256').update(relPath).digest('hex').slice(0, 24)}.json`);
|
|
149
|
+
/** Observable for tests: how many astOf calls were served without a parse. */
|
|
150
|
+
exports.cacheStats = { hits: 0, misses: 0 };
|
|
151
|
+
/**
|
|
152
|
+
* Basis-checked persisted AST for a repo file. Hit only when sourceHash AND grammar AND runtime
|
|
153
|
+
* versions all match (GraphBasis doctrine: any moved field means reparse); a corrupt cache file
|
|
154
|
+
* degrades to a miss, never a crash. The cache directory is delete-safe — everything regenerates.
|
|
155
|
+
*/
|
|
156
|
+
async function astOf(root, relPath) {
|
|
157
|
+
if (!languageFor(relPath))
|
|
158
|
+
return null;
|
|
159
|
+
let source;
|
|
160
|
+
try {
|
|
161
|
+
source = fs.readFileSync(path.join(root, relPath), 'utf8');
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
const sourceHash = (0, node_crypto_1.createHash)('sha256').update(source).digest('hex');
|
|
167
|
+
const cacheFile = cacheFileFor(root, relPath);
|
|
168
|
+
try {
|
|
169
|
+
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
|
170
|
+
const gramEntry = manifest().grammars.find((g) => g.file === languageFor(relPath).wasm);
|
|
171
|
+
if (cached.sourceHash === sourceHash
|
|
172
|
+
&& cached.grammarVersion === (gramEntry?.packageVersion ?? 'unknown')
|
|
173
|
+
&& cached.runtimeVersion === manifest().webTreeSitter
|
|
174
|
+
&& Array.isArray(cached.nodes)) {
|
|
175
|
+
exports.cacheStats.hits++;
|
|
176
|
+
return cached;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch { /* absent or corrupt: fall through to a fresh parse */ }
|
|
180
|
+
exports.cacheStats.misses++;
|
|
181
|
+
const ast = await parseAst(source, relPath);
|
|
182
|
+
if (!ast)
|
|
183
|
+
return null;
|
|
184
|
+
try {
|
|
185
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
186
|
+
fs.writeFileSync(cacheFile, JSON.stringify(ast));
|
|
187
|
+
}
|
|
188
|
+
catch { /* persistence failure degrades to compute-only — never a crash */ }
|
|
189
|
+
return ast;
|
|
190
|
+
}
|
|
191
|
+
/** The span invariants of REQ-510 L1 — violations by name, [] when the contract holds. */
|
|
192
|
+
function spanViolations(ast) {
|
|
193
|
+
const out = [];
|
|
194
|
+
ast.nodes.forEach((n, i) => {
|
|
195
|
+
if (!(n.start >= 0 && n.start <= n.end && n.end <= ast.sourceLen))
|
|
196
|
+
out.push(`node ${i} (${n.type}): span out of bounds`);
|
|
197
|
+
if (i === 0 ? n.parent !== -1 : !(n.parent >= 0 && n.parent < i))
|
|
198
|
+
out.push(`node ${i} (${n.type}): bad parent index`);
|
|
199
|
+
if (n.parent >= 0) {
|
|
200
|
+
const p = ast.nodes[n.parent];
|
|
201
|
+
if (!(n.start >= p.start && n.end <= p.end))
|
|
202
|
+
out.push(`node ${i} (${n.type}): escapes parent span`);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
if (ast.nodes.length > 0 && !(ast.nodes[0].start === 0 && ast.nodes[0].end === ast.sourceLen)) {
|
|
206
|
+
out.push('root does not cover [0, sourceLen]');
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L3-CDG: control dependence per Ferrante–Ottenstein–Warren, computed in the edge-walk form that
|
|
3
|
+
* is equivalent to the post-dominance frontier — for every CFG edge (A→s) out of a BRANCH block A,
|
|
4
|
+
* every block on the post-dominator chain from s up to (exclusive) ipostdom(A) is control-dependent
|
|
5
|
+
* on A. Joern vocabulary: `CDG`. PDG = REACHING_DEF (.4) ∪ CDG (.5).
|
|
6
|
+
*
|
|
7
|
+
* ENVELOPE: dependence on ENTRY (top-level code that no branch guards) is NOT emitted — "it runs
|
|
8
|
+
* because the function was entered" is vacuous and would be pure noise. Blocks that do not reach
|
|
9
|
+
* EXIT (infinite-loop tails, ipostdom = -1) terminate the chain walk; they still register as
|
|
10
|
+
* dependent on the guarding branch, which is true — whether they run at all hangs on it.
|
|
11
|
+
*/
|
|
12
|
+
import type { PersistedAst } from './ast-store';
|
|
13
|
+
import type { Cfg, FunctionRef } from './cfg';
|
|
14
|
+
import { DdgEdge } from './ddg';
|
|
15
|
+
export interface CdgEdge {
|
|
16
|
+
ctrl: number;
|
|
17
|
+
dep: number;
|
|
18
|
+
}
|
|
19
|
+
export interface Pdg {
|
|
20
|
+
reachingDefs: DdgEdge[];
|
|
21
|
+
controlDeps: CdgEdge[];
|
|
22
|
+
}
|
|
23
|
+
export declare function cdgOf(cfg: Cfg): CdgEdge[];
|
|
24
|
+
/** PDG = the two dependence halves, composed verbatim — no recomputation, no reinterpretation. */
|
|
25
|
+
export declare function pdgOf(ast: PersistedAst, cfg: Cfg | {
|
|
26
|
+
unsupported: string;
|
|
27
|
+
}, fn: FunctionRef, source: string): Pdg | {
|
|
28
|
+
unsupported: string;
|
|
29
|
+
};
|
|
30
|
+
/** The CDG verifier — [] means the FOW definition holds on this instance. */
|
|
31
|
+
export declare function cdgViolations(cfg: Cfg, edges: CdgEdge[]): string[];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.cdgOf = cdgOf;
|
|
4
|
+
exports.pdgOf = pdgOf;
|
|
5
|
+
exports.cdgViolations = cdgViolations;
|
|
6
|
+
const ddg_1 = require("./ddg");
|
|
7
|
+
function cdgOf(cfg) {
|
|
8
|
+
const outdeg = new Map();
|
|
9
|
+
for (const e of cfg.edges)
|
|
10
|
+
outdeg.set(e.from, (outdeg.get(e.from) ?? 0) + 1);
|
|
11
|
+
const edges = [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
for (const e of cfg.edges) {
|
|
14
|
+
const A = e.from;
|
|
15
|
+
if (A === cfg.entry)
|
|
16
|
+
continue; // vacuous ENTRY dependence (envelope)
|
|
17
|
+
if ((outdeg.get(A) ?? 0) < 2)
|
|
18
|
+
continue; // only a BRANCH can control anything
|
|
19
|
+
const stop = cfg.ipostdom[A]; // -1 when A never reaches EXIT
|
|
20
|
+
let B = e.to;
|
|
21
|
+
const guard = new Set();
|
|
22
|
+
while (B !== stop && B !== -1 && !guard.has(B)) {
|
|
23
|
+
guard.add(B);
|
|
24
|
+
const key = `${A}>${B}`;
|
|
25
|
+
if (!seen.has(key)) {
|
|
26
|
+
seen.add(key);
|
|
27
|
+
edges.push({ ctrl: A, dep: B });
|
|
28
|
+
}
|
|
29
|
+
B = cfg.ipostdom[B];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return edges;
|
|
33
|
+
}
|
|
34
|
+
// @implements A-SPEC-510.5
|
|
35
|
+
/** PDG = the two dependence halves, composed verbatim — no recomputation, no reinterpretation. */
|
|
36
|
+
function pdgOf(ast, cfg, fn, source) {
|
|
37
|
+
if ('unsupported' in cfg)
|
|
38
|
+
return cfg;
|
|
39
|
+
const ddg = (0, ddg_1.ddgOf)(ast, cfg, fn, source);
|
|
40
|
+
if ('unsupported' in ddg)
|
|
41
|
+
return ddg;
|
|
42
|
+
return { reachingDefs: ddg.edges, controlDeps: cdgOf(cfg) };
|
|
43
|
+
}
|
|
44
|
+
// @implements A-SPEC-510.5
|
|
45
|
+
/** The CDG verifier — [] means the FOW definition holds on this instance. */
|
|
46
|
+
function cdgViolations(cfg, edges) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const n = cfg.blocks.length;
|
|
49
|
+
const outdeg = new Map();
|
|
50
|
+
const succ = cfg.blocks.map(() => []);
|
|
51
|
+
for (const e of cfg.edges) {
|
|
52
|
+
outdeg.set(e.from, (outdeg.get(e.from) ?? 0) + 1);
|
|
53
|
+
succ[e.from].push(e.to);
|
|
54
|
+
}
|
|
55
|
+
const inCycle = (a) => {
|
|
56
|
+
const seen = new Set();
|
|
57
|
+
const st = [...succ[a]];
|
|
58
|
+
while (st.length) {
|
|
59
|
+
const x = st.pop();
|
|
60
|
+
if (x === a)
|
|
61
|
+
return true;
|
|
62
|
+
if (!seen.has(x)) {
|
|
63
|
+
seen.add(x);
|
|
64
|
+
st.push(...succ[x]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
};
|
|
69
|
+
for (const e of edges) {
|
|
70
|
+
if (e.ctrl < 0 || e.ctrl >= n || e.dep < 0 || e.dep >= n) {
|
|
71
|
+
out.push(`edge escapes blocks: ${e.ctrl}->${e.dep}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if ((outdeg.get(e.ctrl) ?? 0) < 2)
|
|
75
|
+
out.push(`ctrl ${e.ctrl} is not a branch`);
|
|
76
|
+
if (e.dep === cfg.ipostdom[e.ctrl])
|
|
77
|
+
out.push(`dep ${e.dep} post-dominates its ctrl ${e.ctrl}`);
|
|
78
|
+
// Self-dependence is legitimate ONLY for a loop head (the branch sits on its own cycle).
|
|
79
|
+
if (e.ctrl === e.dep && !inCycle(e.ctrl))
|
|
80
|
+
out.push(`acyclic self-dependence at ${e.ctrl}`);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L2 of the CPG foundation: statement-granularity control-flow graphs over the L1 PersistedAst,
|
|
3
|
+
* with synthetic ENTRY/EXIT, labeled edges, and Cooper-Harvey-Kennedy (post-)dominator trees.
|
|
4
|
+
* Vocabulary aligns with the Joern CPG spec's CFG/DOMINATE/POST_DOMINATE partitions.
|
|
5
|
+
*
|
|
6
|
+
* GRANULARITY ENVELOPE (sealed in A-SPEC-510.3): statements are the atoms. Expression-internal
|
|
7
|
+
* control flow (ternary, &&/||, optional chaining short-circuits) is NOT modeled here; await and
|
|
8
|
+
* yield proceed linearly in statement order. Exception edges are a CONSERVATIVE over-approximation:
|
|
9
|
+
* every statement inside a try is assumed able to throw into its handler.
|
|
10
|
+
*
|
|
11
|
+
* FAIL-CLOSED: the statement types this builder understands are ENUMERATED. Anything outside the
|
|
12
|
+
* list — `with_statement` deliberately included — yields `{ unsupported }` for the WHOLE function:
|
|
13
|
+
* a partial control-flow graph is a wrong control-flow graph.
|
|
14
|
+
*/
|
|
15
|
+
import type { PersistedAst } from './ast-store';
|
|
16
|
+
export type EdgeKind = 'seq' | 'true' | 'false' | 'case' | 'default' | 'loop-back' | 'break' | 'continue' | 'exception' | 'return' | 'throw';
|
|
17
|
+
export interface CfgBlock {
|
|
18
|
+
id: number;
|
|
19
|
+
stmts: number[];
|
|
20
|
+
}
|
|
21
|
+
export interface CfgEdge {
|
|
22
|
+
from: number;
|
|
23
|
+
to: number;
|
|
24
|
+
kind: EdgeKind;
|
|
25
|
+
}
|
|
26
|
+
export interface Cfg {
|
|
27
|
+
blocks: CfgBlock[];
|
|
28
|
+
edges: CfgEdge[];
|
|
29
|
+
entry: number;
|
|
30
|
+
exit: number;
|
|
31
|
+
/** idom[b] = immediate dominator block id (-1 for entry / unreachable). */
|
|
32
|
+
idom: Int32Array;
|
|
33
|
+
/** ipostdom[b] = immediate post-dominator block id (-1 for exit / not reaching exit). */
|
|
34
|
+
ipostdom: Int32Array;
|
|
35
|
+
/** Blocks not reachable from ENTRY (dead code after return etc.) — lint material, not an error. */
|
|
36
|
+
unreachable: number[];
|
|
37
|
+
}
|
|
38
|
+
export interface FunctionRef {
|
|
39
|
+
nodeIndex: number;
|
|
40
|
+
bodyIndex: number;
|
|
41
|
+
}
|
|
42
|
+
export declare const FUNCTION_TYPES: Set<string>;
|
|
43
|
+
/**
|
|
44
|
+
* The languages this lowering understands — DERIVED from the rule table, so the layer×language
|
|
45
|
+
* matrix and the guard below can never disagree with what is actually implemented.
|
|
46
|
+
*/
|
|
47
|
+
export declare const CFG_LANGUAGES: ReadonlySet<string>;
|
|
48
|
+
export interface Children {
|
|
49
|
+
of(i: number): number[];
|
|
50
|
+
}
|
|
51
|
+
/** Shared child index over the flat pre-order AST — DDG (L3) walks with the same truth. */
|
|
52
|
+
export declare function childrenIndex(ast: PersistedAst): Children;
|
|
53
|
+
/** Every function-like node in the AST, with its body (statement_block or expression body). */
|
|
54
|
+
export declare function functionsIn(ast: PersistedAst): FunctionRef[];
|
|
55
|
+
/** Build the CFG of one function, or refuse the whole function honestly. */
|
|
56
|
+
export declare function cfgOf(ast: PersistedAst, fn: FunctionRef, source?: string): Cfg | {
|
|
57
|
+
unsupported: string;
|
|
58
|
+
};
|
|
59
|
+
/** The invariant verifier — the ONE truth tests and later layers share. [] means the theory holds. */
|
|
60
|
+
export declare function domViolations(cfg: Cfg): string[];
|