@optave/codegraph 2.3.0 → 2.3.1-dev.1aeea34
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 +40 -14
- package/package.json +5 -5
- package/src/builder.js +66 -0
- package/src/cli.js +113 -9
- package/src/cochange.js +498 -0
- package/src/config.js +6 -0
- package/src/db.js +40 -0
- package/src/embedder.js +53 -2
- package/src/export.js +158 -13
- package/src/extractors/helpers.js +2 -1
- package/src/extractors/javascript.js +294 -78
- package/src/index.js +13 -0
- package/src/mcp.js +62 -1
- package/src/parser.js +39 -2
- package/src/queries.js +158 -9
- package/src/registry.js +9 -1
- package/src/structure.js +94 -0
package/src/parser.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { Language, Parser } from 'web-tree-sitter';
|
|
4
|
+
import { Language, Parser, Query } from 'web-tree-sitter';
|
|
5
5
|
import { warn } from './logger.js';
|
|
6
6
|
import { getNative, loadNative } from './native.js';
|
|
7
7
|
|
|
@@ -38,6 +38,33 @@ function grammarPath(name) {
|
|
|
38
38
|
|
|
39
39
|
let _initialized = false;
|
|
40
40
|
|
|
41
|
+
// Query cache for JS/TS/TSX extractors (populated during createParsers)
|
|
42
|
+
const _queryCache = new Map();
|
|
43
|
+
|
|
44
|
+
// Shared patterns for all JS/TS/TSX (class_declaration excluded — name type differs)
|
|
45
|
+
const COMMON_QUERY_PATTERNS = [
|
|
46
|
+
'(function_declaration name: (identifier) @fn_name) @fn_node',
|
|
47
|
+
'(variable_declarator name: (identifier) @varfn_name value: (arrow_function) @varfn_value)',
|
|
48
|
+
'(variable_declarator name: (identifier) @varfn_name value: (function_expression) @varfn_value)',
|
|
49
|
+
'(method_definition name: (property_identifier) @meth_name) @meth_node',
|
|
50
|
+
'(import_statement source: (string) @imp_source) @imp_node',
|
|
51
|
+
'(export_statement) @exp_node',
|
|
52
|
+
'(call_expression function: (identifier) @callfn_name) @callfn_node',
|
|
53
|
+
'(call_expression function: (member_expression) @callmem_fn) @callmem_node',
|
|
54
|
+
'(call_expression function: (subscript_expression) @callsub_fn) @callsub_node',
|
|
55
|
+
'(expression_statement (assignment_expression left: (member_expression) @assign_left right: (_) @assign_right)) @assign_node',
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// JS: class name is (identifier)
|
|
59
|
+
const JS_CLASS_PATTERN = '(class_declaration name: (identifier) @cls_name) @cls_node';
|
|
60
|
+
|
|
61
|
+
// TS/TSX: class name is (type_identifier), plus interface and type alias
|
|
62
|
+
const TS_EXTRA_PATTERNS = [
|
|
63
|
+
'(class_declaration name: (type_identifier) @cls_name) @cls_node',
|
|
64
|
+
'(interface_declaration name: (type_identifier) @iface_name) @iface_node',
|
|
65
|
+
'(type_alias_declaration name: (type_identifier) @type_name) @type_node',
|
|
66
|
+
];
|
|
67
|
+
|
|
41
68
|
export async function createParsers() {
|
|
42
69
|
if (!_initialized) {
|
|
43
70
|
await Parser.init();
|
|
@@ -51,6 +78,14 @@ export async function createParsers() {
|
|
|
51
78
|
const parser = new Parser();
|
|
52
79
|
parser.setLanguage(lang);
|
|
53
80
|
parsers.set(entry.id, parser);
|
|
81
|
+
// Compile and cache tree-sitter Query for JS/TS/TSX extractors
|
|
82
|
+
if (entry.extractor === extractSymbols && !_queryCache.has(entry.id)) {
|
|
83
|
+
const isTS = entry.id === 'typescript' || entry.id === 'tsx';
|
|
84
|
+
const patterns = isTS
|
|
85
|
+
? [...COMMON_QUERY_PATTERNS, ...TS_EXTRA_PATTERNS]
|
|
86
|
+
: [...COMMON_QUERY_PATTERNS, JS_CLASS_PATTERN];
|
|
87
|
+
_queryCache.set(entry.id, new Query(lang, patterns.join('\n')));
|
|
88
|
+
}
|
|
54
89
|
} catch (e) {
|
|
55
90
|
if (entry.required) throw e;
|
|
56
91
|
warn(
|
|
@@ -242,7 +277,9 @@ function wasmExtractSymbols(parsers, filePath, code) {
|
|
|
242
277
|
|
|
243
278
|
const ext = path.extname(filePath);
|
|
244
279
|
const entry = _extToLang.get(ext);
|
|
245
|
-
|
|
280
|
+
if (!entry) return null;
|
|
281
|
+
const query = _queryCache.get(entry.id) || null;
|
|
282
|
+
return entry.extractor(tree, filePath, query);
|
|
246
283
|
}
|
|
247
284
|
|
|
248
285
|
/**
|
package/src/queries.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { coChangeForFiles } from './cochange.js';
|
|
4
5
|
import { findCycles } from './cycles.js';
|
|
5
6
|
import { findDbPath, openReadonlyOrFail } from './db.js';
|
|
6
7
|
import { debug } from './logger.js';
|
|
@@ -67,6 +68,8 @@ export const ALL_SYMBOL_KINDS = [
|
|
|
67
68
|
'module',
|
|
68
69
|
];
|
|
69
70
|
|
|
71
|
+
export const VALID_ROLES = ['entry', 'core', 'utility', 'adapter', 'dead', 'leaf'];
|
|
72
|
+
|
|
70
73
|
/**
|
|
71
74
|
* Get all ancestor class names for a given class using extends edges.
|
|
72
75
|
*/
|
|
@@ -728,16 +731,34 @@ export function diffImpactData(customDbPath, opts = {}) {
|
|
|
728
731
|
const affectedFiles = new Set();
|
|
729
732
|
for (const key of allAffected) affectedFiles.add(key.split(':')[0]);
|
|
730
733
|
|
|
734
|
+
// Look up historically coupled files from co-change data
|
|
735
|
+
let historicallyCoupled = [];
|
|
736
|
+
try {
|
|
737
|
+
db.prepare('SELECT 1 FROM co_changes LIMIT 1').get();
|
|
738
|
+
const changedFilesList = [...changedRanges.keys()];
|
|
739
|
+
const coResults = coChangeForFiles(changedFilesList, db, {
|
|
740
|
+
minJaccard: 0.3,
|
|
741
|
+
limit: 20,
|
|
742
|
+
noTests,
|
|
743
|
+
});
|
|
744
|
+
// Exclude files already found via static analysis
|
|
745
|
+
historicallyCoupled = coResults.filter((r) => !affectedFiles.has(r.file));
|
|
746
|
+
} catch {
|
|
747
|
+
/* co_changes table doesn't exist — skip silently */
|
|
748
|
+
}
|
|
749
|
+
|
|
731
750
|
db.close();
|
|
732
751
|
return {
|
|
733
752
|
changedFiles: changedRanges.size,
|
|
734
753
|
newFiles: [...newFiles],
|
|
735
754
|
affectedFunctions: functionResults,
|
|
736
755
|
affectedFiles: [...affectedFiles],
|
|
756
|
+
historicallyCoupled,
|
|
737
757
|
summary: {
|
|
738
758
|
functionsChanged: affectedFunctions.length,
|
|
739
759
|
callersAffected: allAffected.size,
|
|
740
760
|
filesAffected: affectedFiles.size,
|
|
761
|
+
historicallyCoupledCount: historicallyCoupled.length,
|
|
741
762
|
},
|
|
742
763
|
};
|
|
743
764
|
}
|
|
@@ -876,7 +897,7 @@ export function listFunctionsData(customDbPath, opts = {}) {
|
|
|
876
897
|
|
|
877
898
|
let rows = db
|
|
878
899
|
.prepare(
|
|
879
|
-
`SELECT name, kind, file, line FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
|
|
900
|
+
`SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY file, line`,
|
|
880
901
|
)
|
|
881
902
|
.all(...params);
|
|
882
903
|
|
|
@@ -1077,6 +1098,22 @@ export function statsData(customDbPath, opts = {}) {
|
|
|
1077
1098
|
falsePositiveWarnings,
|
|
1078
1099
|
};
|
|
1079
1100
|
|
|
1101
|
+
// Role distribution
|
|
1102
|
+
let roleRows;
|
|
1103
|
+
if (noTests) {
|
|
1104
|
+
const allRoleNodes = db.prepare('SELECT role, file FROM nodes WHERE role IS NOT NULL').all();
|
|
1105
|
+
const filtered = allRoleNodes.filter((n) => !isTestFile(n.file));
|
|
1106
|
+
const counts = {};
|
|
1107
|
+
for (const n of filtered) counts[n.role] = (counts[n.role] || 0) + 1;
|
|
1108
|
+
roleRows = Object.entries(counts).map(([role, c]) => ({ role, c }));
|
|
1109
|
+
} else {
|
|
1110
|
+
roleRows = db
|
|
1111
|
+
.prepare('SELECT role, COUNT(*) as c FROM nodes WHERE role IS NOT NULL GROUP BY role')
|
|
1112
|
+
.all();
|
|
1113
|
+
}
|
|
1114
|
+
const roles = {};
|
|
1115
|
+
for (const r of roleRows) roles[r.role] = r.c;
|
|
1116
|
+
|
|
1080
1117
|
db.close();
|
|
1081
1118
|
return {
|
|
1082
1119
|
nodes: { total: totalNodes, byKind: nodesByKind },
|
|
@@ -1086,6 +1123,7 @@ export function statsData(customDbPath, opts = {}) {
|
|
|
1086
1123
|
hotspots,
|
|
1087
1124
|
embeddings,
|
|
1088
1125
|
quality,
|
|
1126
|
+
roles,
|
|
1089
1127
|
};
|
|
1090
1128
|
}
|
|
1091
1129
|
|
|
@@ -1182,6 +1220,22 @@ export function stats(customDbPath, opts = {}) {
|
|
|
1182
1220
|
}
|
|
1183
1221
|
}
|
|
1184
1222
|
|
|
1223
|
+
// Roles
|
|
1224
|
+
if (data.roles && Object.keys(data.roles).length > 0) {
|
|
1225
|
+
const total = Object.values(data.roles).reduce((a, b) => a + b, 0);
|
|
1226
|
+
console.log(`\nRoles: ${total} classified symbols`);
|
|
1227
|
+
const roleParts = Object.entries(data.roles)
|
|
1228
|
+
.sort((a, b) => b[1] - a[1])
|
|
1229
|
+
.map(([k, v]) => `${k} ${v}`);
|
|
1230
|
+
for (let i = 0; i < roleParts.length; i += 3) {
|
|
1231
|
+
const row = roleParts
|
|
1232
|
+
.slice(i, i + 3)
|
|
1233
|
+
.map((p) => p.padEnd(18))
|
|
1234
|
+
.join('');
|
|
1235
|
+
console.log(` ${row}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1185
1239
|
console.log();
|
|
1186
1240
|
}
|
|
1187
1241
|
|
|
@@ -1649,6 +1703,7 @@ export function contextData(name, customDbPath, opts = {}) {
|
|
|
1649
1703
|
kind: node.kind,
|
|
1650
1704
|
file: node.file,
|
|
1651
1705
|
line: node.line,
|
|
1706
|
+
role: node.role || null,
|
|
1652
1707
|
endLine: node.end_line || null,
|
|
1653
1708
|
source,
|
|
1654
1709
|
signature,
|
|
@@ -1675,7 +1730,8 @@ export function context(name, customDbPath, opts = {}) {
|
|
|
1675
1730
|
|
|
1676
1731
|
for (const r of data.results) {
|
|
1677
1732
|
const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
|
|
1678
|
-
|
|
1733
|
+
const roleTag = r.role ? ` [${r.role}]` : '';
|
|
1734
|
+
console.log(`\n# ${r.name} (${r.kind})${roleTag} — ${r.file}:${lineRange}\n`);
|
|
1679
1735
|
|
|
1680
1736
|
// Signature
|
|
1681
1737
|
if (r.signature) {
|
|
@@ -1787,6 +1843,7 @@ function explainFileImpl(db, target, getFileLines) {
|
|
|
1787
1843
|
name: s.name,
|
|
1788
1844
|
kind: s.kind,
|
|
1789
1845
|
line: s.line,
|
|
1846
|
+
role: s.role || null,
|
|
1790
1847
|
summary: fileLines ? extractSummary(fileLines, s.line) : null,
|
|
1791
1848
|
signature: fileLines ? extractSignature(fileLines, s.line) : null,
|
|
1792
1849
|
});
|
|
@@ -1907,6 +1964,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
|
|
|
1907
1964
|
kind: node.kind,
|
|
1908
1965
|
file: node.file,
|
|
1909
1966
|
line: node.line,
|
|
1967
|
+
role: node.role || null,
|
|
1910
1968
|
endLine: node.end_line || null,
|
|
1911
1969
|
lineCount,
|
|
1912
1970
|
summary,
|
|
@@ -2018,8 +2076,9 @@ export function explain(target, customDbPath, opts = {}) {
|
|
|
2018
2076
|
console.log(`\n## Exported`);
|
|
2019
2077
|
for (const s of r.publicApi) {
|
|
2020
2078
|
const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
|
|
2079
|
+
const roleTag = s.role ? ` [${s.role}]` : '';
|
|
2021
2080
|
const summary = s.summary ? ` -- ${s.summary}` : '';
|
|
2022
|
-
console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
|
|
2081
|
+
console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`);
|
|
2023
2082
|
}
|
|
2024
2083
|
}
|
|
2025
2084
|
|
|
@@ -2027,8 +2086,9 @@ export function explain(target, customDbPath, opts = {}) {
|
|
|
2027
2086
|
console.log(`\n## Internal`);
|
|
2028
2087
|
for (const s of r.internal) {
|
|
2029
2088
|
const sig = s.signature?.params != null ? `(${s.signature.params})` : '';
|
|
2089
|
+
const roleTag = s.role ? ` [${s.role}]` : '';
|
|
2030
2090
|
const summary = s.summary ? ` -- ${s.summary}` : '';
|
|
2031
|
-
console.log(` ${kindIcon(s.kind)} ${s.name}${sig} :${s.line}${summary}`);
|
|
2091
|
+
console.log(` ${kindIcon(s.kind)} ${s.name}${sig}${roleTag} :${s.line}${summary}`);
|
|
2032
2092
|
}
|
|
2033
2093
|
}
|
|
2034
2094
|
|
|
@@ -2045,9 +2105,10 @@ export function explain(target, customDbPath, opts = {}) {
|
|
|
2045
2105
|
const lineRange = r.endLine ? `${r.line}-${r.endLine}` : `${r.line}`;
|
|
2046
2106
|
const lineInfo = r.lineCount ? `${r.lineCount} lines` : '';
|
|
2047
2107
|
const summaryPart = r.summary ? ` | ${r.summary}` : '';
|
|
2108
|
+
const roleTag = r.role ? ` [${r.role}]` : '';
|
|
2048
2109
|
const depthLevel = r._depth || 0;
|
|
2049
2110
|
const heading = depthLevel === 0 ? '#' : '##'.padEnd(depthLevel + 2, '#');
|
|
2050
|
-
console.log(`\n${indent}${heading} ${r.name} (${r.kind}) ${r.file}:${lineRange}`);
|
|
2111
|
+
console.log(`\n${indent}${heading} ${r.name} (${r.kind})${roleTag} ${r.file}:${lineRange}`);
|
|
2051
2112
|
if (lineInfo || r.summary) {
|
|
2052
2113
|
console.log(`${indent} ${lineInfo}${summaryPart}`);
|
|
2053
2114
|
}
|
|
@@ -2134,6 +2195,7 @@ function whereSymbolImpl(db, target, noTests) {
|
|
|
2134
2195
|
kind: node.kind,
|
|
2135
2196
|
file: node.file,
|
|
2136
2197
|
line: node.line,
|
|
2198
|
+
role: node.role || null,
|
|
2137
2199
|
exported,
|
|
2138
2200
|
uses: uses.map((u) => ({ name: u.name, file: u.file, line: u.line })),
|
|
2139
2201
|
};
|
|
@@ -2220,8 +2282,9 @@ export function where(target, customDbPath, opts = {}) {
|
|
|
2220
2282
|
|
|
2221
2283
|
if (data.mode === 'symbol') {
|
|
2222
2284
|
for (const r of data.results) {
|
|
2285
|
+
const roleTag = r.role ? ` [${r.role}]` : '';
|
|
2223
2286
|
const tag = r.exported ? ' (exported)' : '';
|
|
2224
|
-
console.log(`\n${kindIcon(r.kind)} ${r.name} ${r.file}:${r.line}${tag}`);
|
|
2287
|
+
console.log(`\n${kindIcon(r.kind)} ${r.name}${roleTag} ${r.file}:${r.line}${tag}`);
|
|
2225
2288
|
if (r.uses.length > 0) {
|
|
2226
2289
|
const useStrs = r.uses.map((u) => `${u.file}:${u.line}`);
|
|
2227
2290
|
console.log(` Used in: ${useStrs.join(', ')}`);
|
|
@@ -2250,6 +2313,81 @@ export function where(target, customDbPath, opts = {}) {
|
|
|
2250
2313
|
console.log();
|
|
2251
2314
|
}
|
|
2252
2315
|
|
|
2316
|
+
// ─── rolesData ──────────────────────────────────────────────────────────
|
|
2317
|
+
|
|
2318
|
+
export function rolesData(customDbPath, opts = {}) {
|
|
2319
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
2320
|
+
const noTests = opts.noTests || false;
|
|
2321
|
+
const filterRole = opts.role || null;
|
|
2322
|
+
const filterFile = opts.file || null;
|
|
2323
|
+
|
|
2324
|
+
const conditions = ['role IS NOT NULL'];
|
|
2325
|
+
const params = [];
|
|
2326
|
+
|
|
2327
|
+
if (filterRole) {
|
|
2328
|
+
conditions.push('role = ?');
|
|
2329
|
+
params.push(filterRole);
|
|
2330
|
+
}
|
|
2331
|
+
if (filterFile) {
|
|
2332
|
+
conditions.push('file LIKE ?');
|
|
2333
|
+
params.push(`%${filterFile}%`);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
let rows = db
|
|
2337
|
+
.prepare(
|
|
2338
|
+
`SELECT name, kind, file, line, role FROM nodes WHERE ${conditions.join(' AND ')} ORDER BY role, file, line`,
|
|
2339
|
+
)
|
|
2340
|
+
.all(...params);
|
|
2341
|
+
|
|
2342
|
+
if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
|
|
2343
|
+
|
|
2344
|
+
const summary = {};
|
|
2345
|
+
for (const r of rows) {
|
|
2346
|
+
summary[r.role] = (summary[r.role] || 0) + 1;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
db.close();
|
|
2350
|
+
return { count: rows.length, summary, symbols: rows };
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
export function roles(customDbPath, opts = {}) {
|
|
2354
|
+
const data = rolesData(customDbPath, opts);
|
|
2355
|
+
if (opts.json) {
|
|
2356
|
+
console.log(JSON.stringify(data, null, 2));
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
if (data.count === 0) {
|
|
2361
|
+
console.log('No classified symbols found. Run "codegraph build" first.');
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
const total = data.count;
|
|
2366
|
+
console.log(`\nNode roles (${total} symbols):\n`);
|
|
2367
|
+
|
|
2368
|
+
const summaryParts = Object.entries(data.summary)
|
|
2369
|
+
.sort((a, b) => b[1] - a[1])
|
|
2370
|
+
.map(([role, count]) => `${role}: ${count}`);
|
|
2371
|
+
console.log(` ${summaryParts.join(' ')}\n`);
|
|
2372
|
+
|
|
2373
|
+
const byRole = {};
|
|
2374
|
+
for (const s of data.symbols) {
|
|
2375
|
+
if (!byRole[s.role]) byRole[s.role] = [];
|
|
2376
|
+
byRole[s.role].push(s);
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
for (const [role, symbols] of Object.entries(byRole)) {
|
|
2380
|
+
console.log(`## ${role} (${symbols.length})`);
|
|
2381
|
+
for (const s of symbols.slice(0, 30)) {
|
|
2382
|
+
console.log(` ${kindIcon(s.kind)} ${s.name} ${s.file}:${s.line}`);
|
|
2383
|
+
}
|
|
2384
|
+
if (symbols.length > 30) {
|
|
2385
|
+
console.log(` ... and ${symbols.length - 30} more`);
|
|
2386
|
+
}
|
|
2387
|
+
console.log();
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2253
2391
|
export function fnImpact(name, customDbPath, opts = {}) {
|
|
2254
2392
|
const data = fnImpactData(name, customDbPath, opts);
|
|
2255
2393
|
if (opts.json) {
|
|
@@ -2309,9 +2447,20 @@ export function diffImpact(customDbPath, opts = {}) {
|
|
|
2309
2447
|
console.log(` ${kindIcon(fn.kind)} ${fn.name} -- ${fn.file}:${fn.line}`);
|
|
2310
2448
|
if (fn.transitiveCallers > 0) console.log(` ^ ${fn.transitiveCallers} transitive callers`);
|
|
2311
2449
|
}
|
|
2450
|
+
if (data.historicallyCoupled && data.historicallyCoupled.length > 0) {
|
|
2451
|
+
console.log('\n Historically coupled (not in static graph):\n');
|
|
2452
|
+
for (const c of data.historicallyCoupled) {
|
|
2453
|
+
const pct = `${(c.jaccard * 100).toFixed(0)}%`;
|
|
2454
|
+
console.log(
|
|
2455
|
+
` ${c.file} <- coupled with ${c.coupledWith} (${pct}, ${c.commitCount} commits)`,
|
|
2456
|
+
);
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2312
2459
|
if (data.summary) {
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2460
|
+
let summaryLine = `\n Summary: ${data.summary.functionsChanged} functions changed -> ${data.summary.callersAffected} callers affected across ${data.summary.filesAffected} files`;
|
|
2461
|
+
if (data.summary.historicallyCoupledCount > 0) {
|
|
2462
|
+
summaryLine += `, ${data.summary.historicallyCoupledCount} historically coupled`;
|
|
2463
|
+
}
|
|
2464
|
+
console.log(`${summaryLine}\n`);
|
|
2316
2465
|
}
|
|
2317
2466
|
}
|
package/src/registry.js
CHANGED
|
@@ -136,12 +136,20 @@ export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) {
|
|
|
136
136
|
* or that haven't been accessed within `ttlDays` days.
|
|
137
137
|
* Returns an array of `{ name, path, reason }` for each pruned entry.
|
|
138
138
|
*/
|
|
139
|
-
export function pruneRegistry(
|
|
139
|
+
export function pruneRegistry(
|
|
140
|
+
registryPath = REGISTRY_PATH,
|
|
141
|
+
ttlDays = DEFAULT_TTL_DAYS,
|
|
142
|
+
excludeNames = [],
|
|
143
|
+
) {
|
|
140
144
|
const registry = loadRegistry(registryPath);
|
|
141
145
|
const pruned = [];
|
|
142
146
|
const cutoff = Date.now() - ttlDays * 24 * 60 * 60 * 1000;
|
|
147
|
+
const excludeSet = new Set(
|
|
148
|
+
excludeNames.filter((n) => typeof n === 'string' && n.trim().length > 0),
|
|
149
|
+
);
|
|
143
150
|
|
|
144
151
|
for (const [name, entry] of Object.entries(registry.repos)) {
|
|
152
|
+
if (excludeSet.has(name)) continue;
|
|
145
153
|
if (!fs.existsSync(entry.path)) {
|
|
146
154
|
pruned.push({ name, path: entry.path, reason: 'missing' });
|
|
147
155
|
delete registry.repos[name];
|
package/src/structure.js
CHANGED
|
@@ -224,6 +224,100 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
224
224
|
debug(`Structure: ${dirCount} directories, ${fileSymbols.size} files with metrics`);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
// ─── Node role classification ─────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
function median(sorted) {
|
|
230
|
+
if (sorted.length === 0) return 0;
|
|
231
|
+
const mid = Math.floor(sorted.length / 2);
|
|
232
|
+
return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function classifyNodeRoles(db) {
|
|
236
|
+
const rows = db
|
|
237
|
+
.prepare(
|
|
238
|
+
`SELECT n.id, n.kind, n.file,
|
|
239
|
+
COALESCE(fi.cnt, 0) AS fan_in,
|
|
240
|
+
COALESCE(fo.cnt, 0) AS fan_out
|
|
241
|
+
FROM nodes n
|
|
242
|
+
LEFT JOIN (
|
|
243
|
+
SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id
|
|
244
|
+
) fi ON n.id = fi.target_id
|
|
245
|
+
LEFT JOIN (
|
|
246
|
+
SELECT source_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY source_id
|
|
247
|
+
) fo ON n.id = fo.source_id
|
|
248
|
+
WHERE n.kind NOT IN ('file', 'directory')`,
|
|
249
|
+
)
|
|
250
|
+
.all();
|
|
251
|
+
|
|
252
|
+
if (rows.length === 0) {
|
|
253
|
+
return { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const exportedIds = new Set(
|
|
257
|
+
db
|
|
258
|
+
.prepare(
|
|
259
|
+
`SELECT DISTINCT e.target_id
|
|
260
|
+
FROM edges e
|
|
261
|
+
JOIN nodes caller ON e.source_id = caller.id
|
|
262
|
+
JOIN nodes target ON e.target_id = target.id
|
|
263
|
+
WHERE e.kind = 'calls' AND caller.file != target.file`,
|
|
264
|
+
)
|
|
265
|
+
.all()
|
|
266
|
+
.map((r) => r.target_id),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const nonZeroFanIn = rows
|
|
270
|
+
.filter((r) => r.fan_in > 0)
|
|
271
|
+
.map((r) => r.fan_in)
|
|
272
|
+
.sort((a, b) => a - b);
|
|
273
|
+
const nonZeroFanOut = rows
|
|
274
|
+
.filter((r) => r.fan_out > 0)
|
|
275
|
+
.map((r) => r.fan_out)
|
|
276
|
+
.sort((a, b) => a - b);
|
|
277
|
+
|
|
278
|
+
const medFanIn = median(nonZeroFanIn);
|
|
279
|
+
const medFanOut = median(nonZeroFanOut);
|
|
280
|
+
|
|
281
|
+
const updates = [];
|
|
282
|
+
const summary = { entry: 0, core: 0, utility: 0, adapter: 0, dead: 0, leaf: 0 };
|
|
283
|
+
|
|
284
|
+
for (const row of rows) {
|
|
285
|
+
const highIn = row.fan_in >= medFanIn && row.fan_in > 0;
|
|
286
|
+
const highOut = row.fan_out >= medFanOut && row.fan_out > 0;
|
|
287
|
+
const isExported = exportedIds.has(row.id);
|
|
288
|
+
|
|
289
|
+
let role;
|
|
290
|
+
if (row.fan_in === 0 && !isExported) {
|
|
291
|
+
role = 'dead';
|
|
292
|
+
} else if (row.fan_in === 0 && isExported) {
|
|
293
|
+
role = 'entry';
|
|
294
|
+
} else if (highIn && !highOut) {
|
|
295
|
+
role = 'core';
|
|
296
|
+
} else if (highIn && highOut) {
|
|
297
|
+
role = 'utility';
|
|
298
|
+
} else if (!highIn && highOut) {
|
|
299
|
+
role = 'adapter';
|
|
300
|
+
} else {
|
|
301
|
+
role = 'leaf';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
updates.push({ id: row.id, role });
|
|
305
|
+
summary[role]++;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const clearRoles = db.prepare('UPDATE nodes SET role = NULL');
|
|
309
|
+
const setRole = db.prepare('UPDATE nodes SET role = ? WHERE id = ?');
|
|
310
|
+
|
|
311
|
+
db.transaction(() => {
|
|
312
|
+
clearRoles.run();
|
|
313
|
+
for (const u of updates) {
|
|
314
|
+
setRole.run(u.role, u.id);
|
|
315
|
+
}
|
|
316
|
+
})();
|
|
317
|
+
|
|
318
|
+
return summary;
|
|
319
|
+
}
|
|
320
|
+
|
|
227
321
|
// ─── Query functions (read-only) ──────────────────────────────────────
|
|
228
322
|
|
|
229
323
|
/**
|