@optave/codegraph 3.0.3 → 3.0.4

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 CHANGED
@@ -555,14 +555,14 @@ Self-measured on every release via CI ([build benchmarks](generated/benchmarks/B
555
555
 
556
556
  | Metric | Latest |
557
557
  |---|---|
558
- | Build speed (native) | **11.5 ms/file** |
559
- | Build speed (WASM) | **17.8 ms/file** |
558
+ | Build speed (native) | **12.3 ms/file** |
559
+ | Build speed (WASM) | **16.3 ms/file** |
560
560
  | Query time | **3ms** |
561
561
  | No-op rebuild (native) | **5ms** |
562
- | 1-file rebuild (native) | **384ms** |
562
+ | 1-file rebuild (native) | **375ms** |
563
563
  | Query: fn-deps | **0.8ms** |
564
564
  | Query: path | **0.8ms** |
565
- | ~50,000 files (est.) | **~575.0s build** |
565
+ | ~50,000 files (est.) | **~615.0s build** |
566
566
 
567
567
  Metrics are normalized per file for cross-version comparability. Times above are for a full initial build — incremental rebuilds only re-parse changed files.
568
568
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optave/codegraph",
3
- "version": "3.0.3",
3
+ "version": "3.0.4",
4
4
  "description": "Local code graph CLI — parse codebases with tree-sitter, build dependency graphs, query them",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -71,10 +71,13 @@
71
71
  },
72
72
  "optionalDependencies": {
73
73
  "@modelcontextprotocol/sdk": "^1.0.0",
74
- "@optave/codegraph-darwin-arm64": "3.0.3",
75
- "@optave/codegraph-darwin-x64": "3.0.3",
76
- "@optave/codegraph-linux-x64-gnu": "3.0.3",
77
- "@optave/codegraph-win32-x64-msvc": "3.0.3"
74
+ "@optave/codegraph-darwin-arm64": "3.0.4",
75
+ "@optave/codegraph-darwin-x64": "3.0.4",
76
+ "@optave/codegraph-linux-arm64-gnu": "3.0.4",
77
+ "@optave/codegraph-linux-arm64-musl": "3.0.4",
78
+ "@optave/codegraph-linux-x64-gnu": "3.0.4",
79
+ "@optave/codegraph-linux-x64-musl": "3.0.4",
80
+ "@optave/codegraph-win32-x64-msvc": "3.0.4"
78
81
  },
79
82
  "devDependencies": {
80
83
  "@biomejs/biome": "^2.4.4",
package/src/ast.js CHANGED
@@ -197,33 +197,33 @@ export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
197
197
  }
198
198
  }
199
199
 
