@tekyzinc/gsd-t 5.21.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.
@@ -173,7 +173,7 @@ function extractImportedNames(importNode) {
173
173
  * SCIP upgrade will replace these with fully-resolved funcIds.
174
174
  *
175
175
  * [RULE] who-calls-function-identity-disambiguated: src is the funcId of the
176
- * enclosing function (or a synthetic _anon@line if top-level); dst is the
176
+ * enclosing function (or a synthetic route/anonymous caller id, else _toplevel); dst is the
177
177
  * callee's best-effort funcId.
178
178
  */
179
179
  function resolveCalleeName(fnNode) {
@@ -185,6 +185,57 @@ function resolveCalleeName(fnNode) {
185
185
  return null;
186
186
  }
187
187
 
188
+ // ── Anonymous callers (route handlers + callbacks) ───────────────────────────
189
+ //
190
+ // A call inside an anonymous function has no named caller. Before this, such a
191
+ // call was credited to `file#_toplevel` (and a const-arrow body was walked twice,
192
+ // crediting every call in it to _toplevel as well as to the real function). A
193
+ // Hono/Express route file is almost entirely `router.get('/p', mw, async (c) =>
194
+ // { helper() })`, so who-calls answered with one meaningless "_toplevel" caller
195
+ // or — when the dst never resolved — nothing at all.
196
+ //
197
+ // Now every anonymous body gets a caller identity that says where it is:
198
+ // route handler → `file#GET /locations/:id@3217` (method + path + line)
199
+ // other callback → the enclosing NAMED function, when there is one
200
+ // top-level cb → `file#anonymous@<line>`
201
+ // [RULE] anonymous-caller-synthesized-never-dropped
202
+
203
+ const ROUTE_METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'del', 'all', 'options', 'head', 'use']);
204
+
205
+ function isAnonymousFn(node) {
206
+ if (!node) return false;
207
+ if (node.type === 'arrow_function') return true;
208
+ if (node.type === 'function' || node.type === 'function_expression') {
209
+ return !node.childForFieldName('name');
210
+ }
211
+ return false;
212
+ }
213
+
214
+ /**
215
+ * `router.get('/path', ...)` → "GET /path"; anything else → null.
216
+ * The method match is case-insensitive (a domain value, not an identifier).
217
+ */
218
+ function routeLabel(fnNode, argsNode) {
219
+ if (!fnNode || fnNode.type !== 'member_expression' || !argsNode) return null;
220
+ const prop = fnNode.childForFieldName('property');
221
+ if (!prop) return null;
222
+ const method = prop.text.toLowerCase();
223
+ if (!ROUTE_METHODS.has(method)) return null;
224
+ const first = argsNode.namedChild(0);
225
+ if (!first || (first.type !== 'string' && first.type !== 'template_string')) return null;
226
+ return `${method.toUpperCase()} ${first.text.slice(1, -1)}`;
227
+ }
228
+
229
+ /**
230
+ * A registration with no anonymous handler (`router.get('/x', requireAuth(), handler)`)
231
+ * is only treated as a route when its first argument looks like a URL path, so a
232
+ * `cache.get('key', compute())` is never mistaken for one.
233
+ */
234
+ function isRoutePath(firstArg) {
235
+ if (!firstArg) return false;
236
+ return firstArg.text.slice(1).startsWith('/');
237
+ }
238
+
188
239
  // ── Python-specific extraction ────────────────────────────────────────────────
189
240
 
190
241
  function walkPython(rootNode, relPath, entities, edges) {
@@ -298,6 +349,7 @@ function walkPython(rootNode, relPath, entities, edges) {
298
349
  source: enclosingFuncId,
299
350
  target: `UNRESOLVED#${calleeName}`,
300
351
  line: node.startPosition.row + 1,
352
+ col: fn.startPosition.column, // [RULE] scip-tier-proportional
301
353
  });
302
354
  }
303
355
  }
