@tekyzinc/gsd-t 4.9.13 → 4.10.10
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 +24 -0
- package/README.md +1 -1
- package/bin/gsd-t-competition-judge.cjs +7 -1
- package/bin/gsd-t-context-brief-kinds/impact.cjs +91 -4
- package/bin/gsd-t-context-brief-kinds/partition.cjs +95 -0
- package/bin/gsd-t-context-brief-kinds/plan.cjs +110 -4
- package/bin/gsd-t-file-disjointness.cjs +319 -7
- package/bin/gsd-t-graph-anti-grep-lint.cjs +515 -0
- package/bin/gsd-t-graph-edge-extract.cjs +612 -0
- package/bin/gsd-t-graph-freshness.cjs +506 -0
- package/bin/gsd-t-graph-index.cjs +540 -0
- package/bin/gsd-t-graph-k1-sqlite-stream.cjs +251 -0
- package/bin/gsd-t-graph-query-cli.cjs +1182 -0
- package/bin/gsd-t-graph-scip-upgrade.cjs +440 -0
- package/bin/gsd-t-graph-store-bakeoff.cjs +889 -0
- package/bin/gsd-t-graph-synthetic-gen.cjs +304 -0
- package/bin/gsd-t-graph-ts-throughput.cjs +587 -0
- package/bin/gsd-t-scip-reader.cjs +167 -0
- package/bin/gsd-t.js +166 -48
- package/commands/gsd-t-debug.md +10 -0
- package/commands/gsd-t-design-build.md +10 -0
- package/commands/gsd-t-execute.md +15 -0
- package/commands/gsd-t-feature.md +15 -7
- package/commands/gsd-t-gap-analysis.md +17 -7
- package/commands/gsd-t-impact.md +25 -0
- package/commands/gsd-t-integrate.md +25 -0
- package/commands/gsd-t-partition.md +18 -0
- package/commands/gsd-t-plan.md +16 -0
- package/commands/gsd-t-populate.md +16 -5
- package/commands/gsd-t-prd.md +18 -0
- package/commands/gsd-t-project.md +19 -0
- package/commands/gsd-t-promote-debt.md +16 -5
- package/commands/gsd-t-qa.md +20 -8
- package/commands/gsd-t-quick.md +10 -0
- package/commands/gsd-t-scan.md +21 -3
- package/commands/gsd-t-test-sync.md +10 -0
- package/commands/gsd-t-verify.md +25 -0
- package/commands/gsd-t-wave.md +8 -0
- package/package.json +10 -2
- package/templates/workflows/gsd-t-debug.workflow.js +81 -0
- package/templates/workflows/gsd-t-integrate.workflow.js +47 -1
- package/templates/workflows/gsd-t-phase.workflow.js +99 -1
- package/templates/workflows/gsd-t-quick.workflow.js +64 -0
- package/templates/workflows/gsd-t-scan.workflow.js +200 -9
- package/templates/workflows/gsd-t-verify.workflow.js +50 -1
- package/bin/graph-cgc.js +0 -510
- package/bin/graph-indexer.js +0 -147
- package/bin/graph-overlay.js +0 -195
- package/bin/graph-parsers.js +0 -327
- package/bin/graph-query.js +0 -453
- package/bin/graph-store.js +0 -154
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* gsd-t-graph-query-cli — M94 D5 (extended by D9)
|
|
6
|
+
*
|
|
7
|
+
* The DETERMINISTIC query CLI the model cannot route around — the no-stale-no-wrong KEYSTONE.
|
|
8
|
+
*
|
|
9
|
+
* Verbs (D5 — original):
|
|
10
|
+
* gsd-t graph who-imports <file> — file→file reverse import edges
|
|
11
|
+
* gsd-t graph who-calls <file#function> — function→function reverse call edges (funcId-keyed)
|
|
12
|
+
* gsd-t graph blast-radius <target> — UNION of import-graph + call-graph reverse-reachable set (transitive)
|
|
13
|
+
* gsd-t graph status — live queryable index state
|
|
14
|
+
*
|
|
15
|
+
* Verbs (D9 additions):
|
|
16
|
+
* gsd-t graph cluster — tightly-coupled file groups (for /partition + /project)
|
|
17
|
+
* gsd-t graph dead-code — nodes with no inbound edges (alias: orphan)
|
|
18
|
+
* gsd-t graph orphan — alias for dead-code
|
|
19
|
+
* gsd-t graph dangling — edges whose dst is a missing node (delete/rename residue)
|
|
20
|
+
* gsd-t graph test-impl [--inverse] — test→impl call coverage (--inverse = untested-impl)
|
|
21
|
+
*
|
|
22
|
+
* Invariants (all verified by keystone tests):
|
|
23
|
+
* [RULE] query-cli-never-greps — NO directive-driven grep fallback in any code path
|
|
24
|
+
* [RULE] parser-fail-disables-loud-never-silent — genuine parser/store failure → {ok:false, reason:'graph-unavailable'}
|
|
25
|
+
* [RULE] stale-file-reindexed-before-answer — D4 freshness check INLINE before answering
|
|
26
|
+
* [RULE] who-calls-function-identity-disambiguated — bare name matching multiple funcIds → ambiguous-function
|
|
27
|
+
* [RULE] blast-radius-unions-both-graphs — UNION of import + call reverse-reachable, transitive closure
|
|
28
|
+
* [RULE] graph-status-live — status returns a live queryable index (M20–M21 anti-goal fails)
|
|
29
|
+
* [RULE] cluster-verb-deterministic-coupling — DETERMINISTIC coupling metric, not LLM judgment
|
|
30
|
+
* [RULE] dead-code-verb-excludes-entrypoints-and-exports — exclusions declared in contract
|
|
31
|
+
* [RULE] dangling-verb-surfaces-missing-dst — edges whose dst is not in the node set
|
|
32
|
+
* [RULE] orphan-tier-labeled-candidate-not-certainty — floor-tier orphans labeled CANDIDATE
|
|
33
|
+
* [RULE] test-impl-verb-from-call-site-edges-no-new-type — derived from existing call-site edges
|
|
34
|
+
* [RULE] test-impl-no-new-edge-type-needed — confirmed: call-site edges already cover test→impl
|
|
35
|
+
* [RULE] test-impl-never-presents-unresolved-as-coverage — UNRESOLVED# targets never in implFuncs
|
|
36
|
+
* [RULE] query-surfaces-incompleteness-never-silent-empty — coverage field on who-imports/who-calls/blast-radius
|
|
37
|
+
*
|
|
38
|
+
* Dependencies (fail-loud if absent — never silently fall back):
|
|
39
|
+
* bin/gsd-t-graph-freshness.cjs — D4: freshness_check_on_query + compute_touched_files
|
|
40
|
+
* bin/gsd-t-graph-index.cjs — D3: parse_and_put (called by D4, not directly here)
|
|
41
|
+
*
|
|
42
|
+
* Zero external runtime deps for the installer. Sync file APIs. JSON envelope output.
|
|
43
|
+
*
|
|
44
|
+
* JSON envelope shapes:
|
|
45
|
+
* { ok: true, verb: "who-imports|who-calls|blast-radius|status", target: "...", results: [...], tier: "...", coverage: {...} }
|
|
46
|
+
* { ok: true, verb: "cluster", results: [{files:[...], couplingScore}], tier: "..." }
|
|
47
|
+
* { ok: true, verb: "dead-code", results: [{funcId, file, tier, candidateLabel}], tier: "..." }
|
|
48
|
+
* { ok: true, verb: "dangling", results: [{src, dst, kind}], tier: "..." }
|
|
49
|
+
* { ok: true, verb: "test-impl", results: [{testFunc, implFuncs:[...]}], tier: "..." }
|
|
50
|
+
* { ok: false, reason: "graph-unavailable" }
|
|
51
|
+
* { ok: false, reason: "ambiguous-function", verb: "who-calls", target: "foo", candidates: ["a.ts#foo","b.ts#foo"] }
|
|
52
|
+
* { ok: false, reason: "unknown-target", verb: "...", target: "..." }
|
|
53
|
+
*
|
|
54
|
+
* CLI usage:
|
|
55
|
+
* node bin/gsd-t-graph-query-cli.cjs who-imports src/foo.ts
|
|
56
|
+
* node bin/gsd-t-graph-query-cli.cjs who-calls 'src/foo.ts#bar'
|
|
57
|
+
* node bin/gsd-t-graph-query-cli.cjs blast-radius src/foo.ts
|
|
58
|
+
* node bin/gsd-t-graph-query-cli.cjs status
|
|
59
|
+
* node bin/gsd-t-graph-query-cli.cjs cluster
|
|
60
|
+
* node bin/gsd-t-graph-query-cli.cjs dead-code
|
|
61
|
+
* node bin/gsd-t-graph-query-cli.cjs orphan
|
|
62
|
+
* node bin/gsd-t-graph-query-cli.cjs dangling
|
|
63
|
+
* node bin/gsd-t-graph-query-cli.cjs test-impl
|
|
64
|
+
* node bin/gsd-t-graph-query-cli.cjs test-impl --inverse
|
|
65
|
+
*
|
|
66
|
+
* Exit: 0 on success, 1 on graph-unavailable or error, 2 on ambiguous-function.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
const fs = require("node:fs");
|
|
70
|
+
const path = require("node:path");
|
|
71
|
+
const os = require("node:os");
|
|
72
|
+
|
|
73
|
+
// ─── ANSI colours ────────────────────────────────────────────────────────────
|
|
74
|
+
const C = {
|
|
75
|
+
reset: "\x1b[0m",
|
|
76
|
+
bold: "\x1b[1m",
|
|
77
|
+
green: "\x1b[32m",
|
|
78
|
+
yellow: "\x1b[33m",
|
|
79
|
+
red: "\x1b[31m",
|
|
80
|
+
cyan: "\x1b[36m",
|
|
81
|
+
dim: "\x1b[2m",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function colorize(str, ...codes) {
|
|
85
|
+
return codes.join("") + str + C.reset;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Store location ───────────────────────────────────────────────────────────
|
|
89
|
+
// The store path is resolved from the environment or a default discovery path.
|
|
90
|
+
// D3 writes to a store directory; we read from it.
|
|
91
|
+
// Convention: GSD_T_GRAPH_STORE env var overrides; otherwise look for
|
|
92
|
+
// .gsd-t/graph-index/ relative to cwd.
|
|
93
|
+
|
|
94
|
+
function resolveStorePath() {
|
|
95
|
+
if (process.env.GSD_T_GRAPH_STORE) {
|
|
96
|
+
return process.env.GSD_T_GRAPH_STORE;
|
|
97
|
+
}
|
|
98
|
+
// Walk up from cwd to find the store. K1 PICKED SQLite, so the indexer
|
|
99
|
+
// (gsd-t-graph-index.cjs) writes `.gsd-t/graph.db`. Discover THAT first; fall
|
|
100
|
+
// back to the legacy `.gsd-t/graph-index/` JSONL directory for the baseline
|
|
101
|
+
// store. (The original CLI only looked for the JSONL dir — so a freshly-built
|
|
102
|
+
// SQLite index reported graph-unavailable: the index wrote .gsd-t/graph.db but
|
|
103
|
+
// the query never discovered it.)
|
|
104
|
+
let dir = process.cwd();
|
|
105
|
+
for (let i = 0; i < 10; i++) {
|
|
106
|
+
const dbPath = path.join(dir, ".gsd-t", "graph.db");
|
|
107
|
+
if (fs.existsSync(dbPath)) return dbPath;
|
|
108
|
+
const jsonlDir = path.join(dir, ".gsd-t", "graph-index");
|
|
109
|
+
if (fs.existsSync(jsonlDir)) return jsonlDir;
|
|
110
|
+
const parent = path.dirname(dir);
|
|
111
|
+
if (parent === dir) break;
|
|
112
|
+
dir = parent;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── In-memory store interface ────────────────────────────────────────────────
|
|
118
|
+
// Reads the on-disk JSONL/SQLite store (whichever D3 wrote) and builds
|
|
119
|
+
// in-memory index structures optimised for the query verbs.
|
|
120
|
+
//
|
|
121
|
+
// Store format (per graph-store-schema-contract.md):
|
|
122
|
+
// Each record: { file, content_hash, entities: FuncEntity[], edges: Edge[], tier }
|
|
123
|
+
// Edge: { kind: "IMPORT"|"CALL", src, dst }
|
|
124
|
+
// FuncEntity: { funcId, name, kind, file, tier }
|
|
125
|
+
//
|
|
126
|
+
// D5 supports the JSONL flat-file store (the K1 baseline that PASSED).
|
|
127
|
+
// If SQLite becomes the picked store, D3 owns that path; D5 reads via the
|
|
128
|
+
// same record shape declared in graph-store-schema-contract.md.
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {{ importGraph: Map<string,Set<string>>, callGraph: Map<string,Set<string>>,
|
|
132
|
+
* funcEntities: Map<string,{name:string,file:string,tier:string}>,
|
|
133
|
+
* allFiles: Set<string>, tier: string,
|
|
134
|
+
* skippedFiles: Set<string> }} IndexStructure
|
|
135
|
+
*
|
|
136
|
+
* skippedFiles: files that failed to parse (D3-T6 skipped set). Used by T5 to
|
|
137
|
+
* compute coverage.complete:false when a query's contributors include unparsed files.
|
|
138
|
+
*/
|
|
139
|
+
|
|
140
|
+
// ─── Test-path patterns for test→impl verb (D9-T3) ───────────────────────────
|
|
141
|
+
// Declared here (configurable via GSD_T_TEST_PATTERNS env var) so they are
|
|
142
|
+
// visible in the contract and overridable for different project layouts.
|
|
143
|
+
// Default patterns: *.test.*, *.spec.*, e2e/** directories.
|
|
144
|
+
// [RULE] test-impl-verb-from-call-site-edges-no-new-type
|
|
145
|
+
|
|
146
|
+
const DEFAULT_TEST_PATTERNS = [
|
|
147
|
+
/\.test\.[^.]+$/, // *.test.ts, *.test.js, etc.
|
|
148
|
+
/\.spec\.[^.]+$/, // *.spec.ts, *.spec.js, etc.
|
|
149
|
+
/(?:^|\/)e2e\//, // e2e/** directory prefix
|
|
150
|
+
/(?:^|\/)__tests__\//,// Jest __tests__ directory
|
|
151
|
+
/(?:^|\/)tests?\//, // tests/ or test/ directory
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Return the active test-path patterns (env override or defaults).
|
|
156
|
+
* @returns {RegExp[]}
|
|
157
|
+
*/
|
|
158
|
+
function getTestPatterns() {
|
|
159
|
+
const envPatterns = process.env.GSD_T_TEST_PATTERNS;
|
|
160
|
+
if (envPatterns) {
|
|
161
|
+
try {
|
|
162
|
+
return envPatterns.split(",").map((p) => new RegExp(p.trim()));
|
|
163
|
+
} catch (_e) {
|
|
164
|
+
// fall through to defaults on bad regex
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return DEFAULT_TEST_PATTERNS;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Return true if a funcId's file matches any test-path pattern.
|
|
172
|
+
* @param {string} funcId — e.g. "test/foo.test.ts#myTest@12"
|
|
173
|
+
* @param {RegExp[]} patterns
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
*/
|
|
176
|
+
function isTestFile(funcId, patterns) {
|
|
177
|
+
const file = funcId.includes("#") ? funcId.split("#")[0] : funcId;
|
|
178
|
+
return patterns.some((re) => re.test(file));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─── UNRESOLVED sentinel (D9-T3 + D9-T4) ─────────────────────────────────────
|
|
182
|
+
// The edge extractor emits UNRESOLVED#<name> when a call target cannot be resolved.
|
|
183
|
+
// [RULE] test-impl-never-presents-unresolved-as-coverage — these must NEVER appear
|
|
184
|
+
// in the implFuncs coverage set.
|
|
185
|
+
|
|
186
|
+
const UNRESOLVED_PREFIX = "UNRESOLVED#";
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Load records from a JSONL store directory.
|
|
190
|
+
* Returns null if the store is not present or is corrupt.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} storePath
|
|
193
|
+
* @returns {{ records: object[] } | null}
|
|
194
|
+
*/
|
|
195
|
+
function loadJsonlStore(storePath) {
|
|
196
|
+
const recordsPath = path.join(storePath, "records.jsonl");
|
|
197
|
+
if (!fs.existsSync(recordsPath)) return null;
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const raw = fs.readFileSync(recordsPath, "utf8");
|
|
201
|
+
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
|
202
|
+
const records = lines.map((l) => JSON.parse(l));
|
|
203
|
+
return { records };
|
|
204
|
+
} catch (e) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Load the skipped-file set from the D3-T6 SQLite meta table (best-effort).
|
|
211
|
+
* Returns a Set<string> of file paths that failed to parse.
|
|
212
|
+
* Returns an empty Set if the table is absent or the DB is not accessible.
|
|
213
|
+
*
|
|
214
|
+
* The store path may be either a JSONL directory (in which case we look for
|
|
215
|
+
* a graph.db sibling up the tree) or may be a .db path itself.
|
|
216
|
+
*
|
|
217
|
+
* @param {string|null} storePath
|
|
218
|
+
* @returns {Set<string>}
|
|
219
|
+
*/
|
|
220
|
+
function loadSkippedFiles(storePath) {
|
|
221
|
+
if (!storePath) return new Set();
|
|
222
|
+
try {
|
|
223
|
+
// The SQLite DB is written by D3 at .gsd-t/graph.db (adjacent to graph-index/ dir).
|
|
224
|
+
// storePath may be .gsd-t/graph-index/ (JSONL) or the db path itself.
|
|
225
|
+
let dbPath = null;
|
|
226
|
+
// Try storePath as a directory: look for graph.db one level up (sibling of graph-index/)
|
|
227
|
+
if (fs.existsSync(storePath) && fs.statSync(storePath).isDirectory()) {
|
|
228
|
+
const candidate = path.join(path.dirname(storePath), "graph.db");
|
|
229
|
+
if (fs.existsSync(candidate)) dbPath = candidate;
|
|
230
|
+
} else if (storePath.endsWith(".db") && fs.existsSync(storePath)) {
|
|
231
|
+
dbPath = storePath;
|
|
232
|
+
}
|
|
233
|
+
if (!dbPath) return new Set();
|
|
234
|
+
|
|
235
|
+
// Try to load better-sqlite3 — it is a devDependency, may be absent
|
|
236
|
+
let Database;
|
|
237
|
+
try {
|
|
238
|
+
Database = require("better-sqlite3");
|
|
239
|
+
} catch (_e) {
|
|
240
|
+
return new Set();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const db = new Database(dbPath, { readonly: true });
|
|
244
|
+
const skipped = new Set();
|
|
245
|
+
try {
|
|
246
|
+
const rows = db.prepare("SELECT file FROM skipped_files").all();
|
|
247
|
+
for (const row of rows) skipped.add(row.file);
|
|
248
|
+
} catch (_e) {
|
|
249
|
+
// Table absent — normal for repos indexed before D3-T6 landed
|
|
250
|
+
} finally {
|
|
251
|
+
db.close();
|
|
252
|
+
}
|
|
253
|
+
return skipped;
|
|
254
|
+
} catch (_e) {
|
|
255
|
+
return new Set();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Compute the coverage field for a query result.
|
|
261
|
+
*
|
|
262
|
+
* [RULE] query-surfaces-incompleteness-never-silent-empty
|
|
263
|
+
*
|
|
264
|
+
* Logic: any file in the skipped set is a potential contributor to ANY query
|
|
265
|
+
* (it could have imported or called any target). So the coverage is incomplete
|
|
266
|
+
* whenever the skipped set is non-empty — we cannot know which of those files
|
|
267
|
+
* would have provided edges pointing at the query target.
|
|
268
|
+
*
|
|
269
|
+
* This is the conservative (correct) semantics: a clean empty result from a
|
|
270
|
+
* fully-parsed graph IS "no importers"; a clean empty result when 5 files failed
|
|
271
|
+
* to parse is NOT "no importers" — it is "no importers among the files we parsed".
|
|
272
|
+
*
|
|
273
|
+
* @param {Set<string>} skippedFiles — files that failed to parse (D3-T6)
|
|
274
|
+
* @returns {{ complete: boolean, unparsedContributors?: number, note?: string }}
|
|
275
|
+
*/
|
|
276
|
+
function computeCoverage(skippedFiles) {
|
|
277
|
+
if (!skippedFiles || skippedFiles.size === 0) {
|
|
278
|
+
return { complete: true };
|
|
279
|
+
}
|
|
280
|
+
const n = skippedFiles.size;
|
|
281
|
+
return {
|
|
282
|
+
complete: false,
|
|
283
|
+
unparsedContributors: n,
|
|
284
|
+
note: `result may be incomplete — ${n} file(s) unparsed`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Build in-memory index structures from raw records.
|
|
290
|
+
*
|
|
291
|
+
* importGraph: Map<dstFile, Set<srcFile>> — reverse import edges (who imports dstFile)
|
|
292
|
+
* callGraph: Map<dstFuncId, Set<srcFuncId>> — reverse call edges (who calls dstFuncId)
|
|
293
|
+
* forwardCallEdges: Array<{src,dst}> — raw forward call edges (for dangling detection)
|
|
294
|
+
* funcEntities: Map<funcId, {name, file, tier}> — entity lookup + tier for dead-code labeling
|
|
295
|
+
* allFiles: Set<string> — all indexed file paths
|
|
296
|
+
* skippedFiles: Set<string> — files that failed to parse (D3-T6; empty if not provided)
|
|
297
|
+
*
|
|
298
|
+
* @param {object[]} records
|
|
299
|
+
* @param {Set<string>} [skippedFiles] — optional set of skipped file paths from D3-T6
|
|
300
|
+
* @returns {IndexStructure}
|
|
301
|
+
*/
|
|
302
|
+
function buildIndex(records, skippedFiles) {
|
|
303
|
+
/** @type {Map<string,Set<string>>} */
|
|
304
|
+
const importGraph = new Map();
|
|
305
|
+
/** @type {Map<string,Set<string>>} */
|
|
306
|
+
const callGraph = new Map();
|
|
307
|
+
/** @type {Array<{src:string,dst:string,kind:string}>} */
|
|
308
|
+
const forwardCallEdges = [];
|
|
309
|
+
/** @type {Map<string,{name:string,file:string,tier:string}>} */
|
|
310
|
+
const funcEntities = new Map();
|
|
311
|
+
/** @type {Set<string>} */
|
|
312
|
+
const allFiles = new Set();
|
|
313
|
+
|
|
314
|
+
let dominantTier = "compiler-accurate";
|
|
315
|
+
let hasFloor = false;
|
|
316
|
+
let hasStaleScip = false;
|
|
317
|
+
|
|
318
|
+
for (const rec of records) {
|
|
319
|
+
allFiles.add(rec.file);
|
|
320
|
+
|
|
321
|
+
if (rec.tier === "tree-sitter-floor") hasFloor = true;
|
|
322
|
+
if (rec.tier === "tree-sitter-floor-STALE-SCIP") hasStaleScip = true;
|
|
323
|
+
|
|
324
|
+
// Index entities for bare-name disambiguation + tier labeling
|
|
325
|
+
if (Array.isArray(rec.entities)) {
|
|
326
|
+
for (const ent of rec.entities) {
|
|
327
|
+
if (ent.funcId) {
|
|
328
|
+
funcEntities.set(ent.funcId, {
|
|
329
|
+
name: ent.name,
|
|
330
|
+
file: ent.file || rec.file,
|
|
331
|
+
tier: ent.tier || rec.tier,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Build reverse edge indices + collect forward call edges for dangling detection
|
|
338
|
+
if (Array.isArray(rec.edges)) {
|
|
339
|
+
for (const edge of rec.edges) {
|
|
340
|
+
if (edge.kind === "IMPORT") {
|
|
341
|
+
// Reverse: dst ← src (who imports dst = src)
|
|
342
|
+
if (!importGraph.has(edge.dst)) importGraph.set(edge.dst, new Set());
|
|
343
|
+
importGraph.get(edge.dst).add(edge.src);
|
|
344
|
+
} else if (edge.kind === "CALL") {
|
|
345
|
+
// Reverse: dst ← src (who calls dst = src)
|
|
346
|
+
if (!callGraph.has(edge.dst)) callGraph.set(edge.dst, new Set());
|
|
347
|
+
callGraph.get(edge.dst).add(edge.src);
|
|
348
|
+
// Forward: collect ALL call edges for dangling + test-impl verbs
|
|
349
|
+
forwardCallEdges.push({ src: edge.src, dst: edge.dst, kind: "CALL" });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (hasStaleScip) dominantTier = "tree-sitter-floor-STALE-SCIP";
|
|
356
|
+
else if (hasFloor) dominantTier = "tree-sitter-floor";
|
|
357
|
+
|
|
358
|
+
return {
|
|
359
|
+
importGraph,
|
|
360
|
+
callGraph,
|
|
361
|
+
forwardCallEdges,
|
|
362
|
+
funcEntities,
|
|
363
|
+
allFiles,
|
|
364
|
+
tier: dominantTier,
|
|
365
|
+
skippedFiles: skippedFiles instanceof Set ? skippedFiles : new Set(),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── Load store + build index (fail-loud on any failure) ─────────────────────
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Load the on-disk store and build the in-memory index.
|
|
373
|
+
* Returns { ok: true, index: IndexStructure } on success.
|
|
374
|
+
* Returns { ok: false, reason: 'graph-unavailable' } on any load failure.
|
|
375
|
+
*
|
|
376
|
+
* Also loads the D3-T6 skipped-file set (best-effort; never fails the load).
|
|
377
|
+
*
|
|
378
|
+
* @param {string|null} storePath
|
|
379
|
+
* @returns {{ ok: boolean, index?: IndexStructure, reason?: string }}
|
|
380
|
+
*/
|
|
381
|
+
/**
|
|
382
|
+
* Load records from the SQLite store (`.gsd-t/graph.db`) — the store K1 PICKED
|
|
383
|
+
* and the indexer (gsd-t-graph-index.cjs) actually writes. The query CLI was
|
|
384
|
+
* originally JSONL-only; on a real SQLite index loadJsonlStore returned null and
|
|
385
|
+
* every query reported graph-unavailable. This reader reconstructs per-file
|
|
386
|
+
* records in the shape buildIndex expects ({file, entities, edges}).
|
|
387
|
+
*
|
|
388
|
+
* Edge normalization (bug fix): the indexer stores IMPORT edge `dst` as the RAW
|
|
389
|
+
* import specifier (e.g. "../../bin/x.cjs"), not a repo-relative path — so
|
|
390
|
+
* who-imports('bin/x.cjs') could never match. We resolve each relative specifier
|
|
391
|
+
* against its source file's directory to a repo-relative POSIX path so the
|
|
392
|
+
* import graph keys on real file ids. Bare/package specifiers (no leading '.')
|
|
393
|
+
* are left as-is (they're external modules, not repo files).
|
|
394
|
+
*/
|
|
395
|
+
function loadSqliteStore(dbPath) {
|
|
396
|
+
let Database;
|
|
397
|
+
try { Database = require("better-sqlite3"); }
|
|
398
|
+
catch (_e) { return null; }
|
|
399
|
+
let db;
|
|
400
|
+
try {
|
|
401
|
+
db = new Database(dbPath, { readonly: true });
|
|
402
|
+
const nodes = db.prepare("SELECT id, kind, tier, file, name, func_id FROM nodes").all();
|
|
403
|
+
const edges = db.prepare("SELECT kind, src, dst FROM edges").all();
|
|
404
|
+
|
|
405
|
+
const norm = (p) => p.replace(/\\/g, "/");
|
|
406
|
+
// The set of indexed file ids — used to resolve an extensionless import
|
|
407
|
+
// specifier (`./b`) to the actual file id (`src/b.ts`). Import specifiers in
|
|
408
|
+
// TS/JS routinely omit the extension; the graph keys on real file ids.
|
|
409
|
+
// Every indexed file appears as the `file` of its nodes (the indexer does not
|
|
410
|
+
// emit dedicated FILE nodes), so collect the distinct file set from there.
|
|
411
|
+
const fileIds = new Set(nodes.map((n) => norm(n.file)).filter(Boolean));
|
|
412
|
+
const EXTS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", "/index.ts", "/index.tsx", "/index.js"];
|
|
413
|
+
const resolveDst = (srcFile, dst) => {
|
|
414
|
+
if (typeof dst !== "string" || !dst.startsWith(".")) return dst; // package/external
|
|
415
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(norm(srcFile)), norm(dst)));
|
|
416
|
+
// Prefer an actual indexed file id (try the bare path, then common extensions).
|
|
417
|
+
for (const ext of EXTS) {
|
|
418
|
+
if (fileIds.has(base + ext)) return base + ext;
|
|
419
|
+
}
|
|
420
|
+
return base; // no indexed match (external/missing) — keep the resolved path
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
// Group into per-file records.
|
|
424
|
+
const byFile = new Map();
|
|
425
|
+
const rec = (file) => {
|
|
426
|
+
if (!byFile.has(file)) byFile.set(file, { file, entities: [], edges: [] });
|
|
427
|
+
return byFile.get(file);
|
|
428
|
+
};
|
|
429
|
+
for (const n of nodes) {
|
|
430
|
+
if (n.func_id) rec(n.file).entities.push({ funcId: n.func_id, name: n.name, file: n.file, tier: n.tier });
|
|
431
|
+
}
|
|
432
|
+
for (const e of edges) {
|
|
433
|
+
// src for an IMPORT edge is the source FILE; for a CALL edge it's a funcId
|
|
434
|
+
// (file#fn@line). The owning file record is the src's file part.
|
|
435
|
+
const srcFile = e.src.includes("#") ? e.src.split("#")[0] : e.src;
|
|
436
|
+
const dst = e.kind === "IMPORT" ? resolveDst(srcFile, e.dst) : e.dst;
|
|
437
|
+
rec(srcFile).edges.push({ kind: e.kind, src: e.src, dst });
|
|
438
|
+
}
|
|
439
|
+
db.close();
|
|
440
|
+
return { records: Array.from(byFile.values()) };
|
|
441
|
+
} catch (_e) {
|
|
442
|
+
try { if (db) db.close(); } catch {}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function loadStore(storePath) {
|
|
448
|
+
if (!storePath) return { ok: false, reason: "graph-unavailable" };
|
|
449
|
+
|
|
450
|
+
// SQLite is the PICKED store (K1) — read it first; fall back to JSONL baseline.
|
|
451
|
+
const loaded = (storePath.endsWith(".db") ? loadSqliteStore(storePath) : loadJsonlStore(storePath))
|
|
452
|
+
|| loadJsonlStore(storePath) || (storePath.endsWith(".db") ? null : loadSqliteStore(path.join(path.dirname(storePath), "graph.db")));
|
|
453
|
+
if (!loaded) return { ok: false, reason: "graph-unavailable" };
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// Load skipped files (best-effort — never fails the store load)
|
|
457
|
+
const skippedFiles = loadSkippedFiles(storePath);
|
|
458
|
+
const index = buildIndex(loaded.records, skippedFiles);
|
|
459
|
+
return { ok: true, index };
|
|
460
|
+
} catch (_e) {
|
|
461
|
+
return { ok: false, reason: "graph-unavailable" };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// ─── D4 freshness integration ─────────────────────────────────────────────────
|
|
466
|
+
// D5 calls D4's freshness_check_on_query INLINE before answering any query.
|
|
467
|
+
// If D4's module is absent, the graph is unavailable (fail-loud per the contract).
|
|
468
|
+
//
|
|
469
|
+
// D4 exports: { freshness_check_on_query, compute_touched_files }
|
|
470
|
+
// Both are called from the same module — no grep fallback ever.
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Load D4's freshness module. Returns null if unavailable.
|
|
474
|
+
* If null, the caller MUST return graph-unavailable (fail-loud).
|
|
475
|
+
*/
|
|
476
|
+
function loadFreshnessModule() {
|
|
477
|
+
try {
|
|
478
|
+
const freshnessPath = path.join(__dirname, "gsd-t-graph-freshness.cjs");
|
|
479
|
+
return require(freshnessPath);
|
|
480
|
+
} catch (_e) {
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Run D4's freshness check inline.
|
|
487
|
+
* Returns { ok: true } if index is fresh (or was re-indexed to become fresh).
|
|
488
|
+
* Returns { ok: false, reason: 'graph-unavailable' } if D4 is missing or fails.
|
|
489
|
+
*
|
|
490
|
+
* @param {string|null} storePath
|
|
491
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
492
|
+
*/
|
|
493
|
+
function runFreshnessCheck(storePath) {
|
|
494
|
+
const freshnessModule = loadFreshnessModule();
|
|
495
|
+
if (!freshnessModule) {
|
|
496
|
+
// D4 not yet built — fail-loud per [RULE] parser-fail-disables-loud-never-silent
|
|
497
|
+
return { ok: false, reason: "graph-unavailable" };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
// D4's real signatures take (db, projectRoot, ...) — NOT (storePath). The
|
|
502
|
+
// store path is `.gsd-t/graph.db`; projectRoot is the repo containing `.gsd-t`.
|
|
503
|
+
// (Earlier this called compute_touched_files(storePath) / freshness_check_on_query
|
|
504
|
+
// (touched, storePath) — a cross-domain signature mismatch that threw on every
|
|
505
|
+
// real query: freshness_check_on_query read `.edits` off the storePath string.)
|
|
506
|
+
const projectRoot = storePath.endsWith(".db")
|
|
507
|
+
? path.dirname(path.dirname(storePath)) // .gsd-t/graph.db → repo root
|
|
508
|
+
: path.dirname(path.dirname(storePath)); // .gsd-t/graph-index → repo root
|
|
509
|
+
// Pass the exact storePath (canonical `.gsd-t/graph.db`) so openDb opens the
|
|
510
|
+
// real store, not a re-derived nested path. projectRoot is still used for the
|
|
511
|
+
// working-tree walk.
|
|
512
|
+
const db = freshnessModule.openDb(projectRoot, storePath.endsWith(".db") ? storePath : undefined);
|
|
513
|
+
// No real store at this root → fail-loud BEFORE deriving a touched-set.
|
|
514
|
+
// (Without this, a fake/non-existent storePath yields projectRoot="/" and
|
|
515
|
+
// compute_touched_files walks the entire filesystem → OOM. [RULE]
|
|
516
|
+
// parser-fail-disables-loud-never-silent: absent store = graph-unavailable.)
|
|
517
|
+
if (!db) return { ok: false, reason: "graph-unavailable" };
|
|
518
|
+
const touched = freshnessModule.compute_touched_files(db, projectRoot);
|
|
519
|
+
// parse_and_put (from D3) lets freshness re-index stale files; pass it when
|
|
520
|
+
// available so an edited file is refreshed before the answer.
|
|
521
|
+
let parseAndPut = null;
|
|
522
|
+
try { parseAndPut = require(path.join(__dirname, "gsd-t-graph-index.cjs")).parse_and_put; } catch { /* optional */ }
|
|
523
|
+
freshnessModule.freshness_check_on_query(db, projectRoot, touched, parseAndPut);
|
|
524
|
+
return { ok: true };
|
|
525
|
+
} catch (_e) {
|
|
526
|
+
return { ok: false, reason: "graph-unavailable" };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ─── Query: who-imports ───────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* who-imports(target): return all files that import `target`.
|
|
534
|
+
* Answers from the reverse import-graph index.
|
|
535
|
+
*
|
|
536
|
+
* Adds a `coverage` field per [RULE] query-surfaces-incompleteness-never-silent-empty:
|
|
537
|
+
* when the skipped-file set includes files that could have imported `target` (any
|
|
538
|
+
* file in the skipped set is a potential contributor), the coverage is incomplete.
|
|
539
|
+
*
|
|
540
|
+
* @param {IndexStructure} index
|
|
541
|
+
* @param {string} target repo-relative POSIX path
|
|
542
|
+
* @returns {{ results: string[], tier: string, coverage: object }}
|
|
543
|
+
*/
|
|
544
|
+
function queryWhoImports(index, target) {
|
|
545
|
+
const importers = index.importGraph.get(target);
|
|
546
|
+
const results = importers ? Array.from(importers).sort() : [];
|
|
547
|
+
const coverage = computeCoverage(index.skippedFiles);
|
|
548
|
+
return { results, tier: index.tier, coverage };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ─── Query: who-calls ─────────────────────────────────────────────────────────
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* who-calls(identity): return all callers of a function identified by funcId or bare name.
|
|
555
|
+
*
|
|
556
|
+
* Identity forms:
|
|
557
|
+
* 'file#function' — file-qualified (exact funcId lookup)
|
|
558
|
+
* 'file#function@line' — overload-qualified funcId
|
|
559
|
+
* 'function' — bare name (may be ambiguous)
|
|
560
|
+
*
|
|
561
|
+
* Per [RULE] who-calls-function-identity-disambiguated:
|
|
562
|
+
* - A file#function identity resolves exactly one funcId.
|
|
563
|
+
* - A bare name matching multiple funcIds returns { ambiguous: true, candidates: [...] }.
|
|
564
|
+
* - A bare name matching exactly one funcId resolves directly.
|
|
565
|
+
* - NEVER merges callers across same-named functions.
|
|
566
|
+
*
|
|
567
|
+
* @param {IndexStructure} index
|
|
568
|
+
* @param {string} identity
|
|
569
|
+
* @returns {{ results?: string[], tier?: string, ambiguous?: boolean, candidates?: string[], notFound?: boolean }}
|
|
570
|
+
*/
|
|
571
|
+
function queryWhoCalls(index, identity) {
|
|
572
|
+
const isFuncId = identity.includes("#");
|
|
573
|
+
const coverage = computeCoverage(index.skippedFiles);
|
|
574
|
+
|
|
575
|
+
if (isFuncId) {
|
|
576
|
+
// File-qualified identity — exact funcId lookup
|
|
577
|
+
const callers = index.callGraph.get(identity);
|
|
578
|
+
const results = callers ? Array.from(callers).sort() : [];
|
|
579
|
+
return { results, tier: index.tier, coverage };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Bare name — disambiguate against all funcIds
|
|
583
|
+
const bareName = identity;
|
|
584
|
+
const matchingFuncIds = [];
|
|
585
|
+
for (const [funcId, meta] of index.funcEntities) {
|
|
586
|
+
if (meta.name === bareName) {
|
|
587
|
+
matchingFuncIds.push(funcId);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (matchingFuncIds.length === 0) {
|
|
592
|
+
return { results: [], tier: index.tier, coverage };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (matchingFuncIds.length === 1) {
|
|
596
|
+
// Unambiguous bare name — resolve directly
|
|
597
|
+
const callers = index.callGraph.get(matchingFuncIds[0]);
|
|
598
|
+
const results = callers ? Array.from(callers).sort() : [];
|
|
599
|
+
return { results, tier: index.tier, coverage };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Multiple matches — ambiguous, NEVER merge
|
|
603
|
+
return { ambiguous: true, candidates: matchingFuncIds.sort() };
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ─── Query: blast-radius ──────────────────────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* blast-radius(target): downstream impact set — UNION of reverse-reachable nodes
|
|
610
|
+
* from `target` in BOTH the import graph AND the call graph, transitive closure.
|
|
611
|
+
*
|
|
612
|
+
* Per [RULE] blast-radius-unions-both-graphs:
|
|
613
|
+
* - Import graph: file→file (reverse: who transitively imports target)
|
|
614
|
+
* - Call graph: funcId→funcId (reverse: who transitively calls target)
|
|
615
|
+
* - Both graphs UNIONED: a node reachable via EITHER is in the set.
|
|
616
|
+
* - Transitive closure (full reverse-reachable), NOT one-hop.
|
|
617
|
+
* - A node reachable via ONLY the call graph MUST appear (not just import-reachable).
|
|
618
|
+
* - A node reachable via NEITHER is excluded.
|
|
619
|
+
*
|
|
620
|
+
* File-root expansion:
|
|
621
|
+
* When `target` is a FILE path (not a funcId — no '#'), the BFS initial frontier is
|
|
622
|
+
* expanded to include ALL funcIds owned by that file. This ensures call-graph-only
|
|
623
|
+
* downstream nodes (callers of functions IN the target file) are included in the
|
|
624
|
+
* blast radius, not just import-graph reachable nodes.
|
|
625
|
+
*
|
|
626
|
+
* Why: the call-graph edges are keyed by funcId (e.g. "target.ts#targetFn"), not by
|
|
627
|
+
* file. A query like blast-radius("target.ts") without expansion would only follow
|
|
628
|
+
* import-graph edges, missing any function callers — producing an under-broad result.
|
|
629
|
+
* The UNION semantics require expanding to both the file node AND its owned funcIds.
|
|
630
|
+
*
|
|
631
|
+
* Note: blast-radius is a SEQUENCED-FOLLOW-ON deliverable per
|
|
632
|
+
* [RULE] blast-radius-sequenced-follow-on-not-phase1-consumed — it is built and
|
|
633
|
+
* tested here (D5-T1 verb + D5-T3 union fixture) but has ZERO Phase-1 consumer.
|
|
634
|
+
* Its /impact + /debug consumers are DEFERRED. This is an honest foundation.
|
|
635
|
+
*
|
|
636
|
+
* @param {IndexStructure} index
|
|
637
|
+
* @param {string} target file path or funcId
|
|
638
|
+
* @returns {{ results: string[], tier: string }}
|
|
639
|
+
*/
|
|
640
|
+
function queryBlastRadius(index, target) {
|
|
641
|
+
const isFilePath = !target.includes("#");
|
|
642
|
+
|
|
643
|
+
// Build the initial frontier (multi-root if file-path: include owned funcIds)
|
|
644
|
+
const initialFrontier = new Set([target]);
|
|
645
|
+
|
|
646
|
+
if (isFilePath) {
|
|
647
|
+
// Expand file-root to include ALL funcIds owned by this file.
|
|
648
|
+
// This is the UNION seam: call-graph edges are keyed by funcId, not file.
|
|
649
|
+
// Without this expansion, callers of target's functions would be missed.
|
|
650
|
+
for (const [funcId, meta] of index.funcEntities) {
|
|
651
|
+
if (meta.file === target) {
|
|
652
|
+
initialFrontier.add(funcId);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// BFS over the UNION of reverse import + call edges, transitive closure
|
|
658
|
+
const visited = new Set();
|
|
659
|
+
const queue = Array.from(initialFrontier);
|
|
660
|
+
|
|
661
|
+
while (queue.length > 0) {
|
|
662
|
+
const node = queue.shift();
|
|
663
|
+
if (visited.has(node)) continue;
|
|
664
|
+
visited.add(node);
|
|
665
|
+
|
|
666
|
+
// Reverse import edges: who imports this file node?
|
|
667
|
+
const importers = index.importGraph.get(node);
|
|
668
|
+
if (importers) {
|
|
669
|
+
for (const imp of importers) {
|
|
670
|
+
if (!visited.has(imp)) queue.push(imp);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Reverse call edges: who calls this function node?
|
|
675
|
+
const callers = index.callGraph.get(node);
|
|
676
|
+
if (callers) {
|
|
677
|
+
for (const caller of callers) {
|
|
678
|
+
if (!visited.has(caller)) queue.push(caller);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Exclude the initial frontier (the root and its owned funcIds) from the result set.
|
|
684
|
+
// They are roots, not downstream nodes.
|
|
685
|
+
for (const root of initialFrontier) {
|
|
686
|
+
visited.delete(root);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const results = Array.from(visited).sort();
|
|
690
|
+
const coverage = computeCoverage(index.skippedFiles);
|
|
691
|
+
return { results, tier: index.tier, coverage };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ─── Query: status ────────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* status: return live queryable index state.
|
|
698
|
+
* Per [RULE] graph-status-live — must return a LIVE queryable index;
|
|
699
|
+
* the M20–M21 "no graph index found" is the anti-goal.
|
|
700
|
+
*
|
|
701
|
+
* @param {IndexStructure} index
|
|
702
|
+
* @param {string} storePath
|
|
703
|
+
* @returns {object}
|
|
704
|
+
*/
|
|
705
|
+
function queryStatus(index, storePath) {
|
|
706
|
+
return {
|
|
707
|
+
queryable: true,
|
|
708
|
+
storePath,
|
|
709
|
+
fileCount: index.allFiles.size,
|
|
710
|
+
funcCount: index.funcEntities.size,
|
|
711
|
+
importEdgeCount: Array.from(index.importGraph.values()).reduce((s, v) => s + v.size, 0),
|
|
712
|
+
callEdgeCount: Array.from(index.callGraph.values()).reduce((s, v) => s + v.size, 0),
|
|
713
|
+
tier: index.tier,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// ─── D9-T1: Query: cluster (tightly-coupled file groups) ─────────────────────
|
|
718
|
+
//
|
|
719
|
+
// [RULE] cluster-verb-deterministic-coupling
|
|
720
|
+
// Algorithm: shared-neighbour coupling over the IMPORT graph.
|
|
721
|
+
// Coupling score between files A and B = (|shared import targets| + |shared importers|)
|
|
722
|
+
// normalised by (|A's neighbours| + |B's neighbours|). This is a DETERMINISTIC metric
|
|
723
|
+
// (same edges → same score → same grouping), NOT an LLM judgment.
|
|
724
|
+
//
|
|
725
|
+
// Grouping: Union-Find over all file pairs whose coupling score ≥ COUPLING_THRESHOLD.
|
|
726
|
+
// A file with NO other coupled file becomes its own singleton cluster.
|
|
727
|
+
//
|
|
728
|
+
// Consumers: /partition (domain-boundary suggestion), /project (milestone decomposition).
|
|
729
|
+
|
|
730
|
+
const COUPLING_THRESHOLD = 0.2; // declared in contract — files sharing ≥20% of their import neighbourhood
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Union-Find (path-compressed) for cluster grouping.
|
|
734
|
+
* @param {string[]} items
|
|
735
|
+
*/
|
|
736
|
+
function makeUnionFind(items) {
|
|
737
|
+
const parent = new Map(items.map((x) => [x, x]));
|
|
738
|
+
function find(x) {
|
|
739
|
+
if (parent.get(x) !== x) parent.set(x, find(parent.get(x)));
|
|
740
|
+
return parent.get(x);
|
|
741
|
+
}
|
|
742
|
+
function union(x, y) {
|
|
743
|
+
const rx = find(x), ry = find(y);
|
|
744
|
+
if (rx !== ry) parent.set(rx, ry);
|
|
745
|
+
}
|
|
746
|
+
return { find, union, parent };
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* cluster: return tightly-coupled file groups by a DETERMINISTIC coupling metric.
|
|
751
|
+
*
|
|
752
|
+
* @param {IndexStructure} index
|
|
753
|
+
* @returns {{ results: Array<{files:string[], couplingScore:number}>, tier: string }}
|
|
754
|
+
*/
|
|
755
|
+
function queryCluster(index) {
|
|
756
|
+
const files = Array.from(index.allFiles).sort(); // deterministic order
|
|
757
|
+
|
|
758
|
+
// Build forward import adjacency: srcFile → Set<dstFile>
|
|
759
|
+
// importGraph is reverse (dst→Set<src>), so we invert it.
|
|
760
|
+
/** @type {Map<string,Set<string>>} */
|
|
761
|
+
const importTargets = new Map(); // file → files it imports
|
|
762
|
+
/** @type {Map<string,Set<string>>} */
|
|
763
|
+
const importedBy = index.importGraph; // file → files that import it (already built)
|
|
764
|
+
|
|
765
|
+
for (const [dst, srcs] of importedBy) {
|
|
766
|
+
for (const src of srcs) {
|
|
767
|
+
if (!importTargets.has(src)) importTargets.set(src, new Set());
|
|
768
|
+
importTargets.get(src).add(dst);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Compute pairwise coupling scores for all file pairs that share at least one neighbour.
|
|
773
|
+
// Only compute O(N²) where N = files with edges — skip fully-isolated files for pairing.
|
|
774
|
+
const uf = makeUnionFind(files);
|
|
775
|
+
|
|
776
|
+
// For each unique pair (i < j), compute coupling = Jaccard over import neighbourhood.
|
|
777
|
+
// neighbourhood(f) = importTargets(f) ∪ importedBy(f)
|
|
778
|
+
const neighbourhood = new Map();
|
|
779
|
+
for (const f of files) {
|
|
780
|
+
const nb = new Set();
|
|
781
|
+
if (importTargets.has(f)) for (const x of importTargets.get(f)) nb.add(x);
|
|
782
|
+
if (importedBy.has(f)) for (const x of importedBy.get(f)) nb.add(x);
|
|
783
|
+
neighbourhood.set(f, nb);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
for (let i = 0; i < files.length; i++) {
|
|
787
|
+
const a = files[i];
|
|
788
|
+
const nbA = neighbourhood.get(a);
|
|
789
|
+
if (nbA.size === 0) continue;
|
|
790
|
+
for (let j = i + 1; j < files.length; j++) {
|
|
791
|
+
const b = files[j];
|
|
792
|
+
const nbB = neighbourhood.get(b);
|
|
793
|
+
if (nbB.size === 0) continue;
|
|
794
|
+
// Jaccard(A,B) = |A∩B| / |A∪B|
|
|
795
|
+
let intersection = 0;
|
|
796
|
+
for (const x of nbA) if (nbB.has(x)) intersection++;
|
|
797
|
+
const unionSize = nbA.size + nbB.size - intersection;
|
|
798
|
+
if (unionSize === 0) continue;
|
|
799
|
+
const score = intersection / unionSize;
|
|
800
|
+
if (score >= COUPLING_THRESHOLD) {
|
|
801
|
+
uf.union(a, b);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Collect groups
|
|
807
|
+
const groupMap = new Map();
|
|
808
|
+
for (const f of files) {
|
|
809
|
+
const root = uf.find(f);
|
|
810
|
+
if (!groupMap.has(root)) groupMap.set(root, []);
|
|
811
|
+
groupMap.get(root).push(f);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// Sort groups by descending size, then by first file name (deterministic)
|
|
815
|
+
const groups = Array.from(groupMap.values());
|
|
816
|
+
groups.sort((a, b) => {
|
|
817
|
+
if (b.length !== a.length) return b.length - a.length;
|
|
818
|
+
return a[0] < b[0] ? -1 : 1;
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
// Compute a representative coupling score for each group (average pairwise Jaccard among members)
|
|
822
|
+
const results = groups.map((group) => {
|
|
823
|
+
if (group.length === 1) return { files: group, couplingScore: 0 };
|
|
824
|
+
let totalScore = 0, pairs = 0;
|
|
825
|
+
for (let i = 0; i < group.length; i++) {
|
|
826
|
+
const nbA = neighbourhood.get(group[i]);
|
|
827
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
828
|
+
const nbB = neighbourhood.get(group[j]);
|
|
829
|
+
let intersection = 0;
|
|
830
|
+
for (const x of nbA) if (nbB.has(x)) intersection++;
|
|
831
|
+
const unionSize = nbA.size + nbB.size - intersection;
|
|
832
|
+
if (unionSize > 0) totalScore += intersection / unionSize;
|
|
833
|
+
pairs++;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const avg = pairs > 0 ? totalScore / pairs : 0;
|
|
837
|
+
return { files: group, couplingScore: Math.round(avg * 1000) / 1000 };
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
return { results, tier: index.tier };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// ─── D9-T2: Query: dead-code / orphan (no inbound edges) ─────────────────────
|
|
844
|
+
//
|
|
845
|
+
// [RULE] dead-code-verb-excludes-entrypoints-and-exports
|
|
846
|
+
// Exclusions (declared here and in the contract):
|
|
847
|
+
// 1. Files matching ENTRY_POINT_PATTERNS (bin/, main.*, index.*, cli.*)
|
|
848
|
+
// 2. Test files (matched by DEFAULT_TEST_PATTERNS) — these intentionally have no callers
|
|
849
|
+
// 3. Functions whose name starts with an uppercase letter (exported class constructors / public API)
|
|
850
|
+
// 4. Functions named "exports", "module.exports", or matching EXPORTED_FUNC_PATTERNS
|
|
851
|
+
//
|
|
852
|
+
// [RULE] orphan-tier-labeled-candidate-not-certainty
|
|
853
|
+
// Tree-sitter-floor-tier orphans are labeled CANDIDATE (a missed unresolved call
|
|
854
|
+
// could make a live function look orphan). Compiler-accurate orphans labeled accordingly.
|
|
855
|
+
|
|
856
|
+
const ENTRY_POINT_PATTERNS = [
|
|
857
|
+
/(?:^|\/)bin\//,
|
|
858
|
+
/(?:^|\/)main\.[^.]+$/,
|
|
859
|
+
/(?:^|\/)index\.[^.]+$/,
|
|
860
|
+
/(?:^|\/)cli\.[^.]+$/,
|
|
861
|
+
/(?:^|\/)app\.[^.]+$/,
|
|
862
|
+
/(?:^|\/)server\.[^.]+$/,
|
|
863
|
+
];
|
|
864
|
+
|
|
865
|
+
const EXPORTED_FUNC_PATTERNS = [
|
|
866
|
+
/^exports?\./,
|
|
867
|
+
/^module\.exports/,
|
|
868
|
+
];
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* dead-code / orphan: return function entities with no inbound edges (callers or importers).
|
|
872
|
+
* Applies declared exclusions and tier-honest CANDIDATE labeling.
|
|
873
|
+
*
|
|
874
|
+
* @param {IndexStructure} index
|
|
875
|
+
* @returns {{ results: Array<{funcId:string, file:string, tier:string, candidateLabel:string|null}>, tier: string }}
|
|
876
|
+
*/
|
|
877
|
+
function queryDeadCode(index) {
|
|
878
|
+
const testPatterns = getTestPatterns();
|
|
879
|
+
const results = [];
|
|
880
|
+
|
|
881
|
+
for (const [funcId, meta] of index.funcEntities) {
|
|
882
|
+
// Exclusion 1: entry-point files
|
|
883
|
+
if (ENTRY_POINT_PATTERNS.some((re) => re.test(meta.file))) continue;
|
|
884
|
+
// Exclusion 2: test files
|
|
885
|
+
if (testPatterns.some((re) => re.test(meta.file))) continue;
|
|
886
|
+
// Exclusion 3: exported public API — function name starts with uppercase
|
|
887
|
+
if (/^[A-Z]/.test(meta.name)) continue;
|
|
888
|
+
// Exclusion 4: module.exports patterns
|
|
889
|
+
if (EXPORTED_FUNC_PATTERNS.some((re) => re.test(meta.name))) continue;
|
|
890
|
+
|
|
891
|
+
// Check for inbound call edges
|
|
892
|
+
const callers = index.callGraph.get(funcId);
|
|
893
|
+
if (callers && callers.size > 0) continue;
|
|
894
|
+
|
|
895
|
+
// Check for inbound import edges (function's file is imported)
|
|
896
|
+
const fileImporters = index.importGraph.get(meta.file);
|
|
897
|
+
// If the file is imported, do NOT flag its functions as dead — the file is in use.
|
|
898
|
+
// (The import itself is the "caller" at the file level.)
|
|
899
|
+
if (fileImporters && fileImporters.size > 0) continue;
|
|
900
|
+
|
|
901
|
+
// This function has no callers AND its file is not imported by anyone.
|
|
902
|
+
// [RULE] orphan-tier-labeled-candidate-not-certainty:
|
|
903
|
+
// Floor-tier results are CANDIDATE (a missed unresolved call could explain the absence).
|
|
904
|
+
const tier = meta.tier || index.tier;
|
|
905
|
+
const isFloor = tier === "tree-sitter-floor" || tier === "tree-sitter-floor-STALE-SCIP";
|
|
906
|
+
const candidateLabel = isFloor ? "CANDIDATE" : null;
|
|
907
|
+
|
|
908
|
+
results.push({ funcId, file: meta.file, tier, candidateLabel });
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// Sort deterministically by funcId
|
|
912
|
+
results.sort((a, b) => (a.funcId < b.funcId ? -1 : 1));
|
|
913
|
+
return { results, tier: index.tier };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// ─── D9-T2: Query: dangling (edges to missing nodes) ─────────────────────────
|
|
917
|
+
//
|
|
918
|
+
// [RULE] dangling-verb-surfaces-missing-dst
|
|
919
|
+
// A dangling edge: a CALL edge whose dst funcId is NOT in the funcEntities map
|
|
920
|
+
// and is NOT an UNRESOLVED# sentinel (those are expected floor-tier unknowns).
|
|
921
|
+
// IMPORT dangling: an IMPORT edge whose dst file is NOT in allFiles.
|
|
922
|
+
//
|
|
923
|
+
// Consumer: /qa + /verify (dead-code / dangling-ref detection).
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* dangling: return edges whose dst is a missing node (delete/rename residue).
|
|
927
|
+
*
|
|
928
|
+
* @param {IndexStructure} index
|
|
929
|
+
* @returns {{ results: Array<{src:string, dst:string, kind:string, note:string}>, tier: string }}
|
|
930
|
+
*/
|
|
931
|
+
function queryDangling(index) {
|
|
932
|
+
const results = [];
|
|
933
|
+
|
|
934
|
+
for (const { src, dst, kind } of index.forwardCallEdges) {
|
|
935
|
+
// Skip UNRESOLVED# sentinels — these are expected at the tree-sitter floor;
|
|
936
|
+
// they represent unresolvable call targets, NOT deleted/missing nodes.
|
|
937
|
+
if (dst.startsWith(UNRESOLVED_PREFIX)) continue;
|
|
938
|
+
// If the dst funcId is not in the entities map, it is a dangling ref
|
|
939
|
+
if (!index.funcEntities.has(dst)) {
|
|
940
|
+
results.push({
|
|
941
|
+
src,
|
|
942
|
+
dst,
|
|
943
|
+
kind: "CALL",
|
|
944
|
+
note: "dst function not in index — possibly deleted or renamed",
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Also check IMPORT edges: importGraph is reverse (dst → srcs).
|
|
950
|
+
// To find dangling import dsts, scan all dst keys in importGraph
|
|
951
|
+
// and check if they are in allFiles.
|
|
952
|
+
for (const [dst, srcs] of index.importGraph) {
|
|
953
|
+
if (!index.allFiles.has(dst)) {
|
|
954
|
+
for (const src of srcs) {
|
|
955
|
+
results.push({
|
|
956
|
+
src,
|
|
957
|
+
dst,
|
|
958
|
+
kind: "IMPORT",
|
|
959
|
+
note: "dst file not in index — possibly deleted or renamed",
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
results.sort((a, b) => {
|
|
966
|
+
const c = a.src < b.src ? -1 : a.src > b.src ? 1 : 0;
|
|
967
|
+
if (c !== 0) return c;
|
|
968
|
+
return a.dst < b.dst ? -1 : 1;
|
|
969
|
+
});
|
|
970
|
+
return { results, tier: index.tier };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ─── D9-T3: Query: test-impl (test→impl coverage from call-site edges) ───────
|
|
974
|
+
//
|
|
975
|
+
// [RULE] test-impl-verb-from-call-site-edges-no-new-type
|
|
976
|
+
// [RULE] test-impl-no-new-edge-type-needed
|
|
977
|
+
// [RULE] test-impl-never-presents-unresolved-as-coverage
|
|
978
|
+
//
|
|
979
|
+
// The extractor already emits call-site edges keyed file#function@LINE at both ends.
|
|
980
|
+
// A test→impl edge is DERIVABLE by filtering call edges where src file matches a
|
|
981
|
+
// test-path pattern. NO new edge type is added to D3.
|
|
982
|
+
//
|
|
983
|
+
// Consumers: /test-sync (align tests with impl via coverage edges).
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* test-impl: return, per test function, the impl funcIds it calls.
|
|
987
|
+
*
|
|
988
|
+
* @param {IndexStructure} index
|
|
989
|
+
* @param {{ inverse?: boolean, testPatterns?: RegExp[] }} [options]
|
|
990
|
+
* inverse: if true, return impl funcs with NO test-file caller (untested-impl)
|
|
991
|
+
* @returns {{ results: Array<{testFunc:string, implFuncs:string[]}> |
|
|
992
|
+
* Array<{funcId:string, file:string}>, tier: string, mode: string }}
|
|
993
|
+
*/
|
|
994
|
+
function queryTestImpl(index, options) {
|
|
995
|
+
const { inverse = false, testPatterns = getTestPatterns() } = options || {};
|
|
996
|
+
|
|
997
|
+
if (!inverse) {
|
|
998
|
+
// Forward mode: for each test function, list the impl funcIds it calls.
|
|
999
|
+
// [RULE] test-impl-never-presents-unresolved-as-coverage:
|
|
1000
|
+
// UNRESOLVED# targets are filtered out — they are NOT coverage.
|
|
1001
|
+
/** @type {Map<string, string[]>} */
|
|
1002
|
+
const testToImpl = new Map();
|
|
1003
|
+
|
|
1004
|
+
for (const { src, dst } of index.forwardCallEdges) {
|
|
1005
|
+
// src must be a test file
|
|
1006
|
+
if (!isTestFile(src, testPatterns)) continue;
|
|
1007
|
+
// dst must NOT be a test file (it's an impl target)
|
|
1008
|
+
if (isTestFile(dst, testPatterns)) continue;
|
|
1009
|
+
// dst must NOT be an UNRESOLVED sentinel
|
|
1010
|
+
if (dst.startsWith(UNRESOLVED_PREFIX)) continue;
|
|
1011
|
+
|
|
1012
|
+
if (!testToImpl.has(src)) testToImpl.set(src, []);
|
|
1013
|
+
testToImpl.get(src).push(dst);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Deduplicate and sort implFuncs for each test function
|
|
1017
|
+
const results = [];
|
|
1018
|
+
for (const [testFunc, implFuncs] of testToImpl) {
|
|
1019
|
+
const unique = Array.from(new Set(implFuncs)).sort();
|
|
1020
|
+
results.push({ testFunc, implFuncs: unique });
|
|
1021
|
+
}
|
|
1022
|
+
results.sort((a, b) => (a.testFunc < b.testFunc ? -1 : 1));
|
|
1023
|
+
return { results, tier: index.tier, mode: "forward" };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// Inverse mode: impl funcs with NO test-file caller (untested-impl).
|
|
1027
|
+
// Collect all impl funcIds that have at least one test caller.
|
|
1028
|
+
const testedFuncIds = new Set();
|
|
1029
|
+
for (const { src, dst } of index.forwardCallEdges) {
|
|
1030
|
+
if (!isTestFile(src, testPatterns)) continue;
|
|
1031
|
+
if (isTestFile(dst, testPatterns)) continue;
|
|
1032
|
+
if (dst.startsWith(UNRESOLVED_PREFIX)) continue;
|
|
1033
|
+
testedFuncIds.add(dst);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const untested = [];
|
|
1037
|
+
for (const [funcId, meta] of index.funcEntities) {
|
|
1038
|
+
// Only consider impl files (not test files)
|
|
1039
|
+
if (isTestFile(funcId, testPatterns)) continue;
|
|
1040
|
+
if (!testedFuncIds.has(funcId)) {
|
|
1041
|
+
untested.push({ funcId, file: meta.file });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
untested.sort((a, b) => (a.funcId < b.funcId ? -1 : 1));
|
|
1045
|
+
return { results: untested, tier: index.tier, mode: "untested-impl" };
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// ─── Public API (for tests to import directly) ────────────────────────────────
|
|
1049
|
+
// Tests build an index from fixture records, then call these pure functions.
|
|
1050
|
+
// The query CLI is the only caller of runFreshnessCheck (integration seam).
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Build an IndexStructure from an array of store records.
|
|
1054
|
+
* Used by tests to construct fixture-based indices without disk I/O.
|
|
1055
|
+
*
|
|
1056
|
+
* @param {object[]} records
|
|
1057
|
+
* @param {Set<string>} [skippedFiles] — optional skipped-file set for T5 coverage tests
|
|
1058
|
+
*/
|
|
1059
|
+
function buildIndexFromRecords(records, skippedFiles) {
|
|
1060
|
+
return buildIndex(records, skippedFiles);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
module.exports = {
|
|
1064
|
+
buildIndexFromRecords,
|
|
1065
|
+
queryWhoImports,
|
|
1066
|
+
queryWhoCalls,
|
|
1067
|
+
queryBlastRadius,
|
|
1068
|
+
queryStatus,
|
|
1069
|
+
// D9 additions
|
|
1070
|
+
queryCluster,
|
|
1071
|
+
queryDeadCode,
|
|
1072
|
+
queryDangling,
|
|
1073
|
+
queryTestImpl,
|
|
1074
|
+
computeCoverage,
|
|
1075
|
+
loadStore,
|
|
1076
|
+
runFreshnessCheck,
|
|
1077
|
+
resolveStorePath,
|
|
1078
|
+
// Exported for fault-injection tests
|
|
1079
|
+
loadFreshnessModule,
|
|
1080
|
+
// Exported constants for test assertions
|
|
1081
|
+
COUPLING_THRESHOLD,
|
|
1082
|
+
DEFAULT_TEST_PATTERNS,
|
|
1083
|
+
UNRESOLVED_PREFIX,
|
|
1084
|
+
getTestPatterns,
|
|
1085
|
+
isTestFile,
|
|
1086
|
+
};
|
|
1087
|
+
|
|
1088
|
+
// ─── CLI entry point ───────────────────────────────────────────────────────────
|
|
1089
|
+
if (require.main === module) {
|
|
1090
|
+
const args = process.argv.slice(2);
|
|
1091
|
+
const verb = args[0];
|
|
1092
|
+
const target = args[1];
|
|
1093
|
+
|
|
1094
|
+
function emit(envelope) {
|
|
1095
|
+
process.stdout.write(JSON.stringify(envelope) + "\n");
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function fail(envelope) {
|
|
1099
|
+
emit(envelope);
|
|
1100
|
+
process.exit(1);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
const ALL_VERBS = [
|
|
1104
|
+
"who-imports", "who-calls", "blast-radius", "status",
|
|
1105
|
+
"cluster", "dead-code", "orphan", "dangling", "test-impl",
|
|
1106
|
+
];
|
|
1107
|
+
|
|
1108
|
+
if (!verb) {
|
|
1109
|
+
fail({ ok: false, reason: "no-verb", usage: `gsd-t graph <${ALL_VERBS.join("|")}> [target]` });
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (!ALL_VERBS.includes(verb)) {
|
|
1113
|
+
fail({ ok: false, reason: "unknown-verb", verb });
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const storePath = resolveStorePath();
|
|
1117
|
+
|
|
1118
|
+
// ── Step 1: D4 freshness check INLINE — [RULE] stale-file-reindexed-before-answer ──
|
|
1119
|
+
const freshnessResult = runFreshnessCheck(storePath);
|
|
1120
|
+
if (!freshnessResult.ok) {
|
|
1121
|
+
fail({ ok: false, reason: "graph-unavailable" });
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// ── Step 2: Load store (fail-loud if missing or corrupt) ──
|
|
1125
|
+
const storeResult = loadStore(storePath);
|
|
1126
|
+
if (!storeResult.ok) {
|
|
1127
|
+
fail({ ok: false, reason: "graph-unavailable" });
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const index = storeResult.index;
|
|
1131
|
+
|
|
1132
|
+
// ── Step 3: Dispatch verb ──
|
|
1133
|
+
if (verb === "who-imports") {
|
|
1134
|
+
if (!target) fail({ ok: false, reason: "missing-target", verb });
|
|
1135
|
+
const { results, tier, coverage } = queryWhoImports(index, target);
|
|
1136
|
+
emit({ ok: true, verb, target, results, tier, coverage });
|
|
1137
|
+
|
|
1138
|
+
} else if (verb === "who-calls") {
|
|
1139
|
+
if (!target) fail({ ok: false, reason: "missing-target", verb });
|
|
1140
|
+
const queryResult = queryWhoCalls(index, target);
|
|
1141
|
+
if (queryResult.ambiguous) {
|
|
1142
|
+
// [RULE] who-calls-function-identity-disambiguated
|
|
1143
|
+
process.stdout.write(JSON.stringify({
|
|
1144
|
+
ok: false,
|
|
1145
|
+
reason: "ambiguous-function",
|
|
1146
|
+
verb,
|
|
1147
|
+
target,
|
|
1148
|
+
candidates: queryResult.candidates,
|
|
1149
|
+
}) + "\n");
|
|
1150
|
+
process.exit(2);
|
|
1151
|
+
}
|
|
1152
|
+
emit({ ok: true, verb, target, results: queryResult.results, tier: queryResult.tier, coverage: queryResult.coverage });
|
|
1153
|
+
|
|
1154
|
+
} else if (verb === "blast-radius") {
|
|
1155
|
+
if (!target) fail({ ok: false, reason: "missing-target", verb });
|
|
1156
|
+
const { results, tier, coverage } = queryBlastRadius(index, target);
|
|
1157
|
+
emit({ ok: true, verb, target, results, tier, coverage });
|
|
1158
|
+
|
|
1159
|
+
} else if (verb === "status") {
|
|
1160
|
+
const statusData = queryStatus(index, storePath);
|
|
1161
|
+
emit({ ok: true, verb: "status", ...statusData });
|
|
1162
|
+
|
|
1163
|
+
} else if (verb === "cluster") {
|
|
1164
|
+
const { results, tier } = queryCluster(index);
|
|
1165
|
+
emit({ ok: true, verb: "cluster", results, tier });
|
|
1166
|
+
|
|
1167
|
+
} else if (verb === "dead-code" || verb === "orphan") {
|
|
1168
|
+
const { results, tier } = queryDeadCode(index);
|
|
1169
|
+
emit({ ok: true, verb: "dead-code", results, tier });
|
|
1170
|
+
|
|
1171
|
+
} else if (verb === "dangling") {
|
|
1172
|
+
const { results, tier } = queryDangling(index);
|
|
1173
|
+
emit({ ok: true, verb: "dangling", results, tier });
|
|
1174
|
+
|
|
1175
|
+
} else if (verb === "test-impl") {
|
|
1176
|
+
const inverse = args.includes("--inverse");
|
|
1177
|
+
const { results, tier, mode } = queryTestImpl(index, { inverse });
|
|
1178
|
+
emit({ ok: true, verb: "test-impl", mode, results, tier });
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
process.exit(0);
|
|
1182
|
+
}
|