200
- // 2. AST walk for JS/TS/TSX — extract new, throw, await, string, regex
201
- const ext = path.extname(relPath).toLowerCase();
202
- if (WALK_EXTENSIONS.has(ext)) {
203
- if (symbols._tree) {
204
- // WASM path: walk the tree-sitter AST
200
+ // 2. Non-call AST nodes (new, throw, await, string, regex)
201
+ if (symbols.astNodes?.length) {
202
+ // Native path: use pre-extracted AST nodes from Rust (all languages)
203
+ for (const n of symbols.astNodes) {
204
+ const parentDef = findParentDef(defs, n.line);
205
+ let parentNodeId = null;
206
+ if (parentDef) {
207
+ parentNodeId =
208
+ nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
209
+ }
210
+ allRows.push({
211
+ file: relPath,
212
+ line: n.line,
213
+ kind: n.kind,
214
+ name: n.name,
215
+ text: n.text || null,
216
+ receiver: n.receiver || null,
217
+ parentNodeId,
218
+ });
219
+ }
220
+ } else {
221
+ // WASM fallback: walk the tree-sitter AST (JS/TS/TSX only)
222
+ const ext = path.extname(relPath).toLowerCase();
223
+ if (WALK_EXTENSIONS.has(ext) && symbols._tree) {
205
224
  const astRows = [];
206
225
  walkAst(symbols._tree.rootNode, defs, relPath, astRows, nodeIdMap);
207
226
  allRows.push(...astRows);
208
- } else if (symbols.astNodes?.length) {
209
- // Native path: use pre-extracted AST nodes from Rust
210
- for (const n of symbols.astNodes) {
211
- const parentDef = findParentDef(defs, n.line);
212
- let parentNodeId = null;
213
- if (parentDef) {
214
- parentNodeId =
215
- nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
216
- }
217
- allRows.push({
218
- file: relPath,
219
- line: n.line,
220
- kind: n.kind,
221
- name: n.name,
222
- text: n.text || null,
223
- receiver: n.receiver || null,
224
- parentNodeId,
225
- });
226
- }
227
227
  }
228
228
  }
229
229
  }
package/src/builder.js CHANGED
@@ -444,7 +444,7 @@ export async function buildGraph(rootDir, opts = {}) {
444
444
  opts.incremental !== false && config.build && config.build.incremental !== false;
445
445
 
446
446
  // Engine selection: 'native', 'wasm', or 'auto' (default)
447
- const engineOpts = { engine: opts.engine || 'auto' };
447
+ const engineOpts = { engine: opts.engine || 'auto', dataflow: opts.dataflow !== false };
448
448
  const { name: engineName, version: engineVersion } = getActiveEngine(engineOpts);
449
449
  info(`Using ${engineName} engine${engineVersion ? ` (v${engineVersion})` : ''}`);
450
450
 
@@ -548,7 +548,11 @@ export async function buildGraph(rootDir, opts = {}) {
548
548
 
549
549
  if (needsCfg || needsDataflow) {
550
550
  info('No file changes. Running pending analysis pass...');
551
- const analysisSymbols = await parseFilesAuto(files, rootDir, engineOpts);
551
+ const analysisOpts = {
552
+ ...engineOpts,
553
+ dataflow: needsDataflow && opts.dataflow !== false,
554
+ };
555
+ const analysisSymbols = await parseFilesAuto(files, rootDir, analysisOpts);
552
556
  if (needsCfg) {
553
557
  const { buildCFGData } = await import('./cfg.js');
554
558
  await buildCFGData(db, analysisSymbols, rootDir, engineOpts);
@@ -1317,16 +1321,47 @@ export async function buildGraph(rootDir, opts = {}) {
1317
1321
  _t.complexityMs = performance.now() - _t.complexity0;
1318
1322
 
1319
1323
  // Pre-parse files missing WASM trees (native builds) so CFG + dataflow
1320
- // share a single parse pass instead of each creating parsers independently
1324
+ // share a single parse pass instead of each creating parsers independently.
1325
+ // Skip entirely when native engine already provides CFG + dataflow data.
1321
1326
  if (opts.cfg !== false || opts.dataflow !== false) {
1322
- _t.wasmPre0 = performance.now();
1323
- try {
1324
- const { ensureWasmTrees } = await import('./parser.js');
1325
- await ensureWasmTrees(astComplexitySymbols, rootDir);
1326
- } catch (err) {
1327
- debug(`WASM pre-parse failed: ${err.message}`);
1327
+ const needsCfg = opts.cfg !== false;
1328
+ const needsDataflow = opts.dataflow !== false;
1329
+
1330
+ let needsWasmTrees = false;
1331
+ for (const [, symbols] of astComplexitySymbols) {
1332
+ if (symbols._tree) continue; // already has a tree
1333
+ // CFG: need tree if any function/method def lacks native CFG
1334
+ if (needsCfg) {
1335
+ const fnDefs = (symbols.definitions || []).filter(
1336
+ (d) => (d.kind === 'function' || d.kind === 'method') && d.line,
1337
+ );
1338
+ if (
1339
+ fnDefs.length > 0 &&
1340
+ !fnDefs.every((d) => d.cfg === null || Array.isArray(d.cfg?.blocks))
1341
+ ) {
1342
+ needsWasmTrees = true;
1343
+ break;
1344
+ }
1345
+ }
1346
+ // Dataflow: need tree if file lacks native dataflow
1347
+ if (needsDataflow && !symbols.dataflow) {
1348
+ needsWasmTrees = true;
1349
+ break;
1350
+ }
1351
+ }
1352
+
1353
+ if (needsWasmTrees) {
1354
+ _t.wasmPre0 = performance.now();
1355
+ try {
1356
+ const { ensureWasmTrees } = await import('./parser.js');
1357
+ await ensureWasmTrees(astComplexitySymbols, rootDir);
1358
+ } catch (err) {
1359
+ debug(`WASM pre-parse failed: ${err.message}`);
1360
+ }
1361
+ _t.wasmPreMs = performance.now() - _t.wasmPre0;
1362
+ } else {
1363
+ _t.wasmPreMs = 0;
1328
1364
  }
1329
- _t.wasmPreMs = performance.now() - _t.wasmPre0;
1330
1365
  }
1331
1366
 
1332
1367
  // CFG analysis (skip with --no-cfg)
package/src/cfg.js CHANGED
@@ -1053,8 +1053,14 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1053
1053
  if (!symbols._tree) {
1054
1054
  const ext = path.extname(relPath).toLowerCase();
1055
1055
  if (CFG_EXTENSIONS.has(ext)) {
1056
- needsFallback = true;
1057
- break;
1056
+ // Check if all function/method defs already have native CFG data
1057
+ const hasNativeCfg = symbols.definitions
1058
+ .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line)
1059
+ .every((d) => d.cfg === null || d.cfg?.blocks?.length);
1060
+ if (!hasNativeCfg) {
1061
+ needsFallback = true;
1062
+ break;
1063
+ }
1058
1064
  }
1059
1065
  }
1060
1066
  }
@@ -1102,8 +1108,13 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1102
1108
  let tree = symbols._tree;
1103
1109
  let langId = symbols._langId;
1104
1110
 
1105
- // WASM fallback if no cached tree
1106
- if (!tree) {
1111
+ // Check if all defs already have native CFG — skip WASM parse if so
1112
+ const allNative = symbols.definitions
1113
+ .filter((d) => (d.kind === 'function' || d.kind === 'method') && d.line)
1114
+ .every((d) => d.cfg === null || d.cfg?.blocks?.length);
1115
+
1116
+ // WASM fallback if no cached tree and not all native
1117
+ if (!tree && !allNative) {
1107
1118
  if (!extToLang || !getParserFn) continue;
1108
1119
  langId = extToLang.get(ext);
1109
1120
  if (!langId || !CFG_LANG_IDS.has(langId)) continue;
@@ -1135,7 +1146,7 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1135
1146
  if (!cfgRules) continue;
1136
1147
 
1137
1148
  const complexityRules = COMPLEXITY_RULES.get(langId);
1138
- if (!complexityRules) continue;
1149
+ // complexityRules only needed for WASM fallback path
1139
1150
 
1140
1151
  for (const def of symbols.definitions) {
1141
1152
  if (def.kind !== 'function' && def.kind !== 'method') continue;
@@ -1144,11 +1155,19 @@ export async function buildCFGData(db, fileSymbols, rootDir, _engineOpts) {
1144
1155
  const row = getNodeId.get(def.name, relPath, def.line);
1145
1156
  if (!row) continue;
1146
1157
 
1147
- const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules);
1148
- if (!funcNode) continue;
1158
+ // Native path: use pre-computed CFG from Rust engine
1159
+ let cfg = null;
1160
+ if (def.cfg?.blocks?.length) {
1161
+ cfg = def.cfg;
1162
+ } else {
1163
+ // WASM fallback: compute CFG from tree-sitter AST
1164
+ if (!tree || !complexityRules) continue;
1165
+ const funcNode = findFunctionNode(tree.rootNode, def.line, def.endLine, complexityRules);
1166
+ if (!funcNode) continue;
1167
+ cfg = buildFunctionCFG(funcNode, langId);
1168
+ }
1149
1169
 
1150
- const cfg = buildFunctionCFG(funcNode, langId);
1151
- if (cfg.blocks.length === 0) continue;
1170
+ if (!cfg || cfg.blocks.length === 0) continue;
1152
1171
 
1153
1172
  // Clear old CFG data for this function
1154
1173
  deleteEdges.run(row.id);
package/src/dataflow.js CHANGED
@@ -1009,7 +1009,7 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts)
1009
1009
  let needsFallback = false;
1010
1010
 
1011
1011
  for (const [relPath, symbols] of fileSymbols) {
1012
- if (!symbols._tree) {
1012
+ if (!symbols._tree && !symbols.dataflow) {
1013
1013
  const ext = path.extname(relPath).toLowerCase();
1014
1014
  if (DATAFLOW_EXTENSIONS.has(ext)) {
1015
1015
  needsFallback = true;
@@ -1061,41 +1061,45 @@ export async function buildDataflowEdges(db, fileSymbols, rootDir, _engineOpts)
1061
1061
  const ext = path.extname(relPath).toLowerCase();
1062
1062
  if (!DATAFLOW_EXTENSIONS.has(ext)) continue;
1063
1063
 
1064
- let tree = symbols._tree;
1065
- let langId = symbols._langId;
1064
+ // Use native dataflow data if available — skip WASM extraction
1065
+ let data = symbols.dataflow;
1066
+ if (!data) {
1067
+ let tree = symbols._tree;
1068
+ let langId = symbols._langId;
1069
+
1070
+ // WASM fallback if no cached tree
1071
+ if (!tree) {
1072
+ if (!extToLang || !getParserFn) continue;
1073
+ langId = extToLang.get(ext);
1074
+ if (!langId || !DATAFLOW_LANG_IDS.has(langId)) continue;
1075
+
1076
+ const absPath = path.join(rootDir, relPath);
1077
+ let code;
1078
+ try {
1079
+ code = fs.readFileSync(absPath, 'utf-8');
1080
+ } catch {
1081
+ continue;
1082
+ }
1066
1083
 
1067
- // WASM fallback if no cached tree
1068
- if (!tree) {
1069
- if (!extToLang || !getParserFn) continue;
1070
- langId = extToLang.get(ext);
1071
- if (!langId || !DATAFLOW_LANG_IDS.has(langId)) continue;
1084
+ const parser = getParserFn(parsers, absPath);
1085
+ if (!parser) continue;
1072
1086
 
1073
- const absPath = path.join(rootDir, relPath);
1074
- let code;
1075
- try {
1076
- code = fs.readFileSync(absPath, 'utf-8');
1077
- } catch {
1078
- continue;
1087
+ try {
1088
+ tree = parser.parse(code);
1089
+ } catch {
1090
+ continue;
1091
+ }
1079
1092
  }
1080
1093
 
1081
- const parser = getParserFn(parsers, absPath);
1082
- if (!parser) continue;
1083
-
1084
- try {
1085
- tree = parser.parse(code);
1086
- } catch {
1087
- continue;
1094
+ if (!langId) {
1095
+ langId = extToLang ? extToLang.get(ext) : null;
1096
+ if (!langId) continue;
1088
1097
  }
1089
- }
1090
-
1091
- if (!langId) {
1092
- langId = extToLang ? extToLang.get(ext) : null;
1093
- if (!langId) continue;
1094
- }
1095
1098
 
1096
- if (!DATAFLOW_RULES.has(langId)) continue;
1099
+ if (!DATAFLOW_RULES.has(langId)) continue;
1097
1100
 
1098
- const data = extractDataflow(tree, relPath, symbols.definitions, langId);
1101
+ data = extractDataflow(tree, relPath, symbols.definitions, langId);
1102
+ }
1099
1103
 
1100
1104
  // Resolve function names to node IDs in this file first, then globally
1101
1105
  function resolveNode(funcName) {
package/src/native.js CHANGED
@@ -12,9 +12,27 @@ import os from 'node:os';
12
12
  let _cached; // undefined = not yet tried, null = failed, object = module
13
13
  let _loadError = null;
14
14
 
15
- /** Map of (platform-arch) → npm package name. */
15
+ /**
16
+ * Detect whether the current Linux environment uses glibc or musl.
17
+ * Returns 'gnu' for glibc, 'musl' for musl, 'gnu' as fallback.
18
+ */
19
+ function detectLibc() {
20
+ try {
21
+ const { readdirSync } = require('node:fs');
22
+ const files = readdirSync('/lib');
23
+ if (files.some((f) => f.startsWith('ld-musl-') && f.endsWith('.so.1'))) {
24
+ return 'musl';
25
+ }
26
+ } catch {}
27
+ return 'gnu';
28
+ }
29
+
30
+ /** Map of (platform-arch[-libc]) → npm package name. */
16
31
  const PLATFORM_PACKAGES = {
17
- 'linux-x64': '@optave/codegraph-linux-x64-gnu',
32
+ 'linux-x64-gnu': '@optave/codegraph-linux-x64-gnu',
33
+ 'linux-x64-musl': '@optave/codegraph-linux-x64-musl',
34
+ 'linux-arm64-gnu': '@optave/codegraph-linux-arm64-gnu',
35
+ 'linux-arm64-musl': '@optave/codegraph-linux-arm64-musl', // not yet published — placeholder for future CI target
18
36
  'darwin-arm64': '@optave/codegraph-darwin-arm64',
19
37
  'darwin-x64': '@optave/codegraph-darwin-x64',
20
38
  'win32-x64': '@optave/codegraph-win32-x64-msvc',
@@ -29,7 +47,9 @@ export function loadNative() {
29
47
 
30
48
  const require = createRequire(import.meta.url);
31
49
 
32
- const key = `${os.platform()}-${os.arch()}`;
50
+ const platform = os.platform();
51
+ const arch = os.arch();
52
+ const key = platform === 'linux' ? `${platform}-${arch}-${detectLibc()}` : `${platform}-${arch}`;
33
53
  const pkg = PLATFORM_PACKAGES[key];
34
54
  if (pkg) {
35
55
  try {
package/src/parser.js CHANGED
@@ -205,6 +205,22 @@ function normalizeNativeSymbols(result) {
205
205
  maintainabilityIndex: d.complexity.maintainabilityIndex ?? null,
206
206
  }
207
207
  : null,
208
+ cfg: d.cfg?.blocks?.length
209
+ ? {
210
+ blocks: d.cfg.blocks.map((b) => ({
211
+ index: b.index,
212
+ type: b.type,
213
+ startLine: b.startLine,
214
+ endLine: b.endLine,
215
+ label: b.label ?? null,
216
+ })),
217
+ edges: d.cfg.edges.map((e) => ({
218
+ sourceIndex: e.sourceIndex,
219
+ targetIndex: e.targetIndex,
220
+ kind: e.kind,
221
+ })),
222
+ }
223
+ : null,
208
224
  children: d.children?.length
209
225
  ? d.children.map((c) => ({
210
226
  name: c.name,
@@ -253,6 +269,46 @@ function normalizeNativeSymbols(result) {
253
269
  text: n.text ?? null,
254
270
  receiver: n.receiver ?? null,
255
271
  })),
272
+ dataflow: result.dataflow
273
+ ? {
274
+ parameters: (result.dataflow.parameters || []).map((p) => ({
275
+ funcName: p.funcName,
276
+ paramName: p.paramName,
277
+ paramIndex: p.paramIndex,
278
+ line: p.line,
279
+ })),
280
+ returns: (result.dataflow.returns || []).map((r) => ({
281
+ funcName: r.funcName,
282
+ expression: r.expression ?? '',
283
+ referencedNames: r.referencedNames ?? [],
284
+ line: r.line,
285
+ })),
286
+ assignments: (result.dataflow.assignments || []).map((a) => ({
287
+ varName: a.varName,
288
+ callerFunc: a.callerFunc ?? null,
289
+ sourceCallName: a.sourceCallName,
290
+ expression: a.expression ?? '',
291
+ line: a.line,
292
+ })),
293
+ argFlows: (result.dataflow.argFlows ?? []).map((f) => ({
294
+ callerFunc: f.callerFunc ?? null,
295
+ calleeName: f.calleeName,
296
+ argIndex: f.argIndex,
297
+ argName: f.argName ?? null,
298
+ binding: f.bindingType ? { type: f.bindingType } : null,
299
+ confidence: f.confidence,
300
+ expression: f.expression ?? '',
301
+ line: f.line,
302
+ })),
303
+ mutations: (result.dataflow.mutations || []).map((m) => ({
304
+ funcName: m.funcName ?? null,
305
+ receiverName: m.receiverName,
306
+ binding: m.bindingType ? { type: m.bindingType } : null,
307
+ mutatingExpr: m.mutatingExpr,
308
+ line: m.line,
309
+ })),
310
+ }
311
+ : null,
256
312
  };
257
313
  }
258
314
 
@@ -384,7 +440,7 @@ export async function parseFileAuto(filePath, source, opts = {}) {
384
440
  const { native } = resolveEngine(opts);
385
441
 
386
442
  if (native) {
387
- const result = native.parseFile(filePath, source);
443
+ const result = native.parseFile(filePath, source, !!opts.dataflow);
388
444
  return result ? normalizeNativeSymbols(result) : null;
389
445
  }
390
446
 
@@ -407,7 +463,7 @@ export async function parseFilesAuto(filePaths, rootDir, opts = {}) {
407
463
  const result = new Map();
408
464
 
409
465
  if (native) {
410
- const nativeResults = native.parseFiles(filePaths, rootDir);
466
+ const nativeResults = native.parseFiles(filePaths, rootDir, !!opts.dataflow);
411
467
  for (const r of nativeResults) {
412
468
  if (!r) continue;
413
469
  const relPath = path.relative(rootDir, r.file).split(path.sep).join('/');