@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.
- package/CHANGELOG.md +32 -0
- package/README.md +3 -2
- package/bin/gsd-t-estimate-sheet.cjs +206 -28
- package/bin/gsd-t-graph-edge-extract.cjs +454 -2
- package/bin/gsd-t-graph-exclude.cjs +106 -0
- package/bin/gsd-t-graph-freshness.cjs +16 -5
- package/bin/gsd-t-graph-index.cjs +102 -10
- package/bin/gsd-t-graph-query-cli.cjs +546 -27
- package/bin/gsd-t-graph-scip-upgrade.cjs +92 -12
- package/bin/gsd-t-scip-reader.cjs +29 -2
- package/bin/gsd-t.js +43 -6
- package/commands/gsd-t-estimate-rescale.md +64 -0
- package/commands/gsd-t-estimate.md +2 -2
- package/commands/gsd-t-help.md +6 -0
- package/package.json +1 -1
- package/scripts/gsd-t-graph-search-guard.js +98 -4
- package/scripts/statusline-command.sh +11 -2
- package/templates/CLAUDE-global.md +1 -1
- package/templates/estimate-config.json +2 -2
- package/templates/estimate-sheet-spec.md +27 -7
- package/templates/playbooks/tekyz-estimation-and-prd-playbook.md +2 -2
|
@@ -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
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
977
|
-
|
|
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
|
|
992
|
-
|
|
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
|
-
|
|
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
|
|
1580
|
-
|
|
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
|
-
|
|
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);
|