@optave/codegraph 3.1.5 → 3.2.0
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 +3 -2
- package/package.json +7 -7
- package/src/ast-analysis/engine.js +252 -258
- package/src/ast-analysis/shared.js +0 -12
- package/src/ast-analysis/visitors/cfg-visitor.js +635 -649
- package/src/ast-analysis/visitors/complexity-visitor.js +135 -139
- package/src/ast-analysis/visitors/dataflow-visitor.js +230 -224
- package/src/cli/commands/ast.js +2 -1
- package/src/cli/commands/audit.js +2 -1
- package/src/cli/commands/batch.js +2 -1
- package/src/cli/commands/brief.js +12 -0
- package/src/cli/commands/cfg.js +2 -1
- package/src/cli/commands/check.js +20 -23
- package/src/cli/commands/children.js +6 -1
- package/src/cli/commands/complexity.js +2 -1
- package/src/cli/commands/context.js +6 -1
- package/src/cli/commands/dataflow.js +2 -1
- package/src/cli/commands/deps.js +8 -3
- package/src/cli/commands/flow.js +2 -1
- package/src/cli/commands/fn-impact.js +6 -1
- package/src/cli/commands/owners.js +4 -2
- package/src/cli/commands/query.js +6 -1
- package/src/cli/commands/roles.js +2 -1
- package/src/cli/commands/search.js +8 -2
- package/src/cli/commands/sequence.js +2 -1
- package/src/cli/commands/triage.js +38 -27
- package/src/db/connection.js +18 -12
- package/src/db/migrations.js +41 -64
- package/src/db/query-builder.js +60 -4
- package/src/db/repository/in-memory-repository.js +27 -16
- package/src/db/repository/nodes.js +8 -10
- package/src/domain/analysis/brief.js +155 -0
- package/src/domain/analysis/context.js +174 -190
- package/src/domain/analysis/dependencies.js +200 -146
- package/src/domain/analysis/exports.js +3 -2
- package/src/domain/analysis/impact.js +267 -152
- package/src/domain/analysis/module-map.js +247 -221
- package/src/domain/analysis/roles.js +8 -5
- package/src/domain/analysis/symbol-lookup.js +7 -5
- package/src/domain/graph/builder/helpers.js +1 -1
- package/src/domain/graph/builder/incremental.js +116 -90
- package/src/domain/graph/builder/pipeline.js +106 -80
- package/src/domain/graph/builder/stages/build-edges.js +318 -239
- package/src/domain/graph/builder/stages/detect-changes.js +198 -177
- package/src/domain/graph/builder/stages/insert-nodes.js +147 -139
- package/src/domain/graph/watcher.js +2 -2
- package/src/domain/parser.js +20 -11
- package/src/domain/queries.js +1 -0
- package/src/domain/search/search/filters.js +9 -5
- package/src/domain/search/search/keyword.js +12 -5
- package/src/domain/search/search/prepare.js +13 -5
- package/src/extractors/csharp.js +224 -207
- package/src/extractors/go.js +176 -172
- package/src/extractors/hcl.js +94 -78
- package/src/extractors/java.js +213 -207
- package/src/extractors/javascript.js +274 -304
- package/src/extractors/php.js +234 -221
- package/src/extractors/python.js +252 -250
- package/src/extractors/ruby.js +192 -185
- package/src/extractors/rust.js +182 -167
- package/src/features/ast.js +5 -3
- package/src/features/audit.js +4 -2
- package/src/features/boundaries.js +98 -83
- package/src/features/cfg.js +134 -143
- package/src/features/communities.js +68 -53
- package/src/features/complexity.js +143 -132
- package/src/features/dataflow.js +146 -149
- package/src/features/export.js +3 -3
- package/src/features/graph-enrichment.js +2 -2
- package/src/features/manifesto.js +9 -6
- package/src/features/owners.js +4 -3
- package/src/features/sequence.js +152 -141
- package/src/features/shared/find-nodes.js +31 -0
- package/src/features/structure.js +130 -99
- package/src/features/triage.js +83 -68
- package/src/graph/classifiers/risk.js +3 -2
- package/src/graph/classifiers/roles.js +6 -3
- package/src/index.js +1 -0
- package/src/mcp/server.js +65 -56
- package/src/mcp/tool-registry.js +13 -0
- package/src/mcp/tools/brief.js +8 -0
- package/src/mcp/tools/index.js +2 -0
- package/src/presentation/brief.js +51 -0
- package/src/presentation/queries-cli/exports.js +21 -14
- package/src/presentation/queries-cli/impact.js +55 -39
- package/src/presentation/queries-cli/inspect.js +184 -189
- package/src/presentation/queries-cli/overview.js +57 -58
- package/src/presentation/queries-cli/path.js +36 -29
- package/src/presentation/table.js +0 -8
- package/src/shared/generators.js +7 -3
- package/src/shared/kinds.js +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { openReadonlyOrFail, testFilterSQL } from '../../db/index.js';
|
|
3
|
+
import { debug } from '../../infrastructure/logger.js';
|
|
3
4
|
import { isTestFile } from '../../infrastructure/test-filter.js';
|
|
4
5
|
import { findCycles } from '../graph/cycles.js';
|
|
5
6
|
import { LANGUAGE_REGISTRY } from '../parser.js';
|
|
@@ -36,6 +37,241 @@ export const FALSE_POSITIVE_NAMES = new Set([
|
|
|
36
37
|
]);
|
|
37
38
|
export const FALSE_POSITIVE_CALLER_THRESHOLD = 20;
|
|
38
39
|
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Section helpers
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
function buildTestFileIds(db) {
|
|
45
|
+
const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
|
|
46
|
+
const testFileIds = new Set();
|
|
47
|
+
const testFiles = new Set();
|
|
48
|
+
for (const n of allFileNodes) {
|
|
49
|
+
if (isTestFile(n.file)) {
|
|
50
|
+
testFileIds.add(n.id);
|
|
51
|
+
testFiles.add(n.file);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const allNodes = db.prepare('SELECT id, file FROM nodes').all();
|
|
55
|
+
for (const n of allNodes) {
|
|
56
|
+
if (testFiles.has(n.file)) testFileIds.add(n.id);
|
|
57
|
+
}
|
|
58
|
+
return testFileIds;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function countNodesByKind(db, testFileIds) {
|
|
62
|
+
let nodeRows;
|
|
63
|
+
if (testFileIds) {
|
|
64
|
+
const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
|
|
65
|
+
const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
|
|
66
|
+
const counts = {};
|
|
67
|
+
for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
|
|
68
|
+
nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
|
|
69
|
+
} else {
|
|
70
|
+
nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
|
|
71
|
+
}
|
|
72
|
+
const byKind = {};
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const r of nodeRows) {
|
|
75
|
+
byKind[r.kind] = r.c;
|
|
76
|
+
total += r.c;
|
|
77
|
+
}
|
|
78
|
+
return { total, byKind };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function countEdgesByKind(db, testFileIds) {
|
|
82
|
+
let edgeRows;
|
|
83
|
+
if (testFileIds) {
|
|
84
|
+
const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
|
|
85
|
+
const filtered = allEdges.filter(
|
|
86
|
+
(e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
|
|
87
|
+
);
|
|
88
|
+
const counts = {};
|
|
89
|
+
for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
|
|
90
|
+
edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
|
|
91
|
+
} else {
|
|
92
|
+
edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
|
|
93
|
+
}
|
|
94
|
+
const byKind = {};
|
|
95
|
+
let total = 0;
|
|
96
|
+
for (const r of edgeRows) {
|
|
97
|
+
byKind[r.kind] = r.c;
|
|
98
|
+
total += r.c;
|
|
99
|
+
}
|
|
100
|
+
return { total, byKind };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function countFilesByLanguage(db, noTests) {
|
|
104
|
+
const extToLang = new Map();
|
|
105
|
+
for (const entry of LANGUAGE_REGISTRY) {
|
|
106
|
+
for (const ext of entry.extensions) {
|
|
107
|
+
extToLang.set(ext, entry.id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
|
|
111
|
+
if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
|
|
112
|
+
const byLanguage = {};
|
|
113
|
+
for (const row of fileNodes) {
|
|
114
|
+
const ext = path.extname(row.file).toLowerCase();
|
|
115
|
+
const lang = extToLang.get(ext) || 'other';
|
|
116
|
+
byLanguage[lang] = (byLanguage[lang] || 0) + 1;
|
|
117
|
+
}
|
|
118
|
+
return { total: fileNodes.length, languages: Object.keys(byLanguage).length, byLanguage };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function findHotspots(db, noTests, limit) {
|
|
122
|
+
const testFilter = testFilterSQL('n.file', noTests);
|
|
123
|
+
const hotspotRows = db
|
|
124
|
+
.prepare(`
|
|
125
|
+
SELECT n.file,
|
|
126
|
+
(SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
|
|
127
|
+
(SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
|
|
128
|
+
FROM nodes n
|
|
129
|
+
WHERE n.kind = 'file' ${testFilter}
|
|
130
|
+
ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
|
|
131
|
+
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
|
|
132
|
+
`)
|
|
133
|
+
.all();
|
|
134
|
+
const filtered = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
|
|
135
|
+
return filtered.slice(0, limit).map((r) => ({
|
|
136
|
+
file: r.file,
|
|
137
|
+
fanIn: r.fan_in,
|
|
138
|
+
fanOut: r.fan_out,
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function getEmbeddingsInfo(db) {
|
|
143
|
+
try {
|
|
144
|
+
const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
|
|
145
|
+
if (count && count.c > 0) {
|
|
146
|
+
const meta = {};
|
|
147
|
+
const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
|
|
148
|
+
for (const r of metaRows) meta[r.key] = r.value;
|
|
149
|
+
return {
|
|
150
|
+
count: count.c,
|
|
151
|
+
model: meta.model || null,
|
|
152
|
+
dim: meta.dim ? parseInt(meta.dim, 10) : null,
|
|
153
|
+
builtAt: meta.built_at || null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
debug(`embeddings lookup skipped: ${e.message}`);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function computeQualityMetrics(db, testFilter) {
|
|
163
|
+
const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
|
|
164
|
+
|
|
165
|
+
const totalCallable = db
|
|
166
|
+
.prepare(
|
|
167
|
+
`SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
|
|
168
|
+
)
|
|
169
|
+
.get().c;
|
|
170
|
+
const callableWithCallers = db
|
|
171
|
+
.prepare(`
|
|
172
|
+
SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
|
|
173
|
+
JOIN nodes n ON e.target_id = n.id
|
|
174
|
+
WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
|
|
175
|
+
`)
|
|
176
|
+
.get().c;
|
|
177
|
+
const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
|
|
178
|
+
|
|
179
|
+
const totalCallEdges = db.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'").get().c;
|
|
180
|
+
const highConfCallEdges = db
|
|
181
|
+
.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
|
|
182
|
+
.get().c;
|
|
183
|
+
const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
|
|
184
|
+
|
|
185
|
+
const fpRows = db
|
|
186
|
+
.prepare(`
|
|
187
|
+
SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
|
|
188
|
+
FROM nodes n
|
|
189
|
+
LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
|
|
190
|
+
WHERE n.kind IN ('function', 'method')
|
|
191
|
+
GROUP BY n.id
|
|
192
|
+
HAVING caller_count > ?
|
|
193
|
+
ORDER BY caller_count DESC
|
|
194
|
+
`)
|
|
195
|
+
.all(FALSE_POSITIVE_CALLER_THRESHOLD);
|
|
196
|
+
const falsePositiveWarnings = fpRows
|
|
197
|
+
.filter((r) =>
|
|
198
|
+
FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
|
|
199
|
+
)
|
|
200
|
+
.map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
|
|
201
|
+
|
|
202
|
+
let fpEdgeCount = 0;
|
|
203
|
+
for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
|
|
204
|
+
const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
|
|
205
|
+
|
|
206
|
+
const score = Math.round(
|
|
207
|
+
callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
score,
|
|
212
|
+
callerCoverage: {
|
|
213
|
+
ratio: callerCoverage,
|
|
214
|
+
covered: callableWithCallers,
|
|
215
|
+
total: totalCallable,
|
|
216
|
+
},
|
|
217
|
+
callConfidence: {
|
|
218
|
+
ratio: callConfidence,
|
|
219
|
+
highConf: highConfCallEdges,
|
|
220
|
+
total: totalCallEdges,
|
|
221
|
+
},
|
|
222
|
+
falsePositiveWarnings,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function countRoles(db, noTests) {
|
|
227
|
+
let roleRows;
|
|
228
|
+
if (noTests) {
|
|
229
|
+
const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
|
|
230
|
+
const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
|
|
231
|
+
const counts = {};
|
|
232
|
+
for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
|
|
233
|
+
roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
|
|
234
|
+
} else {
|
|
235
|
+
roleRows = db
|
|
236
|
+
.prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
|
|
237
|
+
.all();
|
|
238
|
+
}
|
|
239
|
+
const roles = {};
|
|
240
|
+
for (const r of roleRows) roles[r.role] = r.c;
|
|
241
|
+
return roles;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getComplexitySummary(db, testFilter) {
|
|
245
|
+
try {
|
|
246
|
+
const cRows = db
|
|
247
|
+
.prepare(
|
|
248
|
+
`SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
|
|
249
|
+
FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
|
|
250
|
+
WHERE n.kind IN ('function','method') ${testFilter}`,
|
|
251
|
+
)
|
|
252
|
+
.all();
|
|
253
|
+
if (cRows.length > 0) {
|
|
254
|
+
const miValues = cRows.map((r) => r.maintainability_index || 0);
|
|
255
|
+
return {
|
|
256
|
+
analyzed: cRows.length,
|
|
257
|
+
avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
|
|
258
|
+
avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
|
|
259
|
+
maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
|
|
260
|
+
maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
|
|
261
|
+
avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
|
|
262
|
+
minMI: +Math.min(...miValues).toFixed(1),
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
debug(`complexity summary skipped: ${e.message}`);
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
// Public API
|
|
273
|
+
// ---------------------------------------------------------------------------
|
|
274
|
+
|
|
39
275
|
export function moduleMapData(customDbPath, limit = 20, opts = {}) {
|
|
40
276
|
const db = openReadonlyOrFail(customDbPath);
|
|
41
277
|
try {
|
|
@@ -78,237 +314,27 @@ export function statsData(customDbPath, opts = {}) {
|
|
|
78
314
|
const db = openReadonlyOrFail(customDbPath);
|
|
79
315
|
try {
|
|
80
316
|
const noTests = opts.noTests || false;
|
|
317
|
+
const testFilter = testFilterSQL('n.file', noTests);
|
|
81
318
|
|
|
82
|
-
|
|
83
|
-
let testFileIds = null;
|
|
84
|
-
if (noTests) {
|
|
85
|
-
const allFileNodes = db.prepare("SELECT id, file FROM nodes WHERE kind = 'file'").all();
|
|
86
|
-
testFileIds = new Set();
|
|
87
|
-
const testFiles = new Set();
|
|
88
|
-
for (const n of allFileNodes) {
|
|
89
|
-
if (isTestFile(n.file)) {
|
|
90
|
-
testFileIds.add(n.id);
|
|
91
|
-
testFiles.add(n.file);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// Also collect non-file node IDs that belong to test files
|
|
96
|
-
const allNodes = db.prepare('SELECT id, file FROM nodes').all();
|
|
97
|
-
for (const n of allNodes) {
|
|
98
|
-
if (testFiles.has(n.file)) testFileIds.add(n.id);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Node breakdown by kind
|
|
103
|
-
let nodeRows;
|
|
104
|
-
if (noTests) {
|
|
105
|
-
const allNodes = db.prepare('SELECT id, kind, file FROM nodes').all();
|
|
106
|
-
const filtered = allNodes.filter((n) => !testFileIds.has(n.id));
|
|
107
|
-
const counts = {};
|
|
108
|
-
for (const n of filtered) counts[n.kind] = (counts[n.kind] || 0) + 1;
|
|
109
|
-
nodeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
|
|
110
|
-
} else {
|
|
111
|
-
nodeRows = db.prepare('SELECT kind, COUNT(*) as c FROM nodes GROUP BY kind').all();
|
|
112
|
-
}
|
|
113
|
-
const nodesByKind = {};
|
|
114
|
-
let totalNodes = 0;
|
|
115
|
-
for (const r of nodeRows) {
|
|
116
|
-
nodesByKind[r.kind] = r.c;
|
|
117
|
-
totalNodes += r.c;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Edge breakdown by kind
|
|
121
|
-
let edgeRows;
|
|
122
|
-
if (noTests) {
|
|
123
|
-
const allEdges = db.prepare('SELECT source_id, target_id, kind FROM edges').all();
|
|
124
|
-
const filtered = allEdges.filter(
|
|
125
|
-
(e) => !testFileIds.has(e.source_id) && !testFileIds.has(e.target_id),
|
|
126
|
-
);
|
|
127
|
-
const counts = {};
|
|
128
|
-
for (const e of filtered) counts[e.kind] = (counts[e.kind] || 0) + 1;
|
|
129
|
-
edgeRows = Object.entries(counts).map(([kind, c]) => ({ kind, c }));
|
|
130
|
-
} else {
|
|
131
|
-
edgeRows = db.prepare('SELECT kind, COUNT(*) as c FROM edges GROUP BY kind').all();
|
|
132
|
-
}
|
|
133
|
-
const edgesByKind = {};
|
|
134
|
-
let totalEdges = 0;
|
|
135
|
-
for (const r of edgeRows) {
|
|
136
|
-
edgesByKind[r.kind] = r.c;
|
|
137
|
-
totalEdges += r.c;
|
|
138
|
-
}
|
|
319
|
+
const testFileIds = noTests ? buildTestFileIds(db) : null;
|
|
139
320
|
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
for (const ext of entry.extensions) {
|
|
144
|
-
extToLang.set(ext, entry.id);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
let fileNodes = db.prepare("SELECT file FROM nodes WHERE kind = 'file'").all();
|
|
148
|
-
if (noTests) fileNodes = fileNodes.filter((n) => !isTestFile(n.file));
|
|
149
|
-
const byLanguage = {};
|
|
150
|
-
for (const row of fileNodes) {
|
|
151
|
-
const ext = path.extname(row.file).toLowerCase();
|
|
152
|
-
const lang = extToLang.get(ext) || 'other';
|
|
153
|
-
byLanguage[lang] = (byLanguage[lang] || 0) + 1;
|
|
154
|
-
}
|
|
155
|
-
const langCount = Object.keys(byLanguage).length;
|
|
321
|
+
const { total: totalNodes, byKind: nodesByKind } = countNodesByKind(db, testFileIds);
|
|
322
|
+
const { total: totalEdges, byKind: edgesByKind } = countEdgesByKind(db, testFileIds);
|
|
323
|
+
const files = countFilesByLanguage(db, noTests);
|
|
156
324
|
|
|
157
|
-
// Cycles
|
|
158
325
|
const fileCycles = findCycles(db, { fileLevel: true, noTests });
|
|
159
326
|
const fnCycles = findCycles(db, { fileLevel: false, noTests });
|
|
160
327
|
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
(SELECT COUNT(*) FROM edges WHERE target_id = n.id) as fan_in,
|
|
167
|
-
(SELECT COUNT(*) FROM edges WHERE source_id = n.id) as fan_out
|
|
168
|
-
FROM nodes n
|
|
169
|
-
WHERE n.kind = 'file' ${testFilter}
|
|
170
|
-
ORDER BY (SELECT COUNT(*) FROM edges WHERE target_id = n.id)
|
|
171
|
-
+ (SELECT COUNT(*) FROM edges WHERE source_id = n.id) DESC
|
|
172
|
-
`)
|
|
173
|
-
.all();
|
|
174
|
-
const filteredHotspots = noTests ? hotspotRows.filter((r) => !isTestFile(r.file)) : hotspotRows;
|
|
175
|
-
const hotspots = filteredHotspots.slice(0, 5).map((r) => ({
|
|
176
|
-
file: r.file,
|
|
177
|
-
fanIn: r.fan_in,
|
|
178
|
-
fanOut: r.fan_out,
|
|
179
|
-
}));
|
|
180
|
-
|
|
181
|
-
// Embeddings metadata
|
|
182
|
-
let embeddings = null;
|
|
183
|
-
try {
|
|
184
|
-
const count = db.prepare('SELECT COUNT(*) as c FROM embeddings').get();
|
|
185
|
-
if (count && count.c > 0) {
|
|
186
|
-
const meta = {};
|
|
187
|
-
const metaRows = db.prepare('SELECT key, value FROM embedding_meta').all();
|
|
188
|
-
for (const r of metaRows) meta[r.key] = r.value;
|
|
189
|
-
embeddings = {
|
|
190
|
-
count: count.c,
|
|
191
|
-
model: meta.model || null,
|
|
192
|
-
dim: meta.dim ? parseInt(meta.dim, 10) : null,
|
|
193
|
-
builtAt: meta.built_at || null,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
} catch {
|
|
197
|
-
/* embeddings table may not exist */
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Graph quality metrics
|
|
201
|
-
const qualityTestFilter = testFilter.replace(/n\.file/g, 'file');
|
|
202
|
-
const totalCallable = db
|
|
203
|
-
.prepare(
|
|
204
|
-
`SELECT COUNT(*) as c FROM nodes WHERE kind IN ('function', 'method') ${qualityTestFilter}`,
|
|
205
|
-
)
|
|
206
|
-
.get().c;
|
|
207
|
-
const callableWithCallers = db
|
|
208
|
-
.prepare(`
|
|
209
|
-
SELECT COUNT(DISTINCT e.target_id) as c FROM edges e
|
|
210
|
-
JOIN nodes n ON e.target_id = n.id
|
|
211
|
-
WHERE e.kind = 'calls' AND n.kind IN ('function', 'method') ${testFilter}
|
|
212
|
-
`)
|
|
213
|
-
.get().c;
|
|
214
|
-
const callerCoverage = totalCallable > 0 ? callableWithCallers / totalCallable : 0;
|
|
215
|
-
|
|
216
|
-
const totalCallEdges = db
|
|
217
|
-
.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls'")
|
|
218
|
-
.get().c;
|
|
219
|
-
const highConfCallEdges = db
|
|
220
|
-
.prepare("SELECT COUNT(*) as c FROM edges WHERE kind = 'calls' AND confidence >= 0.7")
|
|
221
|
-
.get().c;
|
|
222
|
-
const callConfidence = totalCallEdges > 0 ? highConfCallEdges / totalCallEdges : 0;
|
|
223
|
-
|
|
224
|
-
// False-positive warnings: generic names with > threshold callers
|
|
225
|
-
const fpRows = db
|
|
226
|
-
.prepare(`
|
|
227
|
-
SELECT n.name, n.file, n.line, COUNT(e.source_id) as caller_count
|
|
228
|
-
FROM nodes n
|
|
229
|
-
LEFT JOIN edges e ON n.id = e.target_id AND e.kind = 'calls'
|
|
230
|
-
WHERE n.kind IN ('function', 'method')
|
|
231
|
-
GROUP BY n.id
|
|
232
|
-
HAVING caller_count > ?
|
|
233
|
-
ORDER BY caller_count DESC
|
|
234
|
-
`)
|
|
235
|
-
.all(FALSE_POSITIVE_CALLER_THRESHOLD);
|
|
236
|
-
const falsePositiveWarnings = fpRows
|
|
237
|
-
.filter((r) =>
|
|
238
|
-
FALSE_POSITIVE_NAMES.has(r.name.includes('.') ? r.name.split('.').pop() : r.name),
|
|
239
|
-
)
|
|
240
|
-
.map((r) => ({ name: r.name, file: r.file, line: r.line, callerCount: r.caller_count }));
|
|
241
|
-
|
|
242
|
-
// Edges from suspicious nodes
|
|
243
|
-
let fpEdgeCount = 0;
|
|
244
|
-
for (const fp of falsePositiveWarnings) fpEdgeCount += fp.callerCount;
|
|
245
|
-
const falsePositiveRatio = totalCallEdges > 0 ? fpEdgeCount / totalCallEdges : 0;
|
|
246
|
-
|
|
247
|
-
const score = Math.round(
|
|
248
|
-
callerCoverage * 40 + callConfidence * 40 + (1 - falsePositiveRatio) * 20,
|
|
249
|
-
);
|
|
250
|
-
|
|
251
|
-
const quality = {
|
|
252
|
-
score,
|
|
253
|
-
callerCoverage: {
|
|
254
|
-
ratio: callerCoverage,
|
|
255
|
-
covered: callableWithCallers,
|
|
256
|
-
total: totalCallable,
|
|
257
|
-
},
|
|
258
|
-
callConfidence: {
|
|
259
|
-
ratio: callConfidence,
|
|
260
|
-
highConf: highConfCallEdges,
|
|
261
|
-
total: totalCallEdges,
|
|
262
|
-
},
|
|
263
|
-
falsePositiveWarnings,
|
|
264
|
-
};
|
|
265
|
-
|
|
266
|
-
// Role distribution
|
|
267
|
-
let roleRows;
|
|
268
|
-
if (noTests) {
|
|
269
|
-
const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
|
|
270
|
-
const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
|
|
271
|
-
const counts = {};
|
|
272
|
-
for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
|
|
273
|
-
roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
|
|
274
|
-
} else {
|
|
275
|
-
roleRows = db
|
|
276
|
-
.prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
|
|
277
|
-
.all();
|
|
278
|
-
}
|
|
279
|
-
const roles = {};
|
|
280
|
-
for (const r of roleRows) roles[r.role] = r.c;
|
|
281
|
-
|
|
282
|
-
// Complexity summary
|
|
283
|
-
let complexity = null;
|
|
284
|
-
try {
|
|
285
|
-
const cRows = db
|
|
286
|
-
.prepare(
|
|
287
|
-
`SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
|
|
288
|
-
FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
|
|
289
|
-
WHERE n.kind IN ('function','method') ${testFilter}`,
|
|
290
|
-
)
|
|
291
|
-
.all();
|
|
292
|
-
if (cRows.length > 0) {
|
|
293
|
-
const miValues = cRows.map((r) => r.maintainability_index || 0);
|
|
294
|
-
complexity = {
|
|
295
|
-
analyzed: cRows.length,
|
|
296
|
-
avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
|
|
297
|
-
avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
|
|
298
|
-
maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
|
|
299
|
-
maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
|
|
300
|
-
avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
|
|
301
|
-
minMI: +Math.min(...miValues).toFixed(1),
|
|
302
|
-
};
|
|
303
|
-
}
|
|
304
|
-
} catch {
|
|
305
|
-
/* table may not exist in older DBs */
|
|
306
|
-
}
|
|
328
|
+
const hotspots = findHotspots(db, noTests, 5);
|
|
329
|
+
const embeddings = getEmbeddingsInfo(db);
|
|
330
|
+
const quality = computeQualityMetrics(db, testFilter);
|
|
331
|
+
const roles = countRoles(db, noTests);
|
|
332
|
+
const complexity = getComplexitySummary(db, testFilter);
|
|
307
333
|
|
|
308
334
|
return {
|
|
309
335
|
nodes: { total: totalNodes, byKind: nodesByKind },
|
|
310
336
|
edges: { total: totalEdges, byKind: edgesByKind },
|
|
311
|
-
files
|
|
337
|
+
files,
|
|
312
338
|
cycles: { fileLevel: fileCycles.length, functionLevel: fnCycles.length },
|
|
313
339
|
hotspots,
|
|
314
340
|
embeddings,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { openReadonlyOrFail } from '../../db/index.js';
|
|
2
|
+
import { buildFileConditionSQL } from '../../db/query-builder.js';
|
|
2
3
|
import { isTestFile } from '../../infrastructure/test-filter.js';
|
|
3
4
|
import { normalizeSymbol } from '../../shared/normalize.js';
|
|
4
5
|
import { paginateResult } from '../../shared/paginate.js';
|
|
@@ -8,8 +9,6 @@ export function rolesData(customDbPath, opts = {}) {
|
|
|
8
9
|
try {
|
|
9
10
|
const noTests = opts.noTests || false;
|
|
10
11
|
const filterRole = opts.role || null;
|
|
11
|
-
const filterFile = opts.file || null;
|
|
12
|
-
|
|
13
12
|
const conditions = ['role IS NOT NULL'];
|
|
14
13
|
const params = [];
|
|
15
14
|
|
|
@@ -17,9 +16,13 @@ export function rolesData(customDbPath, opts = {}) {
|
|
|
17
16
|
conditions.push('role = ?');
|
|
18
17
|
params.push(filterRole);
|
|
19
18
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
{
|
|
20
|
+
const fc = buildFileConditionSQL(opts.file, 'file');
|
|
21
|
+
if (fc.sql) {
|
|
22
|
+
// Strip leading ' AND ' since we're using conditions array
|
|
23
|
+
conditions.push(fc.sql.replace(/^ AND /, ''));
|
|
24
|
+
params.push(...fc.params);
|
|
25
|
+
}
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
let rows = db
|
|
@@ -14,12 +14,13 @@ import {
|
|
|
14
14
|
openReadonlyOrFail,
|
|
15
15
|
Repository,
|
|
16
16
|
} from '../../db/index.js';
|
|
17
|
+
import { debug } from '../../infrastructure/logger.js';
|
|
17
18
|
import { isTestFile } from '../../infrastructure/test-filter.js';
|
|
18
|
-
import {
|
|
19
|
+
import { EVERY_SYMBOL_KIND } from '../../shared/kinds.js';
|
|
19
20
|
import { getFileHash, normalizeSymbol } from '../../shared/normalize.js';
|
|
20
21
|
import { paginateResult } from '../../shared/paginate.js';
|
|
21
22
|
|
|
22
|
-
const FUNCTION_KINDS = ['function', 'method', 'class'];
|
|
23
|
+
const FUNCTION_KINDS = ['function', 'method', 'class', 'constant'];
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Find nodes matching a name query, ranked by relevance.
|
|
@@ -109,12 +110,12 @@ export function queryNameData(name, customDbPath, opts = {}) {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
function whereSymbolImpl(db, target, noTests) {
|
|
112
|
-
const placeholders =
|
|
113
|
+
const placeholders = EVERY_SYMBOL_KIND.map(() => '?').join(', ');
|
|
113
114
|
let nodes = db
|
|
114
115
|
.prepare(
|
|
115
116
|
`SELECT * FROM nodes WHERE name LIKE ? AND kind IN (${placeholders}) ORDER BY file, line`,
|
|
116
117
|
)
|
|
117
|
-
.all(`%${target}%`, ...
|
|
118
|
+
.all(`%${target}%`, ...EVERY_SYMBOL_KIND);
|
|
118
119
|
if (noTests) nodes = nodes.filter((n) => !isTestFile(n.file));
|
|
119
120
|
|
|
120
121
|
const hc = new Map();
|
|
@@ -206,7 +207,8 @@ export function childrenData(name, customDbPath, opts = {}) {
|
|
|
206
207
|
let children;
|
|
207
208
|
try {
|
|
208
209
|
children = findNodeChildren(db, node.id);
|
|
209
|
-
} catch {
|
|
210
|
+
} catch (e) {
|
|
211
|
+
debug(`findNodeChildren failed for node ${node.id}: ${e.message}`);
|
|
210
212
|
children = [];
|
|
211
213
|
}
|
|
212
214
|
if (noTests) children = children.filter((c) => !isTestFile(c.file || node.file));
|
|
@@ -179,7 +179,7 @@ export function purgeFilesFromGraph(db, files, options = {}) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/** Batch INSERT chunk size for multi-value INSERTs. */
|
|
182
|
-
|
|
182
|
+
const BATCH_CHUNK = 200;
|
|
183
183
|
|
|
184
184
|
/**
|
|
185
185
|
* Batch-insert node rows via multi-value INSERT statements.
|