@optave/codegraph 2.5.0 → 2.6.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 +119 -47
- package/package.json +8 -7
- package/src/audit.js +423 -0
- package/src/batch.js +90 -0
- package/src/boundaries.js +346 -0
- package/src/branch-compare.js +568 -0
- package/src/builder.js +66 -2
- package/src/check.js +432 -0
- package/src/cli.js +375 -9
- package/src/cochange.js +5 -2
- package/src/communities.js +7 -1
- package/src/complexity.js +116 -9
- package/src/config.js +10 -0
- package/src/embedder.js +350 -38
- package/src/flow.js +4 -4
- package/src/index.js +28 -1
- package/src/manifesto.js +69 -1
- package/src/mcp.js +347 -19
- package/src/owners.js +359 -0
- package/src/paginate.js +35 -0
- package/src/queries.js +233 -19
- package/src/registry.js +6 -3
- package/src/snapshot.js +149 -0
- package/src/structure.js +5 -2
- package/src/triage.js +273 -0
package/src/triage.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { openReadonlyOrFail } from './db.js';
|
|
2
|
+
import { warn } from './logger.js';
|
|
3
|
+
import { paginateResult, printNdjson } from './paginate.js';
|
|
4
|
+
import { isTestFile } from './queries.js';
|
|
5
|
+
|
|
6
|
+
// ─── Constants ────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const DEFAULT_WEIGHTS = {
|
|
9
|
+
fanIn: 0.25,
|
|
10
|
+
complexity: 0.3,
|
|
11
|
+
churn: 0.2,
|
|
12
|
+
role: 0.15,
|
|
13
|
+
mi: 0.1,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const ROLE_WEIGHTS = {
|
|
17
|
+
core: 1.0,
|
|
18
|
+
utility: 0.9,
|
|
19
|
+
entry: 0.8,
|
|
20
|
+
adapter: 0.5,
|
|
21
|
+
leaf: 0.2,
|
|
22
|
+
dead: 0.1,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_ROLE_WEIGHT = 0.5;
|
|
26
|
+
|
|
27
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Min-max normalize an array of numbers. All-equal → all zeros. */
|
|
30
|
+
function minMaxNormalize(values) {
|
|
31
|
+
const min = Math.min(...values);
|
|
32
|
+
const max = Math.max(...values);
|
|
33
|
+
if (max === min) return values.map(() => 0);
|
|
34
|
+
const range = max - min;
|
|
35
|
+
return values.map((v) => (v - min) / range);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── Data Function ────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Compute composite risk scores for all symbols.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} [customDbPath] - Path to graph.db
|
|
44
|
+
* @param {object} [opts]
|
|
45
|
+
* @returns {{ items: object[], summary: object, _pagination?: object }}
|
|
46
|
+
*/
|
|
47
|
+
export function triageData(customDbPath, opts = {}) {
|
|
48
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
49
|
+
const noTests = opts.noTests || false;
|
|
50
|
+
const fileFilter = opts.file || null;
|
|
51
|
+
const kindFilter = opts.kind || null;
|
|
52
|
+
const roleFilter = opts.role || null;
|
|
53
|
+
const minScore = opts.minScore != null ? Number(opts.minScore) : null;
|
|
54
|
+
const sort = opts.sort || 'risk';
|
|
55
|
+
const weights = { ...DEFAULT_WEIGHTS, ...(opts.weights || {}) };
|
|
56
|
+
|
|
57
|
+
// Build WHERE clause
|
|
58
|
+
let where = "WHERE n.kind IN ('function','method','class')";
|
|
59
|
+
const params = [];
|
|
60
|
+
|
|
61
|
+
if (noTests) {
|
|
62
|
+
where += ` AND n.file NOT LIKE '%.test.%'
|
|
63
|
+
AND n.file NOT LIKE '%.spec.%'
|
|
64
|
+
AND n.file NOT LIKE '%__test__%'
|
|
65
|
+
AND n.file NOT LIKE '%__tests__%'
|
|
66
|
+
AND n.file NOT LIKE '%.stories.%'`;
|
|
67
|
+
}
|
|
68
|
+
if (fileFilter) {
|
|
69
|
+
where += ' AND n.file LIKE ?';
|
|
70
|
+
params.push(`%${fileFilter}%`);
|
|
71
|
+
}
|
|
72
|
+
if (kindFilter) {
|
|
73
|
+
where += ' AND n.kind = ?';
|
|
74
|
+
params.push(kindFilter);
|
|
75
|
+
}
|
|
76
|
+
if (roleFilter) {
|
|
77
|
+
where += ' AND n.role = ?';
|
|
78
|
+
params.push(roleFilter);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let rows;
|
|
82
|
+
try {
|
|
83
|
+
rows = db
|
|
84
|
+
.prepare(
|
|
85
|
+
`SELECT n.id, n.name, n.kind, n.file, n.line, n.end_line, n.role,
|
|
86
|
+
COALESCE(fi.cnt, 0) AS fan_in,
|
|
87
|
+
COALESCE(fc.cognitive, 0) AS cognitive,
|
|
88
|
+
COALESCE(fc.maintainability_index, 0) AS mi,
|
|
89
|
+
COALESCE(fc.cyclomatic, 0) AS cyclomatic,
|
|
90
|
+
COALESCE(fc.max_nesting, 0) AS max_nesting,
|
|
91
|
+
COALESCE(fcc.commit_count, 0) AS churn
|
|
92
|
+
FROM nodes n
|
|
93
|
+
LEFT JOIN (SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind='calls' GROUP BY target_id) fi
|
|
94
|
+
ON n.id = fi.target_id
|
|
95
|
+
LEFT JOIN function_complexity fc ON fc.node_id = n.id
|
|
96
|
+
LEFT JOIN file_commit_counts fcc ON n.file = fcc.file
|
|
97
|
+
${where}
|
|
98
|
+
ORDER BY n.file, n.line`,
|
|
99
|
+
)
|
|
100
|
+
.all(...params);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
warn(`triage query failed: ${err.message}`);
|
|
103
|
+
db.close();
|
|
104
|
+
return {
|
|
105
|
+
items: [],
|
|
106
|
+
summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} },
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Post-filter test files (belt-and-suspenders)
|
|
111
|
+
const filtered = noTests ? rows.filter((r) => !isTestFile(r.file)) : rows;
|
|
112
|
+
|
|
113
|
+
if (filtered.length === 0) {
|
|
114
|
+
db.close();
|
|
115
|
+
return {
|
|
116
|
+
items: [],
|
|
117
|
+
summary: { total: 0, analyzed: 0, avgScore: 0, maxScore: 0, weights, signalCoverage: {} },
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Extract raw signal arrays
|
|
122
|
+
const fanIns = filtered.map((r) => r.fan_in);
|
|
123
|
+
const cognitives = filtered.map((r) => r.cognitive);
|
|
124
|
+
const churns = filtered.map((r) => r.churn);
|
|
125
|
+
const mis = filtered.map((r) => r.mi);
|
|
126
|
+
|
|
127
|
+
// Min-max normalize
|
|
128
|
+
const normFanIns = minMaxNormalize(fanIns);
|
|
129
|
+
const normCognitives = minMaxNormalize(cognitives);
|
|
130
|
+
const normChurns = minMaxNormalize(churns);
|
|
131
|
+
// MI: higher is better, so invert: 1 - norm(mi)
|
|
132
|
+
const normMIsRaw = minMaxNormalize(mis);
|
|
133
|
+
const normMIs = normMIsRaw.map((v) => round4(1 - v));
|
|
134
|
+
|
|
135
|
+
// Compute risk scores
|
|
136
|
+
const items = filtered.map((r, i) => {
|
|
137
|
+
const roleWeight = ROLE_WEIGHTS[r.role] ?? DEFAULT_ROLE_WEIGHT;
|
|
138
|
+
const riskScore =
|
|
139
|
+
weights.fanIn * normFanIns[i] +
|
|
140
|
+
weights.complexity * normCognitives[i] +
|
|
141
|
+
weights.churn * normChurns[i] +
|
|
142
|
+
weights.role * roleWeight +
|
|
143
|
+
weights.mi * normMIs[i];
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
name: r.name,
|
|
147
|
+
kind: r.kind,
|
|
148
|
+
file: r.file,
|
|
149
|
+
line: r.line,
|
|
150
|
+
role: r.role || null,
|
|
151
|
+
fanIn: r.fan_in,
|
|
152
|
+
cognitive: r.cognitive,
|
|
153
|
+
churn: r.churn,
|
|
154
|
+
maintainabilityIndex: r.mi,
|
|
155
|
+
normFanIn: round4(normFanIns[i]),
|
|
156
|
+
normComplexity: round4(normCognitives[i]),
|
|
157
|
+
normChurn: round4(normChurns[i]),
|
|
158
|
+
normMI: round4(normMIs[i]),
|
|
159
|
+
roleWeight,
|
|
160
|
+
riskScore: round4(riskScore),
|
|
161
|
+
};
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Apply minScore filter
|
|
165
|
+
const scored = minScore != null ? items.filter((it) => it.riskScore >= minScore) : items;
|
|
166
|
+
|
|
167
|
+
// Sort
|
|
168
|
+
const sortFns = {
|
|
169
|
+
risk: (a, b) => b.riskScore - a.riskScore,
|
|
170
|
+
complexity: (a, b) => b.cognitive - a.cognitive,
|
|
171
|
+
churn: (a, b) => b.churn - a.churn,
|
|
172
|
+
'fan-in': (a, b) => b.fanIn - a.fanIn,
|
|
173
|
+
mi: (a, b) => a.maintainabilityIndex - b.maintainabilityIndex,
|
|
174
|
+
};
|
|
175
|
+
scored.sort(sortFns[sort] || sortFns.risk);
|
|
176
|
+
|
|
177
|
+
// Signal coverage: % of items with non-zero signal
|
|
178
|
+
const signalCoverage = {
|
|
179
|
+
complexity: round4(filtered.filter((r) => r.cognitive > 0).length / filtered.length),
|
|
180
|
+
churn: round4(filtered.filter((r) => r.churn > 0).length / filtered.length),
|
|
181
|
+
fanIn: round4(filtered.filter((r) => r.fan_in > 0).length / filtered.length),
|
|
182
|
+
mi: round4(filtered.filter((r) => r.mi > 0).length / filtered.length),
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const scores = scored.map((it) => it.riskScore);
|
|
186
|
+
const avgScore =
|
|
187
|
+
scores.length > 0 ? round4(scores.reduce((a, b) => a + b, 0) / scores.length) : 0;
|
|
188
|
+
const maxScore = scores.length > 0 ? round4(Math.max(...scores)) : 0;
|
|
189
|
+
|
|
190
|
+
const result = {
|
|
191
|
+
items: scored,
|
|
192
|
+
summary: {
|
|
193
|
+
total: filtered.length,
|
|
194
|
+
analyzed: scored.length,
|
|
195
|
+
avgScore,
|
|
196
|
+
maxScore,
|
|
197
|
+
weights,
|
|
198
|
+
signalCoverage,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
db.close();
|
|
203
|
+
|
|
204
|
+
return paginateResult(result, 'items', {
|
|
205
|
+
limit: opts.limit,
|
|
206
|
+
offset: opts.offset,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── CLI Formatter ────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Print triage results to console.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} [customDbPath]
|
|
216
|
+
* @param {object} [opts]
|
|
217
|
+
*/
|
|
218
|
+
export function triage(customDbPath, opts = {}) {
|
|
219
|
+
const data = triageData(customDbPath, opts);
|
|
220
|
+
|
|
221
|
+
if (opts.ndjson) {
|
|
222
|
+
printNdjson(data, 'items');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (opts.json) {
|
|
226
|
+
console.log(JSON.stringify(data, null, 2));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (data.items.length === 0) {
|
|
231
|
+
if (data.summary.total === 0) {
|
|
232
|
+
console.log('\nNo symbols found. Run "codegraph build" first.\n');
|
|
233
|
+
} else {
|
|
234
|
+
console.log('\nNo symbols match the given filters.\n');
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
console.log('\n# Risk Audit Queue\n');
|
|
240
|
+
|
|
241
|
+
console.log(
|
|
242
|
+
` ${'Symbol'.padEnd(35)} ${'File'.padEnd(28)} ${'Role'.padEnd(8)} ${'Score'.padStart(6)} ${'Fan-In'.padStart(7)} ${'Cog'.padStart(4)} ${'Churn'.padStart(6)} ${'MI'.padStart(5)}`,
|
|
243
|
+
);
|
|
244
|
+
console.log(
|
|
245
|
+
` ${'─'.repeat(35)} ${'─'.repeat(28)} ${'─'.repeat(8)} ${'─'.repeat(6)} ${'─'.repeat(7)} ${'─'.repeat(4)} ${'─'.repeat(6)} ${'─'.repeat(5)}`,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
for (const it of data.items) {
|
|
249
|
+
const name = it.name.length > 33 ? `${it.name.slice(0, 32)}…` : it.name;
|
|
250
|
+
const file = it.file.length > 26 ? `…${it.file.slice(-25)}` : it.file;
|
|
251
|
+
const role = (it.role || '-').padEnd(8);
|
|
252
|
+
const score = it.riskScore.toFixed(2).padStart(6);
|
|
253
|
+
const fanIn = String(it.fanIn).padStart(7);
|
|
254
|
+
const cog = String(it.cognitive).padStart(4);
|
|
255
|
+
const churn = String(it.churn).padStart(6);
|
|
256
|
+
const mi = it.maintainabilityIndex > 0 ? String(it.maintainabilityIndex).padStart(5) : ' -';
|
|
257
|
+
console.log(
|
|
258
|
+
` ${name.padEnd(35)} ${file.padEnd(28)} ${role} ${score} ${fanIn} ${cog} ${churn} ${mi}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const s = data.summary;
|
|
263
|
+
console.log(
|
|
264
|
+
`\n ${s.analyzed} symbols scored (of ${s.total} total) | avg: ${s.avgScore.toFixed(2)} | max: ${s.maxScore.toFixed(2)} | sort: ${opts.sort || 'risk'}`,
|
|
265
|
+
);
|
|
266
|
+
console.log();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Utilities ────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function round4(n) {
|
|
272
|
+
return Math.round(n * 10000) / 10000;
|
|
273
|
+
}
|