@@ -374,13 +426,58 @@ function walkTSJS(rootNode, relPath, entities, edges) {
374
426
  source: srcId,
375
427
  target: `UNRESOLVED#${calleeName}`,
376
428
  line: node.startPosition.row + 1,
429
+ // callee column — lets the SCIP upgrader tell a call the compiler saw
430
+ // from one it never looked at. [RULE] scip-tier-proportional
431
+ col: fn.startPosition.column,
432
+ });
433
+ }
434
+
435
+ // Route registration: the route itself becomes the caller, named by
436
+ // method + path + line — for its anonymous handler AND for every
437
+ // middleware call in its arguments (`requireAuth()`, `requireLocationTenant()`).
438
+ // Crediting middleware to the enclosing scope collapsed 95 routes into one
439
+ // `_toplevel` caller per file. [RULE] anonymous-caller-synthesized-never-dropped
440
+ // [RULE] route-middleware-args-credited-to-route
441
+ const label = routeLabel(fn, args);
442
+ const handlers = label ? args.namedChildren.filter(isAnonymousFn) : [];
443
+ const middlewareCalls = label && isRoutePath(args.namedChild(0))
444
+ ? args.namedChildren.slice(1).filter((a) => a.type === 'call_expression') : [];
445
+ if (handlers.length || middlewareCalls.length) {
446
+ const line = node.startPosition.row + 1;
447
+ const routeId = `${relPath}#${label}@${line}`;
448
+ entities.push({
449
+ id: routeId,
450
+ name: label,
451
+ type: 'function',
452
+ line,
453
+ endLine: node.endPosition.row + 1,
454
+ exported: false,
455
+ synthetic: 'route-handler',
377
456
  });
457
+ // Node objects are re-created per access, so match handlers by position.
458
+ const handlerStarts = new Set(handlers.map((h) => h.startIndex));
459
+ walk(fn, enclosingFuncId, enclosingClass);
460
+ for (let i = 0; i < args.childCount; i++) {
461
+ const arg = args.child(i);
462
+ if (!handlerStarts.has(arg.startIndex) || !isAnonymousFn(arg)) { walk(arg, routeId, enclosingClass); continue; }
463
+ for (let j = 0; j < arg.childCount; j++) walk(arg.child(j), routeId, enclosingClass);
464
+ }
465
+ return;
378
466
  }
379
467
  }
380
468
 
381
469
  // Fall through to walk children (the call_expression can contain more nodes)
382
470
  }
383
471
 
472
+ // ── anonymous function with no named scope around it ─────────────────
473
+ // Inside a named function, a callback's calls belong to that function
474
+ // (enclosingFuncId passes through). Outside one, give it a located id.
475
+ if (!enclosingFuncId && isAnonymousFn(node)) {
476
+ const anonId = `${relPath}#anonymous@${node.startPosition.row + 1}`;
477
+ for (let i = 0; i < node.childCount; i++) walk(node.child(i), anonId, enclosingClass);
478
+ return;
479
+ }
480
+
384
481
  // ── function_declaration ──────────────────────────────────────────────
