@optave/codegraph 3.9.0 → 3.9.1
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 +7 -6
- package/dist/ast-analysis/engine.d.ts.map +1 -1
- package/dist/ast-analysis/engine.js +78 -48
- package/dist/ast-analysis/engine.js.map +1 -1
- package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
- package/dist/ast-analysis/visitors/ast-store-visitor.js +15 -18
- package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
- package/dist/db/connection.d.ts +1 -0
- package/dist/db/connection.d.ts.map +1 -1
- package/dist/db/connection.js +22 -4
- package/dist/db/connection.js.map +1 -1
- package/dist/db/repository/base.d.ts +35 -0
- package/dist/db/repository/base.d.ts.map +1 -1
- package/dist/db/repository/base.js +8 -0
- package/dist/db/repository/base.js.map +1 -1
- package/dist/db/repository/index.d.ts +1 -0
- package/dist/db/repository/index.d.ts.map +1 -1
- package/dist/db/repository/index.js.map +1 -1
- package/dist/db/repository/native-repository.d.ts +7 -1
- package/dist/db/repository/native-repository.d.ts.map +1 -1
- package/dist/db/repository/native-repository.js +46 -1
- package/dist/db/repository/native-repository.js.map +1 -1
- package/dist/domain/analysis/dependencies.d.ts +1 -28
- package/dist/domain/analysis/dependencies.d.ts.map +1 -1
- package/dist/domain/analysis/dependencies.js +12 -0
- package/dist/domain/analysis/dependencies.js.map +1 -1
- package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
- package/dist/domain/graph/builder/incremental.js +18 -0
- package/dist/domain/graph/builder/incremental.js.map +1 -1
- package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
- package/dist/domain/graph/builder/pipeline.js +293 -296
- package/dist/domain/graph/builder/pipeline.js.map +1 -1
- package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/build-edges.js +29 -2
- package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
- package/dist/domain/graph/builder/stages/resolve-imports.d.ts.map +1 -1
- package/dist/domain/graph/builder/stages/resolve-imports.js +19 -23
- package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
- package/dist/domain/graph/watcher.d.ts.map +1 -1
- package/dist/domain/graph/watcher.js +99 -95
- package/dist/domain/graph/watcher.js.map +1 -1
- package/dist/domain/parser.d.ts.map +1 -1
- package/dist/domain/parser.js +2 -0
- package/dist/domain/parser.js.map +1 -1
- package/dist/extractors/go.js +53 -35
- package/dist/extractors/go.js.map +1 -1
- package/dist/extractors/javascript.js +66 -27
- package/dist/extractors/javascript.js.map +1 -1
- package/dist/features/complexity.d.ts.map +1 -1
- package/dist/features/complexity.js +78 -58
- package/dist/features/complexity.js.map +1 -1
- package/dist/features/dataflow.d.ts.map +1 -1
- package/dist/features/dataflow.js +109 -118
- package/dist/features/dataflow.js.map +1 -1
- package/dist/features/structure.d.ts.map +1 -1
- package/dist/features/structure.js +147 -97
- package/dist/features/structure.js.map +1 -1
- package/dist/graph/algorithms/louvain.d.ts.map +1 -1
- package/dist/graph/algorithms/louvain.js +4 -2
- package/dist/graph/algorithms/louvain.js.map +1 -1
- package/dist/graph/classifiers/roles.d.ts +2 -0
- package/dist/graph/classifiers/roles.d.ts.map +1 -1
- package/dist/graph/classifiers/roles.js +13 -5
- package/dist/graph/classifiers/roles.js.map +1 -1
- package/dist/presentation/communities.d.ts.map +1 -1
- package/dist/presentation/communities.js +38 -34
- package/dist/presentation/communities.js.map +1 -1
- package/dist/presentation/manifesto.d.ts.map +1 -1
- package/dist/presentation/manifesto.js +31 -33
- package/dist/presentation/manifesto.js.map +1 -1
- package/dist/presentation/queries-cli/inspect.d.ts.map +1 -1
- package/dist/presentation/queries-cli/inspect.js +47 -46
- package/dist/presentation/queries-cli/inspect.js.map +1 -1
- package/dist/shared/file-utils.d.ts.map +1 -1
- package/dist/shared/file-utils.js +94 -72
- package/dist/shared/file-utils.js.map +1 -1
- package/dist/types.d.ts +81 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +7 -7
- package/src/ast-analysis/engine.ts +99 -55
- package/src/ast-analysis/visitors/ast-store-visitor.ts +19 -21
- package/src/db/connection.ts +24 -5
- package/src/db/repository/base.ts +43 -0
- package/src/db/repository/index.ts +1 -0
- package/src/db/repository/native-repository.ts +67 -1
- package/src/domain/analysis/dependencies.ts +13 -0
- package/src/domain/graph/builder/incremental.ts +21 -0
- package/src/domain/graph/builder/pipeline.ts +392 -362
- package/src/domain/graph/builder/stages/build-edges.ts +30 -1
- package/src/domain/graph/builder/stages/resolve-imports.ts +20 -20
- package/src/domain/graph/watcher.ts +118 -98
- package/src/domain/parser.ts +2 -0
- package/src/extractors/go.ts +57 -32
- package/src/extractors/javascript.ts +67 -27
- package/src/features/complexity.ts +94 -58
- package/src/features/dataflow.ts +153 -132
- package/src/features/structure.ts +167 -95
- package/src/graph/algorithms/louvain.ts +5 -2
- package/src/graph/classifiers/roles.ts +14 -5
- package/src/presentation/communities.ts +44 -39
- package/src/presentation/manifesto.ts +35 -38
- package/src/presentation/queries-cli/inspect.ts +48 -46
- package/src/shared/file-utils.ts +116 -77
- package/src/types.ts +85 -0
|
@@ -145,6 +145,7 @@ function computeImportEdgeMaps(db: BetterSqlite3Database): {
|
|
|
145
145
|
JOIN nodes n2 ON e.target_id = n2.id
|
|
146
146
|
WHERE e.kind IN ('imports', 'imports-type')
|
|
147
147
|
AND n1.file != n2.file
|
|
148
|
+
AND n2.kind = 'file'
|
|
148
149
|
`)
|
|
149
150
|
.all() as ImportEdge[];
|
|
150
151
|
|
|
@@ -444,6 +445,77 @@ export function classifyNodeRoles(
|
|
|
444
445
|
return classifyNodeRolesFull(db, emptySummary);
|
|
445
446
|
}
|
|
446
447
|
|
|
448
|
+
// ─── Shared role-classification helpers ───────────────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Build a role summary and group node IDs by role from classifier output.
|
|
452
|
+
* Shared between full and incremental classification paths.
|
|
453
|
+
*/
|
|
454
|
+
function buildRoleSummary(
|
|
455
|
+
rows: { id: number }[],
|
|
456
|
+
leafRows: { id: number }[],
|
|
457
|
+
roleMap: Map<string, string>,
|
|
458
|
+
emptySummary: RoleSummary,
|
|
459
|
+
): { summary: RoleSummary; idsByRole: Map<string, number[]> } {
|
|
460
|
+
const summary: RoleSummary = { ...emptySummary };
|
|
461
|
+
const idsByRole = new Map<string, number[]>();
|
|
462
|
+
|
|
463
|
+
// Leaf kinds are always dead-leaf — skip classifier
|
|
464
|
+
if (leafRows.length > 0) {
|
|
465
|
+
const leafIds: number[] = [];
|
|
466
|
+
for (const row of leafRows) leafIds.push(row.id);
|
|
467
|
+
idsByRole.set('dead-leaf', leafIds);
|
|
468
|
+
summary.dead += leafRows.length;
|
|
469
|
+
summary['dead-leaf'] += leafRows.length;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const row of rows) {
|
|
473
|
+
const role = roleMap.get(String(row.id)) || 'leaf';
|
|
474
|
+
if (role.startsWith('dead')) summary.dead++;
|
|
475
|
+
summary[role] = (summary[role] || 0) + 1;
|
|
476
|
+
let ids = idsByRole.get(role);
|
|
477
|
+
if (!ids) {
|
|
478
|
+
ids = [];
|
|
479
|
+
idsByRole.set(role, ids);
|
|
480
|
+
}
|
|
481
|
+
ids.push(row.id);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
return { summary, idsByRole };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Batch-update node roles in the database. Executes a reset callback
|
|
489
|
+
* first (full resets all nodes, incremental resets only affected files),
|
|
490
|
+
* then writes new roles in chunks.
|
|
491
|
+
*/
|
|
492
|
+
function batchUpdateRoles(
|
|
493
|
+
db: BetterSqlite3Database,
|
|
494
|
+
idsByRole: Map<string, number[]>,
|
|
495
|
+
resetFn: () => void,
|
|
496
|
+
): void {
|
|
497
|
+
const ROLE_CHUNK = 500;
|
|
498
|
+
const roleStmtCache = new Map<number, SqliteStatement>();
|
|
499
|
+
db.transaction(() => {
|
|
500
|
+
resetFn();
|
|
501
|
+
for (const [role, ids] of idsByRole) {
|
|
502
|
+
for (let i = 0; i < ids.length; i += ROLE_CHUNK) {
|
|
503
|
+
const end = Math.min(i + ROLE_CHUNK, ids.length);
|
|
504
|
+
const chunkSize = end - i;
|
|
505
|
+
let stmt = roleStmtCache.get(chunkSize);
|
|
506
|
+
if (!stmt) {
|
|
507
|
+
const placeholders = Array.from({ length: chunkSize }, () => '?').join(',');
|
|
508
|
+
stmt = db.prepare(`UPDATE nodes SET role = ? WHERE id IN (${placeholders})`);
|
|
509
|
+
roleStmtCache.set(chunkSize, stmt);
|
|
510
|
+
}
|
|
511
|
+
const vals: unknown[] = [role];
|
|
512
|
+
for (let j = i; j < end; j++) vals.push(ids[j]);
|
|
513
|
+
stmt.run(...vals);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
})();
|
|
517
|
+
}
|
|
518
|
+
|
|
447
519
|
function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSummary): RoleSummary {
|
|
448
520
|
// Leaf kinds (parameter, property) can never have callers/callees.
|
|
449
521
|
// Classify them directly as dead-leaf without the expensive fan-in/fan-out JOINs.
|
|
@@ -463,7 +535,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
|
|
|
463
535
|
COALESCE(fo.cnt, 0) AS fan_out
|
|
464
536
|
FROM nodes n
|
|
465
537
|
LEFT JOIN (
|
|
466
|
-
SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind
|
|
538
|
+
SELECT target_id, COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type') GROUP BY target_id
|
|
467
539
|
) fi ON n.id = fi.target_id
|
|
468
540
|
LEFT JOIN (
|
|
469
541
|
SELECT source_id, COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY source_id
|
|
@@ -489,12 +561,42 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
|
|
|
489
561
|
FROM edges e
|
|
490
562
|
JOIN nodes caller ON e.source_id = caller.id
|
|
491
563
|
JOIN nodes target ON e.target_id = target.id
|
|
492
|
-
WHERE e.kind
|
|
564
|
+
WHERE e.kind IN ('calls', 'imports-type') AND caller.file != target.file`,
|
|
493
565
|
)
|
|
494
566
|
.all() as { target_id: number }[]
|
|
495
567
|
).map((r) => r.target_id),
|
|
496
568
|
);
|
|
497
569
|
|
|
570
|
+
// Mark symbols as exported when their files are targets of reexports edges
|
|
571
|
+
// from production-reachable barrels (traces through multi-level chains) (#837)
|
|
572
|
+
const reexportExported = db
|
|
573
|
+
.prepare(
|
|
574
|
+
`WITH RECURSIVE prod_reachable(file_id) AS (
|
|
575
|
+
SELECT DISTINCT e.target_id
|
|
576
|
+
FROM edges e
|
|
577
|
+
JOIN nodes src ON e.source_id = src.id
|
|
578
|
+
WHERE e.kind IN ('imports', 'dynamic-imports', 'imports-type')
|
|
579
|
+
AND src.kind = 'file'
|
|
580
|
+
${testFilterSQL('src.file')}
|
|
581
|
+
UNION
|
|
582
|
+
SELECT e.target_id
|
|
583
|
+
FROM edges e
|
|
584
|
+
JOIN prod_reachable pr ON e.source_id = pr.file_id
|
|
585
|
+
WHERE e.kind = 'reexports'
|
|
586
|
+
)
|
|
587
|
+
SELECT DISTINCT n.id
|
|
588
|
+
FROM nodes n
|
|
589
|
+
JOIN nodes f ON f.file = n.file AND f.kind = 'file'
|
|
590
|
+
WHERE f.id IN (
|
|
591
|
+
SELECT e.target_id FROM edges e
|
|
592
|
+
WHERE e.kind = 'reexports'
|
|
593
|
+
AND e.source_id IN (SELECT file_id FROM prod_reachable)
|
|
594
|
+
)
|
|
595
|
+
AND n.kind NOT IN ('file', 'directory', 'parameter', 'property')`,
|
|
596
|
+
)
|
|
597
|
+
.all() as { id: number }[];
|
|
598
|
+
for (const r of reexportExported) exportedIds.add(r.id);
|
|
599
|
+
|
|
498
600
|
// Compute production fan-in (excluding callers in test files)
|
|
499
601
|
const prodFanInMap = new Map<number, number>();
|
|
500
602
|
const prodRows = db
|
|
@@ -502,7 +604,7 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
|
|
|
502
604
|
`SELECT e.target_id, COUNT(*) AS cnt
|
|
503
605
|
FROM edges e
|
|
504
606
|
JOIN nodes caller ON e.source_id = caller.id
|
|
505
|
-
WHERE e.kind
|
|
607
|
+
WHERE e.kind IN ('calls', 'imports-type')
|
|
506
608
|
${testFilterSQL('caller.file')}
|
|
507
609
|
GROUP BY e.target_id`,
|
|
508
610
|
)
|
|
@@ -511,6 +613,15 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
|
|
|
511
613
|
prodFanInMap.set(r.target_id, r.cnt);
|
|
512
614
|
}
|
|
513
615
|
|
|
616
|
+
// Files with at least one callable (non-constant) connected to the graph.
|
|
617
|
+
// Constants in these files are likely consumed locally via identifier reference.
|
|
618
|
+
const activeFiles = new Set<string>();
|
|
619
|
+
for (const r of rows) {
|
|
620
|
+
if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') {
|
|
621
|
+
activeFiles.add(r.file);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
514
625
|
// Delegate classification to the pure-logic classifier
|
|
515
626
|
const classifierInput = rows.map((r) => ({
|
|
516
627
|
id: String(r.id),
|
|
@@ -521,56 +632,16 @@ function classifyNodeRolesFull(db: BetterSqlite3Database, emptySummary: RoleSumm
|
|
|
521
632
|
fanOut: r.fan_out,
|
|
522
633
|
isExported: exportedIds.has(r.id),
|
|
523
634
|
productionFanIn: prodFanInMap.get(r.id) || 0,
|
|
635
|
+
hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined,
|
|
524
636
|
}));
|
|
525
637
|
|
|
526
638
|
const roleMap = classifyRoles(classifierInput);
|
|
527
639
|
|
|
528
|
-
|
|
529
|
-
const summary: RoleSummary = { ...emptySummary };
|
|
530
|
-
const idsByRole = new Map<string, number[]>();
|
|
531
|
-
|
|
532
|
-
// Leaf kinds are always dead-leaf -- skip classifier
|
|
533
|
-
if (leafRows.length > 0) {
|
|
534
|
-
const leafIds: number[] = [];
|
|
535
|
-
for (const row of leafRows) leafIds.push(row.id);
|
|
536
|
-
idsByRole.set('dead-leaf', leafIds);
|
|
537
|
-
summary.dead += leafRows.length;
|
|
538
|
-
summary['dead-leaf'] += leafRows.length;
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
for (const row of rows) {
|
|
542
|
-
const role = roleMap.get(String(row.id)) || 'leaf';
|
|
543
|
-
if (role.startsWith('dead')) summary.dead++;
|
|
544
|
-
summary[role] = (summary[role] || 0) + 1;
|
|
545
|
-
let ids = idsByRole.get(role);
|
|
546
|
-
if (!ids) {
|
|
547
|
-
ids = [];
|
|
548
|
-
idsByRole.set(role, ids);
|
|
549
|
-
}
|
|
550
|
-
ids.push(row.id);
|
|
551
|
-
}
|
|
640
|
+
const { summary, idsByRole } = buildRoleSummary(rows, leafRows, roleMap, emptySummary);
|
|
552
641
|
|
|
553
|
-
|
|
554
|
-
const ROLE_CHUNK = 500;
|
|
555
|
-
const roleStmtCache = new Map<number, SqliteStatement>();
|
|
556
|
-
db.transaction(() => {
|
|
642
|
+
batchUpdateRoles(db, idsByRole, () => {
|
|
557
643
|
db.prepare('UPDATE nodes SET role = NULL').run();
|
|
558
|
-
|
|
559
|
-
for (let i = 0; i < ids.length; i += ROLE_CHUNK) {
|
|
560
|
-
const end = Math.min(i + ROLE_CHUNK, ids.length);
|
|
561
|
-
const chunkSize = end - i;
|
|
562
|
-
let stmt = roleStmtCache.get(chunkSize);
|
|
563
|
-
if (!stmt) {
|
|
564
|
-
const placeholders = Array.from({ length: chunkSize }, () => '?').join(',');
|
|
565
|
-
stmt = db.prepare(`UPDATE nodes SET role = ? WHERE id IN (${placeholders})`);
|
|
566
|
-
roleStmtCache.set(chunkSize, stmt);
|
|
567
|
-
}
|
|
568
|
-
const vals: unknown[] = [role];
|
|
569
|
-
for (let j = i; j < end; j++) vals.push(ids[j]);
|
|
570
|
-
stmt.run(...vals);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
})();
|
|
644
|
+
});
|
|
574
645
|
|
|
575
646
|
return summary;
|
|
576
647
|
}
|
|
@@ -598,7 +669,7 @@ function classifyNodeRolesIncremental(
|
|
|
598
669
|
`SELECT DISTINCT n2.file FROM edges e
|
|
599
670
|
JOIN nodes n1 ON (e.source_id = n1.id OR e.target_id = n1.id)
|
|
600
671
|
JOIN nodes n2 ON (e.source_id = n2.id OR e.target_id = n2.id)
|
|
601
|
-
WHERE e.kind
|
|
672
|
+
WHERE e.kind IN ('calls', 'imports-type', 'reexports')
|
|
602
673
|
AND n1.file IN (${seedPlaceholders})
|
|
603
674
|
AND n2.file NOT IN (${seedPlaceholders})
|
|
604
675
|
AND n2.kind NOT IN ('file', 'directory')`,
|
|
@@ -610,7 +681,9 @@ function classifyNodeRolesIncremental(
|
|
|
610
681
|
// 1. Compute global medians from edge distribution (fast: scans edge index, no node join)
|
|
611
682
|
const fanInDist = (
|
|
612
683
|
db
|
|
613
|
-
.prepare(
|
|
684
|
+
.prepare(
|
|
685
|
+
`SELECT COUNT(*) AS cnt FROM edges WHERE kind IN ('calls', 'imports-type') GROUP BY target_id`,
|
|
686
|
+
)
|
|
614
687
|
.all() as { cnt: number }[]
|
|
615
688
|
)
|
|
616
689
|
.map((r) => r.cnt)
|
|
@@ -638,7 +711,7 @@ function classifyNodeRolesIncremental(
|
|
|
638
711
|
const rows = db
|
|
639
712
|
.prepare(
|
|
640
713
|
`SELECT n.id, n.name, n.kind, n.file,
|
|
641
|
-
(SELECT COUNT(*) FROM edges WHERE kind
|
|
714
|
+
(SELECT COUNT(*) FROM edges WHERE kind IN ('calls', 'imports-type') AND target_id = n.id) AS fan_in,
|
|
642
715
|
(SELECT COUNT(*) FROM edges WHERE kind = 'calls' AND source_id = n.id) AS fan_out
|
|
643
716
|
FROM nodes n
|
|
644
717
|
WHERE n.kind NOT IN ('file', 'directory', 'parameter', 'property')
|
|
@@ -664,13 +737,44 @@ function classifyNodeRolesIncremental(
|
|
|
664
737
|
FROM edges e
|
|
665
738
|
JOIN nodes caller ON e.source_id = caller.id
|
|
666
739
|
JOIN nodes target ON e.target_id = target.id
|
|
667
|
-
WHERE e.kind
|
|
740
|
+
WHERE e.kind IN ('calls', 'imports-type') AND caller.file != target.file
|
|
668
741
|
AND target.file IN (${placeholders})`,
|
|
669
742
|
)
|
|
670
743
|
.all(...allAffectedFiles) as { target_id: number }[]
|
|
671
744
|
).map((r) => r.target_id),
|
|
672
745
|
);
|
|
673
746
|
|
|
747
|
+
// 3b. Mark symbols as exported when their files are targets of reexports edges
|
|
748
|
+
// from production-reachable barrels (traces through multi-level chains) (#837)
|
|
749
|
+
const reexportExported = db
|
|
750
|
+
.prepare(
|
|
751
|
+
`WITH RECURSIVE prod_reachable(file_id) AS (
|
|
752
|
+
SELECT DISTINCT e.target_id
|
|
753
|
+
FROM edges e
|
|
754
|
+
JOIN nodes src ON e.source_id = src.id
|
|
755
|
+
WHERE e.kind IN ('imports', 'dynamic-imports', 'imports-type')
|
|
756
|
+
AND src.kind = 'file'
|
|
757
|
+
${testFilterSQL('src.file')}
|
|
758
|
+
UNION
|
|
759
|
+
SELECT e.target_id
|
|
760
|
+
FROM edges e
|
|
761
|
+
JOIN prod_reachable pr ON e.source_id = pr.file_id
|
|
762
|
+
WHERE e.kind = 'reexports'
|
|
763
|
+
)
|
|
764
|
+
SELECT DISTINCT n.id
|
|
765
|
+
FROM nodes n
|
|
766
|
+
JOIN nodes f ON f.file = n.file AND f.kind = 'file'
|
|
767
|
+
WHERE f.id IN (
|
|
768
|
+
SELECT e.target_id FROM edges e
|
|
769
|
+
WHERE e.kind = 'reexports'
|
|
770
|
+
AND e.source_id IN (SELECT file_id FROM prod_reachable)
|
|
771
|
+
)
|
|
772
|
+
AND n.kind NOT IN ('file', 'directory', 'parameter', 'property')
|
|
773
|
+
AND n.file IN (${placeholders})`,
|
|
774
|
+
)
|
|
775
|
+
.all(...allAffectedFiles) as { id: number }[];
|
|
776
|
+
for (const r of reexportExported) exportedIds.add(r.id);
|
|
777
|
+
|
|
674
778
|
// 4. Production fan-in for affected nodes only
|
|
675
779
|
const prodFanInMap = new Map<number, number>();
|
|
676
780
|
const prodRows = db
|
|
@@ -679,7 +783,7 @@ function classifyNodeRolesIncremental(
|
|
|
679
783
|
FROM edges e
|
|
680
784
|
JOIN nodes caller ON e.source_id = caller.id
|
|
681
785
|
JOIN nodes target ON e.target_id = target.id
|
|
682
|
-
WHERE e.kind
|
|
786
|
+
WHERE e.kind IN ('calls', 'imports-type')
|
|
683
787
|
AND target.file IN (${placeholders})
|
|
684
788
|
${testFilterSQL('caller.file')}
|
|
685
789
|
GROUP BY e.target_id`,
|
|
@@ -690,6 +794,13 @@ function classifyNodeRolesIncremental(
|
|
|
690
794
|
}
|
|
691
795
|
|
|
692
796
|
// 5. Classify affected nodes using global medians
|
|
797
|
+
const activeFiles = new Set<string>();
|
|
798
|
+
for (const r of rows) {
|
|
799
|
+
if ((r.fan_in > 0 || r.fan_out > 0) && r.kind !== 'constant') {
|
|
800
|
+
activeFiles.add(r.file);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
693
804
|
const classifierInput = rows.map((r) => ({
|
|
694
805
|
id: String(r.id),
|
|
695
806
|
name: r.name,
|
|
@@ -699,59 +810,20 @@ function classifyNodeRolesIncremental(
|
|
|
699
810
|
fanOut: r.fan_out,
|
|
700
811
|
isExported: exportedIds.has(r.id),
|
|
701
812
|
productionFanIn: prodFanInMap.get(r.id) || 0,
|
|
813
|
+
hasActiveFileSiblings: r.kind === 'constant' ? activeFiles.has(r.file) : undefined,
|
|
702
814
|
}));
|
|
703
815
|
|
|
704
816
|
const roleMap = classifyRoles(classifierInput, globalMedians);
|
|
705
817
|
|
|
706
818
|
// 6. Build summary (only for affected nodes) and update only those nodes
|
|
707
|
-
const summary
|
|
708
|
-
const idsByRole = new Map<string, number[]>();
|
|
709
|
-
|
|
710
|
-
// Leaf kinds are always dead-leaf -- skip classifier
|
|
711
|
-
if (leafRows.length > 0) {
|
|
712
|
-
const leafIds: number[] = [];
|
|
713
|
-
for (const row of leafRows) leafIds.push(row.id);
|
|
714
|
-
idsByRole.set('dead-leaf', leafIds);
|
|
715
|
-
summary.dead += leafRows.length;
|
|
716
|
-
summary['dead-leaf'] += leafRows.length;
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
for (const row of rows) {
|
|
720
|
-
const role = roleMap.get(String(row.id)) || 'leaf';
|
|
721
|
-
if (role.startsWith('dead')) summary.dead++;
|
|
722
|
-
summary[role] = (summary[role] || 0) + 1;
|
|
723
|
-
let ids = idsByRole.get(role);
|
|
724
|
-
if (!ids) {
|
|
725
|
-
ids = [];
|
|
726
|
-
idsByRole.set(role, ids);
|
|
727
|
-
}
|
|
728
|
-
ids.push(row.id);
|
|
729
|
-
}
|
|
819
|
+
const { summary, idsByRole } = buildRoleSummary(rows, leafRows, roleMap, emptySummary);
|
|
730
820
|
|
|
731
|
-
|
|
732
|
-
const ROLE_CHUNK = 500;
|
|
733
|
-
const roleStmtCache = new Map<number, SqliteStatement>();
|
|
734
|
-
db.transaction(() => {
|
|
821
|
+
batchUpdateRoles(db, idsByRole, () => {
|
|
735
822
|
// Reset roles only for affected files' nodes
|
|
736
823
|
db.prepare(
|
|
737
824
|
`UPDATE nodes SET role = NULL WHERE file IN (${placeholders}) AND kind NOT IN ('file', 'directory')`,
|
|
738
825
|
).run(...allAffectedFiles);
|
|
739
|
-
|
|
740
|
-
for (let i = 0; i < ids.length; i += ROLE_CHUNK) {
|
|
741
|
-
const end = Math.min(i + ROLE_CHUNK, ids.length);
|
|
742
|
-
const chunkSize = end - i;
|
|
743
|
-
let stmt = roleStmtCache.get(chunkSize);
|
|
744
|
-
if (!stmt) {
|
|
745
|
-
const ph = Array.from({ length: chunkSize }, () => '?').join(',');
|
|
746
|
-
stmt = db.prepare(`UPDATE nodes SET role = ? WHERE id IN (${ph})`);
|
|
747
|
-
roleStmtCache.set(chunkSize, stmt);
|
|
748
|
-
}
|
|
749
|
-
const vals: unknown[] = [role];
|
|
750
|
-
for (let j = i; j < end; j++) vals.push(ids[j]);
|
|
751
|
-
stmt.run(...vals);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
})();
|
|
826
|
+
});
|
|
755
827
|
|
|
756
828
|
return summary;
|
|
757
829
|
}
|
|
@@ -12,6 +12,9 @@ import type { CodeGraph } from '../model.js';
|
|
|
12
12
|
import type { DetectClustersResult } from './leiden/index.js';
|
|
13
13
|
import { detectClusters } from './leiden/index.js';
|
|
14
14
|
|
|
15
|
+
/** Default random seed for deterministic community detection. */
|
|
16
|
+
const DEFAULT_RANDOM_SEED = 42;
|
|
17
|
+
|
|
15
18
|
export interface LouvainOptions {
|
|
16
19
|
resolution?: number;
|
|
17
20
|
maxLevels?: number;
|
|
@@ -42,7 +45,7 @@ export function louvainCommunities(graph: CodeGraph, opts: LouvainOptions = {}):
|
|
|
42
45
|
}
|
|
43
46
|
const edges = graph.toEdgeArray();
|
|
44
47
|
const nodeIds = graph.nodeIds();
|
|
45
|
-
const result = native.louvainCommunities(edges, nodeIds, resolution,
|
|
48
|
+
const result = native.louvainCommunities(edges, nodeIds, resolution, DEFAULT_RANDOM_SEED);
|
|
46
49
|
const assignments = new Map<string, number>();
|
|
47
50
|
for (const entry of result.assignments) {
|
|
48
51
|
assignments.set(entry.node, entry.community);
|
|
@@ -57,7 +60,7 @@ export function louvainCommunities(graph: CodeGraph, opts: LouvainOptions = {}):
|
|
|
57
60
|
function louvainJS(graph: CodeGraph, opts: LouvainOptions, resolution: number): LouvainResult {
|
|
58
61
|
const result: DetectClustersResult = detectClusters(graph, {
|
|
59
62
|
resolution,
|
|
60
|
-
randomSeed:
|
|
63
|
+
randomSeed: DEFAULT_RANDOM_SEED,
|
|
61
64
|
directed: false,
|
|
62
65
|
...(opts.maxLevels != null && { maxLevels: opts.maxLevels }),
|
|
63
66
|
...(opts.maxLocalPasses != null && { maxLocalPasses: opts.maxLocalPasses }),
|
|
@@ -74,6 +74,8 @@ export interface RoleClassificationNode {
|
|
|
74
74
|
isExported: boolean;
|
|
75
75
|
testOnlyFanIn?: number;
|
|
76
76
|
productionFanIn?: number;
|
|
77
|
+
/** True when the same file contains at least one non-constant callable connected to the graph (fanIn > 0 or fanOut > 0). */
|
|
78
|
+
hasActiveFileSiblings?: boolean;
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
/**
|
|
@@ -115,13 +117,20 @@ export function classifyRoles(
|
|
|
115
117
|
if (isFrameworkEntry) {
|
|
116
118
|
role = 'entry';
|
|
117
119
|
} else if (node.fanIn === 0 && !node.isExported) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
if (node.kind === 'constant' && node.hasActiveFileSiblings) {
|
|
121
|
+
// Constants consumed via identifier reference (not calls) have no
|
|
122
|
+
// inbound call edges. If the same file has active callables, the
|
|
123
|
+
// constant is almost certainly used locally — classify as leaf.
|
|
124
|
+
role = 'leaf';
|
|
125
|
+
} else {
|
|
126
|
+
role =
|
|
127
|
+
node.testOnlyFanIn != null && node.testOnlyFanIn > 0
|
|
128
|
+
? 'test-only'
|
|
129
|
+
: classifyDeadSubRole(node);
|
|
130
|
+
}
|
|
122
131
|
} else if (node.fanIn === 0 && node.isExported) {
|
|
123
132
|
role = 'entry';
|
|
124
|
-
} else if (hasProdFanIn && node.fanIn > 0 && node.productionFanIn === 0) {
|
|
133
|
+
} else if (hasProdFanIn && node.fanIn > 0 && node.productionFanIn === 0 && !node.isExported) {
|
|
125
134
|
role = 'test-only';
|
|
126
135
|
} else if (highIn && !highOut) {
|
|
127
136
|
role = 'core';
|
|
@@ -44,6 +44,48 @@ interface CommunitiesResult {
|
|
|
44
44
|
drift: DriftAnalysis;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function renderCommunityList(communityList: Community[]): void {
|
|
48
|
+
for (const c of communityList) {
|
|
49
|
+
const dirs = Object.entries(c.directories)
|
|
50
|
+
.sort((a, b) => b[1] - a[1])
|
|
51
|
+
.map(([d, n]) => `${d} (${n})`)
|
|
52
|
+
.join(', ');
|
|
53
|
+
console.log(` Community ${c.id} (${c.size} members): ${dirs}`);
|
|
54
|
+
if (c.members) {
|
|
55
|
+
const shown = c.members.slice(0, 8);
|
|
56
|
+
for (const m of shown) {
|
|
57
|
+
const kind = m.kind ? ` [${m.kind}]` : '';
|
|
58
|
+
console.log(` - ${m.name}${kind} ${m.file}`);
|
|
59
|
+
}
|
|
60
|
+
if (c.members.length > 8) {
|
|
61
|
+
console.log(` ... and ${c.members.length - 8} more`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderDriftAnalysis(d: DriftAnalysis, driftScore: number): void {
|
|
68
|
+
if (d.splitCandidates.length === 0 && d.mergeCandidates.length === 0) return;
|
|
69
|
+
|
|
70
|
+
console.log(`\n# Drift Analysis (score: ${driftScore}%)\n`);
|
|
71
|
+
|
|
72
|
+
if (d.splitCandidates.length > 0) {
|
|
73
|
+
console.log(' Split candidates (directories spanning multiple communities):');
|
|
74
|
+
for (const s of d.splitCandidates.slice(0, 10)) {
|
|
75
|
+
console.log(` - ${s.directory} → ${s.communityCount} communities`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (d.mergeCandidates.length > 0) {
|
|
80
|
+
console.log(' Merge candidates (communities spanning multiple directories):');
|
|
81
|
+
for (const m of d.mergeCandidates.slice(0, 10)) {
|
|
82
|
+
console.log(
|
|
83
|
+
` - Community ${m.communityId} (${m.size} members) → ${m.directoryCount} dirs: ${m.directories.join(', ')}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
47
89
|
export function communities(customDbPath: string | undefined, opts: CommunitiesCliOpts = {}): void {
|
|
48
90
|
const data = communitiesData(customDbPath, opts) as unknown as CommunitiesResult;
|
|
49
91
|
|
|
@@ -64,46 +106,9 @@ export function communities(customDbPath: string | undefined, opts: CommunitiesC
|
|
|
64
106
|
);
|
|
65
107
|
|
|
66
108
|
if (!opts.drift) {
|
|
67
|
-
|
|
68
|
-
const dirs = Object.entries(c.directories)
|
|
69
|
-
.sort((a, b) => b[1] - a[1])
|
|
70
|
-
.map(([d, n]) => `${d} (${n})`)
|
|
71
|
-
.join(', ');
|
|
72
|
-
console.log(` Community ${c.id} (${c.size} members): ${dirs}`);
|
|
73
|
-
if (c.members) {
|
|
74
|
-
const shown = c.members.slice(0, 8);
|
|
75
|
-
for (const m of shown) {
|
|
76
|
-
const kind = m.kind ? ` [${m.kind}]` : '';
|
|
77
|
-
console.log(` - ${m.name}${kind} ${m.file}`);
|
|
78
|
-
}
|
|
79
|
-
if (c.members.length > 8) {
|
|
80
|
-
console.log(` ... and ${c.members.length - 8} more`);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Drift analysis
|
|
87
|
-
const d = data.drift;
|
|
88
|
-
if (d.splitCandidates.length > 0 || d.mergeCandidates.length > 0) {
|
|
89
|
-
console.log(`\n# Drift Analysis (score: ${data.summary.driftScore}%)\n`);
|
|
90
|
-
|
|
91
|
-
if (d.splitCandidates.length > 0) {
|
|
92
|
-
console.log(' Split candidates (directories spanning multiple communities):');
|
|
93
|
-
for (const s of d.splitCandidates.slice(0, 10)) {
|
|
94
|
-
console.log(` - ${s.directory} → ${s.communityCount} communities`);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (d.mergeCandidates.length > 0) {
|
|
99
|
-
console.log(' Merge candidates (communities spanning multiple directories):');
|
|
100
|
-
for (const m of d.mergeCandidates.slice(0, 10)) {
|
|
101
|
-
console.log(
|
|
102
|
-
` - Community ${m.communityId} (${m.size} members) → ${m.directoryCount} dirs: ${m.directories.join(', ')}`,
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
109
|
+
renderCommunityList(data.communities);
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
renderDriftAnalysis(data.drift, data.summary.driftScore);
|
|
108
113
|
console.log();
|
|
109
114
|
}
|
|
@@ -22,17 +22,9 @@ interface ManifestoViolationRow {
|
|
|
22
22
|
line?: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
const data = manifestoData(customDbPath, opts as any) as any;
|
|
27
|
-
|
|
28
|
-
if (outputResult(data, 'violations', opts)) {
|
|
29
|
-
if (!data.passed) process.exitCode = 1;
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
|
|
25
|
+
function renderRulesTable(data: any): void {
|
|
33
26
|
console.log('\n# Manifesto Rules\n');
|
|
34
27
|
|
|
35
|
-
// Rules table
|
|
36
28
|
console.log(
|
|
37
29
|
` ${'Rule'.padEnd(20)} ${'Level'.padEnd(10)} ${'Status'.padEnd(8)} ${'Warn'.padStart(6)} ${'Fail'.padStart(6)} ${'Violations'.padStart(11)}`,
|
|
38
30
|
);
|
|
@@ -49,44 +41,49 @@ export function manifesto(customDbPath: string | undefined, opts: ManifestoOpts
|
|
|
49
41
|
);
|
|
50
42
|
}
|
|
51
43
|
|
|
52
|
-
// Summary
|
|
53
44
|
const s = data.summary;
|
|
54
45
|
console.log(
|
|
55
46
|
`\n ${s.total} rules | ${s.passed} passed | ${s.warned} warned | ${s.failed} failed | ${s.violationCount} violations`,
|
|
56
47
|
);
|
|
48
|
+
}
|
|
57
49
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
50
|
+
function renderViolationList(
|
|
51
|
+
label: string,
|
|
52
|
+
violations: ManifestoViolationRow[],
|
|
53
|
+
maxShown = 20,
|
|
54
|
+
): void {
|
|
55
|
+
if (violations.length === 0) return;
|
|
56
|
+
console.log(`\n## ${label} (${violations.length})\n`);
|
|
57
|
+
for (const v of violations.slice(0, maxShown)) {
|
|
58
|
+
const loc = v.line ? `${v.file}:${v.line}` : v.file;
|
|
59
|
+
const tag = label === 'Failures' ? 'FAIL' : 'WARN';
|
|
60
|
+
console.log(
|
|
61
|
+
` [${tag}] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
if (violations.length > maxShown) {
|
|
65
|
+
console.log(` ... and ${violations.length - maxShown} more`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function renderViolations(violations: ManifestoViolationRow[]): void {
|
|
70
|
+
if (violations.length === 0) return;
|
|
71
|
+
const failViolations = violations.filter((v) => v.level === 'fail');
|
|
72
|
+
const warnViolations = violations.filter((v) => v.level === 'warn');
|
|
73
|
+
renderViolationList('Failures', failViolations);
|
|
74
|
+
renderViolationList('Warnings', warnViolations);
|
|
75
|
+
}
|
|
62
76
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
for (const v of failViolations.slice(0, 20)) {
|
|
66
|
-
const loc = v.line ? `${v.file}:${v.line}` : v.file;
|
|
67
|
-
console.log(
|
|
68
|
-
` [FAIL] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`,
|
|
69
|
-
);
|
|
70
|
-
}
|
|
71
|
-
if (failViolations.length > 20) {
|
|
72
|
-
console.log(` ... and ${failViolations.length - 20} more`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
77
|
+
export function manifesto(customDbPath: string | undefined, opts: ManifestoOpts = {}): void {
|
|
78
|
+
const data = manifestoData(customDbPath, opts as any) as any;
|
|
75
79
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const loc = v.line ? `${v.file}:${v.line}` : v.file;
|
|
80
|
-
console.log(
|
|
81
|
-
` [WARN] ${v.rule}: ${v.name} (${v.value}) at ${loc} — threshold ${v.threshold}`,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
if (warnViolations.length > 20) {
|
|
85
|
-
console.log(` ... and ${warnViolations.length - 20} more`);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
80
|
+
if (outputResult(data, 'violations', opts)) {
|
|
81
|
+
if (!data.passed) process.exitCode = 1;
|
|
82
|
+
return;
|
|
88
83
|
}
|
|
89
84
|
|
|
85
|
+
renderRulesTable(data);
|
|
86
|
+
renderViolations(data.violations);
|
|
90
87
|
console.log();
|
|
91
88
|
|
|
92
89
|
if (!data.passed) {
|