@tekyzinc/gsd-t 5.22.10 → 5.24.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.
@@ -20,6 +20,11 @@
20
20
  * gsd-t graph dangling — edges whose dst is a missing node (delete/rename residue)
21
21
  * gsd-t graph test-impl [--inverse] — test→impl call coverage (--inverse = untested-impl)
22
22
  *
23
+ * Verbs (database tables — Drizzle pgTable/mysqlTable/sqliteTable + pgEnum):
24
+ * gsd-t graph who-uses <table> [--writes|--reads] — functions / methods / route handlers using a table
25
+ * gsd-t graph table <table> — columns + foreign keys in both directions
26
+ * (blast-radius <table> and body <table> also accept a table)
27
+ *
23
28
  * Invariants (all verified by keystone tests):
24
29
  * [RULE] query-cli-never-greps — NO directive-driven grep fallback in any code path
25
30
  * [RULE] parser-fail-disables-loud-never-silent — genuine parser/store failure → {ok:false, reason:'graph-unavailable'}
@@ -204,6 +209,16 @@ function isTestFile(funcId, patterns) {
204
209
  // in the implFuncs coverage set.
205
210
 
206
211
  const UNRESOLVED_PREFIX = "UNRESOLVED#";
212
+ // A file the SCIP indexer ran for but never produced a document for (see
213
+ // gsd-t-graph-scip-upgrade.cjs). [RULE] scip-missing-file-detected-never-silent
214
+ const SCIP_MISSING_TIER = "tree-sitter-floor-SCIP-MISSING";
215
+ // A file SCIP indexed but where too few repo-resolvable calls resolved to call it
216
+ // compiler-accurate. [RULE] scip-tier-proportional
217
+ const COMPILER_PARTIAL_TIER = "compiler-partial";
218
+ // Tiers whose call edges SCIP actually looked at. An UNRESOLVED call in one of
219
+ // these files is SCIP saying "not a repo function" (a local, a mock, a library),
220
+ // so it is never name-matched. [RULE] name-match-only-where-scip-never-looked
221
+ const SCIP_BACKED_TIERS = new Set(["compiler-accurate", COMPILER_PARTIAL_TIER]);
207
222
 
208
223
  /**
209
224
  * Load records from a JSONL store directory.
@@ -370,6 +385,12 @@ function buildIndex(records, skippedFiles) {
370
385
  /** @type {Map<string,string>} file → tier (M114: who-calls needs the TARGET
371
386
  * file's tier, not just the repo-wide dominant one). */
372
387
  const fileTier = new Map();
388
+ /** @type {Map<string,{name:string,file:string,tier:string,endLine:?number,kind:string,meta:object}>}
389
+ * Drizzle tables + enums, kept OUT of funcEntities so dead-code / test-impl /
390
+ * who-calls never mistake a table for a function. [RULE] drizzle-table-entity-and-usage-edges */
391
+ const tableEntities = new Map();
392
+ /** @type {Map<string,Array<{src:string,access:string,op:string,line:number}>>} table code name → uses */
393
+ const tableUses = new Map();
373
394
 
374
395
  let dominantTier = "compiler-accurate";
375
396
  let hasFloor = false;
@@ -379,13 +400,18 @@ function buildIndex(records, skippedFiles) {
379
400
  allFiles.add(rec.file);
380
401
  if (rec.tier) fileTier.set(rec.file, rec.tier);
381
402
 
382
- if (rec.tier === "tree-sitter-floor") hasFloor = true;
403
+ if (rec.tier === "tree-sitter-floor" || rec.tier === SCIP_MISSING_TIER || rec.tier === COMPILER_PARTIAL_TIER) hasFloor = true;
383
404
  if (rec.tier === "tree-sitter-floor-STALE-SCIP") hasStaleScip = true;
384
405
 
385
406
  // Index entities for bare-name disambiguation + tier labeling
386
407
  if (Array.isArray(rec.entities)) {
387
408
  for (const ent of rec.entities) {
388
- if (ent.funcId) {
409
+ if (ent.funcId && (ent.kind === "table" || ent.kind === "enum")) {
410
+ tableEntities.set(ent.funcId, {
411
+ name: ent.name, file: ent.file || rec.file, tier: ent.tier || rec.tier,
412
+ endLine: ent.endLine ?? null, kind: ent.kind, meta: ent.meta || {},
413
+ });
414
+ } else if (ent.funcId) {
389
415
  funcEntities.set(ent.funcId, {
390
416
  name: ent.name,
391
417
  file: ent.file || rec.file,
@@ -409,6 +435,13 @@ function buildIndex(records, skippedFiles) {
409
435
  callGraph.get(edge.dst).add(edge.src);
410
436
  // Forward: collect ALL call edges for dangling + test-impl verbs
411
437
  forwardCallEdges.push({ src: edge.src, dst: edge.dst, kind: "CALL" });
438
+ } else if (edge.kind === "TABLE-READ" || edge.kind === "TABLE-WRITE") {
439
+ // dst = TABLE#<name>#<operation>@<line>
440
+ const m = /^TABLE#([^#]+)#([^@]+)@(\d+)$/.exec(edge.dst);
441
+ if (m) {
442
+ if (!tableUses.has(m[1])) tableUses.set(m[1], []);
443
+ tableUses.get(m[1]).push({ src: edge.src, access: edge.kind.slice(6), op: m[2], line: Number(m[3]) });
444
+ }
412
445
  }
413
446
  }
414
447
  }
@@ -420,8 +453,11 @@ function buildIndex(records, skippedFiles) {
420
453
  return {
421
454
  importGraph,
422
455
  callGraph,
456
+ nameMatchedCallGraph: buildNameMatchedCallGraph(forwardCallEdges, funcEntities, fileTier),
423
457
  forwardCallEdges,
424
458
  funcEntities,
459
+ tableEntities,
460
+ tableUses,
425
461
  allFiles,
426
462
  fileTier,
427
463
  tier: dominantTier,
@@ -429,6 +465,76 @@ function buildIndex(records, skippedFiles) {
429
465
  };
430
466
  }
431
467
 
468
+ /**
469
+ * Name-matched reverse call edges: `UNRESOLVED#<name>` → the ONE function in the
470
+ * repo named <name>, for callers in files SCIP never resolved (no SCIP document,
471
+ * floor, stale). A labelled answer, never a compiler one: who-calls reports these
472
+ * callers under `nameMatched` with `resolution: "name-matched"`.
473
+ * A name defined 2+ times is never matched — it stays in
474
+ * coverage.unresolvedCallSites. [RULE] unique-name-unresolved-call-name-matched
475
+ * [RULE] name-match-only-where-scip-never-looked
476
+ *
477
+ * @returns {Map<string,Set<string>>} dstFuncId (both `file#name@line` and `file#name`) → callers
478
+ */
479
+ function buildNameMatchedCallGraph(forwardCallEdges, funcEntities, fileTier) {
480
+ const byName = new Map(); // name → [funcId, ...]
481
+ for (const [funcId, meta] of funcEntities) {
482
+ if (!byName.has(meta.name)) byName.set(meta.name, []);
483
+ byName.get(meta.name).push(funcId);
484
+ }
485
+ const graph = new Map();
486
+ for (const { src, dst } of forwardCallEdges) {
487
+ if (!dst.startsWith(UNRESOLVED_PREFIX)) continue;
488
+ const defs = byName.get(dst.slice(UNRESOLVED_PREFIX.length));
489
+ if (!defs || defs.length !== 1) continue;
490
+ // [RULE] name-match-only-where-scip-never-looked: include a caller ONLY if its
491
+ // file was NOT SCIP-backed (not compiler-accurate or compiler-partial). Files
492
+ // missing from fileTier default to tree-sitter-floor (SCIP never looked), so
493
+ // they are included. [ISSUE] M119: files outside tsconfig have no fileTier entry
494
+ // yet contribute valid UNRESOLVED edges → include them.
495
+ const callerFile = src.split("#")[0];
496
+ const callerTier = fileTier.get(callerFile);
497
+ // A missing tier → never SCIP-backed → include it (tree-sitter-floor by default)
498
+ if (callerTier !== undefined && SCIP_BACKED_TIERS.has(callerTier)) continue;
499
+ for (const key of new Set([defs[0], defs[0].replace(/@\d+$/, "")])) {
500
+ if (!graph.has(key)) graph.set(key, new Set());
501
+ graph.get(key).add(src);
502
+ }
503
+ }
504
+ return graph;
505
+ }
506
+
507
+ /**
508
+ * Callers of `funcId` found only by name match (not already compiler callers).
509
+ * @returns {string[]}
510
+ */
511
+ function nameMatchedCallersOf(index, funcId, compilerCallers) {
512
+ if (!index.nameMatchedCallGraph) return [];
513
+ const set = index.nameMatchedCallGraph.get(funcId) || index.nameMatchedCallGraph.get(funcId.replace(/@\d+$/, ""));
514
+ if (!set) return [];
515
+ return Array.from(set).filter((c) => !compilerCallers.has(c)).sort();
516
+ }
517
+
518
+ /**
519
+ * who-calls envelope: compiler callers + labelled name-matched callers. A
520
+ * name-matched caller leaves coverage.unresolvedCallSites (it is accounted for).
521
+ */
522
+ function whoCallsResult(index, funcId, baseCoverage, identity) {
523
+ const compiler = index.callGraph.get(funcId) || index.callGraph.get(funcId.replace(/@\d+$/, "")) || new Set();
524
+ const matched = nameMatchedCallersOf(index, funcId, compiler);
525
+ const results = Array.from(compiler).concat(matched).sort();
526
+ const coverage = withUnresolvedSites(baseCoverage, index, identity, matched);
527
+ if (!matched.length) return { results, tier: index.tier, coverage };
528
+ const out = { results, tier: index.tier, coverage };
529
+ out.nameMatched = {
530
+ resolution: "name-matched",
531
+ note: "unresolved call sites naming the only function in the repo with this name, in files SCIP never resolved — name-matched, not compiler-resolved",
532
+ count: matched.length,
533
+ callers: matched,
534
+ };
535
+ return out;
536
+ }
537
+
432
538
  // ─── Load store + build index (fail-loud on any failure) ─────────────────────
433
539
 
434
540
  /**
@@ -465,9 +571,12 @@ function loadSqliteStore(dbPath) {
465
571
  // M98: end_line is a post-M98 column; a pre-M98 graph (read-only here, so the
466
572
  // write-path migration can't run) lacks it. Detect and select conditionally so
467
573
  // an older index still loads (end_line just comes back null → body re-indexes).
468
- const hasEndLine = db.prepare("PRAGMA table_info(nodes)").all().some((c) => c.name === "end_line");
574
+ const nodeCols = db.prepare("PRAGMA table_info(nodes)").all().map((c) => c.name);
575
+ const hasEndLine = nodeCols.includes("end_line");
576
+ // meta (table columns / enum values) is newer still — same conditional select.
577
+ const hasMeta = nodeCols.includes("meta");
469
578
  const nodes = db.prepare(
470
- `SELECT id, kind, tier, file, name, func_id, ${hasEndLine ? "end_line" : "NULL AS end_line"} FROM nodes`
579
+ `SELECT id, kind, tier, file, name, func_id, ${hasEndLine ? "end_line" : "NULL AS end_line"}, ${hasMeta ? "meta" : "NULL AS meta"} FROM nodes`
471
580
  ).all();
472
581
  const edges = db.prepare("SELECT kind, src, dst FROM edges").all();
473
582
 
@@ -582,7 +691,12 @@ function loadSqliteStore(dbPath) {
582
691
  return byFile.get(file);
583
692
  };
584
693
  for (const n of nodes) {
585
- if (n.func_id) rec(n.file).entities.push({ funcId: n.func_id, name: n.name, file: n.file, tier: n.tier, endLine: n.end_line });
694
+ if (n.func_id) {
695
+ rec(n.file).entities.push({
696
+ funcId: n.func_id, name: n.name, file: n.file, tier: n.tier, endLine: n.end_line, kind: n.kind,
697
+ ...(n.meta === null ? {} : { meta: JSON.parse(n.meta) }),
698
+ });
699
+ }
586
700
  }
587
701
  // M114 — carry each file's TIER onto its record. Without this the record is
588
702
  // {file, entities, edges} with no tier, so the query layer cannot tell a
@@ -595,6 +709,13 @@ function loadSqliteStore(dbPath) {
595
709
  if (n.tier && n.tier !== 'compiler-accurate') r.tier = n.tier;
596
710
  else if (!r.tier) r.tier = n.tier || 'compiler-accurate';
597
711
  }
712
+ // The files table is where a SCIP-MISSING label lives even for a file that
713
+ // declares no function — so `graph status` can list every one of them.
714
+ if (hasFilesTable) {
715
+ for (const r of db.prepare("SELECT file, tier FROM files WHERE tier IN (?, ?)").all(SCIP_MISSING_TIER, COMPILER_PARTIAL_TIER)) {
716
+ rec(norm(r.file)).tier = r.tier;
717
+ }
718
+ }
598
719
  for (const e of edges) {
599
720
  // src for an IMPORT edge is the source FILE; for a CALL edge it's a funcId
600
721
  // (file#fn@line). The owning file record is the src's file part.
@@ -602,6 +723,21 @@ function loadSqliteStore(dbPath) {
602
723
  const dst = e.kind === "IMPORT" ? resolveDst(srcFile, e.dst) : e.dst;
603
724
  rec(srcFile).edges.push({ kind: e.kind, src: e.src, dst });
604
725
  }
726
+ // A file with call edges but no function node (top-level code only) got no
727
+ // tier from the node pass. Name matching needs to know whether SCIP looked
728
+ // at it, so take its tier from the files table. [RULE] name-match-only-where-scip-never-looked
729
+ //
730
+ // Every file in the files table is a record, even one with no function and
731
+ // no edge (a types-only or constants-only file). Building records only from
732
+ // nodes/edges left those out, so `graph status` reported 4,301 files right
733
+ // after an index of 4,373 (hilo-figma-atos). [RULE] status-counts-files-table
734
+ if (hasFilesTable) {
735
+ for (const r of db.prepare("SELECT file, tier FROM files").all()) {
736
+ const f = norm(r.file);
737
+ const fr = rec(f);
738
+ if (r.tier && !fr.tier) fr.tier = r.tier;
739
+ }
740
+ }
605
741
  db.close();
606
742
  return { records: Array.from(byFile.values()) };
607
743
  } catch (_e) {
@@ -708,7 +844,8 @@ function runFreshnessCheck(storePath) {
708
844
  };
709
845
  } catch (_e) {
710
846
  // Parse/corrupt/other freshness error → BROKEN, carry the cause code.
711
- return { ok: false, reason: "graph-broken", detail: _e && _e.code ? _e.code : "freshness-failed" };
847
+ // Carry the message: "package.json is not valid JSON" must reach the user, not "freshness-failed".
848
+ return { ok: false, reason: "graph-broken", detail: _e && _e.code ? _e.code : ("freshness-failed: " + (_e && _e.message ? _e.message : String(_e))) };
712
849
  }
713
850
  }
714
851
 
@@ -733,6 +870,42 @@ function queryWhoImports(index, target) {
733
870
  return { results, tier: index.tier, coverage };
734
871
  }
735
872
 
873
+ // ─── Unresolved call sites that name the target ──────────────────────────────
874
+ //
875
+ // When coverage is incomplete, the calls the graph could not resolve are still
876
+ // in it — as `UNRESOLVED#<name>` edges from a known caller. They are NOT results
877
+ // (a name match is not a resolved call), so they are reported inside `coverage`,
878
+ // labelled, with the files to open. That turns "[] and incomplete" into a named
879
+ // place to look. [RULE] incomplete-empty-answer-names-a-path-forward
880
+
881
+ function unresolvedCallSitesFor(index, identity, accounted) {
882
+ const name = identity.split("#").pop().replace(/@\d+$/, "");
883
+ if (!name) return null;
884
+ const exact = UNRESOLVED_PREFIX + name;
885
+ const member = "." + name;
886
+ const callers = new Set();
887
+ for (const { src, dst } of index.forwardCallEdges) {
888
+ if (dst === exact || (dst.startsWith(UNRESOLVED_PREFIX) && dst.endsWith(member))) callers.add(src);
889
+ }
890
+ // A caller already reported as name-matched is accounted for, not unknown.
891
+ if (accounted) for (const c of accounted) callers.delete(c);
892
+ if (callers.size === 0) return null;
893
+ const sorted = Array.from(callers).sort();
894
+ const files = Array.from(new Set(sorted.map((c) => c.split("#")[0]))).sort();
895
+ return {
896
+ count: sorted.length,
897
+ note: "name-match only — these callers call something named '" + name + "' that the graph could not resolve; open these files to confirm",
898
+ callers: sorted.slice(0, 100),
899
+ files: files.slice(0, 50),
900
+ };
901
+ }
902
+
903
+ function withUnresolvedSites(coverage, index, identity, accounted) {
904
+ if (!coverage || coverage.complete !== false) return coverage;
905
+ const sites = unresolvedCallSitesFor(index, identity, accounted);
906
+ return sites ? { ...coverage, unresolvedCallSites: sites } : coverage;
907
+ }
908
+
736
909
  // ─── Query: who-calls ─────────────────────────────────────────────────────────
737
910
 
738
911
  /**
@@ -755,14 +928,13 @@ function queryWhoImports(index, target) {
755
928
  */
756
929
  function queryWhoCalls(index, identity) {
757
930
  const isFuncId = identity.includes("#");
758
- const coverage = computeCoverage(index.skippedFiles, { callEdgesUnresolved: true, unresolvedFiles: countUnresolvedFiles(index) });
931
+ const baseCoverage = computeCoverage(index.skippedFiles, { callEdgesUnresolved: true, unresolvedFiles: countUnresolvedFiles(index) });
932
+ const coverage = withUnresolvedSites(baseCoverage, index, identity);
759
933
 
760
934
  if (isFuncId) {
761
935
  // File-qualified identity — exact funcId lookup (tolerate @line suffix:
762
936
  // callGraph keys on `file#name`, callers may pass `file#name@line`).
763
- const callers = index.callGraph.get(identity) || index.callGraph.get(identity.replace(/@\d+$/, ''));
764
- const results = callers ? Array.from(callers).sort() : [];
765
- return { results, tier: index.tier, coverage };
937
+ return whoCallsResult(index, identity, baseCoverage, identity);
766
938
  }
767
939
 
768
940
  // Bare name — disambiguate against all funcIds
@@ -775,7 +947,10 @@ function queryWhoCalls(index, identity) {
775
947
  }
776
948
 
777
949
  if (matchingFuncIds.length === 0) {
778
- return { results: [], tier: index.tier, coverage };
950
+ const out = { results: [], tier: index.tier, coverage };
951
+ const hint = tableHint(index, bareName);
952
+ if (hint) out.hint = hint;
953
+ return out;
779
954
  }
780
955
 
781
956
  if (matchingFuncIds.length === 1) {
@@ -783,17 +958,23 @@ function queryWhoCalls(index, identity) {
783
958
  // funcEntities key as `file#name@line`, but call edges (and thus callGraph)
784
959
  // key as `file#name` (no @line) — try both so the @line-suffix difference
785
960
  // doesn't drop real callers. [RULE] who-calls-funcid-line-suffix-tolerant
786
- const fid = matchingFuncIds[0];
787
- const fidNoLine = fid.replace(/@\d+$/, '');
788
- const callers = index.callGraph.get(fid) || index.callGraph.get(fidNoLine);
789
- const results = callers ? Array.from(callers).sort() : [];
790
- return { results, tier: index.tier, coverage };
961
+ return whoCallsResult(index, matchingFuncIds[0], baseCoverage, identity);
791
962
  }
792
963
 
793
964
  // Multiple matches — ambiguous, NEVER merge
794
965
  return { ambiguous: true, candidates: matchingFuncIds.sort() };
795
966
  }
796
967
 
968
+ /** A who-calls on a table name (or a table builder) is a table question — say which verbs answer it. */
969
+ function tableHint(index, name) {
970
+ if (name === "pgTable" || name === "mysqlTable" || name === "sqliteTable" || name === "pgEnum") {
971
+ return `${name} declares database tables — the graph records each one: gsd-t graph table <name>, gsd-t graph who-uses <name>`;
972
+ }
973
+ const r = resolveTable(index, name);
974
+ if (r.notFound) return null;
975
+ return `'${name}' is a database ${r.table ? r.table.kind : "table"} — ask: gsd-t graph who-uses ${name} | gsd-t graph table ${name} | gsd-t graph blast-radius ${name}`;
976
+ }
977
+
797
978
  // ─── Query: body (M98) ────────────────────────────────────────────────────────
798
979
 
799
980
  /**
@@ -845,7 +1026,13 @@ function queryBody(index, identity, projectRoot) {
845
1026
  else if (matches.length > 1) return { ambiguous: true, candidates: matches.sort() };
846
1027
  }
847
1028
 
848
- if (!funcId) return { notFound: true };
1029
+ if (!funcId) {
1030
+ // Not a function — a database table or enum? [RULE] drizzle-table-entity-and-usage-edges
1031
+ const tr = resolveTable(index, identity);
1032
+ if (tr.ambiguous) return tr;
1033
+ if (!tr.table) return { notFound: true };
1034
+ return tableBody(index, tr.table, projectRoot);
1035
+ }
849
1036
 
850
1037
  const meta = index.funcEntities.get(funcId);
851
1038
  // Start line is encoded in the funcId suffix `@<line>`; end_line from the node.
@@ -904,6 +1091,27 @@ function queryBody(index, identity, projectRoot) {
904
1091
  };
905
1092
  }
906
1093
 
1094
+ /** body of a table: its declaration sliced live from disk + columns + FKs both ways. */
1095
+ function tableBody(index, t, projectRoot) {
1096
+ const startLine = parseInt(/@(\d+)$/.exec(t.id)[1], 10);
1097
+ const absFile = path.isAbsolute(t.file) ? t.file : path.join(projectRoot, t.file);
1098
+ let lines;
1099
+ try { lines = fs.readFileSync(absFile, "utf8").split("\n"); }
1100
+ catch (_e) { return { notFound: true, file: t.file }; }
1101
+ return {
1102
+ ok: true,
1103
+ funcId: t.id,
1104
+ file: t.file,
1105
+ lineRange: [startLine, t.endLine],
1106
+ tier: t.tier,
1107
+ imports: [],
1108
+ classHeader: null,
1109
+ source: lines.slice(startLine - 1, t.endLine).join("\n"),
1110
+ callers: [],
1111
+ table: tableDetail(index, t),
1112
+ };
1113
+ }
1114
+
907
1115
  // ─── Query: blast-radius ──────────────────────────────────────────────────────
908
1116
 
909
1117
  /**
@@ -939,6 +1147,12 @@ function queryBody(index, identity, projectRoot) {
939
1147
  * @returns {{ results: string[], tier: string }}
940
1148
  */
941
1149
  function queryBlastRadius(index, target) {
1150
+ // A database table (by code name, SQL name, or id) — not a file, not a function.
1151
+ if (!index.allFiles.has(target) && !index.funcEntities.has(target)) {
1152
+ const tr = resolveTable(index, target);
1153
+ if (tr.table) return queryTableBlastRadius(index, tr.table);
1154
+ if (tr.ambiguous) return tr;
1155
+ }
942
1156
  const isFilePath = !target.includes("#");
943
1157
 
944
1158
  // Build the initial frontier (multi-root if file-path: include owned funcIds)
@@ -951,12 +1165,17 @@ function queryBlastRadius(index, target) {
951
1165
  for (const [funcId, meta] of index.funcEntities) {
952
1166
  if (meta.file === target) {
953
1167
  initialFrontier.add(funcId);
1168
+ // SCIP-resolved call edges key on `file#name` (no @line) — seed that
1169
+ // form too, or a file's callers never enter the radius.
1170
+ // [RULE] who-calls-funcid-line-suffix-tolerant
1171
+ initialFrontier.add(funcId.replace(/@\d+$/, ""));
954
1172
  }
955
1173
  }
956
1174
  }
957
1175
 
958
1176
  // BFS over the UNION of reverse import + call edges, transitive closure
959
1177
  const visited = new Set();
1178
+ const nameMatchedReached = new Set();
960
1179
  const queue = Array.from(initialFrontier);
961
1180
 
962
1181
  while (queue.length > 0) {
@@ -972,10 +1191,13 @@ function queryBlastRadius(index, target) {
972
1191
  }
973
1192
  }
974
1193
 
975
- // Reverse call edges: who calls this function node?
976
- const callers = index.callGraph.get(node);
977
- if (callers) {
1194
+ // Reverse call edges: who calls this function node? (compiler + name-matched;
1195
+ // name-matched ones are listed separately below) [RULE] unique-name-unresolved-call-name-matched
1196
+ for (const graph of [index.callGraph, index.nameMatchedCallGraph]) {
1197
+ const callers = graph && graph.get(node);
1198
+ if (!callers) continue;
978
1199
  for (const caller of callers) {
1200
+ if (graph !== index.callGraph && !(index.callGraph.get(node) || new Set()).has(caller)) nameMatchedReached.add(caller);
979
1201
  if (!visited.has(caller)) queue.push(caller);
980
1202
  }
981
1203
  }
@@ -988,8 +1210,180 @@ function queryBlastRadius(index, target) {
988
1210
  }
989
1211
 
990
1212
  const results = Array.from(visited).sort();
991
- const coverage = computeCoverage(index.skippedFiles, { callEdgesUnresolved: true, unresolvedFiles: countUnresolvedFiles(index) });
992
- return { results, tier: index.tier, coverage };
1213
+ const matched = Array.from(nameMatchedReached).filter((c) => visited.has(c)).sort();
1214
+ const coverage = withUnresolvedSites(
1215
+ computeCoverage(index.skippedFiles, { callEdgesUnresolved: true, unresolvedFiles: countUnresolvedFiles(index) }),
1216
+ index, target, matched);
1217
+ if (!matched.length) return { results, tier: index.tier, coverage };
1218
+ return {
1219
+ results, tier: index.tier, coverage,
1220
+ nameMatched: { resolution: "name-matched", count: matched.length, callers: matched.slice(0, 100) },
1221
+ };
1222
+ }
1223
+
1224
+ // ─── Database tables (Drizzle) ────────────────────────────────────────────────
1225
+ //
1226
+ // Verbs: `table <name>`, `who-uses <table> [--writes|--reads]`, and table mode of
1227
+ // `blast-radius` and `body`. A table is found by its code name (scheduleEvents)
1228
+ // or its SQL name (schedule_events, case-insensitive — a domain value).
1229
+ // Uses are matched by the table's exported name as written at the use site
1230
+ // (syntactic, not compiler-resolved): an import alias is followed, a table passed
1231
+ // through a function parameter is not. [RULE] drizzle-table-entity-and-usage-edges
1232
+ // [RULE] table-not-indexed-distinct-from-no-users
1233
+
1234
+ /** → { table: {id, ...meta} } | { ambiguous, candidates } | { notFound } */
1235
+ function resolveTable(index, target) {
1236
+ if (!index.tableEntities) return { notFound: true };
1237
+ if (target.includes("#")) {
1238
+ const noLine = target.replace(/@\d+$/, "");
1239
+ for (const [id, t] of index.tableEntities) {
1240
+ if (id === target || id.replace(/@\d+$/, "") === noLine) return { table: { id, ...t } };
1241
+ }
1242
+ return { notFound: true };
1243
+ }
1244
+ const byCode = [];
1245
+ const bySql = [];
1246
+ const lower = target.toLowerCase();
1247
+ for (const [id, t] of index.tableEntities) {
1248
+ if (t.name === target) byCode.push(id);
1249
+ else if (typeof t.meta.sqlName === "string" && t.meta.sqlName.toLowerCase() === lower) bySql.push(id);
1250
+ }
1251
+ const ids = byCode.length ? byCode : bySql;
1252
+ if (ids.length === 0) return { notFound: true };
1253
+ if (ids.length > 1) return { ambiguous: true, candidates: ids.sort() };
1254
+ return { table: { id: ids[0], ...index.tableEntities.get(ids[0]) } };
1255
+ }
1256
+
1257
+ function notIndexedDetail(index, target) {
1258
+ const n = index.tableEntities ? index.tableEntities.size : 0;
1259
+ return `no table or enum named '${target}' is indexed (${n} tables/enums in the graph, found by code name or SQL name) — ` +
1260
+ `check the name; if it was declared since the last build, run: gsd-t graph index`;
1261
+ }
1262
+
1263
+ /** Table ids declared under a code name (FK targets are written as code names). */
1264
+ function tableIdsNamed(index, name) {
1265
+ const ids = [];
1266
+ for (const [id, t] of index.tableEntities) if (t.name === name) ids.push(id);
1267
+ return ids.sort();
1268
+ }
1269
+
1270
+ /** Foreign keys out of `t` (column-level references + table-level foreignKey()). */
1271
+ function foreignKeysOut(index, t) {
1272
+ const out = [];
1273
+ for (const c of t.meta.columns ? t.meta.columns : []) {
1274
+ if (!c.references) continue;
1275
+ out.push({ column: c.name, sqlColumn: c.sqlName, table: c.references.table, targetColumn: c.references.column, tableIds: tableIdsNamed(index, c.references.table) });
1276
+ }
1277
+ for (const fk of t.meta.foreignKeys ? t.meta.foreignKeys : []) {
1278
+ out.push({ column: fk.columns.join(", "), table: fk.table, targetColumn: fk.foreignColumns.join(", "), tableIds: tableIdsNamed(index, fk.table) });
1279
+ }
1280
+ return out;
1281
+ }
1282
+
1283
+ /** Foreign keys INTO `t` from every other indexed table. */
1284
+ function foreignKeysIn(index, t) {
1285
+ const refs = [];
1286
+ for (const [id, other] of index.tableEntities) {
1287
+ if (other.kind !== "table") continue;
1288
+ for (const fk of foreignKeysOut(index, other)) {
1289
+ if (fk.table === t.name) refs.push({ table: other.name, tableId: id, column: fk.column, sqlColumn: fk.sqlColumn, targetColumn: fk.targetColumn });
1290
+ }
1291
+ }
1292
+ return refs.sort((a, b) => (a.tableId + a.column < b.tableId + b.column ? -1 : 1));
1293
+ }
1294
+
1295
+ function tableDetail(index, t) {
1296
+ const detail = {
1297
+ id: t.id, kind: t.kind, name: t.name, sqlName: t.meta.sqlName, dialect: t.meta.dialect, file: t.file,
1298
+ };
1299
+ if (t.kind === "enum") {
1300
+ detail.values = t.meta.values;
1301
+ // Tables whose columns are built from this enum (`statusEnum('status')`).
1302
+ detail.usedByColumns = [];
1303
+ for (const [id, other] of index.tableEntities) {
1304
+ for (const c of other.meta.columns ? other.meta.columns : []) {
1305
+ if (c.type === t.name) detail.usedByColumns.push({ table: other.name, tableId: id, column: c.name, sqlColumn: c.sqlName });
1306
+ }
1307
+ }
1308
+ } else {
1309
+ detail.columns = t.meta.columns;
1310
+ detail.references = foreignKeysOut(index, t);
1311
+ detail.referencedBy = foreignKeysIn(index, t);
1312
+ }
1313
+ if (t.meta.unresolved) detail.unresolved = t.meta.unresolved;
1314
+ return detail;
1315
+ }
1316
+
1317
+ /** `table <name>` → columns + foreign keys in both directions. */
1318
+ function queryTable(index, target) {
1319
+ const r = resolveTable(index, target);
1320
+ if (!r.table) return r.ambiguous ? r : { notFound: true, detail: notIndexedDetail(index, target) };
1321
+ return { table: tableDetail(index, r.table), tier: index.tier };
1322
+ }
1323
+
1324
+ /** `who-uses <table> [--writes|--reads]` → the functions / methods / route handlers that use it. */
1325
+ function queryWhoUses(index, target, options) {
1326
+ const mode = options && options.mode ? options.mode : "all";
1327
+ const r = resolveTable(index, target);
1328
+ if (!r.table) return r.ambiguous ? r : { notFound: true, detail: notIndexedDetail(index, target) };
1329
+ const t = r.table;
1330
+ const all = index.tableUses.has(t.name) ? index.tableUses.get(t.name) : [];
1331
+ const uses = all.filter((u) => mode === "all" || (mode === "writes" ? u.access === "WRITE" : u.access === "READ"));
1332
+ const byUser = new Map();
1333
+ const byOperation = {};
1334
+ for (const u of uses) {
1335
+ if (!byUser.has(u.src)) byUser.set(u.src, { user: u.src, file: u.src.split("#")[0], access: [], operations: {} });
1336
+ const row = byUser.get(u.src);
1337
+ if (!row.access.includes(u.access)) row.access.push(u.access);
1338
+ if (!row.operations[u.op]) row.operations[u.op] = [];
1339
+ row.operations[u.op].push(u.line);
1340
+ byOperation[u.op] = (byOperation[u.op] ? byOperation[u.op] : 0) + 1;
1341
+ }
1342
+ const results = Array.from(byUser.values()).sort((a, b) => (a.user < b.user ? -1 : 1));
1343
+ for (const row of results) row.access.sort();
1344
+ const out = {
1345
+ table: { id: t.id, name: t.name, sqlName: t.meta.sqlName, kind: t.kind },
1346
+ mode,
1347
+ results,
1348
+ summary: { users: results.length, files: new Set(results.map((x) => x.file)).size, byOperation },
1349
+ resolution: "syntactic — uses matched by the table's imported name; a table passed through a function parameter or alias() is not followed",
1350
+ coverage: computeCoverage(index.skippedFiles),
1351
+ tier: index.tier,
1352
+ };
1353
+ const sharing = tableIdsNamed(index, t.name);
1354
+ if (sharing.length > 1) out.sharedName = { note: `${sharing.length} tables share the code name '${t.name}' — uses cannot be split between them`, tables: sharing };
1355
+ if (results.length === 0) {
1356
+ out.note = mode === "all"
1357
+ ? `'${t.name}' is indexed; no code in the index uses it`
1358
+ : `'${t.name}' is indexed; no code in the index ${mode === "writes" ? "writes" : "reads"} it`;
1359
+ }
1360
+ return out;
1361
+ }
1362
+
1363
+ /** blast-radius of a table: tables that reference it by FK (transitively) + the code that uses it. */
1364
+ function queryTableBlastRadius(index, t) {
1365
+ const tables = [];
1366
+ const seen = new Set([t.id]);
1367
+ const queue = [{ table: t, depth: 1 }];
1368
+ while (queue.length) {
1369
+ const { table, depth } = queue.shift();
1370
+ for (const ref of foreignKeysIn(index, table)) {
1371
+ if (seen.has(ref.tableId)) continue;
1372
+ seen.add(ref.tableId);
1373
+ tables.push({ id: ref.tableId, via: `${ref.table}.${ref.column} → ${table.name}.${ref.targetColumn}`, depth });
1374
+ queue.push({ table: { id: ref.tableId, ...index.tableEntities.get(ref.tableId) }, depth: depth + 1 });
1375
+ }
1376
+ }
1377
+ const users = (index.tableUses.has(t.name) ? index.tableUses.get(t.name) : []).map((u) => u.src);
1378
+ const code = Array.from(new Set(users)).sort();
1379
+ return {
1380
+ results: tables.map((x) => x.id).concat(code),
1381
+ table: { id: t.id, name: t.name, sqlName: t.meta.sqlName },
1382
+ referencingTables: tables,
1383
+ users: code,
1384
+ tier: index.tier,
1385
+ coverage: computeCoverage(index.skippedFiles),
1386
+ };
993
1387
  }
994
1388
 
995
1389
  // ─── Query: status ────────────────────────────────────────────────────────────
@@ -1004,17 +1398,71 @@ function queryBlastRadius(index, target) {
1004
1398
  * @returns {object}
1005
1399
  */
1006
1400
  function queryStatus(index, storePath) {
1401
+ // [RULE] scip-missing-file-detected-never-silent — status names them.
1402
+ const scipMissing = [];
1403
+ for (const [file, tier] of index.fileTier || []) if (tier === SCIP_MISSING_TIER) scipMissing.push(file);
1404
+ scipMissing.sort();
1007
1405
  return {
1406
+ scipMissing: { count: scipMissing.length, files: scipMissing.slice(0, 50) },
1008
1407
  queryable: true,
1009
1408
  storePath,
1010
1409
  fileCount: index.allFiles.size,
1011
1410
  funcCount: index.funcEntities.size,
1012
1411
  importEdgeCount: Array.from(index.importGraph.values()).reduce((s, v) => s + v.size, 0),
1013
1412
  callEdgeCount: Array.from(index.callGraph.values()).reduce((s, v) => s + v.size, 0),
1413
+ // `tier` is the WORST tier present (one floor file makes it floor). The per-tier
1414
+ // counts are what a build reports, so status shows them too. [RULE] status-counts-files-table
1014
1415
  tier: index.tier,
1416
+ tiers: tierCounts(index),
1417
+ tableCount: countKind(index, "table"),
1418
+ enumCount: countKind(index, "enum"),
1419
+ excludeSuggestions: suggestExcludes(index),
1015
1420
  };
1016
1421
  }
1017
1422
 
1423
+ function tierCounts(index) {
1424
+ const counts = {};
1425
+ for (const tier of (index.fileTier ? index.fileTier.values() : [])) counts[tier] = (counts[tier] ? counts[tier] : 0) + 1;
1426
+ return counts;
1427
+ }
1428
+
1429
+ function countKind(index, kind) {
1430
+ let n = 0;
1431
+ for (const t of (index.tableEntities ? index.tableEntities.values() : [])) if (t.kind === kind) n++;
1432
+ return n;
1433
+ }
1434
+
1435
+ /**
1436
+ * Top-level folders that look like they are not the application: no file in them
1437
+ * imports, or is imported by, a file in the project's largest folder (the app).
1438
+ * A SUGGESTION for .gsd-t/graph-exclude.json — never applied automatically, since
1439
+ * guessing wrong would silently drop app code from the graph.
1440
+ */
1441
+ function suggestExcludes(index) {
1442
+ const top = (f) => (f.includes("/") ? f.split("/")[0] : null);
1443
+ const sizes = new Map();
1444
+ for (const f of index.allFiles) {
1445
+ const t = top(f);
1446
+ if (t !== null) sizes.set(t, (sizes.has(t) ? sizes.get(t) : 0) + 1);
1447
+ }
1448
+ if (sizes.size < 2) return [];
1449
+ const main = Array.from(sizes.entries()).sort((a, b) => b[1] - a[1])[0][0];
1450
+ const linked = new Set([main]);
1451
+ for (const [dst, srcs] of index.importGraph) {
1452
+ if (!index.allFiles.has(dst)) continue;
1453
+ const d = top(dst);
1454
+ for (const src of srcs) {
1455
+ const sTop = top(src);
1456
+ if (d === main && sTop !== null) linked.add(sTop);
1457
+ if (sTop === main && d !== null) linked.add(d);
1458
+ }
1459
+ }
1460
+ return Array.from(sizes.entries())
1461
+ .filter(([folder]) => !linked.has(folder))
1462
+ .sort((a, b) => b[1] - a[1])
1463
+ .map(([folder, files]) => ({ folder: folder + "/", files, reason: `no import edges to or from ${main}/` }));
1464
+ }
1465
+
1018
1466
  // ─── D9-T1: Query: cluster (tightly-coupled file groups) ─────────────────────
1019
1467
  //
1020
1468
  // [RULE] cluster-verb-deterministic-coupling
@@ -1203,7 +1651,7 @@ function queryDeadCode(index) {
1203
1651
  // [RULE] orphan-tier-labeled-candidate-not-certainty:
1204
1652
  // Floor-tier results are CANDIDATE (a missed unresolved call could explain the absence).
1205
1653
  const tier = meta.tier || index.tier;
1206
- const isFloor = tier === "tree-sitter-floor" || tier === "tree-sitter-floor-STALE-SCIP";
1654
+ const isFloor = tier === "tree-sitter-floor" || tier === "tree-sitter-floor-STALE-SCIP" || tier === SCIP_MISSING_TIER;
1207
1655
  const candidateLabel = isFloor ? "CANDIDATE" : null;
1208
1656
 
1209
1657
  results.push({ funcId, file: meta.file, tier, candidateLabel });
@@ -1346,6 +1794,32 @@ function queryTestImpl(index, options) {
1346
1794
  return { results: untested, tier: index.tier, mode: "untested-impl" };
1347
1795
  }
1348
1796
 
1797
+ // ─── Incomplete-empty marker (read by the M117 search guard) ──────────────────
1798
+ //
1799
+ // The search guard runs BEFORE a grep and cannot see what the graph just said.
1800
+ // When who-calls / blast-radius answers [] with coverage incomplete, record it,
1801
+ // so the guard's block message can name a path forward (the files to open, the
1802
+ // re-index) instead of leaving none. A write failure is said on stderr.
1803
+ // [RULE] incomplete-empty-answer-names-a-path-forward
1804
+
1805
+ const INCOMPLETE_MARKER_NAME = "last-incomplete-answer.json";
1806
+
1807
+ function writeIncompleteAnswerMarker(projectRoot, verb, target, coverage) {
1808
+ const file = path.join(projectRoot, ".gsd-t", "graphDB", INCOMPLETE_MARKER_NAME);
1809
+ try {
1810
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1811
+ fs.writeFileSync(file, JSON.stringify({
1812
+ ts: new Date().toISOString(),
1813
+ verb,
1814
+ target,
1815
+ note: coverage.note || null,
1816
+ unresolvedCallSites: coverage.unresolvedCallSites || null,
1817
+ }, null, 2));
1818
+ } catch (e) {
1819
+ process.stderr.write("[graph] could not record the incomplete answer for the search guard (" + e.message + ")\n");
1820
+ }
1821
+ }
1822
+
1349
1823
  // ─── Public API (for tests to import directly) ────────────────────────────────
1350
1824
  // Tests build an index from fixture records, then call these pure functions.
1351
1825
  // The query CLI is the only caller of runFreshnessCheck (integration seam).
@@ -1368,6 +1842,10 @@ module.exports = {
1368
1842
  queryBody,
1369
1843
  queryBlastRadius,
1370
1844
  queryStatus,
1845
+ queryTable,
1846
+ queryWhoUses,
1847
+ resolveTable,
1848
+ suggestExcludes,
1371
1849
  // D9 additions
1372
1850
  queryCluster,
1373
1851
  queryDeadCode,
@@ -1384,6 +1862,10 @@ module.exports = {
1384
1862
  COUPLING_THRESHOLD,
1385
1863
  DEFAULT_TEST_PATTERNS,
1386
1864
  UNRESOLVED_PREFIX,
1865
+ SCIP_MISSING_TIER,
1866
+ unresolvedCallSitesFor,
1867
+ writeIncompleteAnswerMarker,
1868
+ INCOMPLETE_MARKER_NAME,
1387
1869
  getTestPatterns,
1388
1870
  isTestFile,
1389
1871
  };
@@ -1459,6 +1941,7 @@ if (require.main === module) {
1459
1941
  const ALL_VERBS = [
1460
1942
  "who-imports", "who-calls", "body", "blast-radius", "status",
1461
1943
  "cluster", "dead-code", "orphan", "dangling", "test-impl",
1944
+ "who-uses", "table",
1462
1945
  ];
1463
1946
 
1464
1947
  if (!verb) {
@@ -1522,7 +2005,13 @@ if (require.main === module) {
1522
2005
  });
1523
2006
  process.exit(2);
1524
2007
  }
1525
- emit({ ok: true, verb, target, results: queryResult.results, tier: queryResult.tier, coverage: queryResult.coverage });
2008
+ if (queryResult.results.length === 0 && queryResult.coverage && queryResult.coverage.complete === false) {
2009
+ writeIncompleteAnswerMarker(_resolver.deriveProjectRoot(storePath), verb, target, queryResult.coverage);
2010
+ }
2011
+ const env = { ok: true, verb, target, results: queryResult.results, tier: queryResult.tier, coverage: queryResult.coverage };
2012
+ if (queryResult.nameMatched) env.nameMatched = queryResult.nameMatched; // [RULE] unique-name-unresolved-call-name-matched
2013
+ if (queryResult.hint) env.hint = queryResult.hint; // a table name asked of who-calls
2014
+ emit(env);
1526
2015
 
1527
2016
  } else if (verb === "body") {
1528
2017
  if (!target) fail({ ok: false, reason: "missing-target", verb });
@@ -1572,16 +2061,46 @@ if (require.main === module) {
1572
2061
  lineRange: bodyResult.lineRange, tier: bodyResult.tier,
1573
2062
  imports: bodyResult.imports, classHeader: bodyResult.classHeader,
1574
2063
  source: bodyResult.source, callers: bodyResult.callers,
2064
+ ...(bodyResult.table ? { table: bodyResult.table } : {}),
1575
2065
  });
1576
2066
 
1577
2067
  } else if (verb === "blast-radius") {
1578
2068
  if (!target) fail({ ok: false, reason: "missing-target", verb });
1579
- const { results, tier, coverage } = queryBlastRadius(index, target);
1580
- emit({ ok: true, verb, target, results, tier, coverage });
2069
+ const br = queryBlastRadius(index, target);
2070
+ if (br.ambiguous) {
2071
+ emit({ ok: false, reason: "ambiguous-table", verb, target, candidates: br.candidates });
2072
+ process.exit(2);
2073
+ }
2074
+ if (br.table) {
2075
+ emit({ ok: true, verb, target, results: br.results, table: br.table, referencingTables: br.referencingTables, users: br.users, tier: br.tier, coverage: br.coverage });
2076
+ process.exit(0);
2077
+ }
2078
+ const { results, tier, coverage, nameMatched } = br;
2079
+ if (results.length === 0 && coverage && coverage.complete === false) {
2080
+ writeIncompleteAnswerMarker(_resolver.deriveProjectRoot(storePath), verb, target, coverage);
2081
+ }
2082
+ const env = { ok: true, verb, target, results, tier, coverage };
2083
+ if (nameMatched) env.nameMatched = nameMatched; // [RULE] unique-name-unresolved-call-name-matched
2084
+ emit(env);
1581
2085
 
1582
2086
  } else if (verb === "status") {
1583
2087
  const statusData = queryStatus(index, storePath);
1584
- emit({ ok: true, verb: "status", ...statusData });
2088
+ // Name the project exclude list so a missing folder is never a mystery.
2089
+ const ex = require("./gsd-t-graph-exclude.cjs").loadGraphExcludes(_resolver.deriveProjectRoot(storePath));
2090
+ emit({ ok: true, verb: "status", ...statusData, excludes: { source: ex.source, patterns: ex.patterns, defaults: ex.defaults } });
2091
+
2092
+ } else if (verb === "who-uses" || verb === "table") {
2093
+ // [RULE] table-not-indexed-distinct-from-no-users — not-found (reason + detail)
2094
+ // is a different envelope from an indexed table with no users (ok, results []).
2095
+ if (!target) fail({ ok: false, reason: "missing-target", verb });
2096
+ const mode = args.includes("--writes") ? "writes" : (args.includes("--reads") ? "reads" : "all");
2097
+ const r = verb === "table" ? queryTable(index, target) : queryWhoUses(index, target, { mode });
2098
+ if (r.ambiguous) {
2099
+ emit({ ok: false, reason: "ambiguous-table", verb, target, candidates: r.candidates });
2100
+ process.exit(2);
2101
+ }
2102
+ if (r.notFound) fail({ ok: false, reason: "not-found", verb, target, detail: r.detail });
2103
+ emit({ ok: true, verb, target, ...r });
1585
2104
 
1586
2105
  } else if (verb === "cluster") {
1587
2106
  const { results, tier } = queryCluster(index);