@ctxo/cli 0.7.2 → 0.8.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.
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ArchitecturalOverlay,
4
+ BoundaryViolationDetector,
5
+ CommunitySnapshotWriter,
4
6
  DeadCodeDetector,
7
+ DriftDetector,
5
8
  PageRankCalculator,
6
9
  RevertDetector,
7
10
  SessionRecorderAdapter,
@@ -9,28 +12,32 @@ import {
9
12
  SqliteStorageAdapter,
10
13
  SymbolGraph,
11
14
  aggregateCoChanges,
12
- createLogger,
13
15
  detectWorkspace,
14
16
  indexIgnorePatterns,
15
17
  indexIgnoreProjectPatterns,
16
18
  loadConfig,
17
- makeGlobMatcher
18
- } from "./chunk-4M4V5V36.js";
19
+ makeGlobMatcher,
20
+ watchSnapshotMinFileChanges
21
+ } from "./chunk-HXEPS2XY.js";
22
+ import {
23
+ atomicWrite,
24
+ createLogger
25
+ } from "./chunk-6EKERTJ7.js";
19
26
  import {
20
27
  JsonIndexReader
21
- } from "./chunk-WXA2JJLH.js";
28
+ } from "./chunk-2TVHKCCU.js";
22
29
  import {
23
30
  StalenessDetector
24
31
  } from "./chunk-PSKDDLSF.js";
25
32
 
26
33
  // src/cli/cli-router.ts
27
- import { readFileSync as readFileSync13, existsSync as existsSync21 } from "fs";
28
- import { dirname as dirname8, join as join23 } from "path";
34
+ import { readFileSync as readFileSync14, existsSync as existsSync22 } from "fs";
35
+ import { dirname as dirname8, join as join24 } from "path";
29
36
 
30
37
  // src/cli/index-command.ts
31
38
  import { execFileSync as execFileSync2 } from "child_process";
32
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync3, existsSync as existsSync8, statSync, readdirSync as readdirSync2 } from "fs";
33
- import { join as join7, relative, extname as extname3 } from "path";
39
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync2, existsSync as existsSync9, statSync, readdirSync as readdirSync2 } from "fs";
40
+ import { join as join8, relative, extname as extname3 } from "path";
34
41
  import { createRequire as createRequire2 } from "module";
35
42
 
36
43
  // src/core/staleness/content-hasher.ts
@@ -603,15 +610,15 @@ function isLockedCI(env) {
603
610
  return true;
604
611
  }
