@ctxo/cli 0.13.1 → 0.15.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,40 +1,248 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- atomicWrite
4
- } from "./chunk-RFEXMBTG.js";
3
+ DATA_EDGE_KINDS,
4
+ createTestFileClassifier,
5
+ filesWithTestAnnotations
6
+ } from "./chunk-I7RPXT6L.js";
7
+ import {
8
+ compareCodepoint
9
+ } from "./chunk-72CEQEQD.js";
5
10
  import {
6
11
  CoChangeMatrixSchema,
7
12
  EDGE_KINDS,
8
13
  EdgeKindSchema,
9
14
  JsonIndexReader
10
- } from "./chunk-DT3ZH4AU.js";
15
+ } from "./chunk-EC6INGPV.js";
16
+ import {
17
+ atomicWrite
18
+ } from "./chunk-RFEXMBTG.js";
11
19
  import {
12
20
  createLogger
13
21
  } from "./chunk-5DSKZJNN.js";
14
22
 
23
+ // src/adapters/workspace/single-package-workspace.ts
24
+ import { existsSync, readdirSync } from "fs";
25
+ import { join, resolve } from "path";
26
+ var MANIFEST_CANDIDATES = [
27
+ "package.json",
28
+ "pyproject.toml",
29
+ "setup.py",
30
+ "pom.xml",
31
+ "build.gradle",
32
+ "build.gradle.kts",
33
+ "go.mod",
34
+ "Cargo.toml"
35
+ ];
36
+ function findManifest(dir) {
37
+ for (const name of MANIFEST_CANDIDATES) {
38
+ const candidate = join(dir, name);
39
+ if (existsSync(candidate)) return candidate;
40
+ }
41
+ const csproj = findFirstByExt(dir, ".csproj") ?? findFirstByExt(dir, ".sln");
42
+ if (csproj) return csproj;
43
+ return join(dir, "package.json");
44
+ }
45
+ function findFirstByExt(dir, ext) {
46
+ try {
47
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
48
+ if (entry.isFile() && entry.name.endsWith(ext)) return join(dir, entry.name);
49
+ }
50
+ } catch {
51
+ }
52
+ return null;
53
+ }
54
+ function detectWorkspace(cwd) {
55
+ const root = resolve(cwd);
56
+ const pkg = {
57
+ root,
58
+ manifest: findManifest(root)
59
+ };
60
+ return {
61
+ root,
62
+ packages: [pkg]
63
+ };
64
+ }
65
+
66
+ // src/adapters/storage/co-change-reader.ts
67
+ import { readFileSync, statSync } from "fs";
68
+ import { join as join2 } from "path";
69
+
70
+ // src/core/co-change/co-change-analyzer.ts
71
+ var MIN_FREQUENCY = 0.1;
72
+ function buildCoChangeMatrix(windowed, options) {
73
+ const commitsPerFile = /* @__PURE__ */ new Map();
74
+ const sharedPerPair = /* @__PURE__ */ new Map();
75
+ let commitsUsed = 0;
76
+ let massSkipped = 0;
77
+ for (const commit of windowed) {
78
+ const files = includedFiles(commit, options.isIncluded);
79
+ if (files.length === 0) continue;
80
+ if (files.length > options.maxFilesPerCommit) {
81
+ massSkipped++;
82
+ continue;
83
+ }
84
+ commitsUsed++;
85
+ for (const file of files) {
86
+ commitsPerFile.set(file, (commitsPerFile.get(file) ?? 0) + 1);
87
+ }
88
+ for (let i = 0; i < files.length - 1; i++) {
89
+ const file1 = files[i];
90
+ let partners = sharedPerPair.get(file1);
91
+ if (!partners) {
92
+ partners = /* @__PURE__ */ new Map();
93
+ sharedPerPair.set(file1, partners);
94
+ }
95
+ for (let j = i + 1; j < files.length; j++) {
96
+ const file2 = files[j];
97
+ partners.set(file2, (partners.get(file2) ?? 0) + 1);
98
+ }
99
+ }
100
+ }
101
+ const entries = [];
102
+ for (const [file1, partners] of sharedPerPair) {
103
+ for (const [file2, sharedCommits] of partners) {
104
+ if (sharedCommits < options.minShared) continue;
105
+ const smallerHistory = Math.min(commitsPerFile.get(file1), commitsPerFile.get(file2));
106
+ const frequency = Math.round(sharedCommits / smallerHistory * 1e3) / 1e3;
107
+ if (frequency < MIN_FREQUENCY) continue;
108
+ entries.push({ file1, file2, sharedCommits, frequency });
109
+ }
110
+ }
111
+ entries.sort(compareEntries);
112
+ const window = {
113
+ maxCommits: options.maxCommits,
114
+ ...options.since !== void 0 ? { since: options.since } : {},
115
+ head: options.head,
116
+ commitsScanned: windowed.length,
117
+ commitsUsed,
118
+ massSkipped,
119
+ maxFilesPerCommit: options.maxFilesPerCommit,
120
+ minShared: options.minShared
121
+ };
122
+ return { version: 1, timestamp: options.timestamp, window, entries };
123
+ }
124
+ function includedFiles(commit, isIncluded) {
125
+ const included = /* @__PURE__ */ new Set();
126
+ for (const file of commit.files) {
127
+ if (isIncluded(file)) included.add(file);
128
+ }
129
+ return [...included].sort(compareCodepoint);
130
+ }
131
+ function compareEntries(a, b) {
132
+ return b.frequency - a.frequency || b.sharedCommits - a.sharedCommits || compareCodepoint(a.file1, b.file1) || compareCodepoint(a.file2, b.file2);
133
+ }
134
+ function loadCoChangeMap(matrix) {
135
+ const map = /* @__PURE__ */ new Map();
136
+ for (const entry of matrix.entries) {
137
+ let list1 = map.get(entry.file1);
138
+ if (!list1) {
139
+ list1 = [];
140
+ map.set(entry.file1, list1);
141
+ }
142
+ list1.push(entry);
143
+ let list2 = map.get(entry.file2);
144
+ if (!list2) {
145
+ list2 = [];
146
+ map.set(entry.file2, list2);
147
+ }
148
+ list2.push(entry);
149
+ }
150
+ return map;
151
+ }
152
+
153
+ // src/adapters/storage/co-change-reader.ts
154
+ var log = createLogger("ctxo:co-change");
155
+ var UNTIL_REWRITTEN = "co-change is ignored until ctxo index rewrites it";
156
+ var UNTIL_RETRY = "co-change is ignored for now and the next call tries again";
157
+ var CoChangeReader = class {
158
+ filePath;
159
+ cached;
160
+ /** What the last warning was about, so one problem is reported once. */
161
+ lastWarning;
162
+ constructor(ctxoRoot) {
163
+ this.filePath = join2(ctxoRoot, "index", "co-changes.json");
164
+ }
165
+ readCoChangeMap() {
166
+ let stats;
167
+ try {
168
+ stats = statSync(this.filePath);
169
+ } catch (err) {
170
+ this.cached = void 0;
171
+ const { code, message } = err;
172
+ if (code === "ENOENT") {
173
+ this.lastWarning = void 0;
174
+ } else {
175
+ this.warnOnce(`stat ${code ?? message}`, `cannot be inspected (${message})`, UNTIL_RETRY);
176
+ }
177
+ return void 0;
178
+ }
179
+ const version = `${stats.mtimeMs}:${stats.size}`;
180
+ if (this.cached?.version === version) return this.cached.map;
181
+ return this.load(version);
182
+ }
183
+ load(version) {
184
+ let raw;
185
+ try {
186
+ raw = readFileSync(this.filePath, "utf-8");
187
+ } catch (err) {
188
+ this.cached = void 0;
189
+ this.warnOnce(`read ${version}`, `cannot be read (${err.message})`, UNTIL_RETRY);
190
+ return void 0;
191
+ }
192
+ const map = this.parse(raw, version);
193
+ this.cached = { version, map };
194
+ if (map) this.lastWarning = void 0;
195
+ return map;
196
+ }
197
+ parse(raw, version) {
198
+ let data;
199
+ try {
200
+ data = JSON.parse(raw);
201
+ } catch (err) {
202
+ return this.unusable(version, `is not valid JSON (${err.message})`);
203
+ }
204
+ const parsed = CoChangeMatrixSchema.safeParse(data);
205
+ if (!parsed.success) {
206
+ const issue = parsed.error.issues[0];
207
+ const where = issue && issue.path.length > 0 ? `${issue.path.map(String).join(".")}: ` : "";
208
+ return this.unusable(version, `is not a valid co-change matrix (${where}${issue?.message ?? "unknown problem"})`);
209
+ }
210
+ return freezeEntries(loadCoChangeMap(parsed.data));
211
+ }
212
+ unusable(version, problem) {
213
+ this.warnOnce(`content ${version}`, problem, UNTIL_REWRITTEN);
214
+ return void 0;
215
+ }
216
+ warnOnce(key, problem, consequence) {
217
+ if (this.lastWarning === key) return;
218
+ this.lastWarning = key;
219
+ log.warn(`${this.filePath} ${problem}; ${consequence}`);
220
+ }
221
+ };
222
+ function freezeEntries(map) {
223
+ for (const list of map.values()) {
224
+ for (const entry of list) Object.freeze(entry);
225
+ Object.freeze(list);
226
+ }
227
+ return map;
228
+ }
229
+
15
230
  // src/adapters/storage/sqlite-storage-adapter.ts
16
231
  import initSqlJs from "sql.js";
17
- import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
18
- import { join as join2, dirname } from "path";
232
+ import { readFileSync as readFileSync3, writeFileSync, existsSync as existsSync3, mkdirSync as mkdirSync2 } from "fs";
233
+ import { join as join4, dirname } from "path";
19
234
 
20
235
  // src/adapters/storage/community-snapshot-writer.ts
21
236
  import {
22
- existsSync,
237
+ existsSync as existsSync2,
23
238
  mkdirSync,
24
- readFileSync,
25
- readdirSync,
239
+ readFileSync as readFileSync2,
240
+ readdirSync as readdirSync2,
26
241
  unlinkSync
27
242
  } from "fs";
28
243
  import { tmpdir } from "os";
29
- import { basename, join, resolve } from "path";
30
-
31
- // src/core/compare-codepoint.ts
32
- function compareCodepoint(a, b) {
33
- return a < b ? -1 : a > b ? 1 : 0;
34
- }
35
-
36
- // src/adapters/storage/community-snapshot-writer.ts
37
- var log = createLogger("ctxo:community-snapshot");
244
+ import { basename, join as join3, resolve as resolve2 } from "path";
245
+ var log2 = createLogger("ctxo:community-snapshot");
38
246
  var DEFAULT_HISTORY_LIMIT = 10;
39
247
  var HISTORY_DIR_NAME = "communities.history";
40
248
  var CURRENT_FILE_NAME = "communities.json";
