@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/bin/gsd-t-competition-judge.cjs +7 -1
  4. package/bin/gsd-t-context-brief-kinds/impact.cjs +91 -4
  5. package/bin/gsd-t-context-brief-kinds/partition.cjs +95 -0
  6. package/bin/gsd-t-context-brief-kinds/plan.cjs +110 -4
  7. package/bin/gsd-t-file-disjointness.cjs +319 -7
  8. package/bin/gsd-t-graph-anti-grep-lint.cjs +515 -0
  9. package/bin/gsd-t-graph-edge-extract.cjs +612 -0
  10. package/bin/gsd-t-graph-freshness.cjs +506 -0
  11. package/bin/gsd-t-graph-index.cjs +540 -0
  12. package/bin/gsd-t-graph-k1-sqlite-stream.cjs +251 -0
  13. package/bin/gsd-t-graph-query-cli.cjs +1182 -0
  14. package/bin/gsd-t-graph-scip-upgrade.cjs +440 -0
  15. package/bin/gsd-t-graph-store-bakeoff.cjs +889 -0
  16. package/bin/gsd-t-graph-synthetic-gen.cjs +304 -0
  17. package/bin/gsd-t-graph-ts-throughput.cjs +587 -0
  18. package/bin/gsd-t-scip-reader.cjs +167 -0
  19. package/bin/gsd-t.js +166 -48
  20. package/commands/gsd-t-debug.md +10 -0
  21. package/commands/gsd-t-design-build.md +10 -0
  22. package/commands/gsd-t-execute.md +15 -0
  23. package/commands/gsd-t-feature.md +15 -7
  24. package/commands/gsd-t-gap-analysis.md +17 -7
  25. package/commands/gsd-t-impact.md +25 -0
  26. package/commands/gsd-t-integrate.md +25 -0
  27. package/commands/gsd-t-partition.md +18 -0
  28. package/commands/gsd-t-plan.md +16 -0
  29. package/commands/gsd-t-populate.md +16 -5
  30. package/commands/gsd-t-prd.md +18 -0
  31. package/commands/gsd-t-project.md +19 -0
  32. package/commands/gsd-t-promote-debt.md +16 -5
  33. package/commands/gsd-t-qa.md +20 -8
  34. package/commands/gsd-t-quick.md +10 -0
  35. package/commands/gsd-t-scan.md +21 -3
  36. package/commands/gsd-t-test-sync.md +10 -0
  37. package/commands/gsd-t-verify.md +25 -0
  38. package/commands/gsd-t-wave.md +8 -0
  39. package/package.json +10 -2
  40. package/templates/workflows/gsd-t-debug.workflow.js +81 -0
  41. package/templates/workflows/gsd-t-integrate.workflow.js +47 -1
  42. package/templates/workflows/gsd-t-phase.workflow.js +99 -1
  43. package/templates/workflows/gsd-t-quick.workflow.js +64 -0
  44. package/templates/workflows/gsd-t-scan.workflow.js +200 -9
  45. package/templates/workflows/gsd-t-verify.workflow.js +50 -1
  46. package/bin/graph-cgc.js +0 -510
  47. package/bin/graph-indexer.js +0 -147
  48. package/bin/graph-overlay.js +0 -195
  49. package/bin/graph-parsers.js +0 -327
  50. package/bin/graph-query.js +0 -453
  51. 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
+ }