@ctxo/cli 0.11.5 → 0.13.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.
@@ -3,8 +3,11 @@ import {
3
3
  atomicWrite
4
4
  } from "./chunk-RFEXMBTG.js";
5
5
  import {
6
+ CoChangeMatrixSchema,
7
+ EDGE_KINDS,
8
+ EdgeKindSchema,
6
9
  JsonIndexReader
7
- } from "./chunk-CDGQGCVN.js";
10
+ } from "./chunk-DT3ZH4AU.js";
8
11
  import {
9
12
  createLogger
10
13
  } from "./chunk-5DSKZJNN.js";
@@ -24,11 +27,19 @@ import {
24
27
  } from "fs";
25
28
  import { tmpdir } from "os";
26
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
27
37
  var log = createLogger("ctxo:community-snapshot");
28
38
  var DEFAULT_HISTORY_LIMIT = 10;
29
39
  var HISTORY_DIR_NAME = "communities.history";
30
40
  var CURRENT_FILE_NAME = "communities.json";
31
41
  var NOCOMMIT_SHA = "nocommit";
42
+ var COLLISION_COUNTER_RE = /^(.*)\.(\d+)\.json$/;
32
43
  var CommunitySnapshotWriter = class {
33
44
  indexDir;
34
45
  historyDir;
@@ -48,7 +59,8 @@ var CommunitySnapshotWriter = class {
48
59
  try {
49
60
  const existingRaw = readFileSync(currentPath, "utf-8");
50
61
  const existing = JSON.parse(existingRaw);
51
- if (existing.commitSha && existing.commitSha !== NOCOMMIT_SHA) {
62
+ const existingCommit = archivedCommit(existing);
63
+ if (existingCommit !== void 0 && existingCommit !== snapshot.commitSha) {
52
64
  const historyFile = this.historyFilename(existing);
53
65
  this.atomicWrite(join(this.historyDir, historyFile), existingRaw);
54
66
  }
@@ -65,14 +77,23 @@ var CommunitySnapshotWriter = class {
65
77
  if (!existsSync(path)) return void 0;
66
78
  return this.safeParse(path);
67
79
  }
80
+ /**
81
+ * Archived snapshots, newest first, at most one per commit. Where several
82
+ * files hold the same commitSha (older versions archived once per
83
+ * `ctxo index --file` run) the newest file wins. `limit` caps the number of
84
+ * commits returned. Read-only: the duplicates stay on disk until the next
85
+ * write rotates them out.
86
+ */
68
87
  listHistory(limit) {
69
88
  if (!existsSync(this.historyDir)) return [];
70
- const entries = readdirSync(this.historyDir).filter((name) => name.endsWith(".json")).sort().reverse();
71
89
  const snapshots = [];
72
- for (const name of entries) {
90
+ const seen = /* @__PURE__ */ new Set();
91
+ for (const name of this.historyNames().reverse()) {
73
92
  const snap = this.safeParse(join(this.historyDir, name));
74
- if (!snap) continue;
75
- if (!snap.commitSha || snap.commitSha === NOCOMMIT_SHA) continue;
93
+ const commit = archivedCommit(snap);
94
+ if (!snap || commit === void 0) continue;
95
+ if (seen.has(commit)) continue;
96
+ seen.add(commit);
76
97
  snapshots.push(snap);
77
98
  if (typeof limit === "number" && snapshots.length >= limit) break;
78
99
  }
@@ -93,13 +114,39 @@ var CommunitySnapshotWriter = class {
93
114
  }
94
115
  return candidate.slice(this.historyDir.length + 1);
95
116
  }
117
+ /** The `.json` files in the history folder, oldest first. */
118
+ historyNames() {
119
+ return readdirSync(this.historyDir).filter((name) => name.endsWith(".json")).sort(compareHistoryNames);
120
+ }
121
+ /**
122
+ * Rotation in two steps. First the duplicates go: every file that is not
123
+ * the newest readable file of its commit (the copies older versions left
124
+ * behind, one per `ctxo index --file` run). Then, by file count, the oldest
125
+ * files beyond `historyLimit`. Copies therefore never use up slots, history
126
+ * keeps each commit at most once, and a commit's newest file is removed
127
+ * only together with its commit, when that is among the oldest beyond the
128
+ * limit. Files that stand for no commit (unreadable, 'nocommit') are not
129
+ * duplicates of anything and age out by count like the rest.
130
+ */
96
131
  rotateHistory() {
97
132
  if (!existsSync(this.historyDir)) return;
98
- const entries = readdirSync(this.historyDir).filter((name) => name.endsWith(".json")).sort();
99
- const excess = entries.length - this.historyLimit;
100
- if (excess <= 0) return;
101
- for (let i = 0; i < excess; i++) {
102
- const target = join(this.historyDir, entries[i]);
133
+ const seen = /* @__PURE__ */ new Set();
134
+ const unique = [];
135
+ const doomed = [];
136
+ for (const name of this.historyNames().reverse()) {
137
+ const commit = archivedCommit(this.safeParse(join(this.historyDir, name)));
138
+ if (commit !== void 0) {
139
+ if (seen.has(commit)) {
140
+ doomed.push(name);
141
+ continue;
142
+ }
143
+ seen.add(commit);
144
+ }
145
+ unique.push(name);
146
+ }
147
+ doomed.push(...unique.slice(this.historyLimit));
148
+ for (const name of doomed) {
149
+ const target = join(this.historyDir, name);
103
150
  try {
104
151
  unlinkSync(target);
105
152
  } catch (err) {
@@ -117,6 +164,19 @@ var CommunitySnapshotWriter = class {
117
164
  }
118
165
  }
119
166
  };
167
+ function archivedCommit(snapshot) {
168
+ const sha = snapshot?.commitSha;
169
+ return sha && sha !== NOCOMMIT_SHA ? sha : void 0;
170
+ }
171
+ function compareHistoryNames(a, b) {
172
+ const [baseA, counterA] = splitCollisionCounter(a);
173
+ const [baseB, counterB] = splitCollisionCounter(b);
174
+ return compareCodepoint(baseA, baseB) || counterA - counterB;
175
+ }
176
+ function splitCollisionCounter(name) {
177
+ const match = COLLISION_COUNTER_RE.exec(name);
178
+ return match ? [`${match[1]}.json`, Number(match[2])] : [name, 0];
179
+ }
120
180
  function assertSafeWritePath(ctxoRoot, allowProduction) {
121
181
  const resolved = resolve(ctxoRoot);
122
182
  const tmpRoot = resolve(tmpdir());
@@ -474,14 +534,593 @@ var SqliteStorageAdapter = class {
474
534
  }
475
535
  };
476
536
 
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
+ }
581
+ }
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);
599
+ }
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);
612
+ }
613
+ list1.push(entry);
614
+ let list2 = map.get(entry.file2);
615
+ if (!list2) {
616
+ list2 = [];
617
+ map.set(entry.file2, list2);
618
+ }
619
+ list2.push(entry);
620
+ }
621
+ return map;
622
+ }
623
+
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 {
629
+ filePath;
630
+ cached;
631
+ /** What the last warning was about, so one problem is reported once. */
632
+ lastWarning;
633
+ constructor(ctxoRoot) {
634
+ this.filePath = join3(ctxoRoot, "index", "co-changes.json");
635
+ }
636
+ readCoChangeMap() {
637
+ let stats;
638
+ try {
639
+ stats = statSync(this.filePath);
640
+ } catch (err) {
641
+ this.cached = void 0;
642
+ const { code, message } = err;
643
+ if (code === "ENOENT") {
644
+ this.lastWarning = void 0;
645
+ } else {
646
+ this.warnOnce(`stat ${code ?? message}`, `cannot be inspected (${message})`, UNTIL_RETRY);
647
+ }
648
+ return void 0;
649
+ }
650
+ const version = `${stats.mtimeMs}:${stats.size}`;
651
+ if (this.cached?.version === version) return this.cached.map;
652
+ return this.load(version);
653
+ }
654
+ load(version) {
655
+ let raw;
656
+ try {
657
+ raw = readFileSync3(this.filePath, "utf-8");
658
+ } catch (err) {
659
+ this.cached = void 0;
660
+ this.warnOnce(`read ${version}`, `cannot be read (${err.message})`, UNTIL_RETRY);
661
+ return void 0;
662
+ }
663
+ const map = this.parse(raw, version);
664
+ this.cached = { version, map };
665
+ if (map) this.lastWarning = void 0;
666
+ return map;
667
+ }
668
+ parse(raw, version) {
669
+ let data;
670
+ try {
671
+ data = JSON.parse(raw);
672
+ } catch (err) {
673
+ return this.unusable(version, `is not valid JSON (${err.message})`);
674
+ }
675
+ const parsed = CoChangeMatrixSchema.safeParse(data);
676
+ if (!parsed.success) {
677
+ const issue = parsed.error.issues[0];
678
+ 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"})`);
680
+ }
681
+ return freezeEntries(loadCoChangeMap(parsed.data));
682
+ }
683
+ unusable(version, problem) {
684
+ this.warnOnce(`content ${version}`, problem, UNTIL_REWRITTEN);
685
+ return void 0;
686
+ }
687
+ warnOnce(key, problem, consequence) {
688
+ if (this.lastWarning === key) return;
689
+ 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
+
477
1046
  // src/adapters/git/simple-git-adapter.ts
1047
+ import { spawnSync } from "child_process";
478
1048
  import { simpleGit } from "simple-git";
479
- var log3 = createLogger("ctxo: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
+ }
480
1117
  var SimpleGitAdapter = class {
481
1118
  git;
1119
+ projectRoot;
482
1120
  constructor(projectRoot) {
1121
+ this.projectRoot = projectRoot;
483
1122
  this.git = simpleGit(projectRoot);
484
- log3.debug("Initialized with root: %s", projectRoot);
1123
+ log5.debug("Initialized with root: %s", projectRoot);
485
1124
  }
486
1125
  async isAvailable() {
487
1126
  try {
@@ -493,97 +1132,311 @@ var SimpleGitAdapter = class {
493
1132
  }
494
1133
  async getCommitHistory(filePath, maxCount) {
495
1134
  try {
496
- const log6 = await this.git.log({
1135
+ const log8 = await this.git.log({
497
1136
  file: filePath,
498
1137
  "--follow": null,
499
1138
  ...maxCount ? { maxCount } : {}
500
1139
  });
501
- return log6.all.map((entry) => ({
1140
+ return log8.all.map((entry) => ({
502
1141
  hash: entry.hash,
503
1142
  message: entry.message,
504
1143
  date: entry.date,
505
1144
  author: entry.author_name
506
1145
  }));
507
1146
  } catch (err) {
508
- log3.error(` Failed to get history for ${filePath}: ${err.message}`);
1147
+ log5.error(` Failed to get history for ${filePath}: ${err.message}`);
509
1148
  return [];
510
1149
  }
511
1150
  }
512
- async getBatchHistory(maxCount = 20) {
1151
+ async getChangedFiles(since) {
513
1152
  try {
514
- const log6 = await this.git.log({ maxCount: maxCount * 50, "--name-only": null });
515
- const result = /* @__PURE__ */ new Map();
516
- for (const entry of log6.all) {
517
- const record = {
518
- hash: entry.hash,
519
- message: entry.message,
520
- date: entry.date,
521
- author: entry.author_name
522
- };
523
- const diff = entry.diff;
524
- const files = diff?.files?.map((f) => f.file.replace(/\\/g, "/")) ?? [];
525
- for (const file of files) {
526
- let list = result.get(file);
527
- if (!list) {
528
- list = [];
529
- result.set(file, list);
530
- }
531
- if (list.length < maxCount) {
532
- list.push(record);
533
- }
534
- }
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)}`);
535
1199
  }
536
- return result;
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)) };
537
1204
  } catch (err) {
538
- log3.error(` Batch history failed: ${err.message}`);
539
- return /* @__PURE__ */ new Map();
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;
540
1215
  }
541
1216
  }
542
- async getChangedFiles(since) {
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
+ }
543
1240
  try {
544
- const diff = await this.git.diffSummary([since]);
545
- return diff.files.map((f) => f.file.replace(/\\/g, "/"));
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));
546
1253
  } catch (err) {
547
- log3.error(` Failed to get changed files since ${since}: ${err.message}`);
548
- return [];
1254
+ log5.error(`Failed to read file changes of ${rev}: ${err.message}`);
1255
+ return void 0;
549
1256
  }
550
1257
  }
551
- async getFileChurn(filePath) {
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;
552
1380
  try {
553
- const log6 = await this.git.log({ file: filePath, "--follow": null });
554
- return {
555
- filePath,
556
- commitCount: log6.total
557
- };
1381
+ stats = statSync2(this.filePath);
558
1382
  } catch (err) {
559
- log3.error(` Failed to get churn for ${filePath}: ${err.message}`);
560
- return { filePath, commitCount: 0 };
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;
561
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);
562
1395
  }
563
- async getHeadSha() {
1396
+ load(version) {
1397
+ let raw;
564
1398
  try {
565
- const raw = await this.git.revparse(["--short", "HEAD"]);
566
- const sha = raw.trim();
567
- return sha.length > 0 ? sha : void 0;
1399
+ raw = readFileSync5(this.filePath, "utf-8");
568
1400
  } catch (err) {
569
- log3.error(`Failed to resolve HEAD: ${err.message}`);
1401
+ this.cached = void 0;
1402
+ this.warnOnce(`read ${version}`, `cannot be read (${err.message})`, UNTIL_RETRY2);
570
1403
  return void 0;
571
1404
  }
1405
+ const view = this.parse(raw, version);
1406
+ this.cached = { version, view };
1407
+ if (view) this.lastWarning = void 0;
1408
+ return view;
572
1409
  }
573
- async countCommitsBetween(base) {
1410
+ parse(raw, version) {
1411
+ let data;
574
1412
  try {
575
- const raw = await this.git.raw(["rev-list", "--count", `${base}..HEAD`]);
576
- const n = parseInt(raw.trim(), 10);
577
- return Number.isFinite(n) && n >= 0 ? n : void 0;
1413
+ data = JSON.parse(raw);
578
1414
  } catch (err) {
579
- log3.error(`Failed to count commits since ${base}: ${err.message}`);
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
+ );
580
1427
  return void 0;
581
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}`);
582
1435
  }
583
1436
  };
584
1437
 
585
1438
  // src/adapters/stats/session-recorder-adapter.ts
586
- var log4 = createLogger("ctxo:stats");
1439
+ var log7 = createLogger("ctxo:stats");
587
1440
  var SessionRecorderAdapter = class {
588
1441
  db;
589
1442
  onWrite;
@@ -629,7 +1482,7 @@ var SessionRecorderAdapter = class {
629
1482
  );
630
1483
  this.flushToDisk();
631
1484
  } catch (err) {
632
- log4.error(`Failed to record session event: ${err.message}`);
1485
+ log7.error(`Failed to record session event: ${err.message}`);
633
1486
  }
634
1487
  }
635
1488
  queryStats(days) {
@@ -697,14 +1550,14 @@ var SessionRecorderAdapter = class {
697
1550
  try {
698
1551
  this.onWrite?.();
699
1552
  } catch (err) {
700
- log4.error(`Failed to flush stats to disk: ${err.message}`);
1553
+ log7.error(`Failed to flush stats to disk: ${err.message}`);
701
1554
  }
702
1555
  }
703
1556
  };
704
1557
 
705
1558
  // src/adapters/workspace/single-package-workspace.ts
706
- import { existsSync as existsSync3, readdirSync as readdirSync2 } from "fs";
707
- import { join as join3, resolve as resolve2 } from "path";
1559
+ import { existsSync as existsSync4, readdirSync as readdirSync2 } from "fs";
1560
+ import { join as join6, resolve as resolve2 } from "path";
708
1561
  var MANIFEST_CANDIDATES = [
709
1562
  "package.json",
710
1563
  "pyproject.toml",
@@ -717,17 +1570,17 @@ var MANIFEST_CANDIDATES = [
717
1570
  ];
718
1571
  function findManifest(dir) {
719
1572
  for (const name of MANIFEST_CANDIDATES) {
720
- const candidate = join3(dir, name);
721
- if (existsSync3(candidate)) return candidate;
1573
+ const candidate = join6(dir, name);
1574
+ if (existsSync4(candidate)) return candidate;
722
1575
  }
723
1576
  const csproj = findFirstByExt(dir, ".csproj") ?? findFirstByExt(dir, ".sln");
724
1577
  if (csproj) return csproj;
725
- return join3(dir, "package.json");
1578
+ return join6(dir, "package.json");
726
1579
  }
727
1580
  function findFirstByExt(dir, ext) {
728
1581
  try {
729
1582
  for (const entry of readdirSync2(dir, { withFileTypes: true })) {
730
- if (entry.isFile() && entry.name.endsWith(ext)) return join3(dir, entry.name);
1583
+ if (entry.isFile() && entry.name.endsWith(ext)) return join6(dir, entry.name);
731
1584
  }
732
1585
  } catch {
733
1586
  }
@@ -745,154 +1598,90 @@ function detectWorkspace(cwd) {
745
1598
  };
746
1599
  }
747
1600
 
748
- // src/core/config/load-config.ts
749
- import { existsSync as existsSync4, readFileSync as readFileSync3 } 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
- }).strict();
763
- var MaskingConfigSchema = z.object({
764
- /**
765
- * Glob patterns matched against community cluster labels. Matching labels
766
- * are replaced with `[masked-cluster-N]` in MCP tool responses so
767
- * org-sensitive folder names (e.g. `src/payment-vault`, `internal-*`)
768
- * never surface to AI agents over the stdio channel.
769
- */
770
- clusterLabels: z.array(z.string().min(1)).optional()
771
- }).strict();
772
- var WatchConfigSchema = z.object({
773
- /**
774
- * Minimum number of files that must be re-indexed since the last
775
- * community snapshot before `ctxo watch` recomputes one. Default: 5.
776
- * On large monorepos Louvain + PageRank cost ~1 s of blocking CPU;
777
- * bumping this (e.g. 25) avoids recomputing on every save.
778
- */
779
- snapshotMinFileChanges: z.number().int().min(1).optional()
780
- }).strict();
781
- var GateConfigSchema = z.object({
782
- enabled: z.boolean().optional(),
783
- sensitivity: z.enum(["strict", "balanced", "lenient"]).optional(),
784
- /** Absolute floor: minimum confirmed+likely dependents before the gate can fire. */
785
- minDependents: z.number().int().min(0).optional(),
786
- /** Repo-relative significance: symbol must be in the top `percentile`% by PageRank. */
787
- percentile: z.number().int().min(1).max(100).optional()
788
- }).strict();
789
- var CtxoConfigSchema = z.object({
790
- version: z.union([z.string(), z.number()]).optional(),
791
- stats: StatsConfigSchema.optional(),
792
- index: IndexConfigSchema.optional(),
793
- masking: MaskingConfigSchema.optional(),
794
- watch: WatchConfigSchema.optional(),
795
- gate: GateConfigSchema.optional()
796
- }).passthrough();
797
- var DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES = 5;
798
- var DEFAULT_CONFIG = {
799
- version: "1.0",
800
- stats: { enabled: true },
801
- index: { ignore: [], ignoreProjects: [] },
802
- masking: { clusterLabels: [] },
803
- watch: { snapshotMinFileChanges: DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES },
804
- gate: { enabled: true, sensitivity: "balanced" }
805
- };
806
-
807
- // src/core/config/load-config.ts
808
- var log5 = createLogger("ctxo:config");
809
- function isValidGlob(pattern) {
810
- try {
811
- picomatch(pattern);
812
- return true;
813
- } catch {
814
- return false;
815
- }
816
- }
817
- function validateGlobs(patterns, field, invalid) {
818
- if (!patterns) return;
819
- for (const p of patterns) {
820
- if (!isValidGlob(p)) invalid.push(`${field}: ${p}`);
821
- }
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);
822
1607
  }
823
- function loadConfig(ctxoRoot) {
824
- const path = join4(ctxoRoot, "config.yaml");
825
- const errors = [];
826
- const invalidGlobs = [];
827
- if (!existsSync4(path)) {
828
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: false };
829
- }
830
- let raw;
831
- try {
832
- raw = readFileSync3(path, "utf-8");
833
- } catch (err) {
834
- errors.push(`Cannot read ${path}: ${err.message}`);
835
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
836
- }
837
- let parsed;
838
- try {
839
- parsed = parseYaml(raw);
840
- } catch (err) {
841
- errors.push(`Invalid YAML in ${path}: ${err.message}`);
842
- log5.warn(`Invalid YAML in config.yaml, falling back to defaults: ${err.message}`);
843
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
844
- }
845
- if (parsed === null || parsed === void 0) {
846
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
847
- }
848
- const result = CtxoConfigSchema.safeParse(parsed);
849
- if (!result.success) {
850
- for (const issue of result.error.issues) {
851
- errors.push(`${issue.path.join(".") || "<root>"}: ${issue.message}`);
852
- }
853
- log5.warn(`Invalid config.yaml (${errors.length} issue(s)), falling back to defaults`);
854
- return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
855
- }
856
- validateGlobs(result.data.index?.ignore, "index.ignore", invalidGlobs);
857
- validateGlobs(result.data.index?.ignoreProjects, "index.ignoreProjects", invalidGlobs);
858
- for (const pattern of invalidGlobs) {
859
- log5.warn(`Invalid glob pattern in config.yaml \u2014 ${pattern}`);
860
- }
861
- return { config: result.data, errors, invalidGlobs, path, exists: true };
1608
+ function dataReferenceKinds(kind) {
1609
+ if (isDataResourceKind(kind)) return DATA_RESOURCE_KINDS2;
1610
+ if (kind === "procedure") return PROCEDURE_KINDS;
1611
+ return void 0;
862
1612
  }
863
- function statsEnabled(config) {
864
- return config.stats?.enabled !== false;
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, "/");
865
1646
  }
866
- function indexIgnorePatterns(config) {
867
- return (config.index?.ignore ?? []).filter(isValidGlob);
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;
868
1655
  }
869
- function indexIgnoreProjectPatterns(config) {
870
- return (config.index?.ignoreProjects ?? []).filter(isValidGlob);
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);
871
1661
  }
872
- function maskingClusterLabelPatterns(config) {
873
- return (config.masking?.clusterLabels ?? []).filter(isValidGlob);
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));
874
1668
  }
875
- function watchSnapshotMinFileChanges(config) {
876
- const value = config.watch?.snapshotMinFileChanges;
877
- if (typeof value === "number" && Number.isFinite(value) && value >= 1) return value;
878
- return DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES;
1669
+ function isUnderDocumentationFolder(file) {
1670
+ const folders = file.replace(/\\/g, "/").split("/").slice(0, -1);
1671
+ return folders.some((folder) => DOCUMENTATION_FOLDERS.has(asciiLower(folder)));
879
1672
  }
880
- function makeGlobMatcher(patterns) {
881
- if (patterns.length === 0) return () => false;
882
- const matchers = patterns.map((p) => picomatch(p, { dot: true }));
883
- return (input) => {
884
- const normalised = input.replace(/\\/g, "/");
885
- return matchers.some((m) => m(normalised));
886
- };
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);
887
1677
  }
888
-
889
- // src/core/graph/symbol-graph.ts
890
1678
  var SymbolGraph = class {
891
1679
  nodes = /* @__PURE__ */ new Map();
892
1680
  nodesByFileAndName = /* @__PURE__ */ new Map();
893
1681
  nodesByNameAndKind = /* @__PURE__ */ new Map();
894
1682
  forwardEdges = /* @__PURE__ */ new Map();
895
1683
  reverseEdges = /* @__PURE__ */ new Map();
1684
+ definitionRanks = /* @__PURE__ */ new Map();
896
1685
  edgeSet = /* @__PURE__ */ new Set();
897
1686
  addNode(node) {
898
1687
  this.nodes.set(node.symbolId, node);
@@ -940,6 +1729,8 @@ var SymbolGraph = class {
940
1729
  if (altMatch) return altMatch.symbolId;
941
1730
  }
942
1731
  }
1732
+ const dataMatch = this.resolveDataReference(segs);
1733
+ if (dataMatch) return dataMatch.symbolId;
943
1734
  const nameKind = this.extractNameKind(segs);
944
1735
  if (nameKind) {
945
1736
  const candidates = this.nodesByNameAndKind.get(nameKind);
@@ -959,6 +1750,8 @@ var SymbolGraph = class {
959
1750
  if (tsMatch) return tsMatch;
960
1751
  }
961
1752
  }
1753
+ const dataMatch = this.resolveDataReference(segs);
1754
+ if (dataMatch) return dataMatch;
962
1755
  const nameKind = this.extractNameKind(segs);
963
1756
  if (nameKind) {
964
1757
  const candidates = this.nodesByNameAndKind.get(nameKind);
@@ -966,6 +1759,63 @@ var SymbolGraph = class {
966
1759
  }
967
1760
  return void 0;
968
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
+ }
969
1819
  /**
970
1820
  * Extracts a `name::kind` key from the segments of a reference ID, supporting:
971
1821
  * - 2-part: `Bar::class` → `Bar::class`
@@ -983,6 +1833,18 @@ var SymbolGraph = class {
983
1833
  getReverseEdges(symbolId) {
984
1834
  return this.reverseEdges.get(symbolId) ?? [];
985
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
+ }
986
1848
  hasNode(symbolId) {
987
1849
  return this.nodes.has(symbolId) || this.resolveNodeFuzzy(symbolId) !== void 0;
988
1850
  }
@@ -1043,6 +1905,21 @@ var RevertDetector = class {
1043
1905
  }
1044
1906
  };
1045
1907
 
1908
+ // src/core/overlay/snapshot-history.ts
1909
+ function priorCommitSnapshots(history, currentSha) {
1910
+ const seen = /* @__PURE__ */ new Set();
1911
+ const prior = [];
1912
+ for (const snapshot of history) {
1913
+ if (snapshot.commitSha === currentSha || seen.has(snapshot.commitSha)) continue;
1914
+ seen.add(snapshot.commitSha);
1915
+ prior.push(snapshot);
1916
+ }
1917
+ return prior;
1918
+ }
1919
+ function distinctCommitCount(history, currentSha) {
1920
+ return priorCommitSnapshots(history, currentSha).length;
1921
+ }
1922
+
1046
1923
  // src/core/overlay/drift-detector.ts
1047
1924
  var DEFAULT_JACCARD_THRESHOLD = 0.3;
1048
1925
  var DriftDetector = class {
@@ -1054,15 +1931,18 @@ var DriftDetector = class {
1054
1931
  }
1055
1932
  detect(current, history) {
1056
1933
  const snapshotsAvailable = history.length;
1057
- const previous = history[0];
1058
- const anchor = history[1];
1934
+ const distinctCommits = distinctCommitCount(history, current.commitSha);
1935
+ const confidence = confidenceFor(distinctCommits);
1936
+ const hint = hintFor(distinctCommits);
1937
+ const [previous, anchor] = priorCommitSnapshots(history, current.commitSha);
1059
1938
  if (!previous) {
1060
1939
  return {
1061
1940
  events: [],
1062
- confidence: "low",
1941
+ confidence,
1063
1942
  snapshotsAvailable,
1943
+ distinctCommits,
1064
1944
  stability: { stable: 0, transient: 0, unverified: 0 },
1065
- 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."
1945
+ hint
1066
1946
  };
1067
1947
  }
1068
1948
  const currentByCommunityId = indexByCommunityId(current.communities);
@@ -1115,13 +1995,14 @@ var DriftDetector = class {
1115
1995
  stability: classification
1116
1996
  });
1117
1997
  }
1118
- const events = (this.suppressTransient ? rawEvents.filter((e) => e.stability !== "transient") : rawEvents).sort((a, b) => a.symbolId.localeCompare(b.symbolId));
1998
+ const events = (this.suppressTransient ? rawEvents.filter((e) => e.stability !== "transient") : rawEvents).sort((a, b) => compareCodepoint(a.symbolId, b.symbolId));
1119
1999
  return {
1120
2000
  events,
1121
- confidence: confidenceFor(snapshotsAvailable),
2001
+ confidence,
1122
2002
  snapshotsAvailable,
2003
+ distinctCommits,
1123
2004
  stability,
1124
- hint: hintFor(snapshotsAvailable)
2005
+ hint
1125
2006
  };
1126
2007
  }
1127
2008
  mapCommunities(current, previous) {
@@ -1175,41 +2056,56 @@ function jaccard(a, b) {
1175
2056
  const union = a.size + b.size - intersection;
1176
2057
  return union === 0 ? 0 : intersection / union;
1177
2058
  }
1178
- function confidenceFor(snapshots) {
1179
- if (snapshots >= 7) return "high";
1180
- if (snapshots >= 3) return "medium";
2059
+ function confidenceFor(distinctCommits) {
2060
+ if (distinctCommits >= 7) return "high";
2061
+ if (distinctCommits >= 3) return "medium";
1181
2062
  return "low";
1182
2063
  }
1183
- function hintFor(snapshots) {
1184
- if (snapshots >= 3) return void 0;
1185
- return "Install post-commit hook or run `ctxo index` more often to build a richer drift history.";
2064
+ function hintFor(distinctCommits) {
2065
+ if (distinctCommits >= 3) return void 0;
2066
+ return "Install the post-commit hook (`ctxo init`) or run `ctxo index` after commits to build history; `ctxo index --check` does not record snapshots.";
1186
2067
  }
1187
2068
 
1188
2069
  // src/core/blast-radius/blast-radius-calculator.ts
1189
- var CONFIRMED_KINDS = /* @__PURE__ */ new Set(["calls", "extends", "implements"]);
1190
- var LIKELY_KINDS = /* @__PURE__ */ new Set(["uses"]);
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
+ };
1191
2080
  var CONFIDENCE_RANK = {
1192
2081
  confirmed: 2,
1193
2082
  likely: 1,
1194
2083
  potential: 0
1195
2084
  };
1196
2085
  function edgeKindToConfidence(kind) {
1197
- if (CONFIRMED_KINDS.has(kind)) return "confirmed";
1198
- if (LIKELY_KINDS.has(kind)) return "likely";
1199
- return "potential";
2086
+ return EDGE_KINDS.includes(kind) ? EDGE_KIND_CONFIDENCE[kind] : "potential";
1200
2087
  }
1201
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
+ */
1202
2095
  calculate(graph, symbolId, coChangeMap) {
1203
- if (!graph.hasNode(symbolId)) {
2096
+ const root = graph.getNode(symbolId);
2097
+ if (!root) {
1204
2098
  return { impactedSymbols: [], directDependentsCount: 0, confirmedCount: 0, likelyCount: 0, potentialCount: 0, overallRiskScore: 0 };
1205
2099
  }
1206
- const sourceFile = symbolId.split("::")[0];
1207
- const visited = /* @__PURE__ */ new Set([symbolId]);
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]);
1208
2104
  const rawEntries = [];
1209
- const queue = [{ id: symbolId, depth: 0 }];
2105
+ const queue = [{ id: rootId, depth: 0 }];
1210
2106
  while (queue.length > 0) {
1211
2107
  const current = queue.shift();
1212
- const reverseEdges = graph.getReverseEdges(current.id);
2108
+ const reverseEdges = graph.getDependentEdges(current.id);
1213
2109
  const bestByNode = /* @__PURE__ */ new Map();
1214
2110
  for (const edge of reverseEdges) {
1215
2111
  if (visited.has(edge.from)) continue;
@@ -1234,7 +2130,7 @@ var BlastRadiusCalculator = class {
1234
2130
  const nodeFile = nodeId.split("::")[0];
1235
2131
  const coEntries = coChangeMap.get(sourceFile);
1236
2132
  if (coEntries) {
1237
- const match = coEntries.find((e) => e.file1 === nodeFile || e.file2 === nodeFile);
2133
+ const match = coEntries.find((e) => (e.file1 === sourceFile ? e.file2 : e.file1) === nodeFile);
1238
2134
  if (match) {
1239
2135
  coChangeFrequency = match.frequency;
1240
2136
  if (confidence === "potential" && match.frequency > 0.5) {
@@ -1254,11 +2150,10 @@ var BlastRadiusCalculator = class {
1254
2150
  queue.push({ id: nodeId, depth });
1255
2151
  }
1256
2152
  }
1257
- const blastSet = new Set(rawEntries.map((e) => e.symbolId));
1258
- blastSet.add(symbolId);
2153
+ const blastSet = /* @__PURE__ */ new Set([rootId, ...definitions, ...rawEntries.map((e) => e.symbolId)]);
1259
2154
  const entries = rawEntries.map((e) => ({
1260
2155
  ...e,
1261
- dependentCount: graph.getReverseEdges(e.symbolId).filter((re) => blastSet.has(re.from)).length
2156
+ dependentCount: graph.getDependentEdges(e.symbolId).filter((re) => blastSet.has(re.from)).length
1262
2157
  }));
1263
2158
  entries.sort((a, b) => a.depth - b.depth);
1264
2159
  const directDependentsCount = entries.filter((e) => e.depth === 1).length;
@@ -1271,6 +2166,36 @@ var BlastRadiusCalculator = class {
1271
2166
  }
1272
2167
  };
1273
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
+
1274
2199
  // src/core/overlay/architectural-overlay.ts
1275
2200
  var DEFAULT_RULES = [
1276
2201
  // Test layer (matched first — __tests__, .test.ts, tests/, fixtures)
@@ -1324,6 +2249,7 @@ var ArchitecturalOverlay = class {
1324
2249
  // src/core/dead-code/dead-code-detector.ts
1325
2250
  var TEST_PATTERNS = [/__tests__/, /\.test\.ts$/, /\.spec\.ts$/, /\btests\//, /\bfixtures?\//];
1326
2251
  var CONFIG_PATTERNS = [/\.(config|rc)\.(ts|js|json)$/, /tsconfig/, /eslint/, /\.d\.ts$/];
2252
+ var SQL_PATTERN = /\.sql$/i;
1327
2253
  var FRAMEWORK_PATTERNS = [
1328
2254
  // Vitest
1329
2255
  /^(describe|it|expect|beforeAll|beforeEach|afterAll|afterEach|vi)$/,
@@ -1347,8 +2273,10 @@ var SCAFFOLDING_PATTERNS = [
1347
2273
  var DeadCodeDetector = class {
1348
2274
  detect(graph, options = {}) {
1349
2275
  const allNodes = graph.allNodes();
1350
- const candidates = options.includeTests ? allNodes : allNodes.filter((n) => {
2276
+ const candidates = allNodes.filter((n) => {
1351
2277
  const file = n.symbolId.split("::")[0] ?? "";
2278
+ if (SQL_PATTERN.test(file)) return false;
2279
+ if (options.includeTests) return true;
1352
2280
  return !this.matchesAny(file, TEST_PATTERNS) && !this.matchesAny(file, CONFIG_PATTERNS);
1353
2281
  });
1354
2282
  if (candidates.length === 0) {
@@ -1365,7 +2293,7 @@ var DeadCodeDetector = class {
1365
2293
  const forwardEdges = graph.getForwardEdges(node.symbolId);
1366
2294
  const incomingFromCandidates = reverseEdges.filter((e) => candidateIds.has(e.from) && e.from !== node.symbolId);
1367
2295
  if (incomingFromCandidates.length === 0) {
1368
- const hasOutgoing = forwardEdges.some((e) => candidateIds.has(e.to));
2296
+ const hasOutgoing = forwardEdges.some((e) => candidateIds.has(e.to) || DATA_EDGE_KINDS.has(e.kind));
1369
2297
  if (hasOutgoing || reverseEdges.length > 0) {
1370
2298
  entryPoints.add(node.symbolId);
1371
2299
  }
@@ -1531,13 +2459,13 @@ var DeadCodeDetector = class {
1531
2459
  var DEFAULT_DAMPING = 0.85;
1532
2460
  var DEFAULT_MAX_ITERATIONS = 100;
1533
2461
  var DEFAULT_TOLERANCE = 1e-6;
1534
- var DEFAULT_LIMIT = 25;
2462
+ var DEFAULT_LIMIT2 = 25;
1535
2463
  var PageRankCalculator = class {
1536
2464
  calculate(graph, options = {}) {
1537
2465
  const damping = options.damping ?? DEFAULT_DAMPING;
1538
2466
  const maxIterations = options.maxIterations ?? DEFAULT_MAX_ITERATIONS;
1539
2467
  const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
1540
- const limit = options.limit ?? DEFAULT_LIMIT;
2468
+ const limit = options.limit ?? DEFAULT_LIMIT2;
1541
2469
  const nodes = graph.allNodes();
1542
2470
  const n = nodes.length;
1543
2471
  if (n === 0) {
@@ -1613,13 +2541,15 @@ var PageRankCalculator = class {
1613
2541
  // src/core/overlay/boundary-violation-detector.ts
1614
2542
  var HIGH_SEVERITY_THRESHOLD = 0;
1615
2543
  var MEDIUM_SEVERITY_THRESHOLD = 2;
1616
- var MIN_SNAPSHOTS_FOR_CONFIDENCE = 2;
2544
+ var MIN_DISTINCT_COMMITS_FOR_CONFIDENCE = 2;
1617
2545
  var BoundaryViolationDetector = class {
1618
2546
  detect(graph, current, history) {
1619
2547
  const snapshotsAvailable = history.length;
2548
+ const distinctCommits = distinctCommitCount(history, current.commitSha);
1620
2549
  const currentAssignments = assignmentMap2(current.communities);
1621
2550
  const currentLabels = labelMap2(current.communities);
1622
- const historicalAssignments = history.map((snapshot) => ({
2551
+ const earlierCommits = priorCommitSnapshots(history, current.commitSha);
2552
+ const historicalAssignments = earlierCommits.map((snapshot) => ({
1623
2553
  snapshot,
1624
2554
  assignments: assignmentMap2(snapshot.communities),
1625
2555
  labels: labelMap2(snapshot.communities)
@@ -1627,13 +2557,14 @@ var BoundaryViolationDetector = class {
1627
2557
  const violations = [];
1628
2558
  for (const edge of graph.allEdges()) {
1629
2559
  if (edge.from === edge.to) continue;
2560
+ if (DATA_EDGE_KINDS.has(edge.kind)) continue;
1630
2561
  const fromCommunity = currentAssignments.get(edge.from);
1631
2562
  const toCommunity = currentAssignments.get(edge.to);
1632
2563
  if (fromCommunity === void 0 || toCommunity === void 0) continue;
1633
2564
  if (fromCommunity === toCommunity) continue;
1634
2565
  const fromLabel = currentLabels.get(fromCommunity) ?? `community-${fromCommunity}`;
1635
2566
  const toLabel = currentLabels.get(toCommunity) ?? `community-${toCommunity}`;
1636
- const historicalCount = snapshotsAvailable < MIN_SNAPSHOTS_FOR_CONFIDENCE ? -1 : countHistoricalCrossCluster(historicalAssignments, edge.from, edge.to);
2567
+ const historicalCount = distinctCommits < MIN_DISTINCT_COMMITS_FOR_CONFIDENCE ? -1 : countHistoricalCrossCluster(historicalAssignments, edge.from, edge.to);
1637
2568
  if (historicalCount === -1 || historicalCount <= MEDIUM_SEVERITY_THRESHOLD) {
1638
2569
  violations.push({
1639
2570
  from: {
@@ -1654,26 +2585,27 @@ var BoundaryViolationDetector = class {
1654
2585
  }
1655
2586
  violations.sort((a, b) => {
1656
2587
  if (a.severity !== b.severity) return a.severity === "high" ? -1 : 1;
1657
- const cmp = a.from.symbolId.localeCompare(b.from.symbolId);
1658
- return cmp !== 0 ? cmp : a.to.symbolId.localeCompare(b.to.symbolId);
2588
+ const cmp = compareCodepoint(a.from.symbolId, b.from.symbolId);
2589
+ return cmp !== 0 ? cmp : compareCodepoint(a.to.symbolId, b.to.symbolId);
1659
2590
  });
1660
2591
  return {
1661
2592
  violations,
1662
- confidence: confidenceFor2(snapshotsAvailable),
2593
+ confidence: confidenceFor2(distinctCommits),
1663
2594
  snapshotsAvailable,
1664
- hint: hintFor2(snapshotsAvailable)
2595
+ distinctCommits,
2596
+ hint: hintFor2(distinctCommits)
1665
2597
  };
1666
2598
  }
1667
2599
  };
1668
2600
  function countHistoricalCrossCluster(history, fromSymbol, toSymbol) {
1669
- let count = 0;
2601
+ let count2 = 0;
1670
2602
  for (const snap of history) {
1671
2603
  const fromId = snap.assignments.get(fromSymbol);
1672
2604
  const toId = snap.assignments.get(toSymbol);
1673
2605
  if (fromId === void 0 || toId === void 0) continue;
1674
- if (fromId !== toId) count++;
2606
+ if (fromId !== toId) count2++;
1675
2607
  }
1676
- return count;
2608
+ return count2;
1677
2609
  }
1678
2610
  function assignmentMap2(entries) {
1679
2611
  const map = /* @__PURE__ */ new Map();
@@ -1691,109 +2623,227 @@ function labelMap2(entries) {
1691
2623
  }
1692
2624
  return labels;
1693
2625
  }
1694
- function confidenceFor2(snapshots) {
1695
- if (snapshots >= 7) return "high";
1696
- if (snapshots >= 3) return "medium";
2626
+ function confidenceFor2(distinctCommits) {
2627
+ if (distinctCommits >= 7) return "high";
2628
+ if (distinctCommits >= 3) return "medium";
1697
2629
  return "low";
1698
2630
  }
1699
- function hintFor2(snapshots) {
1700
- if (snapshots >= MIN_SNAPSHOTS_FOR_CONFIDENCE) return void 0;
1701
- return "Insufficient snapshot history \u2014 enable `ctxo init` hook or add `ctxo index --check` to CI for boundary-violation signal.";
2631
+ function hintFor2(distinctCommits) {
2632
+ if (distinctCommits >= MIN_DISTINCT_COMMITS_FOR_CONFIDENCE) return void 0;
2633
+ return "Install the post-commit hook (`ctxo init`) or run `ctxo index` after commits to build history; `ctxo index --check` does not record snapshots.";
1702
2634
  }
1703
2635
 
1704
- // src/core/co-change/co-change-analyzer.ts
1705
- var MIN_SHARED_COMMITS = 2;
1706
- var MIN_FREQUENCY = 0.1;
1707
- function aggregateCoChanges(indices) {
1708
- const commitToFiles = /* @__PURE__ */ new Map();
1709
- const fileCommitCounts = /* @__PURE__ */ new Map();
1710
- for (const idx of indices) {
1711
- const file = idx.file;
1712
- let count = 0;
1713
- for (const intent of idx.intent) {
1714
- count++;
1715
- let files = commitToFiles.get(intent.hash);
1716
- if (!files) {
1717
- files = /* @__PURE__ */ new Set();
1718
- commitToFiles.set(intent.hash, files);
2636
+ // src/core/search/symbol-tokenizer.ts
2637
+ var STOP_WORDS = /* @__PURE__ */ new Set([
2638
+ "get",
2639
+ "set",
2640
+ "is",
2641
+ "has",
2642
+ "can",
2643
+ "do",
2644
+ "the",
2645
+ "a",
2646
+ "an",
2647
+ "of",
2648
+ "to",
2649
+ "in",
2650
+ "for",
2651
+ "on",
2652
+ "with",
2653
+ "at",
2654
+ "by",
2655
+ "from",
2656
+ "as",
2657
+ "into",
2658
+ "not",
2659
+ "no",
2660
+ "or"
2661
+ ]);
2662
+ var DEFAULT_OPTIONS = {
2663
+ includeStopWords: false,
2664
+ includeOriginal: true,
2665
+ includeFilePath: false
2666
+ };
2667
+ var SymbolTokenizer = class _SymbolTokenizer {
2668
+ options;
2669
+ constructor(options) {
2670
+ this.options = { ...DEFAULT_OPTIONS, ...options };
2671
+ }
2672
+ /**
2673
+ * Tokenize a symbol name into searchable tokens.
2674
+ *
2675
+ * Examples:
2676
+ * "getCoChangeMetrics" → ["get", "co", "change", "metrics"] (or without stop words: ["co", "change", "metrics"])
2677
+ * "SqliteStorageAdapter" → ["sqlite", "storage", "adapter"]
2678
+ * "i_storage_port" → ["i", "storage", "port"]
2679
+ * "BM25Scorer" → ["bm", "25", "scorer"]
2680
+ * "DB_PASSWORD" → ["db", "password"]
2681
+ */
2682
+ tokenize(symbolName, filePath) {
2683
+ const splitTokens = _SymbolTokenizer.splitName(symbolName);
2684
+ const lowered = splitTokens.map((t) => t.toLowerCase());
2685
+ const tokens = this.options.includeStopWords ? lowered : lowered.filter((t) => !STOP_WORDS.has(t));
2686
+ if (this.options.includeOriginal) {
2687
+ const original = symbolName.toLowerCase();
2688
+ if (!tokens.includes(original)) {
2689
+ tokens.unshift(original);
1719
2690
  }
1720
- files.add(file);
1721
2691
  }
1722
- if (count > 0) {
1723
- fileCommitCounts.set(file, count);
2692
+ if (this.options.includeFilePath && filePath) {
2693
+ const pathTokens = _SymbolTokenizer.tokenizeFilePath(filePath);
2694
+ for (const pt of pathTokens) {
2695
+ if (!tokens.includes(pt)) {
2696
+ tokens.push(pt);
2697
+ }
2698
+ }
1724
2699
  }
2700
+ return tokens;
1725
2701
  }
1726
- const pairCounts = /* @__PURE__ */ new Map();
1727
- for (const files of commitToFiles.values()) {
1728
- if (files.size < 2) continue;
1729
- const sorted = [...files].sort();
1730
- for (let i = 0; i < sorted.length; i++) {
1731
- for (let j = i + 1; j < sorted.length; j++) {
1732
- const key = `${sorted[i]}|${sorted[j]}`;
1733
- pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
2702
+ /**
2703
+ * Tokenize a search query. Queries use stop words and don't include originals.
2704
+ */
2705
+ tokenizeQuery(query) {
2706
+ const words = query.trim().split(/\s+/);
2707
+ const tokens = [];
2708
+ for (const word of words) {
2709
+ const split = _SymbolTokenizer.splitName(word);
2710
+ for (const t of split) {
2711
+ const lowered = t.toLowerCase();
2712
+ if (lowered.length > 0 && !tokens.includes(lowered)) {
2713
+ tokens.push(lowered);
2714
+ }
1734
2715
  }
1735
2716
  }
2717
+ return tokens;
1736
2718
  }
1737
- const entries = [];
1738
- for (const [key, sharedCommits] of pairCounts) {
1739
- if (sharedCommits < MIN_SHARED_COMMITS) continue;
1740
- const [file1, file2] = key.split("|");
1741
- const count1 = fileCommitCounts.get(file1) ?? 0;
1742
- const count2 = fileCommitCounts.get(file2) ?? 0;
1743
- const minCount = Math.min(count1, count2);
1744
- if (minCount === 0) continue;
1745
- const frequency = Math.round(sharedCommits / minCount * 1e3) / 1e3;
1746
- if (frequency < MIN_FREQUENCY) continue;
1747
- entries.push({ file1, file2, sharedCommits, frequency });
1748
- }
1749
- entries.sort((a, b) => b.frequency - a.frequency || b.sharedCommits - a.sharedCommits);
1750
- return {
1751
- version: 1,
1752
- timestamp: Math.floor(Date.now() / 1e3),
1753
- entries
1754
- };
1755
- }
1756
- function loadCoChangeMap(matrix) {
1757
- const map = /* @__PURE__ */ new Map();
1758
- for (const entry of matrix.entries) {
1759
- let list1 = map.get(entry.file1);
1760
- if (!list1) {
1761
- list1 = [];
1762
- map.set(entry.file1, list1);
2719
+ /**
2720
+ * Split a name into constituent words based on naming convention boundaries.
2721
+ *
2722
+ * Rules:
2723
+ * 1. Split on underscores and hyphens (snake_case, kebab-case)
2724
+ * 2. Split on camelCase/PascalCase boundaries (uppercase after lowercase)
2725
+ * 3. Split acronym boundaries (e.g., "HTMLParser" → ["HTML", "Parser"])
2726
+ * 4. Split on digit ↔ letter boundaries
2727
+ */
2728
+ static splitName(name) {
2729
+ if (!name || name.length === 0) return [];
2730
+ const segments = name.split(/[_\-./\\]+/).filter((s) => s.length > 0);
2731
+ const tokens = [];
2732
+ for (const segment of segments) {
2733
+ tokens.push(..._SymbolTokenizer.splitCamelCase(segment));
1763
2734
  }
1764
- list1.push(entry);
1765
- let list2 = map.get(entry.file2);
1766
- if (!list2) {
1767
- list2 = [];
1768
- map.set(entry.file2, list2);
2735
+ return tokens.filter((t) => t.length > 0);
2736
+ }
2737
+ /**
2738
+ * Split a single segment on camelCase/PascalCase/digit boundaries.
2739
+ */
2740
+ static splitCamelCase(segment) {
2741
+ const tokens = [];
2742
+ let current = "";
2743
+ for (let i = 0; i < segment.length; i++) {
2744
+ const ch = segment[i];
2745
+ const prev = i > 0 ? segment[i - 1] : "";
2746
+ if (current.length > 0) {
2747
+ const prevIsDigit = isDigit(prev);
2748
+ const currIsDigit = isDigit(ch);
2749
+ if (prevIsDigit !== currIsDigit) {
2750
+ tokens.push(current);
2751
+ current = ch;
2752
+ continue;
2753
+ }
2754
+ }
2755
+ if (current.length > 0 && isLower(prev) && isUpper(ch)) {
2756
+ tokens.push(current);
2757
+ current = ch;
2758
+ continue;
2759
+ }
2760
+ if (current.length > 1 && isUpper(ch) === false && isUpper(prev) && !isDigit(ch)) {
2761
+ const lastChar = current[current.length - 1];
2762
+ if (isUpper(lastChar)) {
2763
+ tokens.push(current.slice(0, -1));
2764
+ current = lastChar + ch;
2765
+ continue;
2766
+ }
2767
+ }
2768
+ current += ch;
1769
2769
  }
1770
- list2.push(entry);
2770
+ if (current.length > 0) {
2771
+ tokens.push(current);
2772
+ }
2773
+ return tokens;
1771
2774
  }
1772
- return map;
2775
+ /**
2776
+ * Extract searchable tokens from a file path.
2777
+ * "src/core/search/symbol-tokenizer.ts" → ["src", "core", "search", "symbol", "tokenizer", "ts"]
2778
+ */
2779
+ static tokenizeFilePath(filePath) {
2780
+ const withoutExt = filePath.replace(/\.[^.]+$/, "");
2781
+ const segments = withoutExt.split(/[/\\]+/);
2782
+ const tokens = [];
2783
+ for (const seg of segments) {
2784
+ const parts = seg.split(/[_\-.]+/).filter((s) => s.length > 0);
2785
+ for (const p of parts) {
2786
+ const lowered = p.toLowerCase();
2787
+ if (lowered.length > 0 && !tokens.includes(lowered)) {
2788
+ tokens.push(lowered);
2789
+ }
2790
+ }
2791
+ }
2792
+ return tokens;
2793
+ }
2794
+ /** Check if a token is a stop word */
2795
+ static isStopWord(token) {
2796
+ return STOP_WORDS.has(token.toLowerCase());
2797
+ }
2798
+ };
2799
+ function isUpper(ch) {
2800
+ return ch >= "A" && ch <= "Z";
2801
+ }
2802
+ function isLower(ch) {
2803
+ return ch >= "a" && ch <= "z";
2804
+ }
2805
+ function isDigit(ch) {
2806
+ return ch >= "0" && ch <= "9";
1773
2807
  }
1774
2808
 
1775
2809
  export {
2810
+ compareCodepoint,
1776
2811
  CommunitySnapshotWriter,
1777
2812
  SqliteStorageAdapter,
2813
+ buildCoChangeMatrix,
2814
+ CoChangeReader,
2815
+ DATA_EDGE_KINDS,
2816
+ isDataResourceKind,
2817
+ parseCoChangeSince,
2818
+ sinceCutoff,
2819
+ selectWindow,
2820
+ loadConfig,
2821
+ statsEnabled,
2822
+ indexIgnorePatterns,
2823
+ indexIgnoreProjectPatterns,
2824
+ factsRequested,
2825
+ indexTestPatterns,
2826
+ communityDetectorOptions,
2827
+ maskingClusterLabelPatterns,
2828
+ watchSnapshotMinFileChanges,
2829
+ resolveCoChangeConfig,
2830
+ resolveContextsViewConfig,
2831
+ makeGlobMatcher,
2832
+ filesWithTestAnnotations,
2833
+ createTestFileClassifier,
1778
2834
  SymbolGraph,
1779
2835
  RevertDetector,
1780
2836
  DriftDetector,
1781
2837
  SimpleGitAdapter,
1782
2838
  BlastRadiusCalculator,
2839
+ findCoChangedFilesForBlastRadius,
1783
2840
  ArchitecturalOverlay,
2841
+ ContextsViewReader,
1784
2842
  DeadCodeDetector,
2843
+ SymbolTokenizer,
1785
2844
  PageRankCalculator,
1786
2845
  BoundaryViolationDetector,
1787
- aggregateCoChanges,
1788
- loadCoChangeMap,
1789
2846
  SessionRecorderAdapter,
1790
- detectWorkspace,
1791
- loadConfig,
1792
- statsEnabled,
1793
- indexIgnorePatterns,
1794
- indexIgnoreProjectPatterns,
1795
- maskingClusterLabelPatterns,
1796
- watchSnapshotMinFileChanges,
1797
- makeGlobMatcher
2847
+ detectWorkspace
1798
2848
  };
1799
- //# sourceMappingURL=chunk-HZ4UGA6J.js.map
2849
+ //# sourceMappingURL=chunk-BYX3UFBJ.js.map