@@ -46,26 +254,26 @@ var CommunitySnapshotWriter = class {
46
254
  historyLimit;
47
255
  constructor(ctxoRoot, historyLimitOrOptions = DEFAULT_HISTORY_LIMIT) {
48
256
  const options = typeof historyLimitOrOptions === "number" ? { historyLimit: historyLimitOrOptions } : historyLimitOrOptions;
49
- this.indexDir = join(ctxoRoot, "index");
50
- this.historyDir = join(this.indexDir, HISTORY_DIR_NAME);
257
+ this.indexDir = join3(ctxoRoot, "index");
258
+ this.historyDir = join3(this.indexDir, HISTORY_DIR_NAME);
51
259
  this.historyLimit = options.historyLimit ?? DEFAULT_HISTORY_LIMIT;
52
260
  assertSafeWritePath(ctxoRoot, options.allowProductionPath ?? false);
53
261
  }
54
262
  writeSnapshot(snapshot) {
55
263
  mkdirSync(this.indexDir, { recursive: true });
56
264
  mkdirSync(this.historyDir, { recursive: true });
57
- const currentPath = join(this.indexDir, CURRENT_FILE_NAME);
58
- if (existsSync(currentPath)) {
265
+ const currentPath = join3(this.indexDir, CURRENT_FILE_NAME);
266
+ if (existsSync2(currentPath)) {
59
267
  try {
60
- const existingRaw = readFileSync(currentPath, "utf-8");
268
+ const existingRaw = readFileSync2(currentPath, "utf-8");
61
269
  const existing = JSON.parse(existingRaw);
62
270
  const existingCommit = archivedCommit(existing);
63
271
  if (existingCommit !== void 0 && existingCommit !== snapshot.commitSha) {
64
272
  const historyFile = this.historyFilename(existing);
65
- this.atomicWrite(join(this.historyDir, historyFile), existingRaw);
273
+ this.atomicWrite(join3(this.historyDir, historyFile), existingRaw);
66
274
  }
67
275
  } catch (err) {
68
- log.error(`failed to archive existing snapshot: ${err.message}`);
276
+ log2.error(`failed to archive existing snapshot: ${err.message}`);
69
277
  }
70
278
  }
71
279
  const json = JSON.stringify(snapshot, null, 2);
@@ -73,8 +281,8 @@ var CommunitySnapshotWriter = class {
73
281
  this.rotateHistory();
74
282
  }
75
283
  readCurrent() {
76
- const path = join(this.indexDir, CURRENT_FILE_NAME);
77
- if (!existsSync(path)) return void 0;
284
+ const path = join3(this.indexDir, CURRENT_FILE_NAME);
285
+ if (!existsSync2(path)) return void 0;
78
286
  return this.safeParse(path);
79
287
  }
80
288
  /**
@@ -85,11 +293,11 @@ var CommunitySnapshotWriter = class {
85
293
  * write rotates them out.
86
294
  */
87
295
  listHistory(limit) {
88
- if (!existsSync(this.historyDir)) return [];
296
+ if (!existsSync2(this.historyDir)) return [];
89
297
  const snapshots = [];
90
298
  const seen = /* @__PURE__ */ new Set();
91
299
  for (const name of this.historyNames().reverse()) {
92
- const snap = this.safeParse(join(this.historyDir, name));
300
+ const snap = this.safeParse(join3(this.historyDir, name));
93
301
  const commit = archivedCommit(snap);
94
302
  if (!snap || commit === void 0) continue;
95
303
  if (seen.has(commit)) continue;
@@ -105,10 +313,10 @@ var CommunitySnapshotWriter = class {
105
313
  historyFilename(snapshot) {
106
314
  const ts = snapshot.computedAt.replace(/[:.]/g, "-");
107
315
  const sha = snapshot.commitSha || "nocommit";
108
- const base = join(this.historyDir, `${ts}-${sha}`);
316
+ const base = join3(this.historyDir, `${ts}-${sha}`);
109
317
  let candidate = `${base}.json`;
110
318
  let n = 1;
111
- while (existsSync(candidate)) {
319
+ while (existsSync2(candidate)) {
112
320
  candidate = `${base}.${n++}.json`;
113
321
  if (n > 1e3) break;
114
322
  }
@@ -116,7 +324,7 @@ var CommunitySnapshotWriter = class {
116
324
  }
117
325
  /** The `.json` files in the history folder, oldest first. */
118
326
  historyNames() {
119
- return readdirSync(this.historyDir).filter((name) => name.endsWith(".json")).sort(compareHistoryNames);
327
+ return readdirSync2(this.historyDir).filter((name) => name.endsWith(".json")).sort(compareHistoryNames);
120
328
  }
121
329
  /**
122
330
  * Rotation in two steps. First the duplicates go: every file that is not
@@ -129,12 +337,12 @@ var CommunitySnapshotWriter = class {
129
337
  * duplicates of anything and age out by count like the rest.
130
338
  */
131
339
  rotateHistory() {
132
- if (!existsSync(this.historyDir)) return;
340
+ if (!existsSync2(this.historyDir)) return;
133
341
  const seen = /* @__PURE__ */ new Set();
134
342
  const unique = [];
135
343
  const doomed = [];
136
344
  for (const name of this.historyNames().reverse()) {
137
- const commit = archivedCommit(this.safeParse(join(this.historyDir, name)));
345
+ const commit = archivedCommit(this.safeParse(join3(this.historyDir, name)));
138
346
  if (commit !== void 0) {
139
347
  if (seen.has(commit)) {
140
348
  doomed.push(name);
@@ -146,20 +354,20 @@ var CommunitySnapshotWriter = class {
146
354
  }
147
355
  doomed.push(...unique.slice(this.historyLimit));
148
356
  for (const name of doomed) {
149
- const target = join(this.historyDir, name);
357
+ const target = join3(this.historyDir, name);
150
358
  try {
151
359
  unlinkSync(target);
152
360
  } catch (err) {
153
- log.error(`failed to rotate ${basename(target)}: ${err.message}`);
361
+ log2.error(`failed to rotate ${basename(target)}: ${err.message}`);
154
362
  }
155
363
  }
156
364
  }
157
365
  safeParse(path) {
158
366
  try {
159
- const raw = readFileSync(path, "utf-8");
367
+ const raw = readFileSync2(path, "utf-8");
160
368
  return JSON.parse(raw);
161
369
  } catch (err) {
162
- log.error(`failed to parse ${basename(path)}: ${err.message}`);
370
+ log2.error(`failed to parse ${basename(path)}: ${err.message}`);
163
371
  return void 0;
164
372
  }
165
373
  }
@@ -178,8 +386,8 @@ function splitCollisionCounter(name) {
178
386
  return match ? [`${match[1]}.json`, Number(match[2])] : [name, 0];
179
387
  }
180
388
  function assertSafeWritePath(ctxoRoot, allowProduction) {
181
- const resolved = resolve(ctxoRoot);
182
- const tmpRoot = resolve(tmpdir());
389
+ const resolved = resolve2(ctxoRoot);
390
+ const tmpRoot = resolve2(tmpdir());
183
391
  const inTmp = resolved.startsWith(tmpRoot);
184
392
  if (inTmp || allowProduction) return;
185
393
  throw new Error(
@@ -188,7 +396,7 @@ function assertSafeWritePath(ctxoRoot, allowProduction) {
188
396
  }
189
397
 
190
398
  // src/adapters/storage/sqlite-storage-adapter.ts
191
- var log2 = createLogger("ctxo:storage");
399
+ var log3 = createLogger("ctxo:storage");
192
400
  var SqliteStorageAdapter = class {
193
401
  db;
194
402
  dbPath;
@@ -196,20 +404,20 @@ var SqliteStorageAdapter = class {
196
404
  communityWriter;
197
405
  constructor(ctxoRoot, options = {}) {
198
406
  this.ctxoRoot = ctxoRoot;
199
- this.dbPath = join2(ctxoRoot, ".cache", "symbols.db");
407
+ this.dbPath = join4(ctxoRoot, ".cache", "symbols.db");
200
408
  this.communityWriter = new CommunitySnapshotWriter(ctxoRoot, {
201
409
  allowProductionPath: options.allowProductionPath
202
410
  });
203
411
  }
204
412
  async init() {
205
413
  const SQL = await initSqlJs();
206
- if (existsSync2(this.dbPath)) {
414
+ if (existsSync3(this.dbPath)) {
207
415
  try {
208
- const buffer = readFileSync2(this.dbPath);
416
+ const buffer = readFileSync3(this.dbPath);
209
417
  this.db = new SQL.Database(buffer);
210
418
  this.verifyIntegrity();
211
419
  } catch {
212
- log2.warn("Corrupt DB detected, rebuilding from JSON index");
420
+ log3.warn("Corrupt DB detected, rebuilding from JSON index");
213
421
  this.db = new SQL.Database();
214
422
  this.createTables();
215
423
  }
@@ -221,9 +429,9 @@ var SqliteStorageAdapter = class {
221
429
  }
222
430
  async initEmpty() {
223
431
  const SQL = await initSqlJs();
224
- if (existsSync2(this.dbPath)) {
432
+ if (existsSync3(this.dbPath)) {
225
433
  try {
226
- const buffer = readFileSync2(this.dbPath);
434
+ const buffer = readFileSync3(this.dbPath);
227
435
  this.db = new SQL.Database(buffer);
228
436
  } catch {
229
437
  this.db = new SQL.Database();
@@ -488,7 +696,7 @@ var SqliteStorageAdapter = class {
488
696
  try {
489
697
  this.persist();
490
698
  } catch (err) {
491
- log2.error(`Failed to persist DB: ${err.message}`);
699
+ log3.error(`Failed to persist DB: ${err.message}`);
492
700
  }
493
701
  }
494
702
  deleteFileData(db, filePath) {
@@ -534,1335 +742,301 @@ var SqliteStorageAdapter = class {
534
742
  }
535
743
  };
536
744
 
537
- // src/adapters/storage/co-change-reader.ts
538
- import { readFileSync as readFileSync3, statSync } from "fs";
539
- import { join as join3 } from "path";
540
-
541
- // src/core/co-change/co-change-analyzer.ts
542
- var MIN_FREQUENCY = 0.1;
543
- function buildCoChangeMatrix(windowed, options) {
544
- const commitsPerFile = /* @__PURE__ */ new Map();
545
- const sharedPerPair = /* @__PURE__ */ new Map();
546
- let commitsUsed = 0;
547
- let massSkipped = 0;
548
- for (const commit of windowed) {
549
- const files = includedFiles(commit, options.isIncluded);
550
- if (files.length === 0) continue;
551
- if (files.length > options.maxFilesPerCommit) {
552
- massSkipped++;
553
- continue;
554
- }
555
- commitsUsed++;
556
- for (const file of files) {
557
- commitsPerFile.set(file, (commitsPerFile.get(file) ?? 0) + 1);
558
- }
559
- for (let i = 0; i < files.length - 1; i++) {
560
- const file1 = files[i];
561
- let partners = sharedPerPair.get(file1);
562
- if (!partners) {
563
- partners = /* @__PURE__ */ new Map();
564
- sharedPerPair.set(file1, partners);
565
- }
566
- for (let j = i + 1; j < files.length; j++) {
567
- const file2 = files[j];
568
- partners.set(file2, (partners.get(file2) ?? 0) + 1);
569
- }
570
- }
571
- }
572
- const entries = [];
573
- for (const [file1, partners] of sharedPerPair) {
574
- for (const [file2, sharedCommits] of partners) {
575
- if (sharedCommits < options.minShared) continue;
576
- const smallerHistory = Math.min(commitsPerFile.get(file1), commitsPerFile.get(file2));
577
- const frequency = Math.round(sharedCommits / smallerHistory * 1e3) / 1e3;
578
- if (frequency < MIN_FREQUENCY) continue;
579
- entries.push({ file1, file2, sharedCommits, frequency });
580
- }
745
+ // src/adapters/stats/session-recorder-adapter.ts
746
+ var log4 = createLogger("ctxo:stats");
747
+ var SessionRecorderAdapter = class {
748
+ db;
749
+ onWrite;
750
+ tableCreated = false;
751
+ constructor(db, onWrite) {
752
+ this.db = db;
753
+ this.onWrite = onWrite ?? null;
581
754
  }
582
- entries.sort(compareEntries);
583
- const window = {
584
- maxCommits: options.maxCommits,
585
- ...options.since !== void 0 ? { since: options.since } : {},
586
- head: options.head,
587
- commitsScanned: windowed.length,
588
- commitsUsed,
589
- massSkipped,
590
- maxFilesPerCommit: options.maxFilesPerCommit,
591
- minShared: options.minShared
592
- };
593
- return { version: 1, timestamp: options.timestamp, window, entries };
594
- }
595
- function includedFiles(commit, isIncluded) {
596
- const included = /* @__PURE__ */ new Set();
597
- for (const file of commit.files) {
598
- if (isIncluded(file)) included.add(file);
755
+ ensureTable() {
756
+ if (this.tableCreated) return;
757
+ this.db.run(`
758
+ CREATE TABLE IF NOT EXISTS session_events (
759
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
760
+ timestamp TEXT DEFAULT (datetime('now')),
761
+ tool TEXT NOT NULL,
762
+ symbol_id TEXT,
763
+ detail_level TEXT,
764
+ response_tokens INTEGER,
765
+ response_bytes INTEGER,
766
+ latency_ms REAL,
767
+ truncated BOOLEAN DEFAULT 0
768
+ )
769
+ `);
770
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_session_timestamp ON session_events(timestamp)");
771
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_session_tool ON session_events(tool)");
772
+ this.tableCreated = true;
599
773
  }
600
- return [...included].sort(compareCodepoint);
601
- }
602
- function compareEntries(a, b) {
603
- return b.frequency - a.frequency || b.sharedCommits - a.sharedCommits || compareCodepoint(a.file1, b.file1) || compareCodepoint(a.file2, b.file2);
604
- }
605
- function loadCoChangeMap(matrix) {
606
- const map = /* @__PURE__ */ new Map();
607
- for (const entry of matrix.entries) {
608
- let list1 = map.get(entry.file1);
609
- if (!list1) {
610
- list1 = [];
611
- map.set(entry.file1, list1);
774
+ record(event) {
775
+ try {
776
+ this.ensureTable();
777
+ this.db.run(
778
+ `INSERT INTO session_events (tool, symbol_id, detail_level, response_tokens, response_bytes, latency_ms, truncated)
779
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
780
+ [
781
+ event.tool,
782
+ event.symbolId,
783
+ event.detailLevel,
784
+ event.responseTokens,
785
+ event.responseBytes,
786
+ event.latencyMs,
787
+ event.truncated ? 1 : 0
788
+ ]
789
+ );
790
+ this.flushToDisk();
791
+ } catch (err) {
792
+ log4.error(`Failed to record session event: ${err.message}`);
612
793
  }
613
- list1.push(entry);
614
- let list2 = map.get(entry.file2);
615
- if (!list2) {
616
- list2 = [];
617
- map.set(entry.file2, list2);
794
+ }
795
+ queryStats(days) {
796
+ this.ensureTable();
797
+ const whereClause = days != null && days > 0 ? `WHERE timestamp >= datetime('now', '-${Math.floor(days)} days')` : "";
798
+ const summaryResult = this.db.exec(
799
+ `SELECT COUNT(*) as total_calls, COALESCE(SUM(response_tokens), 0) as total_tokens
800
+ FROM session_events ${whereClause}`
801
+ );
802
+ const summaryRow = summaryResult[0]?.values[0];
803
+ const totalCalls = Number(summaryRow?.[0] ?? 0);
804
+ const totalTokensServed = Number(summaryRow?.[1] ?? 0);
805
+ const toolsResult = this.db.exec(
806
+ `SELECT tool, COUNT(*) as calls, CAST(AVG(response_tokens) AS INTEGER) as avg_tokens
807
+ FROM session_events ${whereClause}
808
+ GROUP BY tool ORDER BY calls DESC LIMIT 5`
809
+ );
810
+ const topTools = (toolsResult[0]?.values ?? []).map((row) => ({
811
+ tool: String(row[0]),
812
+ calls: Number(row[1]),
813
+ avgTokens: Number(row[2])
814
+ }));
815
+ const symbolWherePrefix = whereClause ? whereClause + " AND" : "WHERE";
816
+ const symbolsResult = this.db.exec(
817
+ `SELECT symbol_id, COUNT(*) as queries
818
+ FROM session_events ${symbolWherePrefix} symbol_id IS NOT NULL
819
+ GROUP BY symbol_id ORDER BY queries DESC LIMIT 5`
820
+ );
821
+ const topSymbols = (symbolsResult[0]?.values ?? []).map((row) => {
822
+ const symbolId = String(row[0]);
823
+ const parts = symbolId.split("::");
824
+ const name = parts.length >= 2 ? parts[1] : symbolId;
825
+ return {
826
+ symbolId,
827
+ name,
828
+ queries: Number(row[1])
829
+ };
830
+ });
831
+ const levelResult = this.db.exec(
832
+ `SELECT detail_level, COUNT(*) as count
833
+ FROM session_events ${symbolWherePrefix} detail_level IS NOT NULL
834
+ GROUP BY detail_level ORDER BY detail_level`
835
+ );
836
+ const levelRows = levelResult[0]?.values ?? [];
837
+ const levelTotal = levelRows.reduce((sum, row) => sum + Number(row[1]), 0);
838
+ const detailLevelDistribution = levelRows.map((row) => ({
839
+ level: String(row[0]),
840
+ count: Number(row[1]),
841
+ percentage: levelTotal > 0 ? Math.round(Number(row[1]) / levelTotal * 100) : 0
842
+ }));
843
+ return {
844
+ totalCalls,
845
+ totalTokensServed,
846
+ topTools,
847
+ topSymbols,
848
+ detailLevelDistribution
849
+ };
850
+ }
851
+ clear() {
852
+ this.ensureTable();
853
+ this.db.run("DELETE FROM session_events");
854
+ this.flushToDisk();
855
+ }
856
+ flushToDisk() {
857
+ try {
858
+ this.onWrite?.();
859
+ } catch (err) {
860
+ log4.error(`Failed to flush stats to disk: ${err.message}`);
618
861
  }
619
- list2.push(entry);
620
862
  }
621
- return map;
622
- }
863
+ };
623
864
 
624
- // src/adapters/storage/co-change-reader.ts
625
- var log3 = createLogger("ctxo:co-change");
626
- var UNTIL_REWRITTEN = "co-change is ignored until ctxo index rewrites it";
627
- var UNTIL_RETRY = "co-change is ignored for now and the next call tries again";
628
- var CoChangeReader = class {
865
+ // src/adapters/storage/contexts-view-reader.ts
866
+ import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
867
+ import { join as join5 } from "path";
868
+
869
+ // src/core/contexts-view/contexts-view-schema.ts
870
+ import { DATA_RESOURCE_KINDS } from "@ctxo/plugin-api";
871
+ import { z } from "zod";
872
+ var count = z.number().int().nonnegative();
873
+ var SettingsSchema = z.object({
874
+ // A PR 1A artifact has no data weight; it was 0 then.
875
+ weights: z.object({ dependency: z.number(), coChange: z.number(), data: z.number().default(0) }),
876
+ exclude: z.array(z.string()),
877
+ hubs: z.object({
878
+ auto: z.boolean(),
879
+ topPercent: z.number(),
880
+ minDependents: z.number(),
881
+ paths: z.array(z.string())
882
+ }),
883
+ resolution: z.number(),
884
+ minCandidateFiles: z.number(),
885
+ technicalTerms: z.array(z.string())
886
+ });
887
+ var DataAccessSchema = z.object({
888
+ candidate: z.number().int(),
889
+ modes: z.array(z.enum(["reads", "writes"]))
890
+ });
891
+ var SharedTableSchema = z.object({
892
+ name: z.string(),
893
+ kind: z.enum(DATA_RESOURCE_KINDS).optional(),
894
+ external: z.boolean(),
895
+ accesses: z.array(DataAccessSchema),
896
+ owner: z.number().int().optional()
897
+ });
898
+ var DataResourceSchema = z.object({
899
+ name: z.string(),
900
+ kind: z.enum(DATA_RESOURCE_KINDS).optional(),
901
+ external: z.boolean(),
902
+ accesses: z.array(DataAccessSchema),
903
+ writers: z.array(z.number().int()),
904
+ owner: z.number().int().optional()
905
+ });
906
+ var CandidateSchema = z.object({
907
+ id: z.number().int(),
908
+ label: z.string(),
909
+ fingerprint: z.string(),
910
+ fileCount: count,
911
+ files: z.array(z.string()),
912
+ cohesion: z.number(),
913
+ topTerms: z.array(z.object({ term: z.string(), files: count })),
914
+ crossEdges: z.array(
915
+ z.object({
916
+ candidate: z.number().int(),
917
+ total: count,
918
+ byKind: z.partialRecord(EdgeKindSchema, count)
919
+ })
920
+ ),
921
+ coChangePairs: z.array(
922
+ z.object({
923
+ file: z.string(),
924
+ otherFile: z.string(),
925
+ otherCandidate: z.number().int(),
926
+ frequency: z.number(),
927
+ sharedCommits: count
928
+ })
929
+ ),
930
+ // Absent in a PR 1A artifact.
931
+ sharedTables: z.array(SharedTableSchema).default([])
932
+ });
933
+ var HubSchema = z.object({
934
+ file: z.string(),
935
+ reasons: z.array(z.enum(["configured", "fan-in", "god-node"])),
936
+ dependents: count,
937
+ damping: z.number(),
938
+ candidate: z.number().int().optional()
939
+ });
940
+ var CollisionSchema = z.object({
941
+ name: z.string(),
942
+ minJaccard: z.number(),
943
+ occurrences: z.array(
944
+ z.object({
945
+ candidate: z.number().int(),
946
+ file: z.string(),
947
+ symbolId: z.string(),
948
+ members: z.array(z.string())
949
+ })
950
+ )
951
+ });
952
+ var ContextsViewSchema = z.object({
953
+ version: z.literal(1),
954
+ commitSha: z.string(),
955
+ settings: SettingsSchema,
956
+ coverage: z.object({ files: count, covered: count, ratio: z.number() }),
957
+ candidates: z.array(CandidateSchema),
958
+ hubs: z.array(HubSchema),
959
+ unclustered: z.object({ count, files: z.array(z.string()) }),
960
+ collisions: z.array(CollisionSchema),
961
+ // Absent in a PR 1A artifact.
962
+ dataResources: z.array(DataResourceSchema).default([])
963
+ });
964
+
965
+ // src/adapters/storage/contexts-view-reader.ts
966
+ var log5 = createLogger("ctxo:contexts-view");
967
+ var UNTIL_REWRITTEN2 = "the contexts view is ignored until ctxo index rewrites it";
968
+ var UNTIL_RETRY2 = "the contexts view is ignored for now and the next call tries again";
969
+ function deepFreeze(value) {
970
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
971
+ Object.freeze(value);
972
+ for (const child of Object.values(value)) deepFreeze(child);
973
+ }
974
+ return value;
975
+ }
976
+ var ContextsViewReader = class {
629
977
  filePath;
630
978
  cached;
631
- /** What the last warning was about, so one problem is reported once. */
632
979
  lastWarning;
633
980
  constructor(ctxoRoot) {
634
- this.filePath = join3(ctxoRoot, "index", "co-changes.json");
981
+ this.filePath = join5(ctxoRoot, "index", "contexts-view.json");
635
982
  }
636
- readCoChangeMap() {
983
+ readContextsView() {
637
984
  let stats;
638
985
  try {
639
- stats = statSync(this.filePath);
986
+ stats = statSync2(this.filePath);
640
987
  } catch (err) {
641
988
  this.cached = void 0;
642
989
  const { code, message } = err;
643
990
  if (code === "ENOENT") {
644
991
  this.lastWarning = void 0;
645
992
  } else {
646
- this.warnOnce(`stat ${code ?? message}`, `cannot be inspected (${message})`, UNTIL_RETRY);
993
+ this.warnOnce(`stat ${code ?? message}`, `cannot be inspected (${message})`, UNTIL_RETRY2);
647
994
  }
648
995
  return void 0;
649
996
  }
650
- const version = `${stats.mtimeMs}:${stats.size}`;
651
- if (this.cached?.version === version) return this.cached.map;
997
+ const version = `${stats.mtimeMs}:${stats.size}:${stats.ino}`;
998
+ if (this.cached?.version === version) return this.cached.view;
652
999
  return this.load(version);
653
1000
  }
654
1001
  load(version) {
655
1002
  let raw;
656
1003
  try {
657
- raw = readFileSync3(this.filePath, "utf-8");
1004
+ raw = readFileSync4(this.filePath, "utf-8");
658
1005
  } catch (err) {
659
1006
  this.cached = void 0;
660
- this.warnOnce(`read ${version}`, `cannot be read (${err.message})`, UNTIL_RETRY);
1007
+ this.warnOnce(`read ${version}`, `cannot be read (${err.message})`, UNTIL_RETRY2);
661
1008
  return void 0;
662
1009
  }
663
- const map = this.parse(raw, version);
664
- this.cached = { version, map };
665
- if (map) this.lastWarning = void 0;
666
- return map;
1010
+ const view = this.parse(raw, version);
1011
+ this.cached = { version, view };
1012
+ if (view) this.lastWarning = void 0;
1013
+ return view;
667
1014
  }
668
1015
  parse(raw, version) {
669
1016
  let data;
670
1017
  try {
671
1018
  data = JSON.parse(raw);
672
1019
  } catch (err) {
673
- return this.unusable(version, `is not valid JSON (${err.message})`);
1020
+ this.warnOnce(`content ${version}`, `is not valid JSON (${err.message})`, UNTIL_REWRITTEN2);
1021
+ return void 0;
674
1022
  }
675
- const parsed = CoChangeMatrixSchema.safeParse(data);
1023
+ const parsed = ContextsViewSchema.safeParse(data);
676
1024
  if (!parsed.success) {
677
1025
  const issue = parsed.error.issues[0];
678
1026
  const where = issue && issue.path.length > 0 ? `${issue.path.map(String).join(".")}: ` : "";
679
- return this.unusable(version, `is not a valid co-change matrix (${where}${issue?.message ?? "unknown problem"})`);
1027
+ this.warnOnce(
1028
+ `content ${version}`,
1029
+ `is not a valid contexts view (${where}${issue?.message ?? "unknown problem"})`,
1030
+ UNTIL_REWRITTEN2
1031
+ );
1032
+ return void 0;
680
1033
  }
681
- return freezeEntries(loadCoChangeMap(parsed.data));
682
- }
683
- unusable(version, problem) {
684
- this.warnOnce(`content ${version}`, problem, UNTIL_REWRITTEN);
685
- return void 0;
1034
+ return deepFreeze(parsed.data);
686
1035
  }
687
1036
  warnOnce(key, problem, consequence) {
688
1037
  if (this.lastWarning === key) return;
689
1038
  this.lastWarning = key;
690
- log3.warn(`${this.filePath} ${problem}; ${consequence}`);
691
- }
692
- };
693
- function freezeEntries(map) {
694
- for (const list of map.values()) {
695
- for (const entry of list) Object.freeze(entry);
696
- Object.freeze(list);
697
- }
698
- return map;
699
- }
700
-
701
- // src/core/co-change/co-change-window.ts
702
- var SINCE_PATTERN = /^(\d+)([dwmy])$/;
703
- var SECONDS_PER_DAY = 86400;
704
- function parseCoChangeSince(value) {
705
- const match = SINCE_PATTERN.exec(value);
706
- if (!match) return void 0;
707
- const amount = Number.parseInt(match[1], 10);
708
- if (!Number.isSafeInteger(amount)) return void 0;
709
- return { amount, unit: match[2] };
710
- }
711
- function sinceCutoff(headCommittedAt, since) {
712
- const cutoff = subtractSince(headCommittedAt, since);
713
- return Number.isFinite(cutoff) ? cutoff : Number.NEGATIVE_INFINITY;
714
- }
715
- function subtractSince(headCommittedAt, since) {
716
- switch (since.unit) {
717
- case "d":
718
- return headCommittedAt - since.amount * SECONDS_PER_DAY;
719
- case "w":
720
- return headCommittedAt - since.amount * 7 * SECONDS_PER_DAY;
721
- case "m":
722
- return subtractUtcMonths(headCommittedAt, since.amount);
723
- case "y":
724
- return subtractUtcMonths(headCommittedAt, since.amount * 12);
725
- }
726
- }
727
- function subtractUtcMonths(epochSeconds, months) {
728
- const date = new Date(epochSeconds * 1e3);
729
- const dayOfMonth = date.getUTCDate();
730
- date.setUTCDate(1);
731
- date.setUTCMonth(date.getUTCMonth() - months);
732
- date.setUTCDate(Math.min(dayOfMonth, daysInUtcMonth(date)));
733
- return date.getTime() / 1e3;
734
- }
735
- function daysInUtcMonth(date) {
736
- const lastDay = new Date(date.getTime());
737
- lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 0);
738
- return lastDay.getUTCDate();
739
- }
740
- function selectWindow(commits, maxCommits, cutoff) {
741
- const newest = maxCommits === "all" ? [...commits] : commits.slice(0, wholeCommitCount(maxCommits));
742
- return cutoff === void 0 ? newest : newest.filter((commit) => commit.committedAt >= cutoff);
743
- }
744
- function wholeCommitCount(maxCommits) {
745
- return maxCommits >= 1 ? Math.floor(maxCommits) : 0;
746
- }
747
-
748
- // src/core/config/load-config.ts
749
- import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
750
- import { join as join4 } from "path";
751
- import { parse as parseYaml } from "yaml";
752
- import picomatch from "picomatch";
753
-
754
- // src/core/config/config-schema.ts
755
- import { z } from "zod";
756
- var StatsConfigSchema = z.object({
757
- enabled: z.boolean().optional()
758
- }).strict();
759
- var IndexConfigSchema = z.object({
760
- ignore: z.array(z.string().min(1)).optional(),
761
- ignoreProjects: z.array(z.string().min(1)).optional(),
762
- facts: z.boolean().optional(),
763
- tests: z.array(z.string().min(1)).optional()
764
- }).strict();
765
- var MaskingConfigSchema = z.object({
766
- /**
767
- * Glob patterns matched against community cluster labels. Matching labels
768
- * are replaced with `[masked-cluster-N]` in MCP tool responses so
769
- * org-sensitive folder names (e.g. `src/payment-vault`, `internal-*`)
770
- * never surface to AI agents over the stdio channel.
771
- */
772
- clusterLabels: z.array(z.string().min(1)).optional()
773
- }).strict();
774
- var WatchConfigSchema = z.object({
775
- /**
776
- * Minimum number of files that must be re-indexed since the last
777
- * community snapshot before `ctxo watch` recomputes one. Default: 5.
778
- * On large monorepos Louvain + PageRank cost ~1 s of blocking CPU;
779
- * bumping this (e.g. 25) avoids recomputing on every save.
780
- */
781
- snapshotMinFileChanges: z.number().int().min(1).optional()
782
- }).strict();
783
- var GateConfigSchema = z.object({
784
- enabled: z.boolean().optional(),
785
- sensitivity: z.enum(["strict", "balanced", "lenient"]).optional(),
786
- /** Absolute floor: minimum confirmed+likely dependents before the gate can fire. */
787
- minDependents: z.number().int().min(0).optional(),
788
- /** Repo-relative significance: symbol must be in the top `percentile`% by PageRank. */
789
- percentile: z.number().int().min(1).max(100).optional()
790
- }).strict();
791
- var CoChangeConfigSchema = z.object({
792
- /**
793
- * Size of the co-change window: the last N non-merge commits from HEAD, or "all".
794
- * Default: 2000. Independent of `ctxo index --max-history`, which only sets how much
795
- * per-file commit history is kept.
796
- */
797
- maxCommits: z.union([z.number().int().min(1), z.literal("all")], { error: 'must be a positive integer or "all"' }).optional(),
798
- /**
799
- * Narrows the window to a duration counted back from HEAD's committer date (never the wall
800
- * clock): `30d`, `2w`, `12m` or `1y`. Applies together with `maxCommits`.
801
- */
802
- since: z.string().refine((value) => parseCoChangeSince(value) !== void 0, {
803
- error: "must be a duration such as 30d, 2w, 12m or 1y"
804
- }).optional(),
805
- /** Commits touching more analysed files than this are skipped as mass changes. Default: 30. */
806
- maxFilesPerCommit: z.number().int().min(1).optional(),
807
- /** A file pair becomes an entry only after this many shared commits. Default: 2. */
808
- minShared: z.number().int().min(1).optional(),
809
- /** Extra tracked files to analyse that no language plugin indexes, such as SQL scripts (globs). */
810
- include: z.array(z.string().min(1)).optional(),
811
- /** Indexed files to leave out of the analysis (globs). */
812
- exclude: z.array(z.string().min(1)).optional()
813
- }).strict();
814
- var CommunitiesConfigSchema = z.object({
815
- /** Louvain resolution: higher gives more, smaller communities. Default: 1.0. */
816
- resolution: z.number().positive().optional(),
817
- /** Minimum number of communities a symbol must bridge to count as a god node. Default: 3. */
818
- godNodeMinBridged: z.number().int().min(1).optional(),
819
- /** Maximum number of god nodes reported. Default: 25. */
820
- godNodeMaxResults: z.number().int().min(0).optional()
821
- }).strict();
822
- var ContextsViewConfigSchema = z.object({
823
- /** Blend of the file-edge signals. Defaults: dependency 1.0, coChange 1.0, data 0 (shared tables are a finding, not a clustering weight). */
824
- weights: z.object({
825
- dependency: z.number().min(0).optional(),
826
- coChange: z.number().min(0).optional(),
827
- data: z.number().min(0).optional()
828
- }).strict().optional(),
829
- /** Files to leave out of the file graph (globs). */
830
- exclude: z.array(z.string().min(1)).optional(),
831
- hubs: z.object({
832
- /** Detect hubs automatically (fan-in and god nodes). Default: true. */
833
- auto: z.boolean().optional(),
834
- /** A file is a fan-in hub when it is in the top `topPercent`% by dependents. Default: 1. */
835
- topPercent: z.number().positive().max(100).optional(),
836
- /** Minimum distinct dependent files for a fan-in hub. Default: 10. */
837
- minDependents: z.number().int().min(1).optional(),
838
- /** Files that lose all their edges (globs). */
839
- paths: z.array(z.string().min(1)).optional()
840
- }).strict().optional(),
841
- /** Louvain resolution for the file graph. Default: 1.0. */
842
- resolution: z.number().positive().optional(),
843
- /** Candidates with fewer files fold into a neighbour. Default: 3. */
844
- minCandidateFiles: z.number().int().min(1).optional(),
845
- /** Words dropped from candidate terms; replaces the default list. */
846
- technicalTerms: z.array(z.string().min(1)).optional()
847
- }).strict();
848
- var CtxoConfigSchema = z.object({
849
- version: z.union([z.string(), z.number()]).optional(),
850
- stats: StatsConfigSchema.optional(),
851
- index: IndexConfigSchema.optional(),
852
- masking: MaskingConfigSchema.optional(),
853
- watch: WatchConfigSchema.optional(),
854
- gate: GateConfigSchema.optional(),
855
- cochange: CoChangeConfigSchema.optional(),
856
- communities: CommunitiesConfigSchema.optional(),
857
- contextsView: ContextsViewConfigSchema.optional()
858
- }).passthrough();
859
- var DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES = 5;
860
- var DEFAULT_COCHANGE_MAX_COMMITS = 2e3;
861
- var DEFAULT_COCHANGE_MAX_FILES_PER_COMMIT = 30;
862
- var DEFAULT_COCHANGE_MIN_SHARED = 2;
863
- var DEFAULT_TECHNICAL_TERMS = [
864
- "Service",
865
- "Repository",
866
- "Controller",
867
- "Handler",
868
- "Manager",
869
- "Dto",
870
- "Impl",
871
- "Async"
872
- ];
873
- var DEFAULT_CONFIG = {
874
- version: "1.0",
875
- stats: { enabled: true },
876
- index: { ignore: [], ignoreProjects: [] },
877
- masking: { clusterLabels: [] },
878
- watch: { snapshotMinFileChanges: DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES },
879
- gate: { enabled: true, sensitivity: "balanced" },
880
- cochange: {
881
- maxCommits: DEFAULT_COCHANGE_MAX_COMMITS,
882
- maxFilesPerCommit: DEFAULT_COCHANGE_MAX_FILES_PER_COMMIT,
883
- minShared: DEFAULT_COCHANGE_MIN_SHARED,
884
- include: [],
885
- exclude: []
886
- }
887
- };
888
-
889
- // src/core/config/load-config.ts
890
- var log4 = createLogger("ctxo:config");
891
- function isValidGlob(pattern) {
892
- try {
893
- picomatch(pattern);
894
- return true;
895
- } catch {
896
- return false;
897
- }
898
- }
899
- function validateGlobs(patterns, field, invalid) {
900
- if (!patterns) return;
901
- for (const p of patterns) {
902
- if (!isValidGlob(p)) invalid.push(`${field}: ${p}`);
903
- }
904
- }
905
- function loadConfig(ctxoRoot) {
906
- const path = join4(ctxoRoot, "config.yaml");
907
- const errors = [];
908
- const invalidGlobs = [];
909
- if (!existsSync3(path)) {
910
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: false };
911
- }
912
- let raw;
913
- try {
914
- raw = readFileSync4(path, "utf-8");
915
- } catch (err) {
916
- errors.push(`Cannot read ${path}: ${err.message}`);
917
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
918
- }
919
- let parsed;
920
- try {
921
- parsed = parseYaml(raw);
922
- } catch (err) {
923
- errors.push(`Invalid YAML in ${path}: ${err.message}`);
924
- log4.warn(`Invalid YAML in config.yaml, falling back to defaults: ${err.message}`);
925
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
926
- }
927
- if (parsed === null || parsed === void 0) {
928
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
929
- }
930
- const result = CtxoConfigSchema.safeParse(parsed);
931
- if (!result.success) {
932
- for (const issue of result.error.issues) {
933
- errors.push(`${issue.path.join(".") || "<root>"}: ${issue.message}`);
934
- }
935
- log4.warn(`Invalid config.yaml (${errors.length} issue(s)), falling back to defaults`);
936
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
937
- }
938
- validateGlobs(result.data.index?.ignore, "index.ignore", invalidGlobs);
939
- validateGlobs(result.data.index?.ignoreProjects, "index.ignoreProjects", invalidGlobs);
940
- validateGlobs(result.data.index?.tests, "index.tests", invalidGlobs);
941
- validateGlobs(result.data.cochange?.include, "cochange.include", invalidGlobs);
942
- validateGlobs(result.data.cochange?.exclude, "cochange.exclude", invalidGlobs);
943
- validateGlobs(result.data.contextsView?.exclude, "contextsView.exclude", invalidGlobs);
944
- validateGlobs(result.data.contextsView?.hubs?.paths, "contextsView.hubs.paths", invalidGlobs);
945
- for (const pattern of invalidGlobs) {
946
- log4.warn(`Invalid glob pattern in config.yaml \u2014 ${pattern}`);
947
- }
948
- return { config: result.data, errors, invalidGlobs, path, exists: true };
949
- }
950
- function statsEnabled(config) {
951
- return config.stats?.enabled !== false;
952
- }
953
- function indexIgnorePatterns(config) {
954
- return (config.index?.ignore ?? []).filter(isValidGlob);
955
- }
956
- function indexIgnoreProjectPatterns(config) {
957
- return (config.index?.ignoreProjects ?? []).filter(isValidGlob);
958
- }
959
- function factsRequested(config) {
960
- return config.index?.facts === true;
961
- }
962
- function indexTestPatterns(config) {
963
- return (config.index?.tests ?? []).filter(isValidGlob);
964
- }
965
- function communityDetectorOptions(config) {
966
- const section = config.communities;
967
- const resolution = section?.resolution;
968
- const minBridged = section?.godNodeMinBridged;
969
- const maxResults = section?.godNodeMaxResults;
970
- return {
971
- ...typeof resolution === "number" && Number.isFinite(resolution) && resolution > 0 ? { resolution } : {},
972
- ...typeof minBridged === "number" && Number.isInteger(minBridged) && minBridged >= 1 ? { godNodeMinBridged: minBridged } : {},
973
- ...typeof maxResults === "number" && Number.isInteger(maxResults) && maxResults >= 0 ? { godNodeMaxResults: maxResults } : {}
974
- };
975
- }
976
- function maskingClusterLabelPatterns(config) {
977
- return (config.masking?.clusterLabels ?? []).filter(isValidGlob);
978
- }
979
- function watchSnapshotMinFileChanges(config) {
980
- const value = config.watch?.snapshotMinFileChanges;
981
- if (typeof value === "number" && Number.isFinite(value) && value >= 1) return value;
982
- return DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES;
983
- }
984
- function isPositiveInteger(value) {
985
- return typeof value === "number" && Number.isInteger(value) && value >= 1;
986
- }
987
- function positiveIntegerOr(value, fallback) {
988
- return isPositiveInteger(value) ? value : fallback;
989
- }
990
- function validMaxCommits(value) {
991
- if (value === "all") return "all";
992
- return isPositiveInteger(value) ? value : void 0;
993
- }
994
- function validSince(value) {
995
- return typeof value === "string" && parseCoChangeSince(value) !== void 0 ? value : void 0;
996
- }
997
- function resolveCoChangeConfig(config, overrides = {}) {
998
- const section = config.cochange;
999
- const since = validSince(overrides.since) ?? validSince(section?.since);
1000
- return {
1001
- maxCommits: validMaxCommits(overrides.maxCommits) ?? validMaxCommits(section?.maxCommits) ?? DEFAULT_COCHANGE_MAX_COMMITS,
1002
- ...since !== void 0 ? { since } : {},
1003
- maxFilesPerCommit: positiveIntegerOr(section?.maxFilesPerCommit, DEFAULT_COCHANGE_MAX_FILES_PER_COMMIT),
1004
- minShared: positiveIntegerOr(section?.minShared, DEFAULT_COCHANGE_MIN_SHARED),
1005
- include: (section?.include ?? []).filter(isValidGlob),
1006
- exclude: (section?.exclude ?? []).filter(isValidGlob)
1007
- };
1008
- }
1009
- function nonNegativeOr(value, fallback) {
1010
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
1011
- }
1012
- function positiveOr(value, fallback) {
1013
- return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
1014
- }
1015
- function resolveContextsViewConfig(config) {
1016
- const section = config.contextsView;
1017
- const hubs = section?.hubs;
1018
- const topPercent = hubs?.topPercent;
1019
- return {
1020
- weights: {
1021
- dependency: nonNegativeOr(section?.weights?.dependency, 1),
1022
- coChange: nonNegativeOr(section?.weights?.coChange, 1),
1023
- data: nonNegativeOr(section?.weights?.data, 0)
1024
- },
1025
- exclude: (section?.exclude ?? []).filter(isValidGlob),
1026
- hubs: {
1027
- auto: hubs?.auto !== false,
1028
- topPercent: typeof topPercent === "number" && topPercent > 0 && topPercent <= 100 ? topPercent : 1,
1029
- minDependents: positiveIntegerOr(hubs?.minDependents, 10),
1030
- paths: (hubs?.paths ?? []).filter(isValidGlob)
1031
- },
1032
- resolution: positiveOr(section?.resolution, 1),
1033
- minCandidateFiles: positiveIntegerOr(section?.minCandidateFiles, 3),
1034
- technicalTerms: section?.technicalTerms ? [...section.technicalTerms] : [...DEFAULT_TECHNICAL_TERMS]
1035
- };
1036
- }
1037
- function makeGlobMatcher(patterns) {
1038
- if (patterns.length === 0) return () => false;
1039
- const matchers = patterns.map((p) => picomatch(p, { dot: true }));
1040
- return (input) => {
1041
- const normalised = input.replace(/\\/g, "/");
1042
- return matchers.some((m) => m(normalised));
1043
- };
1044
- }
1045
-
1046
- // src/adapters/git/simple-git-adapter.ts
1047
- import { spawnSync } from "child_process";
1048
- import { simpleGit } from "simple-git";
1049
- var log5 = createLogger("ctxo:git");
1050
- var NUL = "\0";
1051
- var RECORD_SEPARATOR = "";
1052
- var FIELD_SEPARATOR = "";
1053
- var CHANGE_LOG_FORMAT = "--format=%x1e%H%x1f%aI%x1f%ct%x1f%an%x1f%s";
1054
- var STABLE_OUTPUT_CONFIG = [
1055
- "-c",
1056
- "core.quotepath=off",
1057
- "-c",
1058
- "log.showSignature=false"
1059
- ];
1060
- function toForwardSlashes(path) {
1061
- return path.replace(/\\/g, "/");
1062
- }
1063
- function isFileChangeStatus(value) {
1064
- return value === "A" || value === "M" || value === "D" || value === "R" || value === "C" || value === "T";
1065
- }
1066
- function parseNameStatus(tokens) {
1067
- const changes = [];
1068
- let i = 0;
1069
- while (i < tokens.length) {
1070
- const kind = (tokens[i] ?? "").trim().charAt(0);
1071
- if (kind === "") {
1072
- i += 1;
1073
- } else if (kind === "R" || kind === "C") {
1074
- const oldPath = tokens[i + 1];
1075
- const path = tokens[i + 2];
1076
- i += 3;
1077
- if (oldPath !== void 0 && path !== void 0) {
1078
- changes.push({ status: kind, path: toForwardSlashes(path), oldPath: toForwardSlashes(oldPath) });
1079
- }
1080
- } else {
1081
- const path = tokens[i + 1];
1082
- i += 2;
1083
- if (path !== void 0 && isFileChangeStatus(kind)) {
1084
- changes.push({ status: kind, path: toForwardSlashes(path) });
1085
- }
1086
- }
1087
- }
1088
- return changes;
1089
- }
1090
- function parseSkipWorktree(raw) {
1091
- const paths = /* @__PURE__ */ new Set();
1092
- for (const record of raw.split(NUL)) {
1093
- if (record.length > 2 && record.charAt(0) === "S" && record.charAt(1) === " ") {
1094
- paths.add(toForwardSlashes(record.slice(2)));
1095
- }
1096
- }
1097
- return paths;
1098
- }
1099
- function parseChangeLog(raw) {
1100
- const commits = [];
1101
- for (const record of raw.split(RECORD_SEPARATOR)) {
1102
- const [header = "", ...tokens] = record.split(NUL);
1103
- const [hash, date, time, author, ...subject] = header.split(FIELD_SEPARATOR);
1104
- const committedAt = Number.parseInt(time ?? "", 10);
1105
- if (!hash || date === void 0 || author === void 0 || Number.isNaN(committedAt)) continue;
1106
- commits.push({
1107
- hash,
1108
- message: subject.join(FIELD_SEPARATOR).trim(),
1109
- date,
1110
- author,
1111
- committedAt,
1112
- changes: parseNameStatus(tokens)
1113
- });
1114
- }
1115
- return commits;
1116
- }
1117
- var SimpleGitAdapter = class {
1118
- git;
1119
- projectRoot;
1120
- constructor(projectRoot) {
1121
- this.projectRoot = projectRoot;
1122
- this.git = simpleGit(projectRoot);
1123
- log5.debug("Initialized with root: %s", projectRoot);
1124
- }
1125
- async isAvailable() {
1126
- try {
1127
- await this.git.version();
1128
- return true;
1129
- } catch {
1130
- return false;
1131
- }
1132
- }
1133
- async getCommitHistory(filePath, maxCount) {
1134
- try {
1135
- const log8 = await this.git.log({
1136
- file: filePath,
1137
- "--follow": null,
1138
- ...maxCount ? { maxCount } : {}
1139
- });
1140
- return log8.all.map((entry) => ({
1141
- hash: entry.hash,
1142
- message: entry.message,
1143
- date: entry.date,
1144
- author: entry.author_name
1145
- }));
1146
- } catch (err) {
1147
- log5.error(` Failed to get history for ${filePath}: ${err.message}`);
1148
- return [];
1149
- }
1150
- }
1151
- async getChangedFiles(since) {
1152
- try {
1153
- const diff = await this.git.diffSummary([since]);
1154
- return diff.files.map((f) => f.file.replace(/\\/g, "/"));
1155
- } catch (err) {
1156
- log5.error(` Failed to get changed files since ${since}: ${err.message}`);
1157
- return [];
1158
- }
1159
- }
1160
- async getFileChurn(filePath) {
1161
- try {
1162
- const log8 = await this.git.log({ file: filePath, "--follow": null });
1163
- return {
1164
- filePath,
1165
- commitCount: log8.total
1166
- };
1167
- } catch (err) {
1168
- log5.error(` Failed to get churn for ${filePath}: ${err.message}`);
1169
- return { filePath, commitCount: 0 };
1170
- }
1171
- }
1172
- async getHeadSha() {
1173
- try {
1174
- const raw = await this.git.revparse(["--short", "HEAD"]);
1175
- const sha = raw.trim();
1176
- return sha.length > 0 ? sha : void 0;
1177
- } catch (err) {
1178
- log5.error(`Failed to resolve HEAD: ${err.message}`);
1179
- return void 0;
1180
- }
1181
- }
1182
- async countCommitsBetween(base) {
1183
- try {
1184
- const raw = await this.git.raw(["rev-list", "--count", `${base}..HEAD`]);
1185
- const n = parseInt(raw.trim(), 10);
1186
- return Number.isFinite(n) && n >= 0 ? n : void 0;
1187
- } catch (err) {
1188
- log5.error(`Failed to count commits since ${base}: ${err.message}`);
1189
- return void 0;
1190
- }
1191
- }
1192
- async getChangeLog(maxCommits) {
1193
- try {
1194
- const headRaw = await this.git.raw([...STABLE_OUTPUT_CONFIG, "log", "-1", "--format=%h%x1f%ct", "HEAD"]);
1195
- const [head, headTime] = headRaw.trim().split(FIELD_SEPARATOR);
1196
- const headCommittedAt = Number.parseInt(headTime ?? "", 10);
1197
- if (!head || Number.isNaN(headCommittedAt)) {
1198
- throw new Error(`unexpected HEAD description ${JSON.stringify(headRaw)}`);
1199
- }
1200
- const args = [...STABLE_OUTPUT_CONFIG, "log", "--no-merges", "-M", "--name-status", "-z", CHANGE_LOG_FORMAT];
1201
- if (maxCommits !== "all") args.push(`--max-count=${maxCommits}`);
1202
- args.push("HEAD");
1203
- return { head, headCommittedAt, commits: parseChangeLog(await this.git.raw(args)) };
1204
- } catch (err) {
1205
- log5.error(`Failed to read change log: ${err.message}`);
1206
- return void 0;
1207
- }
1208
- }
1209
- async getSkipWorktreePaths() {
1210
- try {
1211
- return parseSkipWorktree(await this.git.raw([...STABLE_OUTPUT_CONFIG, "ls-files", "-t", "-z"]));
1212
- } catch (err) {
1213
- log5.error(`Failed to list skip-worktree paths: ${err.message}`);
1214
- return void 0;
1215
- }
1216
- }
1217
- async getIgnoredPaths(paths) {
1218
- if (paths.length === 0) return /* @__PURE__ */ new Set();
1219
- const result = spawnSync("git", [...STABLE_OUTPUT_CONFIG, "check-ignore", "-z", "--stdin"], {
1220
- cwd: this.projectRoot,
1221
- input: paths.map((path) => `${path}${NUL}`).join(""),
1222
- encoding: "utf-8",
1223
- maxBuffer: 64 * 1024 * 1024
1224
- });
1225
- if (result.error === void 0 && result.status === 1) return /* @__PURE__ */ new Set();
1226
- if (result.error !== void 0 || result.status !== 0) {
1227
- const reason = (result.error?.message ?? result.stderr ?? "").replace(/\s+/g, " ").trim();
1228
- log5.error(`Failed to check ignored paths: ${reason}`);
1229
- return void 0;
1230
- }
1231
- return new Set(
1232
- result.stdout.split(NUL).filter((path) => path.length > 0).map(toForwardSlashes)
1233
- );
1234
- }
1235
- async getCommitFileChanges(rev) {
1236
- if (rev.startsWith("-")) {
1237
- log5.error(`Refusing to read file changes for option-like revision ${rev}`);
1238
- return void 0;
1239
- }
1240
- try {
1241
- const raw = await this.git.raw([
1242
- ...STABLE_OUTPUT_CONFIG,
1243
- "diff-tree",
1244
- "--root",
1245
- "--no-commit-id",
1246
- "-r",
1247
- "-M",
1248
- "--name-status",
1249
- "-z",
1250
- rev
1251
- ]);
1252
- return parseNameStatus(raw.split(NUL));
1253
- } catch (err) {
1254
- log5.error(`Failed to read file changes of ${rev}: ${err.message}`);
1255
- return void 0;
1256
- }
1257
- }
1258
- };
1259
-
1260
- // src/adapters/storage/contexts-view-reader.ts
1261
- import { readFileSync as readFileSync5, statSync as statSync2 } from "fs";
1262
- import { join as join5 } from "path";
1263
-
1264
- // src/core/contexts-view/contexts-view-schema.ts
1265
- import { DATA_RESOURCE_KINDS } from "@ctxo/plugin-api";
1266
- import { z as z2 } from "zod";
1267
- var count = z2.number().int().nonnegative();
1268
- var SettingsSchema = z2.object({
1269
- // A PR 1A artifact has no data weight; it was 0 then.
1270
- weights: z2.object({ dependency: z2.number(), coChange: z2.number(), data: z2.number().default(0) }),
1271
- exclude: z2.array(z2.string()),
1272
- hubs: z2.object({
1273
- auto: z2.boolean(),
1274
- topPercent: z2.number(),
1275
- minDependents: z2.number(),
1276
- paths: z2.array(z2.string())
1277
- }),
1278
- resolution: z2.number(),
1279
- minCandidateFiles: z2.number(),
1280
- technicalTerms: z2.array(z2.string())
1281
- });
1282
- var DataAccessSchema = z2.object({
1283
- candidate: z2.number().int(),
1284
- modes: z2.array(z2.enum(["reads", "writes"]))
1285
- });
1286
- var SharedTableSchema = z2.object({
1287
- name: z2.string(),
1288
- kind: z2.enum(DATA_RESOURCE_KINDS).optional(),
1289
- external: z2.boolean(),
1290
- accesses: z2.array(DataAccessSchema),
1291
- owner: z2.number().int().optional()
1292
- });
1293
- var DataResourceSchema = z2.object({
1294
- name: z2.string(),
1295
- kind: z2.enum(DATA_RESOURCE_KINDS).optional(),
1296
- external: z2.boolean(),
1297
- accesses: z2.array(DataAccessSchema),
1298
- writers: z2.array(z2.number().int()),
1299
- owner: z2.number().int().optional()
1300
- });
1301
- var CandidateSchema = z2.object({
1302
- id: z2.number().int(),
1303
- label: z2.string(),
1304
- fingerprint: z2.string(),
1305
- fileCount: count,
1306
- files: z2.array(z2.string()),
1307
- cohesion: z2.number(),
1308
- topTerms: z2.array(z2.object({ term: z2.string(), files: count })),
1309
- crossEdges: z2.array(
1310
- z2.object({
1311
- candidate: z2.number().int(),
1312
- total: count,
1313
- byKind: z2.partialRecord(EdgeKindSchema, count)
1314
- })
1315
- ),
1316
- coChangePairs: z2.array(
1317
- z2.object({
1318
- file: z2.string(),
1319
- otherFile: z2.string(),
1320
- otherCandidate: z2.number().int(),
1321
- frequency: z2.number(),
1322
- sharedCommits: count
1323
- })
1324
- ),
1325
- // Absent in a PR 1A artifact.
1326
- sharedTables: z2.array(SharedTableSchema).default([])
1327
- });
1328
- var HubSchema = z2.object({
1329
- file: z2.string(),
1330
- reasons: z2.array(z2.enum(["configured", "fan-in", "god-node"])),
1331
- dependents: count,
1332
- damping: z2.number(),
1333
- candidate: z2.number().int().optional()
1334
- });
1335
- var CollisionSchema = z2.object({
1336
- name: z2.string(),
1337
- minJaccard: z2.number(),
1338
- occurrences: z2.array(
1339
- z2.object({
1340
- candidate: z2.number().int(),
1341
- file: z2.string(),
1342
- symbolId: z2.string(),
1343
- members: z2.array(z2.string())
1344
- })
1345
- )
1346
- });
1347
- var ContextsViewSchema = z2.object({
1348
- version: z2.literal(1),
1349
- commitSha: z2.string(),
1350
- settings: SettingsSchema,
1351
- coverage: z2.object({ files: count, covered: count, ratio: z2.number() }),
1352
- candidates: z2.array(CandidateSchema),
1353
- hubs: z2.array(HubSchema),
1354
- unclustered: z2.object({ count, files: z2.array(z2.string()) }),
1355
- collisions: z2.array(CollisionSchema),
1356
- // Absent in a PR 1A artifact.
1357
- dataResources: z2.array(DataResourceSchema).default([])
1358
- });
1359
-
1360
- // src/adapters/storage/contexts-view-reader.ts
1361
- var log6 = createLogger("ctxo:contexts-view");
1362
- var UNTIL_REWRITTEN2 = "the contexts view is ignored until ctxo index rewrites it";
1363
- var UNTIL_RETRY2 = "the contexts view is ignored for now and the next call tries again";
1364
- function deepFreeze(value) {
1365
- if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
1366
- Object.freeze(value);
1367
- for (const child of Object.values(value)) deepFreeze(child);
1368
- }
1369
- return value;
1370
- }
1371
- var ContextsViewReader = class {
1372
- filePath;
1373
- cached;
1374
- lastWarning;
1375
- constructor(ctxoRoot) {
1376
- this.filePath = join5(ctxoRoot, "index", "contexts-view.json");
1377
- }
1378
- readContextsView() {
1379
- let stats;
1380
- try {
1381
- stats = statSync2(this.filePath);
1382
- } catch (err) {
1383
- this.cached = void 0;
1384
- const { code, message } = err;
1385
- if (code === "ENOENT") {
1386
- this.lastWarning = void 0;
1387
- } else {
1388
- this.warnOnce(`stat ${code ?? message}`, `cannot be inspected (${message})`, UNTIL_RETRY2);
1389
- }
1390
- return void 0;
1391
- }
1392
- const version = `${stats.mtimeMs}:${stats.size}:${stats.ino}`;
1393
- if (this.cached?.version === version) return this.cached.view;
1394
- return this.load(version);
1395
- }
1396
- load(version) {
1397
- let raw;
1398
- try {
1399
- raw = readFileSync5(this.filePath, "utf-8");
1400
- } catch (err) {
1401
- this.cached = void 0;
1402
- this.warnOnce(`read ${version}`, `cannot be read (${err.message})`, UNTIL_RETRY2);
1403
- return void 0;
1404
- }
1405
- const view = this.parse(raw, version);
1406
- this.cached = { version, view };
1407
- if (view) this.lastWarning = void 0;
1408
- return view;
1409
- }
1410
- parse(raw, version) {
1411
- let data;
1412
- try {
1413
- data = JSON.parse(raw);
1414
- } catch (err) {
1415
- this.warnOnce(`content ${version}`, `is not valid JSON (${err.message})`, UNTIL_REWRITTEN2);
1416
- return void 0;
1417
- }
1418
- const parsed = ContextsViewSchema.safeParse(data);
1419
- if (!parsed.success) {
1420
- const issue = parsed.error.issues[0];
1421
- const where = issue && issue.path.length > 0 ? `${issue.path.map(String).join(".")}: ` : "";
1422
- this.warnOnce(
1423
- `content ${version}`,
1424
- `is not a valid contexts view (${where}${issue?.message ?? "unknown problem"})`,
1425
- UNTIL_REWRITTEN2
1426
- );
1427
- return void 0;
1428
- }
1429
- return deepFreeze(parsed.data);
1430
- }
1431
- warnOnce(key, problem, consequence) {
1432
- if (this.lastWarning === key) return;
1433
- this.lastWarning = key;
1434
- log6.warn(`${this.filePath} ${problem}; ${consequence}`);
1435
- }
1436
- };
1437
-
1438
- // src/adapters/stats/session-recorder-adapter.ts
1439
- var log7 = createLogger("ctxo:stats");
1440
- var SessionRecorderAdapter = class {
1441
- db;
1442
- onWrite;
1443
- tableCreated = false;
1444
- constructor(db, onWrite) {
1445
- this.db = db;
1446
- this.onWrite = onWrite ?? null;
1447
- }
1448
- ensureTable() {
1449
- if (this.tableCreated) return;
1450
- this.db.run(`
1451
- CREATE TABLE IF NOT EXISTS session_events (
1452
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1453
- timestamp TEXT DEFAULT (datetime('now')),
1454
- tool TEXT NOT NULL,
1455
- symbol_id TEXT,
1456
- detail_level TEXT,
1457
- response_tokens INTEGER,
1458
- response_bytes INTEGER,
1459
- latency_ms REAL,
1460
- truncated BOOLEAN DEFAULT 0
1461
- )
1462
- `);
1463
- this.db.run("CREATE INDEX IF NOT EXISTS idx_session_timestamp ON session_events(timestamp)");
1464
- this.db.run("CREATE INDEX IF NOT EXISTS idx_session_tool ON session_events(tool)");
1465
- this.tableCreated = true;
1466
- }
1467
- record(event) {
1468
- try {
1469
- this.ensureTable();
1470
- this.db.run(
1471
- `INSERT INTO session_events (tool, symbol_id, detail_level, response_tokens, response_bytes, latency_ms, truncated)
1472
- VALUES (?, ?, ?, ?, ?, ?, ?)`,
1473
- [
1474
- event.tool,
1475
- event.symbolId,
1476
- event.detailLevel,
1477
- event.responseTokens,
1478
- event.responseBytes,
1479
- event.latencyMs,
1480
- event.truncated ? 1 : 0
1481
- ]
1482
- );
1483
- this.flushToDisk();
1484
- } catch (err) {
1485
- log7.error(`Failed to record session event: ${err.message}`);
1486
- }
1487
- }
1488
- queryStats(days) {
1489
- this.ensureTable();
1490
- const whereClause = days != null && days > 0 ? `WHERE timestamp >= datetime('now', '-${Math.floor(days)} days')` : "";
1491
- const summaryResult = this.db.exec(
1492
- `SELECT COUNT(*) as total_calls, COALESCE(SUM(response_tokens), 0) as total_tokens
1493
- FROM session_events ${whereClause}`
1494
- );
1495
- const summaryRow = summaryResult[0]?.values[0];
1496
- const totalCalls = Number(summaryRow?.[0] ?? 0);
1497
- const totalTokensServed = Number(summaryRow?.[1] ?? 0);
1498
- const toolsResult = this.db.exec(
1499
- `SELECT tool, COUNT(*) as calls, CAST(AVG(response_tokens) AS INTEGER) as avg_tokens
1500
- FROM session_events ${whereClause}
1501
- GROUP BY tool ORDER BY calls DESC LIMIT 5`
1502
- );
1503
- const topTools = (toolsResult[0]?.values ?? []).map((row) => ({
1504
- tool: String(row[0]),
1505
- calls: Number(row[1]),
1506
- avgTokens: Number(row[2])
1507
- }));
1508
- const symbolWherePrefix = whereClause ? whereClause + " AND" : "WHERE";
1509
- const symbolsResult = this.db.exec(
1510
- `SELECT symbol_id, COUNT(*) as queries
1511
- FROM session_events ${symbolWherePrefix} symbol_id IS NOT NULL
1512
- GROUP BY symbol_id ORDER BY queries DESC LIMIT 5`
1513
- );
1514
- const topSymbols = (symbolsResult[0]?.values ?? []).map((row) => {
1515
- const symbolId = String(row[0]);
1516
- const parts = symbolId.split("::");
1517
- const name = parts.length >= 2 ? parts[1] : symbolId;
1518
- return {
1519
- symbolId,
1520
- name,
1521
- queries: Number(row[1])
1522
- };
1523
- });
1524
- const levelResult = this.db.exec(
1525
- `SELECT detail_level, COUNT(*) as count
1526
- FROM session_events ${symbolWherePrefix} detail_level IS NOT NULL
1527
- GROUP BY detail_level ORDER BY detail_level`
1528
- );
1529
- const levelRows = levelResult[0]?.values ?? [];
1530
- const levelTotal = levelRows.reduce((sum, row) => sum + Number(row[1]), 0);
1531
- const detailLevelDistribution = levelRows.map((row) => ({
1532
- level: String(row[0]),
1533
- count: Number(row[1]),
1534
- percentage: levelTotal > 0 ? Math.round(Number(row[1]) / levelTotal * 100) : 0
1535
- }));
1536
- return {
1537
- totalCalls,
1538
- totalTokensServed,
1539
- topTools,
1540
- topSymbols,
1541
- detailLevelDistribution
1542
- };
1543
- }
1544
- clear() {
1545
- this.ensureTable();
1546
- this.db.run("DELETE FROM session_events");
1547
- this.flushToDisk();
1548
- }
1549
- flushToDisk() {
1550
- try {
1551
- this.onWrite?.();
1552
- } catch (err) {
1553
- log7.error(`Failed to flush stats to disk: ${err.message}`);
1554
- }
1555
- }
1556
- };
1557
-
1558
- // src/adapters/workspace/single-package-workspace.ts
1559
- import { existsSync as existsSync4, readdirSync as readdirSync2 } from "fs";
1560
- import { join as join6, resolve as resolve2 } from "path";
1561
- var MANIFEST_CANDIDATES = [
1562
- "package.json",
1563
- "pyproject.toml",
1564
- "setup.py",
1565
- "pom.xml",
1566
- "build.gradle",
1567
- "build.gradle.kts",
1568
- "go.mod",
1569
- "Cargo.toml"
1570
- ];
1571
- function findManifest(dir) {
1572
- for (const name of MANIFEST_CANDIDATES) {
1573
- const candidate = join6(dir, name);
1574
- if (existsSync4(candidate)) return candidate;
1575
- }
1576
- const csproj = findFirstByExt(dir, ".csproj") ?? findFirstByExt(dir, ".sln");
1577
- if (csproj) return csproj;
1578
- return join6(dir, "package.json");
1579
- }
1580
- function findFirstByExt(dir, ext) {
1581
- try {
1582
- for (const entry of readdirSync2(dir, { withFileTypes: true })) {
1583
- if (entry.isFile() && entry.name.endsWith(ext)) return join6(dir, entry.name);
1584
- }
1585
- } catch {
1586
- }
1587
- return null;
1588
- }
1589
- function detectWorkspace(cwd) {
1590
- const root = resolve2(cwd);
1591
- const pkg = {
1592
- root,
1593
- manifest: findManifest(root)
1594
- };
1595
- return {
1596
- root,
1597
- packages: [pkg]
1598
- };
1599
- }
1600
-
1601
- // src/core/data-coupling/data-kinds.ts
1602
- import { DATA_RESOURCE_KINDS as DATA_RESOURCE_KINDS2 } from "@ctxo/plugin-api";
1603
- var DATA_EDGE_KINDS = /* @__PURE__ */ new Set(["reads", "writes", "executes"]);
1604
- var PROCEDURE_KINDS = ["procedure"];
1605
- function isDataResourceKind(kind) {
1606
- return DATA_RESOURCE_KINDS2.includes(kind);
1607
- }
1608
- function dataReferenceKinds(kind) {
1609
- if (isDataResourceKind(kind)) return DATA_RESOURCE_KINDS2;
1610
- if (kind === "procedure") return PROCEDURE_KINDS;
1611
- return void 0;
1612
- }
1613
-
1614
- // src/core/test-files/test-file-classifier.ts
1615
- var DEFAULT_TEST_GLOBS = [
1616
- "**/__tests__/**",
1617
- "**/*.test.*",
1618
- "**/*.spec.*",
1619
- "**/test/**",
1620
- "**/tests/**",
1621
- "**/*_test.go",
1622
- "**/src/test/**",
1623
- "**/*.Tests/**",
1624
- "**/*.Test/**",
1625
- "**/*Tests.cs",
1626
- "**/*Test.cs",
1627
- "**/test_*.py",
1628
- "**/*_test.py"
1629
- ];
1630
- var NAME_ONLY_TEST_GLOBS = ["**/*Tests.cs", "**/*Test.cs"];
1631
- var TEST_FRAMEWORK_ANNOTATIONS = [
1632
- "Fact",
1633
- "Theory",
1634
- "SkippableFact",
1635
- "SkippableTheory",
1636
- "Test",
1637
- "TestCase",
1638
- "TestCaseSource",
1639
- "TestFixture",
1640
- "TestMethod",
1641
- "DataTestMethod",
1642
- "TestClass"
1643
- ];
1644
- function normalisePath(path) {
1645
- return path.replace(/\\/g, "/");
1646
- }
1647
- function filesWithTestAnnotations(indices) {
1648
- const names = new Set(TEST_FRAMEWORK_ANNOTATIONS);
1649
- const files = /* @__PURE__ */ new Set();
1650
- for (const index of indices) {
1651
- const marked = index.symbols.some((symbol) => symbol.annotations?.some((annotation) => names.has(annotation.name)));
1652
- if (marked) files.add(normalisePath(index.file));
1653
- }
1654
- return files;
1655
- }
1656
- function createTestFileClassifier(extraGlobs = [], annotatedFiles, options = {}) {
1657
- const nameOnly = new Set(NAME_ONLY_TEST_GLOBS);
1658
- const defaults = options.annotationAware === true ? DEFAULT_TEST_GLOBS.filter((glob) => !nameOnly.has(glob)) : DEFAULT_TEST_GLOBS;
1659
- const matchesGlob = makeGlobMatcher([...defaults, ...extraGlobs]);
1660
- return (path) => annotatedFiles?.has(normalisePath(path)) === true || matchesGlob(path);
1661
- }
1662
-
1663
- // src/core/graph/symbol-graph.ts
1664
- var isDefaultTestFile = createTestFileClassifier();
1665
- var DOCUMENTATION_FOLDERS = /* @__PURE__ */ new Set(["docs", "doc", "documentation"]);
1666
- function asciiLower(text) {
1667
- return text.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
1668
- }
1669
- function isUnderDocumentationFolder(file) {
1670
- const folders = file.replace(/\\/g, "/").split("/").slice(0, -1);
1671
- return folders.some((folder) => DOCUMENTATION_FOLDERS.has(asciiLower(folder)));
1672
- }
1673
- function definitionRank(symbolId) {
1674
- const at = symbolId.indexOf("::");
1675
- const file = at < 0 ? symbolId : symbolId.slice(0, at);
1676
- return (isDefaultTestFile(file) ? 2 : 0) + (isUnderDocumentationFolder(file) ? 1 : 0);
1677
- }
1678
- var SymbolGraph = class {
1679
- nodes = /* @__PURE__ */ new Map();
1680
- nodesByFileAndName = /* @__PURE__ */ new Map();
1681
- nodesByNameAndKind = /* @__PURE__ */ new Map();
1682
- forwardEdges = /* @__PURE__ */ new Map();
1683
- reverseEdges = /* @__PURE__ */ new Map();
1684
- definitionRanks = /* @__PURE__ */ new Map();
1685
- edgeSet = /* @__PURE__ */ new Set();
1686
- addNode(node) {
1687
- this.nodes.set(node.symbolId, node);
1688
- const parts = node.symbolId.split("::");
1689
- const [p0, p1] = parts;
1690
- if (p0 !== void 0 && p1 !== void 0) {
1691
- this.nodesByFileAndName.set(`${p0}::${p1}`, node);
1692
- }
1693
- if (parts.length === 3) {
1694
- const nk = `${parts[1]}::${parts[2]}`;
1695
- const list = this.nodesByNameAndKind.get(nk);
1696
- if (list) list.push(node);
1697
- else this.nodesByNameAndKind.set(nk, [node]);
1698
- }
1699
- }
1700
- addEdge(edge) {
1701
- const resolvedFrom = this.resolveNodeId(edge.from);
1702
- const resolvedTo = this.resolveNodeId(edge.to);
1703
- const resolvedEdge = { from: resolvedFrom, to: resolvedTo, kind: edge.kind };
1704
- const edgeKey = `${resolvedEdge.from}|${resolvedEdge.to}|${resolvedEdge.kind}`;
1705
- if (this.edgeSet.has(edgeKey)) return;
1706
- this.edgeSet.add(edgeKey);
1707
- const forward = this.forwardEdges.get(resolvedEdge.from) ?? [];
1708
- forward.push(resolvedEdge);
1709
- this.forwardEdges.set(resolvedEdge.from, forward);
1710
- const reverse = this.reverseEdges.get(resolvedEdge.to) ?? [];
1711
- reverse.push(resolvedEdge);
1712
- this.reverseEdges.set(resolvedEdge.to, reverse);
1713
- }
1714
- getNode(symbolId) {
1715
- return this.nodes.get(symbolId) ?? this.resolveNodeFuzzy(symbolId);
1716
- }
1717
- resolveNodeId(id) {
1718
- if (this.nodes.has(id)) return id;
1719
- const segs = id.split("::");
1720
- const [p0, p1] = segs;
1721
- if (p0 !== void 0 && p1 !== void 0) {
1722
- const fuzzyKey = `${p0}::${p1}`;
1723
- const match = this.nodesByFileAndName.get(fuzzyKey);
1724
- if (match) return match.symbolId;
1725
- const jsToTs = p0.replace(/\.js$/, ".ts");
1726
- if (jsToTs !== p0) {
1727
- const altKey = `${jsToTs}::${p1}`;
1728
- const altMatch = this.nodesByFileAndName.get(altKey);
1729
- if (altMatch) return altMatch.symbolId;
1730
- }
1731
- }
1732
- const dataMatch = this.resolveDataReference(segs);
1733
- if (dataMatch) return dataMatch.symbolId;
1734
- const nameKind = this.extractNameKind(segs);
1735
- if (nameKind) {
1736
- const candidates = this.nodesByNameAndKind.get(nameKind);
1737
- if (candidates && candidates.length === 1) return candidates[0].symbolId;
1738
- }
1739
- return id;
1740
- }
1741
- resolveNodeFuzzy(id) {
1742
- const segs = id.split("::");
1743
- const [p0, p1] = segs;
1744
- if (p0 !== void 0 && p1 !== void 0) {
1745
- const match = this.nodesByFileAndName.get(`${p0}::${p1}`);
1746
- if (match) return match;
1747
- const jsToTs = p0.replace(/\.js$/, ".ts");
1748
- if (jsToTs !== p0) {
1749
- const tsMatch = this.nodesByFileAndName.get(`${jsToTs}::${p1}`);
1750
- if (tsMatch) return tsMatch;
1751
- }
1752
- }
1753
- const dataMatch = this.resolveDataReference(segs);
1754
- if (dataMatch) return dataMatch;
1755
- const nameKind = this.extractNameKind(segs);
1756
- if (nameKind) {
1757
- const candidates = this.nodesByNameAndKind.get(nameKind);
1758
- if (candidates && candidates.length === 1) return candidates[0];
1759
- }
1760
- return void 0;
1761
- }
1762
- /**
1763
- * Resolves a 2-part data reference (`dbo.loans::table`). A table or view reference is kind-agnostic
1764
- * across data resource kinds, a procedure reference matches procedures only. Several definitions
1765
- * resolve by `definitionRank` (a production copy before a documentation copy before a test copy), then
1766
- * to the codepoint-smallest symbolId, which is the first file in sorted path order. The result does
1767
- * not depend on insertion order.
1768
- */
1769
- resolveDataReference(segs) {
1770
- if (segs.length !== 2) return void 0;
1771
- const [name, kind] = segs;
1772
- const kinds = dataReferenceKinds(kind);
1773
- if (!kinds) return void 0;
1774
- let best;
1775
- for (const k of kinds) {
1776
- for (const node of this.nodesByNameAndKind.get(`${name}::${k}`) ?? []) {
1777
- if (!best || this.compareDefinitions(node, best) < 0) best = node;
1778
- }
1779
- }
1780
- return best;
1781
- }
1782
- /** The tie-break order of same-name definitions: `definitionRank`, then the codepoint-smaller symbolId. */
1783
- compareDefinitions = (a, b) => this.definitionRankOf(a.symbolId) - this.definitionRankOf(b.symbolId) || compareCodepoint(a.symbolId, b.symbolId);
1784
- /**
1785
- * The identity of the table, view or procedure that `id` names: a full id of any of its definitions, or a
1786
- * 2-part reference such as `dbo.loans::table`. Undefined when `id` names no node, or a node of another
1787
- * kind. A table or view identity spans both kinds, as a 2-part `::table` reference does.
1788
- */
1789
- dataResourceIdentity(id) {
1790
- const node = this.getNode(id);
1791
- return node ? this.identityOf(node) : void 0;
1792
- }
1793
- identityOf(node) {
1794
- const segs = node.symbolId.split("::");
1795
- if (segs.length !== 3) return void 0;
1796
- const [, name, kind] = segs;
1797
- const kinds = dataReferenceKinds(kind);
1798
- if (!kinds) return void 0;
1799
- const byId = /* @__PURE__ */ new Map();
1800
- for (const k of kinds) {
1801
- for (const definition of this.nodesByNameAndKind.get(`${name}::${k}`) ?? []) {
1802
- byId.set(definition.symbolId, this.nodes.get(definition.symbolId) ?? definition);
1803
- }
1804
- }
1805
- const definitions = [...byId.values()].sort(this.compareDefinitions);
1806
- const winner = definitions[0] ?? node;
1807
- const production = definitions.filter((definition) => this.definitionRankOf(definition.symbolId) === 0);
1808
- return { canonicalId: `${name}::${winner.kind}`, winner, definitions, production };
1809
- }
1810
- /** `definitionRank`, cached per symbolId (the glob match runs once per definition). */
1811
- definitionRankOf(symbolId) {
1812
- let rank = this.definitionRanks.get(symbolId);
1813
- if (rank === void 0) {
1814
- rank = definitionRank(symbolId);
1815
- this.definitionRanks.set(symbolId, rank);
1816
- }
1817
- return rank;
1818
- }
1819
- /**
1820
- * Extracts a `name::kind` key from the segments of a reference ID, supporting:
1821
- * - 2-part: `Bar::class` → `Bar::class`
1822
- * - 3-part: `pkg.Bar::helper::method` → `helper::method`
1823
- * Returns undefined for 1-part IDs (no kind information available).
1824
- */
1825
- extractNameKind(segs) {
1826
- if (segs.length === 3) return `${segs[1]}::${segs[2]}`;
1827
- if (segs.length === 2) return `${segs[0]}::${segs[1]}`;
1828
- return void 0;
1829
- }
1830
- getForwardEdges(symbolId) {
1831
- return this.forwardEdges.get(symbolId) ?? [];
1832
- }
1833
- getReverseEdges(symbolId) {
1834
- return this.reverseEdges.get(symbolId) ?? [];
1835
- }
1836
- /**
1837
- * The dependents of a resolved symbolId, as the impact tools read them. For a definition of a table, view
1838
- * or procedure these are the incoming edges of every same-name definition, which in practice are the
1839
- * winner's: any copy then reports the dependents of the resource. For every other id they are its own
1840
- * incoming edges, as `getReverseEdges` gives them.
1841
- */
1842
- getDependentEdges(symbolId) {
1843
- const node = this.nodes.get(symbolId);
1844
- const definitions = node ? this.identityOf(node)?.definitions : void 0;
1845
- if (!definitions || definitions.length < 2) return this.getReverseEdges(symbolId);
1846
- return definitions.flatMap((definition) => this.getReverseEdges(definition.symbolId));
1847
- }
1848
- hasNode(symbolId) {
1849
- return this.nodes.has(symbolId) || this.resolveNodeFuzzy(symbolId) !== void 0;
1850
- }
1851
- get nodeCount() {
1852
- return this.nodes.size;
1853
- }
1854
- get edgeCount() {
1855
- return this.edgeSet.size;
1856
- }
1857
- allNodes() {
1858
- return [...this.nodes.values()];
1859
- }
1860
- allEdges() {
1861
- const edges = [];
1862
- for (const list of this.forwardEdges.values()) {
1863
- edges.push(...list);
1864
- }
1865
- return edges;
1039
+ log5.warn(`${this.filePath} ${problem}; ${consequence}`);
1866
1040
  }
1867
1041
  };
1868
1042
 
@@ -2021,26 +1195,128 @@ var DriftDetector = class {
2021
1195
  mapping.set(prevId, bestCurrentId);
2022
1196
  }
2023
1197
  }
2024
- return mapping;
1198
+ return mapping;
1199
+ }
1200
+ };
1201
+ function indexByCommunityId(entries) {
1202
+ const index = /* @__PURE__ */ new Map();
1203
+ for (const entry of entries) {
1204
+ const bucket = index.get(entry.communityId) ?? /* @__PURE__ */ new Set();
1205
+ bucket.add(entry.symbolId);
1206
+ index.set(entry.communityId, bucket);
1207
+ }
1208
+ return index;
1209
+ }
1210
+ function assignmentMap(entries) {
1211
+ const map = /* @__PURE__ */ new Map();
1212
+ for (const entry of entries) {
1213
+ map.set(entry.symbolId, entry.communityId);
1214
+ }
1215
+ return map;
1216
+ }
1217
+ function labelMap(entries) {
1218
+ const labels = /* @__PURE__ */ new Map();
1219
+ for (const entry of entries) {
1220
+ if (!labels.has(entry.communityId)) {
1221
+ labels.set(entry.communityId, entry.communityLabel);
1222
+ }
1223
+ }
1224
+ return labels;
1225
+ }
1226
+ function jaccard(a, b) {
1227
+ if (a.size === 0 && b.size === 0) return 1;
1228
+ let intersection = 0;
1229
+ for (const x of a) if (b.has(x)) intersection++;
1230
+ const union = a.size + b.size - intersection;
1231
+ return union === 0 ? 0 : intersection / union;
1232
+ }
1233
+ function confidenceFor(distinctCommits) {
1234
+ if (distinctCommits >= 7) return "high";
1235
+ if (distinctCommits >= 3) return "medium";
1236
+ return "low";
1237
+ }
1238
+ function hintFor(distinctCommits) {
1239
+ if (distinctCommits >= 3) return void 0;
1240
+ return "Install the post-commit hook (`ctxo init`) or run `ctxo index` after commits to build history; `ctxo index --check` does not record snapshots.";
1241
+ }
1242
+
1243
+ // src/core/overlay/boundary-violation-detector.ts
1244
+ var HIGH_SEVERITY_THRESHOLD = 0;
1245
+ var MEDIUM_SEVERITY_THRESHOLD = 2;
1246
+ var MIN_DISTINCT_COMMITS_FOR_CONFIDENCE = 2;
1247
+ var BoundaryViolationDetector = class {
1248
+ detect(graph, current, history) {
1249
+ const snapshotsAvailable = history.length;
1250
+ const distinctCommits = distinctCommitCount(history, current.commitSha);
1251
+ const currentAssignments = assignmentMap2(current.communities);
1252
+ const currentLabels = labelMap2(current.communities);
1253
+ const earlierCommits = priorCommitSnapshots(history, current.commitSha);
1254
+ const historicalAssignments = earlierCommits.map((snapshot) => ({
1255
+ snapshot,
1256
+ assignments: assignmentMap2(snapshot.communities),
1257
+ labels: labelMap2(snapshot.communities)
1258
+ }));
1259
+ const violations = [];
1260
+ for (const edge of graph.allEdges()) {
1261
+ if (edge.from === edge.to) continue;
1262
+ if (DATA_EDGE_KINDS.has(edge.kind)) continue;
1263
+ const fromCommunity = currentAssignments.get(edge.from);
1264
+ const toCommunity = currentAssignments.get(edge.to);
1265
+ if (fromCommunity === void 0 || toCommunity === void 0) continue;
1266
+ if (fromCommunity === toCommunity) continue;
1267
+ const fromLabel = currentLabels.get(fromCommunity) ?? `community-${fromCommunity}`;
1268
+ const toLabel = currentLabels.get(toCommunity) ?? `community-${toCommunity}`;
1269
+ const historicalCount = distinctCommits < MIN_DISTINCT_COMMITS_FOR_CONFIDENCE ? -1 : countHistoricalCrossCluster(historicalAssignments, edge.from, edge.to);
1270
+ if (historicalCount === -1 || historicalCount <= MEDIUM_SEVERITY_THRESHOLD) {
1271
+ violations.push({
1272
+ from: {
1273
+ symbolId: edge.from,
1274
+ communityId: fromCommunity,
1275
+ label: fromLabel
1276
+ },
1277
+ to: {
1278
+ symbolId: edge.to,
1279
+ communityId: toCommunity,
1280
+ label: toLabel
1281
+ },
1282
+ edgeKind: edge.kind,
1283
+ historicalEdgesBetweenClusters: historicalCount,
1284
+ severity: historicalCount === HIGH_SEVERITY_THRESHOLD ? "high" : "medium"
1285
+ });
1286
+ }
1287
+ }
1288
+ violations.sort((a, b) => {
1289
+ if (a.severity !== b.severity) return a.severity === "high" ? -1 : 1;
1290
+ const cmp = compareCodepoint(a.from.symbolId, b.from.symbolId);
1291
+ return cmp !== 0 ? cmp : compareCodepoint(a.to.symbolId, b.to.symbolId);
1292
+ });
1293
+ return {
1294
+ violations,
1295
+ confidence: confidenceFor2(distinctCommits),
1296
+ snapshotsAvailable,
1297
+ distinctCommits,
1298
+ hint: hintFor2(distinctCommits)
1299
+ };
2025
1300
  }
2026
1301
  };
2027
- function indexByCommunityId(entries) {
2028
- const index = /* @__PURE__ */ new Map();
2029
- for (const entry of entries) {
2030
- const bucket = index.get(entry.communityId) ?? /* @__PURE__ */ new Set();
2031
- bucket.add(entry.symbolId);
2032
- index.set(entry.communityId, bucket);
1302
+ function countHistoricalCrossCluster(history, fromSymbol, toSymbol) {
1303
+ let count2 = 0;
1304
+ for (const snap of history) {
1305
+ const fromId = snap.assignments.get(fromSymbol);
1306
+ const toId = snap.assignments.get(toSymbol);
1307
+ if (fromId === void 0 || toId === void 0) continue;
1308
+ if (fromId !== toId) count2++;
2033
1309
  }
2034
- return index;
1310
+ return count2;
2035
1311
  }
2036
- function assignmentMap(entries) {
1312
+ function assignmentMap2(entries) {
2037
1313
  const map = /* @__PURE__ */ new Map();
2038
1314
  for (const entry of entries) {
2039
1315
  map.set(entry.symbolId, entry.communityId);
2040
1316
  }
2041
1317
  return map;
2042
1318
  }
2043
- function labelMap(entries) {
1319
+ function labelMap2(entries) {
2044
1320
  const labels = /* @__PURE__ */ new Map();
2045
1321
  for (const entry of entries) {
2046
1322
  if (!labels.has(entry.communityId)) {
@@ -2049,200 +1325,96 @@ function labelMap(entries) {
2049
1325
  }
2050
1326
  return labels;
2051
1327
  }
2052
- function jaccard(a, b) {
2053
- if (a.size === 0 && b.size === 0) return 1;
2054
- let intersection = 0;
2055
- for (const x of a) if (b.has(x)) intersection++;
2056
- const union = a.size + b.size - intersection;
2057
- return union === 0 ? 0 : intersection / union;
2058
- }
2059
- function confidenceFor(distinctCommits) {
1328
+ function confidenceFor2(distinctCommits) {
2060
1329
  if (distinctCommits >= 7) return "high";
2061
1330
  if (distinctCommits >= 3) return "medium";
2062
1331
  return "low";
2063
1332
  }
2064
- function hintFor(distinctCommits) {
2065
- if (distinctCommits >= 3) return void 0;
1333
+ function hintFor2(distinctCommits) {
1334
+ if (distinctCommits >= MIN_DISTINCT_COMMITS_FOR_CONFIDENCE) return void 0;
2066
1335
  return "Install the post-commit hook (`ctxo init`) or run `ctxo index` after commits to build history; `ctxo index --check` does not record snapshots.";
2067
1336
  }
2068
1337
 
2069
- // src/core/blast-radius/blast-radius-calculator.ts
2070
- var EDGE_KIND_CONFIDENCE = {
2071
- calls: "confirmed",
2072
- extends: "confirmed",
2073
- implements: "confirmed",
2074
- reads: "confirmed",
2075
- writes: "confirmed",
2076
- executes: "confirmed",
2077
- uses: "likely",
2078
- imports: "potential"
2079
- };
2080
- var CONFIDENCE_RANK = {
2081
- confirmed: 2,
2082
- likely: 1,
2083
- potential: 0
2084
- };
2085
- function edgeKindToConfidence(kind) {
2086
- return EDGE_KINDS.includes(kind) ? EDGE_KIND_CONFIDENCE[kind] : "potential";
2087
- }
2088
- var BlastRadiusCalculator = class {
2089
- /**
2090
- * `symbolId` may be any id `SymbolGraph.getNode` resolves, a 2-part data id included. The root is the
2091
- * node it names, and the root's own file is the one co-change is matched against. Dependents come from
2092
- * `getDependentEdges`, so every copy of a table, view or procedure, and its 2-part id, has the dependents
2093
- * of the resource, and no definition of the resource counts as a dependent of another.
2094
- */
2095
- calculate(graph, symbolId, coChangeMap) {
2096
- const root = graph.getNode(symbolId);
2097
- if (!root) {
2098
- return { impactedSymbols: [], directDependentsCount: 0, confirmedCount: 0, likelyCount: 0, potentialCount: 0, overallRiskScore: 0 };
1338
+ // src/core/importance/pagerank-calculator.ts
1339
+ var DEFAULT_DAMPING = 0.85;
1340
+ var DEFAULT_MAX_ITERATIONS = 100;
1341
+ var DEFAULT_TOLERANCE = 1e-6;
1342
+ var DEFAULT_LIMIT = 25;
1343
+ var PageRankCalculator = class {
1344
+ calculate(graph, options = {}) {
1345
+ const damping = options.damping ?? DEFAULT_DAMPING;
1346
+ const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
1347
+ const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
1348
+ const limit = options.limit ?? DEFAULT_LIMIT;
1349
+ const nodes = graph.allNodes();
1350
+ const n = nodes.length;
1351
+ if (n === 0) {
1352
+ return { rankings: [], totalSymbols: 0, iterations: 0, converged: true };
2099
1353
  }
2100
- const rootId = root.symbolId;
2101
- const sourceFile = rootId.split("::")[0];
2102
- const definitions = graph.dataResourceIdentity(rootId)?.definitions.map((definition) => definition.symbolId) ?? [];
2103
- const visited = /* @__PURE__ */ new Set([rootId, ...definitions]);
2104
- const rawEntries = [];
2105
- const queue = [{ id: rootId, depth: 0 }];
2106
- while (queue.length > 0) {
2107
- const current = queue.shift();
2108
- const reverseEdges = graph.getDependentEdges(current.id);
2109
- const bestByNode = /* @__PURE__ */ new Map();
2110
- for (const edge of reverseEdges) {
2111
- if (visited.has(edge.from)) continue;
2112
- const conf = edgeKindToConfidence(edge.kind);
2113
- const existing = bestByNode.get(edge.from);
2114
- if (!existing) {
2115
- bestByNode.set(edge.from, { confidence: conf, kinds: /* @__PURE__ */ new Set([edge.kind]) });
2116
- } else {
2117
- existing.kinds.add(edge.kind);
2118
- if (CONFIDENCE_RANK[conf] > CONFIDENCE_RANK[existing.confidence]) {
2119
- existing.confidence = conf;
2120
- }
2121
- }
2122
- }
2123
- for (const [nodeId, info] of bestByNode) {
2124
- visited.add(nodeId);
2125
- const depth = current.depth + 1;
2126
- const riskScore = 1 / Math.pow(depth, 0.7);
2127
- let confidence = info.confidence;
2128
- let coChangeFrequency;
2129
- if (coChangeMap) {
2130
- const nodeFile = nodeId.split("::")[0];
2131
- const coEntries = coChangeMap.get(sourceFile);
2132
- if (coEntries) {
2133
- const match = coEntries.find((e) => (e.file1 === sourceFile ? e.file2 : e.file1) === nodeFile);
2134
- if (match) {
2135
- coChangeFrequency = match.frequency;
2136
- if (confidence === "potential" && match.frequency > 0.5) {
2137
- confidence = "likely";
2138
- }
2139
- }
2140
- }
2141
- }
2142
- rawEntries.push({
2143
- symbolId: nodeId,
2144
- depth,
2145
- riskScore: Math.round(riskScore * 1e3) / 1e3,
2146
- confidence,
2147
- edgeKinds: [...info.kinds],
2148
- coChangeFrequency
2149
- });
2150
- queue.push({ id: nodeId, depth });
2151
- }
1354
+ const scores = /* @__PURE__ */ new Map();
1355
+ const initialScore = 1 / n;
1356
+ for (const node of nodes) {
1357
+ scores.set(node.symbolId, initialScore);
2152
1358
  }
2153
- const blastSet = /* @__PURE__ */ new Set([rootId, ...definitions, ...rawEntries.map((e) => e.symbolId)]);
2154
- const entries = rawEntries.map((e) => ({
2155
- ...e,
2156
- dependentCount: graph.getDependentEdges(e.symbolId).filter((re) => blastSet.has(re.from)).length
2157
- }));
2158
- entries.sort((a, b) => a.depth - b.depth);
2159
- const directDependentsCount = entries.filter((e) => e.depth === 1).length;
2160
- const totalRisk = entries.reduce((sum, e) => sum + e.riskScore, 0);
2161
- const overallRiskScore = Math.round(Math.min(totalRisk / Math.max(directDependentsCount, 1), 1) * 1e3) / 1e3;
2162
- const confirmedCount = entries.filter((e) => e.confidence === "confirmed").length;
2163
- const likelyCount = entries.filter((e) => e.confidence === "likely").length;
2164
- const potentialCount = entries.filter((e) => e.confidence === "potential").length;
2165
- return { impactedSymbols: entries, directDependentsCount, confirmedCount, likelyCount, potentialCount, overallRiskScore };
2166
- }
2167
- };
2168
-
2169
- // src/core/blast-radius/co-changed-files.ts
2170
- var DEFAULT_LIMIT = 10;
2171
- var DEFAULT_MIN_FREQUENCY = 0.5;
2172
- function findCoChangedFiles(coChangeMap, rootFile, impactedFiles, options = {}) {
2173
- const limit = options.limit ?? DEFAULT_LIMIT;
2174
- const minFrequency = options.minFrequency ?? DEFAULT_MIN_FREQUENCY;
2175
- const strongestByFile = /* @__PURE__ */ new Map();
2176
- for (const entry of coChangeMap.get(rootFile) ?? []) {
2177
- const file = entry.file1 === rootFile ? entry.file2 : entry.file1;
2178
- if (file === rootFile || impactedFiles.has(file) || entry.frequency < minFrequency) continue;
2179
- const candidate = { file, frequency: entry.frequency, sharedCommits: entry.sharedCommits };
2180
- const known = strongestByFile.get(file);
2181
- if (!known || compareStrongestFirst(candidate, known) < 0) strongestByFile.set(file, candidate);
2182
- }
2183
- return [...strongestByFile.values()].sort(compareStrongestFirst).slice(0, Math.max(0, limit));
2184
- }
2185
- function compareStrongestFirst(a, b) {
2186
- return b.frequency - a.frequency || b.sharedCommits - a.sharedCommits || compareCodepoint(a.file, b.file);
2187
- }
2188
- function findCoChangedFilesForBlastRadius(coChangeMap, rootSymbolId, impactedSymbols) {
2189
- if (!coChangeMap) return [];
2190
- const rootFile = fileOf(rootSymbolId);
2191
- const impactedFiles = /* @__PURE__ */ new Set([rootFile]);
2192
- for (const { symbolId } of impactedSymbols) impactedFiles.add(fileOf(symbolId));
2193
- return findCoChangedFiles(coChangeMap, rootFile, impactedFiles);
2194
- }
2195
- function fileOf(symbolId) {
2196
- return symbolId.split("::")[0];
2197
- }
2198
-
2199
- // src/core/overlay/architectural-overlay.ts
2200
- var DEFAULT_RULES = [
2201
- // Test layer (matched first — __tests__, .test.ts, tests/, fixtures)
2202
- { pattern: /__tests__/, layer: "Test" },
2203
- { pattern: /\.test\.ts$/, layer: "Test" },
2204
- { pattern: /\btests\b/, layer: "Test" },
2205
- { pattern: /\bfixtures?\b/, layer: "Test" },
2206
- // Composition root
2207
- { pattern: /src\/index\.ts$/, layer: "Composition" },
2208
- // Domain
2209
- { pattern: /\bcore\b/, layer: "Domain" },
2210
- { pattern: /\bports?\b/, layer: "Domain" },
2211
- // Adapter
2212
- { pattern: /\badapters?\b/, layer: "Adapter" },
2213
- { pattern: /\bcli\b/, layer: "Adapter" },
2214
- // Infrastructure
2215
- { pattern: /\binfra\b/, layer: "Infrastructure" },
2216
- { pattern: /\bdb\b/, layer: "Infrastructure" },
2217
- { pattern: /\bqueue\b/, layer: "Infrastructure" },
2218
- // Config files
2219
- { pattern: /\.(config|rc)\.(ts|js|json)$/, layer: "Configuration" }
2220
- ];
2221
- var ArchitecturalOverlay = class {
2222
- rules;
2223
- constructor(customRules) {
2224
- this.rules = (customRules ?? DEFAULT_RULES).map(({ pattern, layer }) => ({
2225
- pattern: new RegExp(pattern.source, pattern.flags),
2226
- layer
2227
- }));
2228
- }
2229
- classify(filePaths) {
2230
- const layers = {};
2231
- for (const filePath of filePaths) {
2232
- const layer = this.matchLayer(filePath);
2233
- const list = layers[layer] ?? [];
2234
- list.push(filePath);
2235
- layers[layer] = list;
1359
+ const outDegree = /* @__PURE__ */ new Map();
1360
+ for (const node of nodes) {
1361
+ outDegree.set(node.symbolId, graph.getForwardEdges(node.symbolId).length);
2236
1362
  }
2237
- return { layers };
2238
- }
2239
- matchLayer(filePath) {
2240
- for (const rule of this.rules) {
2241
- if (rule.pattern.test(filePath)) {
2242
- return rule.layer;
1363
+ let iterations = 0;
1364
+ let converged = false;
1365
+ while (iterations < maxIterations) {
1366
+ const newScores = /* @__PURE__ */ new Map();
1367
+ const base = (1 - damping) / n;
1368
+ let danglingSum = 0;
1369
+ for (const node of nodes) {
1370
+ if ((outDegree.get(node.symbolId) ?? 0) === 0) {
1371
+ danglingSum += scores.get(node.symbolId) ?? 0;
1372
+ }
1373
+ }
1374
+ const danglingContrib = damping * danglingSum / n;
1375
+ for (const node of nodes) {
1376
+ let incomingScore = 0;
1377
+ const reverseEdges = graph.getReverseEdges(node.symbolId);
1378
+ const contributors = /* @__PURE__ */ new Set();
1379
+ for (const edge of reverseEdges) {
1380
+ if (contributors.has(edge.from)) continue;
1381
+ contributors.add(edge.from);
1382
+ const fromScore = scores.get(edge.from) ?? 0;
1383
+ const fromOut = outDegree.get(edge.from) ?? 1;
1384
+ incomingScore += fromScore / fromOut;
1385
+ }
1386
+ newScores.set(node.symbolId, base + damping * incomingScore + danglingContrib);
1387
+ }
1388
+ let maxDelta = 0;
1389
+ for (const node of nodes) {
1390
+ const delta = Math.abs((newScores.get(node.symbolId) ?? 0) - (scores.get(node.symbolId) ?? 0));
1391
+ if (delta > maxDelta) maxDelta = delta;
1392
+ }
1393
+ for (const [id, score] of newScores) {
1394
+ scores.set(id, score);
1395
+ }
1396
+ iterations++;
1397
+ if (maxDelta < tolerance) {
1398
+ converged = true;
1399
+ break;
2243
1400
  }
2244
1401
  }
2245
- return "Unknown";
1402
+ const entries = nodes.map((node) => ({
1403
+ symbolId: node.symbolId,
1404
+ name: node.name,
1405
+ kind: node.kind,
1406
+ file: node.symbolId.split("::")[0] ?? "",
1407
+ score: Math.round((scores.get(node.symbolId) ?? 0) * 1e6) / 1e6,
1408
+ inDegree: graph.getReverseEdges(node.symbolId).length,
1409
+ outDegree: outDegree.get(node.symbolId) ?? 0
1410
+ }));
1411
+ entries.sort((a, b) => b.score - a.score);
1412
+ return {
1413
+ rankings: entries.slice(0, limit),
1414
+ totalSymbols: n,
1415
+ iterations,
1416
+ converged
1417
+ };
2246
1418
  }
2247
1419
  };
2248
1420
 
@@ -2403,456 +1575,262 @@ var DeadCodeDetector = class {
2403
1575
  return "Zero importers \u2014 no code references this symbol";
2404
1576
  case 0.9:
2405
1577
  return "Only referenced from test/config files";
2406
- case 0.7:
2407
- return "All importers are themselves dead (cascading)";
2408
- }
2409
- }
2410
- isFrameworkSymbol(name) {
2411
- return FRAMEWORK_PATTERNS.some((p) => p.test(name));
2412
- }
2413
- computeCascadeDepths(graph, deadIds, candidateIds) {
2414
- const depths = /* @__PURE__ */ new Map();
2415
- const rootDead = /* @__PURE__ */ new Set();
2416
- for (const id of deadIds) {
2417
- const reverseEdges = graph.getReverseEdges(id);
2418
- const deadImporters = reverseEdges.filter((e) => deadIds.has(e.from) && e.from !== id);
2419
- if (deadImporters.length === 0) {
2420
- rootDead.add(id);
2421
- depths.set(id, 0);
2422
- }
2423
- }
2424
- const queue = [...rootDead].map((id) => ({ id, depth: 0 }));
2425
- const visited = new Set(rootDead);
2426
- while (queue.length > 0) {
2427
- const current = queue.shift();
2428
- for (const edge of graph.getForwardEdges(current.id)) {
2429
- if (deadIds.has(edge.to) && !visited.has(edge.to) && candidateIds.has(edge.to)) {
2430
- const newDepth = current.depth + 1;
2431
- depths.set(edge.to, newDepth);
2432
- visited.add(edge.to);
2433
- queue.push({ id: edge.to, depth: newDepth });
2434
- }
2435
- }
2436
- }
2437
- return depths;
2438
- }
2439
- detectScaffolding(isExcluded, sourceContents) {
2440
- if (!sourceContents || sourceContents.size === 0) return [];
2441
- const results = [];
2442
- for (const [file, content] of sourceContents) {
2443
- if (isExcluded(file)) continue;
2444
- const lines = content.split("\n");
2445
- for (let i = 0; i < lines.length; i++) {
2446
- const line = lines[i];
2447
- for (const { regex, pattern } of SCAFFOLDING_PATTERNS) {
2448
- if (regex.test(line)) {
2449
- results.push({
2450
- file,
2451
- line: i + 1,
2452
- pattern,
2453
- text: line.trim().slice(0, 120)
2454
- });
2455
- break;
2456
- }
2457
- }
2458
- }
2459
- }
2460
- return results;
2461
- }
2462
- matchesAny(value, patterns) {
2463
- return patterns.some((p) => p.test(value));
2464
- }
2465
- };
2466
-
2467
- // src/core/importance/pagerank-calculator.ts
2468
- var DEFAULT_DAMPING = 0.85;
2469
- var DEFAULT_MAX_ITERATIONS = 100;
2470
- var DEFAULT_TOLERANCE = 1e-6;
2471
- var DEFAULT_LIMIT2 = 25;
2472
- var PageRankCalculator = class {
2473
- calculate(graph, options = {}) {
2474
- const damping = options.damping ?? DEFAULT_DAMPING;
2475
- const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
2476
- const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
2477
- const limit = options.limit ?? DEFAULT_LIMIT2;
2478
- const nodes = graph.allNodes();
2479
- const n = nodes.length;
2480
- if (n === 0) {
2481
- return { rankings: [], totalSymbols: 0, iterations: 0, converged: true };
2482
- }
2483
- const scores = /* @__PURE__ */ new Map();
2484
- const initialScore = 1 / n;
2485
- for (const node of nodes) {
2486
- scores.set(node.symbolId, initialScore);
2487
- }
2488
- const outDegree = /* @__PURE__ */ new Map();
2489
- for (const node of nodes) {
2490
- outDegree.set(node.symbolId, graph.getForwardEdges(node.symbolId).length);
2491
- }
2492
- let iterations = 0;
2493
- let converged = false;
2494
- while (iterations < maxIterations) {
2495
- const newScores = /* @__PURE__ */ new Map();
2496
- const base = (1 - damping) / n;
2497
- let danglingSum = 0;
2498
- for (const node of nodes) {
2499
- if ((outDegree.get(node.symbolId) ?? 0) === 0) {
2500
- danglingSum += scores.get(node.symbolId) ?? 0;
2501
- }
2502
- }
2503
- const danglingContrib = damping * danglingSum / n;
2504
- for (const node of nodes) {
2505
- let incomingScore = 0;
2506
- const reverseEdges = graph.getReverseEdges(node.symbolId);
2507
- const contributors = /* @__PURE__ */ new Set();
2508
- for (const edge of reverseEdges) {
2509
- if (contributors.has(edge.from)) continue;
2510
- contributors.add(edge.from);
2511
- const fromScore = scores.get(edge.from) ?? 0;
2512
- const fromOut = outDegree.get(edge.from) ?? 1;
2513
- incomingScore += fromScore / fromOut;
2514
- }
2515
- newScores.set(node.symbolId, base + damping * incomingScore + danglingContrib);
2516
- }
2517
- let maxDelta = 0;
2518
- for (const node of nodes) {
2519
- const delta = Math.abs((newScores.get(node.symbolId) ?? 0) - (scores.get(node.symbolId) ?? 0));
2520
- if (delta > maxDelta) maxDelta = delta;
2521
- }
2522
- for (const [id, score] of newScores) {
2523
- scores.set(id, score);
2524
- }
2525
- iterations++;
2526
- if (maxDelta < tolerance) {
2527
- converged = true;
2528
- break;
2529
- }
2530
- }
2531
- const entries = nodes.map((node) => ({
2532
- symbolId: node.symbolId,
2533
- name: node.name,
2534
- kind: node.kind,
2535
- file: node.symbolId.split("::")[0] ?? "",
2536
- score: Math.round((scores.get(node.symbolId) ?? 0) * 1e6) / 1e6,
2537
- inDegree: graph.getReverseEdges(node.symbolId).length,
2538
- outDegree: outDegree.get(node.symbolId) ?? 0
2539
- }));
2540
- entries.sort((a, b) => b.score - a.score);
2541
- return {
2542
- rankings: entries.slice(0, limit),
2543
- totalSymbols: n,
2544
- iterations,
2545
- converged
2546
- };
2547
- }
2548
- };
2549
-
2550
- // src/core/overlay/boundary-violation-detector.ts
2551
- var HIGH_SEVERITY_THRESHOLD = 0;
2552
- var MEDIUM_SEVERITY_THRESHOLD = 2;
2553
- var MIN_DISTINCT_COMMITS_FOR_CONFIDENCE = 2;
2554
- var BoundaryViolationDetector = class {
2555
- detect(graph, current, history) {
2556
- const snapshotsAvailable = history.length;
2557
- const distinctCommits = distinctCommitCount(history, current.commitSha);
2558
- const currentAssignments = assignmentMap2(current.communities);
2559
- const currentLabels = labelMap2(current.communities);
2560
- const earlierCommits = priorCommitSnapshots(history, current.commitSha);
2561
- const historicalAssignments = earlierCommits.map((snapshot) => ({
2562
- snapshot,
2563
- assignments: assignmentMap2(snapshot.communities),
2564
- labels: labelMap2(snapshot.communities)
2565
- }));
2566
- const violations = [];
2567
- for (const edge of graph.allEdges()) {
2568
- if (edge.from === edge.to) continue;
2569
- if (DATA_EDGE_KINDS.has(edge.kind)) continue;
2570
- const fromCommunity = currentAssignments.get(edge.from);
2571
- const toCommunity = currentAssignments.get(edge.to);
2572
- if (fromCommunity === void 0 || toCommunity === void 0) continue;
2573
- if (fromCommunity === toCommunity) continue;
2574
- const fromLabel = currentLabels.get(fromCommunity) ?? `community-${fromCommunity}`;
2575
- const toLabel = currentLabels.get(toCommunity) ?? `community-${toCommunity}`;
2576
- const historicalCount = distinctCommits < MIN_DISTINCT_COMMITS_FOR_CONFIDENCE ? -1 : countHistoricalCrossCluster(historicalAssignments, edge.from, edge.to);
2577
- if (historicalCount === -1 || historicalCount <= MEDIUM_SEVERITY_THRESHOLD) {
2578
- violations.push({
2579
- from: {
2580
- symbolId: edge.from,
2581
- communityId: fromCommunity,
2582
- label: fromLabel
2583
- },
2584
- to: {
2585
- symbolId: edge.to,
2586
- communityId: toCommunity,
2587
- label: toLabel
2588
- },
2589
- edgeKind: edge.kind,
2590
- historicalEdgesBetweenClusters: historicalCount,
2591
- severity: historicalCount === HIGH_SEVERITY_THRESHOLD ? "high" : "medium"
2592
- });
2593
- }
2594
- }
2595
- violations.sort((a, b) => {
2596
- if (a.severity !== b.severity) return a.severity === "high" ? -1 : 1;
2597
- const cmp = compareCodepoint(a.from.symbolId, b.from.symbolId);
2598
- return cmp !== 0 ? cmp : compareCodepoint(a.to.symbolId, b.to.symbolId);
2599
- });
2600
- return {
2601
- violations,
2602
- confidence: confidenceFor2(distinctCommits),
2603
- snapshotsAvailable,
2604
- distinctCommits,
2605
- hint: hintFor2(distinctCommits)
2606
- };
2607
- }
2608
- };
2609
- function countHistoricalCrossCluster(history, fromSymbol, toSymbol) {
2610
- let count2 = 0;
2611
- for (const snap of history) {
2612
- const fromId = snap.assignments.get(fromSymbol);
2613
- const toId = snap.assignments.get(toSymbol);
2614
- if (fromId === void 0 || toId === void 0) continue;
2615
- if (fromId !== toId) count2++;
2616
- }
2617
- return count2;
2618
- }
2619
- function assignmentMap2(entries) {
2620
- const map = /* @__PURE__ */ new Map();
2621
- for (const entry of entries) {
2622
- map.set(entry.symbolId, entry.communityId);
2623
- }
2624
- return map;
2625
- }
2626
- function labelMap2(entries) {
2627
- const labels = /* @__PURE__ */ new Map();
2628
- for (const entry of entries) {
2629
- if (!labels.has(entry.communityId)) {
2630
- labels.set(entry.communityId, entry.communityLabel);
1578
+ case 0.7:
1579
+ return "All importers are themselves dead (cascading)";
2631
1580
  }
2632
1581
  }
2633
- return labels;
2634
- }
2635
- function confidenceFor2(distinctCommits) {
2636
- if (distinctCommits >= 7) return "high";
2637
- if (distinctCommits >= 3) return "medium";
2638
- return "low";
2639
- }
2640
- function hintFor2(distinctCommits) {
2641
- if (distinctCommits >= MIN_DISTINCT_COMMITS_FOR_CONFIDENCE) return void 0;
2642
- return "Install the post-commit hook (`ctxo init`) or run `ctxo index` after commits to build history; `ctxo index --check` does not record snapshots.";
2643
- }
2644
-
2645
- // src/core/search/symbol-tokenizer.ts
2646
- var STOP_WORDS = /* @__PURE__ */ new Set([
2647
- "get",
2648
- "set",
2649
- "is",
2650
- "has",
2651
- "can",
2652
- "do",
2653
- "the",
2654
- "a",
2655
- "an",
2656
- "of",
2657
- "to",
2658
- "in",
2659
- "for",
2660
- "on",
2661
- "with",
2662
- "at",
2663
- "by",
2664
- "from",
2665
- "as",
2666
- "into",
2667
- "not",
2668
- "no",
2669
- "or"
2670
- ]);
2671
- var DEFAULT_OPTIONS = {
2672
- includeStopWords: false,
2673
- includeOriginal: true,
2674
- includeFilePath: false
2675
- };
2676
- var SymbolTokenizer = class _SymbolTokenizer {
2677
- options;
2678
- constructor(options) {
2679
- this.options = { ...DEFAULT_OPTIONS, ...options };
1582
+ isFrameworkSymbol(name) {
1583
+ return FRAMEWORK_PATTERNS.some((p) => p.test(name));
2680
1584
  }
2681
- /**
2682
- * Tokenize a symbol name into searchable tokens.
2683
- *
2684
- * Examples:
2685
- * "getCoChangeMetrics" → ["get", "co", "change", "metrics"] (or without stop words: ["co", "change", "metrics"])
2686
- * "SqliteStorageAdapter" → ["sqlite", "storage", "adapter"]
2687
- * "i_storage_port" → ["i", "storage", "port"]
2688
- * "BM25Scorer" → ["bm", "25", "scorer"]
2689
- * "DB_PASSWORD" → ["db", "password"]
2690
- */
2691
- tokenize(symbolName, filePath) {
2692
- const splitTokens = _SymbolTokenizer.splitName(symbolName);
2693
- const lowered = splitTokens.map((t) => t.toLowerCase());
2694
- const tokens = this.options.includeStopWords ? lowered : lowered.filter((t) => !STOP_WORDS.has(t));
2695
- if (this.options.includeOriginal) {
2696
- const original = symbolName.toLowerCase();
2697
- if (!tokens.includes(original)) {
2698
- tokens.unshift(original);
1585
+ computeCascadeDepths(graph, deadIds, candidateIds) {
1586
+ const depths = /* @__PURE__ */ new Map();
1587
+ const rootDead = /* @__PURE__ */ new Set();
1588
+ for (const id of deadIds) {
1589
+ const reverseEdges = graph.getReverseEdges(id);
1590
+ const deadImporters = reverseEdges.filter((e) => deadIds.has(e.from) && e.from !== id);
1591
+ if (deadImporters.length === 0) {
1592
+ rootDead.add(id);
1593
+ depths.set(id, 0);
2699
1594
  }
2700
1595
  }
2701
- if (this.options.includeFilePath && filePath) {
2702
- const pathTokens = _SymbolTokenizer.tokenizeFilePath(filePath);
2703
- for (const pt of pathTokens) {
2704
- if (!tokens.includes(pt)) {
2705
- tokens.push(pt);
1596
+ const queue = [...rootDead].map((id) => ({ id, depth: 0 }));
1597
+ const visited = new Set(rootDead);
1598
+ while (queue.length > 0) {
1599
+ const current = queue.shift();
1600
+ for (const edge of graph.getForwardEdges(current.id)) {
1601
+ if (deadIds.has(edge.to) && !visited.has(edge.to) && candidateIds.has(edge.to)) {
1602
+ const newDepth = current.depth + 1;
1603
+ depths.set(edge.to, newDepth);
1604
+ visited.add(edge.to);
1605
+ queue.push({ id: edge.to, depth: newDepth });
2706
1606
  }
2707
1607
  }
2708
1608
  }
2709
- return tokens;
1609
+ return depths;
2710
1610
  }
2711
- /**
2712
- * Tokenize a search query. Queries use stop words and don't include originals.
2713
- */
2714
- tokenizeQuery(query) {
2715
- const words = query.trim().split(/\s+/);
2716
- const tokens = [];
2717
- for (const word of words) {
2718
- const split = _SymbolTokenizer.splitName(word);
2719
- for (const t of split) {
2720
- const lowered = t.toLowerCase();
2721
- if (lowered.length > 0 && !tokens.includes(lowered)) {
2722
- tokens.push(lowered);
1611
+ detectScaffolding(isExcluded, sourceContents) {
1612
+ if (!sourceContents || sourceContents.size === 0) return [];
1613
+ const results = [];
1614
+ for (const [file, content] of sourceContents) {
1615
+ if (isExcluded(file)) continue;
1616
+ const lines = content.split("\n");
1617
+ for (let i = 0; i < lines.length; i++) {
1618
+ const line = lines[i];
1619
+ for (const { regex, pattern } of SCAFFOLDING_PATTERNS) {
1620
+ if (regex.test(line)) {
1621
+ results.push({
1622
+ file,
1623
+ line: i + 1,
1624
+ pattern,
1625
+ text: line.trim().slice(0, 120)
1626
+ });
1627
+ break;
1628
+ }
2723
1629
  }
2724
1630
  }
2725
1631
  }
2726
- return tokens;
1632
+ return results;
2727
1633
  }
2728
- /**
2729
- * Split a name into constituent words based on naming convention boundaries.
2730
- *
2731
- * Rules:
2732
- * 1. Split on underscores and hyphens (snake_case, kebab-case)
2733
- * 2. Split on camelCase/PascalCase boundaries (uppercase after lowercase)
2734
- * 3. Split acronym boundaries (e.g., "HTMLParser" → ["HTML", "Parser"])
2735
- * 4. Split on digit ↔ letter boundaries
2736
- */
2737
- static splitName(name) {
2738
- if (!name || name.length === 0) return [];
2739
- const segments = name.split(/[_\-./\\]+/).filter((s) => s.length > 0);
2740
- const tokens = [];
2741
- for (const segment of segments) {
2742
- tokens.push(..._SymbolTokenizer.splitCamelCase(segment));
1634
+ matchesAny(value, patterns) {
1635
+ return patterns.some((p) => p.test(value));
1636
+ }
1637
+ };
1638
+
1639
+ // src/core/overlay/architectural-overlay.ts
1640
+ var DEFAULT_RULES = [
1641
+ // Test layer (matched first — __tests__, .test.ts, tests/, fixtures)
1642
+ { pattern: /__tests__/, layer: "Test" },
1643
+ { pattern: /\.test\.ts$/, layer: "Test" },
1644
+ { pattern: /\btests\b/, layer: "Test" },
1645
+ { pattern: /\bfixtures?\b/, layer: "Test" },
1646
+ // Composition root
1647
+ { pattern: /src\/index\.ts$/, layer: "Composition" },
1648
+ // Domain
1649
+ { pattern: /\bcore\b/, layer: "Domain" },
1650
+ { pattern: /\bports?\b/, layer: "Domain" },
1651
+ // Adapter
1652
+ { pattern: /\badapters?\b/, layer: "Adapter" },
1653
+ { pattern: /\bcli\b/, layer: "Adapter" },
1654
+ // Infrastructure
1655
+ { pattern: /\binfra\b/, layer: "Infrastructure" },
1656
+ { pattern: /\bdb\b/, layer: "Infrastructure" },
1657
+ { pattern: /\bqueue\b/, layer: "Infrastructure" },
1658
+ // Config files
1659
+ { pattern: /\.(config|rc)\.(ts|js|json)$/, layer: "Configuration" }
1660
+ ];
1661
+ var ArchitecturalOverlay = class {
1662
+ rules;
1663
+ constructor(customRules) {
1664
+ this.rules = (customRules ?? DEFAULT_RULES).map(({ pattern, layer }) => ({
1665
+ pattern: new RegExp(pattern.source, pattern.flags),
1666
+ layer
1667
+ }));
1668
+ }
1669
+ classify(filePaths) {
1670
+ const layers = {};
1671
+ for (const filePath of filePaths) {
1672
+ const layer = this.matchLayer(filePath);
1673
+ const list = layers[layer] ?? [];
1674
+ list.push(filePath);
1675
+ layers[layer] = list;
2743
1676
  }
2744
- return tokens.filter((t) => t.length > 0);
1677
+ return { layers };
2745
1678
  }
2746
- /**
2747
- * Split a single segment on camelCase/PascalCase/digit boundaries.
2748
- */
2749
- static splitCamelCase(segment) {
2750
- const tokens = [];
2751
- let current = "";
2752
- for (let i = 0; i < segment.length; i++) {
2753
- const ch = segment[i];
2754
- const prev = i > 0 ? segment[i - 1] : "";
2755
- if (current.length > 0) {
2756
- const prevIsDigit = isDigit(prev);
2757
- const currIsDigit = isDigit(ch);
2758
- if (prevIsDigit !== currIsDigit) {
2759
- tokens.push(current);
2760
- current = ch;
2761
- continue;
2762
- }
2763
- }
2764
- if (current.length > 0 && isLower(prev) && isUpper(ch)) {
2765
- tokens.push(current);
2766
- current = ch;
2767
- continue;
2768
- }
2769
- if (current.length > 1 && isUpper(ch) === false && isUpper(prev) && !isDigit(ch)) {
2770
- const lastChar = current[current.length - 1];
2771
- if (isUpper(lastChar)) {
2772
- tokens.push(current.slice(0, -1));
2773
- current = lastChar + ch;
2774
- continue;
2775
- }
1679
+ matchLayer(filePath) {
1680
+ for (const rule of this.rules) {
1681
+ if (rule.pattern.test(filePath)) {
1682
+ return rule.layer;
2776
1683
  }
2777
- current += ch;
2778
1684
  }
2779
- if (current.length > 0) {
2780
- tokens.push(current);
2781
- }
2782
- return tokens;
1685
+ return "Unknown";
2783
1686
  }
1687
+ };
1688
+
1689
+ // src/core/blast-radius/blast-radius-calculator.ts
1690
+ var EDGE_KIND_CONFIDENCE = {
1691
+ calls: "confirmed",
1692
+ extends: "confirmed",
1693
+ implements: "confirmed",
1694
+ reads: "confirmed",
1695
+ writes: "confirmed",
1696
+ executes: "confirmed",
1697
+ uses: "likely",
1698
+ imports: "potential"
1699
+ };
1700
+ var CONFIDENCE_RANK = {
1701
+ confirmed: 2,
1702
+ likely: 1,
1703
+ potential: 0
1704
+ };
1705
+ function edgeKindToConfidence(kind) {
1706
+ return EDGE_KINDS.includes(kind) ? EDGE_KIND_CONFIDENCE[kind] : "potential";
1707
+ }
1708
+ var BlastRadiusCalculator = class {
2784
1709
  /**
2785
- * Extract searchable tokens from a file path.
2786
- * "src/core/search/symbol-tokenizer.ts" → ["src", "core", "search", "symbol", "tokenizer", "ts"]
1710
+ * `symbolId` may be any id `SymbolGraph.getNode` resolves, a 2-part data id included. The root is the
1711
+ * node it names, and the root's own file is the one co-change is matched against. Dependents come from
1712
+ * `getDependentEdges`, so every copy of a table, view or procedure, and its 2-part id, has the dependents
1713
+ * of the resource, and no definition of the resource counts as a dependent of another.
2787
1714
  */
2788
- static tokenizeFilePath(filePath) {
2789
- const withoutExt = filePath.replace(/\.[^.]+$/, "");
2790
- const segments = withoutExt.split(/[/\\]+/);
2791
- const tokens = [];
2792
- for (const seg of segments) {
2793
- const parts = seg.split(/[_\-.]+/).filter((s) => s.length > 0);
2794
- for (const p of parts) {
2795
- const lowered = p.toLowerCase();
2796
- if (lowered.length > 0 && !tokens.includes(lowered)) {
2797
- tokens.push(lowered);
1715
+ calculate(graph, symbolId, coChangeMap) {
1716
+ const root = graph.getNode(symbolId);
1717
+ if (!root) {
1718
+ return { impactedSymbols: [], directDependentsCount: 0, confirmedCount: 0, likelyCount: 0, potentialCount: 0, overallRiskScore: 0 };
1719
+ }
1720
+ const rootId = root.symbolId;
1721
+ const sourceFile = rootId.split("::")[0];
1722
+ const definitions = graph.dataResourceIdentity(rootId)?.definitions.map((definition) => definition.symbolId) ?? [];
1723
+ const visited = /* @__PURE__ */ new Set([rootId, ...definitions]);
1724
+ const rawEntries = [];
1725
+ const queue = [{ id: rootId, depth: 0 }];
1726
+ while (queue.length > 0) {
1727
+ const current = queue.shift();
1728
+ const reverseEdges = graph.getDependentEdges(current.id);
1729
+ const bestByNode = /* @__PURE__ */ new Map();
1730
+ for (const edge of reverseEdges) {
1731
+ if (visited.has(edge.from)) continue;
1732
+ const conf = edgeKindToConfidence(edge.kind);
1733
+ const existing = bestByNode.get(edge.from);
1734
+ if (!existing) {
1735
+ bestByNode.set(edge.from, { confidence: conf, kinds: /* @__PURE__ */ new Set([edge.kind]) });
1736
+ } else {
1737
+ existing.kinds.add(edge.kind);
1738
+ if (CONFIDENCE_RANK[conf] > CONFIDENCE_RANK[existing.confidence]) {
1739
+ existing.confidence = conf;
1740
+ }
1741
+ }
1742
+ }
1743
+ for (const [nodeId, info] of bestByNode) {
1744
+ visited.add(nodeId);
1745
+ const depth = current.depth + 1;
1746
+ const riskScore = 1 / Math.pow(depth, 0.7);
1747
+ let confidence = info.confidence;
1748
+ let coChangeFrequency;
1749
+ if (coChangeMap) {
1750
+ const nodeFile = nodeId.split("::")[0];
1751
+ const coEntries = coChangeMap.get(sourceFile);
1752
+ if (coEntries) {
1753
+ const match = coEntries.find((e) => (e.file1 === sourceFile ? e.file2 : e.file1) === nodeFile);
1754
+ if (match) {
1755
+ coChangeFrequency = match.frequency;
1756
+ if (confidence === "potential" && match.frequency > 0.5) {
1757
+ confidence = "likely";
1758
+ }
1759
+ }
1760
+ }
2798
1761
  }
1762
+ rawEntries.push({
1763
+ symbolId: nodeId,
1764
+ depth,
1765
+ riskScore: Math.round(riskScore * 1e3) / 1e3,
1766
+ confidence,
1767
+ edgeKinds: [...info.kinds],
1768
+ coChangeFrequency
1769
+ });
1770
+ queue.push({ id: nodeId, depth });
2799
1771
  }
2800
1772
  }
2801
- return tokens;
2802
- }
2803
- /** Check if a token is a stop word */
2804
- static isStopWord(token) {
2805
- return STOP_WORDS.has(token.toLowerCase());
1773
+ const blastSet = /* @__PURE__ */ new Set([rootId, ...definitions, ...rawEntries.map((e) => e.symbolId)]);
1774
+ const entries = rawEntries.map((e) => ({
1775
+ ...e,
1776
+ dependentCount: graph.getDependentEdges(e.symbolId).filter((re) => blastSet.has(re.from)).length
1777
+ }));
1778
+ entries.sort((a, b) => a.depth - b.depth);
1779
+ const directDependentsCount = entries.filter((e) => e.depth === 1).length;
1780
+ const totalRisk = entries.reduce((sum, e) => sum + e.riskScore, 0);
1781
+ const overallRiskScore = Math.round(Math.min(totalRisk / Math.max(directDependentsCount, 1), 1) * 1e3) / 1e3;
1782
+ const confirmedCount = entries.filter((e) => e.confidence === "confirmed").length;
1783
+ const likelyCount = entries.filter((e) => e.confidence === "likely").length;
1784
+ const potentialCount = entries.filter((e) => e.confidence === "potential").length;
1785
+ return { impactedSymbols: entries, directDependentsCount, confirmedCount, likelyCount, potentialCount, overallRiskScore };
2806
1786
  }
2807
1787
  };
2808
- function isUpper(ch) {
2809
- return ch >= "A" && ch <= "Z";
1788
+
1789
+ // src/core/blast-radius/co-changed-files.ts
1790
+ var DEFAULT_LIMIT2 = 10;
1791
+ var DEFAULT_MIN_FREQUENCY = 0.5;
1792
+ function findCoChangedFiles(coChangeMap, rootFile, impactedFiles, options = {}) {
1793
+ const limit = options.limit ?? DEFAULT_LIMIT2;
1794
+ const minFrequency = options.minFrequency ?? DEFAULT_MIN_FREQUENCY;
1795
+ const strongestByFile = /* @__PURE__ */ new Map();
1796
+ for (const entry of coChangeMap.get(rootFile) ?? []) {
1797
+ const file = entry.file1 === rootFile ? entry.file2 : entry.file1;
1798
+ if (file === rootFile || impactedFiles.has(file) || entry.frequency < minFrequency) continue;
1799
+ const candidate = { file, frequency: entry.frequency, sharedCommits: entry.sharedCommits };
1800
+ const known = strongestByFile.get(file);
1801
+ if (!known || compareStrongestFirst(candidate, known) < 0) strongestByFile.set(file, candidate);
1802
+ }
1803
+ return [...strongestByFile.values()].sort(compareStrongestFirst).slice(0, Math.max(0, limit));
1804
+ }
1805
+ function compareStrongestFirst(a, b) {
1806
+ return b.frequency - a.frequency || b.sharedCommits - a.sharedCommits || compareCodepoint(a.file, b.file);
2810
1807
  }
2811
- function isLower(ch) {
2812
- return ch >= "a" && ch <= "z";
1808
+ function findCoChangedFilesForBlastRadius(coChangeMap, rootSymbolId, impactedSymbols) {
1809
+ if (!coChangeMap) return [];
1810
+ const rootFile = fileOf(rootSymbolId);
1811
+ const impactedFiles = /* @__PURE__ */ new Set([rootFile]);
1812
+ for (const { symbolId } of impactedSymbols) impactedFiles.add(fileOf(symbolId));
1813
+ return findCoChangedFiles(coChangeMap, rootFile, impactedFiles);
2813
1814
  }
2814
- function isDigit(ch) {
2815
- return ch >= "0" && ch <= "9";
1815
+ function fileOf(symbolId) {
1816
+ return symbolId.split("::")[0];
2816
1817
  }
2817
1818
 
2818
1819
  export {
2819
- compareCodepoint,
2820
- CommunitySnapshotWriter,
2821
- SqliteStorageAdapter,
1820
+ detectWorkspace,
2822
1821
  buildCoChangeMatrix,
2823
1822
  CoChangeReader,
2824
- DATA_EDGE_KINDS,
2825
- isDataResourceKind,
2826
- parseCoChangeSince,
2827
- sinceCutoff,
2828
- selectWindow,
2829
- loadConfig,
2830
- statsEnabled,
2831
- indexIgnorePatterns,
2832
- indexIgnoreProjectPatterns,
2833
- factsRequested,
2834
- indexTestPatterns,
2835
- communityDetectorOptions,
2836
- maskingClusterLabelPatterns,
2837
- watchSnapshotMinFileChanges,
2838
- resolveCoChangeConfig,
2839
- resolveContextsViewConfig,
2840
- makeGlobMatcher,
2841
- filesWithTestAnnotations,
2842
- createTestFileClassifier,
2843
- SymbolGraph,
1823
+ CommunitySnapshotWriter,
1824
+ SqliteStorageAdapter,
2844
1825
  RevertDetector,
2845
1826
  DriftDetector,
2846
- SimpleGitAdapter,
2847
- BlastRadiusCalculator,
2848
- findCoChangedFilesForBlastRadius,
2849
- ArchitecturalOverlay,
2850
- ContextsViewReader,
2851
- DeadCodeDetector,
2852
- SymbolTokenizer,
2853
- PageRankCalculator,
2854
1827
  BoundaryViolationDetector,
1828
+ PageRankCalculator,
2855
1829
  SessionRecorderAdapter,
2856
- detectWorkspace
1830
+ DeadCodeDetector,
1831
+ ArchitecturalOverlay,
1832
+ BlastRadiusCalculator,
1833
+ findCoChangedFilesForBlastRadius,
1834
+ ContextsViewReader
2857
1835
  };
2858
- //# sourceMappingURL=chunk-WB7WZKX4.js.map
1836
+ //# sourceMappingURL=chunk-A4URKKPP.js.map