@holmes-lab/holmes-kit 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/dist/.build-id +1 -1
- package/dist/holmes/assoc/assoc-arm.d.ts +94 -0
- package/dist/holmes/assoc/assoc-arm.js +187 -0
- package/dist/holmes/assoc/explore.d.ts +21 -0
- package/dist/holmes/assoc/explore.js +160 -0
- package/dist/holmes/assoc/impact-baseline.d.ts +13 -0
- package/dist/holmes/assoc/impact-baseline.js +48 -0
- package/dist/holmes/assoc/ppr.d.ts +64 -0
- package/dist/holmes/assoc/ppr.js +110 -0
- package/dist/holmes/cli/agents.d.ts +24 -11
- package/dist/holmes/cli/agents.js +93 -21
- package/dist/holmes/cli/doctor.d.ts +56 -0
- package/dist/holmes/cli/doctor.js +311 -5
- package/dist/holmes/cli/index.js +13 -1
- package/dist/holmes/cli/semantic-key.d.ts +19 -0
- package/dist/holmes/cli/semantic-key.js +93 -0
- package/dist/holmes/config/config.d.ts +9 -0
- package/dist/holmes/config/config.js +8 -1
- package/dist/holmes/cpg/language-capability.d.ts +65 -0
- package/dist/holmes/cpg/language-capability.js +145 -0
- package/dist/holmes/cpg/language-parser-walk.js +179 -34
- package/dist/holmes/cpg/language-parser.d.ts +1 -1
- package/dist/holmes/governance/ledger-rechain.d.ts +12 -0
- package/dist/holmes/governance/ledger-rechain.js +17 -2
- package/dist/holmes/governance/provenance-ledger.js +21 -0
- package/dist/holmes/guardrail/blind-spots.js +12 -1
- package/dist/holmes/guardrail/impact-gate.d.ts +77 -0
- package/dist/holmes/guardrail/impact-gate.js +263 -0
- package/dist/holmes/guardrail/write-target.d.ts +38 -1
- package/dist/holmes/guardrail/write-target.js +41 -4
- package/dist/holmes/hooks/adapters/antigravity.js +12 -1
- package/dist/holmes/hooks/corrupt-state-run.d.ts +33 -0
- package/dist/holmes/hooks/corrupt-state-run.js +16 -0
- package/dist/holmes/hooks/pre-tool-use.d.ts +26 -0
- package/dist/holmes/hooks/pre-tool-use.js +203 -11
- package/dist/holmes/hooks/stop.d.ts +65 -0
- package/dist/holmes/hooks/stop.js +200 -2
- package/dist/holmes/mcp/elicit-approval.d.ts +32 -2
- package/dist/holmes/mcp/elicit-approval.js +30 -2
- package/dist/holmes/mcp/handlers.d.ts +88 -0
- package/dist/holmes/mcp/handlers.js +636 -35
- package/dist/holmes/mcp/maintenance-analyze.d.ts +435 -0
- package/dist/holmes/mcp/maintenance-analyze.js +994 -0
- package/dist/holmes/mcp/maintenance-evidence.d.ts +140 -0
- package/dist/holmes/mcp/maintenance-evidence.js +253 -0
- package/dist/holmes/mcp/server.js +13 -10
- package/dist/holmes/mcp/tool-schemas.js +71 -0
- package/dist/holmes/project/root.js +3 -1
- package/dist/holmes/review/baseline-arm.d.ts +37 -0
- package/dist/holmes/review/baseline-arm.js +51 -0
- package/dist/holmes/review/captured-stdin-guard.d.ts +8 -0
- package/dist/holmes/review/captured-stdin-guard.js +48 -0
- package/dist/holmes/review/coherence-verify.d.ts +31 -0
- package/dist/holmes/review/coherence-verify.js +144 -0
- package/dist/holmes/review/commit-text.d.ts +50 -0
- package/dist/holmes/review/commit-text.js +76 -0
- package/dist/holmes/review/confidence-calibration.d.ts +39 -0
- package/dist/holmes/review/confidence-calibration.js +39 -0
- package/dist/holmes/review/content-baseline.d.ts +38 -0
- package/dist/holmes/review/content-baseline.js +103 -0
- package/dist/holmes/review/content-verify.d.ts +20 -0
- package/dist/holmes/review/content-verify.js +73 -0
- package/dist/holmes/review/dense-retrieval.d.ts +66 -0
- package/dist/holmes/review/dense-retrieval.js +97 -0
- package/dist/holmes/review/edge-quality.d.ts +44 -0
- package/dist/holmes/review/edge-quality.js +117 -0
- package/dist/holmes/review/evaluation-metrics.d.ts +138 -0
- package/dist/holmes/review/evaluation-metrics.js +175 -0
- package/dist/holmes/review/graph-verifier.d.ts +34 -0
- package/dist/holmes/review/graph-verifier.js +62 -0
- package/dist/holmes/review/hop-ablation.d.ts +100 -0
- package/dist/holmes/review/hop-ablation.js +89 -0
- package/dist/holmes/review/manual-baseline.d.ts +209 -0
- package/dist/holmes/review/manual-baseline.js +2846 -0
- package/dist/holmes/review/oracle-gap.d.ts +32 -0
- package/dist/holmes/review/oracle-gap.js +102 -0
- package/dist/holmes/review/point-in-time-replay.d.ts +41 -0
- package/dist/holmes/review/point-in-time-replay.js +161 -0
- package/dist/holmes/review/rank-diagnosis.d.ts +43 -0
- package/dist/holmes/review/rank-diagnosis.js +163 -0
- package/dist/holmes/review/replay-calibration.d.ts +62 -0
- package/dist/holmes/review/replay-calibration.js +83 -0
- package/dist/holmes/review/replay-corpus.d.ts +135 -0
- package/dist/holmes/review/replay-corpus.js +210 -0
- package/dist/holmes/review/run-replay.d.ts +260 -0
- package/dist/holmes/review/run-replay.js +729 -0
- package/dist/holmes/review/semantic-arm.d.ts +271 -0
- package/dist/holmes/review/semantic-arm.js +717 -0
- package/dist/holmes/review/semantic-retrieval.d.ts +55 -0
- package/dist/holmes/review/semantic-retrieval.js +156 -0
- package/dist/holmes/review/spec-layer-stats.d.ts +38 -0
- package/dist/holmes/review/spec-layer-stats.js +52 -0
- package/dist/holmes/review/temporal-prior.d.ts +33 -0
- package/dist/holmes/review/temporal-prior.js +53 -0
- package/dist/holmes/review/test-runner.d.ts +15 -0
- package/dist/holmes/review/test-runner.js +41 -4
- package/dist/holmes/review/test-selection-breadth.d.ts +75 -0
- package/dist/holmes/review/test-selection-breadth.js +57 -0
- package/dist/holmes/review/traceability-benchmark.d.ts +81 -0
- package/dist/holmes/review/traceability-benchmark.js +135 -0
- package/dist/holmes/review/union-verify.d.ts +12 -0
- package/dist/holmes/review/union-verify.js +70 -0
- package/dist/holmes/rtm/graph-store.d.ts +51 -0
- package/dist/holmes/rtm/graph-store.js +122 -0
- package/dist/holmes/rtm/incremental.d.ts +25 -1
- package/dist/holmes/rtm/incremental.js +18 -1
- package/dist/holmes/rtm/localize.d.ts +28 -0
- package/dist/holmes/rtm/localize.js +272 -14
- package/dist/holmes/rtm/rtm-builder.d.ts +118 -3
- package/dist/holmes/rtm/rtm-builder.js +265 -28
- package/dist/holmes/rtm/rtm-graph.d.ts +117 -2
- package/dist/holmes/rtm/rtm-graph.js +194 -31
- package/dist/holmes/rtm/taint-benchmark.d.ts +97 -0
- package/dist/holmes/rtm/taint-benchmark.js +141 -0
- package/dist/holmes/rtm/test-scope.js +24 -1
- package/dist/holmes/semantic/credentials.d.ts +15 -0
- package/dist/holmes/semantic/credentials.js +134 -0
- package/dist/holmes/semantic/embedder.d.ts +44 -0
- package/dist/holmes/semantic/embedder.js +185 -0
- package/dist/holmes/semantic/hit-rerank.d.ts +4 -0
- package/dist/holmes/semantic/hit-rerank.js +38 -0
- package/dist/holmes/semantic/tier.d.ts +37 -0
- package/dist/holmes/semantic/tier.js +54 -0
- package/dist/holmes/semantic/vector-cache.d.ts +11 -0
- package/dist/holmes/semantic/vector-cache.js +91 -0
- package/dist/holmes/spec/acceptance-quality.d.ts +81 -0
- package/dist/holmes/spec/acceptance-quality.js +169 -0
- package/dist/holmes/spec/validator.js +33 -1
- package/dist/holmes/spec/yaml-scalar.d.ts +1 -0
- package/dist/holmes/spec/yaml-scalar.js +43 -0
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
this.
|
|
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: `${
|
|
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;
|