@optave/codegraph 3.0.0 → 3.0.2
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 +23 -20
- package/package.json +5 -5
- package/src/ast.js +40 -14
- package/src/builder.js +117 -55
- package/src/cfg.js +2 -1
- package/src/cli.js +6 -2
- package/src/complexity.js +0 -3
- package/src/dataflow.js +766 -275
- 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 +70 -0
- package/src/structure.js +64 -11
|
@@ -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
|
@@ -38,6 +38,9 @@ function grammarPath(name) {
|
|
|
38
38
|
|
|
39
39
|
let _initialized = false;
|
|
40
40
|
|
|
41
|
+
// Memoized parsers — avoids reloading WASM grammars on every createParsers() call
|
|
42
|
+
let _cachedParsers = null;
|
|
43
|
+
|
|
41
44
|
// Query cache for JS/TS/TSX extractors (populated during createParsers)
|
|
42
45
|
const _queryCache = new Map();
|
|
43
46
|
|
|
@@ -66,6 +69,8 @@ const TS_EXTRA_PATTERNS = [
|
|
|
66
69
|
];
|
|
67
70
|
|
|
68
71
|
export async function createParsers() {
|
|
72
|
+
if (_cachedParsers) return _cachedParsers;
|
|
73
|
+
|
|
69
74
|
if (!_initialized) {
|
|
70
75
|
await Parser.init();
|
|
71
76
|
_initialized = true;
|
|
@@ -94,6 +99,7 @@ export async function createParsers() {
|
|
|
94
99
|
parsers.set(entry.id, null);
|
|
95
100
|
}
|
|
96
101
|
}
|
|
102
|
+
_cachedParsers = parsers;
|
|
97
103
|
return parsers;
|
|
98
104
|
}
|
|
99
105
|
|
|
@@ -104,6 +110,63 @@ export function getParser(parsers, filePath) {
|
|
|
104
110
|
return parsers.get(entry.id) || null;
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Pre-parse files missing `_tree` via WASM so downstream phases (CFG, dataflow)
|
|
115
|
+
* don't each need to create parsers and re-parse independently.
|
|
116
|
+
* Only parses files whose extension is in SUPPORTED_EXTENSIONS.
|
|
117
|
+
*
|
|
118
|
+
* @param {Map<string, object>} fileSymbols - Map<relPath, { definitions, _tree, _langId, ... }>
|
|
119
|
+
* @param {string} rootDir - absolute project root
|
|
120
|
+
*/
|
|
121
|
+
export async function ensureWasmTrees(fileSymbols, rootDir) {
|
|
122
|
+
// Check if any file needs a tree
|
|
123
|
+
let needsParse = false;
|
|
124
|
+
for (const [relPath, symbols] of fileSymbols) {
|
|
125
|
+
if (!symbols._tree) {
|
|
126
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
127
|
+
if (_extToLang.has(ext)) {
|
|
128
|
+
needsParse = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!needsParse) return;
|
|
134
|
+
|
|
135
|
+
const parsers = await createParsers();
|
|
136
|
+
|
|
137
|
+
for (const [relPath, symbols] of fileSymbols) {
|
|
138
|
+
if (symbols._tree) continue;
|
|
139
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
140
|
+
const entry = _extToLang.get(ext);
|
|
141
|
+
if (!entry) continue;
|
|
142
|
+
const parser = parsers.get(entry.id);
|
|
143
|
+
if (!parser) continue;
|
|
144
|
+
|
|
145
|
+
const absPath = path.join(rootDir, relPath);
|
|
146
|
+
let code;
|
|
147
|
+
try {
|
|
148
|
+
code = fs.readFileSync(absPath, 'utf-8');
|
|
149
|
+
} catch {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
symbols._tree = parser.parse(code);
|
|
154
|
+
symbols._langId = entry.id;
|
|
155
|
+
} catch {
|
|
156
|
+
// skip files that fail to parse
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check whether the required WASM grammar files exist on disk.
|
|
163
|
+
*/
|
|
164
|
+
export function isWasmAvailable() {
|
|
165
|
+
return LANGUAGE_REGISTRY.filter((e) => e.required).every((e) =>
|
|
166
|
+
fs.existsSync(grammarPath(e.grammarFile)),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
107
170
|
// ── Unified API ──────────────────────────────────────────────────────────────
|
|
108
171
|
|
|
109
172
|
function resolveEngine(opts = {}) {
|
|
@@ -183,6 +246,13 @@ function normalizeNativeSymbols(result) {
|
|
|
183
246
|
kind: e.kind,
|
|
184
247
|
line: e.line,
|
|
185
248
|
})),
|
|
249
|
+
astNodes: (result.astNodes ?? result.ast_nodes ?? []).map((n) => ({
|
|
250
|
+
kind: n.kind,
|
|
251
|
+
name: n.name,
|
|
252
|
+
line: n.line,
|
|
253
|
+
text: n.text ?? null,
|
|
254
|
+
receiver: n.receiver ?? null,
|
|
255
|
+
})),
|
|
186
256
|
};
|
|
187
257
|
}
|
|
188
258
|
|
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) {
|