@holmes-lab/holmes-kit 0.3.10 → 0.4.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 +51 -0
- package/dist/.build-id +1 -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 +134 -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 +132 -5
- package/dist/holmes/governance/push-gate.d.ts +3 -0
- package/dist/holmes/governance/push-gate.js +6 -1
- 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,57 @@ 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.0] - 2026-09-02
|
|
9
|
+
|
|
10
|
+
The code property graph gets a real foundation, and "officially supported language" becomes a
|
|
11
|
+
checkable fact instead of a sentence. Minor bump: new analysis layers ship, and the language
|
|
12
|
+
capability table widens — no existing behaviour changes.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **CPG foundation, layered** (REQ-510): a tree-sitter (wasm) substrate replaces ad-hoc parsing
|
|
17
|
+
for the analysis layers, and four layers land on top of it — **L1** lossless-span AST with a
|
|
18
|
+
basis-checked cache, **L2** control-flow graphs with Cooper–Harvey–Kennedy dominator and
|
|
19
|
+
post-dominator trees, **L3** reaching definitions (`REACHING_DEF`) and control dependence
|
|
20
|
+
(`CDG`, Ferrante–Ottenstein–Warren); PDG is their union. Vocabulary follows the Joern CPG
|
|
21
|
+
specification so external comparison stays apples-to-apples. **TypeScript/JavaScript and
|
|
22
|
+
Python** are lowered; every other language refuses honestly rather than returning a partial
|
|
23
|
+
graph, because a partial control-flow graph is a wrong one.
|
|
24
|
+
Dogfooded: 10,929 functions in this repository and 1,209 in an external Python corpus build
|
|
25
|
+
with **zero invariant violations** (34,946 REACHING_DEF and 15,090 CDG edges here).
|
|
26
|
+
Python's own semantics are pinned, not approximated: a loop's `else` runs only on exhaustion
|
|
27
|
+
(a `break` bypasses it), `match` has no fallthrough, and `try/else` runs only when nothing
|
|
28
|
+
was raised.
|
|
29
|
+
- **Layer × language support matrix** (A-SPEC-510.6): `docs/language-support.md` is now
|
|
30
|
+
GENERATED from the modules that own each capability — a test pins it byte-identical, so the
|
|
31
|
+
table cannot drift from the code. It states plainly what was previously unwritten: `taint_scan`
|
|
32
|
+
analyses the TypeScript family only, and C++ has no test-runner adapter.
|
|
33
|
+
- **Inheritance for five more languages** (REQ-511): Java (`extends` + `implements`), C#
|
|
34
|
+
(`base_list`), C++ (`base_class_clause`), Rust (`impl Trait for`) and Go (**struct embedding**)
|
|
35
|
+
now produce `inherits` edges that reach the RTM graph — so impact traversal finally sees a base
|
|
36
|
+
type's dependants in those languages. Go's interface satisfaction stays unrecovered by design:
|
|
37
|
+
it is implicit and structural, and guessing it would be worse than declaring the gap.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **`test_run` false red on large suites**: every runner spawn used `execFileSync`'s default 1MB
|
|
42
|
+
buffer, so a full `jest --json` (>2MB here) died with ENOBUFS and a GREEN suite reported as
|
|
43
|
+
failed with no failing test named. All six runner adapters now share a 256MB ceiling.
|
|
44
|
+
- **pre-push gate**: `core.hooksPath` is honored (installing into `.git/hooks` was a silent no-op
|
|
45
|
+
where that config is set), and tag refs are no longer judged — a tag names an existing commit
|
|
46
|
+
rather than advancing a baseline.
|
|
47
|
+
|
|
48
|
+
<!-- @implements A-SPEC-209 -->
|
|
49
|
+
## [0.3.11] - 2026-09-01
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
|
|
53
|
+
- **push gate: tag refs are names, not baselines** (dogfooded): pushing `--tags` sends
|
|
54
|
+
`refs/tags` lines whose shas are usually ancestors of the evidenced tip, and the exact-head
|
|
55
|
+
rule refused the release-tag push of the very version that shipped the gate. Only
|
|
56
|
+
`refs/heads` lines are judged now — a tag merely names an existing commit.
|
|
57
|
+
|
|
7
58
|
<!-- @implements A-SPEC-209 -->
|
|
8
59
|
## [0.3.10] - 2026-09-01
|
|
9
60
|
|
package/dist/.build-id
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
3e5ef00-mtiwan92
|
|
@@ -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[];
|