@optave/codegraph 2.3.1-dev.1aeea34 → 2.5.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 +66 -10
- package/package.json +15 -6
- package/src/builder.js +183 -22
- package/src/cli.js +251 -5
- package/src/cochange.js +8 -8
- package/src/communities.js +303 -0
- package/src/complexity.js +2056 -0
- package/src/config.js +20 -1
- package/src/db.js +111 -1
- package/src/embedder.js +49 -12
- package/src/export.js +25 -1
- package/src/flow.js +361 -0
- package/src/index.js +32 -2
- package/src/manifesto.js +442 -0
- package/src/mcp.js +244 -5
- package/src/paginate.js +70 -0
- package/src/parser.js +21 -5
- package/src/queries.js +396 -7
- package/src/structure.js +88 -24
- package/src/update-check.js +160 -0
- package/src/watcher.js +2 -2
package/src/queries.js
CHANGED
|
@@ -5,6 +5,7 @@ import { coChangeForFiles } from './cochange.js';
|
|
|
5
5
|
import { findCycles } from './cycles.js';
|
|
6
6
|
import { findDbPath, openReadonlyOrFail } from './db.js';
|
|
7
7
|
import { debug } from './logger.js';
|
|
8
|
+
import { paginateResult } from './paginate.js';
|
|
8
9
|
import { LANGUAGE_REGISTRY } from './parser.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -172,7 +173,7 @@ function findMatchingNodes(db, name, opts = {}) {
|
|
|
172
173
|
return nodes;
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
function kindIcon(kind) {
|
|
176
|
+
export function kindIcon(kind) {
|
|
176
177
|
switch (kind) {
|
|
177
178
|
case 'function':
|
|
178
179
|
return 'f';
|
|
@@ -248,7 +249,8 @@ export function queryNameData(name, customDbPath, opts = {}) {
|
|
|
248
249
|
});
|
|
249
250
|
|
|
250
251
|
db.close();
|
|
251
|
-
|
|
252
|
+
const base = { query: name, results };
|
|
253
|
+
return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
|
|
252
254
|
}
|
|
253
255
|
|
|
254
256
|
export function impactAnalysisData(file, customDbPath, opts = {}) {
|
|
@@ -565,6 +567,255 @@ export function fnImpactData(name, customDbPath, opts = {}) {
|
|
|
565
567
|
return { name, results };
|
|
566
568
|
}
|
|
567
569
|
|
|
570
|
+
export function pathData(from, to, customDbPath, opts = {}) {
|
|
571
|
+
const db = openReadonlyOrFail(customDbPath);
|
|
572
|
+
const noTests = opts.noTests || false;
|
|
573
|
+
const maxDepth = opts.maxDepth || 10;
|
|
574
|
+
const edgeKinds = opts.edgeKinds || ['calls'];
|
|
575
|
+
const reverse = opts.reverse || false;
|
|
576
|
+
|
|
577
|
+
const fromNodes = findMatchingNodes(db, from, {
|
|
578
|
+
noTests,
|
|
579
|
+
file: opts.fromFile,
|
|
580
|
+
kind: opts.kind,
|
|
581
|
+
});
|
|
582
|
+
if (fromNodes.length === 0) {
|
|
583
|
+
db.close();
|
|
584
|
+
return {
|
|
585
|
+
from,
|
|
586
|
+
to,
|
|
587
|
+
found: false,
|
|
588
|
+
error: `No symbol matching "${from}"`,
|
|
589
|
+
fromCandidates: [],
|
|
590
|
+
toCandidates: [],
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const toNodes = findMatchingNodes(db, to, {
|
|
595
|
+
noTests,
|
|
596
|
+
file: opts.toFile,
|
|
597
|
+
kind: opts.kind,
|
|
598
|
+
});
|
|
599
|
+
if (toNodes.length === 0) {
|
|
600
|
+
db.close();
|
|
601
|
+
return {
|
|
602
|
+
from,
|
|
603
|
+
to,
|
|
604
|
+
found: false,
|
|
605
|
+
error: `No symbol matching "${to}"`,
|
|
606
|
+
fromCandidates: fromNodes
|
|
607
|
+
.slice(0, 5)
|
|
608
|
+
.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line })),
|
|
609
|
+
toCandidates: [],
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const sourceNode = fromNodes[0];
|
|
614
|
+
const targetNode = toNodes[0];
|
|
615
|
+
|
|
616
|
+
const fromCandidates = fromNodes
|
|
617
|
+
.slice(0, 5)
|
|
618
|
+
.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
|
|
619
|
+
const toCandidates = toNodes
|
|
620
|
+
.slice(0, 5)
|
|
621
|
+
.map((n) => ({ name: n.name, kind: n.kind, file: n.file, line: n.line }));
|
|
622
|
+
|
|
623
|
+
// Self-path
|
|
624
|
+
if (sourceNode.id === targetNode.id) {
|
|
625
|
+
db.close();
|
|
626
|
+
return {
|
|
627
|
+
from,
|
|
628
|
+
to,
|
|
629
|
+
fromCandidates,
|
|
630
|
+
toCandidates,
|
|
631
|
+
found: true,
|
|
632
|
+
hops: 0,
|
|
633
|
+
path: [
|
|
634
|
+
{
|
|
635
|
+
name: sourceNode.name,
|
|
636
|
+
kind: sourceNode.kind,
|
|
637
|
+
file: sourceNode.file,
|
|
638
|
+
line: sourceNode.line,
|
|
639
|
+
edgeKind: null,
|
|
640
|
+
},
|
|
641
|
+
],
|
|
642
|
+
alternateCount: 0,
|
|
643
|
+
edgeKinds,
|
|
644
|
+
reverse,
|
|
645
|
+
maxDepth,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Build edge kind filter
|
|
650
|
+
const kindPlaceholders = edgeKinds.map(() => '?').join(', ');
|
|
651
|
+
|
|
652
|
+
// BFS — direction depends on `reverse` flag
|
|
653
|
+
// Forward: source_id → target_id (A calls... calls B)
|
|
654
|
+
// Reverse: target_id → source_id (B is called by... called by A)
|
|
655
|
+
const neighborQuery = reverse
|
|
656
|
+
? `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
|
|
657
|
+
FROM edges e JOIN nodes n ON e.source_id = n.id
|
|
658
|
+
WHERE e.target_id = ? AND e.kind IN (${kindPlaceholders})`
|
|
659
|
+
: `SELECT n.id, n.name, n.kind, n.file, n.line, e.kind AS edge_kind
|
|
660
|
+
FROM edges e JOIN nodes n ON e.target_id = n.id
|
|
661
|
+
WHERE e.source_id = ? AND e.kind IN (${kindPlaceholders})`;
|
|
662
|
+
const neighborStmt = db.prepare(neighborQuery);
|
|
663
|
+
|
|
664
|
+
const visited = new Set([sourceNode.id]);
|
|
665
|
+
// parent map: nodeId → { parentId, edgeKind }
|
|
666
|
+
const parent = new Map();
|
|
667
|
+
let queue = [sourceNode.id];
|
|
668
|
+
let found = false;
|
|
669
|
+
let alternateCount = 0;
|
|
670
|
+
let foundDepth = -1;
|
|
671
|
+
|
|
672
|
+
for (let depth = 1; depth <= maxDepth; depth++) {
|
|
673
|
+
const nextQueue = [];
|
|
674
|
+
for (const currentId of queue) {
|
|
675
|
+
const neighbors = neighborStmt.all(currentId, ...edgeKinds);
|
|
676
|
+
for (const n of neighbors) {
|
|
677
|
+
if (noTests && isTestFile(n.file)) continue;
|
|
678
|
+
if (n.id === targetNode.id) {
|
|
679
|
+
if (!found) {
|
|
680
|
+
found = true;
|
|
681
|
+
foundDepth = depth;
|
|
682
|
+
parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
|
|
683
|
+
}
|
|
684
|
+
alternateCount++;
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (!visited.has(n.id)) {
|
|
688
|
+
visited.add(n.id);
|
|
689
|
+
parent.set(n.id, { parentId: currentId, edgeKind: n.edge_kind });
|
|
690
|
+
nextQueue.push(n.id);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
if (found) break;
|
|
695
|
+
queue = nextQueue;
|
|
696
|
+
if (queue.length === 0) break;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (!found) {
|
|
700
|
+
db.close();
|
|
701
|
+
return {
|
|
702
|
+
from,
|
|
703
|
+
to,
|
|
704
|
+
fromCandidates,
|
|
705
|
+
toCandidates,
|
|
706
|
+
found: false,
|
|
707
|
+
hops: null,
|
|
708
|
+
path: [],
|
|
709
|
+
alternateCount: 0,
|
|
710
|
+
edgeKinds,
|
|
711
|
+
reverse,
|
|
712
|
+
maxDepth,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// alternateCount includes the one we kept; subtract 1 for "alternates"
|
|
717
|
+
alternateCount = Math.max(0, alternateCount - 1);
|
|
718
|
+
|
|
719
|
+
// Reconstruct path from target back to source
|
|
720
|
+
const pathIds = [targetNode.id];
|
|
721
|
+
let cur = targetNode.id;
|
|
722
|
+
while (cur !== sourceNode.id) {
|
|
723
|
+
const p = parent.get(cur);
|
|
724
|
+
pathIds.push(p.parentId);
|
|
725
|
+
cur = p.parentId;
|
|
726
|
+
}
|
|
727
|
+
pathIds.reverse();
|
|
728
|
+
|
|
729
|
+
// Build path with node info
|
|
730
|
+
const nodeCache = new Map();
|
|
731
|
+
const getNode = (id) => {
|
|
732
|
+
if (nodeCache.has(id)) return nodeCache.get(id);
|
|
733
|
+
const row = db.prepare('SELECT name, kind, file, line FROM nodes WHERE id = ?').get(id);
|
|
734
|
+
nodeCache.set(id, row);
|
|
735
|
+
return row;
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const resultPath = pathIds.map((id, idx) => {
|
|
739
|
+
const node = getNode(id);
|
|
740
|
+
const edgeKind = idx === 0 ? null : parent.get(id).edgeKind;
|
|
741
|
+
return { name: node.name, kind: node.kind, file: node.file, line: node.line, edgeKind };
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
db.close();
|
|
745
|
+
return {
|
|
746
|
+
from,
|
|
747
|
+
to,
|
|
748
|
+
fromCandidates,
|
|
749
|
+
toCandidates,
|
|
750
|
+
found: true,
|
|
751
|
+
hops: foundDepth,
|
|
752
|
+
path: resultPath,
|
|
753
|
+
alternateCount,
|
|
754
|
+
edgeKinds,
|
|
755
|
+
reverse,
|
|
756
|
+
maxDepth,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
export function symbolPath(from, to, customDbPath, opts = {}) {
|
|
761
|
+
const data = pathData(from, to, customDbPath, opts);
|
|
762
|
+
if (opts.json) {
|
|
763
|
+
console.log(JSON.stringify(data, null, 2));
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
if (data.error) {
|
|
768
|
+
console.log(data.error);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (!data.found) {
|
|
773
|
+
const dir = data.reverse ? 'reverse ' : '';
|
|
774
|
+
console.log(`No ${dir}path from "${from}" to "${to}" within ${data.maxDepth} hops.`);
|
|
775
|
+
if (data.fromCandidates.length > 1) {
|
|
776
|
+
console.log(
|
|
777
|
+
`\n "${from}" matched ${data.fromCandidates.length} symbols — using top match: ${data.fromCandidates[0].name} (${data.fromCandidates[0].file}:${data.fromCandidates[0].line})`,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
if (data.toCandidates.length > 1) {
|
|
781
|
+
console.log(
|
|
782
|
+
` "${to}" matched ${data.toCandidates.length} symbols — using top match: ${data.toCandidates[0].name} (${data.toCandidates[0].file}:${data.toCandidates[0].line})`,
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (data.hops === 0) {
|
|
789
|
+
console.log(`\n"${from}" and "${to}" resolve to the same symbol (0 hops):`);
|
|
790
|
+
const n = data.path[0];
|
|
791
|
+
console.log(` ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}\n`);
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const dir = data.reverse ? ' (reverse)' : '';
|
|
796
|
+
console.log(
|
|
797
|
+
`\nPath from ${from} to ${to} (${data.hops} ${data.hops === 1 ? 'hop' : 'hops'})${dir}:\n`,
|
|
798
|
+
);
|
|
799
|
+
for (let i = 0; i < data.path.length; i++) {
|
|
800
|
+
const n = data.path[i];
|
|
801
|
+
const indent = ' '.repeat(i + 1);
|
|
802
|
+
if (i === 0) {
|
|
803
|
+
console.log(`${indent}${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`);
|
|
804
|
+
} else {
|
|
805
|
+
console.log(
|
|
806
|
+
`${indent}--[${n.edgeKind}]--> ${kindIcon(n.kind)} ${n.name} (${n.kind}) -- ${n.file}:${n.line}`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (data.alternateCount > 0) {
|
|
812
|
+
console.log(
|
|
813
|
+
`\n (${data.alternateCount} alternate shortest ${data.alternateCount === 1 ? 'path' : 'paths'} at same depth)`,
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
console.log();
|
|
817
|
+
}
|
|
818
|
+
|
|
568
819
|
/**
|
|
569
820
|
* Fix #2: Shell injection vulnerability.
|
|
570
821
|
* Uses execFileSync instead of execSync to prevent shell interpretation of user input.
|
|
@@ -904,7 +1155,8 @@ export function listFunctionsData(customDbPath, opts = {}) {
|
|
|
904
1155
|
if (noTests) rows = rows.filter((r) => !isTestFile(r.file));
|
|
905
1156
|
|
|
906
1157
|
db.close();
|
|
907
|
-
|
|
1158
|
+
const base = { count: rows.length, functions: rows };
|
|
1159
|
+
return paginateResult(base, 'functions', { limit: opts.limit, offset: opts.offset });
|
|
908
1160
|
}
|
|
909
1161
|
|
|
910
1162
|
export function statsData(customDbPath, opts = {}) {
|
|
@@ -1114,6 +1366,32 @@ export function statsData(customDbPath, opts = {}) {
|
|
|
1114
1366
|
const roles = {};
|
|
1115
1367
|
for (const r of roleRows) roles[r.role] = r.c;
|
|
1116
1368
|
|
|
1369
|
+
// Complexity summary
|
|
1370
|
+
let complexity = null;
|
|
1371
|
+
try {
|
|
1372
|
+
const cRows = db
|
|
1373
|
+
.prepare(
|
|
1374
|
+
`SELECT fc.cognitive, fc.cyclomatic, fc.max_nesting, fc.maintainability_index
|
|
1375
|
+
FROM function_complexity fc JOIN nodes n ON fc.node_id = n.id
|
|
1376
|
+
WHERE n.kind IN ('function','method') ${testFilter}`,
|
|
1377
|
+
)
|
|
1378
|
+
.all();
|
|
1379
|
+
if (cRows.length > 0) {
|
|
1380
|
+
const miValues = cRows.map((r) => r.maintainability_index || 0);
|
|
1381
|
+
complexity = {
|
|
1382
|
+
analyzed: cRows.length,
|
|
1383
|
+
avgCognitive: +(cRows.reduce((s, r) => s + r.cognitive, 0) / cRows.length).toFixed(1),
|
|
1384
|
+
avgCyclomatic: +(cRows.reduce((s, r) => s + r.cyclomatic, 0) / cRows.length).toFixed(1),
|
|
1385
|
+
maxCognitive: Math.max(...cRows.map((r) => r.cognitive)),
|
|
1386
|
+
maxCyclomatic: Math.max(...cRows.map((r) => r.cyclomatic)),
|
|
1387
|
+
avgMI: +(miValues.reduce((s, v) => s + v, 0) / miValues.length).toFixed(1),
|
|
1388
|
+
minMI: +Math.min(...miValues).toFixed(1),
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
} catch {
|
|
1392
|
+
/* table may not exist in older DBs */
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1117
1395
|
db.close();
|
|
1118
1396
|
return {
|
|
1119
1397
|
nodes: { total: totalNodes, byKind: nodesByKind },
|
|
@@ -1124,11 +1402,21 @@ export function statsData(customDbPath, opts = {}) {
|
|
|
1124
1402
|
embeddings,
|
|
1125
1403
|
quality,
|
|
1126
1404
|
roles,
|
|
1405
|
+
complexity,
|
|
1127
1406
|
};
|
|
1128
1407
|
}
|
|
1129
1408
|
|
|
1130
|
-
export function stats(customDbPath, opts = {}) {
|
|
1409
|
+
export async function stats(customDbPath, opts = {}) {
|
|
1131
1410
|
const data = statsData(customDbPath, { noTests: opts.noTests });
|
|
1411
|
+
|
|
1412
|
+
// Community detection summary (async import for lazy-loading)
|
|
1413
|
+
try {
|
|
1414
|
+
const { communitySummaryForStats } = await import('./communities.js');
|
|
1415
|
+
data.communities = communitySummaryForStats(customDbPath, { noTests: opts.noTests });
|
|
1416
|
+
} catch {
|
|
1417
|
+
/* graphology may not be available */
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1132
1420
|
if (opts.json) {
|
|
1133
1421
|
console.log(JSON.stringify(data, null, 2));
|
|
1134
1422
|
return;
|
|
@@ -1236,13 +1524,39 @@ export function stats(customDbPath, opts = {}) {
|
|
|
1236
1524
|
}
|
|
1237
1525
|
}
|
|
1238
1526
|
|
|
1527
|
+
// Complexity
|
|
1528
|
+
if (data.complexity) {
|
|
1529
|
+
const cx = data.complexity;
|
|
1530
|
+
const miPart = cx.avgMI != null ? ` | avg MI: ${cx.avgMI} | min MI: ${cx.minMI}` : '';
|
|
1531
|
+
console.log(
|
|
1532
|
+
`\nComplexity: ${cx.analyzed} functions | avg cognitive: ${cx.avgCognitive} | avg cyclomatic: ${cx.avgCyclomatic} | max cognitive: ${cx.maxCognitive}${miPart}`,
|
|
1533
|
+
);
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// Communities
|
|
1537
|
+
if (data.communities) {
|
|
1538
|
+
const cm = data.communities;
|
|
1539
|
+
console.log(
|
|
1540
|
+
`\nCommunities: ${cm.communityCount} detected | modularity: ${cm.modularity} | drift: ${cm.driftScore}%`,
|
|
1541
|
+
);
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1239
1544
|
console.log();
|
|
1240
1545
|
}
|
|
1241
1546
|
|
|
1242
1547
|
// ─── Human-readable output (original formatting) ───────────────────────
|
|
1243
1548
|
|
|
1244
1549
|
export function queryName(name, customDbPath, opts = {}) {
|
|
1245
|
-
const data = queryNameData(name, customDbPath, {
|
|
1550
|
+
const data = queryNameData(name, customDbPath, {
|
|
1551
|
+
noTests: opts.noTests,
|
|
1552
|
+
limit: opts.limit,
|
|
1553
|
+
offset: opts.offset,
|
|
1554
|
+
});
|
|
1555
|
+
if (opts.ndjson) {
|
|
1556
|
+
if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
|
|
1557
|
+
for (const r of data.results) console.log(JSON.stringify(r));
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1246
1560
|
if (opts.json) {
|
|
1247
1561
|
console.log(JSON.stringify(data, null, 2));
|
|
1248
1562
|
return;
|
|
@@ -1698,6 +2012,27 @@ export function contextData(name, customDbPath, opts = {}) {
|
|
|
1698
2012
|
});
|
|
1699
2013
|
}
|
|
1700
2014
|
|
|
2015
|
+
// Complexity metrics
|
|
2016
|
+
let complexityMetrics = null;
|
|
2017
|
+
try {
|
|
2018
|
+
const cRow = db
|
|
2019
|
+
.prepare(
|
|
2020
|
+
'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?',
|
|
2021
|
+
)
|
|
2022
|
+
.get(node.id);
|
|
2023
|
+
if (cRow) {
|
|
2024
|
+
complexityMetrics = {
|
|
2025
|
+
cognitive: cRow.cognitive,
|
|
2026
|
+
cyclomatic: cRow.cyclomatic,
|
|
2027
|
+
maxNesting: cRow.max_nesting,
|
|
2028
|
+
maintainabilityIndex: cRow.maintainability_index || 0,
|
|
2029
|
+
halsteadVolume: cRow.halstead_volume || 0,
|
|
2030
|
+
};
|
|
2031
|
+
}
|
|
2032
|
+
} catch {
|
|
2033
|
+
/* table may not exist */
|
|
2034
|
+
}
|
|
2035
|
+
|
|
1701
2036
|
return {
|
|
1702
2037
|
name: node.name,
|
|
1703
2038
|
kind: node.kind,
|
|
@@ -1707,6 +2042,7 @@ export function contextData(name, customDbPath, opts = {}) {
|
|
|
1707
2042
|
endLine: node.end_line || null,
|
|
1708
2043
|
source,
|
|
1709
2044
|
signature,
|
|
2045
|
+
complexity: complexityMetrics,
|
|
1710
2046
|
callees,
|
|
1711
2047
|
callers,
|
|
1712
2048
|
relatedTests,
|
|
@@ -1741,6 +2077,17 @@ export function context(name, customDbPath, opts = {}) {
|
|
|
1741
2077
|
console.log();
|
|
1742
2078
|
}
|
|
1743
2079
|
|
|
2080
|
+
// Complexity
|
|
2081
|
+
if (r.complexity) {
|
|
2082
|
+
const cx = r.complexity;
|
|
2083
|
+
const miPart = cx.maintainabilityIndex ? ` | MI: ${cx.maintainabilityIndex}` : '';
|
|
2084
|
+
console.log('## Complexity');
|
|
2085
|
+
console.log(
|
|
2086
|
+
` Cognitive: ${cx.cognitive} | Cyclomatic: ${cx.cyclomatic} | Max Nesting: ${cx.maxNesting}${miPart}`,
|
|
2087
|
+
);
|
|
2088
|
+
console.log();
|
|
2089
|
+
}
|
|
2090
|
+
|
|
1744
2091
|
// Source
|
|
1745
2092
|
if (r.source) {
|
|
1746
2093
|
console.log('## Source');
|
|
@@ -1959,6 +2306,27 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
|
|
|
1959
2306
|
.filter((r) => isTestFile(r.file))
|
|
1960
2307
|
.map((r) => ({ file: r.file }));
|
|
1961
2308
|
|
|
2309
|
+
// Complexity metrics
|
|
2310
|
+
let complexityMetrics = null;
|
|
2311
|
+
try {
|
|
2312
|
+
const cRow = db
|
|
2313
|
+
.prepare(
|
|
2314
|
+
'SELECT cognitive, cyclomatic, max_nesting, maintainability_index, halstead_volume FROM function_complexity WHERE node_id = ?',
|
|
2315
|
+
)
|
|
2316
|
+
.get(node.id);
|
|
2317
|
+
if (cRow) {
|
|
2318
|
+
complexityMetrics = {
|
|
2319
|
+
cognitive: cRow.cognitive,
|
|
2320
|
+
cyclomatic: cRow.cyclomatic,
|
|
2321
|
+
maxNesting: cRow.max_nesting,
|
|
2322
|
+
maintainabilityIndex: cRow.maintainability_index || 0,
|
|
2323
|
+
halsteadVolume: cRow.halstead_volume || 0,
|
|
2324
|
+
};
|
|
2325
|
+
}
|
|
2326
|
+
} catch {
|
|
2327
|
+
/* table may not exist */
|
|
2328
|
+
}
|
|
2329
|
+
|
|
1962
2330
|
return {
|
|
1963
2331
|
name: node.name,
|
|
1964
2332
|
kind: node.kind,
|
|
@@ -1969,6 +2337,7 @@ function explainFunctionImpl(db, target, noTests, getFileLines) {
|
|
|
1969
2337
|
lineCount,
|
|
1970
2338
|
summary,
|
|
1971
2339
|
signature,
|
|
2340
|
+
complexity: complexityMetrics,
|
|
1972
2341
|
callees,
|
|
1973
2342
|
callers,
|
|
1974
2343
|
relatedTests,
|
|
@@ -2118,6 +2487,14 @@ export function explain(target, customDbPath, opts = {}) {
|
|
|
2118
2487
|
if (r.signature.returnType) console.log(`${indent} Returns: ${r.signature.returnType}`);
|
|
2119
2488
|
}
|
|
2120
2489
|
|
|
2490
|
+
if (r.complexity) {
|
|
2491
|
+
const cx = r.complexity;
|
|
2492
|
+
const miPart = cx.maintainabilityIndex ? ` MI=${cx.maintainabilityIndex}` : '';
|
|
2493
|
+
console.log(
|
|
2494
|
+
`${indent} Complexity: cognitive=${cx.cognitive} cyclomatic=${cx.cyclomatic} nesting=${cx.maxNesting}${miPart}`,
|
|
2495
|
+
);
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2121
2498
|
if (r.callees.length > 0) {
|
|
2122
2499
|
console.log(`\n${indent} Calls (${r.callees.length}):`);
|
|
2123
2500
|
for (const c of r.callees) {
|
|
@@ -2261,11 +2638,17 @@ export function whereData(target, customDbPath, opts = {}) {
|
|
|
2261
2638
|
const results = fileMode ? whereFileImpl(db, target) : whereSymbolImpl(db, target, noTests);
|
|
2262
2639
|
|
|
2263
2640
|
db.close();
|
|
2264
|
-
|
|
2641
|
+
const base = { target, mode: fileMode ? 'file' : 'symbol', results };
|
|
2642
|
+
return paginateResult(base, 'results', { limit: opts.limit, offset: opts.offset });
|
|
2265
2643
|
}
|
|
2266
2644
|
|
|
2267
2645
|
export function where(target, customDbPath, opts = {}) {
|
|
2268
2646
|
const data = whereData(target, customDbPath, opts);
|
|
2647
|
+
if (opts.ndjson) {
|
|
2648
|
+
if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
|
|
2649
|
+
for (const r of data.results) console.log(JSON.stringify(r));
|
|
2650
|
+
return;
|
|
2651
|
+
}
|
|
2269
2652
|
if (opts.json) {
|
|
2270
2653
|
console.log(JSON.stringify(data, null, 2));
|
|
2271
2654
|
return;
|
|
@@ -2347,11 +2730,17 @@ export function rolesData(customDbPath, opts = {}) {
|
|
|
2347
2730
|
}
|
|
2348
2731
|
|
|
2349
2732
|
db.close();
|
|
2350
|
-
|
|
2733
|
+
const base = { count: rows.length, summary, symbols: rows };
|
|
2734
|
+
return paginateResult(base, 'symbols', { limit: opts.limit, offset: opts.offset });
|
|
2351
2735
|
}
|
|
2352
2736
|
|
|
2353
2737
|
export function roles(customDbPath, opts = {}) {
|
|
2354
2738
|
const data = rolesData(customDbPath, opts);
|
|
2739
|
+
if (opts.ndjson) {
|
|
2740
|
+
if (data._pagination) console.log(JSON.stringify({ _meta: data._pagination }));
|
|
2741
|
+
for (const s of data.symbols) console.log(JSON.stringify(s));
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2355
2744
|
if (opts.json) {
|
|
2356
2745
|
console.log(JSON.stringify(data, null, 2));
|
|
2357
2746
|
return;
|
package/src/structure.js
CHANGED
|
@@ -162,6 +162,48 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
162
162
|
}
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// Build reverse index: file → set of ancestor directories (O(files × depth))
|
|
166
|
+
const fileToAncestorDirs = new Map();
|
|
167
|
+
for (const [dir, files] of dirFiles) {
|
|
168
|
+
for (const f of files) {
|
|
169
|
+
if (!fileToAncestorDirs.has(f)) fileToAncestorDirs.set(f, new Set());
|
|
170
|
+
fileToAncestorDirs.get(f).add(dir);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Single O(E) pass: pre-aggregate edge counts per directory
|
|
175
|
+
const dirEdgeCounts = new Map();
|
|
176
|
+
for (const dir of allDirs) {
|
|
177
|
+
dirEdgeCounts.set(dir, { intra: 0, fanIn: 0, fanOut: 0 });
|
|
178
|
+
}
|
|
179
|
+
for (const { source_file, target_file } of importEdges) {
|
|
180
|
+
const srcDirs = fileToAncestorDirs.get(source_file);
|
|
181
|
+
const tgtDirs = fileToAncestorDirs.get(target_file);
|
|
182
|
+
if (!srcDirs && !tgtDirs) continue;
|
|
183
|
+
|
|
184
|
+
// For each directory that contains the source file
|
|
185
|
+
if (srcDirs) {
|
|
186
|
+
for (const dir of srcDirs) {
|
|
187
|
+
const counts = dirEdgeCounts.get(dir);
|
|
188
|
+
if (!counts) continue;
|
|
189
|
+
if (tgtDirs?.has(dir)) {
|
|
190
|
+
counts.intra++;
|
|
191
|
+
} else {
|
|
192
|
+
counts.fanOut++;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// For each directory that contains the target but NOT the source
|
|
197
|
+
if (tgtDirs) {
|
|
198
|
+
for (const dir of tgtDirs) {
|
|
199
|
+
if (srcDirs?.has(dir)) continue; // already counted as intra
|
|
200
|
+
const counts = dirEdgeCounts.get(dir);
|
|
201
|
+
if (!counts) continue;
|
|
202
|
+
counts.fanIn++;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
165
207
|
const computeDirMetrics = db.transaction(() => {
|
|
166
208
|
for (const [dir, files] of dirFiles) {
|
|
167
209
|
const dirRow = getNodeId.get(dir, 'directory', dir, 0);
|
|
@@ -169,9 +211,6 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
169
211
|
|
|
170
212
|
const fileCount = files.length;
|
|
171
213
|
let symbolCount = 0;
|
|
172
|
-
let totalFanIn = 0;
|
|
173
|
-
let totalFanOut = 0;
|
|
174
|
-
const filesInDir = new Set(files);
|
|
175
214
|
|
|
176
215
|
for (const f of files) {
|
|
177
216
|
const sym = fileSymbols.get(f);
|
|
@@ -187,23 +226,10 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
187
226
|
}
|
|
188
227
|
}
|
|
189
228
|
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const srcInside = filesInDir.has(source_file);
|
|
195
|
-
const tgtInside = filesInDir.has(target_file);
|
|
196
|
-
if (srcInside && tgtInside) {
|
|
197
|
-
intraEdges++;
|
|
198
|
-
} else if (srcInside || tgtInside) {
|
|
199
|
-
crossEdges++;
|
|
200
|
-
if (!srcInside && tgtInside) totalFanIn++;
|
|
201
|
-
if (srcInside && !tgtInside) totalFanOut++;
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
const totalEdges = intraEdges + crossEdges;
|
|
206
|
-
const cohesion = totalEdges > 0 ? intraEdges / totalEdges : null;
|
|
229
|
+
// O(1) lookup from pre-aggregated edge counts
|
|
230
|
+
const counts = dirEdgeCounts.get(dir) || { intra: 0, fanIn: 0, fanOut: 0 };
|
|
231
|
+
const totalEdges = counts.intra + counts.fanIn + counts.fanOut;
|
|
232
|
+
const cohesion = totalEdges > 0 ? counts.intra / totalEdges : null;
|
|
207
233
|
|
|
208
234
|
upsertMetric.run(
|
|
209
235
|
dirRow.id,
|
|
@@ -211,8 +237,8 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
211
237
|
symbolCount,
|
|
212
238
|
null,
|
|
213
239
|
null,
|
|
214
|
-
|
|
215
|
-
|
|
240
|
+
counts.fanIn,
|
|
241
|
+
counts.fanOut,
|
|
216
242
|
cohesion,
|
|
217
243
|
fileCount,
|
|
218
244
|
);
|
|
@@ -226,6 +252,8 @@ export function buildStructure(db, fileSymbols, _rootDir, lineCountMap, director
|
|
|
226
252
|
|
|
227
253
|
// ─── Node role classification ─────────────────────────────────────────
|
|
228
254
|
|
|
255
|
+
export const FRAMEWORK_ENTRY_PREFIXES = ['route:', 'event:', 'command:'];
|
|
256
|
+
|
|
229
257
|
function median(sorted) {
|
|
230
258
|
if (sorted.length === 0) return 0;
|
|
231
259
|
const mid = Math.floor(sorted.length / 2);
|
|
@@ -235,7 +263,7 @@ function median(sorted) {
|
|
|
235
263
|
export function classifyNodeRoles(db) {
|
|
236
264
|
const rows = db
|
|
237
265
|
.prepare(
|
|
238
|
-
`SELECT n.id, n.kind, n.file,
|
|
266
|
+
`SELECT n.id, n.name, n.kind, n.file,
|
|
239
267
|
COALESCE(fi.cnt, 0) AS fan_in,
|
|
240
268
|
COALESCE(fo.cnt, 0) AS fan_out
|
|
241
269
|
FROM nodes n
|
|
@@ -287,7 +315,10 @@ export function classifyNodeRoles(db) {
|
|
|
287
315
|
const isExported = exportedIds.has(row.id);
|
|
288
316
|
|
|
289
317
|
let role;
|
|
290
|
-
|
|
318
|
+
const isFrameworkEntry = FRAMEWORK_ENTRY_PREFIXES.some((p) => row.name.startsWith(p));
|
|
319
|
+
if (isFrameworkEntry) {
|
|
320
|
+
role = 'entry';
|
|
321
|
+
} else if (row.fan_in === 0 && !isExported) {
|
|
291
322
|
role = 'dead';
|
|
292
323
|
} else if (row.fan_in === 0 && isExported) {
|
|
293
324
|
role = 'entry';
|
|
@@ -330,6 +361,8 @@ export function structureData(customDbPath, opts = {}) {
|
|
|
330
361
|
const maxDepth = opts.depth || null;
|
|
331
362
|
const sortBy = opts.sort || 'files';
|
|
332
363
|
const noTests = opts.noTests || false;
|
|
364
|
+
const full = opts.full || false;
|
|
365
|
+
const fileLimit = opts.fileLimit || 25;
|
|
333
366
|
|
|
334
367
|
// Get all directory nodes with their metrics
|
|
335
368
|
let dirs = db
|
|
@@ -403,6 +436,33 @@ export function structureData(customDbPath, opts = {}) {
|
|
|
403
436
|
});
|
|
404
437
|
|
|
405
438
|
db.close();
|
|
439
|
+
|
|
440
|
+
// Apply global file limit unless full mode
|
|
441
|
+
if (!full) {
|
|
442
|
+
const totalFiles = result.reduce((sum, d) => sum + d.files.length, 0);
|
|
443
|
+
if (totalFiles > fileLimit) {
|
|
444
|
+
let shown = 0;
|
|
445
|
+
for (const d of result) {
|
|
446
|
+
const remaining = fileLimit - shown;
|
|
447
|
+
if (remaining <= 0) {
|
|
448
|
+
d.files = [];
|
|
449
|
+
} else if (d.files.length > remaining) {
|
|
450
|
+
d.files = d.files.slice(0, remaining);
|
|
451
|
+
shown = fileLimit;
|
|
452
|
+
} else {
|
|
453
|
+
shown += d.files.length;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const suppressed = totalFiles - fileLimit;
|
|
457
|
+
return {
|
|
458
|
+
directories: result,
|
|
459
|
+
count: result.length,
|
|
460
|
+
suppressed,
|
|
461
|
+
warning: `${suppressed} files omitted (showing ${fileLimit}/${totalFiles}). Use --full to show all files, or narrow with --directory.`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
406
466
|
return { directories: result, count: result.length };
|
|
407
467
|
}
|
|
408
468
|
|
|
@@ -539,6 +599,10 @@ export function formatStructure(data) {
|
|
|
539
599
|
);
|
|
540
600
|
}
|
|
541
601
|
}
|
|
602
|
+
if (data.warning) {
|
|
603
|
+
lines.push('');
|
|
604
|
+
lines.push(`⚠ ${data.warning}`);
|
|
605
|
+
}
|
|
542
606
|
return lines.join('\n');
|
|
543
607
|
}
|
|
544
608
|
|