@holmes-lab/holmes-kit 0.2.1 → 0.3.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 (129) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
  4. package/dist/holmes/assoc/assoc-arm.js +187 -0
  5. package/dist/holmes/assoc/explore.d.ts +21 -0
  6. package/dist/holmes/assoc/explore.js +160 -0
  7. package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
  8. package/dist/holmes/assoc/impact-baseline.js +48 -0
  9. package/dist/holmes/assoc/ppr.d.ts +64 -0
  10. package/dist/holmes/assoc/ppr.js +110 -0
  11. package/dist/holmes/cli/agents.d.ts +24 -11
  12. package/dist/holmes/cli/agents.js +93 -21
  13. package/dist/holmes/cli/doctor.d.ts +56 -0
  14. package/dist/holmes/cli/doctor.js +311 -5
  15. package/dist/holmes/cli/index.js +13 -1
  16. package/dist/holmes/cli/semantic-key.d.ts +19 -0
  17. package/dist/holmes/cli/semantic-key.js +93 -0
  18. package/dist/holmes/config/config.d.ts +9 -0
  19. package/dist/holmes/config/config.js +8 -1
  20. package/dist/holmes/cpg/language-capability.d.ts +65 -0
  21. package/dist/holmes/cpg/language-capability.js +145 -0
  22. package/dist/holmes/cpg/language-parser-walk.js +179 -34
  23. package/dist/holmes/cpg/language-parser.d.ts +1 -1
  24. package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
  25. package/dist/holmes/governance/ledger-rechain.js +17 -2
  26. package/dist/holmes/governance/provenance-ledger.js +21 -0
  27. package/dist/holmes/guardrail/blind-spots.js +12 -1
  28. package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
  29. package/dist/holmes/guardrail/impact-gate.js +263 -0
  30. package/dist/holmes/guardrail/write-target.d.ts +38 -1
  31. package/dist/holmes/guardrail/write-target.js +41 -4
  32. package/dist/holmes/hooks/adapters/antigravity.js +12 -1
  33. package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
  34. package/dist/holmes/hooks/corrupt-state-run.js +16 -0
  35. package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
  36. package/dist/holmes/hooks/pre-tool-use.js +203 -11
  37. package/dist/holmes/hooks/stop.d.ts +65 -0
  38. package/dist/holmes/hooks/stop.js +200 -2
  39. package/dist/holmes/mcp/handlers.d.ts +88 -0
  40. package/dist/holmes/mcp/handlers.js +597 -16
  41. package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
  42. package/dist/holmes/mcp/maintenance-analyze.js +994 -0
  43. package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
  44. package/dist/holmes/mcp/maintenance-evidence.js +253 -0
  45. package/dist/holmes/mcp/tool-schemas.js +71 -0
  46. package/dist/holmes/project/root.js +3 -1
  47. package/dist/holmes/review/baseline-arm.d.ts +37 -0
  48. package/dist/holmes/review/baseline-arm.js +51 -0
  49. package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
  50. package/dist/holmes/review/captured-stdin-guard.js +48 -0
  51. package/dist/holmes/review/coherence-verify.d.ts +31 -0
  52. package/dist/holmes/review/coherence-verify.js +144 -0
  53. package/dist/holmes/review/commit-text.d.ts +50 -0
  54. package/dist/holmes/review/commit-text.js +76 -0
  55. package/dist/holmes/review/confidence-calibration.d.ts +39 -0
  56. package/dist/holmes/review/confidence-calibration.js +39 -0
  57. package/dist/holmes/review/content-baseline.d.ts +38 -0
  58. package/dist/holmes/review/content-baseline.js +103 -0
  59. package/dist/holmes/review/content-verify.d.ts +20 -0
  60. package/dist/holmes/review/content-verify.js +73 -0
  61. package/dist/holmes/review/dense-retrieval.d.ts +66 -0
  62. package/dist/holmes/review/dense-retrieval.js +97 -0
  63. package/dist/holmes/review/edge-quality.d.ts +44 -0
  64. package/dist/holmes/review/edge-quality.js +117 -0
  65. package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
  66. package/dist/holmes/review/evaluation-metrics.js +175 -0
  67. package/dist/holmes/review/graph-verifier.d.ts +34 -0
  68. package/dist/holmes/review/graph-verifier.js +62 -0
  69. package/dist/holmes/review/hop-ablation.d.ts +100 -0
  70. package/dist/holmes/review/hop-ablation.js +89 -0
  71. package/dist/holmes/review/manual-baseline.d.ts +209 -0
  72. package/dist/holmes/review/manual-baseline.js +2846 -0
  73. package/dist/holmes/review/oracle-gap.d.ts +32 -0
  74. package/dist/holmes/review/oracle-gap.js +102 -0
  75. package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
  76. package/dist/holmes/review/point-in-time-replay.js +161 -0
  77. package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
  78. package/dist/holmes/review/rank-diagnosis.js +163 -0
  79. package/dist/holmes/review/replay-calibration.d.ts +62 -0
  80. package/dist/holmes/review/replay-calibration.js +83 -0
  81. package/dist/holmes/review/replay-corpus.d.ts +135 -0
  82. package/dist/holmes/review/replay-corpus.js +210 -0
  83. package/dist/holmes/review/run-replay.d.ts +260 -0
  84. package/dist/holmes/review/run-replay.js +729 -0
  85. package/dist/holmes/review/semantic-arm.d.ts +271 -0
  86. package/dist/holmes/review/semantic-arm.js +717 -0
  87. package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
  88. package/dist/holmes/review/semantic-retrieval.js +156 -0
  89. package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
  90. package/dist/holmes/review/spec-layer-stats.js +52 -0
  91. package/dist/holmes/review/temporal-prior.d.ts +33 -0
  92. package/dist/holmes/review/temporal-prior.js +53 -0
  93. package/dist/holmes/review/test-runner.d.ts +15 -0
  94. package/dist/holmes/review/test-runner.js +41 -4
  95. package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
  96. package/dist/holmes/review/test-selection-breadth.js +57 -0
  97. package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
  98. package/dist/holmes/review/traceability-benchmark.js +135 -0
  99. package/dist/holmes/review/union-verify.d.ts +12 -0
  100. package/dist/holmes/review/union-verify.js +70 -0
  101. package/dist/holmes/rtm/graph-store.d.ts +51 -0
  102. package/dist/holmes/rtm/graph-store.js +122 -0
  103. package/dist/holmes/rtm/incremental.d.ts +25 -1
  104. package/dist/holmes/rtm/incremental.js +18 -1
  105. package/dist/holmes/rtm/localize.d.ts +28 -0
  106. package/dist/holmes/rtm/localize.js +272 -14
  107. package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
  108. package/dist/holmes/rtm/rtm-builder.js +265 -28
  109. package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
  110. package/dist/holmes/rtm/rtm-graph.js +194 -31
  111. package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
  112. package/dist/holmes/rtm/taint-benchmark.js +141 -0
  113. package/dist/holmes/rtm/test-scope.js +24 -1
  114. package/dist/holmes/semantic/credentials.d.ts +15 -0
  115. package/dist/holmes/semantic/credentials.js +134 -0
  116. package/dist/holmes/semantic/embedder.d.ts +44 -0
  117. package/dist/holmes/semantic/embedder.js +185 -0
  118. package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
  119. package/dist/holmes/semantic/hit-rerank.js +38 -0
  120. package/dist/holmes/semantic/tier.d.ts +37 -0
  121. package/dist/holmes/semantic/tier.js +54 -0
  122. package/dist/holmes/semantic/vector-cache.d.ts +11 -0
  123. package/dist/holmes/semantic/vector-cache.js +91 -0
  124. package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
  125. package/dist/holmes/spec/acceptance-quality.js +169 -0
  126. package/dist/holmes/spec/validator.js +33 -1
  127. package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
  128. package/dist/holmes/spec/yaml-scalar.js +43 -0
  129. package/package.json +1 -1
