@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
|
@@ -134,6 +134,14 @@ function _resetScipCache(override) {
|
|
|
134
134
|
* is confirmed present and invocable. Phase-2 will replace the edges with
|
|
135
135
|
* SCIP-derived ones. This is the documented UPGRADE FLOOR behavior.
|
|
136
136
|
*/
|
|
137
|
+
const SCIP_MAX_FILE_BYTES = '64mb';
|
|
138
|
+
const SCIP_HEAP_MB = 8192;
|
|
139
|
+
const SCIP_MISSING_TIER = 'tree-sitter-floor-SCIP-MISSING';
|
|
140
|
+
// A file SCIP indexed but where fewer than this share of its repo-resolvable calls
|
|
141
|
+
// resolved. [RULE] scip-tier-proportional
|
|
142
|
+
const COMPILER_PARTIAL_TIER = 'compiler-partial';
|
|
143
|
+
const COMPILER_ACCURATE_MIN_FRACTION = 0.9;
|
|
144
|
+
|
|
137
145
|
function runScipTypescript(projectRoot, outPath) {
|
|
138
146
|
// M95: emit to a REAL file (outPath) so the index can be READ and call edges
|
|
139
147
|
// resolved — not /dev/null. The old /dev/null path only proved invocability.
|
|
@@ -145,10 +153,22 @@ function runScipTypescript(projectRoot, outPath) {
|
|
|
145
153
|
// --infer-tsconfig: resolve a tsconfig even when one isn't at the repo root
|
|
146
154
|
// (monorepos / nested layouts — e.g. web/tsconfig.json). Without it, projects
|
|
147
155
|
// whose tsconfig lives in a subdir got 0 resolved call edges. [RULE] scip-infer-nested-tsconfig
|
|
148
|
-
|
|
156
|
+
// --max-file-byte-size: scip-typescript SKIPS every file over 1mb by default,
|
|
157
|
+
// silently — no warning, no document in the index. A 50k-line route file
|
|
158
|
+
// (hilo-figma-atos routes-locations.ts, 1.9MB) was never resolved, so every
|
|
159
|
+
// call in it stayed unresolved and who-calls answered []. Raise the cap well
|
|
160
|
+
// past any hand-written source file. [RULE] scip-no-silent-large-file-skip
|
|
161
|
+
// A larger heap keeps a big project from dying mid-run once those files are in.
|
|
162
|
+
const nodeOpts = process.env.NODE_OPTIONS || '';
|
|
163
|
+
const env = /max-old-space-size/.test(nodeOpts)
|
|
164
|
+
? process.env
|
|
165
|
+
: { ...process.env, NODE_OPTIONS: `${nodeOpts} --max-old-space-size=${SCIP_HEAP_MB}`.trim() };
|
|
166
|
+
const scip = spawnSync('scip-typescript',
|
|
167
|
+
['index', '--infer-tsconfig', '--max-file-byte-size', SCIP_MAX_FILE_BYTES, '--output', out, '.'], {
|
|
149
168
|
cwd: projectRoot,
|
|
150
169
|
encoding: 'utf8',
|
|
151
170
|
timeout: 180_000,
|
|
171
|
+
env,
|
|
152
172
|
});
|
|
153
173
|
if (scip.status === 0) return { ok: true, scipPath: out };
|
|
154
174
|
return { ok: false, error: scip.stderr || `exit code ${scip.status}` };
|
|
@@ -314,7 +334,7 @@ function findTsProjectDirs(repoRoot) {
|
|
|
314
334
|
* resolveFileEdges: (relPath, edges) => { edges, resolved: number } }}
|
|
315
335
|
*/
|
|
316
336
|
function buildScipResolver(repoRoot, opts = {}) {
|
|
317
|
-
const { readScipIndex } = require('./gsd-t-scip-reader.cjs');
|
|
337
|
+
const { readScipIndex, scipPositionKey } = require('./gsd-t-scip-reader.cjs');
|
|
318
338
|
const avail = detectScip();
|
|
319
339
|
|
|
320
340
|
// Detect which languages have source present (so we only run the relevant
|
|
@@ -325,10 +345,21 @@ function buildScipResolver(repoRoot, opts = {}) {
|
|
|
325
345
|
// is language-agnostic (Python symbols use the same `name().` descriptor form),
|
|
326
346
|
// so TS and Python refs merge into one fileRefs map keyed by repo-relative path.
|
|
327
347
|
const fileRefs = new Map(); // relPath → [{symbol, funcId, line}]
|
|
348
|
+
const scipDocs = new Set(); // every file any indexer produced a document for
|
|
349
|
+
const defNames = new Set(); // every name SCIP saw DEFINED in the repo — what a call could resolve to
|
|
350
|
+
const occurrencePositions = new Map(); // relPath → Set<positionKey> of every SCIP occurrence
|
|
328
351
|
const ranIndexers = [];
|
|
329
352
|
|
|
330
353
|
function mergeRead(read) {
|
|
331
354
|
if (!read || !read.ok) return;
|
|
355
|
+
if (read.docPaths) for (const d of read.docPaths) scipDocs.add(d);
|
|
356
|
+
if (read.symbolToDef) for (const fid of read.symbolToDef.values()) defNames.add(fid.split('#').pop());
|
|
357
|
+
if (read.occurrencePositions) {
|
|
358
|
+
for (const [f, pos] of read.occurrencePositions) {
|
|
359
|
+
if (occurrencePositions.has(f)) for (const k of pos) occurrencePositions.get(f).add(k);
|
|
360
|
+
else occurrencePositions.set(f, pos);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
332
363
|
for (const [file, refs] of read.fileRefs) {
|
|
333
364
|
if (fileRefs.has(file)) fileRefs.get(file).push(...refs);
|
|
334
365
|
else fileRefs.set(file, refs.slice());
|
|
@@ -383,8 +414,7 @@ function buildScipResolver(repoRoot, opts = {}) {
|
|
|
383
414
|
* resolves to a real funcId, rewrite dst to that funcId.
|
|
384
415
|
*/
|
|
385
416
|
function resolveFileEdges(relPath, edges) {
|
|
386
|
-
const refs = fileRefs.get(relPath);
|
|
387
|
-
if (!refs || !refs.length) return { edges, resolved: 0 };
|
|
417
|
+
const refs = fileRefs.get(relPath) || [];
|
|
388
418
|
|
|
389
419
|
// name → resolved funcId (last writer wins; SCIP refs in this file)
|
|
390
420
|
const nameToFuncId = new Map();
|
|
@@ -394,6 +424,12 @@ function buildScipResolver(repoRoot, opts = {}) {
|
|
|
394
424
|
}
|
|
395
425
|
|
|
396
426
|
let resolved = 0;
|
|
427
|
+
// missed = a call to a name the repo defines, at a position where SCIP put NO
|
|
428
|
+
// symbol — the compiler never looked at it. A call SCIP resolved to a library
|
|
429
|
+
// (drizzle's `text()`) or a local (`const [x, setX] = useState()`) is not a
|
|
430
|
+
// miss: SCIP answered, the answer just is not a repo function.
|
|
431
|
+
const positions = occurrencePositions.get(relPath);
|
|
432
|
+
let missed = 0;
|
|
397
433
|
const out = edges.map((edge) => {
|
|
398
434
|
const dst = edge.target || edge.dst || '';
|
|
399
435
|
const kind = edge.kind;
|
|
@@ -401,12 +437,18 @@ function buildScipResolver(repoRoot, opts = {}) {
|
|
|
401
437
|
if (!isCall || !dst.startsWith('UNRESOLVED#')) return edge;
|
|
402
438
|
const calleeName = dst.slice('UNRESOLVED#'.length);
|
|
403
439
|
const funcId = nameToFuncId.get(calleeName);
|
|
404
|
-
if (!funcId)
|
|
440
|
+
if (!funcId) { // still unresolved → stays floor
|
|
441
|
+
const seen = positions && Number.isInteger(edge.col) && positions.has(scipPositionKey(edge.line - 1, edge.col));
|
|
442
|
+
if (defNames.has(calleeName) && !seen) missed++;
|
|
443
|
+
return edge;
|
|
444
|
+
}
|
|
405
445
|
resolved++;
|
|
406
446
|
// rewrite dst to the resolved funcId, mark scip-derived
|
|
407
447
|
return { ...edge, target: funcId, dst: funcId, scipResolved: true };
|
|
408
448
|
});
|
|
409
|
-
|
|
449
|
+
// resolvable = resolved + missed. A call to a library (`c.json`) or a local
|
|
450
|
+
// never counts against the file. [RULE] scip-tier-proportional
|
|
451
|
+
return { edges: out, resolved, resolvable: resolved + missed };
|
|
410
452
|
}
|
|
411
453
|
|
|
412
454
|
// tsProjects reports which tsconfig projects actually contributed refs. A
|
|
@@ -419,6 +461,10 @@ function buildScipResolver(repoRoot, opts = {}) {
|
|
|
419
461
|
indexedFiles: fileRefs.size,
|
|
420
462
|
scipPath: resolveScipPath('index.scip', repoRoot),
|
|
421
463
|
resolveFileEdges,
|
|
464
|
+
// [RULE] scip-missing-file-detected-never-silent — lets the upgrader tell a
|
|
465
|
+
// file the indexer never produced a document for from one it indexed.
|
|
466
|
+
coversLanguage: (lang) => ranIndexers.includes(lang),
|
|
467
|
+
hasScipDoc: (relPath) => scipDocs.has(relPath),
|
|
422
468
|
};
|
|
423
469
|
}
|
|
424
470
|
|
|
@@ -492,8 +538,17 @@ function tryScipUpgrade(absPath, relPath, entities, edges, options) {
|
|
|
492
538
|
return { upgraded: false, tier, entities, edges };
|
|
493
539
|
}
|
|
494
540
|
|
|
541
|
+
// The indexer ran for this language but produced no document for this file
|
|
542
|
+
// (skipped for size, outside every tsconfig, or dropped mid-run). Its call
|
|
543
|
+
// targets are unknown — say so in the tier, never label it plain floor or,
|
|
544
|
+
// worse, compiler-accurate. [RULE] scip-missing-file-detected-never-silent
|
|
545
|
+
if (typeof resolver.hasScipDoc === 'function' && typeof resolver.coversLanguage === 'function' &&
|
|
546
|
+
resolver.coversLanguage(lang) && !resolver.hasScipDoc(relPath)) {
|
|
547
|
+
return { upgraded: false, tier: SCIP_MISSING_TIER, entities, edges };
|
|
548
|
+
}
|
|
549
|
+
|
|
495
550
|
// Resolve this file's UNRESOLVED# call edges against the SCIP index.
|
|
496
|
-
const { edges: resolvedEdges, resolved } = resolver.resolveFileEdges(relPath, edges);
|
|
551
|
+
const { edges: resolvedEdges, resolved, resolvable = 0 } = resolver.resolveFileEdges(relPath, edges);
|
|
497
552
|
|
|
498
553
|
// Rust cross-crate edges stay flagged partial.
|
|
499
554
|
// [RULE] rust-cross-crate-flagged-partial
|
|
@@ -505,15 +560,36 @@ function tryScipUpgrade(absPath, relPath, entities, edges, options) {
|
|
|
505
560
|
});
|
|
506
561
|
|
|
507
562
|
// [RULE] scip-tier-honest: label compiler-accurate ONLY when SCIP actually
|
|
508
|
-
// resolved
|
|
509
|
-
// pure-definition file SCIP indexed cleanly). A file whose calls all stayed
|
|
563
|
+
// resolved enough edges (proportionally). A file whose calls all stayed
|
|
510
564
|
// UNRESOLVED is NOT compiler-accurate — it's floor.
|
|
565
|
+
// [RULE] scip-tier-proportional: the fraction of resolvable calls that actually
|
|
566
|
+
// resolved determines the tier. If ≥COMPILER_ACCURATE_MIN_FRACTION resolved,
|
|
567
|
+
// it's accurate; otherwise partial (still better than floor). A file with no
|
|
568
|
+
// resolvable edges (only locals/library calls) that SCIP indexed cleanly
|
|
569
|
+
// is compiler-accurate. [ISSUE] user reported: routes-locations.ts 80% UNRESOLVED
|
|
570
|
+
// yet labeled compiler-accurate because ONE edge resolved.
|
|
511
571
|
const hadCallEdges = edges.some(e => (e.kind === 'call-site' || e.kind === 'CALL'));
|
|
512
|
-
|
|
513
|
-
|
|
572
|
+
let tier = 'tree-sitter-floor';
|
|
573
|
+
if (!hadCallEdges) {
|
|
574
|
+
// Pure-definition file SCIP indexed cleanly → compiler-accurate
|
|
575
|
+
tier = 'compiler-accurate';
|
|
576
|
+
} else if (resolvable > 0) {
|
|
577
|
+
// File has resolvable calls — judge by proportion resolved
|
|
578
|
+
if (resolved / resolvable >= COMPILER_ACCURATE_MIN_FRACTION) {
|
|
579
|
+
// Enough resolvable calls resolved → accurate
|
|
580
|
+
tier = 'compiler-accurate';
|
|
581
|
+
} else if (resolved > 0) {
|
|
582
|
+
// Some but not enough resolvable calls resolved → partial
|
|
583
|
+
tier = COMPILER_PARTIAL_TIER;
|
|
584
|
+
}
|
|
585
|
+
// else: no edges resolved (0/resolvable) → tree-sitter-floor (default)
|
|
586
|
+
}
|
|
587
|
+
// else: no resolvable calls (all external/local) — stay tree-sitter-floor (default)
|
|
588
|
+
|
|
589
|
+
const upgraded = tier === 'compiler-accurate';
|
|
514
590
|
|
|
515
591
|
return {
|
|
516
|
-
upgraded
|
|
592
|
+
upgraded,
|
|
517
593
|
tier,
|
|
518
594
|
entities,
|
|
519
595
|
edges: finalEdges,
|
|
@@ -584,4 +660,8 @@ module.exports = {
|
|
|
584
660
|
_resetScipCache,
|
|
585
661
|
isRustCrossCrateEdge,
|
|
586
662
|
EXT_TO_LANG,
|
|
663
|
+
SCIP_MISSING_TIER,
|
|
664
|
+
SCIP_MAX_FILE_BYTES,
|
|
665
|
+
COMPILER_PARTIAL_TIER,
|
|
666
|
+
COMPILER_ACCURATE_MIN_FRACTION,
|
|
587
667
|
};
|
|
@@ -151,7 +151,8 @@ function isBuildOutputPath(relPath) {
|
|
|
151
151
|
* @param {string} [pathPrefix] repo-relative dir the index was produced from
|
|
152
152
|
* (e.g. "server"); "" or "." for the repo root
|
|
153
153
|
* @returns {{ ok: true, symbolToDef: Map<string,string>,
|
|
154
|
-
* fileRefs: Map<string, Array<{symbol:string, funcId:string, line:number}
|
|
154
|
+
* fileRefs: Map<string, Array<{symbol:string, funcId:string, line:number}>>,
|
|
155
|
+
* docPaths: Set<string> }
|
|
155
156
|
* | { ok: false, reason: string }}
|
|
156
157
|
*/
|
|
157
158
|
function readScipIndex(scipPath, pathPrefix) {
|
|
@@ -177,6 +178,18 @@ function readScipIndex(scipPath, pathPrefix) {
|
|
|
177
178
|
|
|
178
179
|
const symbolToDef = new Map(); // scipSymbol → funcId (relPath#name)
|
|
179
180
|
const fileRefs = new Map(); // relPath → [{symbol, line}]
|
|
181
|
+
// Every file the indexer produced a document for — including files with no
|
|
182
|
+
// resolvable reference, which never appear in fileRefs. Without this set a
|
|
183
|
+
// file the indexer SKIPPED (scip-typescript drops files over its byte-size cap)
|
|
184
|
+
// is indistinguishable from one it indexed and found nothing in.
|
|
185
|
+
// [RULE] scip-missing-file-detected-never-silent
|
|
186
|
+
const docPaths = new Set();
|
|
187
|
+
// Every position (line, column) SCIP put ANY symbol at, per file — external,
|
|
188
|
+
// local, or repo. A call site whose callee sits at one of these was SEEN by the
|
|
189
|
+
// compiler (resolved to something, even if not a repo function); one that
|
|
190
|
+
// does not was never looked at. Numbers, not strings: a large repo has
|
|
191
|
+
// millions of occurrences. [RULE] scip-tier-proportional
|
|
192
|
+
const occurrencePositions = new Map();
|
|
180
193
|
|
|
181
194
|
const docs = obj.documents || [];
|
|
182
195
|
|
|
@@ -185,6 +198,7 @@ function readScipIndex(scipPath, pathPrefix) {
|
|
|
185
198
|
const rawPath = doc.relative_path;
|
|
186
199
|
if (!rawPath || isBuildOutputPath(rawPath)) continue;
|
|
187
200
|
const relPath = reroot(rawPath);
|
|
201
|
+
docPaths.add(relPath);
|
|
188
202
|
for (const occ of doc.occurrences || []) {
|
|
189
203
|
const isDef = (occ.symbol_roles & SYMBOL_ROLE_DEFINITION) !== 0;
|
|
190
204
|
if (!isDef) continue;
|
|
@@ -196,12 +210,19 @@ function readScipIndex(scipPath, pathPrefix) {
|
|
|
196
210
|
}
|
|
197
211
|
|
|
198
212
|
// Second pass: collect every REFERENCE occurrence per file, resolved to the def.
|
|
213
|
+
// A reference is kept whatever scope encloses it — named function, anonymous
|
|
214
|
+
// route handler, or top-level callback. The CALLER identity comes from the
|
|
215
|
+
// tree-sitter floor (which synthesizes one for anonymous scopes); SCIP only
|
|
216
|
+
// resolves the TARGET. [RULE] anonymous-caller-synthesized-never-dropped
|
|
199
217
|
for (const doc of docs) {
|
|
200
218
|
const rawPath = doc.relative_path;
|
|
201
219
|
if (!rawPath || isBuildOutputPath(rawPath)) continue;
|
|
202
220
|
const relPath = reroot(rawPath);
|
|
203
221
|
const refs = [];
|
|
222
|
+
const positions = new Set();
|
|
223
|
+
occurrencePositions.set(relPath, positions);
|
|
204
224
|
for (const occ of doc.occurrences || []) {
|
|
225
|
+
if (Array.isArray(occ.range) && occ.range.length >= 2) positions.add(scipPositionKey(occ.range[0], occ.range[1]));
|
|
205
226
|
const isDef = (occ.symbol_roles & SYMBOL_ROLE_DEFINITION) !== 0;
|
|
206
227
|
if (isDef) continue; // refs only
|
|
207
228
|
const name = funcNameFromSymbol(occ.symbol);
|
|
@@ -214,11 +235,17 @@ function readScipIndex(scipPath, pathPrefix) {
|
|
|
214
235
|
if (refs.length) fileRefs.set(relPath, refs);
|
|
215
236
|
}
|
|
216
237
|
|
|
217
|
-
return { ok: true, symbolToDef, fileRefs };
|
|
238
|
+
return { ok: true, symbolToDef, fileRefs, docPaths, occurrencePositions };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 0-based line + column → one number (columns never reach 1e6). */
|
|
242
|
+
function scipPositionKey(line0, col) {
|
|
243
|
+
return line0 * 1e6 + col;
|
|
218
244
|
}
|
|
219
245
|
|
|
220
246
|
module.exports = {
|
|
221
247
|
loadScipProto,
|
|
222
248
|
funcNameFromSymbol,
|
|
223
249
|
readScipIndex,
|
|
250
|
+
scipPositionKey,
|
|
224
251
|
};
|
package/bin/gsd-t.js
CHANGED
|
@@ -3708,6 +3708,8 @@ const PROJECT_BIN_TOOLS = [
|
|
|
3708
3708
|
// graph dead → grep fallback (found by the Binvoice architect run 2026-07-12).
|
|
3709
3709
|
// Same class as [[project_global_bin_propagation_gap]] / [[project_m96_graph_runs_in_projects]].
|
|
3710
3710
|
"gsd-t-graph-store-resolver.cjs",
|
|
3711
|
+
// Required by the indexer + freshness walker (project graph exclude list).
|
|
3712
|
+
"gsd-t-graph-exclude.cjs",
|
|
3711
3713
|
// M96 — multi-location resolver for the store engine (better-sqlite3), so a
|
|
3712
3714
|
// copied tool finds the engine from the GSD-T global package, not the project's
|
|
3713
3715
|
// own (usually absent) node_modules. Fail-loud with remediation if all miss.
|
|
@@ -4724,14 +4726,22 @@ function doGraphIndex() {
|
|
|
4724
4726
|
heading("GSD-T Graph — Index");
|
|
4725
4727
|
const { spawnSync } = require("child_process");
|
|
4726
4728
|
const idxPath = require("path").join(__dirname, "gsd-t-graph-index.cjs");
|
|
4729
|
+
// A large repo's SCIP run legitimately takes many minutes (hilo-figma-atos: >5).
|
|
4730
|
+
// The old 5-minute cap killed the indexer mid-build and — because a killed child
|
|
4731
|
+
// has status null — reported nothing and exited 0, leaving a half-built graph
|
|
4732
|
+
// that looked complete. A kill or spawn error is a failure, said out loud.
|
|
4727
4733
|
const result = spawnSync(process.execPath, [idxPath, "build", "--repo", process.cwd()], {
|
|
4728
4734
|
encoding: "utf8",
|
|
4729
4735
|
cwd: process.cwd(),
|
|
4730
4736
|
stdio: ["ignore", "inherit", "inherit"],
|
|
4731
|
-
timeout:
|
|
4737
|
+
timeout: 30 * 60 * 1000,
|
|
4732
4738
|
});
|
|
4733
|
-
if (result.
|
|
4734
|
-
|
|
4739
|
+
if (result.error || result.signal || result.status !== 0) {
|
|
4740
|
+
const why = result.error ? result.error.message
|
|
4741
|
+
: result.signal ? `killed by ${result.signal} (timeout 30 min?) — the graph is INCOMPLETE`
|
|
4742
|
+
: `exit ${result.status}`;
|
|
4743
|
+
error(`Graph index build failed: ${why}`);
|
|
4744
|
+
process.exitCode = 1;
|
|
4735
4745
|
}
|
|
4736
4746
|
}
|
|
4737
4747
|
|
|
@@ -4756,7 +4766,31 @@ function doGraphStatus() {
|
|
|
4756
4766
|
return;
|
|
4757
4767
|
}
|
|
4758
4768
|
success(`Graph index: ${envelope.fileCount || 0} files`);
|
|
4759
|
-
|
|
4769
|
+
// Per-tier file counts (what the build reported), then the worst tier present.
|
|
4770
|
+
const tiers = envelope.tiers ? Object.entries(envelope.tiers).sort((a, b) => b[1] - a[1]) : [];
|
|
4771
|
+
if (tiers.length) info(`Tiers: ${tiers.map(([t, n]) => `${t} ${n}`).join(" · ")}`);
|
|
4772
|
+
if (envelope.tier) info(`Lowest tier present: ${envelope.tier}`);
|
|
4773
|
+
if (envelope.tableCount || envelope.enumCount) info(`Database tables: ${envelope.tableCount}, enums: ${envelope.enumCount} (gsd-t graph table <name> · who-uses <name>)`);
|
|
4774
|
+
// [RULE] scip-missing-file-detected-never-silent — files the SCIP indexer never
|
|
4775
|
+
// produced a document for: their call edges stay unresolved, so who-calls is blind there.
|
|
4776
|
+
const miss = envelope.scipMissing;
|
|
4777
|
+
if (miss && miss.count > 0) {
|
|
4778
|
+
warn(`${miss.count} file(s) not in SCIP (call edges unresolved): ${miss.files.join(", ")}${miss.count > miss.files.length ? ", …" : ""}`);
|
|
4779
|
+
info("Re-run: gsd-t graph index (then check again)");
|
|
4780
|
+
}
|
|
4781
|
+
const ex = envelope.excludes;
|
|
4782
|
+
if (ex && ex.patterns && ex.patterns.length) {
|
|
4783
|
+
info(`Excluded by ${ex.source}: ${ex.patterns.join(", ")}`);
|
|
4784
|
+
} else {
|
|
4785
|
+
info("Excludes: none (add folders to .gsd-t/graph-exclude.json — { \"exclude\": [\"design/\"] })");
|
|
4786
|
+
}
|
|
4787
|
+
if (ex && ex.defaults && ex.defaults.length) info(`Excluded by default: ${ex.defaults.join(", ")}`);
|
|
4788
|
+
// Suggestions only — a folder is never dropped from the graph without the user listing it.
|
|
4789
|
+
const sug = envelope.excludeSuggestions;
|
|
4790
|
+
if (sug && sug.length) {
|
|
4791
|
+
info(`Folders that may not be the app (${sug[0].reason}) — add to .gsd-t/graph-exclude.json if so:`);
|
|
4792
|
+
for (const x of sug.slice(0, 15)) log(` ${x.folder} (${x.files} files)`);
|
|
4793
|
+
}
|
|
4760
4794
|
if (envelope.storeSize !== undefined) info(`Store size: ${envelope.storeSize} bytes`);
|
|
4761
4795
|
if (envelope.detail) log(JSON.stringify(envelope, null, 2));
|
|
4762
4796
|
}
|
|
@@ -4770,7 +4804,7 @@ function doGraphQuery(args) {
|
|
|
4770
4804
|
const verb = args[0];
|
|
4771
4805
|
if (!verb) {
|
|
4772
4806
|
error("Usage: gsd-t graph query <verb> [target]");
|
|
4773
|
-
info("Verbs: status, who-imports, who-calls, blast-radius, cluster, dead-code, orphan, dangling, test-impl");
|
|
4807
|
+
info("Verbs: status, who-imports, who-calls, body, blast-radius, who-uses [--writes|--reads], table, cluster, dead-code, orphan, dangling, test-impl");
|
|
4774
4808
|
return;
|
|
4775
4809
|
}
|
|
4776
4810
|
const envelope = _graphQueryCli([verb].concat(args.slice(1)));
|
|
@@ -4795,6 +4829,9 @@ function doGraph(args) {
|
|
|
4795
4829
|
case "who-calls": { const e = _graphQueryCli(["who-calls", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
|
|
4796
4830
|
case "blast-radius": { const e = _graphQueryCli(["blast-radius", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
|
|
4797
4831
|
case "body": { const e = _graphQueryCli(["body", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
|
|
4832
|
+
// Database tables (Drizzle pgTable/mysqlTable/sqliteTable + pgEnum).
|
|
4833
|
+
case "who-uses": { const e = _graphQueryCli(["who-uses"].concat(args.slice(1))); log(JSON.stringify(e, null, 2)); break; }
|
|
4834
|
+
case "table": { const e = _graphQueryCli(["table", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
|
|
4798
4835
|
case "tasks": doGraphTaskOutput(args[1] || "table"); break;
|
|
4799
4836
|
case "metrics": { // M99 D3-T2: append-only arm — rollup the telemetry ledger
|
|
4800
4837
|
const _metricsRollup = require("./gsd-t-graph-metrics-rollup.cjs");
|
|
@@ -4843,7 +4880,7 @@ function doGraph(args) {
|
|
|
4843
4880
|
}
|
|
4844
4881
|
default:
|
|
4845
4882
|
error(`Unknown graph subcommand: ${sub}`);
|
|
4846
|
-
info("Usage: gsd-t graph [index|status|query|who-imports|who-calls|blast-radius|body|tasks|metrics|wiring-log]");
|
|
4883
|
+
info("Usage: gsd-t graph [index|status|query|who-imports|who-calls|blast-radius|body|who-uses|table|tasks|metrics|wiring-log]");
|
|
4847
4884
|
info(" gsd-t graph --output json|table (task DAG)");
|
|
4848
4885
|
}
|
|
4849
4886
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# GSD-T: Estimate Rescale — Re-price an Existing Estimate on the AI-Assisted Scale
|
|
2
|
+
|
|
3
|
+
You are re-estimating an **existing** Tekyz estimate sheet with the AI-assisted sizing model, **without touching the original**. The tool copies the sheet's `T-Shirt Size Estimate` and `Team Mix` tabs into two new tabs — **`T-Shirt Size Estimate (Rescale)`** and **`Team Mix (Rescale)`** — and re-sizes the copies. The original tabs, the Overview tab and the Hilo Estimates Summaries index (which reads the Overview) are never written. `$ARGUMENTS` carries `--sheet <url>` and optionally the project type (`--project <type>`).
|
|
4
|
+
|
|
5
|
+
**THE SHEET IS WRITTEN BY A TOOL, NOT BY HAND.** `gsd-t estimate-sheet rescale` (`bin/gsd-t-estimate-sheet.cjs`, project-local `bin/` first, else the global `gsd-t`) lists the rows, validates your plan, makes the copies, writes the sizes, rebuilds the Team Mix and audits the copies by reading back — halting on any violation. **You never PUT a cell yourself.** Your output is judgment only: a size per row. Spec: `~/.claude/templates/estimate-sheet-spec.md` §1.4 (the sizing model) and §7 (rescale).
|
|
6
|
+
|
|
7
|
+
> **Client-billed work.** Dollar figures here are client deliverables, not GSD-T build cost.
|
|
8
|
+
|
|
9
|
+
## Human-in-the-Loop (SUPERVISED)
|
|
10
|
+
|
|
11
|
+
**Step 2 (sizing) PAUSES for review** before anything is written. Steps 1, 3 and 4 flow but show their result.
|
|
12
|
+
|
|
13
|
+
## Step 1: Inputs + the rows to re-size (MECHANICAL · show result)
|
|
14
|
+
|
|
15
|
+
1. Resolve the sheet from `--sheet <url>`; otherwise ask for the URL. A `403` means the sheet is not shared with the service account (`gsd-t-sheets-writer@ai-estimator-415612.iam.gserviceaccount.com`) — ask the operator to share it as Editor, then re-run.
|
|
16
|
+
2. **Confirm the project type** (spec §1.4) — it sets every row's multiplier:
|
|
17
|
+
|
|
18
|
+
| Project | `--project` | Multiplier |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| Greenfield, solo | `greenfield-solo` | × 1 |
|
|
21
|
+
| Greenfield, team | `greenfield-team` | × 3 |
|
|
22
|
+
| Yellow-field (existing app), solo | `yellowfield-solo` | × 2 |
|
|
23
|
+
| Yellow-field, team — isolated change | `yellowfield-team-isolated` | × 5 |
|
|
24
|
+
| Yellow-field, team — big blast radius | `yellowfield-team-wide` | × 7 |
|
|
25
|
+
|
|
26
|
+
A yellow-field team estimate chooses isolated vs wide **per row**, from the code graph of the app being changed (`gsd-t graph blast-radius <file-or-symbol>` in that repo) — not a guess. No graph for the app → say so and ask the operator which rows are wide.
|
|
27
|
+
3. List the rows: `gsd-t estimate-sheet rescale --sheet <url> --list`. It prints every sized item row (row number, module, functionality, requirement, phase, current sizes), the size-column labels, the current legend and the sheet's overhead factor. Show the operator the count and the current Low hours.
|
|
28
|
+
|
|
29
|
+
## Step 2: Re-size every row — JUDGMENT · PAUSE FOR REVIEW
|
|
30
|
+
|
|
31
|
+
Nobody hand-writes code. For each row and each size column:
|
|
32
|
+
|
|
33
|
+
1. Estimate the **solo AI-assisted minutes** — one person directing Claude, greenfield, counting the person's time AND Claude's time. Runway rate (26 h = 13 human + 13 Claude): ~10 min for a trivial change, ~40 min for a typical screen element or endpoint, 2–4½ hrs for the heaviest pieces. Read the Functionality and Low Level Requirements; do not scale the old size mechanically — the old sizes assumed hand-coding.
|
|
34
|
+
2. Run `gsd-t estimate-sheet size --solo-min <n> --project <type> --xxs` — it multiplies, adds task switching after the multiplier, and prints the size. `--xxs` is always on here: the (Rescale) tab carries **XXS (0.5 hr)** so sub-hour work does not round up to XS. Count switching once per row: pass `--switch-min 0` for the row's smaller column.
|
|
35
|
+
3. Build the plan — one entry per listed row, `functionality` copied **exactly** from the list (the tool matches row AND text, and halts on a row that moved):
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{ "items": [ { "row": 15, "functionality": "Route the existing permission resolver into every unguarded surface", "sizes": ["S", "M"] } ] }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`sizes` follows the size-column order the list printed; `""` for a column with no work. Every sized row must be in the plan — a missing row halts, because its copy would silently re-price on the new scale.
|
|
42
|
+
4. Write it to `.gsd-t/estimate-rescale-plan.json` and preview: `gsd-t estimate-sheet rescale --sheet <url> --plan .gsd-t/estimate-rescale-plan.json --dry-run` (old Low hours → new Low hours; nothing written).
|
|
43
|
+
5. **PAUSE:** show the operator the per-row table (row · functionality · solo min · multiplier · size) and the before → after Low hours. Wait for `continue` or corrections.
|
|
44
|
+
|
|
45
|
+
## Step 3: Write the (Rescale) tabs (MECHANICAL · show result)
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
gsd-t estimate-sheet rescale --sheet <url> --plan .gsd-t/estimate-rescale-plan.json # add --replace to rebuild existing (Rescale) tabs
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It copies the two tabs next to their originals, puts the AI-assisted scale in the copy's legend (XXS 0.0625 · XS 0.1 · S 0.25 · M 0.5 · L 1 · XL 2 · XXL 4 days; XXS in the row under XXL), switches the copy's Days formulas to an exact size lookup (the template's two-letter prefix would read `XX*` as XXS + XXL), writes the sizes, rebuilds `Team Mix (Rescale)` with the original tab's roster staffed from the copy's phase rollups, and audits the copies. `--suffix "<name>"` writes a separately named pair (`… (<name>)`) instead of `(Rescale)`. Existing (Rescale) tabs halt the run unless `--replace` — and `--replace` deletes only the two (Rescale) tabs. **Exit 4 = a ✗ — fix and re-run. Exit 64 = auth/API/input halt.** Show the tool's output verbatim.
|
|
52
|
+
|
|
53
|
+
## Step 4: Report
|
|
54
|
+
|
|
55
|
+
Sheet URL · project type · rows re-sized · Low hours before → after · the Team Mix (Rescale) roster and months · the audit result. State plainly that the original tabs and the Summary index still show the old figures.
|
|
56
|
+
|
|
57
|
+
## Document Ripple
|
|
58
|
+
|
|
59
|
+
- The Google Sheet (external) — only the two `(Rescale)` tabs are created or rebuilt.
|
|
60
|
+
- `.gsd-t/estimate-rescale-plan.json` — the sizing judgment, kept so the re-estimate is reproducible.
|
|
61
|
+
|
|
62
|
+
## ▶ Next Up
|
|
63
|
+
|
|
64
|
+
Standalone command — no auto-successor.
|
|
@@ -26,7 +26,7 @@ Read from `$ARGUMENTS` or `.gsd-t/estimate-config.json` if present; otherwise us
|
|
|
26
26
|
| `rate` | `$50/hr` | Blended hourly rate for the LOW figure. |
|
|
27
27
|
| `hoursPerDay` | `8` | Hours per person-day. |
|
|
28
28
|
| `sizeScale` | `XS 0.1 · S 0.25 · M 0.5 · L 1 · XL 2 · XXL 4` | AI-assisted T-shirt → person-days (spec §1.4). `write` puts it in the sheet legend. |
|
|
29
|
-
| `projectMultiplier` | greenfield solo ×1 · team ×
|
|
29
|
+
| `projectMultiplier` | greenfield solo ×1 · team ×3 · yellow-field solo ×2 · team ×5 isolated / ×7 wide | Solo AI minutes × this. Includes team overhead. Internal to the estimator — never on the sheet. |
|
|
30
30
|
| `totalMF` | `0.7` | Overhead multiplier. **The sheet's own MF list (`E4:F9`) wins when a sheet exists** — read it, never overwrite it. Hilo sheets run `0.9` (QA .3 · PM .1 · Analysis .1 · Deployment .05 · StdUps/Mtgs .15 · Buffer .2). |
|
|
31
31
|
| `highFactor` | `1.25` | HIGH = LOW × this (the sheet's `G4` wins when a sheet exists). |
|
|
32
32
|
| `sheetTemplateId` | (blank) | Optional template to clone; normally blank — the operator supplies the target sheet. |
|
|
@@ -58,7 +58,7 @@ Client-facing line-items carry **sequential, rational numbering starting at 1**.
|
|
|
58
58
|
|
|
59
59
|
For each in-scope item build a row per spec §1.2 — `A` Module · `B` User Type · `C` Functionality (**with the item id**) · `D` Low-Level Requirement · `E` Phase · `F` Web Portal size · `G` Backend/API size. `H:L` are formulas, never values.
|
|
60
60
|
|
|
61
|
-
- **Size in solo AI minutes, then let the tool pick the size** (spec §1.4). For each column (FE, BE) estimate the SOLO AI-assisted minutes — one person directing Claude — then run `gsd-t estimate-sheet size --solo-min <n> --project <type>`: it multiplies by the project type (greenfield solo ×1 · team ×
|
|
61
|
+
- **Size in solo AI minutes, then let the tool pick the size** (spec §1.4). For each column (FE, BE) estimate the SOLO AI-assisted minutes — one person directing Claude, counting the person's time AND Claude's time (Runway: ~10 min trivial · ~40 min typical · 2–4½ hr heaviest) — then run `gsd-t estimate-sheet size --solo-min <n> --project <type>`: it multiplies by the project type (greenfield solo ×1 · team ×3 · yellow-field solo ×2 · team ×5 isolated / ×7 big blast radius), adds task switching after the multiplier, and prints the size. Blast radius is measured with `gsd-t graph blast-radius`, not guessed. Count switching once per item (pass `--switch-min 0` for the smaller column).
|
|
62
62
|
- **Bare codes in `F:G`** — `XS` `S` `M` `L` `XL` `XXL`. Never the legend text (`"XS - Extra Small"`). Scale: **XS 0.1 · S 0.25 · M 0.5 · L 1 · XL 2 · XXL 4** person-days.
|
|
63
63
|
- The sheet computes: `Days = F+G` → `MFactor Days = Days × Total MF` → `Total Days` → `LOW $ = Total × 8 × rate` → `HIGH $ = LOW × high factor`. The overhead factors and high factor are per-project settings the operator adjusts by hand.
|
|
64
64
|
- **Cluster by fix-shape to size fast**: "add existing guard to N routes" (XS–S, repeated) vs "new backend surface" (M, +FE) vs "config / single route" (XS). Size the cluster once, apply to members.
|
package/commands/gsd-t-help.md
CHANGED
|
@@ -383,6 +383,12 @@ Use these when user asks for help on a specific command:
|
|
|
383
383
|
- **Updates**: the Tekyz estimate Google Sheet (three tabs, written and read-back-audited by `gsd-t estimate-sheet` from `.gsd-t/estimate-plan.json`) + optional `share/<Repo>-estimate-redteam-notes.md` (and, if renumbered, the source doc/docs/scan files)
|
|
384
384
|
- **Use when**: You need a client-facing paid estimate (T-shirt sizing, dollar range, staffed team by month) from a scan, a gap analysis, or a requirements/feature/app spec. **SUPERVISED** — judgment phases (sizing, adjustments, Team Mix, Red Team) pause for your review; **you are the final arbiter** of an Estimate Red Team that challenges the numbers. Accepts `--sheet <url>`. Sizes are AI-assisted (solo AI minutes × project multiplier + task switching → `gsd-t estimate-sheet size`; spec §1.4). Rate + factors are parameterized (default Tekyz; the sheet's own MF list wins). Playbook: `~/.claude/playbooks/tekyz-estimation-and-prd-playbook.md`
|
|
385
385
|
|
|
386
|
+
### estimate-rescale
|
|
387
|
+
- **Summary**: Re-price an EXISTING Tekyz estimate sheet on the AI-assisted scale without touching it — copies the T-Shirt and Team Mix tabs into `T-Shirt Size Estimate (Rescale)` / `Team Mix (Rescale)`, re-sizes every row (solo AI minutes × project multiplier + task switching, with an XXS 0.5 hr size), rebuilds the Team Mix and audits the copies
|
|
388
|
+
- **Auto-invoked**: No
|
|
389
|
+
- **Updates**: the two `(Rescale)` tabs on the sheet (original tabs, Overview and the estimates index are never written) + `.gsd-t/estimate-rescale-plan.json`
|
|
390
|
+
- **Use when**: An estimate was sized on the old hand-coding day scale and you want the AI-assisted figure beside it. **SUPERVISED** — the per-row sizing pauses for your review. Accepts `--sheet <url>` and `--project <greenfield-solo|greenfield-team|yellowfield-solo|yellowfield-team-isolated|yellowfield-team-wide>`. Spec: `~/.claude/templates/estimate-sheet-spec.md` §1.4 and §7
|
|
391
|
+
|
|
386
392
|
### stories
|
|
387
393
|
- **Summary**: Generate a dev-team handoff document in the Tekyz user-stories format — discrete user stories with workflows, grouped acceptance criteria, per-story flow diagrams (Mermaid rendered to embedded images), and mapped test-case tables — from any source (scan register, requirements doc, design contract, or a reverse-engineered codebase)
|
|
388
394
|
- **Auto-invoked**: No
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.24.10",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code \u2014 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -281,8 +281,47 @@ function buildNoGraphReason(found, cls) {
|
|
|
281
281
|
].join("\n");
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// --- Database-table questions ---------------------------------------------
|
|
285
|
+
//
|
|
286
|
+
// `grep -rn "insert(scheduleEvents"` or `grep pgTable` asks about a database
|
|
287
|
+
// table. The graph indexes Drizzle tables (who-uses / table / blast-radius), so
|
|
288
|
+
// the block names those verbs and the table instead of sending the caller to
|
|
289
|
+
// who-calls, which has no answer for a table. [RULE] search-guard-routes-table-questions
|
|
290
|
+
|
|
291
|
+
const TABLE_BUILDER_SHAPE = /\b(pgTable|mysqlTable|sqliteTable|pgEnum)\b/;
|
|
292
|
+
const TABLE_OP_SHAPE = /\.?\b(from|innerJoin|leftJoin|rightJoin|fullJoin|insert|update|delete|references)\s*\\?\(\s*(?:\\?\(\s*\\?\)\s*=>\s*)?([A-Za-z_$][\w$]*)/;
|
|
293
|
+
const QUERY_API_SHAPE = /\bquery\\?\.([A-Za-z_$][\w$]*)/;
|
|
294
|
+
|
|
295
|
+
/** → { table: name|null, why } when the pattern reads as a table question; else null. */
|
|
296
|
+
function tableQuestion(pattern) {
|
|
297
|
+
const p = String(pattern);
|
|
298
|
+
const op = TABLE_OP_SHAPE.exec(p);
|
|
299
|
+
if (op) return { table: op[2], why: "a Drizzle table operation (" + op[1] + ")" };
|
|
300
|
+
const q = QUERY_API_SHAPE.exec(p);
|
|
301
|
+
if (q && /\bdb\b|\btx\b/.test(p)) return { table: q[1], why: "a Drizzle relational query (db.query.<table>)" };
|
|
302
|
+
if (TABLE_BUILDER_SHAPE.test(p)) return { table: null, why: "a Drizzle table declaration" };
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function tableRouting(tq) {
|
|
307
|
+
const t = tq.table === null ? "<table>" : tq.table;
|
|
308
|
+
return [
|
|
309
|
+
"This reads as a database-table question (" + tq.why + "). The graph indexes tables:",
|
|
310
|
+
"",
|
|
311
|
+
" gsd-t graph who-uses " + t + " - every function / route / method that uses it",
|
|
312
|
+
" gsd-t graph who-uses " + t + " --writes - only inserts / updates / deletes (--reads for reads)",
|
|
313
|
+
" gsd-t graph table " + t + " - its columns and foreign keys, both directions",
|
|
314
|
+
" gsd-t graph blast-radius " + t + " - tables that reference it + the code that uses it",
|
|
315
|
+
"",
|
|
316
|
+
"A table is found by its code name (scheduleEvents) or its SQL name (schedule_events).",
|
|
317
|
+
"",
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
|
|
284
321
|
function buildStructuralReason(found, cls) {
|
|
285
322
|
const lines = [];
|
|
323
|
+
const tq = tableQuestion(found.pattern);
|
|
324
|
+
if (tq !== null) lines.push(...tableRouting(tq));
|
|
286
325
|
|
|
287
326
|
const symbol = cls.symbol === null ? found.pattern : cls.symbol;
|
|
288
327
|
const verb = cls.verb === null ? "who-calls" : cls.verb;
|
|
@@ -298,6 +337,7 @@ function buildStructuralReason(found, cls) {
|
|
|
298
337
|
" gsd-t graph " + verb + " " + symbol,
|
|
299
338
|
"",
|
|
300
339
|
"Other verbs: who-imports, who-calls, defines, blast-radius, body.",
|
|
340
|
+
"A database table (Drizzle)? gsd-t graph who-uses " + symbol + " / gsd-t graph table " + symbol,
|
|
301
341
|
"",
|
|
302
342
|
"If this really is a text search - a phrase in prose, a key in config, a string in a",
|
|
303
343
|
"document - scope it to the files the graph does not index, and it will run:",
|
|
@@ -308,7 +348,8 @@ function buildStructuralReason(found, cls) {
|
|
|
308
348
|
}
|
|
309
349
|
|
|
310
350
|
function buildUnclearReason(found, cls) {
|
|
311
|
-
|
|
351
|
+
const tq = tableQuestion(found.pattern);
|
|
352
|
+
return (tq === null ? [] : tableRouting(tq)).concat([
|
|
312
353
|
"This search could be asking about code structure, and that has to be settled before",
|
|
313
354
|
"it runs - a guess in either direction is how the graph rule stopped having teeth.",
|
|
314
355
|
"",
|
|
@@ -320,12 +361,65 @@ function buildUnclearReason(found, cls) {
|
|
|
320
361
|
" Structure (who calls, who imports, where defined):",
|
|
321
362
|
" gsd-t graph who-calls <symbol>",
|
|
322
363
|
"",
|
|
364
|
+
" A database table (who reads or writes it, its columns and foreign keys):",
|
|
365
|
+
" gsd-t graph who-uses <table> / gsd-t graph table <table>",
|
|
366
|
+
"",
|
|
323
367
|
" Text in files the graph does not index (.md, .json, .sql, config, prose):",
|
|
324
368
|
" add --include='*.md' (or the right extensions) and run it again",
|
|
325
|
-
].join("\n");
|
|
369
|
+
]).join("\n");
|
|
326
370
|
}
|
|
327
371
|
|
|
328
372
|
|
|
373
|
+
// --- A path forward after an incomplete, empty graph answer ----------------
|
|
374
|
+
//
|
|
375
|
+
// When the graph just answered [] and said its answer was incomplete, blocking
|
|
376
|
+
// the grep with "ask the graph" sends the caller back to the answer that failed
|
|
377
|
+
// them - a dead end. The query CLI records that answer; this names what IS
|
|
378
|
+
// allowed next: open the files holding the unresolved call sites (Read is not
|
|
379
|
+
// blocked), and re-index so SCIP can resolve them. This is a message, not a
|
|
380
|
+
// bypass - the search itself stays blocked.
|
|
381
|
+
// [RULE] incomplete-empty-answer-names-a-path-forward
|
|
382
|
+
|
|
383
|
+
const INCOMPLETE_MARKER_MAX_AGE_MS = 30 * 60 * 1000;
|
|
384
|
+
|
|
385
|
+
/** Throws when the marker exists but cannot be read - the caller denies with that. */
|
|
386
|
+
function incompleteAnswerNote(projectDir) {
|
|
387
|
+
const file = path.join(projectDir, ".gsd-t", "graphDB", "last-incomplete-answer.json");
|
|
388
|
+
if (!fs.existsSync(file)) return "";
|
|
389
|
+
const m = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
390
|
+
if (!m || Date.now() - Date.parse(m.ts) > INCOMPLETE_MARKER_MAX_AGE_MS) return "";
|
|
391
|
+
const lines = [
|
|
392
|
+
"",
|
|
393
|
+
"",
|
|
394
|
+
"The graph's last answer (" + m.verb + " " + m.target + ") was EMPTY and marked incomplete:",
|
|
395
|
+
" " + (m.note || "some call edges are unresolved"),
|
|
396
|
+
"",
|
|
397
|
+
"Allowed path forward:",
|
|
398
|
+
];
|
|
399
|
+
const sites = m.unresolvedCallSites;
|
|
400
|
+
if (sites && Array.isArray(sites.files) && sites.files.length) {
|
|
401
|
+
lines.push(" 1. Open these files with the Read tool - they hold " + sites.count +
|
|
402
|
+
" unresolved call site(s) naming the target:");
|
|
403
|
+
for (const f of sites.files.slice(0, 10)) lines.push(" " + f);
|
|
404
|
+
if (sites.files.length > 10) lines.push(" ... (" + sites.files.length + " files; full list in the query's coverage.unresolvedCallSites)");
|
|
405
|
+
} else {
|
|
406
|
+
lines.push(" 1. gsd-t graph body <symbol> - read the definition, then Read the files that import its module");
|
|
407
|
+
lines.push(" (gsd-t graph who-imports <file>)");
|
|
408
|
+
}
|
|
409
|
+
lines.push(" 2. gsd-t graph status - lists files missing from SCIP; gsd-t graph index re-resolves them");
|
|
410
|
+
return lines.join("\n");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The note, or a deny naming why the marker could not be read. */
|
|
414
|
+
function pathForwardOrDeny(projectDir) {
|
|
415
|
+
try {
|
|
416
|
+
return incompleteAnswerNote(projectDir);
|
|
417
|
+
} catch (e) {
|
|
418
|
+
deny("The graph's last incomplete-answer record (.gsd-t/graphDB/last-incomplete-answer.json) " +
|
|
419
|
+
"could not be read: " + e.message + "\n\nDelete it and re-run the graph query.");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
329
423
|
// --- Recording the decision ------------------------------------------------
|
|
330
424
|
//
|
|
331
425
|
// One line per block, into the same ledger the graph's own tooling writes. The
|
|
@@ -473,13 +567,13 @@ function decide(raw) {
|
|
|
473
567
|
|
|
474
568
|
if (cls.verdict === "structural") {
|
|
475
569
|
recordBlock(projectDir, f.program, f.pattern, "structural");
|
|
476
|
-
if (hasGraph) deny(buildStructuralReason(f, cls));
|
|
570
|
+
if (hasGraph) deny(buildStructuralReason(f, cls) + pathForwardOrDeny(projectDir));
|
|
477
571
|
else deny(buildNoGraphReason(f, cls));
|
|
478
572
|
return;
|
|
479
573
|
}
|
|
480
574
|
if (cls.verdict === "unclear") {
|
|
481
575
|
recordBlock(projectDir, f.program, f.pattern, "unclear");
|
|
482
|
-
deny(buildUnclearReason(f, cls));
|
|
576
|
+
deny(buildUnclearReason(f, cls) + (hasGraph ? pathForwardOrDeny(projectDir) : ""));
|
|
483
577
|
return;
|
|
484
578
|
}
|
|
485
579
|
}
|