@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.
@@ -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,313 @@ 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
+
239
+ // ── Drizzle database tables ──────────────────────────────────────────────────
240
+ //
241
+ // `export const scheduleEvents = pgTable('schedule_events', { ...columns }, (t) => [...])`
242
+ // declares a database table. Before this, it was a constant with no entity, so
243
+ // `body scheduleEvents` said not-found and every table question dead-ended.
244
+ //
245
+ // Now each declaration is a `table` (or `enum`) entity whose meta carries the SQL
246
+ // name and the columns (code name, SQL name, type builder, notNull, primaryKey,
247
+ // foreign key). Every use of a table in code becomes an edge from the function,
248
+ // method or route handler that uses it:
249
+ // TABLE-READ .from(T) / .innerJoin(T) / db.query.T.* / a column ref T.col
250
+ // TABLE-WRITE .insert(T) / .update(T) / .delete(T) / a column ref inside one
251
+ // dst = `TABLE#<code name>#<operation>@<line>`. The query layer keeps only dsts
252
+ // that name an indexed table. [RULE] drizzle-table-entity-and-usage-edges
253
+
254
+ const TABLE_BUILDERS = { pgTable: 'pg', mysqlTable: 'mysql', sqliteTable: 'sqlite' };
255
+ const ENUM_BUILDERS = { pgEnum: 'pg' };
256
+ const TABLE_READ_OPS = new Set(['from', 'innerJoin', 'leftJoin', 'rightJoin', 'fullJoin', 'crossJoin']);
257
+ const TABLE_WRITE_OPS = new Set(['insert', 'update', 'delete']);
258
+ const TABLE_OPS = new Set([...TABLE_READ_OPS, ...TABLE_WRITE_OPS]);
259
+ // Identifier positions that declare or re-export a name rather than use it.
260
+ const NON_USE_PARENTS = new Set([
261
+ 'import_specifier', 'export_specifier', 'namespace_import', 'required_parameter', 'optional_parameter',
262
+ ]);
263
+
264
+ function stringValue(node) {
265
+ if (!node) return null;
266
+ if (node.type === 'string') return node.text.slice(1, -1);
267
+ if (node.type === 'template_string' && !node.text.includes('${')) return node.text.slice(1, -1);
268
+ return null;
269
+ }
270
+
271
+ function unwrapParens(node) {
272
+ let n = node;
273
+ while (n && n.type === 'parenthesized_expression') n = n.namedChild(0);
274
+ return n;
275
+ }
276
+
277
+ /** The columns argument: an object literal, or `(t) => ({ ... })`. */
278
+ function columnsObject(node) {
279
+ if (!node) return null;
280
+ if (node.type === 'object') return node;
281
+ if (node.type === 'arrow_function') {
282
+ const body = unwrapParens(node.childForFieldName('body'));
283
+ return body && body.type === 'object' ? body : null;
284
+ }
285
+ return null;
286
+ }
287
+
288
+ /** `T` or `schema.T` → "T". Anything else → null. */
289
+ function tableNameOf(node) {
290
+ const n = unwrapParens(node);
291
+ if (!n) return null;
292
+ if (n.type === 'identifier') return n.text;
293
+ if (n.type === 'member_expression') {
294
+ const prop = n.childForFieldName('property');
295
+ const obj = n.childForFieldName('object');
296
+ if (prop && obj && obj.type === 'identifier') return prop.text;
297
+ }
298
+ return null;
299
+ }
300
+
301
+ /** `() => users.id` (or `(): AnyPgColumn => users.id`) → { table: 'users', column: 'id' }. */
302
+ function referenceTarget(arrow) {
303
+ if (!arrow || arrow.type !== 'arrow_function') return null;
304
+ let body = unwrapParens(arrow.childForFieldName('body'));
305
+ if (body && body.type === 'statement_block') {
306
+ const ret = body.namedChildren.find((c) => c.type === 'return_statement');
307
+ body = ret ? unwrapParens(ret.namedChild(0)) : null;
308
+ }
309
+ if (!body || body.type !== 'member_expression') return null;
310
+ const table = tableNameOf(body.childForFieldName('object'));
311
+ const prop = body.childForFieldName('property');
312
+ return table && prop ? { table, column: prop.text } : null;
313
+ }
314
+
315
+ /** One column: `uuid('flight_school_id').notNull().references(() => flightSchools.id)`. */
316
+ function parseColumn(key, value) {
317
+ const col = { name: key, sqlName: null, type: null, notNull: false, primaryKey: false };
318
+ let n = value;
319
+ while (n && n.type === 'call_expression') {
320
+ const fn = n.childForFieldName('function');
321
+ const args = n.childForFieldName('arguments');
322
+ const obj = fn && fn.type === 'member_expression' ? fn.childForFieldName('object') : null;
323
+ if (obj && obj.type === 'call_expression') {
324
+ const method = fn.childForFieldName('property').text;
325
+ if (method === 'notNull') col.notNull = true;
326
+ else if (method === 'primaryKey') col.primaryKey = true;
327
+ else if (method === 'unique') col.unique = true;
328
+ else if (method === 'references') {
329
+ const ref = referenceTarget(args && args.namedChild(0));
330
+ if (ref) col.references = ref;
331
+ else col.unresolved = `references(${(args ? args.text : '').slice(1, 81)}) — target not a plain table.column`;
332
+ }
333
+ n = obj;
334
+ continue;
335
+ }
336
+ // The type builder at the root: uuid('x'), t.uuid('x'), statusEnum('x').
337
+ col.type = fn && fn.type === 'member_expression' ? fn.childForFieldName('property').text : (fn ? fn.text : null);
338
+ col.sqlName = stringValue(args && args.namedChild(0));
339
+ break;
340
+ }
341
+ if (!col.type) col.unresolved = `column value is a ${value ? value.type : 'missing node'}, not a builder call`;
342
+ return col;
343
+ }
344
+
345
+ /** `foreignKey({ columns: [t.a], foreignColumns: [users.id] })` inside the extras argument. */
346
+ function collectForeignKeys(node, out) {
347
+ if (!node) return;
348
+ if (node.type === 'call_expression') {
349
+ const fn = node.childForFieldName('function');
350
+ const arg = node.childForFieldName('arguments');
351
+ const obj = arg && arg.namedChild(0);
352
+ if (fn && fn.text === 'foreignKey' && obj && obj.type === 'object') {
353
+ const fk = { columns: [], table: null, foreignColumns: [] };
354
+ for (const pair of obj.namedChildren) {
355
+ if (pair.type !== 'pair') continue;
356
+ const k = pair.childForFieldName('key').text;
357
+ const v = pair.childForFieldName('value');
358
+ if (!v || v.type !== 'array') continue;
359
+ for (const el of v.namedChildren) {
360
+ if (el.type !== 'member_expression') continue;
361
+ const prop = el.childForFieldName('property').text;
362
+ if (k === 'columns') fk.columns.push(prop);
363
+ if (k === 'foreignColumns') { fk.table = tableNameOf(el.childForFieldName('object')); fk.foreignColumns.push(prop); }
364
+ }
365
+ }
366
+ if (fk.table) out.push(fk);
367
+ return;
368
+ }
369
+ }
370
+ for (let i = 0; i < node.namedChildCount; i++) collectForeignKeys(node.namedChild(i), out);
371
+ }
372
+
373
+ /** meta for `pgTable('sql_name', { columns }, extras)`. Shape gaps are named in `unresolved`. */
374
+ function tableMeta(builder, args) {
375
+ const meta = { kind: 'table', dialect: TABLE_BUILDERS[builder], builder, sqlName: stringValue(args.namedChild(0)), columns: [], foreignKeys: [] };
376
+ const cols = columnsObject(args.namedChild(1));
377
+ const problems = [];
378
+ if (!meta.sqlName) problems.push('table name is not a string literal');
379
+ if (!cols) problems.push('columns argument is not an object literal');
380
+ for (const child of cols ? cols.namedChildren : []) {
381
+ if (child.type === 'pair') meta.columns.push(parseColumn(child.childForFieldName('key').text, child.childForFieldName('value')));
382
+ else if (child.type === 'spread_element') meta.columns.push({ name: child.text, type: 'spread', unresolved: 'spread — columns defined elsewhere' });
383
+ }
384
+ collectForeignKeys(args.namedChild(2), meta.foreignKeys);
385
+ for (const c of meta.columns) if (c.unresolved) problems.push(`column ${c.name}: ${c.unresolved}`);
386
+ if (problems.length) meta.unresolved = problems;
387
+ return meta;
388
+ }
389
+
390
+ function enumMeta(builder, args) {
391
+ const values = args.namedChild(1);
392
+ const meta = { kind: 'enum', dialect: ENUM_BUILDERS[builder], builder, sqlName: stringValue(args.namedChild(0)), values: [] };
393
+ if (values && values.type === 'array') meta.values = values.namedChildren.map(stringValue).filter((v) => v !== null);
394
+ const problems = [];
395
+ if (!meta.sqlName) problems.push('enum name is not a string literal');
396
+ if (!values || values.type !== 'array') problems.push('values argument is not an array literal');
397
+ if (problems.length) meta.unresolved = problems;
398
+ return meta;
399
+ }
400
+
401
+ /**
402
+ * Names in this file that could be a table: imported names (local → exported
403
+ * name), namespace imports (`import * as schema`), and tables declared here.
404
+ */
405
+ function collectTableCandidates(rootNode) {
406
+ const local = new Map();
407
+ const namespaces = new Set();
408
+ for (const stmt of rootNode.namedChildren) {
409
+ if (stmt.type === 'import_statement') {
410
+ const clause = stmt.namedChildren.find((c) => c.type === 'import_clause');
411
+ for (const sub of clause ? clause.namedChildren : []) {
412
+ if (sub.type === 'identifier') local.set(sub.text, sub.text);
413
+ if (sub.type === 'namespace_import') { const id = sub.namedChildren.find((c) => c.type === 'identifier'); if (id) namespaces.add(id.text); }
414
+ if (sub.type === 'named_imports') {
415
+ for (const spec of sub.namedChildren) {
416
+ const name = spec.childForFieldName('name');
417
+ const alias = spec.childForFieldName('alias');
418
+ if (name) local.set((alias || name).text, name.text);
419
+ }
420
+ }
421
+ }
422
+ }
423
+ const decl = stmt.type === 'export_statement' ? stmt.childForFieldName('declaration') : stmt;
424
+ if (decl && decl.type === 'lexical_declaration') {
425
+ for (const d of decl.namedChildren) {
426
+ const value = d.type === 'variable_declarator' ? d.childForFieldName('value') : null;
427
+ const fn = value && value.type === 'call_expression' ? value.childForFieldName('function') : null;
428
+ if (fn && (TABLE_BUILDERS[fn.text] || ENUM_BUILDERS[fn.text])) local.set(d.childForFieldName('name').text, d.childForFieldName('name').text);
429
+ }
430
+ }
431
+ }
432
+ return { local, namespaces };
433
+ }
434
+
435
+ /**
436
+ * The table a call chain writes (`db.update(T).set().where(...)` → 'T'), or null.
437
+ * A chain that reads (`.from(`) before any write is a subquery, not a write.
438
+ */
439
+ function chainWriteTarget(call) {
440
+ let n = call;
441
+ while (n && n.type === 'call_expression') {
442
+ const fn = n.childForFieldName('function');
443
+ if (!fn || fn.type !== 'member_expression') return null;
444
+ const prop = fn.childForFieldName('property').text;
445
+ if (TABLE_READ_OPS.has(prop)) return null;
446
+ if (TABLE_WRITE_OPS.has(prop)) {
447
+ const args = n.childForFieldName('arguments');
448
+ return args ? tableNameOf(args.namedChild(0)) : null;
449
+ }
450
+ n = unwrapParens(fn.childForFieldName('object'));
451
+ }
452
+ return null;
453
+ }
454
+
455
+ const SCOPE_BOUNDARY = /(_statement|_declaration|^arrow_function$|^function$|^function_expression$|^method_definition$|^statement_block$)/;
456
+
457
+ /**
458
+ * Is this identifier a USE of the name? Not its declaration, an import/export
459
+ * specifier, a parameter, a callee, or the receiver of a method call (`logger.info()`).
460
+ */
461
+ function isUseSite(node) {
462
+ const parent = node.parent;
463
+ if (!parent) return false;
464
+ if (NON_USE_PARENTS.has(parent.type)) return false;
465
+ const same = (field) => { const f = parent.childForFieldName(field); return f !== null && f.startIndex === node.startIndex && f.type === node.type; };
466
+ if (parent.type === 'variable_declarator' && same('name')) return false;
467
+ if (parent.type === 'call_expression' && same('function')) return false;
468
+ if (parent.type === 'new_expression' && same('constructor')) return false;
469
+ if (parent.type === 'member_expression' && same('object')) {
470
+ const grand = parent.parent;
471
+ const callee = grand && grand.type === 'call_expression' ? grand.childForFieldName('function') : null;
472
+ if (callee !== null && callee.startIndex === parent.startIndex) return false;
473
+ }
474
+ return true;
475
+ }
476
+
477
+ /**
478
+ * A ref to table `name` is a WRITE only inside a chain that writes THAT table
479
+ * (`db.update(T).where(eq(T.id, …))`). A ref to another table inside it
480
+ * (`.values({ who: users.name })`, a `.from(users)` subquery) is a READ, and so
481
+ * is `hash.update(users.id)`. The nearest enclosing chain decides.
482
+ */
483
+ function refAccess(node, name) {
484
+ for (let p = node.parent; p && !SCOPE_BOUNDARY.test(p.type); p = p.parent) {
485
+ if (p.type !== 'call_expression') continue;
486
+ const fn = p.childForFieldName('function');
487
+ const prop = fn && fn.type === 'member_expression' ? fn.childForFieldName('property').text : null;
488
+ if (prop !== null && TABLE_READ_OPS.has(prop)) return 'READ';
489
+ const target = chainWriteTarget(p);
490
+ if (target !== null) return target === name ? 'WRITE' : 'READ';
491
+ }
492
+ return 'READ';
493
+ }
494
+
188
495
  // ── Python-specific extraction ────────────────────────────────────────────────