@@ -4,32 +4,39 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.RtmGraph = void 0;
7
+ // @implements A-SPEC-293
8
+ // @implements A-SPEC-289
9
+ // @implements A-SPEC-288
10
+ // @implements A-SPEC-283
11
+ // @implements A-SPEC-282
12
+ // @implements A-SPEC-281
13
+ // @implements A-SPEC-280
7
14
  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
- */
15
+ const PROVENANCE_COLUMNS = [
16
+ 'source_commit', 'extractor_version', 'source_location', 'confidence',
17
+ 'evidence_digest', 'observed_at', 'valid_until', 'derivation',
18
+ ];
19
+ function provenanceRow(p) {
20
+ return [
21
+ p?.sourceCommit ?? null, p?.extractorVersion ?? null, p?.sourceLocation ?? null,
22
+ p?.confidence ?? null, p?.evidenceDigest ?? null, p?.observedAt ?? null,
23
+ p?.validUntil ?? null, p?.derivation ?? null,
24
+ ];
25
+ }
26
+ function provenanceOf(row) {
27
+ if (!row)
28
+ return null;
29
+ return {
30
+ sourceCommit: row.source_commit ?? null,
31
+ extractorVersion: row.extractor_version ?? null,
32
+ sourceLocation: row.source_location ?? null,
33
+ confidence: row.confidence === null || row.confidence === undefined ? null : Number(row.confidence),
34
+ evidenceDigest: row.evidence_digest ?? null,
35
+ observedAt: row.observed_at ?? null,
36
+ validUntil: row.valid_until ?? null,
37
+ derivation: row.derivation ?? null,
38
+ };
39
+ }
33
40
  class RtmGraph {
34
41
  db;
35
42
  // IDENTITY / COLLISION NOTE: node identity is `id` alone; edge identity is
@@ -59,6 +66,15 @@ class RtmGraph {
59
66
  // full rebuild.
60
67
  constructor(dbPath = ':memory:') {
61
68
  this.db = new better_sqlite3_1.default(dbPath);
69
+ // @implements A-SPEC-283
70
+ // Only a FILE-backed store can be contended. Several MCP servers can be live at once, and the
71
+ // hook and the CLI can touch the same project, so a reader must not be blocked by a writer and a
72
+ // busy file must be waited on rather than thrown at. `:memory:` is private to this process and
73
+ // cannot use WAL at all — setting it there would be a pragma that cannot apply.
74
+ if (dbPath !== ':memory:') {
75
+ this.db.pragma('journal_mode = WAL');
76
+ this.db.pragma('busy_timeout = 5000');
77
+ }
62
78
  this.db.exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, kind TEXT NOT NULL, source_path TEXT);
63
79
  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
80
  -- The PK covers src-prefixed lookups; nothing covered dst or source_path, so every reverse
@@ -70,6 +86,24 @@ class RtmGraph {
70
86
  CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst);
71
87
  CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_path);
72
88
  CREATE INDEX IF NOT EXISTS idx_nodes_source ON nodes(source_path);`);
89
+ // @implements A-SPEC-281
90
+ // `CREATE TABLE IF NOT EXISTS` leaves an EXISTING database on its old schema, so a store written
91
+ // before provenance existed would silently reject every new insert. Add the missing columns in
92
+ // place; existing rows keep their data and simply report null provenance, which is the honest
93
+ // record for a fact nobody annotated.
94
+ // @implements A-SPEC-282 — a small key/value side table so a persisted graph can carry the basis
95
+ // it was built on. Kept out of `nodes`/`edges` because it describes the WHOLE graph, not a fact.
96
+ this.db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
97
+ for (const table of ['nodes', 'edges']) {
98
+ const present = new Set(this.db.prepare(`PRAGMA table_info(${table})`).all()
99
+ .map((c) => c.name));
100
+ for (const column of PROVENANCE_COLUMNS) {
101
+ if (present.has(column))
102
+ continue;
103
+ const type = column === 'confidence' ? 'REAL' : 'TEXT';
104
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
105
+ }
106
+ }
73
107
  }
74
108
  // @implements A-SPEC-139
75
109
  // Insert statements are compiled ONCE and reused. Measured cause of the graph-build bottleneck:
@@ -79,13 +113,22 @@ class RtmGraph {
79
113
  // Semantics are unchanged: same signatures, same INSERT OR IGNORE idempotence, same columns.
80
114
  nodeStmt;
81
115
  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);
116
+ addNode(id, kind, sourcePath, provenance) {
117
+ this.nodeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO nodes (id,kind,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?)`);
118
+ this.nodeStmt.run(id, kind, sourcePath ?? null, ...provenanceRow(provenance));
119
+ }
120
+ addEdge(src, dst, rel, sourcePath, provenance) {
121
+ this.edgeStmt ??= this.db.prepare(`INSERT OR IGNORE INTO edges (src,dst,rel,source_path,${PROVENANCE_COLUMNS.join(',')}) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`);
122
+ this.edgeStmt.run(src, dst, rel, sourcePath ?? null, ...provenanceRow(provenance));
123
+ }
124
+ /** @implements A-SPEC-281 — how this node got here, or null if the node is unknown. */
125
+ provenanceOfNode(id) {
126
+ return provenanceOf(this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(id));
85
127
  }
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);
128
+ /** @implements A-SPEC-281 — how this edge got here, or null if the edge is unknown. */
129
+ provenanceOfEdge(src, dst, rel) {
130
+ return provenanceOf(this.db.prepare('SELECT * FROM edges WHERE src=? AND dst=? AND rel=?')
131
+ .get(src, dst, rel));
89
132
  }
