@ctxo/cli 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,62 +1,153 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ atomicWrite,
4
+ createLogger
5
+ } from "./chunk-6EKERTJ7.js";
2
6
  import {
3
7
  JsonIndexReader
4
- } from "./chunk-WXA2JJLH.js";
8
+ } from "./chunk-2TVHKCCU.js";
5
9
 
6
- // src/core/logger.ts
7
- function isDebugEnabled(namespace) {
8
- const debugEnv = process.env["DEBUG"] ?? "";
9
- if (!debugEnv) return false;
10
- const patterns = debugEnv.split(",").map((p) => p.trim());
11
- return patterns.some((p) => {
12
- if (p === "*" || p === "ctxo:*") return true;
13
- if (p === namespace) return true;
14
- if (p.endsWith(":*") && namespace.startsWith(p.slice(0, -1))) return true;
15
- return false;
16
- });
17
- }
18
- function createLogger(namespace) {
19
- const prefix = `[${namespace}]`;
20
- return {
21
- debug(message, ...args) {
22
- if (isDebugEnabled(namespace)) {
23
- console.error(`${prefix} DEBUG ${message}`, ...args);
10
+ // src/adapters/storage/sqlite-storage-adapter.ts
11
+ import initSqlJs from "sql.js";
12
+ import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
13
+ import { join as join2, dirname } from "path";
14
+
15
+ // src/adapters/storage/community-snapshot-writer.ts
16
+ import {
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ readdirSync,
21
+ unlinkSync
22
+ } from "fs";
23
+ import { tmpdir } from "os";
24
+ import { basename, join, resolve } from "path";
25
+ var log = createLogger("ctxo:community-snapshot");
26
+ var DEFAULT_HISTORY_LIMIT = 10;
27
+ var HISTORY_DIR_NAME = "communities.history";
28
+ var CURRENT_FILE_NAME = "communities.json";
29
+ var NOCOMMIT_SHA = "nocommit";
30
+ var CommunitySnapshotWriter = class {
31
+ indexDir;
32
+ historyDir;
33
+ historyLimit;
34
+ constructor(ctxoRoot, historyLimitOrOptions = DEFAULT_HISTORY_LIMIT) {
35
+ const options = typeof historyLimitOrOptions === "number" ? { historyLimit: historyLimitOrOptions } : historyLimitOrOptions;
36
+ this.indexDir = join(ctxoRoot, "index");
37
+ this.historyDir = join(this.indexDir, HISTORY_DIR_NAME);
38
+ this.historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT;
39
+ assertSafeWritePath(ctxoRoot, options.allowProductionPath ?? false);
40
+ }
41
+ writeSnapshot(snapshot) {
42
+ mkdirSync(this.indexDir, { recursive: true });
43
+ mkdirSync(this.historyDir, { recursive: true });
44
+ const currentPath = join(this.indexDir, CURRENT_FILE_NAME);
45
+ if (existsSync(currentPath)) {
46
+ try {
47
+ const existingRaw = readFileSync(currentPath, "utf-8");
48
+ const existing = JSON.parse(existingRaw);
49
+ if (existing.commitSha && existing.commitSha !== NOCOMMIT_SHA) {
50
+ const historyFile = this.historyFilename(existing);
51
+ this.atomicWrite(join(this.historyDir, historyFile), existingRaw);
52
+ }
53
+ } catch (err) {
54
+ log.error(`failed to archive existing snapshot: ${err.message}`);
24
55
  }
25
- },
26
- info(message, ...args) {
27
- console.error(`${prefix} ${message}`, ...args);
28
- },
29
- warn(message, ...args) {
30
- console.error(`${prefix} WARN ${message}`, ...args);
31
- },
32
- error(message, ...args) {
33
- console.error(`${prefix} ERROR ${message}`, ...args);
34
56
  }
35
- };
57
+ const json = JSON.stringify(snapshot, null, 2);
58
+ this.atomicWrite(currentPath, json);
59
+ this.rotateHistory();
60
+ }
61
+ readCurrent() {
62
+ const path = join(this.indexDir, CURRENT_FILE_NAME);
63
+ if (!existsSync(path)) return void 0;
64
+ return this.safeParse(path);
65
+ }
66
+ listHistory(limit) {
67
+ if (!existsSync(this.historyDir)) return [];
68
+ const entries = readdirSync(this.historyDir).filter((name) => name.endsWith(".json")).sort().reverse();
69
+ const snapshots = [];
70
+ for (const name of entries) {
71
+ const snap = this.safeParse(join(this.historyDir, name));
72
+ if (!snap) continue;
73
+ if (!snap.commitSha || snap.commitSha === NOCOMMIT_SHA) continue;
74
+ snapshots.push(snap);
75
+ if (typeof limit === "number" && snapshots.length >= limit) break;
76
+ }
77
+ return snapshots;
78
+ }
79
+ atomicWrite(targetPath, content) {
80
+ atomicWrite(targetPath, content);
81
+ }
82
+ historyFilename(snapshot) {
83
+ const ts = snapshot.computedAt.replace(/[:.]/g, "-");
84
+ const sha = snapshot.commitSha || "nocommit";
85
+ const base = join(this.historyDir, `${ts}-${sha}`);
86
+ let candidate = `${base}.json`;
87
+ let n = 1;
88
+ while (existsSync(candidate)) {
89
+ candidate = `${base}.${n++}.json`;
90
+ if (n > 1e3) break;
91
+ }
92
+ return candidate.slice(this.historyDir.length + 1);
93
+ }
94
+ rotateHistory() {
95
+ if (!existsSync(this.historyDir)) return;
96
+ const entries = readdirSync(this.historyDir).filter((name) => name.endsWith(".json")).sort();
97
+ const excess = entries.length - this.historyLimit;
98
+ if (excess <= 0) return;
99
+ for (let i = 0; i < excess; i++) {
100
+ const target = join(this.historyDir, entries[i]);
101
+ try {
102
+ unlinkSync(target);
103
+ } catch (err) {
104
+ log.error(`failed to rotate ${basename(target)}: ${err.message}`);
105
+ }
106
+ }
107
+ }
108
+ safeParse(path) {
109
+ try {
110
+ const raw = readFileSync(path, "utf-8");
111
+ return JSON.parse(raw);
112
+ } catch (err) {
113
+ log.error(`failed to parse ${basename(path)}: ${err.message}`);
114
+ return void 0;
115
+ }
116
+ }
117
+ };
118
+ function assertSafeWritePath(ctxoRoot, allowProduction) {
119
+ const resolved = resolve(ctxoRoot);
120
+ const tmpRoot = resolve(tmpdir());
121
+ const inTmp = resolved.startsWith(tmpRoot);
122
+ if (inTmp || allowProduction) return;
123
+ throw new Error(
124
+ `CommunitySnapshotWriter: refusing to write to non-tmp path ${resolved}. Pass { allowProductionPath: true } for production CLI writes, or target a tmpdir-based path in tests.`
125
+ );
36
126
  }
37
127
 
38
128
  // src/adapters/storage/sqlite-storage-adapter.ts
39
- import initSqlJs from "sql.js";
40
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
41
- import { join, dirname } from "path";
42
- var log = createLogger("ctxo:storage");
129
+ var log2 = createLogger("ctxo:storage");
43
130
  var SqliteStorageAdapter = class {
44
131
  db;
45
132
  dbPath;
46
133
  ctxoRoot;
47
- constructor(ctxoRoot) {
134
+ communityWriter;
135
+ constructor(ctxoRoot, options = {}) {
48
136
  this.ctxoRoot = ctxoRoot;
49
- this.dbPath = join(ctxoRoot, ".cache", "symbols.db");
137
+ this.dbPath = join2(ctxoRoot, ".cache", "symbols.db");
138
+ this.communityWriter = new CommunitySnapshotWriter(ctxoRoot, {
139
+ allowProductionPath: options.allowProductionPath
140
+ });
50
141
  }
51
142
  async init() {
52
143
  const SQL = await initSqlJs();
53
- if (existsSync(this.dbPath)) {
144
+ if (existsSync2(this.dbPath)) {
54
145
  try {
55
- const buffer = readFileSync(this.dbPath);
146
+ const buffer = readFileSync2(this.dbPath);
56
147
  this.db = new SQL.Database(buffer);
57
148
  this.verifyIntegrity();
58
149
  } catch {
59
- log.warn("Corrupt DB detected, rebuilding from JSON index");
150
+ log2.warn("Corrupt DB detected, rebuilding from JSON index");
60
151
  this.db = new SQL.Database();
61
152
  this.createTables();
62
153
  }
@@ -68,9 +159,9 @@ var SqliteStorageAdapter = class {
68
159
  }
69
160
  async initEmpty() {
70
161
  const SQL = await initSqlJs();
71
- if (existsSync(this.dbPath)) {
162
+ if (existsSync2(this.dbPath)) {
72
163
  try {
73
- const buffer = readFileSync(this.dbPath);
164
+ const buffer = readFileSync2(this.dbPath);
74
165
  this.db = new SQL.Database(buffer);
75
166
  } catch {
76
167
  this.db = new SQL.Database();
@@ -312,9 +403,18 @@ var SqliteStorageAdapter = class {
312
403
  const db = this.database();
313
404
  const data = db.export();
314
405
  const buffer = Buffer.from(data);
315
- mkdirSync(dirname(this.dbPath), { recursive: true });
406
+ mkdirSync2(dirname(this.dbPath), { recursive: true });
316
407
  writeFileSync(this.dbPath, buffer);
317
408
  }
409
+ readCommunities() {
410
+ return this.communityWriter.readCurrent();
411
+ }
412
+ writeCommunities(snapshot) {
413
+ this.communityWriter.writeSnapshot(snapshot);
414
+ }
415
+ listCommunityHistory(limit) {
416
+ return this.communityWriter.listHistory(limit);
417
+ }
318
418
  close() {
319
419
  if (this.db) {
320
420
  this.persist();
@@ -326,7 +426,7 @@ var SqliteStorageAdapter = class {
326
426
  try {
327
427
  this.persist();
328
428
  } catch (err) {
329
- log.error(`Failed to persist DB: ${err.message}`);
429
+ log2.error(`Failed to persist DB: ${err.message}`);
330
430
  }
331
431
  }
332
432
  deleteFileData(db, filePath) {
@@ -374,12 +474,12 @@ var SqliteStorageAdapter = class {
374
474
 
375
475
  // src/adapters/git/simple-git-adapter.ts
376
476
  import { simpleGit } from "simple-git";
377
- var log2 = createLogger("ctxo:git");
477
+ var log3 = createLogger("ctxo:git");
378
478
  var SimpleGitAdapter = class {
379
479
  git;
380
480
  constructor(projectRoot) {
381
481
  this.git = simpleGit(projectRoot);
382
- log2.debug("Initialized with root: %s", projectRoot);
482
+ log3.debug("Initialized with root: %s", projectRoot);
383
483
  }
384
484
  async isAvailable() {
385
485
  try {
@@ -391,27 +491,27 @@ var SimpleGitAdapter = class {
391
491
  }
392
492
  async getCommitHistory(filePath, maxCount) {
393
493
  try {
394
- const log5 = await this.git.log({
494
+ const log6 = await this.git.log({
395
495
  file: filePath,
396
496
  "--follow": null,
397
497
  ...maxCount ? { maxCount } : {}
398
498
  });
399
- return log5.all.map((entry) => ({
499
+ return log6.all.map((entry) => ({
400
500
  hash: entry.hash,
401
501
  message: entry.message,
402
502
  date: entry.date,
403
503
  author: entry.author_name
404
504
  }));
405
505
  } catch (err) {
406
- log2.error(` Failed to get history for ${filePath}: ${err.message}`);
506
+ log3.error(` Failed to get history for ${filePath}: ${err.message}`);
407
507
  return [];
408
508
  }
409
509
  }
410
510
  async getBatchHistory(maxCount = 20) {
411
511
  try {
412
- const log5 = await this.git.log({ maxCount: maxCount * 50, "--name-only": null });
512
+ const log6 = await this.git.log({ maxCount: maxCount * 50, "--name-only": null });
413
513
  const result = /* @__PURE__ */ new Map();
414
- for (const entry of log5.all) {
514
+ for (const entry of log6.all) {
415
515
  const record = {
416
516
  hash: entry.hash,
417
517
  message: entry.message,
@@ -433,7 +533,7 @@ var SimpleGitAdapter = class {
433
533
  }
434
534
  return result;
435
535
  } catch (err) {
436
- log2.error(` Batch history failed: ${err.message}`);
536
+ log3.error(` Batch history failed: ${err.message}`);
437
537
  return /* @__PURE__ */ new Map();
438
538
  }
439
539
  }
@@ -442,26 +542,46 @@ var SimpleGitAdapter = class {
442
542
  const diff = await this.git.diffSummary([since]);
443
543
  return diff.files.map((f) => f.file.replace(/\\/g, "/"));
444
544
  } catch (err) {
445
- log2.error(` Failed to get changed files since ${since}: ${err.message}`);
545
+ log3.error(` Failed to get changed files since ${since}: ${err.message}`);
446
546
  return [];
447
547
  }
448
548
  }
449
549
  async getFileChurn(filePath) {
450
550
  try {
451
- const log5 = await this.git.log({ file: filePath, "--follow": null });
551
+ const log6 = await this.git.log({ file: filePath, "--follow": null });
452
552
  return {
453
553
  filePath,
454
- commitCount: log5.total
554
+ commitCount: log6.total
455
555
  };
456
556
  } catch (err) {
457
- log2.error(` Failed to get churn for ${filePath}: ${err.message}`);
557
+ log3.error(` Failed to get churn for ${filePath}: ${err.message}`);
458
558
  return { filePath, commitCount: 0 };
459
559
  }
460
560
  }
561
+ async getHeadSha() {
562
+ try {
563
+ const raw = await this.git.revparse(["--short", "HEAD"]);
564
+ const sha = raw.trim();
565
+ return sha.length > 0 ? sha : void 0;
566
+ } catch (err) {
567
+ log3.error(`Failed to resolve HEAD: ${err.message}`);
568
+ return void 0;
569
+ }
570
+ }
571
+ async countCommitsBetween(base) {
572
+ try {
573
+ const raw = await this.git.raw(["rev-list", "--count", `${base}..HEAD`]);
574
+ const n = parseInt(raw.trim(), 10);
575
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
576
+ } catch (err) {
577
+ log3.error(`Failed to count commits since ${base}: ${err.message}`);
578
+ return void 0;
579
+ }
580
+ }
461
581
  };
462
582
 
463
583
  // src/adapters/stats/session-recorder-adapter.ts
464
- var log3 = createLogger("ctxo:stats");
584
+ var log4 = createLogger("ctxo:stats");
465
585
  var SessionRecorderAdapter = class {
466
586
  db;
467
587
  onWrite;
@@ -507,7 +627,7 @@ var SessionRecorderAdapter = class {
507
627
  );
508
628
  this.flushToDisk();
509
629
  } catch (err) {
510
- log3.error(`Failed to record session event: ${err.message}`);
630
+ log4.error(`Failed to record session event: ${err.message}`);
511
631
  }
512
632
  }
513
633
  queryStats(days) {
@@ -575,14 +695,14 @@ var SessionRecorderAdapter = class {
575
695
  try {
576
696
  this.onWrite?.();
577
697
  } catch (err) {
578
- log3.error(`Failed to flush stats to disk: ${err.message}`);
698
+ log4.error(`Failed to flush stats to disk: ${err.message}`);
579
699
  }
580
700
  }
581
701
  };
582
702
 
583
703
  // src/adapters/workspace/single-package-workspace.ts
584
- import { existsSync as existsSync2, readdirSync } from "fs";
585
- import { join as join2, resolve } from "path";
704
+ import { existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
705
+ import { join as join3, resolve as resolve2 } from "path";
586
706
  var MANIFEST_CANDIDATES = [
587
707
  "package.json",
588
708
  "pyproject.toml",
@@ -595,24 +715,24 @@ var MANIFEST_CANDIDATES = [
595
715
  ];
596
716
  function findManifest(dir) {
597
717
  for (const name of MANIFEST_CANDIDATES) {
598
- const candidate = join2(dir, name);
599
- if (existsSync2(candidate)) return candidate;
718
+ const candidate = join3(dir, name);
719
+ if (existsSync3(candidate)) return candidate;
600
720
  }
601
721
  const csproj = findFirstByExt(dir, ".csproj") ?? findFirstByExt(dir, ".sln");
602
722
  if (csproj) return csproj;
603
- return join2(dir, "package.json");
723
+ return join3(dir, "package.json");
604
724
  }
605
725
  function findFirstByExt(dir, ext) {
606
726
  try {
607
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
608
- if (entry.isFile() && entry.name.endsWith(ext)) return join2(dir, entry.name);
727
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
728
+ if (entry.isFile() && entry.name.endsWith(ext)) return join3(dir, entry.name);
609
729
  }
610
730
  } catch {
611
731
  }
612
732
  return null;
613
733
  }
614
734
  function detectWorkspace(cwd) {
615
- const root = resolve(cwd);
735
+ const root = resolve2(cwd);
616
736
  const pkg = {
617
737
  root,
618
738
  manifest: findManifest(root)
@@ -624,8 +744,8 @@ function detectWorkspace(cwd) {
624
744
  }
625
745
 
626
746
  // src/core/config/load-config.ts
627
- import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
628
- import { join as join3 } from "path";
747
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
748
+ import { join as join4 } from "path";
629
749
  import { parse as parseYaml } from "yaml";
630
750
  import picomatch from "picomatch";
631
751
 
@@ -638,19 +758,42 @@ var IndexConfigSchema = z.object({
638
758
  ignore: z.array(z.string().min(1)).optional(),
639
759
  ignoreProjects: z.array(z.string().min(1)).optional()
640
760
  }).strict();
761
+ var MaskingConfigSchema = z.object({
762
+ /**
763
+ * Glob patterns matched against community cluster labels. Matching labels
764
+ * are replaced with `[masked-cluster-N]` in MCP tool responses so
765
+ * org-sensitive folder names (e.g. `src/payment-vault`, `internal-*`)
766
+ * never surface to AI agents over the stdio channel.
767
+ */
768
+ clusterLabels: z.array(z.string().min(1)).optional()
769
+ }).strict();
770
+ var WatchConfigSchema = z.object({
771
+ /**
772
+ * Minimum number of files that must be re-indexed since the last
773
+ * community snapshot before `ctxo watch` recomputes one. Default: 5.
774
+ * On large monorepos Louvain + PageRank cost ~1 s of blocking CPU;
775
+ * bumping this (e.g. 25) avoids recomputing on every save.
776
+ */
777
+ snapshotMinFileChanges: z.number().int().min(1).optional()
778
+ }).strict();
641
779
  var CtxoConfigSchema = z.object({
642
780
  version: z.union([z.string(), z.number()]).optional(),
643
781
  stats: StatsConfigSchema.optional(),
644
- index: IndexConfigSchema.optional()
782
+ index: IndexConfigSchema.optional(),
783
+ masking: MaskingConfigSchema.optional(),
784
+ watch: WatchConfigSchema.optional()
645
785
  }).passthrough();
786
+ var DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES = 5;
646
787
  var DEFAULT_CONFIG = {
647
788
  version: "1.0",
648
789
  stats: { enabled: true },
649
- index: { ignore: [], ignoreProjects: [] }
790
+ index: { ignore: [], ignoreProjects: [] },
791
+ masking: { clusterLabels: [] },
792
+ watch: { snapshotMinFileChanges: DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES }
650
793
  };
651
794
 
652
795
  // src/core/config/load-config.ts
653
- var log4 = createLogger("ctxo:config");
796
+ var log5 = createLogger("ctxo:config");
654
797
  function isValidGlob(pattern) {
655
798
  try {
656
799
  picomatch(pattern);
@@ -666,15 +809,15 @@ function validateGlobs(patterns, field, invalid) {
666
809
  }
667
810
  }
668
811
  function loadConfig(ctxoRoot) {
669
- const path = join3(ctxoRoot, "config.yaml");
812
+ const path = join4(ctxoRoot, "config.yaml");
670
813
  const errors = [];
671
814
  const invalidGlobs = [];
672
- if (!existsSync3(path)) {
815
+ if (!existsSync4(path)) {
673
816
  return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: false };
674
817
  }
675
818
  let raw;
676
819
  try {
677
- raw = readFileSync2(path, "utf-8");
820
+ raw = readFileSync3(path, "utf-8");
678
821
  } catch (err) {
679
822
  errors.push(`Cannot read ${path}: ${err.message}`);
680
823
  return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
@@ -684,7 +827,7 @@ function loadConfig(ctxoRoot) {
684
827
  parsed = parseYaml(raw);
685
828
  } catch (err) {
686
829
  errors.push(`Invalid YAML in ${path}: ${err.message}`);
687
- log4.warn(`Invalid YAML in config.yaml, falling back to defaults: ${err.message}`);
830
+ log5.warn(`Invalid YAML in config.yaml, falling back to defaults: ${err.message}`);
688
831
  return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
689
832
  }
690
833
  if (parsed === null || parsed === void 0) {
@@ -695,13 +838,13 @@ function loadConfig(ctxoRoot) {
695
838
  for (const issue of result.error.issues) {
696
839
  errors.push(`${issue.path.join(".") || "<root>"}: ${issue.message}`);
697
840
  }
698
- log4.warn(`Invalid config.yaml (${errors.length} issue(s)), falling back to defaults`);
841
+ log5.warn(`Invalid config.yaml (${errors.length} issue(s)), falling back to defaults`);
699
842
  return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
700
843
  }
701
844
  validateGlobs(result.data.index?.ignore, "index.ignore", invalidGlobs);
702
845
  validateGlobs(result.data.index?.ignoreProjects, "index.ignoreProjects", invalidGlobs);
703
846
  for (const pattern of invalidGlobs) {
704
- log4.warn(`Invalid glob pattern in config.yaml \u2014 ${pattern}`);
847
+ log5.warn(`Invalid glob pattern in config.yaml \u2014 ${pattern}`);
705
848
  }
706
849
  return { config: result.data, errors, invalidGlobs, path, exists: true };
707
850
  }
@@ -714,6 +857,14 @@ function indexIgnorePatterns(config) {
714
857
  function indexIgnoreProjectPatterns(config) {
715
858
  return (config.index?.ignoreProjects ?? []).filter(isValidGlob);
716
859
  }
860
+ function maskingClusterLabelPatterns(config) {
861
+ return (config.masking?.clusterLabels ?? []).filter(isValidGlob);
862
+ }
863
+ function watchSnapshotMinFileChanges(config) {
864
+ const value = config.watch?.snapshotMinFileChanges;
865
+ if (typeof value === "number" && Number.isFinite(value) && value >= 1) return value;
866
+ return DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES;
867
+ }
717
868
  function makeGlobMatcher(patterns) {
718
869
  if (patterns.length === 0) return () => false;
719
870
  const matchers = patterns.map((p) => picomatch(p, { dot: true }));
@@ -848,6 +999,148 @@ var RevertDetector = class {
848
999
  }
849
1000
  };
850
1001
 
1002
+ // src/core/overlay/drift-detector.ts
1003
+ var DEFAULT_JACCARD_THRESHOLD = 0.3;
1004
+ var DriftDetector = class {
1005
+ jaccardThreshold;
1006
+ suppressTransient;
1007
+ constructor(options = {}) {
1008
+ this.jaccardThreshold = options.jaccardThreshold ?? DEFAULT_JACCARD_THRESHOLD;
1009
+ this.suppressTransient = options.suppressTransient ?? true;
1010
+ }
1011
+ detect(current, history) {
1012
+ const snapshotsAvailable = history.length;
1013
+ const previous = history[0];
1014
+ const anchor = history[1];
1015
+ if (!previous) {
1016
+ return {
1017
+ events: [],
1018
+ confidence: "low",
1019
+ snapshotsAvailable,
1020
+ stability: { stable: 0, transient: 0, unverified: 0 },
1021
+ hint: "No prior snapshot available \u2014 install the post-commit hook (`ctxo init`), run `ctxo watch`, or add `ctxo index --check` to CI so the drift history accumulates."
1022
+ };
1023
+ }
1024
+ const currentByCommunityId = indexByCommunityId(current.communities);
1025
+ const previousByCommunityId = indexByCommunityId(previous.communities);
1026
+ const mapping = this.mapCommunities(currentByCommunityId, previousByCommunityId);
1027
+ const currentAssignments = assignmentMap(current.communities);
1028
+ const previousAssignments = assignmentMap(previous.communities);
1029
+ const anchorAssignments = anchor ? assignmentMap(anchor.communities) : void 0;
1030
+ const previousLabels = labelMap(previous.communities);
1031
+ const currentLabels = labelMap(current.communities);
1032
+ const anchorToPrev = anchor ? this.mapCommunities(previousByCommunityId, indexByCommunityId(anchor.communities)) : /* @__PURE__ */ new Map();
1033
+ const rawEvents = [];
1034
+ const stability = { stable: 0, transient: 0, unverified: 0 };
1035
+ for (const [symbolId, currentId] of currentAssignments) {
1036
+ const prevId = previousAssignments.get(symbolId);
1037
+ if (prevId === void 0) continue;
1038
+ const mappedPrevId = mapping.get(prevId);
1039
+ if (mappedPrevId === currentId) continue;
1040
+ let classification;
1041
+ if (!anchorAssignments) {
1042
+ classification = "unverified";
1043
+ } else {
1044
+ const anchorId = anchorAssignments.get(symbolId);
1045
+ if (anchorId === void 0) {
1046
+ classification = "unverified";
1047
+ } else {
1048
+ const anchorInPrevSpace = anchorToPrev.get(anchorId);
1049
+ if (anchorInPrevSpace === prevId) {
1050
+ classification = "stable";
1051
+ } else if (anchorInPrevSpace !== void 0) {
1052
+ const anchorInCurrentSpace = mapping.get(anchorInPrevSpace);
1053
+ classification = anchorInCurrentSpace === currentId ? "transient" : "unverified";
1054
+ } else {
1055
+ classification = "unverified";
1056
+ }
1057
+ }
1058
+ }
1059
+ stability[classification]++;
1060
+ rawEvents.push({
1061
+ symbolId,
1062
+ movedFrom: {
1063
+ id: prevId,
1064
+ label: previousLabels.get(prevId) ?? `community-${prevId}`
1065
+ },
1066
+ movedTo: {
1067
+ id: currentId,
1068
+ label: currentLabels.get(currentId) ?? `community-${currentId}`
1069
+ },
1070
+ firstSeenInNewCluster: current.computedAt,
1071
+ stability: classification
1072
+ });
1073
+ }
1074
+ const events = (this.suppressTransient ? rawEvents.filter((e) => e.stability !== "transient") : rawEvents).sort((a, b) => a.symbolId.localeCompare(b.symbolId));
1075
+ return {
1076
+ events,
1077
+ confidence: confidenceFor(snapshotsAvailable),
1078
+ snapshotsAvailable,
1079
+ stability,
1080
+ hint: hintFor(snapshotsAvailable)
1081
+ };
1082
+ }
1083
+ mapCommunities(current, previous) {
1084
+ const mapping = /* @__PURE__ */ new Map();
1085
+ for (const [prevId, prevMembers] of previous) {
1086
+ let bestCurrentId;
1087
+ let bestOverlap = 0;
1088
+ for (const [currentId, currentMembers] of current) {
1089
+ const overlap = jaccard(prevMembers, currentMembers);
1090
+ if (overlap > bestOverlap) {
1091
+ bestOverlap = overlap;
1092
+ bestCurrentId = currentId;
1093
+ }
1094
+ }
1095
+ if (bestCurrentId !== void 0 && bestOverlap >= this.jaccardThreshold) {
1096
+ mapping.set(prevId, bestCurrentId);
1097
+ }
1098
+ }
1099
+ return mapping;
1100
+ }
1101
+ };
1102
+ function indexByCommunityId(entries) {
1103
+ const index = /* @__PURE__ */ new Map();
1104
+ for (const entry of entries) {
1105
+ const bucket = index.get(entry.communityId) ?? /* @__PURE__ */ new Set();
1106
+ bucket.add(entry.symbolId);
1107
+ index.set(entry.communityId, bucket);
1108
+ }
1109
+ return index;
1110
+ }
1111
+ function assignmentMap(entries) {
1112
+ const map = /* @__PURE__ */ new Map();
1113
+ for (const entry of entries) {
1114
+ map.set(entry.symbolId, entry.communityId);
1115
+ }
1116
+ return map;
1117
+ }
1118
+ function labelMap(entries) {
1119
+ const labels = /* @__PURE__ */ new Map();
1120
+ for (const entry of entries) {
1121
+ if (!labels.has(entry.communityId)) {
1122
+ labels.set(entry.communityId, entry.communityLabel);
1123
+ }
1124
+ }
1125
+ return labels;
1126
+ }
1127
+ function jaccard(a, b) {
1128
+ if (a.size === 0 && b.size === 0) return 1;
1129
+ let intersection = 0;
1130
+ for (const x of a) if (b.has(x)) intersection++;
1131
+ const union = a.size + b.size - intersection;
1132
+ return union === 0 ? 0 : intersection / union;
1133
+ }
1134
+ function confidenceFor(snapshots) {
1135
+ if (snapshots >= 7) return "high";
1136
+ if (snapshots >= 3) return "medium";
1137
+ return "low";
1138
+ }
1139
+ function hintFor(snapshots) {
1140
+ if (snapshots >= 3) return void 0;
1141
+ return "Install post-commit hook or run `ctxo index` more often to build a richer drift history.";
1142
+ }
1143
+
851
1144
  // src/core/overlay/architectural-overlay.ts
852
1145
  var DEFAULT_RULES = [
853
1146
  // Test layer (matched first — __tests__, .test.ts, tests/, fixtures)
@@ -1187,6 +1480,97 @@ var PageRankCalculator = class {
1187
1480
  }
1188
1481
  };
1189
1482
 
1483
+ // src/core/overlay/boundary-violation-detector.ts
1484
+ var HIGH_SEVERITY_THRESHOLD = 0;
1485
+ var MEDIUM_SEVERITY_THRESHOLD = 2;
1486
+ var MIN_SNAPSHOTS_FOR_CONFIDENCE = 2;
1487
+ var BoundaryViolationDetector = class {
1488
+ detect(graph, current, history) {
1489
+ const snapshotsAvailable = history.length;
1490
+ const currentAssignments = assignmentMap2(current.communities);
1491
+ const currentLabels = labelMap2(current.communities);
1492
+ const historicalAssignments = history.map((snapshot) => ({
1493
+ snapshot,
1494
+ assignments: assignmentMap2(snapshot.communities),
1495
+ labels: labelMap2(snapshot.communities)
1496
+ }));
1497
+ const violations = [];
1498
+ for (const edge of graph.allEdges()) {
1499
+ if (edge.from === edge.to) continue;
1500
+ const fromCommunity = currentAssignments.get(edge.from);
1501
+ const toCommunity = currentAssignments.get(edge.to);
1502
+ if (fromCommunity === void 0 || toCommunity === void 0) continue;
1503
+ if (fromCommunity === toCommunity) continue;
1504
+ const fromLabel = currentLabels.get(fromCommunity) ?? `community-${fromCommunity}`;
1505
+ const toLabel = currentLabels.get(toCommunity) ?? `community-${toCommunity}`;
1506
+ const historicalCount = snapshotsAvailable < MIN_SNAPSHOTS_FOR_CONFIDENCE ? -1 : countHistoricalCrossCluster(historicalAssignments, edge.from, edge.to);
1507
+ if (historicalCount === -1 || historicalCount <= MEDIUM_SEVERITY_THRESHOLD) {
1508
+ violations.push({
1509
+ from: {
1510
+ symbolId: edge.from,
1511
+ communityId: fromCommunity,
1512
+ label: fromLabel
1513
+ },
1514
+ to: {
1515
+ symbolId: edge.to,
1516
+ communityId: toCommunity,
1517
+ label: toLabel
1518
+ },
1519
+ edgeKind: edge.kind,
1520
+ historicalEdgesBetweenClusters: historicalCount,
1521
+ severity: historicalCount === HIGH_SEVERITY_THRESHOLD ? "high" : "medium"
1522
+ });
1523
+ }
1524
+ }
1525
+ violations.sort((a, b) => {
1526
+ if (a.severity !== b.severity) return a.severity === "high" ? -1 : 1;
1527
+ const cmp = a.from.symbolId.localeCompare(b.from.symbolId);
1528
+ return cmp !== 0 ? cmp : a.to.symbolId.localeCompare(b.to.symbolId);
1529
+ });
1530
+ return {
1531
+ violations,
1532
+ confidence: confidenceFor2(snapshotsAvailable),
1533
+ snapshotsAvailable,
1534
+ hint: hintFor2(snapshotsAvailable)
1535
+ };
1536
+ }
1537
+ };
1538
+ function countHistoricalCrossCluster(history, fromSymbol, toSymbol) {
1539
+ let count = 0;
1540
+ for (const snap of history) {
1541
+ const fromId = snap.assignments.get(fromSymbol);
1542
+ const toId = snap.assignments.get(toSymbol);
1543
+ if (fromId === void 0 || toId === void 0) continue;
1544
+ if (fromId !== toId) count++;
1545
+ }
1546
+ return count;
1547
+ }
1548
+ function assignmentMap2(entries) {
1549
+ const map = /* @__PURE__ */ new Map();
1550
+ for (const entry of entries) {
1551
+ map.set(entry.symbolId, entry.communityId);
1552
+ }
1553
+ return map;
1554
+ }
1555
+ function labelMap2(entries) {
1556
+ const labels = /* @__PURE__ */ new Map();
1557
+ for (const entry of entries) {
1558
+ if (!labels.has(entry.communityId)) {
1559
+ labels.set(entry.communityId, entry.communityLabel);
1560
+ }
1561
+ }
1562
+ return labels;
1563
+ }
1564
+ function confidenceFor2(snapshots) {
1565
+ if (snapshots >= 7) return "high";
1566
+ if (snapshots >= 3) return "medium";
1567
+ return "low";
1568
+ }
1569
+ function hintFor2(snapshots) {
1570
+ if (snapshots >= MIN_SNAPSHOTS_FOR_CONFIDENCE) return void 0;
1571
+ return "Insufficient snapshot history \u2014 enable `ctxo init` hook or add `ctxo index --check` to CI for boundary-violation signal.";
1572
+ }
1573
+
1190
1574
  // src/core/co-change/co-change-analyzer.ts
1191
1575
  var MIN_SHARED_COMMITS = 2;
1192
1576
  var MIN_FREQUENCY = 0.1;
@@ -1259,14 +1643,16 @@ function loadCoChangeMap(matrix) {
1259
1643
  }
1260
1644
 
1261
1645
  export {
1262
- createLogger,
1646
+ CommunitySnapshotWriter,
1263
1647
  SqliteStorageAdapter,
1264
1648
  SymbolGraph,
1265
1649
  RevertDetector,
1650
+ DriftDetector,
1266
1651
  SimpleGitAdapter,
1267
1652
  ArchitecturalOverlay,
1268
1653
  DeadCodeDetector,
1269
1654
  PageRankCalculator,
1655
+ BoundaryViolationDetector,
1270
1656
  aggregateCoChanges,
1271
1657
  loadCoChangeMap,
1272
1658
  SessionRecorderAdapter,
@@ -1275,6 +1661,8 @@ export {
1275
1661
  statsEnabled,
1276
1662
  indexIgnorePatterns,
1277
1663
  indexIgnoreProjectPatterns,
1664
+ maskingClusterLabelPatterns,
1665
+ watchSnapshotMinFileChanges,
1278
1666
  makeGlobMatcher
1279
1667
  };
1280
- //# sourceMappingURL=chunk-4M4V5V36.js.map
1668
+ //# sourceMappingURL=chunk-HXEPS2XY.js.map