@tekyzinc/gsd-t 5.22.10 → 5.23.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.
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ /**
3
+ * gsd-t-graph-exclude.cjs — per-project exclude list for the code graph.
4
+ *
5
+ * Some repos carry large trees that are not the production application —
6
+ * design exports, Figma-generated component dumps, prototypes, archived
7
+ * scripts. They bloat the index, slow SCIP, and put look-alike symbols into
8
+ * who-calls. A project lists them in `.gsd-t/graph-exclude.json`:
9
+ *
10
+ * { "exclude": ["design/", "figma-exports/**", "scripts/archive/", "**\/*.figma.tsx"] }
11
+ *
12
+ * Pattern rules (paths are repo-relative, forward slashes, matched case-insensitively):
13
+ * - no `*` → a path prefix: "design/" or "design" excludes that folder
14
+ * - `*` → any run of characters except "/"
15
+ * - `**` → any run of characters including "/"
16
+ *
17
+ * The indexer AND the freshness walker both read this module, so a file the
18
+ * indexer skipped never shows up as a phantom "new file" on the next query.
19
+ * [RULE] freshness-excludes-match-indexer-skipdirs
20
+ *
21
+ * A malformed file HALTS (throws): silently ignoring it would index exactly the
22
+ * trees the user asked to keep out, with nothing saying so.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const EXCLUDE_FILE = path.join('.gsd-t', 'graph-exclude.json');
29
+ const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g;
30
+
31
+ function toRegex(pattern) {
32
+ const p = String(pattern).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
33
+ if (!p) return null;
34
+ if (!p.includes('*')) {
35
+ // Plain prefix: the path itself, or anything under it.
36
+ const base = p.replace(/\/+$/, '').replace(REGEX_SPECIALS, '\\$&');
37
+ return new RegExp(`^${base}(/|$)`, 'i');
38
+ }
39
+ let re = '';
40
+ for (let i = 0; i < p.length; i++) {
41
+ const c = p[i];
42
+ if (c === '*' && p[i + 1] === '*') {
43
+ i++;
44
+ if (p[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
45
+ } else if (c === '*') re += '[^/]*';
46
+ else re += c.replace(REGEX_SPECIALS, '\\$&');
47
+ }
48
+ return new RegExp(`^${re}(/|$)`, 'i');
49
+ }
50
+
51
+ function validateList(parsed) {
52
+ const list = parsed ? parsed.exclude : undefined;
53
+ if (!Array.isArray(list)) return false;
54
+ return list.every((x) => typeof x === 'string');
55
+ }
56
+
57
+ /**
58
+ * Load the project's exclude list. Throws on a malformed file.
59
+ * @returns {{ patterns: string[], source: string|null, isExcluded: (relPath: string) => boolean }}
60
+ */
61
+ function loadGraphExcludes(projectRoot) {
62
+ const file = path.join(projectRoot, EXCLUDE_FILE);
63
+ if (!fs.existsSync(file)) return { patterns: [], source: null, isExcluded: () => false };
64
+ let parsed;
65
+ try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
66
+ catch (e) { throw new Error(`${EXCLUDE_FILE} is not valid JSON (${e.message}) — fix it or delete it`); }
67
+ if (!validateList(parsed)) {
68
+ throw new Error(`${EXCLUDE_FILE} must be { "exclude": ["folder/", "glob/**", ...] }`);
69
+ }
70
+ const regexes = parsed.exclude.map(toRegex).filter(Boolean);
71
+ return {
72
+ patterns: parsed.exclude,
73
+ source: EXCLUDE_FILE,
74
+ isExcluded: (relPath) => {
75
+ const rel = String(relPath).split(path.sep).join('/');
76
+ return regexes.some((r) => r.test(rel));
77
+ },
78
+ };
79
+ }
80
+
81
+ module.exports = { loadGraphExcludes, EXCLUDE_FILE, _toRegex: toRegex };
@@ -220,7 +220,11 @@ function compute_touched_files(db, projectRoot) {
220
220
  }
221
221
 
222
222
  // Walk the current working tree
