@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/README.md +102 -0
  4. package/bin/holmes-hook-antigravity.js +31 -0
  5. package/bin/holmes-kit.js +23 -0
  6. package/bin/holmes-mcp.js +34 -0
  7. package/bin/holmes-stop-antigravity.js +29 -0
  8. package/dist/.build-id +1 -0
  9. package/dist/holmes/cli/agents.js +168 -0
  10. package/dist/holmes/cli/doctor.js +625 -0
  11. package/dist/holmes/cli/gitignore-merge.js +84 -0
  12. package/dist/holmes/cli/governed-precondition.js +157 -0
  13. package/dist/holmes/cli/index.js +384 -0
  14. package/dist/holmes/cli/init.js +462 -0
  15. package/dist/holmes/cli/playbook-skills.js +711 -0
  16. package/dist/holmes/cli/roles-readme.js +134 -0
  17. package/dist/holmes/cli/settings-merge.js +122 -0
  18. package/dist/holmes/config/config.js +70 -0
  19. package/dist/holmes/context/bundler.js +114 -0
  20. package/dist/holmes/context/render.js +29 -0
  21. package/dist/holmes/context/tiers.js +110 -0
  22. package/dist/holmes/context/tokens.js +8 -0
  23. package/dist/holmes/cpg/cpg-scanner.js +213 -0
  24. package/dist/holmes/cpg/hash-cache.js +86 -0
  25. package/dist/holmes/cpg/language-parser-walk.js +917 -0
  26. package/dist/holmes/cpg/language-parser-worker.js +81 -0
  27. package/dist/holmes/cpg/language-parser.js +234 -0
  28. package/dist/holmes/cpg/scan-cache.js +108 -0
  29. package/dist/holmes/cpg/source-path.js +44 -0
  30. package/dist/holmes/cpg/test-files.js +84 -0
  31. package/dist/holmes/governance/constitution-debt.js +73 -0
  32. package/dist/holmes/governance/constitution-report.js +25 -0
  33. package/dist/holmes/governance/constitution.js +129 -0
  34. package/dist/holmes/governance/identity.js +30 -0
  35. package/dist/holmes/governance/ledger-lock.js +165 -0
  36. package/dist/holmes/governance/ledger-store.conformance.js +90 -0
  37. package/dist/holmes/governance/ledger-store.js +106 -0
  38. package/dist/holmes/governance/progress-ledger.js +83 -0
  39. package/dist/holmes/governance/provenance-chain.js +365 -0
  40. package/dist/holmes/governance/provenance-ledger.js +0 -0
  41. package/dist/holmes/governance/provenance-schema.js +47 -0
  42. package/dist/holmes/governance/replica-id.js +106 -0
  43. package/dist/holmes/governance/role-policy.js +137 -0
  44. package/dist/holmes/governance/trust-score.js +43 -0
  45. package/dist/holmes/guardrail/anchors.js +31 -0
  46. package/dist/holmes/guardrail/blind-spots.js +38 -0
  47. package/dist/holmes/guardrail/decision-ledger.js +107 -0
  48. package/dist/holmes/guardrail/executable-artifact.js +129 -0
  49. package/dist/holmes/guardrail/governance-history.js +101 -0
  50. package/dist/holmes/guardrail/phase.js +169 -0
  51. package/dist/holmes/guardrail/risk-classifier.js +450 -0
  52. package/dist/holmes/guardrail/risk-gate.js +160 -0
  53. package/dist/holmes/guardrail/risk-types.js +6 -0
  54. package/dist/holmes/guardrail/tspec-state.js +392 -0
  55. package/dist/holmes/guardrail/write-target.js +224 -0
  56. package/dist/holmes/hooks/adapters/antigravity.js +194 -0
  57. package/dist/holmes/hooks/pre-tool-use.js +1262 -0
  58. package/dist/holmes/hooks/stop.js +416 -0
  59. package/dist/holmes/mcp/basis.js +162 -0
  60. package/dist/holmes/mcp/handlers.js +1831 -0
  61. package/dist/holmes/mcp/server.js +71 -0
  62. package/dist/holmes/mcp/stdio-client.js +165 -0
  63. package/dist/holmes/mcp/supervisor.js +178 -0
  64. package/dist/holmes/mcp/tool-schemas.js +394 -0
  65. package/dist/holmes/mcp/validate-args.js +281 -0
  66. package/dist/holmes/messages/registry.js +50 -0
  67. package/dist/holmes/project/baseline.js +210 -0
  68. package/dist/holmes/project/change-source.js +233 -0
  69. package/dist/holmes/project/ignore.js +145 -0
  70. package/dist/holmes/project/root.js +113 -0
  71. package/dist/holmes/reverse/anchor.js +162 -0
  72. package/dist/holmes/reverse/cluster.js +187 -0
  73. package/dist/holmes/reverse/draft.js +151 -0
  74. package/dist/holmes/reverse/dynamic-wiring.js +47 -0
  75. package/dist/holmes/reverse/scan.js +194 -0
  76. package/dist/holmes/reverse/surface.js +154 -0
  77. package/dist/holmes/reverse/test-map.js +263 -0
  78. package/dist/holmes/review/coverage.js +33 -0
  79. package/dist/holmes/review/findings.js +123 -0
  80. package/dist/holmes/review/package.js +40 -0
  81. package/dist/holmes/review/review-targets.js +92 -0
  82. package/dist/holmes/review/scope.js +57 -0
  83. package/dist/holmes/review/test-evidence.js +77 -0
  84. package/dist/holmes/review/test-runner.js +572 -0
  85. package/dist/holmes/rtm/dataflow-taint.js +262 -0
  86. package/dist/holmes/rtm/gap-analyzer.js +27 -0
  87. package/dist/holmes/rtm/git-changes.js +72 -0
  88. package/dist/holmes/rtm/incremental.js +45 -0
  89. package/dist/holmes/rtm/localize.js +100 -0
  90. package/dist/holmes/rtm/rtm-builder.js +191 -0
  91. package/dist/holmes/rtm/rtm-check.js +89 -0
  92. package/dist/holmes/rtm/rtm-graph.js +232 -0
  93. package/dist/holmes/rtm/taint.js +92 -0
  94. package/dist/holmes/rtm/test-scope.js +336 -0
  95. package/dist/holmes/spec/approval-blockers.js +204 -0
  96. package/dist/holmes/spec/breaking-change.js +89 -0
  97. package/dist/holmes/spec/legacy-format.js +87 -0
  98. package/dist/holmes/spec/spec-digest.js +71 -0
  99. package/dist/holmes/spec/spec-parser.js +106 -0
  100. package/dist/holmes/spec/spec-store.conformance.js +118 -0
  101. package/dist/holmes/spec/spec-store.js +331 -0
  102. package/dist/holmes/spec/spec-types.js +177 -0
  103. package/dist/holmes/spec/validator.js +280 -0
  104. package/package.json +76 -0
  105. package/playbooks/adopt/PLAYBOOK.md +125 -0
  106. package/playbooks/author-slice/PLAYBOOK.md +119 -0
  107. 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
+ }