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