223
- const liveFiles = walkTree(projectRoot);
223
+ // Same exclude list as the indexer, so an excluded file is never a phantom ADD
224
+ // and a newly-excluded one drops out as a DELETE. [RULE] freshness-excludes-match-indexer-skipdirs
225
+ const { isExcluded } = require('./gsd-t-graph-exclude.cjs').loadGraphExcludes(projectRoot);
226
+ const liveFiles = walkTree(projectRoot)
227
+ .filter((abs) => !isExcluded(path.relative(projectRoot, abs)));
224
228
 
225
229
  const liveRelSet = new Set();
226
230
  const edits = [];
@@ -196,18 +196,21 @@ function contentHash(absPath) {
196
196
 
197
197
  function enumerateFiles(root) {
198
198
  const results = [];
199
+ // Project exclude list (.gsd-t/graph-exclude.json) — ancillary trees such as
200
+ // design/Figma exports that are not the application. Throws on a malformed file.
201
+ const { isExcluded } = require('./gsd-t-graph-exclude.cjs').loadGraphExcludes(root);
199
202
  function walk(dir) {
200
203
  let entries;
201
204
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
202
205
  catch { return; }
203
206
  for (const e of entries) {
207
+ const absPath = path.join(dir, e.name);
208
+ const relPath = path.relative(root, absPath).split(path.sep).join('/');
204
209
  if (e.isDirectory()) {
205
- if (!shouldSkipDir(e.name)) walk(path.join(dir, e.name));
210
+ if (!shouldSkipDir(e.name) && !isExcluded(relPath)) walk(absPath);
206
211
  } else if (e.isFile()) {
207
212
  const ext = path.extname(e.name).toLowerCase();
208
- if (PARSED_EXTS.has(ext)) {
209
- const absPath = path.join(dir, e.name);
210
- const relPath = path.relative(root, absPath).split(path.sep).join('/');
213
+ if (PARSED_EXTS.has(ext) && !isExcluded(relPath)) {
211
214
  results.push({ absPath, relPath, ext });
212
215
  }
213
216
  }
@@ -404,12 +407,16 @@ function parse_and_put(absPath, relPath, options) {
404
407
  finalEntities = upgraded.entities;
405
408
  finalEdges = upgraded.edges;
406
409
  }
407
- } else if (existingTier === 'compiler-accurate') {
410
+ } else if (existingTier === 'compiler-accurate' || existingTier === 'compiler-partial') {
408
411
  // [RULE] reindex-tier-never-silently-downgraded — no SCIP context this call
409
412
  // (e.g. a metadata-only re-index like M98's body end-line backfill). A file that
410
413
  // WAS compiler-accurate must NOT silently drop to plain floor; label it STALE-SCIP
411
414
  // so the tier reflects "previously accurate, not re-resolved" rather than a lie.
412
415
  tier = 'tree-sitter-floor-STALE-SCIP';
416
+ } else if (existingTier === 'tree-sitter-floor-SCIP-MISSING') {
417
+ // Same principle: a file SCIP never indexed stays labelled as such until a
418
+ // full build (with SCIP) says otherwise. [RULE] scip-missing-file-detected-never-silent
419
+ tier = existingTier;
413
420
  }
414
421
 
415
422
  // Normalize edges to store schema (map from parser-floor shape to store shape)
@@ -530,8 +537,12 @@ function build_index(repoRoot, options) {
530
537
  let edgeCount = 0;
531
538
  let tierFloor = 0;
532
539
  let tierUpgraded = 0;
540
+ let tierPartial = 0; // [RULE] scip-tier-proportional
541
+ let callEdges = 0;
542
+ let callEdgesUnresolved = 0;
533
543
  let errors = 0;
534
544
  const skippedFiles = [];
545
+ const scipMissing = []; // files SCIP ran for but produced no document for
535
546
 
536
547
  // Stream: parse + put each file one at a time (never accumulate the full set)
537
548
  for (const { absPath, relPath } of files) {
@@ -541,7 +552,14 @@ function build_index(repoRoot, options) {
541
552
  entityCount += result.entities.length;
542
553
  edgeCount += result.edges.length;
543
554
  if (result.tier === 'compiler-accurate') tierUpgraded++;
555
+ else if (result.tier === 'compiler-partial') tierPartial++;
544
556
  else tierFloor++;
557
+ for (const e of result.edges) {
558
+ if (e.kind !== 'call-site' && e.kind !== 'CALL') continue;
559
+ callEdges++;
560
+ if (String(e.target || e.dst).startsWith('UNRESOLVED#')) callEdgesUnresolved++;
561
+ }
562
+ if (result.tier === 'tree-sitter-floor-SCIP-MISSING') scipMissing.push(relPath);
545
563
  if (typeof onProgress === 'function') {
546
564
  onProgress({ file: relPath, tier: result.tier, fileCount, total: files.length });
547
565
  }
@@ -592,11 +610,23 @@ function build_index(repoRoot, options) {
592
610
  console.error(`\x1b[33m[IDX NOTICE]\x1b[0m ${scipNotice}`);
593
611
  }
594
612
 
613
+ // A file the SCIP indexer skipped keeps every call target unresolved, so
614
+ // who-calls cannot see its callers. Loud, every build — not a fallback, a
615
+ // report. `gsd-t graph status` repeats it. [RULE] scip-missing-file-detected-never-silent
616
+ if (scipMissing.length) {
617
+ const shown = scipMissing.slice(0, 10).join(', ');
618
+ warn(`${scipMissing.length} file(s) not in the SCIP index — their call edges stay unresolved ` +
619
+ `(tier tree-sitter-floor-SCIP-MISSING): ${shown}${scipMissing.length > 10 ? ', …' : ''}`);
620
+ }
621
+
595
622
  return {
596
623
  fileCount,
597
624
  entityCount,
598
625
  edgeCount,
599
- tier: { floor: tierFloor, upgraded: tierUpgraded },
626
+ tier: { floor: tierFloor, upgraded: tierUpgraded, partial: tierPartial },
627
+ callEdges,
628
+ callEdgesUnresolved,
629
+ scipMissing,
600
630
  scipAvailable: scipActive,
601
631
  scipNotice,
602
632
  errors,
@@ -648,7 +678,11 @@ if (require.main === module) {
648
678
  });
649
679
 
650
680
  good(`Indexed ${result.fileCount} files, ${result.entityCount} entities, ${result.edgeCount} edges in ${result.durationMs}ms`);
651
- good(`Tiers — floor: ${result.tier.floor}, compiler-accurate: ${result.tier.upgraded}`);
681
+ good(`Tiers — floor: ${result.tier.floor}, compiler-accurate: ${result.tier.upgraded}, compiler-partial: ${result.tier.partial}`);
682
+ if (result.callEdges) {
683
+ info(`Call edges unresolved: ${result.callEdgesUnresolved} of ${result.callEdges} ` +
684
+ `(${(100 * result.callEdgesUnresolved / result.callEdges).toFixed(1)}% — library calls such as console.log never resolve)`);
685
+ }
652
686
  if (result.errors > 0) warn(`${result.errors} files had parse errors (skipped)`);
653
687
 
654
688
  const envelope = {
@@ -660,6 +694,7 @@ if (require.main === module) {
660
694
  entityCount: result.entityCount,
661
695
  edgeCount: result.edgeCount,
662
696
  tier: result.tier,
697
+ scipMissingCount: result.scipMissing.length,
663
698
  errors: result.errors,
664
699
  durationMs: result.durationMs,
665
700
  };
@@ -204,6 +204,16 @@ function isTestFile(funcId, patterns) {
204
204
  // in the implFuncs coverage set.
205
205
 
206
206
  const UNRESOLVED_PREFIX = "UNRESOLVED#";
207
+ // A file the SCIP indexer ran for but never produced a document for (see
208
+ // gsd-t-graph-scip-upgrade.cjs). [RULE] scip-missing-file-detected-never-silent
209
+ const SCIP_MISSING_TIER = "tree-sitter-floor-SCIP-MISSING";
210
+ // A file SCIP indexed but where too few repo-resolvable calls resolved to call it
211
+ // compiler-accurate. [RULE] scip-tier-proportional
212
+ const COMPILER_PARTIAL_TIER = "compiler-partial";
213
+ // Tiers whose call edges SCIP actually looked at. An UNRESOLVED call in one of
214
+ // these files is SCIP saying "not a repo function" (a local, a mock, a library),
215
+ // so it is never name-matched. [RULE] name-match-only-where-scip-never-looked
216
+ const SCIP_BACKED_TIERS = new Set(["compiler-accurate", COMPILER_PARTIAL_TIER]);
207
217
 
208
218
  /**
209
219
  * Load records from a JSONL store directory.
@@ -379,7 +389,7 @@ function buildIndex(records, skippedFiles) {
379
389
  allFiles.add(rec.file);
380
390
  if (rec.tier) fileTier.set(rec.file, rec.tier);
381
391
 
382
- if (rec.tier === "tree-sitter-floor") hasFloor = true;
392
+ if (rec.tier === "tree-sitter-floor" || rec.tier === SCIP_MISSING_TIER || rec.tier === COMPILER_PARTIAL_TIER) hasFloor = true;
383
393
  if (rec.tier === "tree-sitter-floor-STALE-SCIP") hasStaleScip = true;
384
394
 
385
395
  // Index entities for bare-name disambiguation + tier labeling
@@ -420,6 +430,7 @@ function buildIndex(records, skippedFiles) {
420
430
  return {
421
431
  importGraph,
422
432
  callGraph,
433
+ nameMatchedCallGraph: buildNameMatchedCallGraph(forwardCallEdges, funcEntities, fileTier),
423
434
  forwardCallEdges,
424
435
  funcEntities,
425
436
  allFiles,
@@ -429,6 +440,76 @@ function buildIndex(records, skippedFiles) {
429
440
  };
430
441
  }
431
442
 
443
+ /**
444
+ * Name-matched reverse call edges: `UNRESOLVED#<name>` → the ONE function in the
445
+ * repo named <name>, for callers in files SCIP never resolved (no SCIP document,
446
+ * floor, stale). A labelled answer, never a compiler one: who-calls reports these
447
+ * callers under `nameMatched` with `resolution: "name-matched"`.
448
+ * A name defined 2+ times is never matched — it stays in
449
+ * coverage.unresolvedCallSites. [RULE] unique-name-unresolved-call-name-matched
450
+ * [RULE] name-match-only-where-scip-never-looked
451
+ *
452
+ * @returns {Map<string,Set<string>>} dstFuncId (both `file#name@line` and `file#name`) → callers
453
+ */
454
+ function buildNameMatchedCallGraph(forwardCallEdges, funcEntities, fileTier) {
455
+ const byName = new Map(); // name → [funcId, ...]
456
+ for (const [funcId, meta] of funcEntities) {
457
+ if (!byName.has(meta.name)) byName.set(meta.name, []);
458
+ byName.get(meta.name).push(funcId);
459
+ }
460
+ const graph = new Map();
461
+ for (const { src, dst } of forwardCallEdges) {
462
+ if (!dst.startsWith(UNRESOLVED_PREFIX)) continue;
463
+ const defs = byName.get(dst.slice(UNRESOLVED_PREFIX.length));
464
+ if (!defs || defs.length !== 1) continue;
465
+ // [RULE] name-match-only-where-scip-never-looked: include a caller ONLY if its
466
+ // file was NOT SCIP-backed (not compiler-accurate or compiler-partial). Files
467
+ // missing from fileTier default to tree-sitter-floor (SCIP never looked), so
468
+ // they are included. [ISSUE] M119: files outside tsconfig have no fileTier entry
469
+ // yet contribute valid UNRESOLVED edges → include them.
470
+ const callerFile = src.split("#")[0];
471
+ const callerTier = fileTier.get(callerFile);
472
+ // A missing tier → never SCIP-backed → include it (tree-sitter-floor by default)
473
+ if (callerTier !== undefined && SCIP_BACKED_TIERS.has(callerTier)) continue;
474
+ for (const key of new Set([defs[0], defs[0].replace(/@\d+$/, "")])) {
475
+ if (!graph.has(key)) graph.set(key, new Set());
476
+ graph.get(key).add(src);
477
+ }
478
+ }
479
+ return graph;
480
+ }
481
+
482
+ /**
483
+ * Callers of `funcId` found only by name match (not already compiler callers).
484
+ * @returns {string[]}
485
+ */
486
+ function nameMatchedCallersOf(index, funcId, compilerCallers) {
487
+ if (!index.nameMatchedCallGraph) return [];
488
+ const set = index.nameMatchedCallGraph.get(funcId) || index.nameMatchedCallGraph.get(funcId.replace(/@\d+$/, ""));
489
+ if (!set) return [];
490
+ return Array.from(set).filter((c) => !compilerCallers.has(c)).sort();
491
+ }
492
+
493
+ /**
494
+ * who-calls envelope: compiler callers + labelled name-matched callers. A
495
+ * name-matched caller leaves coverage.unresolvedCallSites (it is accounted for).
496
+ */
497
+ function whoCallsResult(index, funcId, baseCoverage, identity) {
498
+ const compiler = index.callGraph.get(funcId) || index.callGraph.get(funcId.replace(/@\d+$/, "")) || new Set();
499
+ const matched = nameMatchedCallersOf(index, funcId, compiler);
500
+ const results = Array.from(compiler).concat(matched).sort();
501
+ const coverage = withUnresolvedSites(baseCoverage, index, identity, matched);
502
+ if (!matched.length) return { results, tier: index.tier, coverage };
503
+ const out = { results, tier: index.tier, coverage };
504
+ out.nameMatched = {
505
+ resolution: "name-matched",
506
+ note: "unresolved call sites naming the only function in the repo with this name, in files SCIP never resolved — name-matched, not compiler-resolved",
507
+ count: matched.length,
508
+ callers: matched,
509
+ };
510
+ return out;
511
+ }
512
+
432
513
  // ─── Load store + build index (fail-loud on any failure) ─────────────────────
433
514
 
434
515
  /**
@@ -595,6 +676,13 @@ function loadSqliteStore(dbPath) {
595
676
  if (n.tier && n.tier !== 'compiler-accurate') r.tier = n.tier;
596
677
  else if (!r.tier) r.tier = n.tier || 'compiler-accurate';
597
678
  }
679
+ // The files table is where a SCIP-MISSING label lives even for a file that
680
+ // declares no function — so `graph status` can list every one of them.
681
+ if (hasFilesTable) {
682
+ for (const r of db.prepare("SELECT file, tier FROM files WHERE tier IN (?, ?)").all(SCIP_MISSING_TIER, COMPILER_PARTIAL_TIER)) {
683
+ rec(norm(r.file)).tier = r.tier;
684
+ }
685
+ }
598
686
  for (const e of edges) {
599
687
  // src for an IMPORT edge is the source FILE; for a CALL edge it's a funcId
600
688
  // (file#fn@line). The owning file record is the src's file part.
@@ -602,6 +690,15 @@ function loadSqliteStore(dbPath) {
602
690
  const dst = e.kind === "IMPORT" ? resolveDst(srcFile, e.dst) : e.dst;
603
691
  rec(srcFile).edges.push({ kind: e.kind, src: e.src, dst });
604
692
  }
693
+ // A file with call edges but no function node (top-level code only) got no
694
+ // tier from the node pass. Name matching needs to know whether SCIP looked
695
+ // at it, so take its tier from the files table. [RULE] name-match-only-where-scip-never-looked
696
+ if (hasFilesTable) {
697
+ for (const r of db.prepare("SELECT file, tier FROM files").all()) {
698
+ const f = norm(r.file);
699
+ if (r.tier && byFile.has(f) && !byFile.get(f).tier) byFile.get(f).tier = r.tier;
700
+ }
701
+ }
605
702
  db.close();
606
703
  return { records: Array.from(byFile.values()) };
607
704
  } catch (_e) {
@@ -733,6 +830,42 @@ function queryWhoImports(index, target) {
733
830
  return { results, tier: index.tier, coverage };
734
831
  }
735
832
 
833
+ // ─── Unresolved call sites that name the target ──────────────────────────────
834
+ //
835
+ // When coverage is incomplete, the calls the graph could not resolve are still
836
+ // in it — as `UNRESOLVED#<name>` edges from a known caller. They are NOT results
837
+ // (a name match is not a resolved call), so they are reported inside `coverage`,
838
+ // labelled, with the files to open. That turns "[] and incomplete" into a named
839
+ // place to look. [RULE] incomplete-empty-answer-names-a-path-forward
840
+
841
+ function unresolvedCallSitesFor(index, identity, accounted) {
842
+ const name = identity.split("#").pop().replace(/@\d+$/, "");
843
+ if (!name) return null;
844
+ const exact = UNRESOLVED_PREFIX + name;
845
+ const member = "." + name;
846
+ const callers = new Set();
847
+ for (const { src, dst } of index.forwardCallEdges) {
848
+ if (dst === exact || (dst.startsWith(UNRESOLVED_PREFIX) && dst.endsWith(member))) callers.add(src);
849
+ }
850
+ // A caller already reported as name-matched is accounted for, not unknown.
851
+ if (accounted) for (const c of accounted) callers.delete(c);
852
+ if (callers.size === 0) return null;
853
+ const sorted = Array.from(callers).sort();
854
+ const files = Array.from(new Set(sorted.map((c) => c.split("#")[0]))).sort();
855
+ return {
856
+ count: sorted.length,
857
+ note: "name-match only — these callers call something named '" + name + "' that the graph could not resolve; open these files to confirm",
858
+ callers: sorted.slice(0, 100),
859
+ files: files.slice(0, 50),
860
+ };
861
+ }
862
+
863
+ function withUnresolvedSites(coverage, index, identity, accounted) {
864
+ if (!coverage || coverage.complete !== false) return coverage;
865
+ const sites = unresolvedCallSitesFor(index, identity, accounted);
866
+ return sites ? { ...coverage, unresolvedCallSites: sites } : coverage;
867
+ }
868
+
736
869
  // ─── Query: who-calls ─────────────────────────────────────────────────────────
737
870
 
738
871
  /**
@@ -755,14 +888,13 @@ function queryWhoImports(index, target) {
755
888
  */
756
889
  function queryWhoCalls(index, identity) {
757
890
  const isFuncId = identity.includes("#");
758
- const coverage = computeCoverage(index.skippedFiles, { callEdgesUnresolved: true, unresolvedFiles: countUnresolvedFiles(index) });
891
+ const baseCoverage = computeCoverage(index.skippedFiles, { callEdgesUnresolved: true, unresolvedFiles: countUnresolvedFiles(index) });
892
+ const coverage = withUnresolvedSites(baseCoverage, index, identity);
759
893
 
760
894
  if (isFuncId) {
761
895
  // File-qualified identity — exact funcId lookup (tolerate @line suffix:
762
896
  // 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 };
897
+ return whoCallsResult(index, identity, baseCoverage, identity);
766
898
  }
767
899
 
768
900
  // Bare name — disambiguate against all funcIds
@@ -783,11 +915,7 @@ function queryWhoCalls(index, identity) {
783
915
  // funcEntities key as `file#name@line`, but call edges (and thus callGraph)
784
916
  // key as `file#name` (no @line) — try both so the @line-suffix difference
785
917
  // 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 };
918
+ return whoCallsResult(index, matchingFuncIds[0], baseCoverage, identity);
791
919
  }
792
920
 
793
921
  // Multiple matches — ambiguous, NEVER merge
@@ -951,12 +1079,17 @@ function queryBlastRadius(index, target) {
951
1079
  for (const [funcId, meta] of index.funcEntities) {
952
1080
  if (meta.file === target) {
953
1081
  initialFrontier.add(funcId);
1082
+ // SCIP-resolved call edges key on `file#name` (no @line) — seed that
1083
+ // form too, or a file's callers never enter the radius.
1084
+ // [RULE] who-calls-funcid-line-suffix-tolerant
1085
+ initialFrontier.add(funcId.replace(/@\d+$/, ""));
954
1086
  }
955
1087
  }
956
1088
  }
957
1089
 
958
1090
  // BFS over the UNION of reverse import + call edges, transitive closure
959
1091
  const visited = new Set();
1092
+ const nameMatchedReached = new Set();
960
1093
  const queue = Array.from(initialFrontier);
961
1094
 
962
1095
  while (queue.length > 0) {
@@ -972,10 +1105,13 @@ function queryBlastRadius(index, target) {
972
1105
  }
973
1106
  }
974
1107
 
975
- // Reverse call edges: who calls this function node?
976
- const callers = index.callGraph.get(node);
977
- if (callers) {
1108
+ // Reverse call edges: who calls this function node? (compiler + name-matched;
1109
+ // name-matched ones are listed separately below) [RULE] unique-name-unresolved-call-name-matched
1110
+ for (const graph of [index.callGraph, index.nameMatchedCallGraph]) {
1111
+ const callers = graph && graph.get(node);
1112
+ if (!callers) continue;
978
1113
  for (const caller of callers) {
1114
+ if (graph !== index.callGraph && !(index.callGraph.get(node) || new Set()).has(caller)) nameMatchedReached.add(caller);
979
1115
  if (!visited.has(caller)) queue.push(caller);
980
1116
  }
981
1117
  }
@@ -988,8 +1124,15 @@ function queryBlastRadius(index, target) {
988
1124
  }
989
1125
 
990
1126
  const results = Array.from(visited).sort();
991
- const coverage = computeCoverage(index.skippedFiles, { callEdgesUnresolved: true, unresolvedFiles: countUnresolvedFiles(index) });
992
- return { results, tier: index.tier, coverage };
1127
+ const matched = Array.from(nameMatchedReached).filter((c) => visited.has(c)).sort();
1128
+ const coverage = withUnresolvedSites(
1129
+ computeCoverage(index.skippedFiles, { callEdgesUnresolved: true, unresolvedFiles: countUnresolvedFiles(index) }),
1130
+ index, target, matched);
1131
+ if (!matched.length) return { results, tier: index.tier, coverage };
1132
+ return {
1133
+ results, tier: index.tier, coverage,
1134
+ nameMatched: { resolution: "name-matched", count: matched.length, callers: matched.slice(0, 100) },
1135
+ };
993
1136
  }
994
1137
 
995
1138
  // ─── Query: status ────────────────────────────────────────────────────────────
@@ -1004,7 +1147,12 @@ function queryBlastRadius(index, target) {
1004
1147
  * @returns {object}
1005
1148
  */
1006
1149
  function queryStatus(index, storePath) {
1150
+ // [RULE] scip-missing-file-detected-never-silent — status names them.
1151
+ const scipMissing = [];
1152
+ for (const [file, tier] of index.fileTier || []) if (tier === SCIP_MISSING_TIER) scipMissing.push(file);
1153
+ scipMissing.sort();
1007
1154
  return {
1155
+ scipMissing: { count: scipMissing.length, files: scipMissing.slice(0, 50) },
1008
1156
  queryable: true,
1009
1157
  storePath,
1010
1158
  fileCount: index.allFiles.size,
@@ -1203,7 +1351,7 @@ function queryDeadCode(index) {
1203
1351
  // [RULE] orphan-tier-labeled-candidate-not-certainty:
1204
1352
  // Floor-tier results are CANDIDATE (a missed unresolved call could explain the absence).
1205
1353
  const tier = meta.tier || index.tier;
1206
- const isFloor = tier === "tree-sitter-floor" || tier === "tree-sitter-floor-STALE-SCIP";
1354
+ const isFloor = tier === "tree-sitter-floor" || tier === "tree-sitter-floor-STALE-SCIP" || tier === SCIP_MISSING_TIER;
1207
1355
  const candidateLabel = isFloor ? "CANDIDATE" : null;
1208
1356
 
1209
1357
  results.push({ funcId, file: meta.file, tier, candidateLabel });
@@ -1346,6 +1494,32 @@ function queryTestImpl(index, options) {
1346
1494
  return { results: untested, tier: index.tier, mode: "untested-impl" };
1347
1495
  }
1348
1496
 
1497
+ // ─── Incomplete-empty marker (read by the M117 search guard) ──────────────────
1498
+ //
1499
+ // The search guard runs BEFORE a grep and cannot see what the graph just said.
1500
+ // When who-calls / blast-radius answers [] with coverage incomplete, record it,
1501
+ // so the guard's block message can name a path forward (the files to open, the
1502
+ // re-index) instead of leaving none. A write failure is said on stderr.
1503
+ // [RULE] incomplete-empty-answer-names-a-path-forward
1504
+
1505
+ const INCOMPLETE_MARKER_NAME = "last-incomplete-answer.json";
1506
+
1507
+ function writeIncompleteAnswerMarker(projectRoot, verb, target, coverage) {
1508
+ const file = path.join(projectRoot, ".gsd-t", "graphDB", INCOMPLETE_MARKER_NAME);
1509
+ try {
1510
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1511
+ fs.writeFileSync(file, JSON.stringify({
1512
+ ts: new Date().toISOString(),
1513
+ verb,
1514
+ target,
1515
+ note: coverage.note || null,
1516
+ unresolvedCallSites: coverage.unresolvedCallSites || null,
1517
+ }, null, 2));
1518
+ } catch (e) {
1519
+ process.stderr.write("[graph] could not record the incomplete answer for the search guard (" + e.message + ")\n");
1520
+ }
1521
+ }
1522
+
1349
1523
  // ─── Public API (for tests to import directly) ────────────────────────────────
1350
1524
  // Tests build an index from fixture records, then call these pure functions.
1351
1525
  // The query CLI is the only caller of runFreshnessCheck (integration seam).
@@ -1384,6 +1558,10 @@ module.exports = {
1384
1558
  COUPLING_THRESHOLD,
1385
1559
  DEFAULT_TEST_PATTERNS,
1386
1560
  UNRESOLVED_PREFIX,
1561
+ SCIP_MISSING_TIER,
1562
+ unresolvedCallSitesFor,
1563
+ writeIncompleteAnswerMarker,
1564
+ INCOMPLETE_MARKER_NAME,
1387
1565
  getTestPatterns,
1388
1566
  isTestFile,
1389
1567
  };
@@ -1522,7 +1700,12 @@ if (require.main === module) {
1522
1700
  });
1523
1701
  process.exit(2);
1524
1702
  }
1525
- emit({ ok: true, verb, target, results: queryResult.results, tier: queryResult.tier, coverage: queryResult.coverage });
1703
+ if (queryResult.results.length === 0 && queryResult.coverage && queryResult.coverage.complete === false) {
1704
+ writeIncompleteAnswerMarker(_resolver.deriveProjectRoot(storePath), verb, target, queryResult.coverage);
1705
+ }
1706
+ const env = { ok: true, verb, target, results: queryResult.results, tier: queryResult.tier, coverage: queryResult.coverage };
1707
+ if (queryResult.nameMatched) env.nameMatched = queryResult.nameMatched; // [RULE] unique-name-unresolved-call-name-matched
1708
+ emit(env);
1526
1709
 
1527
1710
  } else if (verb === "body") {
1528
1711
  if (!target) fail({ ok: false, reason: "missing-target", verb });
@@ -1576,12 +1759,19 @@ if (require.main === module) {
1576
1759
 
1577
1760
  } else if (verb === "blast-radius") {
1578
1761
  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 });
1762
+ const { results, tier, coverage, nameMatched } = queryBlastRadius(index, target);
1763
+ if (results.length === 0 && coverage && coverage.complete === false) {
1764
+ writeIncompleteAnswerMarker(_resolver.deriveProjectRoot(storePath), verb, target, coverage);
1765
+ }
1766
+ const env = { ok: true, verb, target, results, tier, coverage };
1767
+ if (nameMatched) env.nameMatched = nameMatched; // [RULE] unique-name-unresolved-call-name-matched
1768
+ emit(env);
1581
1769
 
1582
1770
  } else if (verb === "status") {
1583
1771
  const statusData = queryStatus(index, storePath);
1584
- emit({ ok: true, verb: "status", ...statusData });
1772
+ // Name the project exclude list so a missing folder is never a mystery.
1773
+ const ex = require("./gsd-t-graph-exclude.cjs").loadGraphExcludes(_resolver.deriveProjectRoot(storePath));
1774
+ emit({ ok: true, verb: "status", ...statusData, excludes: { source: ex.source, patterns: ex.patterns } });
1585
1775
 
1586
1776
  } else if (verb === "cluster") {
1587
1777
  const { results, tier } = queryCluster(index);