@tekyzinc/gsd-t 5.23.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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [5.24.10] - 2026-09-29
6
+
7
+ ### Added — the code graph indexes Drizzle database tables
8
+
9
+ "Which code uses this table?" had no graph answer: tables are declared as `const X = pgTable('x', {…})`, which the indexer never recorded, and the graph-search guard then blocked grep with nothing to offer. On hilo-figma-atos the graph now holds all 431 tables and 63 enums, and `who-uses scheduleEvents` agrees with TypeScript findReferences on 179 of 181 users (the gaps are explained in the progress log).
10
+
11
+ - `bin/gsd-t-graph-edge-extract.cjs`: `table` / `enum` entities (pg / mysql / sqlite), columns, foreign keys (`.references()` and `foreignKey()`), READ / WRITE usage edges (from / joins / `db.query.T.findMany|findFirst` / insert / update / delete) attributed to the enclosing function or route handler.
12
+ - `bin/gsd-t-graph-query-cli.cjs`, `bin/gsd-t.js`: `who-uses <table> [--writes|--reads]`, `table <name>`; `body` and `blast-radius` accept tables; not-found answers say the table is not indexed.
13
+ - `scripts/gsd-t-graph-search-guard.js`: table-shaped searches point at who-uses / table / blast-radius.
14
+ - `bin/gsd-t-graph-index.cjs`, `bin/gsd-t-graph-freshness.cjs`: `graph status` counts every indexed file (hilo: 4,301 → 4,322, matching the index) with per-tier counts and exclude suggestions; freshness no longer applies folder skips to file names (`build-analytics.ts` was dropped on every query); deleted / newly-excluded files leave no stale records.
15
+ - `bin/gsd-t-graph-exclude.cjs`: GSD-T's own copied `bin/` tools are excluded by default outside the GSD-T repo.
16
+ - Tests: `test/graph-drizzle-tables.test.js` (21) + `test/fixtures/drizzle-graph/`.
17
+
18
+ Known gaps: code that reaches tables through `import { schema }` objects or CommonJS `require` is not seen; an imported non-table sharing a table's name counts as a use. Re-run `gsd-t graph index` to pick up tables.
19
+
5
20
  ## [5.23.10] - 2026-09-29
6
21
 
7
22
  ### Added — who-calls matches the compiler on route files; graph exclude list; estimate math v2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v5.23.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v5.24.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -236,6 +236,262 @@ function isRoutePath(firstArg) {
236
236
  return firstArg.text.slice(1).startsWith('/');
237
237
  }
238
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
+
239
495
  // ── Python-specific extraction ────────────────────────────────────────────────
240
496
 
241
497
  function walkPython(rootNode, relPath, entities, edges) {
@@ -371,6 +627,38 @@ function walkPython(rootNode, relPath, entities, edges) {
371
627
  * a file-qualified source funcId per [RULE] who-calls-function-identity-disambiguated.
372
628
  */
373
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
+
374
662
  /**
375
663
  * @param {object} node - tree-sitter ASTNode
376
664
  * @param {string|null} enclosingFuncId - funcId of innermost function containing this node
@@ -432,6 +720,17 @@ function walkTSJS(rootNode, relPath, entities, edges) {
432
720
  });
433
721
  }
434
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
+
435
734
  // Route registration: the route itself becomes the caller, named by
436
735
  // method + path + line — for its anonymous handler AND for every
437
736
  // middleware call in its arguments (`requireAuth()`, `requireLocationTenant()`).
@@ -469,6 +768,28 @@ function walkTSJS(rootNode, relPath, entities, edges) {
469
768
  // Fall through to walk children (the call_expression can contain more nodes)
470
769
  }
471
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
+
472
793
  // ── anonymous function with no named scope around it ─────────────────
473
794
  // Inside a named function, a callback's calls belong to that function
474
795
  // (enclosingFuncId passes through). Outside one, give it a located id.
@@ -573,6 +894,26 @@ function walkTSJS(rootNode, relPath, entities, edges) {
573
894
  walk(valueNode.child(j), funcId, enclosingClass);
574
895
  }
575
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
+ }
576
917
  }
577
918
  }
578
919
  }
@@ -20,6 +20,13 @@
20
20
  *
21
21
  * A malformed file HALTS (throws): silently ignoring it would index exactly the
22
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
23
30
  */
24
31
 
25
32
  const fs = require('fs');
@@ -27,6 +34,18 @@ const path = require('path');
27
34
 
28
35
  const EXCLUDE_FILE = path.join('.gsd-t', 'graph-exclude.json');
29
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
+ }
30
49
 
31
50
  function toRegex(pattern) {
32
51
  const p = String(pattern).trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
@@ -60,7 +79,12 @@ function validateList(parsed) {
60
79
  */
61
80
  function loadGraphExcludes(projectRoot) {
62
81
  const file = path.join(projectRoot, EXCLUDE_FILE);
63
- if (!fs.existsSync(file)) return { patterns: [], source: null, isExcluded: () => false };
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
+ }
64
88
  let parsed;
65
89
  try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); }
66
90
  catch (e) { throw new Error(`${EXCLUDE_FILE} is not valid JSON (${e.message}) — fix it or delete it`); }
@@ -70,12 +94,13 @@ function loadGraphExcludes(projectRoot) {
70
94
  const regexes = parsed.exclude.map(toRegex).filter(Boolean);
71
95
  return {
72
96
  patterns: parsed.exclude,
97
+ defaults,
73
98
  source: EXCLUDE_FILE,
74
99
  isExcluded: (relPath) => {
75
100
  const rel = String(relPath).split(path.sep).join('/');
76
- return regexes.some((r) => r.test(rel));
101
+ return isDefault(rel) || regexes.some((r) => r.test(rel));
77
102
  },
78
103
  };
79
104
  }
80
105
 
81
- module.exports = { loadGraphExcludes, EXCLUDE_FILE, _toRegex: toRegex };
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);
@@ -290,6 +291,12 @@ function revalidateOneHopImporters(db, fileRel, op) {
290
291
  db.prepare(`DELETE FROM nodes WHERE id=? AND kind='FILE'`).run(fileRel);
291
292
  // Remove entity nodes belonging to this file
292
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);
293
300
  return { revalidated: true, op: 'DELETE', file: fileRel };