189
496
 
190
497
  function walkPython(rootNode, relPath, entities, edges) {
@@ -298,6 +605,7 @@ function walkPython(rootNode, relPath, entities, edges) {
298
605
  source: enclosingFuncId,
299
606
  target: `UNRESOLVED#${calleeName}`,
300
607
  line: node.startPosition.row + 1,
608
+ col: fn.startPosition.column, // [RULE] scip-tier-proportional
301
609
  });
302
610
  }
303
611
  }
@@ -319,6 +627,38 @@ function walkPython(rootNode, relPath, entities, edges) {
319
627
  * a file-qualified source funcId per [RULE] who-calls-function-identity-disambiguated.
320
628
  */
321
629
  function walkTSJS(rootNode, relPath, entities, edges) {
630
+ // Drizzle table tracking — see "Drizzle database tables" above.
631
+ const candidates = collectTableCandidates(rootNode);
632
+ const consumed = new Set(); // startIndex of table args already recorded as an operation
633
+ const declRanges = []; // [start, end] of table declarations (FKs live in meta, not edges)
634
+ const refSeen = new Set(); // one column-ref edge per (user, access, table)
635
+ const inDecl = (i) => declRanges.some(([s, e]) => i >= s && i < e);
636
+ const userOf = (enclosingFuncId) => (enclosingFuncId === null ? `${relPath}#_toplevel` : enclosingFuncId);
637
+ const tableEdge = (access, src, name, op, node) => edges.push({
638
+ kind: `TABLE-${access}`,
639
+ source: src,
640
+ target: `TABLE#${name}#${op}@${node.startPosition.row + 1}`,
641
+ line: node.startPosition.row + 1,
642
+ });
643
+ /** The exported table name `node` refers to, when it is a candidate (`T`, `alias`, `schema.T`); else null. */
644
+ function candidateName(node) {
645
+ if (node.type === 'identifier') return candidates.local.has(node.text) ? candidates.local.get(node.text) : null;
646
+ if (node.type !== 'member_expression') return null;
647
+ const obj = node.childForFieldName('object');
648
+ if (obj.type !== 'identifier' || !candidates.namespaces.has(obj.text)) return null;
649
+ return node.childForFieldName('property').text;
650
+ }
651
+ function tableRef(node, name, enclosingFuncId) {
652
+ if (consumed.has(node.startIndex)) return;
653
+ if (inDecl(node.startIndex)) return;
654
+ const src = userOf(enclosingFuncId);
655
+ const access = refAccess(node, name);
656
+ const key = `${src}\u0000${access}\u0000${name}`;
657
+ if (refSeen.has(key)) return;
658
+ refSeen.add(key);
659
+ tableEdge(access, src, name, 'ref', node);
660
+ }
661
+
322
662
  /**
323
663
  * @param {object} node - tree-sitter ASTNode
324
664
  * @param {string|null} enclosingFuncId - funcId of innermost function containing this node
@@ -374,13 +714,91 @@ function walkTSJS(rootNode, relPath, entities, edges) {
374
714
  source: srcId,
375
715
  target: `UNRESOLVED#${calleeName}`,
376
716
  line: node.startPosition.row + 1,
717
+ // callee column — lets the SCIP upgrader tell a call the compiler saw
718
+ // from one it never looked at. [RULE] scip-tier-proportional
719
+ col: fn.startPosition.column,
720
+ });
721
+ }
722
+
723
+ // Table operation: `.from(T)`, `.innerJoin(T, …)`, `.insert(T)`, `.update(T)`, `.delete(T)`.
724
+ // [RULE] drizzle-table-entity-and-usage-edges
725
+ const op = fn.type === 'member_expression' ? fn.childForFieldName('property').text : null;
726
+ const tableArg = op !== null && TABLE_OPS.has(op) && args ? unwrapParens(args.namedChild(0)) : null;
727
+ const tableName = tableArg ? candidateName(tableArg) : null;
728
+ if (tableName !== null) {
729
+ tableEdge(TABLE_WRITE_OPS.has(op) ? 'WRITE' : 'READ', userOf(enclosingFuncId), tableName, op, fn.childForFieldName('property'));
730
+ consumed.add(tableArg.startIndex);
731
+ if (tableArg.type === 'member_expression') consumed.add(tableArg.childForFieldName('property').startIndex);
732
+ }
733
+
734
+ // Route registration: the route itself becomes the caller, named by
735
+ // method + path + line — for its anonymous handler AND for every
736
+ // middleware call in its arguments (`requireAuth()`, `requireLocationTenant()`).
737
+ // Crediting middleware to the enclosing scope collapsed 95 routes into one
738
+ // `_toplevel` caller per file. [RULE] anonymous-caller-synthesized-never-dropped
739
+ // [RULE] route-middleware-args-credited-to-route
740
+ const label = routeLabel(fn, args);
741
+ const handlers = label ? args.namedChildren.filter(isAnonymousFn) : [];
742
+ const middlewareCalls = label && isRoutePath(args.namedChild(0))
743
+ ? args.namedChildren.slice(1).filter((a) => a.type === 'call_expression') : [];
744
+ if (handlers.length || middlewareCalls.length) {
745
+ const line = node.startPosition.row + 1;
746
+ const routeId = `${relPath}#${label}@${line}`;
747
+ entities.push({
748
+ id: routeId,
749
+ name: label,
750
+ type: 'function',
751
+ line,
752
+ endLine: node.endPosition.row + 1,
753
+ exported: false,
754
+ synthetic: 'route-handler',
377
755
  });
756
+ // Node objects are re-created per access, so match handlers by position.
757
+ const handlerStarts = new Set(handlers.map((h) => h.startIndex));
758
+ walk(fn, enclosingFuncId, enclosingClass);
759
+ for (let i = 0; i < args.childCount; i++) {
760
+ const arg = args.child(i);
761
+ if (!handlerStarts.has(arg.startIndex) || !isAnonymousFn(arg)) { walk(arg, routeId, enclosingClass); continue; }
762
+ for (let j = 0; j < arg.childCount; j++) walk(arg.child(j), routeId, enclosingClass);
763
+ }
764
+ return;
378
765
  }
379
766
  }
380
767
 
381
768
  // Fall through to walk children (the call_expression can contain more nodes)
382
769
  }
383
770
 
771
+ // ── table references: `eq(T.id, …)`, `getTableColumns(T)`, `db.query.T.findMany()` ──
772
+ // [RULE] drizzle-table-entity-and-usage-edges
773
+ if (t === 'identifier' || t === 'shorthand_property_identifier') {
774
+ if (candidates.local.has(node.text) && isUseSite(node)) tableRef(node, candidates.local.get(node.text), enclosingFuncId);
775
+ return;
776
+ }
777
+ if (t === 'member_expression') {
778
+ const obj = node.childForFieldName('object');
779
+ const prop = node.childForFieldName('property');
780
+ // Drizzle's relational API only: `<db>.query.<table>.findMany|findFirst(…)`.
781
+ // `req.query.page` / `data.query?.pages` are not table reads.
782
+ const outer = node.parent;
783
+ const method = outer && outer.type === 'member_expression' ? outer.childForFieldName('property').text : null;
784
+ const viaQuery = obj.type === 'member_expression' && obj.childForFieldName('property').text === 'query' &&
785
+ (method === 'findMany' || method === 'findFirst');
786
+ if (viaQuery && prop.type === 'property_identifier') {
787
+ tableEdge('READ', userOf(enclosingFuncId), prop.text, 'query', node);
788
+ } else if (obj.type === 'identifier' && candidates.namespaces.has(obj.text) && isUseSite(node)) {
789
+ tableRef(node, prop.text, enclosingFuncId);
790
+ }
791
+ }
792
+
793
+ // ── anonymous function with no named scope around it ─────────────────
794
+ // Inside a named function, a callback's calls belong to that function
795
+ // (enclosingFuncId passes through). Outside one, give it a located id.
796
+ if (!enclosingFuncId && isAnonymousFn(node)) {
797
+ const anonId = `${relPath}#anonymous@${node.startPosition.row + 1}`;
798
+ for (let i = 0; i < node.childCount; i++) walk(node.child(i), anonId, enclosingClass);
799
+ return;
800
+ }
801
+
384
802
  // ── function_declaration ──────────────────────────────────────────────
385
803
  if (t === 'function_declaration' || t === 'function') {
386
804
  const nameNode = node.childForFieldName('name');
@@ -451,6 +869,7 @@ function walkTSJS(rootNode, relPath, entities, edges) {
451
869
  // const foo = () => ... or const foo = function...
452
870
  if (t === 'lexical_declaration' || t === 'variable_declaration') {
453
871
  const isExp = isExportedNode(node);
872
+ const walkedValues = new Set(); // bodies already walked under their own funcId
454
873
  for (let i = 0; i < node.namedChildCount; i++) {
455
874
  const decl = node.namedChild(i);
456
875
  if (decl.type === 'variable_declarator') {
@@ -474,10 +893,43 @@ function walkTSJS(rootNode, relPath, entities, edges) {
474
893
  for (let j = 0; j < valueNode.childCount; j++) {
475
894
  walk(valueNode.child(j), funcId, enclosingClass);
476
895
  }
896
+ walkedValues.add(valueNode.startIndex);
897
+ } else if (nameNode && valueNode && valueNode.type === 'call_expression') {
898
+ // `const scheduleEvents = pgTable('schedule_events', {...})` → table entity.
899
+ // [RULE] drizzle-table-entity-and-usage-edges
900
+ const builder = valueNode.childForFieldName('function').text;
901
+ const args = valueNode.childForFieldName('arguments');
902
+ const isTable = Object.prototype.hasOwnProperty.call(TABLE_BUILDERS, builder);
903
+ const isEnum = Object.prototype.hasOwnProperty.call(ENUM_BUILDERS, builder);
904
+ if (args && (isTable || isEnum)) {
905
+ const meta = isTable ? tableMeta(builder, args) : enumMeta(builder, args);
906
+ entities.push({
907
+ id: `${relPath}#${nameNode.text}@${decl.startPosition.row + 1}`,
908
+ name: nameNode.text,
909
+ type: meta.kind,
910
+ line: decl.startPosition.row + 1,
911
+ endLine: valueNode.endPosition.row + 1,
912
+ exported: isExp,
913
+ meta,
914
+ });
915
+ declRanges.push([valueNode.startIndex, valueNode.endIndex]);
916
+ }
477
917
  }
478
918
  }
479
919
  }
480
- // Continue walking children for the declaration itself
920
+ // Walk the rest of the declaration, but NOT a function body already walked
921
+ // above — walking it again credited each of its calls to a second, wrong
922
+ // caller (_toplevel). [RULE] anonymous-caller-synthesized-never-dropped
923
+ for (let i = 0; i < node.childCount; i++) {
924
+ const child = node.child(i);
925
+ if (child.type !== 'variable_declarator') { walk(child, enclosingFuncId, enclosingClass); continue; }
926
+ for (let j = 0; j < child.childCount; j++) {
927
+ const part = child.child(j);
928
+ if (walkedValues.has(part.startIndex) && part.type !== 'identifier') continue;
929
+ walk(part, enclosingFuncId, enclosingClass);
930
+ }
931
+ }
932
+ return;
481
933
  }
482
934
 
483
935
  // ── export_statement (bare re-exports: export { x, y }) ──────────────
@@ -0,0 +1,106 @@
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
+ * DEFAULT exclude (no file needed): GSD-T's own tools, which `gsd-t update-all`
25
+ * copies into every project's bin/ (PROJECT_BIN_TOOLS in bin/gsd-t.js). They are
26
+ * not the project's code; indexed, they put GSD-T's functions into its who-calls.
27
+ * Not applied in GSD-T's own repo, where bin/ IS the application. The pattern is
28
+ * checked against PROJECT_BIN_TOOLS by test/graph-drizzle-tables.test.js.
29
+ * [RULE] graph-excludes-gsdt-copied-tools-by-default
30
+ */
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+
35
+ const EXCLUDE_FILE = path.join('.gsd-t', 'graph-exclude.json');
36
+ const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g;
37
+ const GSDT_TOOL_FILES = /^bin\/(gsd-t-[^/]+|archive-progress|cli-preflight|parallel-cli|parallel-cli-tee)\.cjs$/i;
38
+ const GSDT_TOOL_DEFAULT = "bin/<GSD-T's copied tools: gsd-t-*.cjs, archive-progress.cjs, cli-preflight.cjs, parallel-cli*.cjs>";
39
+
40
+ /** GSD-T's own source repo — its bin/ is the application, never excluded. */
41
+ function isGsdtSourceRepo(projectRoot) {
42
+ const pkg = path.join(projectRoot, 'package.json');
43
+ if (!fs.existsSync(pkg)) return false;
44
+ let parsed;
45
+ try { parsed = JSON.parse(fs.readFileSync(pkg, 'utf8')); }
46
+ catch (e) { throw new Error(`package.json is not valid JSON (${e.message}) — cannot tell whether bin/ holds GSD-T's copied tools`); }
47
+ return parsed !== null && parsed.name === '@tekyzinc/gsd-t';
48
+ }
49
+
50
+ function toRegex(pattern) {
51
+ const p = String(pattern).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
52
+ if (!p) return null;
53
+ if (!p.includes('*')) {
54
+ // Plain prefix: the path itself, or anything under it.
55
+ const base = p.replace(/\/+$/, '').replace(REGEX_SPECIALS, '\\$&');
56
+ return new RegExp(`^${base}(/|$)`, 'i');
57
+ }
58
+ let re = '';
59
+ for (let i = 0; i < p.length; i++) {
60
+ const c = p[i];
61
+ if (c === '*' && p[i + 1] === '*') {
62
+ i++;
63
+ if (p[i + 1] === '/') { i++; re += '(?:.*/)?'; } else re += '.*';
64
+ } else if (c === '*') re += '[^/]*';
65
+ else re += c.replace(REGEX_SPECIALS, '\\$&');
66
+ }
67
+ return new RegExp(`^${re}(/|$)`, 'i');
68
+ }
69
+
70
+ function validateList(parsed) {
71
+ const list = parsed ? parsed.exclude : undefined;
72
+ if (!Array.isArray(list)) return false;
73
+ return list.every((x) => typeof x === 'string');
74
+ }
75
+
76
+ /**
77
+ * Load the project's exclude list. Throws on a malformed file.
78
+ * @returns {{ patterns: string[], source: string|null, isExcluded: (relPath: string) => boolean }}
79
+ */
80
+ function loadGraphExcludes(projectRoot) {
81
+ const file = path.join(projectRoot, EXCLUDE_FILE);
82
+ const toolsExcluded = !isGsdtSourceRepo(projectRoot);
83
+ const defaults = toolsExcluded ? [GSDT_TOOL_DEFAULT] : [];
84
+ const isDefault = (rel) => toolsExcluded && GSDT_TOOL_FILES.test(rel);
85
+ if (!fs.existsSync(file)) {
86
+ return { patterns: [], defaults, source: null, isExcluded: (relPath) => isDefault(String(relPath).split(path.sep).join('/')) };
87
+ }
88
+ let parsed;
89
+ try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
90
+ catch (e) { throw new Error(`${EXCLUDE_FILE} is not valid JSON (${e.message}) — fix it or delete it`); }
91
+ if (!validateList(parsed)) {
92
+ throw new Error(`${EXCLUDE_FILE} must be { "exclude": ["folder/", "glob/**", ...] }`);
93
+ }
94
+ const regexes = parsed.exclude.map(toRegex).filter(Boolean);
95
+ return {
96
+ patterns: parsed.exclude,
97
+ defaults,
98
+ source: EXCLUDE_FILE,
99
+ isExcluded: (relPath) => {
100
+ const rel = String(relPath).split(path.sep).join('/');
101
+ return isDefault(rel) || regexes.some((r) => r.test(rel));
102
+ },
103
+ };
104
+ }
105
+
106
+ module.exports = { loadGraphExcludes, EXCLUDE_FILE, GSDT_TOOL_FILES, _toRegex: toRegex };
@@ -107,12 +107,13 @@ function walkTree(dir, results = []) {
107
107
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
108
108
  catch { return results; }
109
109
  for (const e of entries) {
110
- if (e.name.startsWith('.') && e.name !== '.claude') {
111
- if (shouldExcludeDir(e.name)) continue;
112
- }
113
- if (shouldExcludeDir(e.name)) continue;
114
110
  const full = path.join(dir, e.name);
115
111
  if (e.isDirectory()) {
112
+ // The skip list names DIRECTORIES, as in the indexer. Applied to file names
113
+ // it dropped `build-analytics.ts` / `build_guides.py` (prefix "build"),
114
+ // so every query deleted 7 indexed hilo files from the graph as "gone".
115
+ // [RULE] freshness-excludes-match-indexer-skipdirs
116
+ if (shouldExcludeDir(e.name)) continue;
116
117
  walkTree(full, results);
117
118
  } else if (e.isFile() && TRACKED_EXTS.has(path.extname(e.name))) {
118
119
  results.push(full);
@@ -220,7 +221,11 @@ function compute_touched_files(db, projectRoot) {
220
221
  }
221
222
 
222
223
  // Walk the current working tree
223
- const liveFiles = walkTree(projectRoot);
224
+ // Same exclude list as the indexer, so an excluded file is never a phantom ADD
225
+ // and a newly-excluded one drops out as a DELETE. [RULE] freshness-excludes-match-indexer-skipdirs
226
+ const { isExcluded } = require('./gsd-t-graph-exclude.cjs').loadGraphExcludes(projectRoot);
227
+ const liveFiles = walkTree(projectRoot)
228
+ .filter((abs) => !isExcluded(path.relative(projectRoot, abs)));
224
229
 
225
230
  const liveRelSet = new Set();
226
231
  const edits = [];
@@ -286,6 +291,12 @@ function revalidateOneHopImporters(db, fileRel, op) {
286
291
  db.prepare(`DELETE FROM nodes WHERE id=? AND kind='FILE'`).run(fileRel);
287
292
  // Remove entity nodes belonging to this file
288
293
  db.prepare(`DELETE FROM nodes WHERE file=? AND kind != 'FILE'`).run(fileRel);
294
+ // …its call / table-usage edges (src is `file#fn`, not the bare file) and its
295
+ // files-table row. Left behind, a deleted or newly-excluded file stayed in
296
+ // `graph status` and was re-flagged as a DELETE on every query.
297
+ // [RULE] status-counts-files-table
298
+ db.prepare(`DELETE FROM edges WHERE src LIKE ?`).run(`${fileRel}#%`);
299
+ if (hasTable(db, 'files')) db.prepare(`DELETE FROM files WHERE file=?`).run(fileRel);
289
300
  return { revalidated: true, op: 'DELETE', file: fileRel };
290
301
  }
291
302