90
133
  /**
91
134
  * @implements A-SPEC-139
@@ -109,6 +152,66 @@ class RtmGraph {
109
152
  callersOf(id) {
110
153
  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
154
  }
155
+ /**
156
+ * @implements A-SPEC-288
157
+ * Everything a change to this symbol can reach: its callers PLUS its subclasses. Changing a base
158
+ * method affects a subclass that never calls it, so impact must follow inheritance.
159
+ *
160
+ * Deliberately SEPARATE from `callersOf`, which stays calls-only. Taint follows data flow and hub
161
+ * detection counts call sites; folding inheritance into either would change what those two words
162
+ * mean rather than making them more complete.
163
+ *
164
+ * @implements A-SPEC-304
165
+ * A `references` relation (a type-position use of a contract) was built, wired in here, and
166
+ * measured: 586 correct edges on this repository, and EVERY replay metric identical with the
167
+ * relation on and off — including the impact-reach count, 26 truth files either way. The reason is
168
+ * now understood and is not about the edges: the replay enters from a REQUEST STRING, so impact is
169
+ * never seeded from contract nodes at all. What this feature improves, nothing measures yet.
170
+ */
171
+ impactSourcesOf(id) {
172
+ return this.db.prepare("SELECT DISTINCT src FROM edges WHERE dst=? AND rel IN ('calls','inherits') ORDER BY src ASC")
173
+ .all(id).map((x) => x.src);
174
+ }
175
+ /**
176
+ * @implements A-SPEC-309
177
+ * Both directions of impact: who reaches this symbol, and what this symbol reaches.
178
+ *
179
+ * Goal Phase 5 asks for upstream callers AND downstream dependencies, and only the upstream half
180
+ * existed. Changing a function usually means changing what it calls, so the callee side is not a
181
+ * refinement — it is half the question.
182
+ *
183
+ * Kept as its own name rather than widening `impactSourcesOf`, for the same reason A-SPEC-288 split
184
+ * `impactSourcesOf` off `callersOf`: once one function answers two questions, both get blurred.
185
+ */
186
+ impactNeighborsOf(id, opts) {
187
+ const limit = opts?.maxCalleeInDegree;
188
+ const downstream = limit === undefined
189
+ ? this.callees(id)
190
+ : this.callees(id).filter((callee) => this.callerCount(callee) <= limit);
191
+ return [...new Set([...this.impactSourcesOf(id), ...downstream])].sort();
192
+ }
193
+ /**
194
+ * @implements A-SPEC-289
195
+ * Repo-relative paths of the files that import this one, via resolved FILE->FILE `imports` edges.
196
+ * A coarser dependency than a call, and the only one that reaches a file which depends on another
197
+ * without calling any symbol the resolver could pin down.
198
+ */
199
+ importersOf(sourcePath) {
200
+ return this.db.prepare("SELECT DISTINCT src FROM edges WHERE dst=? AND rel='imports' ORDER BY src ASC")
201
+ .all(`FILE:${sourcePath}`).map((x) => x.src.replace(/^FILE:/, ''));
202
+ }
203
+ /**
204
+ * @implements A-SPEC-293
205
+ * The decisions constraining any of the given node ids (`FILE:<path>` / `SPEC:<id>`), by ADR id.
206
+ * What a change must not break, rather than what it touches.
207
+ */
208
+ decisionsConstraining(nodeIds) {
209
+ if (nodeIds.length === 0)
210
+ return [];
211
+ const marks = nodeIds.map(() => '?').join(',');
212
+ return this.db.prepare(`SELECT DISTINCT dst FROM edges WHERE rel='constrained_by' AND src IN (${marks}) ORDER BY dst ASC`)
213
+ .all(...nodeIds).map((x) => x.dst.replace(/^DECISION:/, ''));
214
+ }
112
215
  /** How many distinct symbols call this one. The hub signal for bounded propagation (REQ-127). */
