@optave/codegraph 3.0.0 β 3.0.1
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 +9 -9
- package/package.json +5 -5
- package/src/ast.js +40 -14
- package/src/builder.js +101 -52
- package/src/cfg.js +2 -1
- package/src/cli.js +6 -2
- package/src/dataflow.js +6 -3
- package/src/extractors/javascript.js +51 -0
- package/src/flow.js +5 -2
- package/src/index.js +1 -1
- package/src/mcp.js +2 -2
- package/src/parser.js +16 -0
- package/src/structure.js +64 -11
package/README.md
CHANGED
|
@@ -208,8 +208,8 @@ Full agent setup: [AI Agent Guide](docs/guides/ai-agent-guide.md) · [CLAU
|
|
|
208
208
|
| π | **Composite audit** | Single `audit` command combining explain + impact + health metrics per function β one call instead of 3-4 |
|
|
209
209
|
| π¦ | **Triage queue** | `triage` merges connectivity, hotspots, roles, and complexity into a ranked audit priority queue |
|
|
210
210
|
| π¦ | **Batch querying** | Accept a list of targets and return all results in one JSON payload β enables multi-agent parallel dispatch |
|
|
211
|
-
| π¬ | **Dataflow analysis** | Track how data moves through functions with `flows_to`, `returns`, and `mutates` edges β
|
|
212
|
-
| π§© | **Control flow graph** | Intraprocedural CFG construction for all 11 languages β `cfg` command with text/DOT/Mermaid output,
|
|
211
|
+
| π¬ | **Dataflow analysis** | Track how data moves through functions with `flows_to`, `returns`, and `mutates` edges β included by default (JS/TS), skip with `--no-dataflow` |
|
|
212
|
+
| π§© | **Control flow graph** | Intraprocedural CFG construction for all 11 languages β `cfg` command with text/DOT/Mermaid output, included by default, skip with `--no-cfg` |
|
|
213
213
|
| π | **AST node querying** | Stored queryable AST nodes (calls, `new`, string, regex, throw, await) β `ast` command with SQL GLOB pattern matching |
|
|
214
214
|
| 𧬠| **Expanded node/edge types** | `parameter`, `property`, `constant` node kinds with `parent_id` for sub-declaration queries; `contains`, `parameter_of`, `receiver` edge kinds |
|
|
215
215
|
| π | **Exports analysis** | `exports <file>` shows all exported symbols with per-symbol consumers, re-export detection, and counts |
|
|
@@ -327,7 +327,7 @@ codegraph ast -k call # Filter by kind: call, new, string, regex
|
|
|
327
327
|
codegraph ast -k throw --file src/ # Combine kind and file filters
|
|
328
328
|
```
|
|
329
329
|
|
|
330
|
-
> **Note:** Dataflow
|
|
330
|
+
> **Note:** Dataflow (JS/TS only) and CFG are included by default. Use `--no-dataflow` / `--no-cfg` for faster builds.
|
|
331
331
|
|
|
332
332
|
### Audit, Triage & Batch
|
|
333
333
|
|
|
@@ -552,14 +552,14 @@ Self-measured on every release via CI ([build benchmarks](generated/benchmarks/B
|
|
|
552
552
|
|
|
553
553
|
| Metric | Latest |
|
|
554
554
|
|---|---|
|
|
555
|
-
| Build speed (native) | **
|
|
556
|
-
| Build speed (WASM) | **
|
|
555
|
+
| Build speed (native) | **4.4 ms/file** |
|
|
556
|
+
| Build speed (WASM) | **13.7 ms/file** |
|
|
557
557
|
| Query time | **3ms** |
|
|
558
558
|
| No-op rebuild (native) | **4ms** |
|
|
559
|
-
| 1-file rebuild (native) | **
|
|
560
|
-
| Query: fn-deps | **
|
|
561
|
-
| Query: path | **
|
|
562
|
-
| ~50,000 files (est.) | **~
|
|
559
|
+
| 1-file rebuild (native) | **325ms** |
|
|
560
|
+
| Query: fn-deps | **0.8ms** |
|
|
561
|
+
| Query: path | **0.8ms** |
|
|
562
|
+
| ~50,000 files (est.) | **~220.0s build** |
|
|
563
563
|
|
|
564
564
|
Metrics are normalized per file for cross-version comparability. Times above are for a full initial build β incremental rebuilds only re-parse changed files.
|
|
565
565
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@optave/codegraph",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.1",
|
|
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,10 @@
|
|
|
71
71
|
},
|
|
72
72
|
"optionalDependencies": {
|
|
73
73
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
74
|
-
"@optave/codegraph-darwin-arm64": "3.0.
|
|
75
|
-
"@optave/codegraph-darwin-x64": "3.0.
|
|
76
|
-
"@optave/codegraph-linux-x64-gnu": "3.0.
|
|
77
|
-
"@optave/codegraph-win32-x64-msvc": "3.0.
|
|
74
|
+
"@optave/codegraph-darwin-arm64": "3.0.1",
|
|
75
|
+
"@optave/codegraph-darwin-x64": "3.0.1",
|
|
76
|
+
"@optave/codegraph-linux-x64-gnu": "3.0.1",
|
|
77
|
+
"@optave/codegraph-win32-x64-msvc": "3.0.1"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@biomejs/biome": "^2.4.4",
|
package/src/ast.js
CHANGED
|
@@ -156,9 +156,8 @@ export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
|
|
|
156
156
|
return;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
);
|
|
159
|
+
// Bulk-fetch all node IDs per file (replaces per-def getNodeId calls)
|
|
160
|
+
const bulkGetNodeIds = db.prepare('SELECT id, name, kind, line FROM nodes WHERE file = ?');
|
|
162
161
|
|
|
163
162
|
const tx = db.transaction((rows) => {
|
|
164
163
|
for (const r of rows) {
|
|
@@ -172,14 +171,20 @@ export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
|
|
|
172
171
|
const rows = [];
|
|
173
172
|
const defs = symbols.definitions || [];
|
|
174
173
|
|
|
174
|
+
// Pre-load all node IDs for this file into a map
|
|
175
|
+
const nodeIdMap = new Map();
|
|
176
|
+
for (const row of bulkGetNodeIds.all(relPath)) {
|
|
177
|
+
nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
|
|
178
|
+
}
|
|
179
|
+
|
|
175
180
|
// 1. Call nodes from symbols.calls (all languages)
|
|
176
181
|
if (symbols.calls) {
|
|
177
182
|
for (const call of symbols.calls) {
|
|
178
183
|
const parentDef = findParentDef(defs, call.line);
|
|
179
184
|
let parentNodeId = null;
|
|
180
185
|
if (parentDef) {
|
|
181
|
-
|
|
182
|
-
|
|
186
|
+
parentNodeId =
|
|
187
|
+
nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
|
|
183
188
|
}
|
|
184
189
|
rows.push({
|
|
185
190
|
file: relPath,
|
|
@@ -195,10 +200,32 @@ export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
|
|
|
195
200
|
|
|
196
201
|
// 2. AST walk for JS/TS/TSX β extract new, throw, await, string, regex
|
|
197
202
|
const ext = path.extname(relPath).toLowerCase();
|
|
198
|
-
if (WALK_EXTENSIONS.has(ext)
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
203
|
+
if (WALK_EXTENSIONS.has(ext)) {
|
|
204
|
+
if (symbols._tree) {
|
|
205
|
+
// WASM path: walk the tree-sitter AST
|
|
206
|
+
const astRows = [];
|
|
207
|
+
walkAst(symbols._tree.rootNode, defs, relPath, astRows, nodeIdMap);
|
|
208
|
+
rows.push(...astRows);
|
|
209
|
+
} else if (symbols.astNodes?.length) {
|
|
210
|
+
// Native path: use pre-extracted AST nodes from Rust
|
|
211
|
+
for (const n of symbols.astNodes) {
|
|
212
|
+
const parentDef = findParentDef(defs, n.line);
|
|
213
|
+
let parentNodeId = null;
|
|
214
|
+
if (parentDef) {
|
|
215
|
+
parentNodeId =
|
|
216
|
+
nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
|
|
217
|
+
}
|
|
218
|
+
rows.push({
|
|
219
|
+
file: relPath,
|
|
220
|
+
line: n.line,
|
|
221
|
+
kind: n.kind,
|
|
222
|
+
name: n.name,
|
|
223
|
+
text: n.text || null,
|
|
224
|
+
receiver: n.receiver || null,
|
|
225
|
+
parentNodeId,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
202
229
|
}
|
|
203
230
|
|
|
204
231
|
if (rows.length > 0) {
|
|
@@ -213,7 +240,7 @@ export async function buildAstNodes(db, fileSymbols, _rootDir, _engineOpts) {
|
|
|
213
240
|
/**
|
|
214
241
|
* Walk a tree-sitter AST and collect new/throw/await/string/regex nodes.
|
|
215
242
|
*/
|
|
216
|
-
function walkAst(node, defs, relPath, rows,
|
|
243
|
+
function walkAst(node, defs, relPath, rows, nodeIdMap) {
|
|
217
244
|
const kind = JS_TS_AST_TYPES[node.type];
|
|
218
245
|
if (kind) {
|
|
219
246
|
// tree-sitter lines are 0-indexed, our DB uses 1-indexed
|
|
@@ -237,7 +264,7 @@ function walkAst(node, defs, relPath, rows, getNodeId) {
|
|
|
237
264
|
if (content.length < 2) {
|
|
238
265
|
// Still recurse children
|
|
239
266
|
for (let i = 0; i < node.childCount; i++) {
|
|
240
|
-
walkAst(node.child(i), defs, relPath, rows,
|
|
267
|
+
walkAst(node.child(i), defs, relPath, rows, nodeIdMap);
|
|
241
268
|
}
|
|
242
269
|
return;
|
|
243
270
|
}
|
|
@@ -251,8 +278,7 @@ function walkAst(node, defs, relPath, rows, getNodeId) {
|
|
|
251
278
|
const parentDef = findParentDef(defs, line);
|
|
252
279
|
let parentNodeId = null;
|
|
253
280
|
if (parentDef) {
|
|
254
|
-
|
|
255
|
-
if (row) parentNodeId = row.id;
|
|
281
|
+
parentNodeId = nodeIdMap.get(`${parentDef.name}|${parentDef.kind}|${parentDef.line}`) || null;
|
|
256
282
|
}
|
|
257
283
|
|
|
258
284
|
rows.push({
|
|
@@ -271,7 +297,7 @@ function walkAst(node, defs, relPath, rows, getNodeId) {
|
|
|
271
297
|
}
|
|
272
298
|
|
|
273
299
|
for (let i = 0; i < node.childCount; i++) {
|
|
274
|
-
walkAst(node.child(i), defs, relPath, rows,
|
|
300
|
+
walkAst(node.child(i), defs, relPath, rows, nodeIdMap);
|
|
275
301
|
}
|
|
276
302
|
}
|
|
277
303
|
|
package/src/builder.js
CHANGED
|
@@ -4,7 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { performance } from 'node:perf_hooks';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
6
|
import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
|
|
7
|
-
import { closeDb, getBuildMeta, initSchema, openDb, setBuildMeta } from './db.js';
|
|
7
|
+
import { closeDb, getBuildMeta, initSchema, MIGRATIONS, openDb, setBuildMeta } from './db.js';
|
|
8
8
|
import { readJournal, writeJournalHeader } from './journal.js';
|
|
9
9
|
import { debug, info, warn } from './logger.js';
|
|
10
10
|
import { getActiveEngine, parseFilesAuto } from './parser.js';
|
|
@@ -448,17 +448,21 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
448
448
|
const { name: engineName, version: engineVersion } = getActiveEngine(engineOpts);
|
|
449
449
|
info(`Using ${engineName} engine${engineVersion ? ` (v${engineVersion})` : ''}`);
|
|
450
450
|
|
|
451
|
-
// Check for engine/
|
|
451
|
+
// Check for engine/schema mismatch β auto-promote to full rebuild
|
|
452
|
+
// Only trigger on engine change or schema version change (not every patch/minor bump)
|
|
453
|
+
const CURRENT_SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
|
452
454
|
let forceFullRebuild = false;
|
|
453
455
|
if (incremental) {
|
|
454
456
|
const prevEngine = getBuildMeta(db, 'engine');
|
|
455
|
-
const prevVersion = getBuildMeta(db, 'codegraph_version');
|
|
456
457
|
if (prevEngine && prevEngine !== engineName) {
|
|
457
458
|
info(`Engine changed (${prevEngine} β ${engineName}), promoting to full rebuild.`);
|
|
458
459
|
forceFullRebuild = true;
|
|
459
460
|
}
|
|
460
|
-
|
|
461
|
-
|
|
461
|
+
const prevSchema = getBuildMeta(db, 'schema_version');
|
|
462
|
+
if (prevSchema && Number(prevSchema) !== CURRENT_SCHEMA_VERSION) {
|
|
463
|
+
info(
|
|
464
|
+
`Schema version changed (${prevSchema} β ${CURRENT_SCHEMA_VERSION}), promoting to full rebuild.`,
|
|
465
|
+
);
|
|
462
466
|
forceFullRebuild = true;
|
|
463
467
|
}
|
|
464
468
|
}
|
|
@@ -522,9 +526,9 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
522
526
|
}
|
|
523
527
|
|
|
524
528
|
if (!isFullBuild && parseChanges.length === 0 && removed.length === 0) {
|
|
525
|
-
// Check if
|
|
529
|
+
// Check if default analyses were never computed (e.g. legacy DB)
|
|
526
530
|
const needsCfg =
|
|
527
|
-
opts.cfg &&
|
|
531
|
+
opts.cfg !== false &&
|
|
528
532
|
(() => {
|
|
529
533
|
try {
|
|
530
534
|
return db.prepare('SELECT COUNT(*) as c FROM cfg_blocks').get().c === 0;
|
|
@@ -533,16 +537,10 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
533
537
|
}
|
|
534
538
|
})();
|
|
535
539
|
const needsDataflow =
|
|
536
|
-
opts.dataflow &&
|
|
540
|
+
opts.dataflow !== false &&
|
|
537
541
|
(() => {
|
|
538
542
|
try {
|
|
539
|
-
return (
|
|
540
|
-
db
|
|
541
|
-
.prepare(
|
|
542
|
-
"SELECT COUNT(*) as c FROM edges WHERE kind IN ('flows_to','returns','mutates')",
|
|
543
|
-
)
|
|
544
|
-
.get().c === 0
|
|
545
|
-
);
|
|
543
|
+
return db.prepare('SELECT COUNT(*) as c FROM dataflow').get().c === 0;
|
|
546
544
|
} catch {
|
|
547
545
|
return true;
|
|
548
546
|
}
|
|
@@ -721,44 +719,66 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
721
719
|
}
|
|
722
720
|
}
|
|
723
721
|
|
|
722
|
+
// Bulk-fetch all node IDs for a file in one query (replaces per-node getNodeId calls)
|
|
723
|
+
const bulkGetNodeIds = db.prepare('SELECT id, name, kind, line FROM nodes WHERE file = ?');
|
|
724
|
+
|
|
724
725
|
const insertAll = db.transaction(() => {
|
|
725
726
|
for (const [relPath, symbols] of allSymbols) {
|
|
726
727
|
fileSymbols.set(relPath, symbols);
|
|
727
728
|
|
|
729
|
+
// Phase 1: Insert file node + definitions + exports (no children yet)
|
|
728
730
|
insertNode.run(relPath, 'file', relPath, 0, null, null);
|
|
729
|
-
const fileRow = getNodeId.get(relPath, 'file', relPath, 0);
|
|
730
731
|
for (const def of symbols.definitions) {
|
|
731
732
|
insertNode.run(def.name, def.kind, relPath, def.line, def.endLine || null, null);
|
|
732
|
-
|
|
733
|
+
}
|
|
734
|
+
for (const exp of symbols.exports) {
|
|
735
|
+
insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Phase 2: Bulk-fetch IDs for file + definitions
|
|
739
|
+
const nodeIdMap = new Map();
|
|
740
|
+
for (const row of bulkGetNodeIds.all(relPath)) {
|
|
741
|
+
nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Phase 3: Insert children with parent_id from the map
|
|
745
|
+
for (const def of symbols.definitions) {
|
|
746
|
+
if (!def.children?.length) continue;
|
|
747
|
+
const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
|
|
748
|
+
if (!defId) continue;
|
|
749
|
+
for (const child of def.children) {
|
|
750
|
+
insertNode.run(child.name, child.kind, relPath, child.line, child.endLine || null, defId);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Phase 4: Re-fetch to include children IDs
|
|
755
|
+
nodeIdMap.clear();
|
|
756
|
+
for (const row of bulkGetNodeIds.all(relPath)) {
|
|
757
|
+
nodeIdMap.set(`${row.name}|${row.kind}|${row.line}`, row.id);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Phase 5: Insert edges using the cached ID map
|
|
761
|
+
const fileId = nodeIdMap.get(`${relPath}|file|0`);
|
|
762
|
+
for (const def of symbols.definitions) {
|
|
763
|
+
const defId = nodeIdMap.get(`${def.name}|${def.kind}|${def.line}`);
|
|
733
764
|
// File β top-level definition contains edge
|
|
734
|
-
if (
|
|
735
|
-
insertEdge.run(
|
|
765
|
+
if (fileId && defId) {
|
|
766
|
+
insertEdge.run(fileId, defId, 'contains', 1.0, 0);
|
|
736
767
|
}
|
|
737
|
-
if (def.children?.length &&
|
|
768
|
+
if (def.children?.length && defId) {
|
|
738
769
|
for (const child of def.children) {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
child
|
|
742
|
-
|
|
743
|
-
child.line,
|
|
744
|
-
child.endLine || null,
|
|
745
|
-
defRow.id,
|
|
746
|
-
);
|
|
747
|
-
// Parent β child contains edge
|
|
748
|
-
const childRow = getNodeId.get(child.name, child.kind, relPath, child.line);
|
|
749
|
-
if (childRow) {
|
|
750
|
-
insertEdge.run(defRow.id, childRow.id, 'contains', 1.0, 0);
|
|
770
|
+
const childId = nodeIdMap.get(`${child.name}|${child.kind}|${child.line}`);
|
|
771
|
+
if (childId) {
|
|
772
|
+
// Parent β child contains edge
|
|
773
|
+
insertEdge.run(defId, childId, 'contains', 1.0, 0);
|
|
751
774
|
// Parameter β parent parameter_of edge (inverse direction)
|
|
752
775
|
if (child.kind === 'parameter') {
|
|
753
|
-
insertEdge.run(
|
|
776
|
+
insertEdge.run(childId, defId, 'parameter_of', 1.0, 0);
|
|
754
777
|
}
|
|
755
778
|
}
|
|
756
779
|
}
|
|
757
780
|
}
|
|
758
781
|
}
|
|
759
|
-
for (const exp of symbols.exports) {
|
|
760
|
-
insertNode.run(exp.name, exp.kind, relPath, exp.line, null, null);
|
|
761
|
-
}
|
|
762
782
|
|
|
763
783
|
// Update file hash with real mtime+size for incremental builds
|
|
764
784
|
// Skip for reverse-dep files β they didn't actually change
|
|
@@ -1229,7 +1249,9 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1229
1249
|
}
|
|
1230
1250
|
try {
|
|
1231
1251
|
const { buildStructure } = await import('./structure.js');
|
|
1232
|
-
|
|
1252
|
+
// Pass changed file paths so incremental builds can scope the rebuild
|
|
1253
|
+
const changedFilePaths = isFullBuild ? null : [...allSymbols.keys()];
|
|
1254
|
+
buildStructure(db, fileSymbols, rootDir, lineCountMap, relDirs, changedFilePaths);
|
|
1233
1255
|
} catch (err) {
|
|
1234
1256
|
debug(`Structure analysis failed: ${err.message}`);
|
|
1235
1257
|
}
|
|
@@ -1250,29 +1272,53 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1250
1272
|
}
|
|
1251
1273
|
_t.rolesMs = performance.now() - _t.roles0;
|
|
1252
1274
|
|
|
1253
|
-
//
|
|
1275
|
+
// For incremental builds, filter out reverse-dep-only files from AST/complexity
|
|
1276
|
+
// β their content didn't change, so existing ast_nodes/function_complexity rows are valid.
|
|
1277
|
+
let astComplexitySymbols = allSymbols;
|
|
1278
|
+
if (!isFullBuild) {
|
|
1279
|
+
const reverseDepFiles = new Set(
|
|
1280
|
+
filesToParse.filter((item) => item._reverseDepOnly).map((item) => item.relPath),
|
|
1281
|
+
);
|
|
1282
|
+
if (reverseDepFiles.size > 0) {
|
|
1283
|
+
astComplexitySymbols = new Map();
|
|
1284
|
+
for (const [relPath, symbols] of allSymbols) {
|
|
1285
|
+
if (!reverseDepFiles.has(relPath)) {
|
|
1286
|
+
astComplexitySymbols.set(relPath, symbols);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
debug(
|
|
1290
|
+
`AST/complexity: processing ${astComplexitySymbols.size} changed files (skipping ${reverseDepFiles.size} reverse-deps)`,
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// AST node extraction (calls, new, string, regex, throw, await)
|
|
1254
1296
|
// Must run before complexity which releases _tree references
|
|
1255
1297
|
_t.ast0 = performance.now();
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1298
|
+
if (opts.ast !== false) {
|
|
1299
|
+
try {
|
|
1300
|
+
const { buildAstNodes } = await import('./ast.js');
|
|
1301
|
+
await buildAstNodes(db, astComplexitySymbols, rootDir, engineOpts);
|
|
1302
|
+
} catch (err) {
|
|
1303
|
+
debug(`AST node extraction failed: ${err.message}`);
|
|
1304
|
+
}
|
|
1261
1305
|
}
|
|
1262
1306
|
_t.astMs = performance.now() - _t.ast0;
|
|
1263
1307
|
|
|
1264
1308
|
// Compute per-function complexity metrics (cognitive, cyclomatic, nesting)
|
|
1265
1309
|
_t.complexity0 = performance.now();
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1310
|
+
if (opts.complexity !== false) {
|
|
1311
|
+
try {
|
|
1312
|
+
const { buildComplexityMetrics } = await import('./complexity.js');
|
|
1313
|
+
await buildComplexityMetrics(db, astComplexitySymbols, rootDir, engineOpts);
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
debug(`Complexity analysis failed: ${err.message}`);
|
|
1316
|
+
}
|
|
1271
1317
|
}
|
|
1272
1318
|
_t.complexityMs = performance.now() - _t.complexity0;
|
|
1273
1319
|
|
|
1274
|
-
//
|
|
1275
|
-
if (opts.cfg) {
|
|
1320
|
+
// CFG analysis (skip with --no-cfg)
|
|
1321
|
+
if (opts.cfg !== false) {
|
|
1276
1322
|
_t.cfg0 = performance.now();
|
|
1277
1323
|
try {
|
|
1278
1324
|
const { buildCFGData } = await import('./cfg.js');
|
|
@@ -1283,8 +1329,8 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1283
1329
|
_t.cfgMs = performance.now() - _t.cfg0;
|
|
1284
1330
|
}
|
|
1285
1331
|
|
|
1286
|
-
//
|
|
1287
|
-
if (opts.dataflow) {
|
|
1332
|
+
// Dataflow analysis (skip with --no-dataflow)
|
|
1333
|
+
if (opts.dataflow !== false) {
|
|
1288
1334
|
_t.dataflow0 = performance.now();
|
|
1289
1335
|
try {
|
|
1290
1336
|
const { buildDataflowEdges } = await import('./dataflow.js');
|
|
@@ -1348,6 +1394,7 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1348
1394
|
engine: engineName,
|
|
1349
1395
|
engine_version: engineVersion || '',
|
|
1350
1396
|
codegraph_version: CODEGRAPH_VERSION,
|
|
1397
|
+
schema_version: String(CURRENT_SCHEMA_VERSION),
|
|
1351
1398
|
built_at: new Date().toISOString(),
|
|
1352
1399
|
node_count: nodeCount,
|
|
1353
1400
|
edge_count: actualEdgeCount,
|
|
@@ -1385,8 +1432,10 @@ export async function buildGraph(rootDir, opts = {}) {
|
|
|
1385
1432
|
edgesMs: +_t.edgesMs.toFixed(1),
|
|
1386
1433
|
structureMs: +_t.structureMs.toFixed(1),
|
|
1387
1434
|
rolesMs: +_t.rolesMs.toFixed(1),
|
|
1435
|
+
astMs: +_t.astMs.toFixed(1),
|
|
1388
1436
|
complexityMs: +_t.complexityMs.toFixed(1),
|
|
1389
1437
|
...(_t.cfgMs != null && { cfgMs: +_t.cfgMs.toFixed(1) }),
|
|
1438
|
+
...(_t.dataflowMs != null && { dataflowMs: +_t.dataflowMs.toFixed(1) }),
|
|
1390
1439
|
},
|
|
1391
1440
|
};
|
|
1392
1441
|
}
|
package/src/cfg.js
CHANGED
|
@@ -1241,7 +1241,8 @@ export function cfgData(name, customDbPath, opts = {}) {
|
|
|
1241
1241
|
return {
|
|
1242
1242
|
name,
|
|
1243
1243
|
results: [],
|
|
1244
|
-
warning:
|
|
1244
|
+
warning:
|
|
1245
|
+
'No CFG data found. Rebuild with `codegraph build` (CFG is now included by default).',
|
|
1245
1246
|
};
|
|
1246
1247
|
}
|
|
1247
1248
|
|
package/src/cli.js
CHANGED
|
@@ -105,13 +105,17 @@ program
|
|
|
105
105
|
.command('build [dir]')
|
|
106
106
|
.description('Parse repo and build graph in .codegraph/graph.db')
|
|
107
107
|
.option('--no-incremental', 'Force full rebuild (ignore file hashes)')
|
|
108
|
-
.option('--
|
|
109
|
-
.option('--
|
|
108
|
+
.option('--no-ast', 'Skip AST node extraction (calls, new, string, regex, throw, await)')
|
|
109
|
+
.option('--no-complexity', 'Skip complexity metrics computation')
|
|
110
|
+
.option('--no-dataflow', 'Skip data flow edge extraction')
|
|
111
|
+
.option('--no-cfg', 'Skip control flow graph building')
|
|
110
112
|
.action(async (dir, opts) => {
|
|
111
113
|
const root = path.resolve(dir || '.');
|
|
112
114
|
const engine = program.opts().engine;
|
|
113
115
|
await buildGraph(root, {
|
|
114
116
|
incremental: opts.incremental,
|
|
117
|
+
ast: opts.ast,
|
|
118
|
+
complexity: opts.complexity,
|
|
115
119
|
engine,
|
|
116
120
|
dataflow: opts.dataflow,
|
|
117
121
|
cfg: opts.cfg,
|
package/src/dataflow.js
CHANGED
|
@@ -734,7 +734,8 @@ export function dataflowData(name, customDbPath, opts = {}) {
|
|
|
734
734
|
return {
|
|
735
735
|
name,
|
|
736
736
|
results: [],
|
|
737
|
-
warning:
|
|
737
|
+
warning:
|
|
738
|
+
'No dataflow data found. Rebuild with `codegraph build` (dataflow is now included by default).',
|
|
738
739
|
};
|
|
739
740
|
}
|
|
740
741
|
|
|
@@ -876,7 +877,8 @@ export function dataflowPathData(from, to, customDbPath, opts = {}) {
|
|
|
876
877
|
from,
|
|
877
878
|
to,
|
|
878
879
|
found: false,
|
|
879
|
-
warning:
|
|
880
|
+
warning:
|
|
881
|
+
'No dataflow data found. Rebuild with `codegraph build` (dataflow is now included by default).',
|
|
880
882
|
};
|
|
881
883
|
}
|
|
882
884
|
|
|
@@ -1005,7 +1007,8 @@ export function dataflowImpactData(name, customDbPath, opts = {}) {
|
|
|
1005
1007
|
return {
|
|
1006
1008
|
name,
|
|
1007
1009
|
results: [],
|
|
1008
|
-
warning:
|
|
1010
|
+
warning:
|
|
1011
|
+
'No dataflow data found. Rebuild with `codegraph build` (dataflow is now included by default).',
|
|
1009
1012
|
};
|
|
1010
1013
|
}
|
|
1011
1014
|
|
|
@@ -170,9 +170,60 @@ function extractSymbolsQuery(tree, query) {
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
// Extract top-level constants via targeted walk (query patterns don't cover these)
|
|
174
|
+
extractConstantsWalk(tree.rootNode, definitions);
|
|
175
|
+
|
|
173
176
|
return { definitions, calls, imports, classes, exports: exps };
|
|
174
177
|
}
|
|
175
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Walk program-level children to extract `const x = <literal>` as constants.
|
|
181
|
+
* The query-based fast path has no pattern for lexical_declaration/variable_declaration,
|
|
182
|
+
* so constants are missed. This targeted walk fills that gap without a full tree traversal.
|
|
183
|
+
*/
|
|
184
|
+
function extractConstantsWalk(rootNode, definitions) {
|
|
185
|
+
for (let i = 0; i < rootNode.childCount; i++) {
|
|
186
|
+
const node = rootNode.child(i);
|
|
187
|
+
if (!node) continue;
|
|
188
|
+
|
|
189
|
+
let declNode = node;
|
|
190
|
+
// Handle `export const β¦` β unwrap the export_statement to its declaration child
|
|
191
|
+
if (node.type === 'export_statement') {
|
|
192
|
+
const inner = node.childForFieldName('declaration');
|
|
193
|
+
if (!inner) continue;
|
|
194
|
+
declNode = inner;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const t = declNode.type;
|
|
198
|
+
if (t !== 'lexical_declaration' && t !== 'variable_declaration') continue;
|
|
199
|
+
if (!declNode.text.startsWith('const ')) continue;
|
|
200
|
+
|
|
201
|
+
for (let j = 0; j < declNode.childCount; j++) {
|
|
202
|
+
const declarator = declNode.child(j);
|
|
203
|
+
if (!declarator || declarator.type !== 'variable_declarator') continue;
|
|
204
|
+
const nameN = declarator.childForFieldName('name');
|
|
205
|
+
const valueN = declarator.childForFieldName('value');
|
|
206
|
+
if (!nameN || nameN.type !== 'identifier' || !valueN) continue;
|
|
207
|
+
// Skip functions β already captured by query patterns
|
|
208
|
+
const valType = valueN.type;
|
|
209
|
+
if (
|
|
210
|
+
valType === 'arrow_function' ||
|
|
211
|
+
valType === 'function_expression' ||
|
|
212
|
+
valType === 'function'
|
|
213
|
+
)
|
|
214
|
+
continue;
|
|
215
|
+
if (isConstantValue(valueN)) {
|
|
216
|
+
definitions.push({
|
|
217
|
+
name: nameN.text,
|
|
218
|
+
kind: 'constant',
|
|
219
|
+
line: declNode.startPosition.row + 1,
|
|
220
|
+
endLine: nodeEndLine(declNode),
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
176
227
|
function handleCommonJSAssignment(left, right, node, imports) {
|
|
177
228
|
if (!left || !right) return;
|
|
178
229
|
const leftText = left.text;
|
package/src/flow.js
CHANGED
|
@@ -45,7 +45,10 @@ export function listEntryPointsData(dbPath, opts = {}) {
|
|
|
45
45
|
.prepare(
|
|
46
46
|
`SELECT n.name, n.kind, n.file, n.line, n.role
|
|
47
47
|
FROM nodes n
|
|
48
|
-
WHERE (
|
|
48
|
+
WHERE (
|
|
49
|
+
(${prefixConditions})
|
|
50
|
+
OR n.role = 'entry'
|
|
51
|
+
)
|
|
49
52
|
AND n.kind NOT IN ('file', 'directory')
|
|
50
53
|
ORDER BY n.name`,
|
|
51
54
|
)
|
|
@@ -59,7 +62,7 @@ export function listEntryPointsData(dbPath, opts = {}) {
|
|
|
59
62
|
file: r.file,
|
|
60
63
|
line: r.line,
|
|
61
64
|
role: r.role,
|
|
62
|
-
type: entryPointType(r.name),
|
|
65
|
+
type: entryPointType(r.name) || (r.role === 'entry' ? 'exported' : null),
|
|
63
66
|
}));
|
|
64
67
|
|
|
65
68
|
const byType = {};
|
package/src/index.js
CHANGED
|
@@ -123,7 +123,7 @@ export { matchOwners, owners, ownersData, ownersForFiles, parseCodeowners } from
|
|
|
123
123
|
export { MCP_DEFAULTS, MCP_MAX_LIMIT, paginate, paginateResult, printNdjson } from './paginate.js';
|
|
124
124
|
|
|
125
125
|
// Unified parser API
|
|
126
|
-
export { getActiveEngine, parseFileAuto, parseFilesAuto } from './parser.js';
|
|
126
|
+
export { getActiveEngine, isWasmAvailable, parseFileAuto, parseFilesAuto } from './parser.js';
|
|
127
127
|
// Query functions (data-returning)
|
|
128
128
|
export {
|
|
129
129
|
ALL_SYMBOL_KINDS,
|
package/src/mcp.js
CHANGED
|
@@ -638,7 +638,7 @@ const BASE_TOOLS = [
|
|
|
638
638
|
},
|
|
639
639
|
{
|
|
640
640
|
name: 'cfg',
|
|
641
|
-
description: 'Show intraprocedural control flow graph for a function.
|
|
641
|
+
description: 'Show intraprocedural control flow graph for a function.',
|
|
642
642
|
inputSchema: {
|
|
643
643
|
type: 'object',
|
|
644
644
|
properties: {
|
|
@@ -658,7 +658,7 @@ const BASE_TOOLS = [
|
|
|
658
658
|
},
|
|
659
659
|
{
|
|
660
660
|
name: 'dataflow',
|
|
661
|
-
description: 'Show data flow edges or data-dependent blast radius.
|
|
661
|
+
description: 'Show data flow edges or data-dependent blast radius.',
|
|
662
662
|
inputSchema: {
|
|
663
663
|
type: 'object',
|
|
664
664
|
properties: {
|
package/src/parser.js
CHANGED
|
@@ -104,6 +104,15 @@ export function getParser(parsers, filePath) {
|
|
|
104
104
|
return parsers.get(entry.id) || null;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Check whether the required WASM grammar files exist on disk.
|
|
109
|
+
*/
|
|
110
|
+
export function isWasmAvailable() {
|
|
111
|
+
return LANGUAGE_REGISTRY.filter((e) => e.required).every((e) =>
|
|
112
|
+
fs.existsSync(grammarPath(e.grammarFile)),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
107
116
|
// ββ Unified API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
108
117
|
|
|
109
118
|
function resolveEngine(opts = {}) {
|
|
@@ -183,6 +192,13 @@ function normalizeNativeSymbols(result) {
|
|
|
183
192
|
kind: e.kind,
|
|
184
193
|
line: e.line,
|
|
185
194
|
})),
|
|
195
|
+
astNodes: (result.astNodes ?? result.ast_nodes ?? []).map((n) => ({
|
|
196
|
+
kind: n.kind,
|
|
197
|
+
name: n.name,
|
|
198
|
+
line: n.line,
|
|
199
|
+
text: n.text ?? null,
|
|
200
|
+
receiver: n.receiver ?? null,
|
|
201
|
+
})),
|
|
186
202
|
};
|
|
187
203
|
}
|
|
188
204
|
|
package/src/structure.js
CHANGED
|
@@ -17,7 +17,7 @@ import { isTestFile } from './queries.js';
|
|
|
17
17
|
* @param {Map<string, number>} lineCountMap - Map of relPath β line count
|
|
18
18
|
* @param {Set<string>} directories - Set of relative directory paths
|
|
19
19
|
*/
|
|
20
|
-
export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, directories) {
|
|
20
|
+
export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, directories, changedFiles) {
|
|
21
21
|
const insertNode = db.prepare(
|
|
22
22
|
'INSERT OR IGNORE INTO nodes (name, kind, file, line, end_line) VALUES (?, ?, ?, ?, ?)',
|
|
23
23
|
);
|
|
@@ -33,15 +33,49 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
33
33
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
34
34
|
`);
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
const isIncremental = changedFiles != null && changedFiles.length > 0;
|
|
37
|
+
|
|
38
|
+
if (isIncremental) {
|
|
39
|
+
// Incremental: only clean up data for changed files and their ancestor directories
|
|
40
|
+
const affectedDirs = new Set();
|
|
41
|
+
for (const f of changedFiles) {
|
|
42
|
+
let d = normalizePath(path.dirname(f));
|
|
43
|
+
while (d && d !== '.') {
|
|
44
|
+
affectedDirs.add(d);
|
|
45
|
+
d = normalizePath(path.dirname(d));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const deleteContainsForDir = db.prepare(
|
|
49
|
+
"DELETE FROM edges WHERE kind = 'contains' AND source_id IN (SELECT id FROM nodes WHERE name = ? AND kind = 'directory')",
|
|
50
|
+
);
|
|
51
|
+
const deleteMetricForNode = db.prepare('DELETE FROM node_metrics WHERE node_id = ?');
|
|
52
|
+
db.transaction(() => {
|
|
53
|
+
// Delete contains edges only from affected directories
|
|
54
|
+
for (const dir of affectedDirs) {
|
|
55
|
+
deleteContainsForDir.run(dir);
|
|
56
|
+
}
|
|
57
|
+
// Delete metrics for changed files
|
|
58
|
+
for (const f of changedFiles) {
|
|
59
|
+
const fileRow = getNodeId.get(f, 'file', f, 0);
|
|
60
|
+
if (fileRow) deleteMetricForNode.run(fileRow.id);
|
|
61
|
+
}
|
|
62
|
+
// Delete metrics for affected directories
|
|
63
|
+
for (const dir of affectedDirs) {
|
|
64
|
+
const dirRow = getNodeId.get(dir, 'directory', dir, 0);
|
|
65
|
+
if (dirRow) deleteMetricForNode.run(dirRow.id);
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
} else {
|
|
69
|
+
// Full rebuild: clean previous directory nodes/edges (idempotent)
|
|
70
|
+
// Scope contains-edge delete to directory-sourced edges only,
|
|
71
|
+
// preserving symbol-level contains edges (fileβdef, classβmethod, etc.)
|
|
72
|
+
db.exec(`
|
|
73
|
+
DELETE FROM edges WHERE kind = 'contains'
|
|
74
|
+
AND source_id IN (SELECT id FROM nodes WHERE kind = 'directory');
|
|
75
|
+
DELETE FROM node_metrics;
|
|
76
|
+
DELETE FROM nodes WHERE kind = 'directory';
|
|
77
|
+
`);
|
|
78
|
+
}
|
|
45
79
|
|
|
46
80
|
// Step 1: Ensure all directories are represented (including intermediate parents)
|
|
47
81
|
const allDirs = new Set();
|
|
@@ -61,7 +95,7 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
61
95
|
}
|
|
62
96
|
}
|
|
63
97
|
|
|
64
|
-
// Step 2: Insert directory nodes
|
|
98
|
+
// Step 2: Insert directory nodes (INSERT OR IGNORE β safe for incremental)
|
|
65
99
|
const insertDirs = db.transaction(() => {
|
|
66
100
|
for (const dir of allDirs) {
|
|
67
101
|
insertNode.run(dir, 'directory', dir, 0, null);
|
|
@@ -70,11 +104,28 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
70
104
|
insertDirs();
|
|
71
105
|
|
|
72
106
|
// Step 3: Insert 'contains' edges (dir β file, dir β subdirectory)
|
|
107
|
+
// On incremental, only re-insert for affected directories (others are intact)
|
|
108
|
+
const affectedDirs = isIncremental
|
|
109
|
+
? (() => {
|
|
110
|
+
const dirs = new Set();
|
|
111
|
+
for (const f of changedFiles) {
|
|
112
|
+
let d = normalizePath(path.dirname(f));
|
|
113
|
+
while (d && d !== '.') {
|
|
114
|
+
dirs.add(d);
|
|
115
|
+
d = normalizePath(path.dirname(d));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return dirs;
|
|
119
|
+
})()
|
|
120
|
+
: null;
|
|
121
|
+
|
|
73
122
|
const insertContains = db.transaction(() => {
|
|
74
123
|
// dir β file
|
|
75
124
|
for (const relPath of fileSymbols.keys()) {
|
|
76
125
|
const dir = normalizePath(path.dirname(relPath));
|
|
77
126
|
if (!dir || dir === '.') continue;
|
|
127
|
+
// On incremental, skip dirs whose contains edges are intact
|
|
128
|
+
if (affectedDirs && !affectedDirs.has(dir)) continue;
|
|
78
129
|
const dirRow = getNodeId.get(dir, 'directory', dir, 0);
|
|
79
130
|
const fileRow = getNodeId.get(relPath, 'file', relPath, 0);
|
|
80
131
|
if (dirRow && fileRow) {
|
|
@@ -85,6 +136,8 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
85
136
|
for (const dir of allDirs) {
|
|
86
137
|
const parent = normalizePath(path.dirname(dir));
|
|
87
138
|
if (!parent || parent === '.' || parent === dir) continue;
|
|
139
|
+
// On incremental, skip parent dirs whose contains edges are intact
|
|
140
|
+
if (affectedDirs && !affectedDirs.has(parent)) continue;
|
|
88
141
|
const parentRow = getNodeId.get(parent, 'directory', parent, 0);
|
|
89
142
|
const childRow = getNodeId.get(dir, 'directory', dir, 0);
|
|
90
143
|
if (parentRow && childRow) {
|