@optave/codegraph 3.9.3 → 3.9.5
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/README.md +10 -10
- package/dist/ast-analysis/visitor.d.ts.map +1 -1
- package/dist/ast-analysis/visitor.js +14 -0
- package/dist/ast-analysis/visitor.js.map +1 -1
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/commands/watch.js +2 -0
- package/dist/cli/commands/watch.js.map +1 -1
- package/dist/cli.js +24 -1
- package/dist/cli.js.map +1 -1
- package/dist/domain/graph/builder/context.d.ts +17 -0
- package/dist/domain/graph/builder/context.d.ts.map +1 -1
- package/dist/domain/graph/builder/context.js +7 -0
- package/dist/domain/graph/builder/context.js.map +1 -1
- package/dist/domain/graph/builder/helpers.d.ts +13 -2
- package/dist/domain/graph/builder/helpers.d.ts.map +1 -1
- package/dist/domain/graph/builder/helpers.js +30 -4
- package/dist/domain/graph/builder/helpers.js.map +1 -1
- package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
- package/dist/domain/graph/builder/pipeline.js +221 -51
- package/dist/domain/graph/builder/pipeline.js.map +1 -1
- package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/build-edges.js +67 -6
- package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
- package/dist/domain/graph/builder/stages/build-structure.js +2 -2
- package/dist/domain/graph/builder/stages/collect-files.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/collect-files.js +58 -26
- package/dist/domain/graph/builder/stages/collect-files.js.map +1 -1
- package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/detect-changes.js +105 -55
- package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
- package/dist/domain/graph/builder/stages/finalize.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/finalize.js +27 -4
- package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
- package/dist/domain/graph/builder/stages/run-analyses.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/run-analyses.js +5 -20
- package/dist/domain/graph/builder/stages/run-analyses.js.map +1 -1
- package/dist/domain/graph/journal.d.ts +15 -0
- package/dist/domain/graph/journal.d.ts.map +1 -1
- package/dist/domain/graph/journal.js +283 -28
- package/dist/domain/graph/journal.js.map +1 -1
- package/dist/domain/graph/watcher.d.ts +17 -0
- package/dist/domain/graph/watcher.d.ts.map +1 -1
- package/dist/domain/graph/watcher.js +23 -7
- package/dist/domain/graph/watcher.js.map +1 -1
- package/dist/domain/parser.d.ts +13 -4
- package/dist/domain/parser.d.ts.map +1 -1
- package/dist/domain/parser.js +174 -80
- package/dist/domain/parser.js.map +1 -1
- package/dist/domain/search/generator.d.ts.map +1 -1
- package/dist/domain/search/generator.js +28 -2
- package/dist/domain/search/generator.js.map +1 -1
- package/dist/domain/wasm-worker-entry.d.ts +24 -0
- package/dist/domain/wasm-worker-entry.d.ts.map +1 -0
- package/dist/domain/wasm-worker-entry.js +643 -0
- package/dist/domain/wasm-worker-entry.js.map +1 -0
- package/dist/domain/wasm-worker-pool.d.ts +59 -0
- package/dist/domain/wasm-worker-pool.d.ts.map +1 -0
- package/dist/domain/wasm-worker-pool.js +312 -0
- package/dist/domain/wasm-worker-pool.js.map +1 -0
- package/dist/domain/wasm-worker-protocol.d.ts +65 -0
- package/dist/domain/wasm-worker-protocol.d.ts.map +1 -0
- package/dist/domain/wasm-worker-protocol.js +13 -0
- package/dist/domain/wasm-worker-protocol.js.map +1 -0
- package/dist/extractors/javascript.js +265 -1
- package/dist/extractors/javascript.js.map +1 -1
- package/dist/features/boundaries.d.ts +2 -2
- package/dist/features/boundaries.d.ts.map +1 -1
- package/dist/features/boundaries.js +2 -31
- package/dist/features/boundaries.js.map +1 -1
- package/dist/features/snapshot.d.ts.map +1 -1
- package/dist/features/snapshot.js +99 -13
- package/dist/features/snapshot.js.map +1 -1
- package/dist/features/structure.d.ts.map +1 -1
- package/dist/features/structure.js +14 -1
- package/dist/features/structure.js.map +1 -1
- package/dist/graph/algorithms/louvain.d.ts.map +1 -1
- package/dist/graph/algorithms/louvain.js +2 -4
- package/dist/graph/algorithms/louvain.js.map +1 -1
- package/dist/infrastructure/config.d.ts.map +1 -1
- package/dist/infrastructure/config.js +12 -2
- package/dist/infrastructure/config.js.map +1 -1
- package/dist/shared/globs.d.ts +40 -0
- package/dist/shared/globs.d.ts.map +1 -0
- package/dist/shared/globs.js +126 -0
- package/dist/shared/globs.js.map +1 -0
- package/dist/types.d.ts +26 -1
- package/dist/types.d.ts.map +1 -1
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/package.json +7 -7
- package/src/ast-analysis/visitor.ts +15 -0
- package/src/cli/commands/watch.ts +2 -0
- package/src/cli.ts +31 -8
- package/src/domain/graph/builder/context.ts +19 -0
- package/src/domain/graph/builder/helpers.ts +53 -3
- package/src/domain/graph/builder/pipeline.ts +235 -49
- package/src/domain/graph/builder/stages/build-edges.ts +80 -6
- package/src/domain/graph/builder/stages/build-structure.ts +2 -2
- package/src/domain/graph/builder/stages/collect-files.ts +56 -26
- package/src/domain/graph/builder/stages/detect-changes.ts +118 -61
- package/src/domain/graph/builder/stages/finalize.ts +27 -4
- package/src/domain/graph/builder/stages/run-analyses.ts +5 -26
- package/src/domain/graph/journal.ts +284 -27
- package/src/domain/graph/watcher.ts +29 -9
- package/src/domain/parser.ts +166 -73
- package/src/domain/search/generator.ts +34 -2
- package/src/domain/wasm-worker-entry.ts +788 -0
- package/src/domain/wasm-worker-pool.ts +330 -0
- package/src/domain/wasm-worker-protocol.ts +81 -0
- package/src/extractors/javascript.ts +290 -1
- package/src/features/boundaries.ts +2 -27
- package/src/features/snapshot.ts +93 -14
- package/src/features/structure.ts +17 -1
- package/src/graph/algorithms/louvain.ts +2 -4
- package/src/infrastructure/config.ts +12 -2
- package/src/shared/globs.ts +121 -0
- package/src/types.ts +26 -1
|
@@ -274,14 +274,17 @@ function dispatchQueryMatch(
|
|
|
274
274
|
name: c.callfn_name!.text,
|
|
275
275
|
line: c.callfn_node.startPosition.row + 1,
|
|
276
276
|
});
|
|
277
|
+
calls.push(...extractCallbackReferenceCalls(c.callfn_node));
|
|
277
278
|
} else if (c.callmem_node) {
|
|
278
279
|
const callInfo = extractCallInfo(c.callmem_fn!, c.callmem_node);
|
|
279
280
|
if (callInfo) calls.push(callInfo);
|
|
280
281
|
const cbDef = extractCallbackDefinition(c.callmem_node, c.callmem_fn);
|
|
281
282
|
if (cbDef) definitions.push(cbDef);
|
|
283
|
+
calls.push(...extractCallbackReferenceCalls(c.callmem_node));
|
|
282
284
|
} else if (c.callsub_node) {
|
|
283
285
|
const callInfo = extractCallInfo(c.callsub_fn!, c.callsub_node);
|
|
284
286
|
if (callInfo) calls.push(callInfo);
|
|
287
|
+
calls.push(...extractCallbackReferenceCalls(c.callsub_node));
|
|
285
288
|
} else if (c.newfn_node) {
|
|
286
289
|
calls.push({
|
|
287
290
|
name: c.newfn_name!.text,
|
|
@@ -321,6 +324,9 @@ function extractSymbolsQuery(tree: TreeSitterTree, query: TreeSitterQuery): Extr
|
|
|
321
324
|
// Extract typeMap from type annotations and new expressions
|
|
322
325
|
extractTypeMapWalk(tree.rootNode, typeMap);
|
|
323
326
|
|
|
327
|
+
// Extract definitions from destructured bindings (query patterns don't match object_pattern)
|
|
328
|
+
extractDestructuredBindingsWalk(tree.rootNode, definitions);
|
|
329
|
+
|
|
324
330
|
return { definitions, calls, imports, classes, exports: exps, typeMap };
|
|
325
331
|
}
|
|
326
332
|
|
|
@@ -334,6 +340,20 @@ const FUNCTION_SCOPE_TYPES = new Set([
|
|
|
334
340
|
'generator_function',
|
|
335
341
|
]);
|
|
336
342
|
|
|
343
|
+
/**
|
|
344
|
+
* Return true when `node` has an ancestor whose type is in FUNCTION_SCOPE_TYPES.
|
|
345
|
+
* Used by the walk path to skip declarations inside function bodies, matching
|
|
346
|
+
* the query path's top-down FUNCTION_SCOPE_TYPES filter.
|
|
347
|
+
*/
|
|
348
|
+
function hasFunctionScopeAncestor(node: TreeSitterNode): boolean {
|
|
349
|
+
let p: TreeSitterNode | null = node.parent ?? null;
|
|
350
|
+
while (p) {
|
|
351
|
+
if (FUNCTION_SCOPE_TYPES.has(p.type)) return true;
|
|
352
|
+
p = p.parent ?? null;
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
337
357
|
/**
|
|
338
358
|
* Recursively walk the AST to extract `const x = <literal>` as constants.
|
|
339
359
|
* Skips nodes inside function scopes so only file-level / block-level constants
|
|
@@ -363,6 +383,48 @@ function extractConstantsWalk(node: TreeSitterNode, definitions: Definition[]):
|
|
|
363
383
|
}
|
|
364
384
|
}
|
|
365
385
|
|
|
386
|
+
/**
|
|
387
|
+
* Walk the AST to find destructured const bindings (query patterns don't match object_pattern).
|
|
388
|
+
* e.g. `const { handleToken, checkPermissions } = initAuth(config)`
|
|
389
|
+
*/
|
|
390
|
+
function extractDestructuredBindingsWalk(node: TreeSitterNode, definitions: Definition[]): void {
|
|
391
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
392
|
+
const child = node.child(i);
|
|
393
|
+
if (!child) continue;
|
|
394
|
+
if (FUNCTION_SCOPE_TYPES.has(child.type)) continue;
|
|
395
|
+
|
|
396
|
+
let declNode = child;
|
|
397
|
+
if (child.type === 'export_statement') {
|
|
398
|
+
const inner = child.childForFieldName('declaration');
|
|
399
|
+
if (inner) declNode = inner;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const t = declNode.type;
|
|
403
|
+
if (
|
|
404
|
+
(t === 'lexical_declaration' || t === 'variable_declaration') &&
|
|
405
|
+
declNode.text.startsWith('const ')
|
|
406
|
+
) {
|
|
407
|
+
for (let j = 0; j < declNode.childCount; j++) {
|
|
408
|
+
const declarator = declNode.child(j);
|
|
409
|
+
if (!declarator || declarator.type !== 'variable_declarator') continue;
|
|
410
|
+
const nameN = declarator.childForFieldName('name');
|
|
411
|
+
if (nameN && nameN.type === 'object_pattern') {
|
|
412
|
+
extractDestructuredBindings(
|
|
413
|
+
nameN,
|
|
414
|
+
declNode.startPosition.row + 1,
|
|
415
|
+
nodeEndLine(declNode),
|
|
416
|
+
definitions,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (child.type !== 'export_statement') {
|
|
423
|
+
extractDestructuredBindingsWalk(child, definitions);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
366
428
|
/** Extract constant definitions from a `const` declaration node. */
|
|
367
429
|
function extractConstDeclarators(declNode: TreeSitterNode, definitions: Definition[]): void {
|
|
368
430
|
const t = declNode.type;
|
|
@@ -637,6 +699,39 @@ function handleTypeAliasDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
|
|
|
637
699
|
}
|
|
638
700
|
}
|
|
639
701
|
|
|
702
|
+
/**
|
|
703
|
+
* Extract definitions from destructured object bindings.
|
|
704
|
+
* `const { handleToken, checkPermissions } = initAuth(...)` creates definitions
|
|
705
|
+
* for handleToken and checkPermissions so they can be resolved as call targets.
|
|
706
|
+
*/
|
|
707
|
+
function extractDestructuredBindings(
|
|
708
|
+
pattern: TreeSitterNode,
|
|
709
|
+
line: number,
|
|
710
|
+
endLine: number,
|
|
711
|
+
definitions: Definition[],
|
|
712
|
+
): void {
|
|
713
|
+
for (let i = 0; i < pattern.childCount; i++) {
|
|
714
|
+
const child = pattern.child(i);
|
|
715
|
+
if (!child) continue;
|
|
716
|
+
if (
|
|
717
|
+
child.type === 'shorthand_property_identifier_pattern' ||
|
|
718
|
+
child.type === 'shorthand_property_identifier'
|
|
719
|
+
) {
|
|
720
|
+
// { handleToken } — shorthand binding
|
|
721
|
+
definitions.push({ name: child.text, kind: 'function', line, endLine });
|
|
722
|
+
} else if (child.type === 'pair_pattern' || child.type === 'pair') {
|
|
723
|
+
// { original: renamed } — renamed binding, use the local alias
|
|
724
|
+
const value = child.childForFieldName('value');
|
|
725
|
+
if (
|
|
726
|
+
value &&
|
|
727
|
+
(value.type === 'identifier' || value.type === 'shorthand_property_identifier_pattern')
|
|
728
|
+
) {
|
|
729
|
+
definitions.push({ name: value.text, kind: 'function', line, endLine });
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
640
735
|
function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
|
|
641
736
|
const isConst = node.text.startsWith('const ');
|
|
642
737
|
for (let i = 0; i < node.childCount; i++) {
|
|
@@ -667,6 +762,20 @@ function handleVariableDecl(node: TreeSitterNode, ctx: ExtractorOutput): void {
|
|
|
667
762
|
line: node.startPosition.row + 1,
|
|
668
763
|
endLine: nodeEndLine(node),
|
|
669
764
|
});
|
|
765
|
+
} else if (isConst && nameN.type === 'object_pattern' && !hasFunctionScopeAncestor(node)) {
|
|
766
|
+
// Destructured bindings: const { handleToken, checkPermissions } = initAuth(...)
|
|
767
|
+
// Each destructured property becomes a function definition so it can be
|
|
768
|
+
// resolved when passed as a callback (e.g. router.use(handleToken)).
|
|
769
|
+
// Restricted to const to avoid creating spurious definitions for
|
|
770
|
+
// transient let/var destructuring (e.g. let { userId } = parseRequest(req)).
|
|
771
|
+
// Scope guard mirrors extractDestructuredBindingsWalk (query path) and
|
|
772
|
+
// handle_var_decl (Rust path) — skips bindings inside function bodies.
|
|
773
|
+
extractDestructuredBindings(
|
|
774
|
+
nameN,
|
|
775
|
+
node.startPosition.row + 1,
|
|
776
|
+
nodeEndLine(node),
|
|
777
|
+
ctx.definitions,
|
|
778
|
+
);
|
|
670
779
|
}
|
|
671
780
|
}
|
|
672
781
|
}
|
|
@@ -715,6 +824,7 @@ function handleCallExpr(node: TreeSitterNode, ctx: ExtractorOutput): void {
|
|
|
715
824
|
const cbDef = extractCallbackDefinition(node, fn);
|
|
716
825
|
if (cbDef) ctx.definitions.push(cbDef);
|
|
717
826
|
}
|
|
827
|
+
ctx.calls.push(...extractCallbackReferenceCalls(node));
|
|
718
828
|
}
|
|
719
829
|
}
|
|
720
830
|
|
|
@@ -1072,7 +1182,11 @@ function handleVarDeclaratorTypeMap(
|
|
|
1072
1182
|
const obj = fn.childForFieldName('object');
|
|
1073
1183
|
if (obj && obj.type === 'identifier') {
|
|
1074
1184
|
const objName = obj.text;
|
|
1075
|
-
if (
|
|
1185
|
+
if (
|
|
1186
|
+
objName[0] &&
|
|
1187
|
+
objName[0] !== objName[0].toLowerCase() &&
|
|
1188
|
+
!BUILTIN_GLOBALS.has(objName)
|
|
1189
|
+
) {
|
|
1076
1190
|
setTypeMapEntry(typeMap, nameN.text, objName, 0.7);
|
|
1077
1191
|
}
|
|
1078
1192
|
}
|
|
@@ -1167,6 +1281,181 @@ function extractSubscriptCallInfo(fn: TreeSitterNode, callNode: TreeSitterNode):
|
|
|
1167
1281
|
return null;
|
|
1168
1282
|
}
|
|
1169
1283
|
|
|
1284
|
+
/**
|
|
1285
|
+
* Callee names that idiomatically accept callback references. Used to gate
|
|
1286
|
+
* member_expression args in {@link extractCallbackReferenceCalls}: arguments
|
|
1287
|
+
* like `user.id` are only emitted as dynamic callback calls when the callee
|
|
1288
|
+
* is a known callback-accepting API (router/middleware, promises, array
|
|
1289
|
+
* methods, event emitters, scheduling APIs). This avoids false positives
|
|
1290
|
+
* from plain property reads passed as data, e.g. `store.set(user.id, user)`.
|
|
1291
|
+
*
|
|
1292
|
+
* Identifier args (e.g. `router.use(handleToken)`) are always emitted — the
|
|
1293
|
+
* collateral damage of dropping them is larger than the FP risk, since plain
|
|
1294
|
+
* identifier data args rarely collide with real function names.
|
|
1295
|
+
*/
|
|
1296
|
+
const CALLBACK_ACCEPTING_CALLEES: ReadonlySet<string> = new Set([
|
|
1297
|
+
// Express / router / middleware
|
|
1298
|
+
'use',
|
|
1299
|
+
'get',
|
|
1300
|
+
'post',
|
|
1301
|
+
'put',
|
|
1302
|
+
'delete',
|
|
1303
|
+
'patch',
|
|
1304
|
+
'options',
|
|
1305
|
+
'head',
|
|
1306
|
+
'all',
|
|
1307
|
+
// Promises
|
|
1308
|
+
'then',
|
|
1309
|
+
'catch',
|
|
1310
|
+
'finally',
|
|
1311
|
+
// Array iteration / reduction
|
|
1312
|
+
'map',
|
|
1313
|
+
'filter',
|
|
1314
|
+
'forEach',
|
|
1315
|
+
'find',
|
|
1316
|
+
'findIndex',
|
|
1317
|
+
'findLast',
|
|
1318
|
+
'findLastIndex',
|
|
1319
|
+
'some',
|
|
1320
|
+
'every',
|
|
1321
|
+
'reduce',
|
|
1322
|
+
'reduceRight',
|
|
1323
|
+
'flatMap',
|
|
1324
|
+
'sort',
|
|
1325
|
+
// Event emitters / DOM
|
|
1326
|
+
'on',
|
|
1327
|
+
'once',
|
|
1328
|
+
'off',
|
|
1329
|
+
'addListener',
|
|
1330
|
+
'removeListener',
|
|
1331
|
+
'addEventListener',
|
|
1332
|
+
'removeEventListener',
|
|
1333
|
+
'subscribe',
|
|
1334
|
+
'unsubscribe',
|
|
1335
|
+
// Scheduling / plain function callbacks
|
|
1336
|
+
'setTimeout',
|
|
1337
|
+
'setInterval',
|
|
1338
|
+
'setImmediate',
|
|
1339
|
+
'queueMicrotask',
|
|
1340
|
+
'requestAnimationFrame',
|
|
1341
|
+
'requestIdleCallback',
|
|
1342
|
+
'nextTick',
|
|
1343
|
+
// Commander / yargs / hooks
|
|
1344
|
+
'action',
|
|
1345
|
+
'command',
|
|
1346
|
+
]);
|
|
1347
|
+
|
|
1348
|
+
/**
|
|
1349
|
+
* HTTP-verb callees that double as Map/cache/repository method names (`get`,
|
|
1350
|
+
* `post`, `put`, `delete`, `patch`, `options`, `head`, `all`). Express/router
|
|
1351
|
+
* invocations always take a string-literal route path as the first argument
|
|
1352
|
+
* (`app.get('/path', handler)`), whereas Map-like APIs pass values/keys
|
|
1353
|
+
* (`cache.get(user.id)`). Requiring a string-literal first arg keeps real
|
|
1354
|
+
* route handlers covered while dropping the Map/cache false-positive surface.
|
|
1355
|
+
*
|
|
1356
|
+
* `use` and `all` without a path are legitimate middleware registrations, so
|
|
1357
|
+
* `use` is intentionally excluded here — it stays in the general allowlist.
|
|
1358
|
+
*/
|
|
1359
|
+
const HTTP_VERB_CALLEES: ReadonlySet<string> = new Set([
|
|
1360
|
+
'get',
|
|
1361
|
+
'post',
|
|
1362
|
+
'put',
|
|
1363
|
+
'delete',
|
|
1364
|
+
'patch',
|
|
1365
|
+
'options',
|
|
1366
|
+
'head',
|
|
1367
|
+
'all',
|
|
1368
|
+
]);
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Extract the callee's final name (function identifier or member expression
|
|
1372
|
+
* property) for callback-eligibility filtering. Returns null if the callee
|
|
1373
|
+
* shape is not analyzable (e.g. computed subscripts, IIFEs).
|
|
1374
|
+
*
|
|
1375
|
+
* Optional-chaining (`obj?.method(...)`) is handled transparently: in both
|
|
1376
|
+
* tree-sitter-javascript and tree-sitter-typescript grammars `obj?.method` is
|
|
1377
|
+
* still a `member_expression` (the `?.` appears as an `optional_chain` child),
|
|
1378
|
+
* so the property extraction below returns `method` as expected.
|
|
1379
|
+
*/
|
|
1380
|
+
function extractCalleeName(callNode: TreeSitterNode): string | null {
|
|
1381
|
+
const fn = callNode.childForFieldName('function');
|
|
1382
|
+
if (!fn) return null;
|
|
1383
|
+
if (fn.type === 'identifier') return fn.text;
|
|
1384
|
+
if (fn.type === 'member_expression') {
|
|
1385
|
+
const prop = fn.childForFieldName('property');
|
|
1386
|
+
return prop ? prop.text : null;
|
|
1387
|
+
}
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* True iff the first argument of an arguments node is a string literal.
|
|
1393
|
+
* Used to distinguish Express/router route handlers (`app.get('/path', h)`)
|
|
1394
|
+
* from Map/cache APIs that reuse the same verb names (`cache.get(user.id)`).
|
|
1395
|
+
*/
|
|
1396
|
+
function firstArgIsStringLiteral(argsNode: TreeSitterNode): boolean {
|
|
1397
|
+
for (let i = 0; i < argsNode.childCount; i++) {
|
|
1398
|
+
const child = argsNode.child(i);
|
|
1399
|
+
if (!child) continue;
|
|
1400
|
+
// Skip parens and commas; the first non-punctuation child is the first arg.
|
|
1401
|
+
if (child.type === '(' || child.type === ',' || child.type === ')') continue;
|
|
1402
|
+
return child.type === 'string' || child.type === 'template_string';
|
|
1403
|
+
}
|
|
1404
|
+
return false;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/**
|
|
1408
|
+
* Extract Call entries for named function references passed as arguments.
|
|
1409
|
+
* e.g. `router.use(handleToken, checkAuth)` yields calls to handleToken and checkAuth.
|
|
1410
|
+
* `app.use(auth.validate)` yields a call to validate with receiver auth.
|
|
1411
|
+
* Skips literals, objects, arrays, anonymous functions, and call expressions (already handled).
|
|
1412
|
+
*
|
|
1413
|
+
* To avoid false positives where plain property reads are passed as data
|
|
1414
|
+
* (e.g. `store.set(user.id, user)` — `user.id` is a value, not a callback),
|
|
1415
|
+
* member_expression args are only emitted when the callee is in
|
|
1416
|
+
* {@link CALLBACK_ACCEPTING_CALLEES}. Identifier args are always emitted.
|
|
1417
|
+
*
|
|
1418
|
+
* HTTP-verb callees (`get`, `post`, `put`, `delete`, `patch`, `options`,
|
|
1419
|
+
* `head`, `all`) double as Map/cache/repository method names, so their
|
|
1420
|
+
* member-expr args are only emitted when the first argument is a string
|
|
1421
|
+
* literal route path — matching Express/router shape and skipping
|
|
1422
|
+
* `cache.get(user.id)`-style calls.
|
|
1423
|
+
*/
|
|
1424
|
+
function extractCallbackReferenceCalls(callNode: TreeSitterNode): Call[] {
|
|
1425
|
+
const args = callNode.childForFieldName('arguments') || findChild(callNode, 'arguments');
|
|
1426
|
+
if (!args) return [];
|
|
1427
|
+
|
|
1428
|
+
const calleeName = extractCalleeName(callNode);
|
|
1429
|
+
let memberExprArgsAllowed = calleeName !== null && CALLBACK_ACCEPTING_CALLEES.has(calleeName);
|
|
1430
|
+
if (memberExprArgsAllowed && calleeName !== null && HTTP_VERB_CALLEES.has(calleeName)) {
|
|
1431
|
+
// HTTP verbs require a string-literal route path to be treated as a
|
|
1432
|
+
// callback-accepting API; otherwise `cache.get(user.id)` etc. would
|
|
1433
|
+
// still emit `id` as a dynamic call.
|
|
1434
|
+
memberExprArgsAllowed = firstArgIsStringLiteral(args);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const result: Call[] = [];
|
|
1438
|
+
const callLine = callNode.startPosition.row + 1;
|
|
1439
|
+
|
|
1440
|
+
for (let i = 0; i < args.childCount; i++) {
|
|
1441
|
+
const child = args.child(i);
|
|
1442
|
+
if (!child) continue;
|
|
1443
|
+
|
|
1444
|
+
if (child.type === 'identifier') {
|
|
1445
|
+
result.push({ name: child.text, line: callLine, dynamic: true });
|
|
1446
|
+
} else if (child.type === 'member_expression' && memberExprArgsAllowed) {
|
|
1447
|
+
const prop = child.childForFieldName('property');
|
|
1448
|
+
const obj = child.childForFieldName('object');
|
|
1449
|
+
if (prop) {
|
|
1450
|
+
const receiver = extractReceiverName(obj);
|
|
1451
|
+
result.push({ name: prop.text, line: callLine, dynamic: true, receiver });
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
return result;
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1170
1459
|
function findAnonymousCallback(argsNode: TreeSitterNode): TreeSitterNode | null {
|
|
1171
1460
|
for (let i = 0; i < argsNode.childCount; i++) {
|
|
1172
1461
|
const child = argsNode.child(i);
|
|
@@ -1,34 +1,9 @@
|
|
|
1
1
|
import { isTestFile } from '../infrastructure/test-filter.js';
|
|
2
2
|
import { BoundaryError } from '../shared/errors.js';
|
|
3
|
+
import { globToRegex } from '../shared/globs.js';
|
|
3
4
|
import type { BetterSqlite3Database } from '../types.js';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export function globToRegex(pattern: string): RegExp {
|
|
8
|
-
let re = '';
|
|
9
|
-
let i = 0;
|
|
10
|
-
while (i < pattern.length) {
|
|
11
|
-
const ch = pattern[i] as string;
|
|
12
|
-
if (ch === '*' && pattern[i + 1] === '*') {
|
|
13
|
-
re += '.*';
|
|
14
|
-
i += 2;
|
|
15
|
-
if (pattern[i] === '/') i++;
|
|
16
|
-
} else if (ch === '*') {
|
|
17
|
-
re += '[^/]*';
|
|
18
|
-
i++;
|
|
19
|
-
} else if (ch === '?') {
|
|
20
|
-
re += '[^/]';
|
|
21
|
-
i++;
|
|
22
|
-
} else if (/[.+^${}()|[\]\\]/.test(ch)) {
|
|
23
|
-
re += `\\${ch}`;
|
|
24
|
-
i++;
|
|
25
|
-
} else {
|
|
26
|
-
re += ch;
|
|
27
|
-
i++;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return new RegExp(`^${re}$`);
|
|
31
|
-
}
|
|
6
|
+
export { globToRegex };
|
|
32
7
|
|
|
33
8
|
// ─── Presets ─────────────────────────────────────────────────────────
|
|
34
9
|
|
package/src/features/snapshot.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
1
2
|
import fs from 'node:fs';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { getDatabase } from '../db/better-sqlite3.js';
|
|
@@ -37,24 +38,77 @@ export function snapshotSave(
|
|
|
37
38
|
const dir = snapshotsDir(dbPath);
|
|
38
39
|
const dest = path.join(dir, `${name}.db`);
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
fs.unlinkSync(dest);
|
|
45
|
-
debug(`Deleted existing snapshot: ${dest}`);
|
|
41
|
+
// Cheap fail-fast for the common non-force case; the authoritative check
|
|
42
|
+
// below uses an atomic linkSync that closes the TOCTOU window.
|
|
43
|
+
if (!options.force && fs.existsSync(dest)) {
|
|
44
|
+
throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
47
|
fs.mkdirSync(dir, { recursive: true });
|
|
49
48
|
|
|
49
|
+
// VACUUM INTO a unique temp path on the same filesystem, then atomically
|
|
50
|
+
// place it at the destination. This closes the TOCTOU window between
|
|
51
|
+
// existsSync/unlinkSync/VACUUM INTO where two concurrent saves could
|
|
52
|
+
// observe a missing file or interleave their VACUUM writes.
|
|
53
|
+
//
|
|
54
|
+
// Unique temp name: process.pid is shared across worker_threads in the
|
|
55
|
+
// same process, so we add random bytes to keep concurrent callers in any
|
|
56
|
+
// thread from colliding on the temp path.
|
|
57
|
+
const tmp = path.join(
|
|
58
|
+
dir,
|
|
59
|
+
`.${name}.db.tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`,
|
|
60
|
+
);
|
|
61
|
+
try {
|
|
62
|
+
fs.unlinkSync(tmp);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
const Database = getDatabase();
|
|
51
68
|
const db = new Database(dbPath, { readonly: true });
|
|
52
69
|
try {
|
|
53
|
-
db.exec(`VACUUM INTO '${
|
|
70
|
+
db.exec(`VACUUM INTO '${tmp.replace(/'/g, "''")}'`);
|
|
54
71
|
} finally {
|
|
55
72
|
db.close();
|
|
56
73
|
}
|
|
57
74
|
|
|
75
|
+
try {
|
|
76
|
+
if (options.force) {
|
|
77
|
+
// renameSync overwrites atomically — the correct semantics for --force.
|
|
78
|
+
fs.renameSync(tmp, dest);
|
|
79
|
+
} else {
|
|
80
|
+
// Non-force path: linkSync fails atomically with EEXIST if dest exists,
|
|
81
|
+
// closing the TOCTOU window between existsSync above and the final
|
|
82
|
+
// placement. We then unlink the temp file; on POSIX and NTFS, link
|
|
83
|
+
// creates a second reference so tmp can safely be removed.
|
|
84
|
+
try {
|
|
85
|
+
fs.linkSync(tmp, dest);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
|
|
88
|
+
throw new ConfigError(`Snapshot "${name}" already exists. Use --force to overwrite.`);
|
|
89
|
+
}
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(tmp);
|
|
94
|
+
} catch (cleanupErr) {
|
|
95
|
+
// Best-effort — dest is already in place, so a leftover tmp file is
|
|
96
|
+
// harmless. Log at debug so repeated failures surface during
|
|
97
|
+
// troubleshooting without noising up normal operation.
|
|
98
|
+
debug(`snapshotSave: failed to remove temp file ${tmp}: ${cleanupErr}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (err) {
|
|
102
|
+
try {
|
|
103
|
+
fs.unlinkSync(tmp);
|
|
104
|
+
} catch (cleanupErr) {
|
|
105
|
+
if ((cleanupErr as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
106
|
+
debug(`snapshotSave: failed to remove temp file ${tmp}: ${cleanupErr}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
|
|
58
112
|
const stat = fs.statSync(dest);
|
|
59
113
|
debug(`Snapshot saved: ${dest} (${stat.size} bytes)`);
|
|
60
114
|
return { name, path: dest, size: stat.size };
|
|
@@ -74,16 +128,38 @@ export function snapshotRestore(name: string, options: SnapshotDbPathOptions = {
|
|
|
74
128
|
throw new DbError(`Snapshot "${name}" not found at ${src}`, { file: src });
|
|
75
129
|
}
|
|
76
130
|
|
|
77
|
-
// Remove WAL/SHM
|
|
131
|
+
// Remove WAL/SHM sidecars first so the old journal can't be replayed over
|
|
132
|
+
// the restored DB. unlink then check ENOENT — avoids the existsSync/unlinkSync
|
|
133
|
+
// race another process could wedge into.
|
|
78
134
|
for (const suffix of ['-wal', '-shm']) {
|
|
79
135
|
const sidecar = dbPath + suffix;
|
|
80
|
-
|
|
136
|
+
try {
|
|
81
137
|
fs.unlinkSync(sidecar);
|
|
82
138
|
debug(`Removed sidecar: ${sidecar}`);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Copy to a temp path next to the DB, then rename atomically. Readers that
|
|
145
|
+
// open dbPath during restore see either the pre-restore or post-restore
|
|
146
|
+
// file, never a partially-written one.
|
|
147
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
148
|
+
const tmp = `${dbPath}.restore-tmp-${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}`;
|
|
149
|
+
try {
|
|
150
|
+
fs.copyFileSync(src, tmp);
|
|
151
|
+
fs.renameSync(tmp, dbPath);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
try {
|
|
154
|
+
fs.unlinkSync(tmp);
|
|
155
|
+
} catch (cleanupErr) {
|
|
156
|
+
if ((cleanupErr as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
157
|
+
debug(`snapshotRestore: failed to remove temp file ${tmp}: ${cleanupErr}`);
|
|
158
|
+
}
|
|
83
159
|
}
|
|
160
|
+
throw err;
|
|
84
161
|
}
|
|
85
162
|
|
|
86
|
-
fs.copyFileSync(src, dbPath);
|
|
87
163
|
debug(`Restored snapshot "${name}" → ${dbPath}`);
|
|
88
164
|
}
|
|
89
165
|
|
|
@@ -122,10 +198,13 @@ export function snapshotDelete(name: string, options: SnapshotDbPathOptions = {}
|
|
|
122
198
|
const dir = snapshotsDir(dbPath);
|
|
123
199
|
const target = path.join(dir, `${name}.db`);
|
|
124
200
|
|
|
125
|
-
|
|
126
|
-
|
|
201
|
+
try {
|
|
202
|
+
fs.unlinkSync(target);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
205
|
+
throw new DbError(`Snapshot "${name}" not found at ${target}`, { file: target });
|
|
206
|
+
}
|
|
207
|
+
throw err;
|
|
127
208
|
}
|
|
128
|
-
|
|
129
|
-
fs.unlinkSync(target);
|
|
130
209
|
debug(`Deleted snapshot: ${target}`);
|
|
131
210
|
}
|
|
@@ -166,6 +166,22 @@ function computeFileMetrics(
|
|
|
166
166
|
fanOutMap: Map<string, number>,
|
|
167
167
|
): void {
|
|
168
168
|
db.transaction(() => {
|
|
169
|
+
// Batch-load import counts per file (distinct imported files,
|
|
170
|
+
// matching the fast-path semantics in updateChangedFileMetrics).
|
|
171
|
+
// Runs inside the transaction for parity with the Rust path.
|
|
172
|
+
const importCountMap = new Map<string, number>();
|
|
173
|
+
for (const row of db
|
|
174
|
+
.prepare(
|
|
175
|
+
`SELECT n1.file AS src, COUNT(DISTINCT n2.file) AS cnt FROM edges e
|
|
176
|
+
JOIN nodes n1 ON e.source_id = n1.id
|
|
177
|
+
JOIN nodes n2 ON e.target_id = n2.id
|
|
178
|
+
WHERE e.kind = 'imports'
|
|
179
|
+
GROUP BY n1.file`,
|
|
180
|
+
)
|
|
181
|
+
.all() as { src: string; cnt: number }[]) {
|
|
182
|
+
importCountMap.set(row.src, row.cnt);
|
|
183
|
+
}
|
|
184
|
+
|
|
169
185
|
for (const [relPath, symbols] of fileSymbols) {
|
|
170
186
|
const fileRow = getNodeIdStmt.get(relPath, 'file', relPath, 0);
|
|
171
187
|
if (!fileRow) continue;
|
|
@@ -180,7 +196,7 @@ function computeFileMetrics(
|
|
|
180
196
|
symbolCount++;
|
|
181
197
|
}
|
|
182
198
|
}
|
|
183
|
-
const importCount =
|
|
199
|
+
const importCount = importCountMap.get(relPath) || 0;
|
|
184
200
|
const exportCount = symbols.exports.length;
|
|
185
201
|
const fanIn = fanInMap.get(relPath) || 0;
|
|
186
202
|
const fanOut = fanOutMap.get(relPath) || 0;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* JS fallback: Leiden algorithm via `detectClusters` (always undirected, `directed: false`).
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
9
|
+
import { debug } from '../../infrastructure/logger.js';
|
|
10
10
|
import { loadNative } from '../../infrastructure/native.js';
|
|
11
11
|
import type { CodeGraph } from '../model.js';
|
|
12
12
|
import type { DetectClustersResult } from './leiden/index.js';
|
|
@@ -36,10 +36,8 @@ export function louvainCommunities(graph: CodeGraph, opts: LouvainOptions = {}):
|
|
|
36
36
|
|
|
37
37
|
const native = loadNative();
|
|
38
38
|
if (native?.louvainCommunities) {
|
|
39
|
-
// maxLevels, maxLocalPasses, and refinementTheta are Leiden-specific tuning knobs
|
|
40
|
-
// not supported by the Rust Louvain implementation. Warn callers who set them.
|
|
41
39
|
if (opts.maxLevels != null || opts.maxLocalPasses != null || opts.refinementTheta != null) {
|
|
42
|
-
|
|
40
|
+
debug(
|
|
43
41
|
'louvainCommunities: maxLevels/maxLocalPasses/refinementTheta are ignored by the native Rust path',
|
|
44
42
|
);
|
|
45
43
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { toErrorMessage } from '../shared/errors.js';
|
|
4
|
+
import { ConfigError, toErrorMessage } from '../shared/errors.js';
|
|
5
5
|
import type { CodegraphConfig } from '../types.js';
|
|
6
6
|
import { debug, warn } from './logger.js';
|
|
7
7
|
|
|
@@ -179,6 +179,7 @@ export function loadConfig(cwd?: string): CodegraphConfig {
|
|
|
179
179
|
_configCache.set(cwd, structuredClone(result));
|
|
180
180
|
return result;
|
|
181
181
|
} catch (err: unknown) {
|
|
182
|
+
if (err instanceof ConfigError) throw err;
|
|
182
183
|
debug(`Failed to parse config ${filePath}: ${toErrorMessage(err)}`);
|
|
183
184
|
}
|
|
184
185
|
}
|
|
@@ -215,7 +216,16 @@ export function applyEnvOverrides(config: CodegraphConfig): CodegraphConfig {
|
|
|
215
216
|
|
|
216
217
|
export function resolveSecrets(config: CodegraphConfig): CodegraphConfig {
|
|
217
218
|
const cmd = config.llm.apiKeyCommand;
|
|
218
|
-
if (
|
|
219
|
+
if (cmd == null) return config;
|
|
220
|
+
if (typeof cmd !== 'string') {
|
|
221
|
+
const actual = Array.isArray(cmd) ? 'array' : typeof cmd;
|
|
222
|
+
throw new ConfigError(
|
|
223
|
+
`llm.apiKeyCommand must be a string (received ${actual}). ` +
|
|
224
|
+
'The command is split on whitespace and executed without a shell. ' +
|
|
225
|
+
'Example: "apiKeyCommand": "op read op://vault/openai/api-key"',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (cmd.trim() === '') return config;
|
|
219
229
|
|
|
220
230
|
const parts = cmd.trim().split(/\s+/);
|
|
221
231
|
const [executable, ...args] = parts;
|