385
482
  if (t === 'function_declaration' || t === 'function') {
386
483
  const nameNode = node.childForFieldName('name');
@@ -451,6 +548,7 @@ function walkTSJS(rootNode, relPath, entities, edges) {
451
548
  // const foo = () => ... or const foo = function...
452
549
  if (t === 'lexical_declaration' || t === 'variable_declaration') {
453
550
  const isExp = isExportedNode(node);
551
+ const walkedValues = new Set(); // bodies already walked under their own funcId
454
552
  for (let i = 0; i < node.namedChildCount; i++) {
455
553
  const decl = node.namedChild(i);
456
554
  if (decl.type === 'variable_declarator') {
@@ -474,10 +572,23 @@ function walkTSJS(rootNode, relPath, entities, edges) {
474
572
  for (let j = 0; j < valueNode.childCount; j++) {
475
573
  walk(valueNode.child(j), funcId, enclosingClass);
476
574
  }
575
+ walkedValues.add(valueNode.startIndex);
477
576
  }
478
577
  }
479
578
  }
480
- // Continue walking children for the declaration itself
579
+ // Walk the rest of the declaration, but NOT a function body already walked
580
+ // above — walking it again credited each of its calls to a second, wrong
581
+ // caller (_toplevel). [RULE] anonymous-caller-synthesized-never-dropped
582
+ for (let i = 0; i < node.childCount; i++) {
583
+ const child = node.child(i);
584
+ if (child.type !== 'variable_declarator') { walk(child, enclosingFuncId, enclosingClass); continue; }
585
+ for (let j = 0; j < child.childCount; j++) {
586
+ const part = child.child(j);
587
+ if (walkedValues.has(part.startIndex) && part.type !== 'identifier') continue;
588
+ walk(part, enclosingFuncId, enclosingClass);
589
+ }
590
+ }
591
+ return;
481
592
  }
482
593
 
483
594
  // ── export_statement (bare re-exports: export { x, y }) ──────────────
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+ /**
3
+ * gsd-t-graph-exclude.cjs — per-project exclude list for the code graph.
4
+ *
5
+ * Some repos carry large trees that are not the production application —
6
+ * design exports, Figma-generated component dumps, prototypes, archived
7
+ * scripts. They bloat the index, slow SCIP, and put look-alike symbols into
8
+ * who-calls. A project lists them in `.gsd-t/graph-exclude.json`:
9
+ *
10
+ * { "exclude": ["design/", "figma-exports/**", "scripts/archive/", "**\/*.figma.tsx"] }
11
+ *
12
+ * Pattern rules (paths are repo-relative, forward slashes, matched case-insensitively):
13
+ * - no `*` → a path prefix: "design/" or "design" excludes that folder
14
+ * - `*` → any run of characters except "/"
15
+ * - `**` → any run of characters including "/"
16
+ *
17
+ * The indexer AND the freshness walker both read this module, so a file the
18
+ * indexer skipped never shows up as a phantom "new file" on the next query.
19
+ * [RULE] freshness-excludes-match-indexer-skipdirs
20
+ *
21
+ * A malformed file HALTS (throws): silently ignoring it would index exactly the
22
+ * trees the user asked to keep out, with nothing saying so.
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const EXCLUDE_FILE = path.join('.gsd-t', 'graph-exclude.json');
29
+ const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g;
30
+
31
+ function toRegex(pattern) {
32
+ const p = String(pattern).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
33
+ if (!p) return null;
34
+ if (!p.includes('*')) {
35
+ // Plain prefix: the path itself, or anything under it.
36
+ const base = p.replace(/\/+$/, '').replace(REGEX_SPECIALS, '\\$&');
37
+ return new RegExp(`^${base}(/|$)`, 'i');
38
+ }
39
+ let re = '';
40
+ for (let i = 0; i < p.length; i++) {
41
+ const c = p[i];
42
+ if (c === '*' && p[i + 1] === '*') {
43
+ i++;
44
+ if (p[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
45
+ } else if (c === '*') re += '[^/]*';
46
+ else re += c.replace(REGEX_SPECIALS, '\\$&');
47
+ }
48
+ return new RegExp(`^${re}(/|$)`, 'i');
49
+ }
50
+
51
+ function validateList(parsed) {
52
+ const list = parsed ? parsed.exclude : undefined;
53
+ if (!Array.isArray(list)) return false;
54
+ return list.every((x) => typeof x === 'string');
55
+ }
56
+
57
+ /**
58
+ * Load the project's exclude list. Throws on a malformed file.
59
+ * @returns {{ patterns: string[], source: string|null, isExcluded: (relPath: string) => boolean }}
60
+ */
61
+ function loadGraphExcludes(projectRoot) {
62
+ const file = path.join(projectRoot, EXCLUDE_FILE);
63
+ if (!fs.existsSync(file)) return { patterns: [], source: null, isExcluded: () => false };
64
+ let parsed;
65
+ try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
66
+ catch (e) { throw new Error(`${EXCLUDE_FILE} is not valid JSON (${e.message}) — fix it or delete it`); }
67
+ if (!validateList(parsed)) {
68
+ throw new Error(`${EXCLUDE_FILE} must be { "exclude": ["folder/", "glob/**", ...] }`);
69
+ }
70
+ const regexes = parsed.exclude.map(toRegex).filter(Boolean);
71
+ return {
72
+ patterns: parsed.exclude,
73
+ source: EXCLUDE_FILE,
74
+ isExcluded: (relPath) => {
75
+ const rel = String(relPath).split(path.sep).join('/');
76
+ return regexes.some((r) => r.test(rel));
77
+ },
78
+ };
79
+ }
80
+
81
+ module.exports = { loadGraphExcludes, EXCLUDE_FILE, _toRegex: toRegex };
@@ -220,7 +220,11 @@ function compute_touched_files(db, projectRoot) {
220
220
  }
221
221
 
222
222
  // Walk the current working tree
223
- const liveFiles = walkTree(projectRoot);
223
+ // Same exclude list as the indexer, so an excluded file is never a phantom ADD
224
+ // and a newly-excluded one drops out as a DELETE. [RULE] freshness-excludes-match-indexer-skipdirs
225
+ const { isExcluded } = require('./gsd-t-graph-exclude.cjs').loadGraphExcludes(projectRoot);
226
+ const liveFiles = walkTree(projectRoot)
227
+ .filter((abs) => !isExcluded(path.relative(projectRoot, abs)));
224
228
 
225
229
  const liveRelSet = new Set();
226
230
  const edits = [];
@@ -196,18 +196,21 @@ function contentHash(absPath) {
196
196
 
197
197
  function enumerateFiles(root) {
198
198
  const results = [];
199
+ // Project exclude list (.gsd-t/graph-exclude.json) — ancillary trees such as
200
+ // design/Figma exports that are not the application. Throws on a malformed file.
201
+ const { isExcluded } = require('./gsd-t-graph-exclude.cjs').loadGraphExcludes(root);
199
202
  function walk(dir) {
200
203
  let entries;
201
204
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
202
205
  catch { return; }
203
206
  for (const e of entries) {
207
+ const absPath = path.join(dir, e.name);
208
+ const relPath = path.relative(root, absPath).split(path.sep).join('/');
204
209
  if (e.isDirectory()) {
205
- if (!shouldSkipDir(e.name)) walk(path.join(dir, e.name));
210
+ if (!shouldSkipDir(e.name) && !isExcluded(relPath)) walk(absPath);
206
211
  } else if (e.isFile()) {
207
212
  const ext = path.extname(e.name).toLowerCase();
208
- if (PARSED_EXTS.has(ext)) {
209
- const absPath = path.join(dir, e.name);
210
- const relPath = path.relative(root, absPath).split(path.sep).join('/');
213
+ if (PARSED_EXTS.has(ext) && !isExcluded(relPath)) {
211
214
  results.push({ absPath, relPath, ext });
212
215
  }
213
216
  }
@@ -404,12 +407,16 @@ function parse_and_put(absPath, relPath, options) {
404
407
  finalEntities = upgraded.entities;
405
408
  finalEdges = upgraded.edges;
406
409
  }
407
- } else if (existingTier === 'compiler-accurate') {
410
+ } else if (existingTier === 'compiler-accurate' || existingTier === 'compiler-partial') {
408
411
  // [RULE] reindex-tier-never-silently-downgraded — no SCIP context this call
409
412
  // (e.g. a metadata-only re-index like M98's body end-line backfill). A file that
410
413
  // WAS compiler-accurate must NOT silently drop to plain floor; label it STALE-SCIP
411
414
  // so the tier reflects "previously accurate, not re-resolved" rather than a lie.
412
415
  tier = 'tree-sitter-floor-STALE-SCIP';
416
+ } else if (existingTier === 'tree-sitter-floor-SCIP-MISSING') {
417
+ // Same principle: a file SCIP never indexed stays labelled as such until a
418
+ // full build (with SCIP) says otherwise. [RULE] scip-missing-file-detected-never-silent
419
+ tier = existingTier;
413
420
  }
414
421
 
415
422
  // Normalize edges to store schema (map from parser-floor shape to store shape)
@@ -530,8 +537,12 @@ function build_index(repoRoot, options) {
530
537
  let edgeCount = 0;
531
538
  let tierFloor = 0;
532
539
  let tierUpgraded = 0;
540
+ let tierPartial = 0; // [RULE] scip-tier-proportional
541
+ let callEdges = 0;
542
+ let callEdgesUnresolved = 0;
533
543
  let errors = 0;
534
544
  const skippedFiles = [];
545
+ const scipMissing = []; // files SCIP ran for but produced no document for
535
546
 
536
547
  // Stream: parse + put each file one at a time (never accumulate the full set)
537
548
  for (const { absPath, relPath } of files) {
@@ -541,7 +552,14 @@ function build_index(repoRoot, options) {
541
552
  entityCount += result.entities.length;
542
553
  edgeCount += result.edges.length;
543
554
  if (result.tier === 'compiler-accurate') tierUpgraded++;
555
+ else if (result.tier === 'compiler-partial') tierPartial++;
544
556
  else tierFloor++;
557
+ for (const e of result.edges) {
558
+ if (e.kind !== 'call-site' && e.kind !== 'CALL') continue;
559
+ callEdges++;
560
+ if (String(e.target || e.dst).startsWith('UNRESOLVED#')) callEdgesUnresolved++;
561
+ }
562
+ if (result.tier === 'tree-sitter-floor-SCIP-MISSING') scipMissing.push(relPath);
545
563
  if (typeof onProgress === 'function') {
546
564
  onProgress({ file: relPath, tier: result.tier, fileCount, total: files.length });
547
565
  }
@@ -592,11 +610,23 @@ function build_index(repoRoot, options) {
592
610
  console.error(`\x1b[33m[IDX NOTICE]\x1b[0m ${scipNotice}`);
593
611
  }
594
612
 
613
+ // A file the SCIP indexer skipped keeps every call target unresolved, so
614
+ // who-calls cannot see its callers. Loud, every build — not a fallback, a
615
+ // report. `gsd-t graph status` repeats it. [RULE] scip-missing-file-detected-never-silent
616
+ if (scipMissing.length) {
617
+ const shown = scipMissing.slice(0, 10).join(', ');
618
+ warn(`${scipMissing.length} file(s) not in the SCIP index — their call edges stay unresolved ` +
619
+ `(tier tree-sitter-floor-SCIP-MISSING): ${shown}${scipMissing.length > 10 ? ', …' : ''}`);
620
+ }
621
+
595
622
  return {
596
623
  fileCount,
597
624
  entityCount,
598
625
  edgeCount,
599
- tier: { floor: tierFloor, upgraded: tierUpgraded },
626
+ tier: { floor: tierFloor, upgraded: tierUpgraded, partial: tierPartial },
627
+ callEdges,
628
+ callEdgesUnresolved,
629
+ scipMissing,
600
630
  scipAvailable: scipActive,
601
631
  scipNotice,
602
632
  errors,
@@ -648,7 +678,11 @@ if (require.main === module) {
648
678
  });
649
679
 
650
680
  good(`Indexed ${result.fileCount} files, ${result.entityCount} entities, ${result.edgeCount} edges in ${result.durationMs}ms`);
651
- good(`Tiers — floor: ${result.tier.floor}, compiler-accurate: ${result.tier.upgraded}`);
681
+ good(`Tiers — floor: ${result.tier.floor}, compiler-accurate: ${result.tier.upgraded}, compiler-partial: ${result.tier.partial}`);
682
+ if (result.callEdges) {
683
+ info(`Call edges unresolved: ${result.callEdgesUnresolved} of ${result.callEdges} ` +
684
+ `(${(100 * result.callEdgesUnresolved / result.callEdges).toFixed(1)}% — library calls such as console.log never resolve)`);
685
+ }
652
686
  if (result.errors > 0) warn(`${result.errors} files had parse errors (skipped)`);
653
687
 
654
688
  const envelope = {
@@ -660,6 +694,7 @@ if (require.main === module) {
660
694
  entityCount: result.entityCount,
661
695
  edgeCount: result.edgeCount,
662
696
  tier: result.tier,
697
+ scipMissingCount: result.scipMissing.length,
663
698
  errors: result.errors,
664
699
  durationMs: result.durationMs,
665
700
  };