@optave/codegraph 3.9.0 → 3.9.2

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.
Files changed (196) hide show
  1. package/README.md +12 -13
  2. package/dist/ast-analysis/engine.d.ts.map +1 -1
  3. package/dist/ast-analysis/engine.js +78 -48
  4. package/dist/ast-analysis/engine.js.map +1 -1
  5. package/dist/ast-analysis/visitors/ast-store-visitor.d.ts.map +1 -1
  6. package/dist/ast-analysis/visitors/ast-store-visitor.js +15 -18
  7. package/dist/ast-analysis/visitors/ast-store-visitor.js.map +1 -1
  8. package/dist/cli/commands/batch.d.ts.map +1 -1
  9. package/dist/cli/commands/batch.js +5 -17
  10. package/dist/cli/commands/batch.js.map +1 -1
  11. package/dist/cli/commands/structure.d.ts.map +1 -1
  12. package/dist/cli/commands/structure.js +18 -1
  13. package/dist/cli/commands/structure.js.map +1 -1
  14. package/dist/db/connection.d.ts +3 -0
  15. package/dist/db/connection.d.ts.map +1 -1
  16. package/dist/db/connection.js +24 -6
  17. package/dist/db/connection.js.map +1 -1
  18. package/dist/db/index.d.ts +1 -1
  19. package/dist/db/index.d.ts.map +1 -1
  20. package/dist/db/index.js +1 -1
  21. package/dist/db/index.js.map +1 -1
  22. package/dist/db/repository/base.d.ts +35 -0
  23. package/dist/db/repository/base.d.ts.map +1 -1
  24. package/dist/db/repository/base.js +8 -0
  25. package/dist/db/repository/base.js.map +1 -1
  26. package/dist/db/repository/index.d.ts +1 -0
  27. package/dist/db/repository/index.d.ts.map +1 -1
  28. package/dist/db/repository/index.js.map +1 -1
  29. package/dist/db/repository/native-repository.d.ts +7 -1
  30. package/dist/db/repository/native-repository.d.ts.map +1 -1
  31. package/dist/db/repository/native-repository.js +46 -1
  32. package/dist/db/repository/native-repository.js.map +1 -1
  33. package/dist/domain/analysis/context.d.ts.map +1 -1
  34. package/dist/domain/analysis/context.js +5 -15
  35. package/dist/domain/analysis/context.js.map +1 -1
  36. package/dist/domain/analysis/dependencies.d.ts +6 -33
  37. package/dist/domain/analysis/dependencies.d.ts.map +1 -1
  38. package/dist/domain/analysis/dependencies.js +18 -16
  39. package/dist/domain/analysis/dependencies.js.map +1 -1
  40. package/dist/domain/analysis/fn-impact.js +2 -2
  41. package/dist/domain/analysis/fn-impact.js.map +1 -1
  42. package/dist/domain/analysis/implementations.d.ts.map +1 -1
  43. package/dist/domain/analysis/implementations.js +3 -13
  44. package/dist/domain/analysis/implementations.js.map +1 -1
  45. package/dist/domain/graph/builder/context.d.ts +4 -0
  46. package/dist/domain/graph/builder/context.d.ts.map +1 -1
  47. package/dist/domain/graph/builder/context.js +4 -0
  48. package/dist/domain/graph/builder/context.js.map +1 -1
  49. package/dist/domain/graph/builder/incremental.d.ts.map +1 -1
  50. package/dist/domain/graph/builder/incremental.js +18 -0
  51. package/dist/domain/graph/builder/incremental.js.map +1 -1
  52. package/dist/domain/graph/builder/native-db-proxy.d.ts +24 -0
  53. package/dist/domain/graph/builder/native-db-proxy.d.ts.map +1 -0
  54. package/dist/domain/graph/builder/native-db-proxy.js +87 -0
  55. package/dist/domain/graph/builder/native-db-proxy.js.map +1 -0
  56. package/dist/domain/graph/builder/pipeline.d.ts.map +1 -1
  57. package/dist/domain/graph/builder/pipeline.js +410 -349
  58. package/dist/domain/graph/builder/pipeline.js.map +1 -1
  59. package/dist/domain/graph/builder/stages/build-edges.d.ts.map +1 -1
  60. package/dist/domain/graph/builder/stages/build-edges.js +44 -4
  61. package/dist/domain/graph/builder/stages/build-edges.js.map +1 -1
  62. package/dist/domain/graph/builder/stages/build-structure.js +2 -2
  63. package/dist/domain/graph/builder/stages/build-structure.js.map +1 -1
  64. package/dist/domain/graph/builder/stages/detect-changes.d.ts.map +1 -1
  65. package/dist/domain/graph/builder/stages/detect-changes.js +6 -28
  66. package/dist/domain/graph/builder/stages/detect-changes.js.map +1 -1
  67. package/dist/domain/graph/builder/stages/finalize.js +1 -1
  68. package/dist/domain/graph/builder/stages/finalize.js.map +1 -1
  69. package/dist/domain/graph/builder/stages/insert-nodes.d.ts.map +1 -1
  70. package/dist/domain/graph/builder/stages/insert-nodes.js +16 -12
  71. package/dist/domain/graph/builder/stages/insert-nodes.js.map +1 -1
  72. package/dist/domain/graph/builder/stages/resolve-imports.d.ts.map +1 -1
  73. package/dist/domain/graph/builder/stages/resolve-imports.js +21 -26
  74. package/dist/domain/graph/builder/stages/resolve-imports.js.map +1 -1
  75. package/dist/domain/graph/watcher.d.ts.map +1 -1
  76. package/dist/domain/graph/watcher.js +99 -95
  77. package/dist/domain/graph/watcher.js.map +1 -1
  78. package/dist/domain/parser.d.ts.map +1 -1
  79. package/dist/domain/parser.js +7 -2
  80. package/dist/domain/parser.js.map +1 -1
  81. package/dist/domain/queries.d.ts +1 -1
  82. package/dist/domain/queries.d.ts.map +1 -1
  83. package/dist/domain/queries.js +1 -1
  84. package/dist/domain/queries.js.map +1 -1
  85. package/dist/extractors/go.js +53 -35
  86. package/dist/extractors/go.js.map +1 -1
  87. package/dist/extractors/javascript.js +66 -27
  88. package/dist/extractors/javascript.js.map +1 -1
  89. package/dist/features/audit.d.ts.map +1 -1
  90. package/dist/features/audit.js +3 -2
  91. package/dist/features/audit.js.map +1 -1
  92. package/dist/features/boundaries.d.ts.map +1 -1
  93. package/dist/features/boundaries.js +3 -5
  94. package/dist/features/boundaries.js.map +1 -1
  95. package/dist/features/branch-compare.d.ts.map +1 -1
  96. package/dist/features/branch-compare.js +2 -1
  97. package/dist/features/branch-compare.js.map +1 -1
  98. package/dist/features/complexity.d.ts.map +1 -1
  99. package/dist/features/complexity.js +78 -58
  100. package/dist/features/complexity.js.map +1 -1
  101. package/dist/features/dataflow.d.ts.map +1 -1
  102. package/dist/features/dataflow.js +109 -118
  103. package/dist/features/dataflow.js.map +1 -1
  104. package/dist/features/flow.d.ts.map +1 -1
  105. package/dist/features/flow.js +2 -1
  106. package/dist/features/flow.js.map +1 -1
  107. package/dist/features/manifesto.d.ts.map +1 -1
  108. package/dist/features/manifesto.js +15 -1
  109. package/dist/features/manifesto.js.map +1 -1
  110. package/dist/features/structure.d.ts.map +1 -1
  111. package/dist/features/structure.js +147 -97
  112. package/dist/features/structure.js.map +1 -1
  113. package/dist/graph/algorithms/louvain.d.ts.map +1 -1
  114. package/dist/graph/algorithms/louvain.js +4 -2
  115. package/dist/graph/algorithms/louvain.js.map +1 -1
  116. package/dist/graph/classifiers/roles.d.ts +2 -0
  117. package/dist/graph/classifiers/roles.d.ts.map +1 -1
  118. package/dist/graph/classifiers/roles.js +13 -5
  119. package/dist/graph/classifiers/roles.js.map +1 -1
  120. package/dist/infrastructure/config.d.ts +1 -0
  121. package/dist/infrastructure/config.d.ts.map +1 -1
  122. package/dist/infrastructure/config.js +1 -0
  123. package/dist/infrastructure/config.js.map +1 -1
  124. package/dist/presentation/batch.d.ts.map +1 -1
  125. package/dist/presentation/batch.js +1 -0
  126. package/dist/presentation/batch.js.map +1 -1
  127. package/dist/presentation/communities.d.ts.map +1 -1
  128. package/dist/presentation/communities.js +38 -34
  129. package/dist/presentation/communities.js.map +1 -1
  130. package/dist/presentation/manifesto.d.ts.map +1 -1
  131. package/dist/presentation/manifesto.js +31 -33
  132. package/dist/presentation/manifesto.js.map +1 -1
  133. package/dist/presentation/queries-cli/inspect.d.ts.map +1 -1
  134. package/dist/presentation/queries-cli/inspect.js +47 -46
  135. package/dist/presentation/queries-cli/inspect.js.map +1 -1
  136. package/dist/presentation/structure.d.ts +1 -1
  137. package/dist/presentation/structure.d.ts.map +1 -1
  138. package/dist/presentation/structure.js +1 -1
  139. package/dist/presentation/structure.js.map +1 -1
  140. package/dist/shared/file-utils.d.ts.map +1 -1
  141. package/dist/shared/file-utils.js +94 -72
  142. package/dist/shared/file-utils.js.map +1 -1
  143. package/dist/shared/normalize.d.ts +12 -0
  144. package/dist/shared/normalize.d.ts.map +1 -1
  145. package/dist/shared/normalize.js +4 -0
  146. package/dist/shared/normalize.js.map +1 -1
  147. package/dist/types.d.ts +82 -1
  148. package/dist/types.d.ts.map +1 -1
  149. package/package.json +7 -7
  150. package/src/ast-analysis/engine.ts +99 -55
  151. package/src/ast-analysis/visitors/ast-store-visitor.ts +19 -21
  152. package/src/cli/commands/batch.ts +5 -26
  153. package/src/cli/commands/structure.ts +21 -1
  154. package/src/db/connection.ts +26 -7
  155. package/src/db/index.ts +2 -0
  156. package/src/db/repository/base.ts +43 -0
  157. package/src/db/repository/index.ts +1 -0
  158. package/src/db/repository/native-repository.ts +67 -1
  159. package/src/domain/analysis/context.ts +5 -15
  160. package/src/domain/analysis/dependencies.ts +19 -16
  161. package/src/domain/analysis/fn-impact.ts +2 -2
  162. package/src/domain/analysis/implementations.ts +3 -13
  163. package/src/domain/graph/builder/context.ts +4 -0
  164. package/src/domain/graph/builder/incremental.ts +21 -0
  165. package/src/domain/graph/builder/native-db-proxy.ts +98 -0
  166. package/src/domain/graph/builder/pipeline.ts +514 -416
  167. package/src/domain/graph/builder/stages/build-edges.ts +45 -3
  168. package/src/domain/graph/builder/stages/build-structure.ts +2 -2
  169. package/src/domain/graph/builder/stages/detect-changes.ts +11 -33
  170. package/src/domain/graph/builder/stages/finalize.ts +1 -1
  171. package/src/domain/graph/builder/stages/insert-nodes.ts +17 -14
  172. package/src/domain/graph/builder/stages/resolve-imports.ts +22 -23
  173. package/src/domain/graph/watcher.ts +118 -98
  174. package/src/domain/parser.ts +8 -2
  175. package/src/domain/queries.ts +1 -1
  176. package/src/extractors/go.ts +57 -32
  177. package/src/extractors/javascript.ts +67 -27
  178. package/src/features/audit.ts +3 -2
  179. package/src/features/boundaries.ts +3 -5
  180. package/src/features/branch-compare.ts +2 -3
  181. package/src/features/complexity.ts +94 -58
  182. package/src/features/dataflow.ts +153 -132
  183. package/src/features/flow.ts +2 -1
  184. package/src/features/manifesto.ts +15 -1
  185. package/src/features/structure.ts +167 -95
  186. package/src/graph/algorithms/louvain.ts +5 -2
  187. package/src/graph/classifiers/roles.ts +14 -5
  188. package/src/infrastructure/config.ts +1 -0
  189. package/src/presentation/batch.ts +1 -0
  190. package/src/presentation/communities.ts +44 -39
  191. package/src/presentation/manifesto.ts +35 -38
  192. package/src/presentation/queries-cli/inspect.ts +48 -46
  193. package/src/presentation/structure.ts +2 -2
  194. package/src/shared/file-utils.ts +116 -77
  195. package/src/shared/normalize.ts +10 -0
  196. package/src/types.ts +86 -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 = 'calls' GROUP BY target_id
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 = 'calls' AND caller.file != target.file`,
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 = 'calls'
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
- // Build summary and group updates by role for batch UPDATE
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
- // Batch UPDATE: one statement per role instead of one per node
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
- for (const [role, ids] of idsByRole) {
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 = 'calls'
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(`SELECT COUNT(*) AS cnt FROM edges WHERE kind = 'calls' GROUP BY target_id`)
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 = 'calls' AND target_id = n.id) AS fan_in,
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 = 'calls' AND caller.file != target.file
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 = 'calls'
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: RoleSummary = { ...emptySummary };
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
- // Only update affected nodes — no global NULL reset
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
- for (const [role, ids] of idsByRole) {
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, 42);
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: 42,
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
- role =
119
- node.testOnlyFanIn != null && node.testOnlyFanIn > 0
120
- ? 'test-only'
121
- : classifyDeadSubRole(node);
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';
@@ -23,6 +23,7 @@ export const DEFAULTS = {
23
23
  incremental: true,
24
24
  dbPath: '.codegraph/graph.db',
25
25
  driftThreshold: 0.2,
26
+ smallFilesThreshold: 5,
26
27
  },
27
28
  query: {
28
29
  defaultDepth: 3,
@@ -36,6 +36,7 @@ export function batchQuery(
36
36
  const { command: defaultCommand = 'where', ...rest } = opts;
37
37
  const isMulti =
38
38
  targets.length > 0 &&
39
+ targets[0] !== null &&
39
40
  typeof targets[0] === 'object' &&
40
41
  !!(targets[0] as MultiBatchTarget).command;
41
42
 
@@ -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
- for (const c of data.communities) {
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
- export function manifesto(customDbPath: string | undefined, opts: ManifestoOpts = {}): void {
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
- // Violations detail
59
- if (data.violations.length > 0) {
60
- const failViolations = data.violations.filter((v: ManifestoViolationRow) => v.level === 'fail');
61
- const warnViolations = data.violations.filter((v: ManifestoViolationRow) => v.level === 'warn');
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
- if (failViolations.length > 0) {
64
- console.log(`\n## Failures (${failViolations.length})\n`);
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
- if (warnViolations.length > 0) {
77
- console.log(`\n## Warnings (${warnViolations.length})\n`);
78
- for (const v of warnViolations.slice(0, 20)) {
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) {