113
216
  callerCount(id) {
114
217
  return this.db.prepare("SELECT COUNT(DISTINCT src) AS c FROM edges WHERE dst=? AND rel='calls'").get(id).c;
@@ -225,6 +328,66 @@ class RtmGraph {
225
328
  callees(fromId) {
226
329
  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
330
  }
331
+ /**
332
+ * @implements A-SPEC-280
333
+ * A canonical, order-independent dump of the whole graph: sorted nodes then sorted edges. Exists
334
+ * so two graphs built by different routes (a full rebuild vs. an incremental update) can be
335
+ * compared as VALUES rather than by spot-checking a few lookups — the only way the convergence
336
+ * property Goal completion criterion 5 asks for can actually be evidence.
337
+ */
338
+ dumpCanonical() {
339
+ // @implements A-SPEC-281 — provenance is part of the value, so a provenance-only divergence
340
+ // between a full rebuild and an incremental update is caught rather than passing as equal.
341
+ const cols = PROVENANCE_COLUMNS.join(', ');
342
+ const prov = (r) => PROVENANCE_COLUMNS.map((c) => (r[c] === null || r[c] === undefined ? '' : String(r[c]))).join('\t');
343
+ const nodes = this.db.prepare(`SELECT id, kind, source_path, ${cols} FROM nodes ORDER BY id`).all();
344
+ const edges = this.db.prepare(`SELECT src, dst, rel, source_path, ${cols} FROM edges ORDER BY src, dst, rel`).all();
345
+ return [
346
+ ...nodes.map((n) => `N\t${n.id}\t${n.kind}\t${n.source_path ?? ''}\t${prov(n)}`),
347
+ ...edges.map((e) => `E\t${e.src}\t${e.dst}\t${e.rel}\t${e.source_path ?? ''}\t${prov(e)}`),
348
+ ].join('\n');
349
+ }
350
+ /**
351
+ * @implements A-SPEC-280
352
+ * Drop every edge of one relation. Needed because call resolution is GLOBAL: whether `B.two`
353
+ * resolves depends on how many files define it across the whole scan, so a file-local update
354
+ * cannot repair a call edge owned by a file that did not change. Clearing the relation and
355
+ * re-resolving is the only way an incremental update lands on the full-rebuild answer.
356
+ */
357
+ removeEdgesByRel(rel) {
358
+ this.db.prepare('DELETE FROM edges WHERE rel = ?').run(rel);
359
+ }
360
+ /** @implements A-SPEC-282 — every stored meta key/value. */
361
+ readMeta() {
362
+ const rows = this.db.prepare('SELECT key, value FROM meta').all();
363
+ return Object.fromEntries(rows.map((r) => [r.key, r.value]));
364
+ }
365
+ /** @implements A-SPEC-282 — replace the stored meta wholesale, so a stale key cannot survive. */
366
+ writeMeta(meta) {
367
+ this.transaction(() => {
368
+ this.db.prepare('DELETE FROM meta').run();
369
+ const stmt = this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?)');
370
+ for (const [key, value] of Object.entries(meta))
371
+ stmt.run(key, value);
372
+ });
373
+ }
374
+ /**
375
+ * @implements A-SPEC-282
376
+ * Empty the graph, keeping the file. Used when a stored graph's basis no longer holds: the caller
377
+ * has just been told it cannot trust these facts, so leaving any of them behind to be built on top
378
+ * of would be worse than having had no cache at all.
379
+ */
380
+ clear() {
381
+ this.transaction(() => {
382
+ this.db.prepare('DELETE FROM edges').run();
383
+ this.db.prepare('DELETE FROM nodes').run();
384
+ this.db.prepare('DELETE FROM meta').run();
385
+ });
386
+ }
387
+ /** @implements A-SPEC-283 — the journal mode actually in force, so the pragma can be asserted. */
388
+ journalMode() {
389
+ return String(this.db.pragma('journal_mode', { simple: true }) ?? '');
390
+ }
228
391
  nodeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM nodes').get().c; }
229
392
  edgeCount() { return this.db.prepare('SELECT COUNT(*) AS c FROM edges').get().c; }
230
393
  close() { this.db.close(); }
@@ -0,0 +1,97 @@
1
+ import { type DataFlowTaintConfig } from './dataflow-taint';
2
+ /**
3
+ * A labelled corpus for the taint engine, and the reason it exists.
4
+ *
5
+ * Goal Phase E asks for CFG. Before that is a decision anyone can judge, this repository needs the
6
+ * number CFG would improve. The propagation today is sanitizer-aware but FLOW-INSENSITIVE: it knows
7
+ * which expressions read what, and not the order the statements run in or which branch they sit on.
8
+ * That has a cost, and the cost is currently unmeasured — so CFG's value is unmeasured with it.
9
+ *
10
+ * Every case names the ground truth AND why it is hard, so an error can be attributed to the missing
11
+ * ordering, the missing branch knowledge, or something else entirely. The count attributable to the
12
+ * first two IS the ceiling on what CFG can win here. If it is small, CFG has no value on this
13
+ * corpus — the same verdict Java `inherits` got for having zero edges to work with.
14
+ */
15
+ export type Difficulty =
16
+ /** Answerable without any control flow — a straight line. */
17
+ 'flow-free'
18
+ /** Needs statement ORDER: the same statements in another order have the opposite answer. */
19
+ | 'needs-order'
20
+ /** Needs BRANCH knowledge: the answer differs per path, or on the merge of paths. */
21
+ | 'needs-branch';
22
+ export interface TaintCase {
23
+ name: string;
24
+ code: string;
25
+ /** True when a real source reaches a real sink unsanitised ON SOME EXECUTION PATH. */
26
+ vulnerable: boolean;
27
+ difficulty: Difficulty;
28
+ /** Why this case is what it claims to be — the ground truth's justification, not a restatement. */
29
+ rationale: string;
30
+ }
31
+ export declare const TAINT_CONFIG: DataFlowTaintConfig;
32
+ export declare const TAINT_CASES: readonly TaintCase[];
33
+ export interface CaseOutcome {
34
+ name: string;
35
+ difficulty: Difficulty;
36
+ expected: boolean;
37
+ actual: boolean;
38
+ /** 'tp' | 'tn' | 'fp' | 'fn' — the confusion cell this case landed in. */
39
+ cell: 'tp' | 'tn' | 'fp' | 'fn';
40
+ }
41
+ export interface TaintBenchmark {
42
+ outcomes: CaseOutcome[];
43
+ tp: number;
44
+ tn: number;
45
+ fp: number;
46
+ fn: number;
47
+ /** null when nothing was predicted positive — unmeasured, not zero. */
48
+ precision: number | null;
49
+ recall: number | null;
50
+ /**
51
+ * Errors whose case is labelled `needs-order` or `needs-branch`. This is the CEILING on what a
52
+ * control-flow graph could win on this corpus: an error on a `flow-free` case has some other
53
+ * cause, and CFG would not touch it.
54
+ */
55
+ controlFlowAttributable: number;
56
+ }
57
+ /** Run the current engine over the labelled cases and score it. */
58
+ export declare function runTaintBenchmark(cases?: readonly TaintCase[]): TaintBenchmark;
59
+ export interface RealCorpusTaint {
60
+ corpus: string;
61
+ filesScanned: number;
62
+ /**
63
+ * Files the propagation actually received facts for.
64
+ *
65
+ * When this is 0 the corpus is UNMEASURED, not clean: the data-flow walk exists for a subset of
66
+ * languages, so a Python/JS repository yields no facts and would otherwise report a confident
67
+ * `findings: 0`. Measured on jarvis: 145 files scanned, 0 with facts.
68
+ */
69
+ filesWithFacts: number;
70
+ findings: number;
71
+ /** Distinct (file, callee, line) sinks reached — a run reporting one sink many times is one site. */
72
+ sites: number;
73
+ /** Findings whose flow contains a sanitizer call anywhere: the shapes control flow could re-judge. */
74
+ findingsTouchingSanitizer: number;
75
+ converged: boolean;
76
+ truncated: number;
77
+ /** null when nothing was analysable — the axis is unmeasured, and 0 findings would be a lie. */
78
+ findingsPerFile: number | null;
79
+ }
80
+ /**
81
+ * How often the taint analysis fires on REAL code.
82
+ *
83
+ * A-SPEC-354 measured what control flow could fix; this measures whether there is anything to fix.
84
+ * An analysis that produces a handful of findings across an entire repository cannot repay a
85
+ * control-flow graph however much its precision would improve — the same reasoning that declined
86
+ * `inherits` edges for a corpus that contained none.
87
+ *
88
+ * `findingsTouchingSanitizer` is the honest upper bound on the control-flow-attributable share: a
89
+ * finding whose derivation never passes a sanitizer cannot be a false positive created by
90
+ * conditional sanitisation. It is an upper bound and not a count of false positives — deciding
91
+ * whether each is truly spurious needs a human, and inflating it into a defect count is exactly the
92
+ * kind of claim this document exists to prevent.
93
+ */
94
+ export declare function taintOnRealCorpus(corpusName: string, root: string, scan: (root: string) => readonly {
95
+ sourcePath: string;
96
+ dataflow?: import('../cpg/language-parser').DataFlowFacts;
97
+ }[], cfg?: DataFlowTaintConfig): RealCorpusTaint;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TAINT_CASES = exports.TAINT_CONFIG = void 0;
4
+ exports.runTaintBenchmark = runTaintBenchmark;
5
+ exports.taintOnRealCorpus = taintOnRealCorpus;
6
+ // @implements A-SPEC-354
7
+ const language_parser_1 = require("../cpg/language-parser");
8
+ const dataflow_taint_1 = require("./dataflow-taint");
9
+ exports.TAINT_CONFIG = {
10
+ sources: ['req.body', 'req.query', 'process.argv'],
11
+ sinks: ['exec', 'execSync', 'query'],
12
+ sanitizers: ['escape', 'sanitize'],
13
+ };
14
+ exports.TAINT_CASES = [
15
+ {
16
+ name: 'direct source to sink',
17
+ vulnerable: true, difficulty: 'flow-free',
18
+ rationale: 'One assignment, one call. Any engine that finds nothing here is broken, not imprecise.',
19
+ code: `function h(req) { const c = req.body.cmd; exec(c); }`,
20
+ },
21
+ {
22
+ name: 'sanitised before the sink',
23
+ vulnerable: false, difficulty: 'flow-free',
24
+ rationale: 'The kill rule alone settles it; no ordering needed because there is only one order.',
25
+ code: `function h(req) { const c = escape(req.body.cmd); exec(c); }`,
26
+ },
27
+ {
28
+ name: 'two hops through a helper',
29
+ vulnerable: true, difficulty: 'flow-free',
30
+ rationale: 'Tests the arg-param and return rules rather than control flow.',
31
+ code: `function pass(x) { return x; }\nfunction h(req) { const c = pass(req.body.cmd); exec(c); }`,
32
+ },
33
+ {
34
+ name: 'sanitised AFTER the sink',
35
+ vulnerable: true, difficulty: 'needs-order',
36
+ rationale: 'The sink runs on the tainted value; the sanitiser runs afterwards and cannot help it. ' +
37
+ 'A flow-insensitive engine sees a sanitiser applied to the variable and kills the taint, ' +
38
+ 'reporting nothing — a FALSE NEGATIVE that only statement order can distinguish.',
39
+ code: `function h(req) { let c = req.body.cmd; exec(c); c = escape(c); return c; }`,
40
+ },
41
+ {
42
+ name: 'overwritten with a constant before the sink',
43
+ vulnerable: false, difficulty: 'needs-order',
44
+ rationale: 'The variable is tainted, then reassigned to a literal, then used. Without order, the ' +
45
+ 'variable is simply "tainted" and the sink is reported — a FALSE POSITIVE that order kills.',
46
+ code: `function h(req) { let c = req.body.cmd; c = "ls"; exec(c); }`,
47
+ },
48
+ {
49
+ name: 'sanitised on one branch only',
50
+ vulnerable: true, difficulty: 'needs-branch',
51
+ rationale: 'One path sanitises and the other does not, so a vulnerable path exists. Flow-insensitively ' +
52
+ 'the sanitiser is present, which can kill the taint and hide the unsanitised path.',
53
+ code: `function h(req, safe) { let c = req.body.cmd; if (safe) { c = escape(c); } exec(c); }`,
54
+ },
55
+ {
56
+ name: 'sanitised on every branch',
57
+ vulnerable: false, difficulty: 'needs-branch',
58
+ rationale: 'Both paths sanitise, so nothing is vulnerable. Distinguishing this from the case above is ' +
59
+ 'exactly what branch knowledge buys — flow-insensitively the two look identical.',
60
+ code: `function h(req, safe) { let c = req.body.cmd; if (safe) { c = escape(c); } else { c = escape(c); } exec(c); }`,
61
+ },
62
+ {
63
+ name: 'tainted only on the branch not taken into the sink',
64
+ vulnerable: false, difficulty: 'needs-branch',
65
+ rationale: 'The sink consumes a different, clean variable; the tainted one never reaches it.',
66
+ code: `function h(req, safe) { const dirty = req.body.cmd; const clean = "ls"; if (safe) { exec(clean); } return dirty; }`,
67
+ },
68
+ {
69
+ name: 'no source at all',
70
+ vulnerable: false, difficulty: 'flow-free',
71
+ rationale: 'Guards against an engine that reports any sink call regardless of what reaches it.',
72
+ code: `function h() { const c = "ls -la"; exec(c); }`,
73
+ },
74
+ {
75
+ name: 'source but no sink',
76
+ vulnerable: false, difficulty: 'flow-free',
77
+ rationale: 'Guards against reporting taint that never lands anywhere dangerous.',
78
+ code: `function h(req) { const c = req.body.cmd; return c.length; }`,
79
+ },
80
+ ];
81
+ /** Run the current engine over the labelled cases and score it. */
82
+ function runTaintBenchmark(cases = exports.TAINT_CASES) {
83
+ const parser = new language_parser_1.TreeSitterTsParser();
84
+ const outcomes = [];
85
+ for (const c of cases) {
86
+ const facts = parser.extractDataFlow(c.code, 'typescript');
87
+ // No facts is not "clean": it means the case was never analysed. Recording it as a negative
88
+ // would credit the engine with an answer it did not give.
89
+ const actual = facts === undefined
90
+ ? false
91
+ : (0, dataflow_taint_1.taintDataFlow)([{ file: `${c.name}.ts`, facts }], exports.TAINT_CONFIG).findings.length > 0;
92
+ const cell = c.vulnerable
93
+ ? (actual ? 'tp' : 'fn')
94
+ : (actual ? 'fp' : 'tn');
95
+ outcomes.push({ name: c.name, difficulty: c.difficulty, expected: c.vulnerable, actual, cell });
96
+ }
97
+ const count = (k) => outcomes.filter((o) => o.cell === k).length;
98
+ const tp = count('tp'), tn = count('tn'), fp = count('fp'), fn = count('fn');
99
+ return {
100
+ outcomes, tp, tn, fp, fn,
101
+ precision: tp + fp === 0 ? null : tp / (tp + fp),
102
+ recall: tp + fn === 0 ? null : tp / (tp + fn),
103
+ controlFlowAttributable: outcomes.filter((o) => (o.cell === 'fp' || o.cell === 'fn') && o.difficulty !== 'flow-free').length,
104
+ };
105
+ }
106
+ /**
107
+ * How often the taint analysis fires on REAL code.
108
+ *
109
+ * A-SPEC-354 measured what control flow could fix; this measures whether there is anything to fix.
110
+ * An analysis that produces a handful of findings across an entire repository cannot repay a
111
+ * control-flow graph however much its precision would improve — the same reasoning that declined
112
+ * `inherits` edges for a corpus that contained none.
113
+ *
114
+ * `findingsTouchingSanitizer` is the honest upper bound on the control-flow-attributable share: a
115
+ * finding whose derivation never passes a sanitizer cannot be a false positive created by
116
+ * conditional sanitisation. It is an upper bound and not a count of false positives — deciding
117
+ * whether each is truly spurious needs a human, and inflating it into a defect count is exactly the
118
+ * kind of claim this document exists to prevent.
119
+ */
120
+ function taintOnRealCorpus(corpusName, root, scan, cfg = exports.TAINT_CONFIG) {
121
+ const scanned = scan(root);
122
+ const withFacts = scanned.filter((f) => f.dataflow !== undefined);
123
+ const result = (0, dataflow_taint_1.taintDataFlow)(withFacts.map((f) => ({ file: f.sourcePath, facts: f.dataflow })),
124
+ // The default 10-iteration bound did NOT converge on this repository, and an unconverged
125
+ // fixpoint under-reports — quoting its finding count as the corpus rate would understate the
126
+ // very number this measurement exists to produce.
127
+ { ...cfg, maxFindings: 10_000, maxIterations: 200 });
128
+ const sites = new Set(result.findings.map((f) => `${f.file}:${f.callee}:${f.line}`));
129
+ const touching = result.findings.filter((f) => f.flow.some((s) => cfg.sanitizers.some((san) => s.detail.toLowerCase().includes(san.toLowerCase())))).length;
130
+ return {
131
+ corpus: corpusName,
132
+ filesScanned: scanned.length,
133
+ filesWithFacts: withFacts.length,
134
+ findings: result.findings.length,
135
+ sites: sites.size,
136
+ findingsTouchingSanitizer: touching,
137
+ converged: result.converged,
138
+ truncated: result.truncated,
139
+ findingsPerFile: withFacts.length === 0 ? null : result.findings.length / withFacts.length,
140
+ };
141
+ }
@@ -263,6 +263,28 @@ function extractAnchors(source) {
263
263
  const noStrings = source.replace(STRINGS_RE, '""');
264
264
  return [...new Set([...noStrings.matchAll(IMPL_LINE_RE)].map((m) => m[1]))];
265
265
  }
266
+ /**
267
+ * @implements A-SPEC-269
268
+ * The unresolved list is heterogeneous. Callers push three different kinds of entry through it:
269
+ * plain changed files that resolved to no lane, `unsupported:<ext>` language markers, and
270
+ * `impact-truncated:<reason>:<node>` markers from a caller walk that stopped at its bound. A single
271
+ * template called all three "changed source file(s) resolved to no spec", so the tier was right and
272
+ * the sentence justifying it described the wrong thing. Naming each axis costs nothing and is the
273
+ * difference between an operator trusting the reason and having to decode the markers by hand.
274
+ */
275
+ function describeUnresolved(entries) {
276
+ const unsupported = entries.filter((e) => e.startsWith('unsupported:'));
277
+ const truncated = entries.filter((e) => e.startsWith('impact-truncated:'));
278
+ const changed = entries.filter((e) => !e.startsWith('unsupported:') && !e.startsWith('impact-truncated:'));
279
+ const parts = [];
280
+ if (changed.length > 0)
281
+ parts.push(`${changed.length} changed source file(s) resolved to no spec`);
282
+ if (unsupported.length > 0)
283
+ parts.push(`${unsupported.length} unsupported language(s) outside the graph`);
284
+ if (truncated.length > 0)
285
+ parts.push(`${truncated.length} truncated impact walk(s)`);
286
+ return parts.join(', ');
287
+ }
266
288
  /**
267
289
  * Scan the repo for TEST files and the A-SPEC ids they @implement. Separate from the CPG scan (which
268
290
  * excludes tests), so the test↔spec edges actually exist for scoping. Returns repo-relative POSIX
@@ -327,10 +349,11 @@ function computeTestScope(impactedSpecsRaw, testAnchors, specs, coverageGaps = [
327
349
  if (unresolvedFiles.length > 0) {
328
350
  const shown = unresolvedFiles.slice(0, 5).join(', ');
329
351
  const suffix = unresolvedFiles.length > 5 ? ` (${unresolvedFiles.length} unresolved total)` : '';
352
+ const described = describeUnresolved(unresolvedFiles);
330
353
  return {
331
354
  tier: 'full', impactedSpecs, impactedTestFiles: selectedTestFiles, blastRadius: 0,
332
355
  hitsFoundational: false, coverageGaps: [...coverageGaps], unresolvedFiles, architectureTouched, obligationGaps,
333
- reason: `${unresolvedFiles.length} changed source file(s) resolved to no spec — cannot narrow safely, full regression: ${shown}${suffix}`,
356
+ reason: `${described} — cannot narrow safely, full regression: ${shown}${suffix}`,
334
357
  };
335
358
  }
336
359
  // @implements A-SPEC-233
@@ -0,0 +1,15 @@
1
+ export type KeySource = 'env' | 'env-compat' | 'keychain' | 'file';
2
+ export interface CredentialOpts {
3
+ env?: NodeJS.ProcessEnv;
4
+ home?: string;
5
+ platform?: NodeJS.Platform;
6
+ /** Injected for tests; the default shells out. stdin is used so a secret never rides argv. */
7
+ exec?: (cmd: string, args: string[], stdin?: string) => string;
8
+ }
9
+ export declare function resolveSemanticKey(opts?: CredentialOpts): {
10
+ key: string;
11
+ source: KeySource;
12
+ } | null;
13
+ /** Store via the human's CLI act. Returns where it landed. */
14
+ export declare function storeSemanticKey(key: string, opts?: CredentialOpts): 'keychain' | 'file';
15
+ export declare function removeSemanticKey(opts?: CredentialOpts): void;