294
301
  }
295
302
 
@@ -264,7 +264,8 @@ function buildSchema(db) {
264
264
  file TEXT NOT NULL,
265
265
  name TEXT,
266
266
  func_id TEXT,
267
- end_line INTEGER
267
+ end_line INTEGER,
268
+ meta TEXT
268
269
  );
269
270
  CREATE TABLE IF NOT EXISTS edges (
270
271
  kind TEXT NOT NULL,
@@ -278,6 +279,18 @@ function buildSchema(db) {
278
279
  CREATE INDEX IF NOT EXISTS nodes_file ON nodes(file);
279
280
  `);
280
281
  migrateEndLine(db);
282
+ migrateMeta(db);
283
+ }
284
+
285
+ /**
286
+ * Idempotent migration: add `nodes.meta` (JSON) — a table's SQL name + columns,
287
+ * an enum's values. NULL for functions/classes. [RULE] drizzle-table-entity-and-usage-edges
288
+ */
289
+ function migrateMeta(db) {
290
+ const cols = db.prepare("PRAGMA table_info(nodes)").all();
291
+ if (!cols.some((c) => c.name === 'meta')) {
292
+ db.exec('ALTER TABLE nodes ADD COLUMN meta TEXT');
293
+ }
281
294
  }
282
295
 
283
296
  /**
@@ -313,11 +326,15 @@ function migrateEndLine(db) {
313
326
  */
314
327
  function getWriteStmts(db) {
315
328
  if (db.__m94WriteStmts) return db.__m94WriteStmts;
329
+ // A handle opened elsewhere (the freshness re-index opens the db without
330
+ // buildSchema) may point at an older graph — add the columns written below.
331
+ migrateEndLine(db);
332
+ migrateMeta(db);
316
333
  const stmts = {
317
334
  deleteNodes: db.prepare('DELETE FROM nodes WHERE file = ?'),
318
335
  deleteEdgesSrc: db.prepare("DELETE FROM edges WHERE src = ? OR src LIKE ? OR src = ?"),
319
336
  insFile: db.prepare('INSERT OR REPLACE INTO files (file, content_hash, tier, indexed_at) VALUES (?, ?, ?, ?)'),
320
- insNode: db.prepare('INSERT OR REPLACE INTO nodes (id, kind, tier, content_hash, file, name, func_id, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'),
337
+ insNode: db.prepare('INSERT OR REPLACE INTO nodes (id, kind, tier, content_hash, file, name, func_id, end_line, meta) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'),
321
338
  insEdge: db.prepare('INSERT INTO edges (kind, src, dst, partial) VALUES (?, ?, ?, ?)'),
322
339
  };
323
340
  // db.transaction() wrapper is also created once (it closes over the stmts).
@@ -327,7 +344,8 @@ function getWriteStmts(db) {
327
344
  stmts.deleteEdgesSrc.run(file, `${file}#%`, file);
328
345
  stmts.insFile.run(file, hash, tier, now);
329
346
  for (const entity of entities) {
330
- stmts.insNode.run(entity.id, entity.type || 'function', tier, hash, file, entity.name || null, entity.id, entity.endLine ?? null);
347
+ stmts.insNode.run(entity.id, entity.type || 'function', tier, hash, file, entity.name || null, entity.id, entity.endLine ?? null,
348
+ entity.meta === undefined ? null : JSON.stringify(entity.meta));
331
349
  }
332
350
  for (const edge of edges) {
333
351
  stmts.insEdge.run(edge.kind, edge.src, edge.dst, edge.partial ? 1 : 0);
@@ -459,6 +477,7 @@ function parse_and_put(absPath, relPath, options) {
459
477
  exported: e.exported,
460
478
  parentClass: e.parentClass,
461
479
  endLine: e.endLine ?? null, // M98 — function end line for body-slice
480
+ ...(e.meta === undefined ? {} : { meta: e.meta }), // table / enum: SQL name, columns, values
462
481
  }));
463
482
 
464
483
  if (db) {
@@ -543,6 +562,9 @@ function build_index(repoRoot, options) {
543
562
  let errors = 0;
544
563
  const skippedFiles = [];
545
564
  const scipMissing = []; // files SCIP ran for but produced no document for
565
+ let tableCount = 0;
566
+ let enumCount = 0;
567
+ const tablesUnresolved = []; // [RULE] drizzle-table-shape-gap-named-never-skipped
546
568
 
547
569
  // Stream: parse + put each file one at a time (never accumulate the full set)
548
570
  for (const { absPath, relPath } of files) {
@@ -560,6 +582,11 @@ function build_index(repoRoot, options) {
560
582
  if (String(e.target || e.dst).startsWith('UNRESOLVED#')) callEdgesUnresolved++;
561
583
  }
562
584
  if (result.tier === 'tree-sitter-floor-SCIP-MISSING') scipMissing.push(relPath);
585
+ for (const ent of result.entities) {
586
+ if (ent.type === 'table') tableCount++;
587
+ if (ent.type === 'enum') enumCount++;
588
+ if (ent.meta && ent.meta.unresolved) tablesUnresolved.push({ file: relPath, name: ent.name, problems: ent.meta.unresolved });
589
+ }
563
590
  if (typeof onProgress === 'function') {
564
591
  onProgress({ file: relPath, tier: result.tier, fileCount, total: files.length });
565
592
  }
@@ -570,6 +597,20 @@ function build_index(repoRoot, options) {
570
597
  }
571
598
  }
572
599
 
600
+ // A full build leaves exactly the enumerated set: rows for a file that is gone
601
+ // or newly excluded (e.g. GSD-T's copied bin/ tools) are pruned, not kept.
602
+ // [RULE] status-counts-files-table
603
+ const enumerated = new Set(files.map((f) => f.relPath));
604
+ const stale = db.prepare('SELECT file FROM files').all().map((r) => r.file).filter((f) => !enumerated.has(f));
605
+ if (stale.length) {
606
+ const w = getWriteStmts(db);
607
+ const delFile = db.prepare('DELETE FROM files WHERE file = ?');
608
+ db.transaction(() => {
609
+ for (const f of stale) { w.deleteNodes.run(f); w.deleteEdgesSrc.run(f, `${f}#%`, f); delFile.run(f); }
610
+ })();
611
+ info(`Pruned ${stale.length} file(s) no longer in the project (deleted or excluded)`);
612
+ }
613
+
573
614
  // Record the skipped set + parse-success-rate so a query whose edges live in a
574
615
  // skipped file can return a coverage flag ('result may be incomplete — N files
575
616
  // unparsed') instead of a bare empty set that reads as authoritative truth.
@@ -619,8 +660,20 @@ function build_index(repoRoot, options) {
619
660
  `(tier tree-sitter-floor-SCIP-MISSING): ${shown}${scipMissing.length > 10 ? ', …' : ''}`);
620
661
  }
621
662
 
663
+ // A table whose declaration had a shape the extractor could not read (a
664
+ // computed name, a spread of columns, a non-literal reference) is still an
665
+ // entity — but its columns or FKs are incomplete. Named every build, loud.
666
+ if (tablesUnresolved.length) {
667
+ warn(`${tablesUnresolved.length} table/enum declaration(s) only partly read — columns or foreign keys incomplete:`);
668
+ for (const t of tablesUnresolved.slice(0, 20)) warn(` ${t.file} ${t.name}: ${t.problems.join('; ')}`);
669
+ if (tablesUnresolved.length > 20) warn(` … ${tablesUnresolved.length - 20} more`);
670
+ }
671
+
622
672
  return {
623
673
  fileCount,
674
+ tableCount,
675
+ enumCount,
676
+ tablesUnresolved,
624
677
  entityCount,
625
678
  edgeCount,
626
679
  tier: { floor: tierFloor, upgraded: tierUpgraded, partial: tierPartial },
@@ -683,6 +736,7 @@ if (require.main === module) {
683
736
  info(`Call edges unresolved: ${result.callEdgesUnresolved} of ${result.callEdges} ` +
684
737
  `(${(100 * result.callEdgesUnresolved / result.callEdges).toFixed(1)}% — library calls such as console.log never resolve)`);
685
738
  }
739
+ good(`Database tables: ${result.tableCount}, enums: ${result.enumCount}`);
686
740
  if (result.errors > 0) warn(`${result.errors} files had parse errors (skipped)`);
687
741
 
688
742
  const envelope = {
@@ -695,6 +749,9 @@ if (require.main === module) {
695
749
  edgeCount: result.edgeCount,
696
750
  tier: result.tier,
697
751
  scipMissingCount: result.scipMissing.length,
752
+ tableCount: result.tableCount,
753
+ enumCount: result.enumCount,
754
+ tablesUnresolved: result.tablesUnresolved,
698
755
  errors: result.errors,
699
756
  durationMs: result.durationMs,
700
757
  };
@@ -20,6 +20,11 @@
20
20
  * gsd-t graph dangling — edges whose dst is a missing node (delete/rename residue)
21
21
  * gsd-t graph test-impl [--inverse] — test→impl call coverage (--inverse = untested-impl)
22
22
  *
23
+ * Verbs (database tables — Drizzle pgTable/mysqlTable/sqliteTable + pgEnum):
24
+ * gsd-t graph who-uses <table> [--writes|--reads] — functions / methods / route handlers using a table
25
+ * gsd-t graph table <table> — columns + foreign keys in both directions
26
+ * (blast-radius <table> and body <table> also accept a table)
27
+ *
23
28
  * Invariants (all verified by keystone tests):
24
29
  * [RULE] query-cli-never-greps — NO directive-driven grep fallback in any code path
25
30
  * [RULE] parser-fail-disables-loud-never-silent — genuine parser/store failure → {ok:false, reason:'graph-unavailable'}
@@ -380,6 +385,12 @@ function buildIndex(records, skippedFiles) {
380
385
  /** @type {Map<string,string>} file → tier (M114: who-calls needs the TARGET
381
386
  * file's tier, not just the repo-wide dominant one). */
382
387
  const fileTier = new Map();
388
+ /** @type {Map<string,{name:string,file:string,tier:string,endLine:?number,kind:string,meta:object}>}
389
+ * Drizzle tables + enums, kept OUT of funcEntities so dead-code / test-impl /
390
+ * who-calls never mistake a table for a function. [RULE] drizzle-table-entity-and-usage-edges */
391
+ const tableEntities = new Map();
392
+ /** @type {Map<string,Array<{src:string,access:string,op:string,line:number}>>} table code name → uses */
393
+ const tableUses = new Map();
383
394
 
384
395
  let dominantTier = "compiler-accurate";
385
396
  let hasFloor = false;
@@ -395,7 +406,12 @@ function buildIndex(records, skippedFiles) {
395
406
  // Index entities for bare-name disambiguation + tier labeling
396
407
  if (Array.isArray(rec.entities)) {
397
408
  for (const ent of rec.entities) {
398
- if (ent.funcId) {
409
+ if (ent.funcId && (ent.kind === "table" || ent.kind === "enum")) {
410
+ tableEntities.set(ent.funcId, {
411
+ name: ent.name, file: ent.file || rec.file, tier: ent.tier || rec.tier,
412
+ endLine: ent.endLine ?? null, kind: ent.kind, meta: ent.meta || {},
413
+ });
414
+ } else if (ent.funcId) {
399
415
  funcEntities.set(ent.funcId, {
400
416
  name: ent.name,
401
417
  file: ent.file || rec.file,
@@ -419,6 +435,13 @@ function buildIndex(records, skippedFiles) {
419
435
  callGraph.get(edge.dst).add(edge.src);
420
436
  // Forward: collect ALL call edges for dangling + test-impl verbs
421
437
  forwardCallEdges.push({ src: edge.src, dst: edge.dst, kind: "CALL" });
438
+ } else if (edge.kind === "TABLE-READ" || edge.kind === "TABLE-WRITE") {
439
+ // dst = TABLE#<name>#<operation>@<line>
440
+ const m = /^TABLE#([^#]+)#([^@]+)@(\d+)$/.exec(edge.dst);
441
+ if (m) {
442
+ if (!tableUses.has(m[1])) tableUses.set(m[1], []);
443
+ tableUses.get(m[1]).push({ src: edge.src, access: edge.kind.slice(6), op: m[2], line: Number(m[3]) });
444
+ }
422
445
  }
423
446
  }
424
447
  }
@@ -433,6 +456,8 @@ function buildIndex(records, skippedFiles) {
433
456
  nameMatchedCallGraph: buildNameMatchedCallGraph(forwardCallEdges, funcEntities, fileTier),
434
457
  forwardCallEdges,
435
458
  funcEntities,
459
+ tableEntities,
460
+ tableUses,
436
461
  allFiles,
437
462
  fileTier,
438
463
  tier: dominantTier,
@@ -546,9 +571,12 @@ function loadSqliteStore(dbPath) {
546
571
  // M98: end_line is a post-M98 column; a pre-M98 graph (read-only here, so the
547
572
  // write-path migration can't run) lacks it. Detect and select conditionally so
548
573
  // an older index still loads (end_line just comes back null → body re-indexes).
549
- const hasEndLine = db.prepare("PRAGMA table_info(nodes)").all().some((c) => c.name === "end_line");
574
+ const nodeCols = db.prepare("PRAGMA table_info(nodes)").all().map((c) => c.name);
575
+ const hasEndLine = nodeCols.includes("end_line");
576
+ // meta (table columns / enum values) is newer still — same conditional select.
577
+ const hasMeta = nodeCols.includes("meta");
550
578
  const nodes = db.prepare(
551
- `SELECT id, kind, tier, file, name, func_id, ${hasEndLine ? "end_line" : "NULL AS end_line"} FROM nodes`
579
+ `SELECT id, kind, tier, file, name, func_id, ${hasEndLine ? "end_line" : "NULL AS end_line"}, ${hasMeta ? "meta" : "NULL AS meta"} FROM nodes`
552
580
  ).all();
553
581
  const edges = db.prepare("SELECT kind, src, dst FROM edges").all();
554
582
 
@@ -663,7 +691,12 @@ function loadSqliteStore(dbPath) {
663
691
  return byFile.get(file);
664
692
  };
665
693
  for (const n of nodes) {
666
- if (n.func_id) rec(n.file).entities.push({ funcId: n.func_id, name: n.name, file: n.file, tier: n.tier, endLine: n.end_line });
694
+ if (n.func_id) {
695
+ rec(n.file).entities.push({
696
+ funcId: n.func_id, name: n.name, file: n.file, tier: n.tier, endLine: n.end_line, kind: n.kind,
697
+ ...(n.meta === null ? {} : { meta: JSON.parse(n.meta) }),
698
+ });
699
+ }
667
700
  }
668
701
  // M114 — carry each file's TIER onto its record. Without this the record is
669
702
  // {file, entities, edges} with no tier, so the query layer cannot tell a
@@ -693,10 +726,16 @@ function loadSqliteStore(dbPath) {
693
726
  // A file with call edges but no function node (top-level code only) got no
694
727
  // tier from the node pass. Name matching needs to know whether SCIP looked
695
728
  // at it, so take its tier from the files table. [RULE] name-match-only-where-scip-never-looked
729
+ //
730
+ // Every file in the files table is a record, even one with no function and
731
+ // no edge (a types-only or constants-only file). Building records only from
732
+ // nodes/edges left those out, so `graph status` reported 4,301 files right
733
+ // after an index of 4,373 (hilo-figma-atos). [RULE] status-counts-files-table
696
734
  if (hasFilesTable) {
697
735
  for (const r of db.prepare("SELECT file, tier FROM files").all()) {
698
736
  const f = norm(r.file);
699
- if (r.tier && byFile.has(f) && !byFile.get(f).tier) byFile.get(f).tier = r.tier;
737
+ const fr = rec(f);
738
+ if (r.tier && !fr.tier) fr.tier = r.tier;
700
739
  }
701
740
  }
702
741
  db.close();
@@ -805,7 +844,8 @@ function runFreshnessCheck(storePath) {
805
844
  };
806
845
  } catch (_e) {
807
846
  // Parse/corrupt/other freshness error → BROKEN, carry the cause code.
808
- return { ok: false, reason: "graph-broken", detail: _e && _e.code ? _e.code : "freshness-failed" };
847
+ // Carry the message: "package.json is not valid JSON" must reach the user, not "freshness-failed".
848
+ return { ok: false, reason: "graph-broken", detail: _e && _e.code ? _e.code : ("freshness-failed: " + (_e && _e.message ? _e.message : String(_e))) };
809
849
  }
810
850
  }
811
851
 
@@ -907,7 +947,10 @@ function queryWhoCalls(index, identity) {
907
947
  }
908
948
 
909
949
  if (matchingFuncIds.length === 0) {
910
- return { results: [], tier: index.tier, coverage };
950
+ const out = { results: [], tier: index.tier, coverage };
951
+ const hint = tableHint(index, bareName);
952
+ if (hint) out.hint = hint;
953
+ return out;
911
954
  }
912
955
 
913
956
  if (matchingFuncIds.length === 1) {
@@ -922,6 +965,16 @@ function queryWhoCalls(index, identity) {
922
965
  return { ambiguous: true, candidates: matchingFuncIds.sort() };
923
966
  }
924
967
 
968
+ /** A who-calls on a table name (or a table builder) is a table question — say which verbs answer it. */
969
+ function tableHint(index, name) {
970
+ if (name === "pgTable" || name === "mysqlTable" || name === "sqliteTable" || name === "pgEnum") {
971
+ return `${name} declares database tables — the graph records each one: gsd-t graph table <name>, gsd-t graph who-uses <name>`;
972
+ }
973
+ const r = resolveTable(index, name);
974
+ if (r.notFound) return null;
975
+ return `'${name}' is a database ${r.table ? r.table.kind : "table"} — ask: gsd-t graph who-uses ${name} | gsd-t graph table ${name} | gsd-t graph blast-radius ${name}`;
976
+ }
977
+
925
978
  // ─── Query: body (M98) ────────────────────────────────────────────────────────
926
979
 
927
980
  /**
@@ -973,7 +1026,13 @@ function queryBody(index, identity, projectRoot) {
973
1026
  else if (matches.length > 1) return { ambiguous: true, candidates: matches.sort() };
974
1027
  }
975
1028
 
976
- if (!funcId) return { notFound: true };
1029
+ if (!funcId) {
1030
+ // Not a function — a database table or enum? [RULE] drizzle-table-entity-and-usage-edges
1031
+ const tr = resolveTable(index, identity);
1032
+ if (tr.ambiguous) return tr;
1033
+ if (!tr.table) return { notFound: true };
1034
+ return tableBody(index, tr.table, projectRoot);
1035
+ }
977
1036
 
978
1037
  const meta = index.funcEntities.get(funcId);
979
1038
  // Start line is encoded in the funcId suffix `@<line>`; end_line from the node.
@@ -1032,6 +1091,27 @@ function queryBody(index, identity, projectRoot) {
1032
1091
  };
1033
1092
  }
1034
1093
 
1094
+ /** body of a table: its declaration sliced live from disk + columns + FKs both ways. */
1095
+ function tableBody(index, t, projectRoot) {
1096
+ const startLine = parseInt(/@(\d+)$/.exec(t.id)[1], 10);
1097
+ const absFile = path.isAbsolute(t.file) ? t.file : path.join(projectRoot, t.file);
1098
+ let lines;
1099
+ try { lines = fs.readFileSync(absFile, "utf8").split("\n"); }
1100
+ catch (_e) { return { notFound: true, file: t.file }; }
1101
+ return {
1102
+ ok: true,
1103
+ funcId: t.id,
1104
+ file: t.file,
1105
+ lineRange: [startLine, t.endLine],
1106
+ tier: t.tier,
1107
+ imports: [],
1108
+ classHeader: null,
1109
+ source: lines.slice(startLine - 1, t.endLine).join("\n"),
1110
+ callers: [],
1111
+ table: tableDetail(index, t),
1112
+ };
1113
+ }
1114
+
1035
1115
  // ─── Query: blast-radius ──────────────────────────────────────────────────────
1036
1116
 
1037
1117
  /**
@@ -1067,6 +1147,12 @@ function queryBody(index, identity, projectRoot) {
1067
1147
  * @returns {{ results: string[], tier: string }}
1068
1148
  */
1069
1149
  function queryBlastRadius(index, target) {
1150
+ // A database table (by code name, SQL name, or id) — not a file, not a function.
1151
+ if (!index.allFiles.has(target) && !index.funcEntities.has(target)) {
1152
+ const tr = resolveTable(index, target);
1153
+ if (tr.table) return queryTableBlastRadius(index, tr.table);
1154
+ if (tr.ambiguous) return tr;
1155
+ }
1070
1156
  const isFilePath = !target.includes("#");
1071
1157
 
1072
1158
  // Build the initial frontier (multi-root if file-path: include owned funcIds)
@@ -1135,6 +1221,171 @@ function queryBlastRadius(index, target) {
1135
1221
  };
1136
1222
  }
1137
1223
 
1224
+ // ─── Database tables (Drizzle) ────────────────────────────────────────────────
1225
+ //
1226
+ // Verbs: `table <name>`, `who-uses <table> [--writes|--reads]`, and table mode of
1227
+ // `blast-radius` and `body`. A table is found by its code name (scheduleEvents)
1228
+ // or its SQL name (schedule_events, case-insensitive — a domain value).
1229
+ // Uses are matched by the table's exported name as written at the use site
1230
+ // (syntactic, not compiler-resolved): an import alias is followed, a table passed
1231
+ // through a function parameter is not. [RULE] drizzle-table-entity-and-usage-edges
1232
+ // [RULE] table-not-indexed-distinct-from-no-users
1233
+
1234
+ /** → { table: {id, ...meta} } | { ambiguous, candidates } | { notFound } */
1235
+ function resolveTable(index, target) {
1236
+ if (!index.tableEntities) return { notFound: true };
1237
+ if (target.includes("#")) {
1238
+ const noLine = target.replace(/@\d+$/, "");
1239
+ for (const [id, t] of index.tableEntities) {
1240
+ if (id === target || id.replace(/@\d+$/, "") === noLine) return { table: { id, ...t } };
1241
+ }
1242
+ return { notFound: true };
1243
+ }
1244
+ const byCode = [];
1245
+ const bySql = [];
1246
+ const lower = target.toLowerCase();
1247
+ for (const [id, t] of index.tableEntities) {
1248
+ if (t.name === target) byCode.push(id);
1249
+ else if (typeof t.meta.sqlName === "string" && t.meta.sqlName.toLowerCase() === lower) bySql.push(id);
1250
+ }
1251
+ const ids = byCode.length ? byCode : bySql;
1252
+ if (ids.length === 0) return { notFound: true };
1253
+ if (ids.length > 1) return { ambiguous: true, candidates: ids.sort() };
1254
+ return { table: { id: ids[0], ...index.tableEntities.get(ids[0]) } };
1255
+ }
1256
+
1257
+ function notIndexedDetail(index, target) {
1258
+ const n = index.tableEntities ? index.tableEntities.size : 0;
1259
+ return `no table or enum named '${target}' is indexed (${n} tables/enums in the graph, found by code name or SQL name) — ` +
1260
+ `check the name; if it was declared since the last build, run: gsd-t graph index`;
1261
+ }
1262
+
1263
+ /** Table ids declared under a code name (FK targets are written as code names). */
1264
+ function tableIdsNamed(index, name) {
1265
+ const ids = [];
1266
+ for (const [id, t] of index.tableEntities) if (t.name === name) ids.push(id);
1267
+ return ids.sort();
1268
+ }
1269
+
1270
+ /** Foreign keys out of `t` (column-level references + table-level foreignKey()). */
1271
+ function foreignKeysOut(index, t) {
1272
+ const out = [];
1273
+ for (const c of t.meta.columns ? t.meta.columns : []) {
1274
+ if (!c.references) continue;
1275
+ out.push({ column: c.name, sqlColumn: c.sqlName, table: c.references.table, targetColumn: c.references.column, tableIds: tableIdsNamed(index, c.references.table) });
1276
+ }
1277
+ for (const fk of t.meta.foreignKeys ? t.meta.foreignKeys : []) {
1278
+ out.push({ column: fk.columns.join(", "), table: fk.table, targetColumn: fk.foreignColumns.join(", "), tableIds: tableIdsNamed(index, fk.table) });
1279
+ }
1280
+ return out;
1281
+ }
1282
+
1283
+ /** Foreign keys INTO `t` from every other indexed table. */
1284
+ function foreignKeysIn(index, t) {
1285
+ const refs = [];
1286
+ for (const [id, other] of index.tableEntities) {
1287
+ if (other.kind !== "table") continue;
1288
+ for (const fk of foreignKeysOut(index, other)) {
1289
+ if (fk.table === t.name) refs.push({ table: other.name, tableId: id, column: fk.column, sqlColumn: fk.sqlColumn, targetColumn: fk.targetColumn });
1290
+ }
1291
+ }
1292
+ return refs.sort((a, b) => (a.tableId + a.column < b.tableId + b.column ? -1 : 1));
1293
+ }
1294
+
1295
+ function tableDetail(index, t) {
1296
+ const detail = {
1297
+ id: t.id, kind: t.kind, name: t.name, sqlName: t.meta.sqlName, dialect: t.meta.dialect, file: t.file,
1298
+ };
1299
+ if (t.kind === "enum") {
1300
+ detail.values = t.meta.values;
1301
+ // Tables whose columns are built from this enum (`statusEnum('status')`).
1302
+ detail.usedByColumns = [];
1303
+ for (const [id, other] of index.tableEntities) {
1304
+ for (const c of other.meta.columns ? other.meta.columns : []) {
1305
+ if (c.type === t.name) detail.usedByColumns.push({ table: other.name, tableId: id, column: c.name, sqlColumn: c.sqlName });
1306
+ }
1307
+ }
1308
+ } else {
1309
+ detail.columns = t.meta.columns;
1310
+ detail.references = foreignKeysOut(index, t);
1311
+ detail.referencedBy = foreignKeysIn(index, t);
1312
+ }
1313
+ if (t.meta.unresolved) detail.unresolved = t.meta.unresolved;
1314
+ return detail;
1315
+ }
1316
+
1317
+ /** `table <name>` → columns + foreign keys in both directions. */
1318
+ function queryTable(index, target) {
1319
+ const r = resolveTable(index, target);
1320
+ if (!r.table) return r.ambiguous ? r : { notFound: true, detail: notIndexedDetail(index, target) };
1321
+ return { table: tableDetail(index, r.table), tier: index.tier };
1322
+ }
1323
+
1324
+ /** `who-uses <table> [--writes|--reads]` → the functions / methods / route handlers that use it. */
1325
+ function queryWhoUses(index, target, options) {
1326
+ const mode = options && options.mode ? options.mode : "all";
1327
+ const r = resolveTable(index, target);
1328
+ if (!r.table) return r.ambiguous ? r : { notFound: true, detail: notIndexedDetail(index, target) };
1329
+ const t = r.table;
1330
+ const all = index.tableUses.has(t.name) ? index.tableUses.get(t.name) : [];
1331
+ const uses = all.filter((u) => mode === "all" || (mode === "writes" ? u.access === "WRITE" : u.access === "READ"));
1332
+ const byUser = new Map();
1333
+ const byOperation = {};
1334
+ for (const u of uses) {
1335
+ if (!byUser.has(u.src)) byUser.set(u.src, { user: u.src, file: u.src.split("#")[0], access: [], operations: {} });
1336
+ const row = byUser.get(u.src);
1337
+ if (!row.access.includes(u.access)) row.access.push(u.access);
1338
+ if (!row.operations[u.op]) row.operations[u.op] = [];
1339
+ row.operations[u.op].push(u.line);
1340
+ byOperation[u.op] = (byOperation[u.op] ? byOperation[u.op] : 0) + 1;
1341
+ }
1342
+ const results = Array.from(byUser.values()).sort((a, b) => (a.user < b.user ? -1 : 1));
1343
+ for (const row of results) row.access.sort();
1344
+ const out = {
1345
+ table: { id: t.id, name: t.name, sqlName: t.meta.sqlName, kind: t.kind },
1346
+ mode,
1347
+ results,
1348
+ summary: { users: results.length, files: new Set(results.map((x) => x.file)).size, byOperation },
1349
+ resolution: "syntactic — uses matched by the table's imported name; a table passed through a function parameter or alias() is not followed",
1350
+ coverage: computeCoverage(index.skippedFiles),
1351
+ tier: index.tier,
1352
+ };
1353
+ const sharing = tableIdsNamed(index, t.name);
1354
+ if (sharing.length > 1) out.sharedName = { note: `${sharing.length} tables share the code name '${t.name}' — uses cannot be split between them`, tables: sharing };
1355
+ if (results.length === 0) {
1356
+ out.note = mode === "all"
1357
+ ? `'${t.name}' is indexed; no code in the index uses it`
1358
+ : `'${t.name}' is indexed; no code in the index ${mode === "writes" ? "writes" : "reads"} it`;
1359
+ }
1360
+ return out;
1361
+ }
1362
+
1363
+ /** blast-radius of a table: tables that reference it by FK (transitively) + the code that uses it. */
1364
+ function queryTableBlastRadius(index, t) {
1365
+ const tables = [];
1366
+ const seen = new Set([t.id]);
1367
+ const queue = [{ table: t, depth: 1 }];
1368
+ while (queue.length) {
1369
+ const { table, depth } = queue.shift();
1370
+ for (const ref of foreignKeysIn(index, table)) {
1371
+ if (seen.has(ref.tableId)) continue;
1372
+ seen.add(ref.tableId);
1373
+ tables.push({ id: ref.tableId, via: `${ref.table}.${ref.column} → ${table.name}.${ref.targetColumn}`, depth });
1374
+ queue.push({ table: { id: ref.tableId, ...index.tableEntities.get(ref.tableId) }, depth: depth + 1 });
1375
+ }
1376
+ }
1377
+ const users = (index.tableUses.has(t.name) ? index.tableUses.get(t.name) : []).map((u) => u.src);
1378
+ const code = Array.from(new Set(users)).sort();
1379
+ return {
1380
+ results: tables.map((x) => x.id).concat(code),
1381
+ table: { id: t.id, name: t.name, sqlName: t.meta.sqlName },
1382
+ referencingTables: tables,
1383
+ users: code,
1384
+ tier: index.tier,
1385
+ coverage: computeCoverage(index.skippedFiles),
1386
+ };
1387
+ }
1388
+
1138
1389
  // ─── Query: status ────────────────────────────────────────────────────────────
1139
1390
 
1140
1391
  /**
@@ -1159,10 +1410,59 @@ function queryStatus(index, storePath) {
1159
1410
  funcCount: index.funcEntities.size,
1160
1411
  importEdgeCount: Array.from(index.importGraph.values()).reduce((s, v) => s + v.size, 0),
1161
1412
  callEdgeCount: Array.from(index.callGraph.values()).reduce((s, v) => s + v.size, 0),
1413
+ // `tier` is the WORST tier present (one floor file makes it floor). The per-tier
1414
+ // counts are what a build reports, so status shows them too. [RULE] status-counts-files-table
1162
1415
  tier: index.tier,
1416
+ tiers: tierCounts(index),
1417
+ tableCount: countKind(index, "table"),
1418
+ enumCount: countKind(index, "enum"),
1419
+ excludeSuggestions: suggestExcludes(index),
1163
1420
  };
1164
1421
  }
1165
1422
 
1423
+ function tierCounts(index) {
1424
+ const counts = {};
1425
+ for (const tier of (index.fileTier ? index.fileTier.values() : [])) counts[tier] = (counts[tier] ? counts[tier] : 0) + 1;
1426
+ return counts;
1427
+ }
1428
+
1429
+ function countKind(index, kind) {
1430
+ let n = 0;
1431
+ for (const t of (index.tableEntities ? index.tableEntities.values() : [])) if (t.kind === kind) n++;
1432
+ return n;
1433
+ }
1434
+
1435
+ /**
1436
+ * Top-level folders that look like they are not the application: no file in them
1437
+ * imports, or is imported by, a file in the project's largest folder (the app).
1438
+ * A SUGGESTION for .gsd-t/graph-exclude.json — never applied automatically, since
1439
+ * guessing wrong would silently drop app code from the graph.
1440
+ */
1441
+ function suggestExcludes(index) {
1442
+ const top = (f) => (f.includes("/") ? f.split("/")[0] : null);
1443
+ const sizes = new Map();
1444
+ for (const f of index.allFiles) {
1445
+ const t = top(f);
1446
+ if (t !== null) sizes.set(t, (sizes.has(t) ? sizes.get(t) : 0) + 1);
1447
+ }
1448
+ if (sizes.size < 2) return [];
1449
+ const main = Array.from(sizes.entries()).sort((a, b) => b[1] - a[1])[0][0];
1450
+ const linked = new Set([main]);
1451
+ for (const [dst, srcs] of index.importGraph) {
1452
+ if (!index.allFiles.has(dst)) continue;
1453
+ const d = top(dst);
1454
+ for (const src of srcs) {
1455
+ const sTop = top(src);
1456
+ if (d === main && sTop !== null) linked.add(sTop);
1457
+ if (sTop === main && d !== null) linked.add(d);
1458
+ }
1459
+ }
1460
+ return Array.from(sizes.entries())
1461
+ .filter(([folder]) => !linked.has(folder))
1462
+ .sort((a, b) => b[1] - a[1])
1463
+ .map(([folder, files]) => ({ folder: folder + "/", files, reason: `no import edges to or from ${main}/` }));
1464
+ }
1465
+
1166
1466
  // ─── D9-T1: Query: cluster (tightly-coupled file groups) ─────────────────────
1167
1467
  //
1168
1468
  // [RULE] cluster-verb-deterministic-coupling
@@ -1542,6 +1842,10 @@ module.exports = {
1542
1842
  queryBody,
1543
1843
  queryBlastRadius,
1544
1844
  queryStatus,
1845
+ queryTable,
1846
+ queryWhoUses,
1847
+ resolveTable,
1848
+ suggestExcludes,
1545
1849
  // D9 additions
1546
1850
  queryCluster,
1547
1851
  queryDeadCode,
@@ -1637,6 +1941,7 @@ if (require.main === module) {
1637
1941
  const ALL_VERBS = [
1638
1942
  "who-imports", "who-calls", "body", "blast-radius", "status",
1639
1943
  "cluster", "dead-code", "orphan", "dangling", "test-impl",
1944
+ "who-uses", "table",
1640
1945
  ];
1641
1946
 
1642
1947
  if (!verb) {
@@ -1705,6 +2010,7 @@ if (require.main === module) {
1705
2010
  }
1706
2011
  const env = { ok: true, verb, target, results: queryResult.results, tier: queryResult.tier, coverage: queryResult.coverage };
1707
2012
  if (queryResult.nameMatched) env.nameMatched = queryResult.nameMatched; // [RULE] unique-name-unresolved-call-name-matched
2013
+ if (queryResult.hint) env.hint = queryResult.hint; // a table name asked of who-calls
1708
2014
  emit(env);
1709
2015
 
1710
2016
  } else if (verb === "body") {
@@ -1755,11 +2061,21 @@ if (require.main === module) {
1755
2061
  lineRange: bodyResult.lineRange, tier: bodyResult.tier,
1756
2062
  imports: bodyResult.imports, classHeader: bodyResult.classHeader,
1757
2063
  source: bodyResult.source, callers: bodyResult.callers,
2064
+ ...(bodyResult.table ? { table: bodyResult.table } : {}),
1758
2065
  });
1759
2066
 
1760
2067
  } else if (verb === "blast-radius") {
1761
2068
  if (!target) fail({ ok: false, reason: "missing-target", verb });
1762
- const { results, tier, coverage, nameMatched } = queryBlastRadius(index, target);
2069
+ const br = queryBlastRadius(index, target);
2070
+ if (br.ambiguous) {
2071
+ emit({ ok: false, reason: "ambiguous-table", verb, target, candidates: br.candidates });
2072
+ process.exit(2);
2073
+ }
2074
+ if (br.table) {
2075
+ emit({ ok: true, verb, target, results: br.results, table: br.table, referencingTables: br.referencingTables, users: br.users, tier: br.tier, coverage: br.coverage });
2076
+ process.exit(0);
2077
+ }
2078
+ const { results, tier, coverage, nameMatched } = br;
1763
2079
  if (results.length === 0 && coverage && coverage.complete === false) {
1764
2080
  writeIncompleteAnswerMarker(_resolver.deriveProjectRoot(storePath), verb, target, coverage);
1765
2081
  }
@@ -1771,7 +2087,20 @@ if (require.main === module) {
1771
2087
  const statusData = queryStatus(index, storePath);
1772
2088
  // Name the project exclude list so a missing folder is never a mystery.
1773
2089
  const ex = require("./gsd-t-graph-exclude.cjs").loadGraphExcludes(_resolver.deriveProjectRoot(storePath));
1774
- emit({ ok: true, verb: "status", ...statusData, excludes: { source: ex.source, patterns: ex.patterns } });
2090
+ emit({ ok: true, verb: "status", ...statusData, excludes: { source: ex.source, patterns: ex.patterns, defaults: ex.defaults } });
2091
+
2092
+ } else if (verb === "who-uses" || verb === "table") {
2093
+ // [RULE] table-not-indexed-distinct-from-no-users — not-found (reason + detail)
2094
+ // is a different envelope from an indexed table with no users (ok, results []).
2095
+ if (!target) fail({ ok: false, reason: "missing-target", verb });
2096
+ const mode = args.includes("--writes") ? "writes" : (args.includes("--reads") ? "reads" : "all");
2097
+ const r = verb === "table" ? queryTable(index, target) : queryWhoUses(index, target, { mode });
2098
+ if (r.ambiguous) {
2099
+ emit({ ok: false, reason: "ambiguous-table", verb, target, candidates: r.candidates });
2100
+ process.exit(2);
2101
+ }
2102
+ if (r.notFound) fail({ ok: false, reason: "not-found", verb, target, detail: r.detail });
2103
+ emit({ ok: true, verb, target, ...r });
1775
2104
 
1776
2105
  } else if (verb === "cluster") {
1777
2106
  const { results, tier } = queryCluster(index);
package/bin/gsd-t.js CHANGED
@@ -4766,7 +4766,11 @@ function doGraphStatus() {
4766
4766
  return;
4767
4767
  }
4768
4768
  success(`Graph index: ${envelope.fileCount || 0} files`);
4769
- if (envelope.tier) info(`Tier: ${envelope.tier}`);
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>)`);
4770
4774
  // [RULE] scip-missing-file-detected-never-silent — files the SCIP indexer never
4771
4775
  // produced a document for: their call edges stay unresolved, so who-calls is blind there.
4772
4776
  const miss = envelope.scipMissing;
@@ -4780,6 +4784,13 @@ function doGraphStatus() {
4780
4784
  } else {
4781
4785
  info("Excludes: none (add folders to .gsd-t/graph-exclude.json — { \"exclude\": [\"design/\"] })");
4782
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
+ }
4783
4794
  if (envelope.storeSize !== undefined) info(`Store size: ${envelope.storeSize} bytes`);
4784
4795
  if (envelope.detail) log(JSON.stringify(envelope, null, 2));
4785
4796
  }
@@ -4793,7 +4804,7 @@ function doGraphQuery(args) {
4793
4804
  const verb = args[0];
4794
4805
  if (!verb) {
4795
4806
  error("Usage: gsd-t graph query <verb> [target]");
4796
- 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");
4797
4808
  return;
4798
4809
  }
4799
4810
  const envelope = _graphQueryCli([verb].concat(args.slice(1)));
@@ -4818,6 +4829,9 @@ function doGraph(args) {
4818
4829
  case "who-calls": { const e = _graphQueryCli(["who-calls", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
4819
4830
  case "blast-radius": { const e = _graphQueryCli(["blast-radius", args[1] || ""]); log(JSON.stringify(e, null, 2)); break; }
4820
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; }
4821
4835
  case "tasks": doGraphTaskOutput(args[1] || "table"); break;
4822
4836
  case "metrics": { // M99 D3-T2: append-only arm — rollup the telemetry ledger
4823
4837
  const _metricsRollup = require("./gsd-t-graph-metrics-rollup.cjs");
@@ -4866,7 +4880,7 @@ function doGraph(args) {
4866
4880
  }
4867
4881
  default:
4868
4882
  error(`Unknown graph subcommand: ${sub}`);
4869
- 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]");
4870
4884
  info(" gsd-t graph --output json|table (task DAG)");
4871
4885
  }
4872
4886
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tekyzinc/gsd-t",
3
- "version": "5.23.10",
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
- return [
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,9 +361,12 @@ 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