@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 +15 -0
- package/README.md +1 -1
- package/bin/gsd-t-graph-edge-extract.cjs +341 -0
- package/bin/gsd-t-graph-exclude.cjs +28 -3
- package/bin/gsd-t-graph-freshness.cjs +11 -4
- package/bin/gsd-t-graph-index.cjs +60 -3
- package/bin/gsd-t-graph-query-cli.cjs +339 -10
- package/bin/gsd-t.js +17 -3
- package/package.json +1 -1
- package/scripts/gsd-t-graph-search-guard.js +46 -2
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.
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "5.24.10",
|
|
4
4
|
"description": "GSD-T: Contract-Driven Development for Claude Code \u2014 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -281,8 +281,47 @@ function buildNoGraphReason(found, cls) {
|
|
|
281
281
|
].join("\n");
|
|
282
282
|
}
|
|
283
283
|
|
|
284
|
+
// --- Database-table questions ---------------------------------------------
|
|
285
|
+
//
|
|
286
|
+
// `grep -rn "insert(scheduleEvents"` or `grep pgTable` asks about a database
|
|
287
|
+
// table. The graph indexes Drizzle tables (who-uses / table / blast-radius), so
|
|
288
|
+
// the block names those verbs and the table instead of sending the caller to
|
|
289
|
+
// who-calls, which has no answer for a table. [RULE] search-guard-routes-table-questions
|
|
290
|
+
|
|
291
|
+
const TABLE_BUILDER_SHAPE = /\b(pgTable|mysqlTable|sqliteTable|pgEnum)\b/;
|
|
292
|
+
const TABLE_OP_SHAPE = /\.?\b(from|innerJoin|leftJoin|rightJoin|fullJoin|insert|update|delete|references)\s*\\?\(\s*(?:\\?\(\s*\\?\)\s*=>\s*)?([A-Za-z_$][\w$]*)/;
|
|
293
|
+
const QUERY_API_SHAPE = /\bquery\\?\.([A-Za-z_$][\w$]*)/;
|
|
294
|
+
|
|
295
|
+
/** → { table: name|null, why } when the pattern reads as a table question; else null. */
|
|
296
|
+
function tableQuestion(pattern) {
|
|
297
|
+
const p = String(pattern);
|
|
298
|
+
const op = TABLE_OP_SHAPE.exec(p);
|
|
299
|
+
if (op) return { table: op[2], why: "a Drizzle table operation (" + op[1] + ")" };
|
|
300
|
+
const q = QUERY_API_SHAPE.exec(p);
|
|
301
|
+
if (q && /\bdb\b|\btx\b/.test(p)) return { table: q[1], why: "a Drizzle relational query (db.query.<table>)" };
|
|
302
|
+
if (TABLE_BUILDER_SHAPE.test(p)) return { table: null, why: "a Drizzle table declaration" };
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function tableRouting(tq) {
|
|
307
|
+
const t = tq.table === null ? "<table>" : tq.table;
|
|
308
|
+
return [
|
|
309
|
+
"This reads as a database-table question (" + tq.why + "). The graph indexes tables:",
|
|
310
|
+
"",
|
|
311
|
+
" gsd-t graph who-uses " + t + " - every function / route / method that uses it",
|
|
312
|
+
" gsd-t graph who-uses " + t + " --writes - only inserts / updates / deletes (--reads for reads)",
|
|
313
|
+
" gsd-t graph table " + t + " - its columns and foreign keys, both directions",
|
|
314
|
+
" gsd-t graph blast-radius " + t + " - tables that reference it + the code that uses it",
|
|
315
|
+
"",
|
|
316
|
+
"A table is found by its code name (scheduleEvents) or its SQL name (schedule_events).",
|
|
317
|
+
"",
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
|
|
284
321
|
function buildStructuralReason(found, cls) {
|
|
285
322
|
const lines = [];
|
|
323
|
+
const tq = tableQuestion(found.pattern);
|
|
324
|
+
if (tq !== null) lines.push(...tableRouting(tq));
|
|
286
325
|
|
|
287
326
|
const symbol = cls.symbol === null ? found.pattern : cls.symbol;
|
|
288
327
|
const verb = cls.verb === null ? "who-calls" : cls.verb;
|
|
@@ -298,6 +337,7 @@ function buildStructuralReason(found, cls) {
|
|
|
298
337
|
" gsd-t graph " + verb + " " + symbol,
|
|
299
338
|
"",
|
|
300
339
|
"Other verbs: who-imports, who-calls, defines, blast-radius, body.",
|
|
340
|
+
"A database table (Drizzle)? gsd-t graph who-uses " + symbol + " / gsd-t graph table " + symbol,
|
|
301
341
|
"",
|
|
302
342
|
"If this really is a text search - a phrase in prose, a key in config, a string in a",
|
|
303
343
|
"document - scope it to the files the graph does not index, and it will run:",
|
|
@@ -308,7 +348,8 @@ function buildStructuralReason(found, cls) {
|
|
|
308
348
|
}
|
|
309
349
|
|
|
310
350
|
function buildUnclearReason(found, cls) {
|
|
311
|
-
|
|
351
|
+
const tq = tableQuestion(found.pattern);
|
|
352
|
+
return (tq === null ? [] : tableRouting(tq)).concat([
|
|
312
353
|
"This search could be asking about code structure, and that has to be settled before",
|
|
313
354
|
"it runs - a guess in either direction is how the graph rule stopped having teeth.",
|
|
314
355
|
"",
|
|
@@ -320,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
|
|