@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.
- package/CHANGELOG.md +17 -0
- package/README.md +3 -2
- package/bin/gsd-t-estimate-sheet.cjs +206 -28
- package/bin/gsd-t-graph-edge-extract.cjs +113 -2
- package/bin/gsd-t-graph-exclude.cjs +81 -0
- package/bin/gsd-t-graph-freshness.cjs +5 -1
- package/bin/gsd-t-graph-index.cjs +42 -7
- package/bin/gsd-t-graph-query-cli.cjs +210 -20
- package/bin/gsd-t-graph-scip-upgrade.cjs +92 -12
- package/bin/gsd-t-scip-reader.cjs +29 -2
- package/bin/gsd-t.js +26 -3
- 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 +52 -2
- 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
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
977
|
-
|
|
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
|
|
992
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|