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