@holmes-lab/holmes-kit 0.1.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 +22 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/bin/holmes-hook-antigravity.js +31 -0
- package/bin/holmes-kit.js +23 -0
- package/bin/holmes-mcp.js +34 -0
- package/bin/holmes-stop-antigravity.js +29 -0
- package/dist/.build-id +1 -0
- package/dist/holmes/cli/agents.js +168 -0
- package/dist/holmes/cli/doctor.js +625 -0
- package/dist/holmes/cli/gitignore-merge.js +84 -0
- package/dist/holmes/cli/governed-precondition.js +157 -0
- package/dist/holmes/cli/index.js +384 -0
- package/dist/holmes/cli/init.js +462 -0
- package/dist/holmes/cli/playbook-skills.js +711 -0
- package/dist/holmes/cli/roles-readme.js +134 -0
- package/dist/holmes/cli/settings-merge.js +122 -0
- package/dist/holmes/config/config.js +70 -0
- package/dist/holmes/context/bundler.js +114 -0
- package/dist/holmes/context/render.js +29 -0
- package/dist/holmes/context/tiers.js +110 -0
- package/dist/holmes/context/tokens.js +8 -0
- package/dist/holmes/cpg/cpg-scanner.js +213 -0
- package/dist/holmes/cpg/hash-cache.js +86 -0
- package/dist/holmes/cpg/language-parser-walk.js +917 -0
- package/dist/holmes/cpg/language-parser-worker.js +81 -0
- package/dist/holmes/cpg/language-parser.js +234 -0
- package/dist/holmes/cpg/scan-cache.js +108 -0
- package/dist/holmes/cpg/source-path.js +44 -0
- package/dist/holmes/cpg/test-files.js +84 -0
- package/dist/holmes/governance/constitution-debt.js +73 -0
- package/dist/holmes/governance/constitution-report.js +25 -0
- package/dist/holmes/governance/constitution.js +129 -0
- package/dist/holmes/governance/identity.js +30 -0
- package/dist/holmes/governance/ledger-lock.js +165 -0
- package/dist/holmes/governance/ledger-store.conformance.js +90 -0
- package/dist/holmes/governance/ledger-store.js +106 -0
- package/dist/holmes/governance/progress-ledger.js +83 -0
- package/dist/holmes/governance/provenance-chain.js +365 -0
- package/dist/holmes/governance/provenance-ledger.js +0 -0
- package/dist/holmes/governance/provenance-schema.js +47 -0
- package/dist/holmes/governance/replica-id.js +106 -0
- package/dist/holmes/governance/role-policy.js +137 -0
- package/dist/holmes/governance/trust-score.js +43 -0
- package/dist/holmes/guardrail/anchors.js +31 -0
- package/dist/holmes/guardrail/blind-spots.js +38 -0
- package/dist/holmes/guardrail/decision-ledger.js +107 -0
- package/dist/holmes/guardrail/executable-artifact.js +129 -0
- package/dist/holmes/guardrail/governance-history.js +101 -0
- package/dist/holmes/guardrail/phase.js +169 -0
- package/dist/holmes/guardrail/risk-classifier.js +450 -0
- package/dist/holmes/guardrail/risk-gate.js +160 -0
- package/dist/holmes/guardrail/risk-types.js +6 -0
- package/dist/holmes/guardrail/tspec-state.js +392 -0
- package/dist/holmes/guardrail/write-target.js +224 -0
- package/dist/holmes/hooks/adapters/antigravity.js +194 -0
- package/dist/holmes/hooks/pre-tool-use.js +1262 -0
- package/dist/holmes/hooks/stop.js +416 -0
- package/dist/holmes/mcp/basis.js +162 -0
- package/dist/holmes/mcp/handlers.js +1831 -0
- package/dist/holmes/mcp/server.js +71 -0
- package/dist/holmes/mcp/stdio-client.js +165 -0
- package/dist/holmes/mcp/supervisor.js +178 -0
- package/dist/holmes/mcp/tool-schemas.js +394 -0
- package/dist/holmes/mcp/validate-args.js +281 -0
- package/dist/holmes/messages/registry.js +50 -0
- package/dist/holmes/project/baseline.js +210 -0
- package/dist/holmes/project/change-source.js +233 -0
- package/dist/holmes/project/ignore.js +145 -0
- package/dist/holmes/project/root.js +113 -0
- package/dist/holmes/reverse/anchor.js +162 -0
- package/dist/holmes/reverse/cluster.js +187 -0
- package/dist/holmes/reverse/draft.js +151 -0
- package/dist/holmes/reverse/dynamic-wiring.js +47 -0
- package/dist/holmes/reverse/scan.js +194 -0
- package/dist/holmes/reverse/surface.js +154 -0
- package/dist/holmes/reverse/test-map.js +263 -0
- package/dist/holmes/review/coverage.js +33 -0
- package/dist/holmes/review/findings.js +123 -0
- package/dist/holmes/review/package.js +40 -0
- package/dist/holmes/review/review-targets.js +92 -0
- package/dist/holmes/review/scope.js +57 -0
- package/dist/holmes/review/test-evidence.js +77 -0
- package/dist/holmes/review/test-runner.js +572 -0
- package/dist/holmes/rtm/dataflow-taint.js +262 -0
- package/dist/holmes/rtm/gap-analyzer.js +27 -0
- package/dist/holmes/rtm/git-changes.js +72 -0
- package/dist/holmes/rtm/incremental.js +45 -0
- package/dist/holmes/rtm/localize.js +100 -0
- package/dist/holmes/rtm/rtm-builder.js +191 -0
- package/dist/holmes/rtm/rtm-check.js +89 -0
- package/dist/holmes/rtm/rtm-graph.js +232 -0
- package/dist/holmes/rtm/taint.js +92 -0
- package/dist/holmes/rtm/test-scope.js +336 -0
- package/dist/holmes/spec/approval-blockers.js +204 -0
- package/dist/holmes/spec/breaking-change.js +89 -0
- package/dist/holmes/spec/legacy-format.js +87 -0
- package/dist/holmes/spec/spec-digest.js +71 -0
- package/dist/holmes/spec/spec-parser.js +106 -0
- package/dist/holmes/spec/spec-store.conformance.js +118 -0
- package/dist/holmes/spec/spec-store.js +331 -0
- package/dist/holmes/spec/spec-types.js +177 -0
- package/dist/holmes/spec/validator.js +280 -0
- package/package.json +76 -0
- package/playbooks/adopt/PLAYBOOK.md +125 -0
- package/playbooks/author-slice/PLAYBOOK.md +119 -0
- package/playbooks/promote-slice/PLAYBOOK.md +134 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_IMPACT = void 0;
|
|
4
|
+
exports.buildFileSubgraph = buildFileSubgraph;
|
|
5
|
+
exports.buildRtm = buildRtm;
|
|
6
|
+
exports.addCallEdges = addCallEdges;
|
|
7
|
+
exports.impactedBy = impactedBy;
|
|
8
|
+
exports.explainImpact = explainImpact;
|
|
9
|
+
/**
|
|
10
|
+
* Adds one scanned file's CODE nodes and `implements` edges to the graph,
|
|
11
|
+
* tagged with that file's sourcePath so RtmGraph.removeBySource(f.sourcePath)
|
|
12
|
+
* can later drop exactly this file's contribution (used by the incremental
|
|
13
|
+
* orchestrator — see Task 4).
|
|
14
|
+
*/
|
|
15
|
+
// @implements A-SPEC-121.2
|
|
16
|
+
function buildFileSubgraph(f, specs, graph) {
|
|
17
|
+
for (const sym of f.symbols) {
|
|
18
|
+
// A-SPEC-121.3: fold source_path into the node id itself so same-named
|
|
19
|
+
// symbols in different files get distinct CODE nodes instead of
|
|
20
|
+
// colliding on a bare `CODE:<qualifiedName>` id (see RtmGraph's class
|
|
21
|
+
// comment). Resolve a bare qn back to these ids via `graph.codeNodeIds`.
|
|
22
|
+
const codeId = `CODE:${sym.qualifiedName}@${f.sourcePath}`;
|
|
23
|
+
graph.addNode(codeId, 'CODE', f.sourcePath);
|
|
24
|
+
for (const aspec of f.implementsSpecs) {
|
|
25
|
+
graph.addEdge(codeId, `SPEC:${aspec}`, 'implements', f.sourcePath);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function buildRtm(specs, scanned, graph, opts) {
|
|
30
|
+
// @implements A-SPEC-139
|
|
31
|
+
// ONE transaction for the whole build: measured, per-insert commits were ~20% of the insert cost
|
|
32
|
+
// on a 56k-insert graph (216 ms -> 173 ms with a transaction; 88 -> 52 ms with cached statements).
|
|
33
|
+
// It also makes a failed build atomic — no half-graph that could be mistaken for a complete one.
|
|
34
|
+
graph.transaction(() => {
|
|
35
|
+
// Add spec nodes and dependencies
|
|
36
|
+
for (const s of specs) {
|
|
37
|
+
const sourcePath = opts?.specSourcePath?.(s.id);
|
|
38
|
+
graph.addNode(`SPEC:${s.id}`, s.type, sourcePath);
|
|
39
|
+
for (const p of s.dependsOn) {
|
|
40
|
+
graph.addEdge(`SPEC:${s.id}`, `SPEC:${p}`, 'depends_on', sourcePath);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Add code nodes and implements edges, one file at a time
|
|
44
|
+
for (const f of scanned) {
|
|
45
|
+
buildFileSubgraph(f, specs, graph);
|
|
46
|
+
}
|
|
47
|
+
// Call edges last: resolving a callee needs every file's symbols to exist as nodes first.
|
|
48
|
+
addCallEdges(scanned, graph);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve each file's recovered call relations to graph edges.
|
|
53
|
+
*
|
|
54
|
+
* A scanner emits `to` as a bare callee name, because a file cannot know where the callee lives.
|
|
55
|
+
* Resolution therefore happens here, where the whole scan is visible, under a deliberately strict
|
|
56
|
+
* rule — PRECISION OVER RECALL, per the D-CPG principle:
|
|
57
|
+
*
|
|
58
|
+
* 1. same file first — a call resolves to a definition in the calling file if one matches;
|
|
59
|
+
* 2. otherwise, only if the name is defined in EXACTLY ONE file across the scan;
|
|
60
|
+
* 3. otherwise nothing is emitted.
|
|
61
|
+
*
|
|
62
|
+
* Rule 3 is the important one. A `save()` defined in nine modules is genuinely ambiguous without type
|
|
63
|
+
* resolution, and guessing produces nine wrong edges that then poison every impact answer traversing
|
|
64
|
+
* them. A missing edge merely leaves a gap that L6 already surfaces; a wrong edge is silent.
|
|
65
|
+
*
|
|
66
|
+
* OWNERSHIP: the edge is tagged with the CALLER's file, which is the file whose text contains the
|
|
67
|
+
* call. Since both endpoints are `CODE:<qn>@<path>` ids, only the caller's file can ever emit this
|
|
68
|
+
* exact (src,dst,rel) triple — so the first-writer-wins hazard documented on RtmGraph cannot arise
|
|
69
|
+
* for call edges, and `removeBySource(caller)` drops exactly them.
|
|
70
|
+
*/
|
|
71
|
+
function addCallEdges(scanned, graph) {
|
|
72
|
+
// qualified name -> the sourcePaths defining it. Built once; ambiguity is decided from counts.
|
|
73
|
+
const definedIn = new Map();
|
|
74
|
+
for (const f of scanned) {
|
|
75
|
+
for (const sym of f.symbols) {
|
|
76
|
+
const list = definedIn.get(sym.qualifiedName);
|
|
77
|
+
if (list) {
|
|
78
|
+
if (!list.includes(f.sourcePath))
|
|
79
|
+
list.push(f.sourcePath);
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
definedIn.set(sym.qualifiedName, [f.sourcePath]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// A bare callee `m` may correspond to a qualified `Class.m`. Index the last segment too, but only
|
|
86
|
+
// to a UNIQUE owner — a method name shared by several classes stays unresolvable on purpose.
|
|
87
|
+
const byLastSegment = new Map();
|
|
88
|
+
for (const [qn, paths] of definedIn) {
|
|
89
|
+
const last = qn.includes('.') ? qn.slice(qn.lastIndexOf('.') + 1) : qn;
|
|
90
|
+
if (byLastSegment.has(last))
|
|
91
|
+
byLastSegment.set(last, null); // ambiguous — never resolved
|
|
92
|
+
else
|
|
93
|
+
byLastSegment.set(last, { qn, paths });
|
|
94
|
+
}
|
|
95
|
+
for (const f of scanned) {
|
|
96
|
+
for (const e of f.edges ?? []) {
|
|
97
|
+
if (e.rel !== 'calls' || !e.from || !e.to || e.from === '<module>')
|
|
98
|
+
continue;
|
|
99
|
+
const fromId = `CODE:${e.from}@${f.sourcePath}`;
|
|
100
|
+
// The caller must be a real node in THIS file; otherwise the walk produced a name the symbol
|
|
101
|
+
// extractor never emitted and the edge cannot be trusted.
|
|
102
|
+
if (!graph.codeNodeIds(e.from).includes(fromId))
|
|
103
|
+
continue;
|
|
104
|
+
let target = null;
|
|
105
|
+
if (definedIn.has(e.to))
|
|
106
|
+
target = { qn: e.to, paths: definedIn.get(e.to) };
|
|
107
|
+
else
|
|
108
|
+
target = byLastSegment.get(e.to) ?? null;
|
|
109
|
+
if (!target)
|
|
110
|
+
continue;
|
|
111
|
+
const sameFile = target.paths.includes(f.sourcePath);
|
|
112
|
+
const path = sameFile ? f.sourcePath : (target.paths.length === 1 ? target.paths[0] : null);
|
|
113
|
+
if (!path)
|
|
114
|
+
continue; // defined in several files — ambiguous, emit nothing
|
|
115
|
+
const toId = `CODE:${target.qn}@${path}`;
|
|
116
|
+
if (toId === fromId)
|
|
117
|
+
continue; // self-recursion carries no impact information
|
|
118
|
+
graph.addEdge(fromId, toId, 'calls', f.sourcePath);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
exports.DEFAULT_IMPACT = { maxCallDepth: 3, hubInDegree: 12 };
|
|
123
|
+
/**
|
|
124
|
+
* Impact of changing a set of symbols, as the set of specs that could be affected.
|
|
125
|
+
*
|
|
126
|
+
* DIRECTION MATTERS. `implements` runs code -> spec and is followed FORWARD; `calls` runs caller ->
|
|
127
|
+
* callee and must be followed BACKWARD, because the things at risk when you change `f` are the
|
|
128
|
+
* things that CALL `f`, not the things `f` calls. Walking calls forward would answer a different
|
|
129
|
+
* question and quietly return the wrong set.
|
|
130
|
+
*
|
|
131
|
+
* Anchor-only impact (no call hops) is a strict subset of this, so the bounded walk can only ever
|
|
132
|
+
* ADD to what the previous implementation returned.
|
|
133
|
+
*/
|
|
134
|
+
function impactedBy(graph, changedSymbolQNames, opts) {
|
|
135
|
+
return explainImpact(graph, changedSymbolQNames, opts).specs;
|
|
136
|
+
}
|
|
137
|
+
function explainImpact(graph, changedSymbolQNames, opts) {
|
|
138
|
+
const { maxCallDepth, hubInDegree } = { ...exports.DEFAULT_IMPACT, ...opts };
|
|
139
|
+
const seeds = [];
|
|
140
|
+
for (const qn of changedSymbolQNames)
|
|
141
|
+
seeds.push(...graph.codeNodeIds(qn));
|
|
142
|
+
// Backward BFS over `calls`, bounded by depth and stopped at hubs.
|
|
143
|
+
const seen = new Set(seeds);
|
|
144
|
+
const reachedByDepth = [seeds.length];
|
|
145
|
+
const stoppedAt = [];
|
|
146
|
+
let frontier = seeds;
|
|
147
|
+
for (let depth = 1; depth <= maxCallDepth && frontier.length > 0; depth++) {
|
|
148
|
+
const next = [];
|
|
149
|
+
for (const id of frontier) {
|
|
150
|
+
// Hub damping applies to nodes REACHED by the walk, never to the seed. Changing a logger must
|
|
151
|
+
// still report the twenty places that call it — refusing to expand the seed answered "nothing
|
|
152
|
+
// is affected", which is worse than answering "too much". Adversarial review caught this:
|
|
153
|
+
// bounded returned 1 spec where unbounded returned 22. Past depth 1 the bound does its job,
|
|
154
|
+
// stopping the walk from continuing THROUGH a hub into the rest of the codebase.
|
|
155
|
+
const inDeg = graph.callerCount(id);
|
|
156
|
+
if (depth > 1 && inDeg > hubInDegree) {
|
|
157
|
+
stoppedAt.push({ id, reason: 'hub', inDegree: inDeg });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
for (const caller of graph.callersOf(id)) {
|
|
161
|
+
if (seen.has(caller))
|
|
162
|
+
continue;
|
|
163
|
+
seen.add(caller);
|
|
164
|
+
next.push(caller);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (next.length)
|
|
168
|
+
reachedByDepth.push(next.length);
|
|
169
|
+
frontier = next;
|
|
170
|
+
}
|
|
171
|
+
// Anything still on the frontier was cut by the depth bound, not by a hub.
|
|
172
|
+
for (const id of frontier)
|
|
173
|
+
stoppedAt.push({ id, reason: 'depth' });
|
|
174
|
+
// Every reached CODE node contributes the specs it ANCHORS, plus their parents — via specsOf,
|
|
175
|
+
// which follows `implements` and `depends_on` only. A generic reachable() here would cross `calls`
|
|
176
|
+
// forward and collect the specs of everything these symbols call, turning the answer into "what
|
|
177
|
+
// this depends on" while still labelled "what breaks if I change this".
|
|
178
|
+
const set = new Set();
|
|
179
|
+
for (const codeId of seen)
|
|
180
|
+
for (const id of graph.specsOf(codeId))
|
|
181
|
+
set.add(id);
|
|
182
|
+
// A hub SEED is reported rather than damped: its callers really are affected, so the set is right,
|
|
183
|
+
// but the caller needs to know the change is broad before treating the set as a bundle.
|
|
184
|
+
const seedCallers = seeds.reduce((max, id) => Math.max(max, graph.callerCount(id)), 0);
|
|
185
|
+
const seedIsHub = maxCallDepth > 0 && seedCallers > hubInDegree
|
|
186
|
+
? { callers: seedCallers, threshold: hubInDegree }
|
|
187
|
+
: undefined;
|
|
188
|
+
// Sorted for a deterministic, lexicographically-stable output contract —
|
|
189
|
+
// `rtm_impact` emits this directly, and determinism is the harness's thesis.
|
|
190
|
+
return { specs: [...set].sort(), reachedByDepth, stoppedAt, ...(seedIsHub ? { seedIsHub } : {}) };
|
|
191
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeBlastRadius = computeBlastRadius;
|
|
4
|
+
exports.rtmCheck = rtmCheck;
|
|
5
|
+
exports.checkImplements = checkImplements;
|
|
6
|
+
const spec_types_1 = require("../spec/spec-types");
|
|
7
|
+
// @implements A-SPEC-121.2
|
|
8
|
+
// @implements A-SPEC-125.3
|
|
9
|
+
// Blast radius of touching spec `targetId`: how many OTHER specs transitively depend on it (the
|
|
10
|
+
// downstream set a change ripples into) and whether the touched node is itself foundational (a REQ
|
|
11
|
+
// root — editing it ripples through its whole subtree). Pure reverse-BFS over depends_on edges; feeds
|
|
12
|
+
// the risk classifier's blast-radius axis so spec edits get graph-aware risk (Tier-2 push-feed).
|
|
13
|
+
// Graceful: an unknown targetId yields { impactedSpecCount: 0, hitsFoundational: false } (axis stays auto).
|
|
14
|
+
function computeBlastRadius(specs, targetId) {
|
|
15
|
+
const target = specs.find((s) => s.id === targetId);
|
|
16
|
+
const reached = new Set([targetId]);
|
|
17
|
+
const dependents = new Set();
|
|
18
|
+
let grew = true;
|
|
19
|
+
while (grew) {
|
|
20
|
+
grew = false;
|
|
21
|
+
for (const s of specs) {
|
|
22
|
+
if (reached.has(s.id))
|
|
23
|
+
continue;
|
|
24
|
+
if (s.dependsOn.some((p) => reached.has(p))) {
|
|
25
|
+
reached.add(s.id);
|
|
26
|
+
dependents.add(s.id);
|
|
27
|
+
grew = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { impactedSpecCount: dependents.size, hitsFoundational: target?.type === 'REQ' };
|
|
32
|
+
}
|
|
33
|
+
function rtmCheck(specs) {
|
|
34
|
+
const issues = [];
|
|
35
|
+
const byId = new Map(specs.map((s) => [s.id, s]));
|
|
36
|
+
// duplicate-id detection (must run BEFORE/independently of the collapsing map above)
|
|
37
|
+
const idCounts = new Map();
|
|
38
|
+
for (const s of specs)
|
|
39
|
+
idCounts.set(s.id, (idCounts.get(s.id) ?? 0) + 1);
|
|
40
|
+
for (const [id, count] of idCounts)
|
|
41
|
+
if (count > 1)
|
|
42
|
+
issues.push({ kind: 'duplicate-id', id, detail: `duplicate id (${count} occurrences)` });
|
|
43
|
+
for (const s of specs)
|
|
44
|
+
for (const p of s.dependsOn)
|
|
45
|
+
if (!byId.has(p))
|
|
46
|
+
issues.push({ kind: 'dangling', id: s.id, detail: `depends_on ${p} missing` });
|
|
47
|
+
// orphan detection: non-root spec types (parents.length > 0) with no depends_on.
|
|
48
|
+
// Route through specTypeDef (own-property lookup) so a stray/unknown type on an
|
|
49
|
+
// unfiltered caller yields undefined (skipped) rather than crashing on a
|
|
50
|
+
// prototype member — consistent with validator.ts (audit Task 3/final review).
|
|
51
|
+
for (const s of specs) {
|
|
52
|
+
const def = (0, spec_types_1.specTypeDef)(s.type);
|
|
53
|
+
if (def && def.parents.length > 0 && s.dependsOn.length === 0)
|
|
54
|
+
issues.push({ kind: 'orphan', id: s.id, detail: `${s.type} has no parent (empty depends_on)` });
|
|
55
|
+
}
|
|
56
|
+
// cycle detection (DFS)
|
|
57
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
58
|
+
const color = new Map(specs.map((s) => [s.id, WHITE]));
|
|
59
|
+
const visit = (id) => {
|
|
60
|
+
color.set(id, GRAY);
|
|
61
|
+
for (const p of byId.get(id)?.dependsOn ?? []) {
|
|
62
|
+
const c = color.get(p);
|
|
63
|
+
if (c === GRAY) {
|
|
64
|
+
issues.push({ kind: 'cycle', id, detail: `cycle via ${p}` });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (c === WHITE && visit(p))
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
color.set(id, BLACK);
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
for (const s of specs)
|
|
74
|
+
if (color.get(s.id) === WHITE)
|
|
75
|
+
visit(s.id);
|
|
76
|
+
return issues;
|
|
77
|
+
}
|
|
78
|
+
function checkImplements(scanned, specs) {
|
|
79
|
+
const issues = [];
|
|
80
|
+
const ids = new Set(specs.map((s) => s.id));
|
|
81
|
+
for (const file of scanned) {
|
|
82
|
+
for (const id of file.implementsSpecs)
|
|
83
|
+
if (!ids.has(id))
|
|
84
|
+
issues.push({ kind: 'dangling-implements', id: file.sourcePath, detail: `@implements ${id} -> no such spec` });
|
|
85
|
+
for (const id of file.unanchoredImplements)
|
|
86
|
+
issues.push({ kind: 'wrong-kind-implements', id: file.sourcePath, detail: `@implements ${id} is not an A-SPEC anchor (anchors must be A-SPEC-*)` });
|
|
87
|
+
}
|
|
88
|
+
return issues;
|
|
89
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RtmGraph = void 0;
|
|
7
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
8
|
+
// @implements A-SPEC-121.2
|
|
9
|
+
/**
|
|
10
|
+
* The RTM graph. **NOT PERSISTED — read this before assuming otherwise.**
|
|
11
|
+
*
|
|
12
|
+
* Every one of the seven handlers that touches a graph (`rtm_impact`, `rtm_reindex`,
|
|
13
|
+
* `context_bundle`, `review_scope`, `review_prepare`, `risk_check`, `test_run`) constructs
|
|
14
|
+
* `new RtmGraph()` with no path, so the store is `:memory:`, built from scratch on each call and
|
|
15
|
+
* discarded when the call returns. Nothing writes `.ax/rtm.sqlite`.
|
|
16
|
+
*
|
|
17
|
+
* The surrounding machinery reads as though a persistent graph exists — `applyIncremental`,
|
|
18
|
+
* `rtm_reindex`, a `.gitignore` entry for `.ax/rtm.sqlite` — and it does not. I misread exactly this
|
|
19
|
+
* and reasoned from a persistent graph that was never there, so it is stated here rather than left
|
|
20
|
+
* to be inferred from seven constructor calls.
|
|
21
|
+
*
|
|
22
|
+
* This is a DELIBERATE position, not an omission (ADR-011): the graph is a derived index whose four
|
|
23
|
+
* inputs all come from git-tracked files, so rebuilding is always correct, a past commit's `.md` +
|
|
24
|
+
* source reconstructs that commit's graph for free, and staleness cannot occur. Persisting is
|
|
25
|
+
* deferred until graph build exceeds ~1 s per call, at which point ADR-011 lists the safety
|
|
26
|
+
* machinery that has to land with it.
|
|
27
|
+
*
|
|
28
|
+
* CONSEQUENCE FOR `rtm_reindex`: it calls `buildRtm` (a full build at HEAD) and only then
|
|
29
|
+
* `applyIncremental`, which removes and re-adds the changed files' subgraphs from that same scan.
|
|
30
|
+
* The result is identical to the full build, so the incremental pass is currently redundant work
|
|
31
|
+
* rather than a saving. It becomes meaningful the moment a persisted graph exists.
|
|
32
|
+
*/
|
|
33
|
+
class RtmGraph {
|
|
34
|
+
db;
|
|
35
|
+
// IDENTITY / COLLISION NOTE: node identity is `id` alone; edge identity is
|
|
36
|
+
// `(src,dst,rel)` alone — `source_path` sits OUTSIDE both keys and is
|
|
37
|
+
// written under INSERT OR IGNORE, i.e. first-write-wins. Consequence: if
|
|
38
|
+
// two files emit the same node id (or the same edge), only the FIRST
|
|
39
|
+
// file's source_path is recorded as owner; a later removeBySource() call
|
|
40
|
+
// for the *other*, non-owning file will not touch that node/edge at all.
|
|
41
|
+
//
|
|
42
|
+
// RESOLVED for CODE nodes (A-SPEC-121.3): `rtm-builder.ts` folds
|
|
43
|
+
// `source_path` into the CODE node id itself (`CODE:<qualifiedName>@
|
|
44
|
+
// <sourcePath>`), so same-named symbols in different files no longer
|
|
45
|
+
// collapse into one node/id — each file gets its own node, and
|
|
46
|
+
// removeBySource() correctly drops exactly its own contribution even
|
|
47
|
+
// under a qn collision. Use `codeNodeIds(qn)` to resolve a bare
|
|
48
|
+
// qualifiedName back to every per-file node id it maps to.
|
|
49
|
+
//
|
|
50
|
+
// This collision class otherwise remains for any OTHER node kind whose id
|
|
51
|
+
// is not source_path-qualified (e.g. SPEC nodes, or a future non-CODE kind
|
|
52
|
+
// reusing this graph) — those still first-write-win exactly as described
|
|
53
|
+
// above. This is harmless under the current rtm_reindex flow (the graph
|
|
54
|
+
// is fully rebuilt at HEAD every call, so collisions self-heal on each
|
|
55
|
+
// full build). It is NOT safe for a future persisted-graph incremental
|
|
56
|
+
// reindex (build once, apply diffs across calls) for such a kind unless
|
|
57
|
+
// it likewise folds a disambiguating key into its node/edge identity (or
|
|
58
|
+
// adds reference-counting) — otherwise it will silently diverge from a
|
|
59
|
+
// full rebuild.
|
|
60
|
+
constructor(dbPath = ':memory:') {
|
|
61
|
+
this.db = new better_sqlite3_1.default(dbPath);
|
|
62
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_path TEXT);
|
|
63
|
+
CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, rel TEXT NOT NULL, source_path TEXT, PRIMARY KEY (src,dst,rel));
|
|
64
|
+
-- The PK covers src-prefixed lookups; nothing covered dst or source_path, so every reverse
|
|
65
|
+
-- traversal and every removeBySource was a full table scan. Measured on a 240k-node graph:
|
|
66
|
+
-- neighbors() x100 655 ms -> 0 ms (the context bundler's BFS primitive — a hot path)
|
|
67
|
+
-- removeBySource() x20 307 ms -> 1 ms
|
|
68
|
+
-- against +408 ms of build cost. Net positive within a single tool call, because the bundler
|
|
69
|
+
-- performs far more than 100 BFS steps and the graph is rebuilt once per call either way.
|
|
70
|
+
CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_path);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_nodes_source ON nodes(source_path);`);
|
|
73
|
+
}
|
|
74
|
+
// @implements A-SPEC-139
|
|
75
|
+
// Insert statements are compiled ONCE and reused. Measured cause of the graph-build bottleneck:
|
|
76
|
+
// review_scope cost ~890 ms on an 8,000-file repo even with the scan cache warm, of which buildRtm
|
|
77
|
+
// was ~620 ms (~70%); a 56,000-insert microbenchmark isolated WHY — re-preparing the same SQL on
|
|
78
|
+
// every call was 216 ms vs 88 ms cached (-59%), and adding a transaction took it to 52 ms (-76%).
|
|
79
|
+
// Semantics are unchanged: same signatures, same INSERT OR IGNORE idempotence, same columns.
|
|
80
|
+
nodeStmt;
|
|
81
|
+
edgeStmt;
|
|
82
|
+
addNode(id, kind, sourcePath) {
|
|
83
|
+
this.nodeStmt ??= this.db.prepare('INSERT OR IGNORE INTO nodes (id,kind,source_path) VALUES (?,?,?)');
|
|
84
|
+
this.nodeStmt.run(id, kind, sourcePath ?? null);
|
|
85
|
+
}
|
|
86
|
+
addEdge(src, dst, rel, sourcePath) {
|
|
87
|
+
this.edgeStmt ??= this.db.prepare('INSERT OR IGNORE INTO edges (src,dst,rel,source_path) VALUES (?,?,?,?)');
|
|
88
|
+
this.edgeStmt.run(src, dst, rel, sourcePath ?? null);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* @implements A-SPEC-139
|
|
92
|
+
* Run `fn` inside ONE SQLite transaction, so a build commits once instead of once per insert.
|
|
93
|
+
* Returns fn's value; on a throw it rolls back and re-throws — a failed build leaves no half-graph
|
|
94
|
+
* a caller could mistake for a complete one (stricter than the previous partial-insert behavior).
|
|
95
|
+
* better-sqlite3 nests via savepoints, so calling this inside an outer transaction is safe.
|
|
96
|
+
*/
|
|
97
|
+
transaction(fn) {
|
|
98
|
+
return this.db.transaction(fn)();
|
|
99
|
+
}
|
|
100
|
+
reachable(fromId) {
|
|
101
|
+
return this.db.prepare(`WITH RECURSIVE r(id) AS (SELECT dst FROM edges WHERE src=? UNION SELECT e.dst FROM edges e JOIN r ON e.src=r.id) SELECT DISTINCT id FROM r`).all(fromId).map(x => x.id);
|
|
102
|
+
}
|
|
103
|
+
incoming(toId) { return this.db.prepare('SELECT DISTINCT src FROM edges WHERE dst=?').all(toId).map(x => x.src); }
|
|
104
|
+
/**
|
|
105
|
+
* Callers of a symbol — `calls` edges only, walked backwards. Impact propagation needs exactly
|
|
106
|
+
* this and not `incoming`, which would also drag in `implements` and `depends_on` and turn a
|
|
107
|
+
* "who calls me" question into "what is attached to me in any way at all".
|
|
108
|
+
*/
|
|
109
|
+
callersOf(id) {
|
|
110
|
+
return this.db.prepare("SELECT DISTINCT src FROM edges WHERE dst=? AND rel='calls' ORDER BY src ASC").all(id).map(x => x.src);
|
|
111
|
+
}
|
|
112
|
+
/** How many distinct symbols call this one. The hub signal for bounded propagation (REQ-127). */
|
|
113
|
+
callerCount(id) {
|
|
114
|
+
return this.db.prepare("SELECT COUNT(DISTINCT src) AS c FROM edges WHERE dst=? AND rel='calls'").get(id).c;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Specs this code node anchors, plus their transitive `depends_on` ancestors.
|
|
118
|
+
*
|
|
119
|
+
* Deliberately NOT `reachable`, which follows every relation. Once call edges exist, a generic
|
|
120
|
+
* forward walk from a CODE node crosses `calls` too and collects the specs of everything the
|
|
121
|
+
* symbol CALLS — silently answering "what does this depend on" while claiming to answer "what
|
|
122
|
+
* breaks if I change this". Adversarial review caught exactly that: changing a caller reported the
|
|
123
|
+
* callee's spec. Impact walks callers BACKWARD; only anchors are followed forward.
|
|
124
|
+
*/
|
|
125
|
+
specsOf(codeId) {
|
|
126
|
+
const direct = this.db.prepare("SELECT DISTINCT dst FROM edges WHERE src=? AND rel='implements'").all(codeId).map(x => x.dst);
|
|
127
|
+
const out = new Set(direct);
|
|
128
|
+
// Spec ancestry is spec-only (`depends_on`), so a recursive walk from a SPEC node cannot wander
|
|
129
|
+
// back into code.
|
|
130
|
+
const anc = this.db.prepare(`WITH RECURSIVE r(id) AS (SELECT dst FROM edges WHERE src=? AND rel='depends_on'
|
|
131
|
+
UNION SELECT e.dst FROM edges e JOIN r ON e.src=r.id WHERE e.rel='depends_on') SELECT DISTINCT id FROM r`);
|
|
132
|
+
for (const s of direct)
|
|
133
|
+
for (const { id } of anc.all(s))
|
|
134
|
+
out.add(id);
|
|
135
|
+
return [...out];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Neighbours with the relation that connects them, so a traversal can price a `calls` hop
|
|
139
|
+
* differently from a spec-chain hop. Same ordering guarantee as `neighbors`.
|
|
140
|
+
*
|
|
141
|
+
* A node reachable by several relations appears once per relation; callers take the cheapest.
|
|
142
|
+
*/
|
|
143
|
+
neighborsWithRel(id) {
|
|
144
|
+
return this.db.prepare(`SELECT dst AS n, rel FROM edges WHERE src=? UNION SELECT src AS n, rel FROM edges WHERE dst=? ORDER BY n ASC, rel ASC`).all(id, id).map((r) => ({ id: r.n, rel: r.rel }));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* This node's `depends_on` PARENTS — outgoing only, so it climbs the spec chain without descending
|
|
148
|
+
* into siblings. A shared parent (one REQ under which many slices hang) is a bidirectional hub;
|
|
149
|
+
* following it in both directions walks from any slice into every other one.
|
|
150
|
+
*/
|
|
151
|
+
parentsOf(id) {
|
|
152
|
+
return this.db.prepare("SELECT DISTINCT dst FROM edges WHERE src=? AND rel='depends_on' ORDER BY dst ASC").all(id).map(x => x.dst);
|
|
153
|
+
}
|
|
154
|
+
/** Nodes exactly one edge away in EITHER direction, sorted ascending and deduped. */
|
|
155
|
+
neighbors(id) {
|
|
156
|
+
return this.db.prepare(`SELECT dst AS n FROM edges WHERE src=? UNION SELECT src AS n FROM edges WHERE dst=? ORDER BY n ASC`).all(id, id).map(x => x.n);
|
|
157
|
+
}
|
|
158
|
+
removeBySource(sourcePath) {
|
|
159
|
+
// Referential integrity, which ownership alone cannot give. A call edge is owned by the CALLER's
|
|
160
|
+
// file, so deleting the CALLEE's file left the edge behind pointing at a node that no longer
|
|
161
|
+
// exists — and a full rebuild would never produce such an edge, so the convergence invariant
|
|
162
|
+
// broke the moment call edges existed. Measured: incremental kept 2 edges where a rebuild had 1.
|
|
163
|
+
//
|
|
164
|
+
// Edges into this file's nodes therefore go FIRST, while those nodes still exist to be selected.
|
|
165
|
+
// That ordering is also what removes any need for scratch state: a first attempt staged the
|
|
166
|
+
// doomed ids in a TEMP table, which left the database unusable if anything threw between
|
|
167
|
+
// creating and dropping it — the next call failed with "table removed already exists".
|
|
168
|
+
//
|
|
169
|
+
// Scoped to THIS file's nodes, never a general dangling-endpoint sweep. A blanket version was
|
|
170
|
+
// tried and destroyed diagnostic information: an `implements` edge may legitimately point at a
|
|
171
|
+
// SPEC node that does not exist — that is code claiming a spec id nobody wrote, exactly what
|
|
172
|
+
// rtm_check's dangling-implements report is for. Deleting those would silence the finding.
|
|
173
|
+
this.db.prepare(`DELETE FROM edges WHERE src IN (SELECT id FROM nodes WHERE source_path = ?)
|
|
174
|
+
OR dst IN (SELECT id FROM nodes WHERE source_path = ?)`).run(sourcePath, sourcePath);
|
|
175
|
+
this.db.prepare('DELETE FROM edges WHERE source_path = ?').run(sourcePath);
|
|
176
|
+
this.db.prepare('DELETE FROM nodes WHERE source_path = ?').run(sourcePath);
|
|
177
|
+
}
|
|
178
|
+
// @implements A-SPEC-121.3
|
|
179
|
+
// Resolve a bare qualifiedName to every per-file CODE node id
|
|
180
|
+
// (CODE:<qn>@<sourcePath>). Deliberately avoids SQL LIKE (and thus its
|
|
181
|
+
// `%`/`_` wildcard hazard — `_` is a valid JS identifier char, so a naive
|
|
182
|
+
// `LIKE 'CODE:'||qn||'@%'` would let e.g. codeNodeIds('my_func') also
|
|
183
|
+
// match CODE:myXfunc@...) by fetching CODE-kind nodes and filtering in JS
|
|
184
|
+
// on an exact string prefix. Returns ids sorted ascending.
|
|
185
|
+
// ASSUMPTION: `qn` contains no literal `@` (the id delimiter). This holds for
|
|
186
|
+
// the current scanner — CodeSymbol.qualifiedName is built from tree-sitter TS/JS
|
|
187
|
+
// identifiers (+`.` for method qualification), and the identifier grammar cannot
|
|
188
|
+
// produce `@`. If a future language's qn could contain `@`, this prefix scheme
|
|
189
|
+
// would need a non-`@` delimiter or explicit escaping.
|
|
190
|
+
codeNodeIds(qn) {
|
|
191
|
+
const prefix = `CODE:${qn}@`;
|
|
192
|
+
// An exact prefix over a TEXT PRIMARY KEY is a RANGE, so it rides the PK index instead of
|
|
193
|
+
// dragging every CODE row into JS. impactedBy calls this once per changed symbol; measured on a
|
|
194
|
+
// 240k-node graph, 50 calls went from 4,428 ms to 0 ms. No index could fix the old shape — the
|
|
195
|
+
// cost was the full scan itself, not a missing lookup.
|
|
196
|
+
//
|
|
197
|
+
// The upper bound is the prefix with its final byte incremented ('@' 0x40 -> 'A' 0x41). SQLite
|
|
198
|
+
// compares TEXT with BINARY collation by default, so every id carrying this exact prefix sorts
|
|
199
|
+
// inside [prefix, upper) whatever bytes precede or follow it — including multi-byte UTF-8 in qn.
|
|
200
|
+
// This also retires the LIKE hazard the previous implementation was written to avoid: a range
|
|
201
|
+
// has no metacharacters, so `_` and `%` in a qualified name are ordinary bytes.
|
|
202
|
+
const upper = prefix.slice(0, -1) + String.fromCharCode(prefix.charCodeAt(prefix.length - 1) + 1);
|
|
203
|
+
// Sorted in JS, NOT by `ORDER BY`. SQLite orders TEXT by UTF-8 bytes while JS orders by UTF-16
|
|
204
|
+
// code units, and the two DISAGREE above the BMP: an emoji (U+1F600, surrogate pair D83D DE00)
|
|
205
|
+
// sorts before U+E000 in UTF-16 but after it in UTF-8. Round-2 review caught the substrate swap
|
|
206
|
+
// silently changing this method's published ordering; a path containing an astral-plane
|
|
207
|
+
// character is rare but entirely legal. The result set is the ids for ONE qualified name, so the
|
|
208
|
+
// sort is free — the index still does the work that mattered, which was finding the rows.
|
|
209
|
+
return this.db.prepare(`SELECT id FROM nodes WHERE id >= ? AND id < ? AND kind='CODE'`)
|
|
210
|
+
.all(prefix, upper).map(x => x.id).sort();
|
|
211
|
+
}
|
|
212
|
+
// @implements A-SPEC-138
|
|
213
|
+
// Forward transitive closure over `rel='calls'` ONLY — the mirror of callersOf. Taint reachability
|
|
214
|
+
// follows calls, never `implements`/`depends_on` (which `reachable` would drag in). Used to screen
|
|
215
|
+
// source→sink call paths.
|
|
216
|
+
callReachable(fromId) {
|
|
217
|
+
return this.db.prepare(`WITH RECURSIVE r(id) AS (SELECT dst FROM edges WHERE src=? AND rel='calls'
|
|
218
|
+
UNION SELECT e.dst FROM edges e JOIN r ON e.src=r.id WHERE e.rel='calls') SELECT DISTINCT id FROM r`).all(fromId).map(x => x.id);
|
|
219
|
+
}
|
|
220
|
+
/** @implements A-SPEC-138 — every CODE node id, for the taint pass to match source/sink names against. */
|
|
221
|
+
allCodeNodeIds() {
|
|
222
|
+
return this.db.prepare(`SELECT id FROM nodes WHERE kind='CODE' ORDER BY id ASC`).all().map(x => x.id);
|
|
223
|
+
}
|
|
224
|
+
/** @implements A-SPEC-138 — direct calls-only callees of a node (one hop), for BFS path assembly. */
|
|
225
|
+
callees(fromId) {
|
|
226
|
+
return this.db.prepare("SELECT DISTINCT dst FROM edges WHERE src=? AND rel='calls' ORDER BY dst ASC").all(fromId).map(x => x.dst);
|
|
227
|
+
}
|
|
228
|
+
nodeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM nodes').get().c; }
|
|
229
|
+
edgeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM edges').get().c; }
|
|
230
|
+
close() { this.db.close(); }
|
|
231
|
+
}
|
|
232
|
+
exports.RtmGraph = RtmGraph;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_TAINT_CONFIG = exports.TAINT_LIMITS = void 0;
|
|
4
|
+
exports.taintReachability = taintReachability;
|
|
5
|
+
/** The limits every taint report must disclose — this is reachability, not data-flow. */
|
|
6
|
+
exports.TAINT_LIMITS = ['name-based matching', 'no def-use', 'no sanitizers', 'call-edge-only'];
|
|
7
|
+
/**
|
|
8
|
+
* A small, conservative default source/sink set. Patterns are case-insensitive substrings on a node's
|
|
9
|
+
* qualified name. Deliberately narrow (precision over recall) and overridable by config.
|
|
10
|
+
*/
|
|
11
|
+
exports.DEFAULT_TAINT_CONFIG = {
|
|
12
|
+
sources: ['getenv', 'argv', 'req.body', 'request', 'stdin', 'readfile', 'readinput', 'query.get'],
|
|
13
|
+
sinks: ['exec', 'eval', 'system', 'query', 'spawn', 'popen', 'deserialize'],
|
|
14
|
+
maxPaths: 100,
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_MAX_PATHS = 100;
|
|
17
|
+
const MAX_DEPTH = 20;
|
|
18
|
+
/** The qualified-name portion of a `CODE:<qn>@<path>` id, lowercased for matching. */
|
|
19
|
+
function qnOf(id) {
|
|
20
|
+
const at = id.lastIndexOf('@');
|
|
21
|
+
const body = id.startsWith('CODE:') ? id.slice('CODE:'.length, at === -1 ? undefined : at) : id;
|
|
22
|
+
return body.toLowerCase();
|
|
23
|
+
}
|
|
24
|
+
const matchesAny = (qn, patterns) => patterns.some((p) => p && qn.includes(p.toLowerCase()));
|
|
25
|
+
/**
|
|
26
|
+
* A representative SHORTEST call path from `source` to `sink` over calls-only edges, or null if none
|
|
27
|
+
* within `MAX_DEPTH`. BFS gives the shortest; ties are broken by the graph's sorted `callees`, so the
|
|
28
|
+
* result is deterministic. Pure over the graph's adjacency.
|
|
29
|
+
*/
|
|
30
|
+
function shortestCallPath(graph, source, sink) {
|
|
31
|
+
if (source === sink)
|
|
32
|
+
return null; // no zero-length self path
|
|
33
|
+
const prev = new Map();
|
|
34
|
+
const seen = new Set([source]);
|
|
35
|
+
let frontier = [source];
|
|
36
|
+
for (let depth = 0; depth < MAX_DEPTH && frontier.length > 0; depth++) {
|
|
37
|
+
const next = [];
|
|
38
|
+
for (const node of frontier) {
|
|
39
|
+
for (const callee of graph.callees(node)) {
|
|
40
|
+
if (seen.has(callee))
|
|
41
|
+
continue;
|
|
42
|
+
seen.add(callee);
|
|
43
|
+
prev.set(callee, node);
|
|
44
|
+
if (callee === sink) {
|
|
45
|
+
const path = [sink];
|
|
46
|
+
let cur = sink;
|
|
47
|
+
while (prev.has(cur)) {
|
|
48
|
+
cur = prev.get(cur);
|
|
49
|
+
path.unshift(cur);
|
|
50
|
+
}
|
|
51
|
+
return path;
|
|
52
|
+
}
|
|
53
|
+
next.push(callee);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
frontier = next;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Source→sink call-reachability pairs over the graph. Deterministic: sources and sinks are iterated
|
|
62
|
+
* in the graph's sorted node order, pairs are deduped by (source, sink) and sorted, and capped at
|
|
63
|
+
* `maxPaths` with `truncated` reporting the remainder. Pure except the graph read.
|
|
64
|
+
*/
|
|
65
|
+
function taintReachability(graph, cfg) {
|
|
66
|
+
const maxPaths = cfg.maxPaths ?? DEFAULT_MAX_PATHS;
|
|
67
|
+
const nodes = graph.allCodeNodeIds(); // already sorted
|
|
68
|
+
const sinkSet = new Set(nodes.filter((n) => matchesAny(qnOf(n), cfg.sinks)));
|
|
69
|
+
const sources = nodes.filter((n) => matchesAny(qnOf(n), cfg.sources));
|
|
70
|
+
const all = [];
|
|
71
|
+
for (const source of sources) {
|
|
72
|
+
const reachable = graph.callReachable(source);
|
|
73
|
+
// A node matching both source and sink is not a source→sink FLOW — skip it here so neither the
|
|
74
|
+
// shortest-path nor the deep-path fallback can mint a spurious self pair (a self-loop or cycle
|
|
75
|
+
// puts the node in its own callReachable set, which the depth fallback would otherwise emit).
|
|
76
|
+
const reachedSinks = reachable.filter((n) => sinkSet.has(n) && n !== source).sort();
|
|
77
|
+
for (const sink of reachedSinks) {
|
|
78
|
+
// Reachability was already confirmed by callReachable (unbounded). shortestCallPath bounds the
|
|
79
|
+
// DISPLAYED path length, not the finding: when a real path is deeper than MAX_DEPTH, still emit
|
|
80
|
+
// the pair (dropping it would be a silent under-report the envelope does not disclose) with an
|
|
81
|
+
// explicit depth-bound placeholder so the reviewer knows a path exists but was not walked out.
|
|
82
|
+
const path = shortestCallPath(graph, source, sink) ?? [source, `…(call path deeper than ${MAX_DEPTH})`, sink];
|
|
83
|
+
all.push({ source, sink, path });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
all.sort((a, b) => {
|
|
87
|
+
const ka = `${a.source} ${a.sink}`, kb = `${b.source} ${b.sink}`;
|
|
88
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
89
|
+
});
|
|
90
|
+
const pairs = all.slice(0, maxPaths);
|
|
91
|
+
return { pairs, truncated: all.length - pairs.length };
|
|
92
|
+
}
|