605
612
  function spawnInstall(command, args, cwd) {
606
- return new Promise((resolve3, reject) => {
613
+ return new Promise((resolve4, reject) => {
607
614
  const child = spawn(command, [...args], { cwd, stdio: "inherit", shell: process.platform === "win32" });
608
- child.on("exit", (code) => resolve3(code ?? 0));
615
+ child.on("exit", (code) => resolve4(code ?? 0));
609
616
  child.on("error", (err) => reject(err));
610
617
  });
611
618
  }
612
619
 
613
620
  // src/adapters/storage/json-index-writer.ts
614
- import { writeFileSync, renameSync, mkdirSync, unlinkSync, existsSync as existsSync6 } from "fs";
621
+ import { mkdirSync, unlinkSync, existsSync as existsSync6 } from "fs";
615
622
  import { dirname as dirname4, join as join5, resolve as resolve2, sep } from "path";
616
623
  var JsonIndexWriter = class {
617
624
  indexDir;
@@ -634,9 +641,7 @@ var JsonIndexWriter = class {
634
641
  this.atomicWrite(targetPath, JSON.stringify(matrix, null, 2));
635
642
  }
636
643
  atomicWrite(targetPath, content) {
637
- const tmpPath = `${targetPath}.${process.pid}.tmp`;
638
- writeFileSync(tmpPath, content, "utf-8");
639
- renameSync(tmpPath, targetPath);
644
+ atomicWrite(targetPath, content);
640
645
  }
641
646
  delete(relativePath) {
642
647
  const targetPath = this.resolveIndexPath(relativePath);
@@ -668,7 +673,7 @@ var JsonIndexWriter = class {
668
673
  };
669
674
 
670
675
  // src/adapters/storage/schema-manager.ts
671
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync2, existsSync as existsSync7, mkdirSync as mkdirSync2 } from "fs";
676
+ import { readFileSync as readFileSync4, writeFileSync, existsSync as existsSync7, mkdirSync as mkdirSync2 } from "fs";
672
677
  import { join as join6, dirname as dirname5 } from "path";
673
678
  var CURRENT_SCHEMA_VERSION = "1.0.0";
674
679
  var SchemaManager = class {
@@ -687,7 +692,7 @@ var SchemaManager = class {
687
692
  }
688
693
  writeVersion() {
689
694
  mkdirSync2(dirname5(this.versionFilePath), { recursive: true });
690
- writeFileSync2(this.versionFilePath, CURRENT_SCHEMA_VERSION, "utf-8");
695
+ writeFileSync(this.versionFilePath, CURRENT_SCHEMA_VERSION, "utf-8");
691
696
  }
692
697
  isCompatible() {
693
698
  const stored = this.readStoredVersion();
@@ -696,6 +701,340 @@ var SchemaManager = class {
696
701
  }
697
702
  };
698
703
 
704
+ // src/adapters/storage/architecture-artifacts-writer.ts
705
+ import { existsSync as existsSync8, mkdirSync as mkdirSync3, readFileSync as readFileSync5 } from "fs";
706
+ import { join as join7, resolve as resolve3 } from "path";
707
+ import { tmpdir } from "os";
708
+ var log3 = createLogger("ctxo:architecture-artifacts");
709
+ var DRIFT_FILE_NAME = "drift-events.json";
710
+ var VIOLATIONS_FILE_NAME = "boundary-violations.json";
711
+ var ArchitectureArtifactsWriter = class {
712
+ indexDir;
713
+ constructor(ctxoRoot, options = {}) {
714
+ this.indexDir = join7(ctxoRoot, "index");
715
+ assertSafeWritePath(ctxoRoot, options.allowProductionPath ?? false);
716
+ }
717
+ writeDriftEvents(artifact) {
718
+ mkdirSync3(this.indexDir, { recursive: true });
719
+ atomicWrite(join7(this.indexDir, DRIFT_FILE_NAME), JSON.stringify(artifact, null, 2));
720
+ }
721
+ writeBoundaryViolations(artifact) {
722
+ mkdirSync3(this.indexDir, { recursive: true });
723
+ atomicWrite(join7(this.indexDir, VIOLATIONS_FILE_NAME), JSON.stringify(artifact, null, 2));
724
+ }
725
+ readDriftEvents() {
726
+ const path = join7(this.indexDir, DRIFT_FILE_NAME);
727
+ if (!existsSync8(path)) return void 0;
728
+ try {
729
+ return JSON.parse(readFileSync5(path, "utf-8"));
730
+ } catch (err) {
731
+ log3.warn(`failed to parse ${DRIFT_FILE_NAME}: ${err.message}`);
732
+ return void 0;
733
+ }
734
+ }
735
+ readBoundaryViolations() {
736
+ const path = join7(this.indexDir, VIOLATIONS_FILE_NAME);
737
+ if (!existsSync8(path)) return void 0;
738
+ try {
739
+ return JSON.parse(readFileSync5(path, "utf-8"));
740
+ } catch (err) {
741
+ log3.warn(`failed to parse ${VIOLATIONS_FILE_NAME}: ${err.message}`);
742
+ return void 0;
743
+ }
744
+ }
745
+ };
746
+ function assertSafeWritePath(ctxoRoot, allowProductionPath) {
747
+ const resolvedRoot = resolve3(ctxoRoot);
748
+ const resolvedTmp = resolve3(tmpdir());
749
+ if (resolvedRoot.startsWith(resolvedTmp)) return;
750
+ if (allowProductionPath) return;
751
+ throw new Error(
752
+ `ArchitectureArtifactsWriter: refusing to write to non-tmp path ${resolvedRoot}. Pass { allowProductionPath: true } for production CLI writes, or target a tmpdir-based path in tests.`
753
+ );
754
+ }
755
+
756
+ // src/core/overlay/community-detector.ts
757
+ import { UndirectedGraph } from "graphology";
758
+ import louvainModule from "graphology-communities-louvain";
759
+
760
+ // src/core/overlay/community-labeler.ts
761
+ var MIN_PREFIX_SEGMENTS = 1;
762
+ var PATH_SEPARATOR = "/";
763
+ function labelCommunities(membersByCommunity, pagerank) {
764
+ const labels = /* @__PURE__ */ new Map();
765
+ const used = /* @__PURE__ */ new Set();
766
+ for (const [communityId, members] of membersByCommunity) {
767
+ const label = pickLabel(members, pagerank, used);
768
+ labels.set(communityId, label);
769
+ used.add(label);
770
+ }
771
+ return labels;
772
+ }
773
+ function pickLabel(members, pagerank, used) {
774
+ const files = members.map(extractFilePath).filter((file) => Boolean(file));
775
+ const prefixLabel = longestCommonPathPrefix(files);
776
+ if (prefixLabel && !used.has(prefixLabel)) {
777
+ return prefixLabel;
778
+ }
779
+ const topSymbol = pickTopByPagerank(members, pagerank);
780
+ if (topSymbol) {
781
+ const name = extractSymbolName(topSymbol);
782
+ if (name) {
783
+ const candidate = `${name} area`;
784
+ if (!used.has(candidate)) return candidate;
785
+ }
786
+ }
787
+ if (prefixLabel) {
788
+ return disambiguate(prefixLabel, used);
789
+ }
790
+ return disambiguate("cluster", used);
791
+ }
792
+ function extractFilePath(symbolId) {
793
+ const parts = symbolId.split("::");
794
+ return parts.length === 3 ? parts[0] : void 0;
795
+ }
796
+ function extractSymbolName(symbolId) {
797
+ const parts = symbolId.split("::");
798
+ return parts.length === 3 ? parts[1] : void 0;
799
+ }
800
+ function longestCommonPathPrefix(files) {
801
+ if (files.length === 0) return void 0;
802
+ const normalized = files.map((file) => file.replace(/\\/g, PATH_SEPARATOR));
803
+ const segments = normalized.map((file) => file.split(PATH_SEPARATOR));
804
+ const first = segments[0];
805
+ let matchLength = first.length;
806
+ for (let i = 1; i < segments.length; i++) {
807
+ const current = segments[i];
808
+ matchLength = Math.min(matchLength, current.length);
809
+ for (let j = 0; j < matchLength; j++) {
810
+ if (current[j] !== first[j]) {
811
+ matchLength = j;
812
+ break;
813
+ }
814
+ }
815
+ if (matchLength === 0) break;
816
+ }
817
+ const fullMatch = matchLength === first.length && files.length === 1;
818
+ const keep = fullMatch ? matchLength - 1 : matchLength;
819
+ if (keep < MIN_PREFIX_SEGMENTS) return void 0;
820
+ const prefix = first.slice(0, keep).join(PATH_SEPARATOR);
821
+ return prefix.length > 0 ? prefix : void 0;
822
+ }
823
+ function pickTopByPagerank(members, pagerank) {
824
+ let best;
825
+ let bestScore = -1;
826
+ for (const symbolId of members) {
827
+ const score = pagerank.get(symbolId) ?? 0;
828
+ if (score > bestScore || score === bestScore && best !== void 0 && symbolId < best) {
829
+ bestScore = score;
830
+ best = symbolId;
831
+ }
832
+ }
833
+ return best ?? members[0];
834
+ }
835
+ function disambiguate(base, used) {
836
+ if (!used.has(base)) return base;
837
+ let suffix = 2;
838
+ while (used.has(`${base} (${suffix})`)) suffix++;
839
+ return `${base} (${suffix})`;
840
+ }
841
+
842
+ // src/core/overlay/community-detector.ts
843
+ var louvain = louvainModule.default ?? louvainModule;
844
+ var DEFAULT_RESOLUTION = 1;
845
+ var DEFAULT_GOD_NODE_MIN_BRIDGED = 3;
846
+ var DEFAULT_GOD_NODE_MAX_RESULTS = 25;
847
+ var CommunityDetector = class {
848
+ resolution;
849
+ godNodeMinBridged;
850
+ godNodeMaxResults;
851
+ constructor(options = {}) {
852
+ this.resolution = options.resolution ?? DEFAULT_RESOLUTION;
853
+ this.godNodeMinBridged = options.godNodeMinBridged ?? DEFAULT_GOD_NODE_MIN_BRIDGED;
854
+ this.godNodeMaxResults = options.godNodeMaxResults ?? DEFAULT_GOD_NODE_MAX_RESULTS;
855
+ }
856
+ detect(graph, pagerank, edgeQuality = "full") {
857
+ const nodes = graph.allNodes();
858
+ if (nodes.length === 0) {
859
+ return {
860
+ communities: [],
861
+ godNodes: [],
862
+ modularity: 0,
863
+ crossClusterEdges: 0,
864
+ edgeQuality
865
+ };
866
+ }
867
+ const sortedNodes = [...nodes].sort(
868
+ (a, b) => a.symbolId < b.symbolId ? -1 : a.symbolId > b.symbolId ? 1 : 0
869
+ );
870
+ const gph = new UndirectedGraph();
871
+ for (const node of sortedNodes) {
872
+ gph.addNode(node.symbolId);
873
+ }
874
+ const weightByPair = /* @__PURE__ */ new Map();
875
+ for (const edge of graph.allEdges()) {
876
+ if (edge.from === edge.to) continue;
877
+ if (!gph.hasNode(edge.from) || !gph.hasNode(edge.to)) continue;
878
+ const [a, b] = edge.from < edge.to ? [edge.from, edge.to] : [edge.to, edge.from];
879
+ const key = `${a}|${b}`;
880
+ weightByPair.set(key, (weightByPair.get(key) ?? 0) + 1);
881
+ }
882
+ for (const [key, weight] of weightByPair) {
883
+ const [from, to] = key.split("|");
884
+ gph.addEdge(from, to, { weight });
885
+ }
886
+ if (gph.size === 0) {
887
+ return {
888
+ communities: this.singletonCommunities(sortedNodes),
889
+ godNodes: [],
890
+ modularity: 0,
891
+ crossClusterEdges: 0,
892
+ edgeQuality
893
+ };
894
+ }
895
+ const result = louvain.detailed(gph, {
896
+ resolution: this.resolution,
897
+ randomWalk: false,
898
+ fastLocalMoves: true,
899
+ getEdgeWeight: "weight"
900
+ });
901
+ const rawAssignments = result.communities;
902
+ const splitAssignments = this.splitDisconnectedCommunities(gph, rawAssignments);
903
+ const normalizedAssignments = this.normalizeCommunityIds(splitAssignments, sortedNodes);
904
+ const memberIndex = this.buildMemberIndex(normalizedAssignments);
905
+ const labels = labelCommunities(memberIndex, pagerank);
906
+ const communities = sortedNodes.map((node) => {
907
+ const communityId = normalizedAssignments.get(node.symbolId) ?? 0;
908
+ return {
909
+ symbolId: node.symbolId,
910
+ communityId,
911
+ communityLabel: labels.get(communityId) ?? `community-${communityId}`
912
+ };
913
+ });
914
+ const crossClusterEdges = this.countCrossClusterEdges(graph, normalizedAssignments);
915
+ const godNodes = this.detectGodNodes(graph, normalizedAssignments, pagerank);
916
+ return {
917
+ communities,
918
+ godNodes,
919
+ modularity: result.modularity,
920
+ crossClusterEdges,
921
+ edgeQuality
922
+ };
923
+ }
924
+ singletonCommunities(nodes) {
925
+ return nodes.map((node, i) => ({
926
+ symbolId: node.symbolId,
927
+ communityId: i,
928
+ communityLabel: `isolated-${i}`
929
+ }));
930
+ }
931
+ splitDisconnectedCommunities(gph, assignments) {
932
+ const result = /* @__PURE__ */ new Map();
933
+ const membersByCommunity = /* @__PURE__ */ new Map();
934
+ for (const [node, communityId] of Object.entries(assignments)) {
935
+ const list = membersByCommunity.get(communityId) ?? [];
936
+ list.push(node);
937
+ membersByCommunity.set(communityId, list);
938
+ }
939
+ let nextCommunityId = 0;
940
+ const sortedCommunities = [...membersByCommunity.entries()].sort(([a], [b]) => a - b);
941
+ for (const [, members] of sortedCommunities) {
942
+ const visited = /* @__PURE__ */ new Set();
943
+ for (const start of [...members].sort()) {
944
+ if (visited.has(start)) continue;
945
+ const componentId = nextCommunityId++;
946
+ const queue = [start];
947
+ visited.add(start);
948
+ while (queue.length > 0) {
949
+ const node = queue.shift();
950
+ result.set(node, componentId);
951
+ for (const neighbor of gph.neighbors(node)) {
952
+ if (visited.has(neighbor)) continue;
953
+ if (!members.includes(neighbor)) continue;
954
+ visited.add(neighbor);
955
+ queue.push(neighbor);
956
+ }
957
+ }
958
+ }
959
+ }
960
+ return result;
961
+ }
962
+ normalizeCommunityIds(assignments, sortedNodes) {
963
+ const remap = /* @__PURE__ */ new Map();
964
+ const result = /* @__PURE__ */ new Map();
965
+ let nextId = 0;
966
+ for (const node of sortedNodes) {
967
+ const raw = assignments.get(node.symbolId);
968
+ if (raw === void 0) {
969
+ result.set(node.symbolId, nextId);
970
+ remap.set(nextId, nextId);
971
+ nextId++;
972
+ continue;
973
+ }
974
+ let normalized = remap.get(raw);
975
+ if (normalized === void 0) {
976
+ normalized = nextId++;
977
+ remap.set(raw, normalized);
978
+ }
979
+ result.set(node.symbolId, normalized);
980
+ }
981
+ return result;
982
+ }
983
+ buildMemberIndex(assignments) {
984
+ const index = /* @__PURE__ */ new Map();
985
+ for (const [symbolId, communityId] of assignments) {
986
+ const list = index.get(communityId) ?? [];
987
+ list.push(symbolId);
988
+ index.set(communityId, list);
989
+ }
990
+ for (const list of index.values()) list.sort();
991
+ return index;
992
+ }
993
+ countCrossClusterEdges(graph, assignments) {
994
+ let count = 0;
995
+ for (const edge of graph.allEdges()) {
996
+ const from = assignments.get(edge.from);
997
+ const to = assignments.get(edge.to);
998
+ if (from === void 0 || to === void 0) continue;
999
+ if (from !== to) count++;
1000
+ }
1001
+ return count;
1002
+ }
1003
+ detectGodNodes(graph, assignments, pagerank) {
1004
+ const candidates = [];
1005
+ for (const node of graph.allNodes()) {
1006
+ const ownCommunity = assignments.get(node.symbolId);
1007
+ if (ownCommunity === void 0) continue;
1008
+ const bridged = /* @__PURE__ */ new Set();
1009
+ const forward = graph.getForwardEdges(node.symbolId);
1010
+ const reverse = graph.getReverseEdges(node.symbolId);
1011
+ for (const edge of forward) {
1012
+ const other = assignments.get(edge.to);
1013
+ if (other !== void 0 && other !== ownCommunity) bridged.add(other);
1014
+ }
1015
+ for (const edge of reverse) {
1016
+ const other = assignments.get(edge.from);
1017
+ if (other !== void 0 && other !== ownCommunity) bridged.add(other);
1018
+ }
1019
+ if (bridged.size >= this.godNodeMinBridged) {
1020
+ const pr = pagerank.get(node.symbolId) ?? 0;
1021
+ candidates.push({
1022
+ symbolId: node.symbolId,
1023
+ bridgedCommunities: [...bridged].sort((a, b) => a - b),
1024
+ centralityScore: bridged.size * (pr > 0 ? pr : 1 / graph.nodeCount)
1025
+ });
1026
+ }
1027
+ }
1028
+ candidates.sort((a, b) => {
1029
+ if (b.centralityScore !== a.centralityScore) {
1030
+ return b.centralityScore - a.centralityScore;
1031
+ }
1032
+ return a.symbolId.localeCompare(b.symbolId);
1033
+ });
1034
+ return candidates.slice(0, this.godNodeMaxResults);
1035
+ }
1036
+ };
1037
+
699
1038
  // src/cli/index-command.ts
700
1039
  var requireCjs = createRequire2(import.meta.url);
701
1040
  var IndexCommand = class {
@@ -704,7 +1043,7 @@ var IndexCommand = class {
704
1043
  supportedExtensions;
705
1044
  constructor(projectRoot, ctxoRoot) {
706
1045
  this.projectRoot = projectRoot;
707
- this.ctxoRoot = ctxoRoot ?? join7(projectRoot, ".ctxo");
1046
+ this.ctxoRoot = ctxoRoot ?? join8(projectRoot, ".ctxo");
708
1047
  this.supportedExtensions = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx", ".go", ".cs"]);
709
1048
  }
710
1049
  loadIgnoreConfig() {
@@ -742,7 +1081,7 @@ var IndexCommand = class {
742
1081
  const revertDetector = new RevertDetector();
743
1082
  let files;
744
1083
  if (options.file) {
745
- const fullPath = join7(this.projectRoot, options.file);
1084
+ const fullPath = join8(this.projectRoot, options.file);
746
1085
  files = [fullPath];
747
1086
  console.error(`[ctxo] Incremental re-index: ${options.file}`);
748
1087
  } else {
@@ -754,6 +1093,14 @@ var IndexCommand = class {
754
1093
  }
755
1094
  console.error(`[ctxo] Building codebase index... Found ${files.length} source files`);
756
1095
  }
1096
+ try {
1097
+ const { sweepStaleTmpFiles } = await import("./atomic-write-AQNYJFAG.js");
1098
+ const swept = sweepStaleTmpFiles(join8(this.ctxoRoot, "index"));
1099
+ if (swept > 0) {
1100
+ console.error(`[ctxo] Cleaned ${swept} orphan .tmp file(s) from previous interrupted run`);
1101
+ }
1102
+ } catch {
1103
+ }
757
1104
  if (roslynAdapter?.isReady()) {
758
1105
  const hasCsFiles = files.some((f) => f.endsWith(".cs"));
759
1106
  if (hasCsFiles) {
@@ -768,7 +1115,7 @@ var IndexCommand = class {
768
1115
  if (!adapter) continue;
769
1116
  const relativePath = relative(this.projectRoot, filePath).replace(/\\/g, "/");
770
1117
  try {
771
- const source = readFileSync5(filePath, "utf-8");
1118
+ const source = readFileSync6(filePath, "utf-8");
772
1119
  const lastModified = Math.floor(Date.now() / 1e3);
773
1120
  const symbols = await adapter.extractSymbols(relativePath, source);
774
1121
  const complexity = await adapter.extractComplexity(relativePath, source);
@@ -833,19 +1180,52 @@ var IndexCommand = class {
833
1180
  writer.writeCoChanges(coChangeMatrix);
834
1181
  console.error(`[ctxo] Co-change analysis: ${coChangeMatrix.entries.length} file pairs detected`);
835
1182
  }
1183
+ let communitySnapshot;
1184
+ if (options.skipCommunity && pendingIndices.length > 0) {
1185
+ if (existsSync9(join8(this.ctxoRoot, "index", "communities.json"))) {
1186
+ console.error(
1187
+ `[ctxo] WARN stale communities.json present \u2014 --skip-community preserved existing snapshot. Run \`ctxo index\` (full) to refresh.`
1188
+ );
1189
+ }
1190
+ } else if (pendingIndices.length > 0) {
1191
+ try {
1192
+ const graphIndices = this.buildGraphInputIndices(pendingIndices);
1193
+ communitySnapshot = this.buildCommunitySnapshot(
1194
+ graphIndices,
1195
+ {
1196
+ csharpTier,
1197
+ goTier,
1198
+ tsCount: graphIndices.filter((idx) => /\.(ts|tsx|js|jsx)$/.test(idx.file)).length
1199
+ },
1200
+ gitAdapter
1201
+ );
1202
+ const scope = options.file ? " (incremental, full-graph recompute)" : "";
1203
+ console.error(
1204
+ `[ctxo] Community detection${scope}: ${countCommunities(communitySnapshot)} clusters (modularity ${communitySnapshot.modularity.toFixed(3)})`
1205
+ );
1206
+ } catch (err) {
1207
+ console.error(`[ctxo] Community detection skipped: ${err.message}`);
1208
+ }
1209
+ }
836
1210
  const indices = [];
837
1211
  for (const { fileIndex } of pendingIndices) {
838
1212
  writer.write(fileIndex);
839
1213
  indices.push(fileIndex);
840
1214
  }
841
1215
  schemaManager.writeVersion();
842
- const storage = new SqliteStorageAdapter(this.ctxoRoot);
1216
+ const storage = new SqliteStorageAdapter(this.ctxoRoot, { allowProductionPath: true });
843
1217
  try {
844
1218
  await storage.initEmpty();
845
1219
  storage.bulkWrite(indices);
1220
+ if (communitySnapshot) {
1221
+ storage.writeCommunities(communitySnapshot);
1222
+ }
846
1223
  } finally {
847
1224
  storage.close();
848
1225
  }
1226
+ if (communitySnapshot) {
1227
+ this.writeArchitectureArtifacts(indices, communitySnapshot);
1228
+ }
849
1229
  if (!options.skipSideEffects) {
850
1230
  this.ensureGitignore();
851
1231
  }
@@ -927,36 +1307,36 @@ var IndexCommand = class {
927
1307
  encoding: "utf-8",
928
1308
  maxBuffer: 10 * 1024 * 1024
929
1309
  });
930
- return output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).filter((line) => this.isSupportedExtension(line)).map((line) => join7(root, line)).filter((abs) => {
1310
+ return output.split("\n").map((line) => line.trim()).filter((line) => line.length > 0).filter((line) => this.isSupportedExtension(line)).map((line) => join8(root, line)).filter((abs) => {
931
1311
  const rel = relative(this.projectRoot, abs).replace(/\\/g, "/");
932
1312
  return !ignoreFile(rel);
933
- });
1313
+ }).filter((abs) => existsSync9(abs));
934
1314
  } catch {
935
1315
  console.error(`[ctxo] git ls-files failed for ${root}`);
936
1316
  return [];
937
1317
  }
938
1318
  }
939
1319
  discoverWorkspaces(ignoreProject = () => false) {
940
- const pkgPath = join7(this.projectRoot, "package.json");
941
- if (!existsSync8(pkgPath)) return [this.projectRoot];
1320
+ const pkgPath = join8(this.projectRoot, "package.json");
1321
+ if (!existsSync9(pkgPath)) return [this.projectRoot];
942
1322
  try {
943
- const pkg = JSON.parse(readFileSync5(pkgPath, "utf-8"));
1323
+ const pkg = JSON.parse(readFileSync6(pkgPath, "utf-8"));
944
1324
  const workspaces = Array.isArray(pkg.workspaces) ? pkg.workspaces : pkg.workspaces?.packages;
945
1325
  if (!workspaces || workspaces.length === 0) return [this.projectRoot];
946
1326
  const resolved = [];
947
1327
  for (const ws of workspaces) {
948
1328
  if (ws.endsWith("/*") || ws.endsWith("\\*")) {
949
- const parentDir = join7(this.projectRoot, ws.slice(0, -2));
950
- if (existsSync8(parentDir)) {
1329
+ const parentDir = join8(this.projectRoot, ws.slice(0, -2));
1330
+ if (existsSync9(parentDir)) {
951
1331
  for (const entry of readdirSync2(parentDir, { withFileTypes: true })) {
952
1332
  if (entry.isDirectory()) {
953
- resolved.push(join7(parentDir, entry.name));
1333
+ resolved.push(join8(parentDir, entry.name));
954
1334
  }
955
1335
  }
956
1336
  }
957
1337
  } else {
958
- const wsPath = join7(this.projectRoot, ws);
959
- if (existsSync8(wsPath)) {
1338
+ const wsPath = join8(this.projectRoot, ws);
1339
+ if (existsSync9(wsPath)) {
960
1340
  resolved.push(wsPath);
961
1341
  }
962
1342
  }
@@ -989,7 +1369,7 @@ var IndexCommand = class {
989
1369
  if (_roslyn) await _roslyn.dispose();
990
1370
  const hasher = new ContentHasher();
991
1371
  const files = this.discoverFilesIn(this.projectRoot);
992
- const reader = new (await import("./json-index-reader-LME4EDA5.js")).JsonIndexReader(this.ctxoRoot);
1372
+ const reader = new (await import("./json-index-reader-XOEE6VMD.js")).JsonIndexReader(this.ctxoRoot);
993
1373
  const indices = reader.readAll();
994
1374
  const indexedMap = new Map(indices.map((i) => [i.file, i]));
995
1375
  let staleCount = 0;
@@ -1002,7 +1382,7 @@ var IndexCommand = class {
1002
1382
  staleCount++;
1003
1383
  continue;
1004
1384
  }
1005
- if (!existsSync8(filePath)) {
1385
+ if (!existsSync9(filePath)) {
1006
1386
  console.error(`[ctxo] DELETED: ${relativePath}`);
1007
1387
  staleCount++;
1008
1388
  continue;
@@ -1010,11 +1390,15 @@ var IndexCommand = class {
1010
1390
  const mtime = Math.floor(statSync(filePath).mtimeMs / 1e3);
1011
1391
  if (mtime <= indexed.lastModified) continue;
1012
1392
  if (indexed.contentHash) {
1013
- const source = readFileSync5(filePath, "utf-8");
1393
+ const source = readFileSync6(filePath, "utf-8");
1014
1394
  const currentHash = hasher.hash(source);
1015
1395
  if (currentHash === indexed.contentHash) continue;
1396
+ console.error(`[ctxo] STALE: ${relativePath} (content hash differs)`);
1397
+ } else {
1398
+ console.error(
1399
+ `[ctxo] STALE: ${relativePath} (index predates contentHash \u2014 run \`ctxo index\` once to enable mtime-tolerant checks)`
1400
+ );
1016
1401
  }
1017
- console.error(`[ctxo] STALE: ${relativePath}`);
1018
1402
  staleCount++;
1019
1403
  }
1020
1404
  if (staleCount > 0) {
@@ -1024,29 +1408,166 @@ var IndexCommand = class {
1024
1408
  console.error("[ctxo] Index is up to date");
1025
1409
  }
1026
1410
  ensureGitignore() {
1027
- const gitignorePath = join7(this.projectRoot, ".gitignore");
1411
+ const gitignorePath = join8(this.projectRoot, ".gitignore");
1028
1412
  const cachePattern = ".ctxo/.cache/";
1029
1413
  const suffix = `
1030
1414
  # Ctxo local cache (never committed)
1031
1415
  ${cachePattern}
1032
1416
  `;
1033
- const existing = existsSync8(gitignorePath) ? readFileSync5(gitignorePath, "utf-8") : "";
1417
+ const existing = existsSync9(gitignorePath) ? readFileSync6(gitignorePath, "utf-8") : "";
1034
1418
  if (existing.includes(cachePattern)) return;
1035
- writeFileSync3(gitignorePath, existing + suffix, "utf-8");
1419
+ writeFileSync2(gitignorePath, existing + suffix, "utf-8");
1036
1420
  console.error("[ctxo] Added .ctxo/.cache/ to .gitignore");
1037
1421
  }
1422
+ /**
1423
+ * Produce the FileIndex list used as input to community detection.
1424
+ *
1425
+ * Full index run: returns pendingIndices as-is — they already cover every file.
1426
+ * Incremental `--file` run: pendingIndices holds only the just-updated file(s). We load the
1427
+ * rest of the committed JSON index from disk and splice in the fresh in-memory indices so
1428
+ * Louvain sees the COMPLETE project graph. Without this, Louvain would run on a 1-file
1429
+ * subgraph and emit a meaningless modularity-0 snapshot.
1430
+ */
1431
+ buildGraphInputIndices(pendingIndices) {
1432
+ const pendingByPath = /* @__PURE__ */ new Map();
1433
+ for (const { fileIndex } of pendingIndices) {
1434
+ pendingByPath.set(fileIndex.file, fileIndex);
1435
+ }
1436
+ const reader = new JsonIndexReader(this.ctxoRoot);
1437
+ const committed = reader.readAll();
1438
+ const merged = [];
1439
+ for (const idx of committed) {
1440
+ const override = pendingByPath.get(idx.file);
1441
+ if (override) {
1442
+ merged.push(override);
1443
+ pendingByPath.delete(idx.file);
1444
+ } else {
1445
+ merged.push(idx);
1446
+ }
1447
+ }
1448
+ for (const remaining of pendingByPath.values()) merged.push(remaining);
1449
+ return merged;
1450
+ }
1451
+ buildCommunitySnapshot(indices, tiers, gitAdapter) {
1452
+ const graph = new SymbolGraph();
1453
+ for (const fileIndex of indices) {
1454
+ for (const symbol of fileIndex.symbols) graph.addNode(symbol);
1455
+ }
1456
+ for (const fileIndex of indices) {
1457
+ for (const edge of fileIndex.edges) graph.addEdge(edge);
1458
+ }
1459
+ const pagerank = new PageRankCalculator().calculate(graph, { limit: graph.nodeCount });
1460
+ const pagerankMap = /* @__PURE__ */ new Map();
1461
+ for (const entry of pagerank.rankings) pagerankMap.set(entry.symbolId, entry.score);
1462
+ const detector = new CommunityDetector();
1463
+ const edgeQuality = pickEdgeQuality(tiers);
1464
+ const detectResult = detector.detect(graph, pagerankMap, edgeQuality);
1465
+ return {
1466
+ version: 1,
1467
+ computedAt: (/* @__PURE__ */ new Date()).toISOString(),
1468
+ commitSha: safeHeadSha(gitAdapter),
1469
+ modularity: detectResult.modularity,
1470
+ communities: detectResult.communities,
1471
+ godNodes: detectResult.godNodes,
1472
+ edgeQuality: detectResult.edgeQuality,
1473
+ crossClusterEdges: detectResult.crossClusterEdges
1474
+ };
1475
+ }
1476
+ /**
1477
+ * Writes drift-events.json and boundary-violations.json next to
1478
+ * communities.json. Must be called AFTER storage.writeCommunities so the
1479
+ * prior snapshot has already been archived into communities.history/ and
1480
+ * can serve as history[0] for the detectors.
1481
+ */
1482
+ writeArchitectureArtifacts(indices, snapshot) {
1483
+ try {
1484
+ const snapshotWriter = new CommunitySnapshotWriter(this.ctxoRoot, {
1485
+ allowProductionPath: true
1486
+ });
1487
+ const history = snapshotWriter.listHistory();
1488
+ const graph = new SymbolGraph();
1489
+ for (const fileIndex of indices) {
1490
+ for (const symbol of fileIndex.symbols) graph.addNode(symbol);
1491
+ }
1492
+ for (const fileIndex of indices) {
1493
+ for (const edge of fileIndex.edges) graph.addEdge(edge);
1494
+ }
1495
+ const driftResult = new DriftDetector().detect(snapshot, history);
1496
+ const violationsResult = new BoundaryViolationDetector().detect(
1497
+ graph,
1498
+ snapshot,
1499
+ history
1500
+ );
1501
+ const computedAt = (/* @__PURE__ */ new Date()).toISOString();
1502
+ const driftArtifact = {
1503
+ version: 1,
1504
+ computedAt,
1505
+ commitSha: snapshot.commitSha,
1506
+ confidence: driftResult.confidence,
1507
+ snapshotsAvailable: driftResult.snapshotsAvailable,
1508
+ ...driftResult.hint ? { hint: driftResult.hint } : {},
1509
+ events: driftResult.events
1510
+ };
1511
+ const violationsArtifact = {
1512
+ version: 1,
1513
+ computedAt,
1514
+ commitSha: snapshot.commitSha,
1515
+ confidence: violationsResult.confidence,
1516
+ snapshotsAvailable: violationsResult.snapshotsAvailable,
1517
+ ...violationsResult.hint ? { hint: violationsResult.hint } : {},
1518
+ violations: violationsResult.violations
1519
+ };
1520
+ const writer = new ArchitectureArtifactsWriter(this.ctxoRoot, {
1521
+ allowProductionPath: true
1522
+ });
1523
+ writer.writeDriftEvents(driftArtifact);
1524
+ writer.writeBoundaryViolations(violationsArtifact);
1525
+ console.error(
1526
+ `[ctxo] Architecture artifacts: ${driftResult.events.length} drift events, ${violationsResult.violations.length} boundary violations (confidence: drift=${driftResult.confidence}, violations=${violationsResult.confidence})`
1527
+ );
1528
+ } catch (err) {
1529
+ console.error(
1530
+ `[ctxo] Architecture artifacts skipped: ${err.message}`
1531
+ );
1532
+ }
1533
+ }
1038
1534
  };
1535
+ function pickEdgeQuality(tiers) {
1536
+ const hasSyntaxOnly = tiers.csharpTier === "syntax" || tiers.goTier === "syntax";
1537
+ const hasFull = tiers.tsCount > 0 || tiers.csharpTier === "full" || tiers.goTier === "full";
1538
+ if (hasSyntaxOnly && hasFull) return "mixed";
1539
+ if (hasSyntaxOnly) return "syntax-only";
1540
+ return "full";
1541
+ }
1542
+ function safeHeadSha(gitAdapter) {
1543
+ try {
1544
+ const maybeSha = gitAdapter.getHeadSha?.();
1545
+ if (maybeSha) return maybeSha.slice(0, 8);
1546
+ } catch {
1547
+ }
1548
+ try {
1549
+ const out = execFileSync2("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf-8" });
1550
+ return out.trim();
1551
+ } catch {
1552
+ return "nocommit";
1553
+ }
1554
+ }
1555
+ function countCommunities(snapshot) {
1556
+ const ids = /* @__PURE__ */ new Set();
1557
+ for (const entry of snapshot.communities) ids.add(entry.communityId);
1558
+ return ids.size;
1559
+ }
1039
1560
 
1040
1561
  // src/cli/sync-command.ts
1041
- import { join as join8 } from "path";
1562
+ import { join as join9 } from "path";
1042
1563
  var SyncCommand = class {
1043
1564
  ctxoRoot;
1044
1565
  constructor(projectRoot) {
1045
- this.ctxoRoot = join8(projectRoot, ".ctxo");
1566
+ this.ctxoRoot = join9(projectRoot, ".ctxo");
1046
1567
  }
1047
1568
  async run() {
1048
1569
  console.error("[ctxo] Rebuilding SQLite cache from committed JSON index...");
1049
- const storage = new SqliteStorageAdapter(this.ctxoRoot);
1570
+ const storage = new SqliteStorageAdapter(this.ctxoRoot, { allowProductionPath: true });
1050
1571
  try {
1051
1572
  await storage.init();
1052
1573
  } finally {
@@ -1057,19 +1578,19 @@ var SyncCommand = class {
1057
1578
  };
1058
1579
 
1059
1580
  // src/cli/status-command.ts
1060
- import { join as join9 } from "path";
1061
- import { existsSync as existsSync9 } from "fs";
1581
+ import { join as join10 } from "path";
1582
+ import { existsSync as existsSync10 } from "fs";
1062
1583
  import { execFileSync as execFileSync3 } from "child_process";
1063
1584
  var StatusCommand = class {
1064
1585
  projectRoot;
1065
1586
  ctxoRoot;
1066
1587
  constructor(projectRoot) {
1067
1588
  this.projectRoot = projectRoot;
1068
- this.ctxoRoot = join9(projectRoot, ".ctxo");
1589
+ this.ctxoRoot = join10(projectRoot, ".ctxo");
1069
1590
  }
1070
1591
  run() {
1071
- const indexDir = join9(this.ctxoRoot, "index");
1072
- if (!existsSync9(indexDir)) {
1592
+ const indexDir = join10(this.ctxoRoot, "index");
1593
+ if (!existsSync10(indexDir)) {
1073
1594
  console.error('[ctxo] No index found. Run "ctxo index" first.');
1074
1595
  return;
1075
1596
  }
@@ -1084,7 +1605,7 @@ var StatusCommand = class {
1084
1605
  console.error(` Indexed files: ${indices.length}`);
1085
1606
  console.error(` Total symbols: ${totalSymbols}`);
1086
1607
  console.error(` Total edges: ${totalEdges}`);
1087
- const cacheExists = existsSync9(join9(this.ctxoRoot, ".cache", "symbols.db"));
1608
+ const cacheExists = existsSync10(join10(this.ctxoRoot, ".cache", "symbols.db"));
1088
1609
  console.error(` SQLite cache: ${cacheExists ? "present" : "missing (run ctxo sync)"}`);
1089
1610
  if (indices.length > 0) {
1090
1611
  console.error("");
@@ -1113,8 +1634,8 @@ var StatusCommand = class {
1113
1634
 
1114
1635
  // src/cli/verify-command.ts
1115
1636
  import { mkdtempSync, rmSync } from "fs";
1116
- import { join as join10 } from "path";
1117
- import { tmpdir } from "os";
1637
+ import { join as join11 } from "path";
1638
+ import { tmpdir as tmpdir2 } from "os";
1118
1639
  var VerifyCommand = class {
1119
1640
  projectRoot;
1120
1641
  constructor(projectRoot) {
@@ -1122,12 +1643,12 @@ var VerifyCommand = class {
1122
1643
  }
1123
1644
  async run() {
1124
1645
  console.error("[ctxo] Verifying index freshness...");
1125
- const tempDir = mkdtempSync(join10(tmpdir(), "ctxo-verify-"));
1646
+ const tempDir = mkdtempSync(join11(tmpdir2(), "ctxo-verify-"));
1126
1647
  try {
1127
- const tempCtxo = join10(tempDir, ".ctxo");
1648
+ const tempCtxo = join11(tempDir, ".ctxo");
1128
1649
  const indexCmd = new IndexCommand(this.projectRoot, tempCtxo);
1129
1650
  await indexCmd.run({ skipSideEffects: true });
1130
- const committedReader = new JsonIndexReader(join10(this.projectRoot, ".ctxo"));
1651
+ const committedReader = new JsonIndexReader(join11(this.projectRoot, ".ctxo"));
1131
1652
  const freshReader = new JsonIndexReader(tempCtxo);
1132
1653
  const committedIndices = committedReader.readAll();
1133
1654
  const freshIndices = freshReader.readAll();
@@ -1160,13 +1681,13 @@ var VerifyCommand = class {
1160
1681
  };
1161
1682
 
1162
1683
  // src/cli/init-command.ts
1163
- import { join as join12 } from "path";
1164
- import { existsSync as existsSync11, readFileSync as readFileSync7, writeFileSync as writeFileSync5, mkdirSync as mkdirSync4, chmodSync } from "fs";
1684
+ import { join as join13 } from "path";
1685
+ import { existsSync as existsSync12, readFileSync as readFileSync8, writeFileSync as writeFileSync4, mkdirSync as mkdirSync5, chmodSync } from "fs";
1165
1686
  import { createRequire as createRequire3 } from "module";
1166
1687
 
1167
1688
  // src/cli/ai-rules.ts
1168
- import { existsSync as existsSync10, readFileSync as readFileSync6, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3 } from "fs";
1169
- import { join as join11, dirname as dirname6 } from "path";
1689
+ import { existsSync as existsSync11, readFileSync as readFileSync7, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4 } from "fs";
1690
+ import { join as join12, dirname as dirname6 } from "path";
1170
1691
  var PLATFORMS = [
1171
1692
  { id: "claude-code", name: "Claude Code", file: "CLAUDE.md", mode: "append", detectPaths: ["CLAUDE.md", ".claude"], starred: true },
1172
1693
  { id: "cursor", name: "Cursor", file: ".cursor/rules/ctxo.mdc", mode: "create", detectPaths: [".cursor", ".cursorrules"], starred: false },
@@ -1179,7 +1700,7 @@ var PLATFORMS = [
1179
1700
  function detectPlatforms(projectRoot) {
1180
1701
  const detected = /* @__PURE__ */ new Set();
1181
1702
  for (const p of PLATFORMS) {
1182
- if (p.detectPaths.some((d) => existsSync10(join11(projectRoot, d)))) {
1703
+ if (p.detectPaths.some((d) => existsSync11(join12(projectRoot, d)))) {
1183
1704
  detected.add(p.id);
1184
1705
  }
1185
1706
  }
@@ -1241,31 +1762,31 @@ function generateRules(platformId) {
1241
1762
  function installRules(projectRoot, platformId) {
1242
1763
  const platform = PLATFORMS.find((p) => p.id === platformId);
1243
1764
  if (!platform) throw new Error(`Unknown platform: ${platformId}`);
1244
- const filePath = join11(projectRoot, platform.file);
1765
+ const filePath = join12(projectRoot, platform.file);
1245
1766
  const content = generateRules(platformId);
1246
1767
  const dir = dirname6(filePath);
1247
- if (!existsSync10(dir)) mkdirSync3(dir, { recursive: true });
1768
+ if (!existsSync11(dir)) mkdirSync4(dir, { recursive: true });
1248
1769
  if (platform.mode === "create") {
1249
- writeFileSync4(filePath, content, "utf-8");
1250
- return { file: platform.file, action: existsSync10(filePath) ? "updated" : "created" };
1770
+ writeFileSync3(filePath, content, "utf-8");
1771
+ return { file: platform.file, action: existsSync11(filePath) ? "updated" : "created" };
1251
1772
  }
1252
- if (!existsSync10(filePath)) {
1253
- writeFileSync4(filePath, content, "utf-8");
1773
+ if (!existsSync11(filePath)) {
1774
+ writeFileSync3(filePath, content, "utf-8");
1254
1775
  return { file: platform.file, action: "created" };
1255
1776
  }
1256
- const existing = readFileSync6(filePath, "utf-8");
1777
+ const existing = readFileSync7(filePath, "utf-8");
1257
1778
  if (existing.includes(SECTION_START)) {
1258
1779
  const re = new RegExp(`${escapeRegExp(SECTION_START)}[\\s\\S]*?${escapeRegExp(SECTION_END)}`, "m");
1259
1780
  const updated2 = existing.replace(re, `${SECTION_START}
1260
1781
  ${content}${SECTION_END}`);
1261
- writeFileSync4(filePath, updated2, "utf-8");
1782
+ writeFileSync3(filePath, updated2, "utf-8");
1262
1783
  return { file: platform.file, action: "updated" };
1263
1784
  }
1264
1785
  const separator = existing.endsWith("\n") ? "\n" : "\n\n";
1265
1786
  const updated = existing + separator + `${SECTION_START}
1266
1787
  ${content}${SECTION_END}
1267
1788
  `;
1268
- writeFileSync4(filePath, updated, "utf-8");
1789
+ writeFileSync3(filePath, updated, "utf-8");
1269
1790
  return { file: platform.file, action: "updated" };
1270
1791
  }
1271
1792
  function escapeRegExp(s) {
@@ -1273,14 +1794,14 @@ function escapeRegExp(s) {
1273
1794
  }
1274
1795
  var GITIGNORE_ENTRY = ".ctxo/.cache/";
1275
1796
  function ensureGitignore(projectRoot) {
1276
- const filePath = join11(projectRoot, ".gitignore");
1277
- if (!existsSync10(filePath)) {
1278
- writeFileSync4(filePath, `# Ctxo local cache (auto-rebuilt, never committed)
1797
+ const filePath = join12(projectRoot, ".gitignore");
1798
+ if (!existsSync11(filePath)) {
1799
+ writeFileSync3(filePath, `# Ctxo local cache (auto-rebuilt, never committed)
1279
1800
  ${GITIGNORE_ENTRY}
1280
1801
  `, "utf-8");
1281
1802
  return { file: ".gitignore", action: "created" };
1282
1803
  }
1283
- const existing = readFileSync6(filePath, "utf-8");
1804
+ const existing = readFileSync7(filePath, "utf-8");
1284
1805
  if (existing.includes(GITIGNORE_ENTRY)) {
1285
1806
  return { file: ".gitignore", action: "skipped" };
1286
1807
  }
@@ -1288,7 +1809,7 @@ ${GITIGNORE_ENTRY}
1288
1809
  const updated = existing + `${separator}# Ctxo local cache (auto-rebuilt, never committed)
1289
1810
  ${GITIGNORE_ENTRY}
1290
1811
  `;
1291
- writeFileSync4(filePath, updated, "utf-8");
1812
+ writeFileSync3(filePath, updated, "utf-8");
1292
1813
  return { file: ".gitignore", action: "updated" };
1293
1814
  }
1294
1815
  var DEFAULT_CONFIG = `# ctxo project configuration
@@ -1311,13 +1832,13 @@ index:
1311
1832
  # - "examples/*"
1312
1833
  `;
1313
1834
  function ensureConfig(projectRoot) {
1314
- const filePath = join11(projectRoot, ".ctxo", "config.yaml");
1835
+ const filePath = join12(projectRoot, ".ctxo", "config.yaml");
1315
1836
  const dir = dirname6(filePath);
1316
- if (!existsSync10(dir)) mkdirSync3(dir, { recursive: true });
1317
- if (existsSync10(filePath)) {
1837
+ if (!existsSync11(dir)) mkdirSync4(dir, { recursive: true });
1838
+ if (existsSync11(filePath)) {
1318
1839
  return { file: ".ctxo/config.yaml", action: "skipped" };
1319
1840
  }
1320
- writeFileSync4(filePath, DEFAULT_CONFIG, "utf-8");
1841
+ writeFileSync3(filePath, DEFAULT_CONFIG, "utf-8");
1321
1842
  return { file: ".ctxo/config.yaml", action: "created" };
1322
1843
  }
1323
1844
  var CTXO_MCP_ENTRY = { command: "npx", args: ["-y", "@ctxo/cli"] };
@@ -1343,21 +1864,21 @@ function getMcpConfigTargets(selectedPlatformIds) {
1343
1864
  return [...targets.values()];
1344
1865
  }
1345
1866
  function ensureMcpConfig(projectRoot, target) {
1346
- const filePath = join11(projectRoot, target.file);
1867
+ const filePath = join12(projectRoot, target.file);
1347
1868
  const dir = dirname6(filePath);
1348
- if (!existsSync10(dir)) mkdirSync3(dir, { recursive: true });
1869
+ if (!existsSync11(dir)) mkdirSync4(dir, { recursive: true });
1349
1870
  const entry = target.extraFields ? { ...target.extraFields, ...CTXO_MCP_ENTRY } : { ...CTXO_MCP_ENTRY };
1350
- if (!existsSync10(filePath)) {
1871
+ if (!existsSync11(filePath)) {
1351
1872
  const config = { [target.serverKey]: { ctxo: entry } };
1352
- writeFileSync4(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1873
+ writeFileSync3(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1353
1874
  return { file: target.file, action: "created" };
1354
1875
  }
1355
1876
  let existing;
1356
1877
  try {
1357
- existing = JSON.parse(readFileSync6(filePath, "utf-8"));
1878
+ existing = JSON.parse(readFileSync7(filePath, "utf-8"));
1358
1879
  } catch {
1359
1880
  const config = { [target.serverKey]: { ctxo: entry } };
1360
- writeFileSync4(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1881
+ writeFileSync3(filePath, JSON.stringify(config, null, 2) + "\n", "utf-8");
1361
1882
  return { file: target.file, action: "updated" };
1362
1883
  }
1363
1884
  const servers = existing[target.serverKey] ?? {};
@@ -1366,7 +1887,7 @@ function ensureMcpConfig(projectRoot, target) {
1366
1887
  }
1367
1888
  servers["ctxo"] = entry;
1368
1889
  existing[target.serverKey] = servers;
1369
- writeFileSync4(filePath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
1890
+ writeFileSync3(filePath, JSON.stringify(existing, null, 2) + "\n", "utf-8");
1370
1891
  return { file: target.file, action: "updated" };
1371
1892
  }
1372
1893
 
@@ -1463,13 +1984,13 @@ var InitCommand = class {
1463
1984
  this.projectRoot = projectRoot;
1464
1985
  }
1465
1986
  installHooks() {
1466
- const hooksDir = join12(this.projectRoot, ".git", "hooks");
1467
- mkdirSync4(hooksDir, { recursive: true });
1987
+ const hooksDir = join13(this.projectRoot, ".git", "hooks");
1988
+ mkdirSync5(hooksDir, { recursive: true });
1468
1989
  this.installHook(hooksDir, "post-commit", POST_COMMIT_CONTENT);
1469
1990
  this.installHook(hooksDir, "post-merge", POST_MERGE_CONTENT);
1470
1991
  }
1471
1992
  async run(options = {}) {
1472
- if (!existsSync11(join12(this.projectRoot, ".git"))) {
1993
+ if (!existsSync12(join13(this.projectRoot, ".git"))) {
1473
1994
  console.error('[ctxo] Not a git repository. Run "git init" first.');
1474
1995
  process.exit(1);
1475
1996
  }
@@ -1506,8 +2027,8 @@ var InitCommand = class {
1506
2027
  clack.cancel("Setup cancelled.");
1507
2028
  process.exit(0);
1508
2029
  }
1509
- const fullPath = join12(this.projectRoot, indexDir);
1510
- if (!existsSync11(fullPath)) mkdirSync4(fullPath, { recursive: true });
2030
+ const fullPath = join13(this.projectRoot, indexDir);
2031
+ if (!existsSync12(fullPath)) mkdirSync5(fullPath, { recursive: true });
1511
2032
  console.error("");
1512
2033
  }
1513
2034
  stepNum++;
@@ -1569,7 +2090,13 @@ var InitCommand = class {
1569
2090
  let installGitHooks = false;
1570
2091
  if (!options.rulesOnly) {
1571
2092
  stepNum++;
1572
- console.error(stepHeader(stepNum, totalSteps, `Git Hooks ${pc.yellow("recommended")}`, "Auto-updates index on every commit and pull.", pc));
2093
+ console.error(stepHeader(
2094
+ stepNum,
2095
+ totalSteps,
2096
+ `Git Hooks ${pc.yellow("recommended")}`,
2097
+ "Auto-updates index and community snapshots on every commit and pull.\n Without hooks, architectural drift & boundary violation signals stay empty\n until you re-run `ctxo index` manually (or start `ctxo watch`).",
2098
+ pc
2099
+ ));
1573
2100
  console.error("");
1574
2101
  const hooks = await clack.confirm({
1575
2102
  message: pc.cyan("Install hooks?"),
@@ -1580,6 +2107,14 @@ var InitCommand = class {
1580
2107
  process.exit(0);
1581
2108
  }
1582
2109
  installGitHooks = hooks;
2110
+ if (!installGitHooks) {
2111
+ console.error("");
2112
+ console.error(pc.yellow(" \u26A0 Drift & boundary-violation signals depend on repeated snapshots."));
2113
+ console.error(pc.dim(" Options without hooks:"));
2114
+ console.error(pc.dim(" \u2022 run `ctxo watch` during development"));
2115
+ console.error(pc.dim(" \u2022 add `ctxo index --check` to CI"));
2116
+ console.error(pc.dim(" \u2022 run `ctxo init` again to install hooks later"));
2117
+ }
1583
2118
  console.error("");
1584
2119
  }
1585
2120
  const s = clack.spinner();
@@ -1650,8 +2185,8 @@ var InitCommand = class {
1650
2185
  const gitignoreResult = ensureGitignore(this.projectRoot);
1651
2186
  const configResult = ensureConfig(this.projectRoot);
1652
2187
  if (!options.rulesOnly) {
1653
- const indexDir = join12(this.projectRoot, ".ctxo", "index");
1654
- if (!existsSync11(indexDir)) mkdirSync4(indexDir, { recursive: true });
2188
+ const indexDir = join13(this.projectRoot, ".ctxo", "index");
2189
+ if (!existsSync12(indexDir)) mkdirSync5(indexDir, { recursive: true });
1655
2190
  console.error("[ctxo] \u2713 .ctxo/index/ ready");
1656
2191
  }
1657
2192
  if (configResult.action !== "skipped") {
@@ -1703,7 +2238,7 @@ var InitCommand = class {
1703
2238
  for (const id of toolIds) {
1704
2239
  const platform = PLATFORMS.find((p) => p.id === id);
1705
2240
  if (platform) {
1706
- const exists = existsSync11(join12(this.projectRoot, platform.file));
2241
+ const exists = existsSync12(join13(this.projectRoot, platform.file));
1707
2242
  const action = platform.mode === "append" && exists ? "append section" : "create";
1708
2243
  console.error(` ${platform.file.padEnd(42)} (${action})`);
1709
2244
  }
@@ -1718,9 +2253,9 @@ var InitCommand = class {
1718
2253
  /* ---------------------------------------------------------------- */
1719
2254
  async getVersion() {
1720
2255
  try {
1721
- const pkgPath = join12(this.projectRoot, "node_modules", "@ctxo", "cli", "package.json");
1722
- if (existsSync11(pkgPath)) {
1723
- return JSON.parse(readFileSync7(pkgPath, "utf-8")).version;
2256
+ const pkgPath = join13(this.projectRoot, "node_modules", "@ctxo", "cli", "package.json");
2257
+ if (existsSync12(pkgPath)) {
2258
+ return JSON.parse(readFileSync8(pkgPath, "utf-8")).version;
1724
2259
  }
1725
2260
  const { createRequire: createRequire7 } = await import("module");
1726
2261
  const require4 = createRequire7(import.meta.url);
@@ -1730,23 +2265,23 @@ var InitCommand = class {
1730
2265
  }
1731
2266
  }
1732
2267
  installHook(hooksDir, hookName, hookContent) {
1733
- const hookPath = join12(hooksDir, hookName);
2268
+ const hookPath = join13(hooksDir, hookName);
1734
2269
  let existing = "";
1735
- if (existsSync11(hookPath)) {
1736
- existing = readFileSync7(hookPath, "utf-8");
2270
+ if (existsSync12(hookPath)) {
2271
+ existing = readFileSync8(hookPath, "utf-8");
1737
2272
  if (existing.includes(CTXO_START)) return;
1738
2273
  } else {
1739
2274
  existing = "#!/bin/sh\n";
1740
2275
  }
1741
2276
  const updated = existing.endsWith("\n") ? existing + "\n" + hookContent + "\n" : existing + "\n\n" + hookContent + "\n";
1742
- writeFileSync5(hookPath, updated, "utf-8");
2277
+ writeFileSync4(hookPath, updated, "utf-8");
1743
2278
  chmodSync(hookPath, 493);
1744
2279
  }
1745
2280
  };
1746
2281
 
1747
2282
  // src/cli/watch-command.ts
1748
- import { join as join13, extname as extname4, relative as relative2 } from "path";
1749
- import { readFileSync as readFileSync8 } from "fs";
2283
+ import { join as join14, extname as extname4, relative as relative2 } from "path";
2284
+ import { readFileSync as readFileSync9 } from "fs";
1750
2285
 
1751
2286
  // src/adapters/watcher/chokidar-watcher-adapter.ts
1752
2287
  import { watch } from "chokidar";
@@ -1787,13 +2322,15 @@ var ChokidarWatcherAdapter = class {
1787
2322
  };
1788
2323
 
1789
2324
  // src/cli/watch-command.ts
2325
+ import { execFileSync as execFileSync4 } from "child_process";
1790
2326
  var DEBOUNCE_MS = 300;
2327
+ var COMMUNITY_SNAPSHOT_DEBOUNCE_MS = 5e3;
1791
2328
  var WatchCommand = class {
1792
2329
  projectRoot;
1793
2330
  ctxoRoot;
1794
2331
  constructor(projectRoot) {
1795
2332
  this.projectRoot = projectRoot;
1796
- this.ctxoRoot = join13(projectRoot, ".ctxo");
2333
+ this.ctxoRoot = join14(projectRoot, ".ctxo");
1797
2334
  }
1798
2335
  async run() {
1799
2336
  console.error("[ctxo] Starting file watcher...");
@@ -1824,13 +2361,15 @@ var WatchCommand = class {
1824
2361
  }
1825
2362
  const supportedExtensions = registry.getSupportedExtensions();
1826
2363
  const writer = new JsonIndexWriter(this.ctxoRoot);
1827
- const storage = new SqliteStorageAdapter(this.ctxoRoot);
2364
+ const storage = new SqliteStorageAdapter(this.ctxoRoot, { allowProductionPath: true });
1828
2365
  await storage.init();
1829
2366
  const hasher = new ContentHasher();
1830
2367
  const gitAdapter = new SimpleGitAdapter(this.projectRoot);
1831
2368
  const revertDetector = new RevertDetector();
1832
2369
  const watcher = new ChokidarWatcherAdapter(this.projectRoot);
1833
2370
  const pendingFiles = /* @__PURE__ */ new Map();
2371
+ const snapshotThreshold = watchSnapshotMinFileChanges(loadConfig(this.ctxoRoot).config);
2372
+ const pathsChangedSinceSnapshot = /* @__PURE__ */ new Set();
1834
2373
  const reindexFile = async (filePath) => {
1835
2374
  const adapter = registry.getAdapter(filePath);
1836
2375
  if (!adapter) return;
@@ -1847,7 +2386,7 @@ var WatchCommand = class {
1847
2386
  kind: "commit"
1848
2387
  }));
1849
2388
  const antiPatterns2 = revertDetector.detect(commits2);
1850
- const source2 = readFileSync8(filePath, "utf-8");
2389
+ const source2 = readFileSync9(filePath, "utf-8");
1851
2390
  const fileIndex2 = {
1852
2391
  file: relativePath,
1853
2392
  lastModified: Math.floor(Date.now() / 1e3),
@@ -1877,7 +2416,7 @@ var WatchCommand = class {
1877
2416
  return;
1878
2417
  }
1879
2418
  }
1880
- const source = readFileSync8(filePath, "utf-8");
2419
+ const source = readFileSync9(filePath, "utf-8");
1881
2420
  const lastModified = Math.floor(Date.now() / 1e3);
1882
2421
  const symbols = await adapter.extractSymbols(relativePath, source);
1883
2422
  const edges = await adapter.extractEdges(relativePath, source);
@@ -1907,14 +2446,38 @@ var WatchCommand = class {
1907
2446
  console.error(`[ctxo] Failed to re-index ${relativePath}: ${err.message}`);
1908
2447
  }
1909
2448
  };
2449
+ let communitySnapshotTimer;
2450
+ const scheduleCommunitySnapshot = () => {
2451
+ if (pathsChangedSinceSnapshot.size < snapshotThreshold) return;
2452
+ if (communitySnapshotTimer) clearTimeout(communitySnapshotTimer);
2453
+ communitySnapshotTimer = setTimeout(() => {
2454
+ communitySnapshotTimer = void 0;
2455
+ if (pathsChangedSinceSnapshot.size < snapshotThreshold) return;
2456
+ const changedCount = pathsChangedSinceSnapshot.size;
2457
+ pathsChangedSinceSnapshot.clear();
2458
+ try {
2459
+ const snapshot = buildWatchCommunitySnapshot(storage);
2460
+ if (snapshot) {
2461
+ storage.writeCommunities(snapshot);
2462
+ console.error(
2463
+ `[ctxo] Community snapshot refreshed after ${changedCount} file change(s) (modularity ${snapshot.modularity.toFixed(3)})`
2464
+ );
2465
+ }
2466
+ } catch (err) {
2467
+ console.error(`[ctxo] Community snapshot skipped: ${err.message}`);
2468
+ }
2469
+ }, COMMUNITY_SNAPSHOT_DEBOUNCE_MS);
2470
+ };
1910
2471
  const debouncedReindex = (filePath) => {
1911
2472
  const existing = pendingFiles.get(filePath);
1912
2473
  if (existing) clearTimeout(existing);
1913
2474
  pendingFiles.set(
1914
2475
  filePath,
1915
- setTimeout(() => {
2476
+ setTimeout(async () => {
1916
2477
  pendingFiles.delete(filePath);
1917
- reindexFile(filePath);
2478
+ await reindexFile(filePath);
2479
+ pathsChangedSinceSnapshot.add(filePath);
2480
+ scheduleCommunitySnapshot();
1918
2481
  }, DEBOUNCE_MS)
1919
2482
  );
1920
2483
  };
@@ -1936,6 +2499,7 @@ var WatchCommand = class {
1936
2499
  for (const timeout of pendingFiles.values()) {
1937
2500
  clearTimeout(timeout);
1938
2501
  }
2502
+ if (communitySnapshotTimer) clearTimeout(communitySnapshotTimer);
1939
2503
  const roslynShutdown = roslynAdapter ? roslynAdapter.dispose() : Promise.resolve();
1940
2504
  roslynShutdown.then(() => {
1941
2505
  watcher.stop().then(() => {
@@ -1949,23 +2513,53 @@ var WatchCommand = class {
1949
2513
  process.on("SIGTERM", cleanup);
1950
2514
  }
1951
2515
  };
2516
+ function buildWatchCommunitySnapshot(storage) {
2517
+ const symbols = storage.getAllSymbols();
2518
+ if (symbols.length === 0) return void 0;
2519
+ const edges = storage.getAllEdges();
2520
+ const graph = new SymbolGraph();
2521
+ for (const symbol of symbols) graph.addNode(symbol);
2522
+ for (const edge of edges) graph.addEdge(edge);
2523
+ const pagerank = new PageRankCalculator().calculate(graph, { limit: graph.nodeCount });
2524
+ const pagerankMap = /* @__PURE__ */ new Map();
2525
+ for (const entry of pagerank.rankings) pagerankMap.set(entry.symbolId, entry.score);
2526
+ const detector = new CommunityDetector();
2527
+ const detectResult = detector.detect(graph, pagerankMap, "full");
2528
+ return {
2529
+ version: 1,
2530
+ computedAt: (/* @__PURE__ */ new Date()).toISOString(),
2531
+ commitSha: readHeadSha(),
2532
+ modularity: detectResult.modularity,
2533
+ communities: detectResult.communities,
2534
+ godNodes: detectResult.godNodes,
2535
+ edgeQuality: detectResult.edgeQuality,
2536
+ crossClusterEdges: detectResult.crossClusterEdges
2537
+ };
2538
+ }
2539
+ function readHeadSha() {
2540
+ try {
2541
+ return execFileSync4("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf-8" }).trim();
2542
+ } catch {
2543
+ return "nocommit";
2544
+ }
2545
+ }
1952
2546
 
1953
2547
  // src/cli/stats-command.ts
1954
- import { join as join14 } from "path";
1955
- import { existsSync as existsSync12, readFileSync as readFileSync9 } from "fs";
2548
+ import { join as join15 } from "path";
2549
+ import { existsSync as existsSync13, readFileSync as readFileSync10 } from "fs";
1956
2550
  import initSqlJs from "sql.js";
1957
2551
  var StatsCommand = class {
1958
2552
  ctxoRoot;
1959
2553
  constructor(projectRoot) {
1960
- this.ctxoRoot = join14(projectRoot, ".ctxo");
2554
+ this.ctxoRoot = join15(projectRoot, ".ctxo");
1961
2555
  }
1962
2556
  async run(options) {
1963
2557
  if (!this.isStatsEnabled()) {
1964
2558
  console.error("[ctxo] Stats collection is disabled in .ctxo/config.yaml");
1965
2559
  return;
1966
2560
  }
1967
- const dbPath = join14(this.ctxoRoot, ".cache", "symbols.db");
1968
- if (!existsSync12(dbPath)) {
2561
+ const dbPath = join15(this.ctxoRoot, ".cache", "symbols.db");
2562
+ if (!existsSync13(dbPath)) {
1969
2563
  if (options.json) {
1970
2564
  process.stdout.write(JSON.stringify(this.emptyReport(options.days)) + "\n");
1971
2565
  } else {
@@ -1974,15 +2568,15 @@ var StatsCommand = class {
1974
2568
  return;
1975
2569
  }
1976
2570
  const SQL = await initSqlJs();
1977
- const buffer = readFileSync9(dbPath);
2571
+ const buffer = readFileSync10(dbPath);
1978
2572
  const db = new SQL.Database(buffer);
1979
2573
  const recorder = new SessionRecorderAdapter(db);
1980
2574
  try {
1981
2575
  if (options.clear) {
1982
2576
  recorder.clear();
1983
2577
  const data = db.export();
1984
- const { writeFileSync: writeFileSync7 } = await import("fs");
1985
- writeFileSync7(dbPath, Buffer.from(data));
2578
+ const { writeFileSync: writeFileSync6 } = await import("fs");
2579
+ writeFileSync6(dbPath, Buffer.from(data));
1986
2580
  console.error("[ctxo] Session data cleared.");
1987
2581
  return;
1988
2582
  }
@@ -2012,10 +2606,10 @@ var StatsCommand = class {
2012
2606
  }
2013
2607
  }
2014
2608
  isStatsEnabled() {
2015
- const configPath = join14(this.ctxoRoot, "config.yaml");
2016
- if (!existsSync12(configPath)) return true;
2609
+ const configPath = join15(this.ctxoRoot, "config.yaml");
2610
+ if (!existsSync13(configPath)) return true;
2017
2611
  try {
2018
- const raw = readFileSync9(configPath, "utf-8");
2612
+ const raw = readFileSync10(configPath, "utf-8");
2019
2613
  const match = raw.match(/stats:\s*\n\s*enabled:\s*(true|false)/);
2020
2614
  if (match) return match[1] !== "false";
2021
2615
  return true;
@@ -2104,15 +2698,17 @@ var StatsCommand = class {
2104
2698
  };
2105
2699
 
2106
2700
  // src/cli/doctor-command.ts
2107
- import { join as join21 } from "path";
2108
- import { existsSync as existsSync19 } from "fs";
2701
+ import { join as join22 } from "path";
2702
+ import { existsSync as existsSync20 } from "fs";
2109
2703
 
2110
2704
  // src/adapters/diagnostics/health-checker.ts
2111
- var log3 = createLogger("ctxo:doctor");
2705
+ var log4 = createLogger("ctxo:doctor");
2112
2706
  var HealthChecker = class {
2113
2707
  checks;
2114
- constructor(checks) {
2708
+ quiet;
2709
+ constructor(checks, options = {}) {
2115
2710
  this.checks = checks;
2711
+ this.quiet = options.quiet ?? false;
2116
2712
  }
2117
2713
  async runAll(ctx) {
2118
2714
  const settled = await Promise.allSettled(
@@ -2122,11 +2718,13 @@ var HealthChecker = class {
2122
2718
  const check = this.checks[i];
2123
2719
  if (outcome.status === "fulfilled") {
2124
2720
  const r = outcome.value;
2125
- log3.info(`${r.id}: ${r.status.toUpperCase()} (${r.message})`);
2721
+ if (!this.quiet) {
2722
+ log4.info(`${r.id}: ${r.status.toUpperCase()} (${r.message})`);
2723
+ }
2126
2724
  return r;
2127
2725
  }
2128
2726
  const message = outcome.reason?.message ?? "Unknown error";
2129
- log3.error(`${check.id}: FAIL (${message})`);
2727
+ if (!this.quiet) log4.error(`${check.id}: FAIL (${message})`);
2130
2728
  return {
2131
2729
  id: check.id,
2132
2730
  title: check.title,
@@ -2437,15 +3035,15 @@ function pluginResolves(packageName) {
2437
3035
  }
2438
3036
 
2439
3037
  // src/adapters/diagnostics/checks/git-check.ts
2440
- import { execFileSync as execFileSync4 } from "child_process";
2441
- import { existsSync as existsSync13 } from "fs";
2442
- import { join as join15 } from "path";
3038
+ import { execFileSync as execFileSync5 } from "child_process";
3039
+ import { existsSync as existsSync14 } from "fs";
3040
+ import { join as join16 } from "path";
2443
3041
  var GitBinaryCheck = class {
2444
3042
  id = "git_binary";
2445
3043
  title = "Git available";
2446
3044
  async run(_ctx) {
2447
3045
  try {
2448
- const version = execFileSync4("git", ["--version"], { encoding: "utf-8" }).trim();
3046
+ const version = execFileSync5("git", ["--version"], { encoding: "utf-8" }).trim();
2449
3047
  return { id: this.id, title: this.title, status: "pass", message: version, value: version };
2450
3048
  } catch {
2451
3049
  return { id: this.id, title: this.title, status: "fail", message: "git not found in PATH", fix: "Install git and ensure it is in your PATH" };
@@ -2456,7 +3054,7 @@ var GitRepoCheck = class {
2456
3054
  id = "git_repo";
2457
3055
  title = "Git repository";
2458
3056
  async run(ctx) {
2459
- if (existsSync13(join15(ctx.projectRoot, ".git"))) {
3057
+ if (existsSync14(join16(ctx.projectRoot, ".git"))) {
2460
3058
  return { id: this.id, title: this.title, status: "pass", message: ctx.projectRoot, value: ctx.projectRoot };
2461
3059
  }
2462
3060
  return { id: this.id, title: this.title, status: "fail", message: "Not a git repository", fix: 'Run "git init" or clone a repository' };
@@ -2464,9 +3062,9 @@ var GitRepoCheck = class {
2464
3062
  };
2465
3063
 
2466
3064
  // src/adapters/diagnostics/checks/index-check.ts
2467
- import { existsSync as existsSync14 } from "fs";
2468
- import { execFileSync as execFileSync5 } from "child_process";
2469
- import { join as join16 } from "path";
3065
+ import { existsSync as existsSync15 } from "fs";
3066
+ import { execFileSync as execFileSync6 } from "child_process";
3067
+ import { join as join17 } from "path";
2470
3068
  function pass2(id, title, message, value) {
2471
3069
  return { id, title, status: "pass", message, value };
2472
3070
  }
@@ -2483,8 +3081,8 @@ var IndexDirectoryCheck = class {
2483
3081
  id = "index_directory";
2484
3082
  title = "Index directory";
2485
3083
  async run(ctx) {
2486
- const indexDir = join16(ctx.ctxoRoot, "index");
2487
- if (!existsSync14(indexDir)) {
3084
+ const indexDir = join17(ctx.ctxoRoot, "index");
3085
+ if (!existsSync15(indexDir)) {
2488
3086
  return fail(this.id, this.title, "No index directory", 'Run "ctxo index"');
2489
3087
  }
2490
3088
  const indices = getIndices(ctx);
@@ -2564,7 +3162,7 @@ var OrphanedFilesCheck = class {
2564
3162
  }
2565
3163
  getSourceFiles(projectRoot) {
2566
3164
  try {
2567
- const output = execFileSync5("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
3165
+ const output = execFileSync6("git", ["ls-files", "--cached", "--others", "--exclude-standard"], {
2568
3166
  cwd: projectRoot,
2569
3167
  encoding: "utf-8"
2570
3168
  });
@@ -2578,8 +3176,8 @@ var CoChangesCacheCheck = class {
2578
3176
  id = "co_changes_cache";
2579
3177
  title = "Co-changes cache";
2580
3178
  async run(ctx) {
2581
- const coChangesPath = join16(ctx.ctxoRoot, "index", "co-changes.json");
2582
- if (existsSync14(coChangesPath)) {
3179
+ const coChangesPath = join17(ctx.ctxoRoot, "index", "co-changes.json");
3180
+ if (existsSync15(coChangesPath)) {
2583
3181
  return pass2(this.id, this.title, "present");
2584
3182
  }
2585
3183
  return warn2(this.id, this.title, "missing (co-change analysis disabled)", 'Run "ctxo index" with git history');
@@ -2603,20 +3201,20 @@ var SchemaVersionCheck = class {
2603
3201
  };
2604
3202
 
2605
3203
  // src/adapters/diagnostics/checks/storage-check.ts
2606
- import { existsSync as existsSync15, readFileSync as readFileSync10 } from "fs";
2607
- import { join as join17 } from "path";
3204
+ import { existsSync as existsSync16, readFileSync as readFileSync11 } from "fs";
3205
+ import { join as join18 } from "path";
2608
3206
  var SqliteCacheCheck = class {
2609
3207
  id = "sqlite_cache";
2610
3208
  title = "SQLite cache";
2611
3209
  async run(ctx) {
2612
- const dbPath = join17(ctx.ctxoRoot, ".cache", "symbols.db");
2613
- if (!existsSync15(dbPath)) {
3210
+ const dbPath = join18(ctx.ctxoRoot, ".cache", "symbols.db");
3211
+ if (!existsSync16(dbPath)) {
2614
3212
  return { id: this.id, title: this.title, status: "warn", message: "SQLite cache missing", fix: 'Run "ctxo sync" to rebuild' };
2615
3213
  }
2616
3214
  try {
2617
3215
  const { default: initSqlJs2 } = await import("sql.js");
2618
3216
  const SQL = await initSqlJs2();
2619
- const buffer = readFileSync10(dbPath);
3217
+ const buffer = readFileSync11(dbPath);
2620
3218
  const db = new SQL.Database(new Uint8Array(buffer));
2621
3219
  const result = db.exec("PRAGMA integrity_check");
2622
3220
  db.close();
@@ -2632,18 +3230,18 @@ var SqliteCacheCheck = class {
2632
3230
  };
2633
3231
 
2634
3232
  // src/adapters/diagnostics/checks/config-check.ts
2635
- import { existsSync as existsSync16, readFileSync as readFileSync11 } from "fs";
2636
- import { join as join18 } from "path";
3233
+ import { existsSync as existsSync17, readFileSync as readFileSync12 } from "fs";
3234
+ import { join as join19 } from "path";
2637
3235
  var ConfigFileCheck = class {
2638
3236
  id = "config_file";
2639
3237
  title = "Config file";
2640
3238
  async run(ctx) {
2641
- const configPath = join18(ctx.ctxoRoot, "config.yaml");
2642
- if (!existsSync16(configPath)) {
3239
+ const configPath = join19(ctx.ctxoRoot, "config.yaml");
3240
+ if (!existsSync17(configPath)) {
2643
3241
  return { id: this.id, title: this.title, status: "warn", message: "No config.yaml (using defaults)" };
2644
3242
  }
2645
3243
  try {
2646
- const content = readFileSync11(configPath, "utf-8");
3244
+ const content = readFileSync12(configPath, "utf-8");
2647
3245
  if (content.trim().length === 0) {
2648
3246
  return { id: this.id, title: this.title, status: "warn", message: "config.yaml is empty (using defaults)" };
2649
3247
  }
@@ -2677,8 +3275,8 @@ var ConfigFileCheck = class {
2677
3275
  };
2678
3276
 
2679
3277
  // src/adapters/diagnostics/checks/disk-check.ts
2680
- import { existsSync as existsSync17, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
2681
- import { join as join19 } from "path";
3278
+ import { existsSync as existsSync18, readdirSync as readdirSync3, statSync as statSync2 } from "fs";
3279
+ import { join as join20 } from "path";
2682
3280
  var DEFAULT_THRESHOLDS = { warnMb: 100, failMb: 500 };
2683
3281
  var DiskUsageCheck = class {
2684
3282
  id = "disk_usage";
@@ -2688,7 +3286,7 @@ var DiskUsageCheck = class {
2688
3286
  this.thresholds = thresholds;
2689
3287
  }
2690
3288
  async run(ctx) {
2691
- if (!existsSync17(ctx.ctxoRoot)) {
3289
+ if (!existsSync18(ctx.ctxoRoot)) {
2692
3290
  return { id: this.id, title: this.title, status: "pass", message: "no .ctxo/ directory", value: "0" };
2693
3291
  }
2694
3292
  const bytes = this.getDirSize(ctx.ctxoRoot);
@@ -2710,7 +3308,7 @@ var DiskUsageCheck = class {
2710
3308
  if (entry.isFile()) {
2711
3309
  try {
2712
3310
  const parent = entry.parentPath ?? entry.path ?? dirPath;
2713
- total += statSync2(join19(parent, entry.name)).size;
3311
+ total += statSync2(join20(parent, entry.name)).size;
2714
3312
  } catch {
2715
3313
  }
2716
3314
  }
@@ -2722,10 +3320,10 @@ var DiskUsageCheck = class {
2722
3320
  };
2723
3321
 
2724
3322
  // src/cli/doctor-fix.ts
2725
- import { execFileSync as execFileSync6 } from "child_process";
2726
- import { appendFileSync, existsSync as existsSync18, mkdirSync as mkdirSync5, rmSync as rmSync2, unlinkSync as unlinkSync2 } from "fs";
3323
+ import { execFileSync as execFileSync7 } from "child_process";
3324
+ import { appendFileSync, existsSync as existsSync19, mkdirSync as mkdirSync6, rmSync as rmSync2, unlinkSync as unlinkSync2 } from "fs";
2727
3325
  import { createRequire as createRequire6 } from "module";
2728
- import { dirname as dirname7, join as join20 } from "path";
3326
+ import { dirname as dirname7, join as join21 } from "path";
2729
3327
  async function applyFixes(report, options) {
2730
3328
  if (isCI(process.env) && !options.yes && !options.dryRun) {
2731
3329
  return {
@@ -2860,8 +3458,8 @@ async function fixIndex(projectRoot, dryRun) {
2860
3458
  }
2861
3459
  async function pruneOrphans(projectRoot, ctxoRoot, dryRun) {
2862
3460
  if (dryRun) return;
2863
- const indexDir = join20(ctxoRoot, "index");
2864
- if (!existsSync18(indexDir)) return;
3461
+ const indexDir = join21(ctxoRoot, "index");
3462
+ if (!existsSync19(indexDir)) return;
2865
3463
  const sourceFiles = listGitFiles2(projectRoot);
2866
3464
  if (sourceFiles.size === 0) return;
2867
3465
  const reader = new JsonIndexReader(ctxoRoot);
@@ -2869,9 +3467,9 @@ async function pruneOrphans(projectRoot, ctxoRoot, dryRun) {
2869
3467
  let removed = 0;
2870
3468
  for (const idx of indices) {
2871
3469
  if (sourceFiles.has(idx.file)) continue;
2872
- const jsonPath = join20(indexDir, `${idx.file}.json`);
3470
+ const jsonPath = join21(indexDir, `${idx.file}.json`);
2873
3471
  try {
2874
- if (existsSync18(jsonPath)) {
3472
+ if (existsSync19(jsonPath)) {
2875
3473
  unlinkSync2(jsonPath);
2876
3474
  removed += 1;
2877
3475
  pruneEmptyParents(jsonPath, indexDir);
@@ -2894,7 +3492,7 @@ function pruneEmptyParents(filePath, stopAt) {
2894
3492
  }
2895
3493
  function listGitFiles2(projectRoot) {
2896
3494
  try {
2897
- const out = execFileSync6(
3495
+ const out = execFileSync7(
2898
3496
  "git",
2899
3497
  ["ls-files", "--cached", "--others", "--exclude-standard"],
2900
3498
  { cwd: projectRoot, encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 }
@@ -2905,9 +3503,9 @@ function listGitFiles2(projectRoot) {
2905
3503
  }
2906
3504
  }
2907
3505
  async function fixSqliteCache(projectRoot, ctxoRoot, dryRun) {
2908
- const cacheDir = join20(ctxoRoot, ".cache");
3506
+ const cacheDir = join21(ctxoRoot, ".cache");
2909
3507
  if (dryRun) return;
2910
- if (existsSync18(cacheDir)) rmSync2(cacheDir, { recursive: true, force: true });
3508
+ if (existsSync19(cacheDir)) rmSync2(cacheDir, { recursive: true, force: true });
2911
3509
  await new SyncCommand(projectRoot).run();
2912
3510
  }
2913
3511
  var doctorRequire = createRequire6(import.meta.url);
@@ -2924,8 +3522,8 @@ function isCI(env) {
2924
3522
  }
2925
3523
  function writeLog(ctxoRoot, attempts, halted, reason) {
2926
3524
  try {
2927
- if (!existsSync18(ctxoRoot)) mkdirSync5(ctxoRoot, { recursive: true });
2928
- const path = join20(ctxoRoot, "doctor-fix.log");
3525
+ if (!existsSync19(ctxoRoot)) mkdirSync6(ctxoRoot, { recursive: true });
3526
+ const path = join21(ctxoRoot, "doctor-fix.log");
2929
3527
  const ts = (/* @__PURE__ */ new Date()).toISOString();
2930
3528
  const lines = [`# ${ts} doctor --fix${halted ? " (halted: " + reason + ")" : ""}`];
2931
3529
  for (const a of attempts) {
@@ -2958,10 +3556,10 @@ var DoctorCommand = class {
2958
3556
  ctxoRoot;
2959
3557
  constructor(projectRoot) {
2960
3558
  this.projectRoot = projectRoot;
2961
- this.ctxoRoot = join21(projectRoot, ".ctxo");
3559
+ this.ctxoRoot = join22(projectRoot, ".ctxo");
2962
3560
  }
2963
3561
  async run(options = {}) {
2964
- const { getVersion: getVersion2 } = await import("./cli-router-T3VZZJD6.js");
3562
+ const { getVersion: getVersion2 } = await import("./cli-router-GZA44VOV.js");
2965
3563
  console.error(`ctxo v${getVersion2()} \u2014 health check
2966
3564
  `);
2967
3565
  const checks = [
@@ -2983,9 +3581,9 @@ var DoctorCommand = class {
2983
3581
  new CoChangesCacheCheck(),
2984
3582
  new SchemaVersionCheck()
2985
3583
  ];
2986
- const indexDir = join21(this.ctxoRoot, "index");
2987
- const indices = existsSync19(indexDir) ? new JsonIndexReader(this.ctxoRoot).readAll() : [];
2988
- const checker = new HealthChecker(checks);
3584
+ const indexDir = join22(this.ctxoRoot, "index");
3585
+ const indices = existsSync20(indexDir) ? new JsonIndexReader(this.ctxoRoot).readAll() : [];
3586
+ const checker = new HealthChecker(checks, { quiet: Boolean(options.quiet) });
2989
3587
  const report = await checker.runAll({
2990
3588
  projectRoot: this.projectRoot,
2991
3589
  ctxoRoot: this.ctxoRoot,
@@ -3025,13 +3623,13 @@ var DoctorCommand = class {
3025
3623
  };
3026
3624
 
3027
3625
  // src/cli/visualize-command.ts
3028
- import { readFileSync as readFileSync12, writeFileSync as writeFileSync6, existsSync as existsSync20 } from "fs";
3029
- import { join as join22, basename } from "path";
3626
+ import { readFileSync as readFileSync13, writeFileSync as writeFileSync5, existsSync as existsSync21 } from "fs";
3627
+ import { join as join23, basename } from "path";
3030
3628
  import { execFile } from "child_process";
3031
3629
  var VisualizeCommand = class {
3032
3630
  constructor(projectRoot) {
3033
3631
  this.projectRoot = projectRoot;
3034
- this.ctxoRoot = join22(projectRoot, ".ctxo");
3632
+ this.ctxoRoot = join23(projectRoot, ".ctxo");
3035
3633
  }
3036
3634
  projectRoot;
3037
3635
  ctxoRoot;
@@ -3091,10 +3689,37 @@ var VisualizeCommand = class {
3091
3689
  fileLayerMap.set(file, layer);
3092
3690
  }
3093
3691
  }
3692
+ const communityWriter = new CommunitySnapshotWriter(this.ctxoRoot, {
3693
+ allowProductionPath: true
3694
+ });
3695
+ const currentSnapshot = communityWriter.readCurrent();
3696
+ const communityByIdForSymbol = /* @__PURE__ */ new Map();
3697
+ if (currentSnapshot) {
3698
+ for (const entry of currentSnapshot.communities) {
3699
+ communityByIdForSymbol.set(entry.symbolId, {
3700
+ id: entry.communityId,
3701
+ label: entry.communityLabel
3702
+ });
3703
+ }
3704
+ }
3705
+ const architectureWriter = new ArchitectureArtifactsWriter(this.ctxoRoot, {
3706
+ allowProductionPath: true
3707
+ });
3708
+ const violationsArtifact = architectureWriter.readBoundaryViolations();
3709
+ const violationEdgeSeverity = /* @__PURE__ */ new Map();
3710
+ if (violationsArtifact) {
3711
+ for (const v of violationsArtifact.violations) {
3712
+ violationEdgeSeverity.set(
3713
+ `${v.from.symbolId}|${v.to.symbolId}`,
3714
+ v.severity
3715
+ );
3716
+ }
3717
+ }
3094
3718
  const totalSymbols = graph.nodeCount;
3095
3719
  let nodes = graph.allNodes().map((sym) => {
3096
3720
  const file = sym.symbolId.split("::")[0] ?? "";
3097
3721
  const dead = deadMap.get(sym.symbolId);
3722
+ const community = communityByIdForSymbol.get(sym.symbolId);
3098
3723
  return {
3099
3724
  id: sym.symbolId,
3100
3725
  name: sym.name,
@@ -3108,7 +3733,9 @@ var VisualizeCommand = class {
3108
3733
  cyclomatic: complexityMap.get(sym.symbolId),
3109
3734
  hasAntiPattern: antiPatternFiles.has(file),
3110
3735
  inDegree: graph.getReverseEdges(sym.symbolId).length,
3111
- outDegree: graph.getForwardEdges(sym.symbolId).length
3736
+ outDegree: graph.getForwardEdges(sym.symbolId).length,
3737
+ communityId: community?.id ?? -1,
3738
+ communityLabel: community?.label ?? "unassigned"
3112
3739
  };
3113
3740
  });
3114
3741
  if (options.maxNodes && nodes.length > options.maxNodes) {
@@ -3116,12 +3743,31 @@ var VisualizeCommand = class {
3116
3743
  nodes = nodes.slice(0, options.maxNodes);
3117
3744
  }
3118
3745
  const nodeIds = new Set(nodes.map((n) => n.id));
3119
- const edges = graph.allEdges().filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to)).map((e) => ({ source: e.from, target: e.to, kind: e.kind }));
3746
+ const edges = graph.allEdges().filter((e) => nodeIds.has(e.from) && nodeIds.has(e.to)).map((e) => {
3747
+ const severity = violationEdgeSeverity.get(`${e.from}|${e.to}`);
3748
+ return {
3749
+ source: e.from,
3750
+ target: e.to,
3751
+ kind: e.kind,
3752
+ ...severity ? { violationSeverity: severity } : {}
3753
+ };
3754
+ });
3120
3755
  const filesInfo = indices.filter((fi) => nodeIds.has(fi.symbols[0]?.symbolId ?? "___") || fi.symbols.some((s) => nodeIds.has(s.symbolId))).map((fi) => ({
3121
3756
  file: fi.file,
3122
3757
  intent: fi.intent.map((i) => ({ hash: i.hash, message: i.message, date: i.date })),
3123
3758
  antiPatterns: fi.antiPatterns.map((a) => ({ hash: a.hash, message: a.message, date: a.date }))
3124
3759
  }));
3760
+ const legendCounts = /* @__PURE__ */ new Map();
3761
+ for (const node of nodes) {
3762
+ if (node.communityId < 0) continue;
3763
+ const entry = legendCounts.get(node.communityId);
3764
+ if (entry) {
3765
+ entry.count += 1;
3766
+ } else {
3767
+ legendCounts.set(node.communityId, { label: node.communityLabel, count: 1 });
3768
+ }
3769
+ }
3770
+ const communities = [...legendCounts.entries()].map(([id, { label, count }]) => ({ id, label, memberCount: count })).sort((a, b) => b.memberCount - a.memberCount);
3125
3771
  const payload = {
3126
3772
  projectName: basename(this.projectRoot),
3127
3773
  generatedAt: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
@@ -3130,13 +3776,16 @@ var VisualizeCommand = class {
3130
3776
  nodes,
3131
3777
  edges,
3132
3778
  layers: overlayResult.layers,
3133
- files: filesInfo
3779
+ files: filesInfo,
3780
+ communities,
3781
+ modularity: currentSnapshot?.modularity ?? 0,
3782
+ violationCount: violationsArtifact?.violations.length ?? 0
3134
3783
  };
3135
3784
  const templatePath = this.getTemplatePath();
3136
- const template = readFileSync12(templatePath, "utf-8");
3785
+ const template = readFileSync13(templatePath, "utf-8");
3137
3786
  const html = template.replace("/*__CTXO_DATA__*/null", JSON.stringify(payload));
3138
- const outputPath = options.output ?? join22(this.ctxoRoot, "visualize.html");
3139
- writeFileSync6(outputPath, html, "utf-8");
3787
+ const outputPath = options.output ?? join23(this.ctxoRoot, "visualize.html");
3788
+ writeFileSync5(outputPath, html, "utf-8");
3140
3789
  console.error(`[ctxo] Wrote visualization to ${outputPath}`);
3141
3790
  console.error(`[ctxo] ${nodes.length} symbols, ${edges.length} edges, ${deadResult.deadSymbols.length} dead`);
3142
3791
  if (!options.noBrowser) {
@@ -3146,9 +3795,9 @@ var VisualizeCommand = class {
3146
3795
  getTemplatePath() {
3147
3796
  let dir = import.meta.dirname;
3148
3797
  for (let i = 0; i < 10; i++) {
3149
- const candidate = join22(dir, "pages", "visualize-template.html");
3150
- if (existsSync20(candidate)) return candidate;
3151
- const parent = join22(dir, "..");
3798
+ const candidate = join23(dir, "pages", "visualize-template.html");
3799
+ if (existsSync21(candidate)) return candidate;
3800
+ const parent = join23(dir, "..");
3152
3801
  if (parent === dir) break;
3153
3802
  dir = parent;
3154
3803
  }
@@ -3175,16 +3824,16 @@ var VisualizeCommand = class {
3175
3824
  function getVersion() {
3176
3825
  let dir = import.meta.dirname;
3177
3826
  for (let i = 0; i < 10; i++) {
3178
- const pkg = join23(dir, "package.json");
3179
- if (existsSync21(pkg)) {
3827
+ const pkg = join24(dir, "package.json");
3828
+ if (existsSync22(pkg)) {
3180
3829
  try {
3181
- const json = JSON.parse(readFileSync13(pkg, "utf-8"));
3830
+ const json = JSON.parse(readFileSync14(pkg, "utf-8"));
3182
3831
  return json.version ?? "unknown";
3183
3832
  } catch {
3184
3833
  break;
3185
3834
  }
3186
3835
  }
3187
- const parent = join23(dir, "..");
3836
+ const parent = join24(dir, "..");
3188
3837
  if (parent === dir) break;
3189
3838
  dir = parent;
3190
3839
  }
@@ -3193,7 +3842,7 @@ function getVersion() {
3193
3842
  function locateGitRoot(startDir) {
3194
3843
  let dir = startDir;
3195
3844
  for (let i = 0; i < 12; i++) {
3196
- if (existsSync21(join23(dir, ".git"))) return dir;
3845
+ if (existsSync22(join24(dir, ".git"))) return dir;
3197
3846
  const parent = dirname8(dir);
3198
3847
  if (parent === dir) break;
3199
3848
  dir = parent;
@@ -3237,6 +3886,7 @@ var CliRouter = class {
3237
3886
  }
3238
3887
  const checkArg = args.includes("--check");
3239
3888
  const skipHistory = args.includes("--skip-history");
3889
+ const skipCommunity = args.includes("--skip-community");
3240
3890
  const installMissing = args.includes("--install-missing");
3241
3891
  const maxHistoryIdx = args.indexOf("--max-history");
3242
3892
  const maxHistoryArg = maxHistoryIdx !== -1 ? Number(args[maxHistoryIdx + 1]) : void 0;
@@ -3249,6 +3899,7 @@ var CliRouter = class {
3249
3899
  file: fileArg,
3250
3900
  check: checkArg,
3251
3901
  skipHistory,
3902
+ skipCommunity,
3252
3903
  maxHistory: maxHistoryArg,
3253
3904
  installMissing
3254
3905
  });
@@ -3372,6 +4023,7 @@ ctxo v${v} \u2014 MCP server for dependency-aware codebase context
3372
4023
  Usage:
3373
4024
  ctxo Start MCP server (stdio transport)
3374
4025
  ctxo index Build full codebase index (--max-history N, default 20)
4026
+ ctxo index --skip-community Skip Louvain community detection for faster indexing
3375
4027
  ctxo sync Rebuild SQLite cache from committed JSON index
3376
4028
  ctxo watch Start file watcher for incremental re-indexing
3377
4029
  ctxo verify-index CI gate: fail if index is stale
@@ -3403,4 +4055,4 @@ export {
3403
4055
  getVersion,
3404
4056
  locateGitRoot
3405
4057
  };
3406
- //# sourceMappingURL=cli-router-T3VZZJD6.js.map
4058
+ //# sourceMappingURL=cli-router-GZA44VOV.js.map