@ctxo/cli 0.13.1 → 0.14.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.
Files changed (42) hide show
  1. package/README.md +16 -0
  2. package/dist/chunk-2Q76LH2H.js +186 -0
  3. package/dist/chunk-2Q76LH2H.js.map +1 -0
  4. package/dist/chunk-72CEQEQD.js +53 -0
  5. package/dist/chunk-72CEQEQD.js.map +1 -0
  6. package/dist/chunk-7HSA5JVP.js +1049 -0
  7. package/dist/chunk-7HSA5JVP.js.map +1 -0
  8. package/dist/chunk-G4BMKCKG.js +76 -0
  9. package/dist/chunk-G4BMKCKG.js.map +1 -0
  10. package/dist/chunk-L2CAUER6.js +508 -0
  11. package/dist/chunk-L2CAUER6.js.map +1 -0
  12. package/dist/chunk-LAX6VVBU.js +98 -0
  13. package/dist/chunk-LAX6VVBU.js.map +1 -0
  14. package/dist/chunk-SRQZDGFZ.js +128 -0
  15. package/dist/chunk-SRQZDGFZ.js.map +1 -0
  16. package/dist/{chunk-DT3ZH4AU.js → chunk-X4TVFJ4C.js} +6 -1
  17. package/dist/chunk-X4TVFJ4C.js.map +1 -0
  18. package/dist/{chunk-WB7WZKX4.js → chunk-ZL36YS2V.js} +766 -1614
  19. package/dist/chunk-ZL36YS2V.js.map +1 -0
  20. package/dist/{cli-router-DDHH4WQH.js → cli-router-AZCDNX5R.js} +980 -504
  21. package/dist/cli-router-AZCDNX5R.js.map +1 -0
  22. package/dist/index.js +13 -2874
  23. package/dist/index.js.map +1 -1
  24. package/dist/internal.d.ts +747 -0
  25. package/dist/internal.js +13 -0
  26. package/dist/{json-index-reader-OXTRTGE6.js → json-index-reader-DBW32TCN.js} +6 -4
  27. package/dist/json-index-reader-DBW32TCN.js.map +1 -0
  28. package/dist/mcp-server-6YG2QE6E.js +2604 -0
  29. package/dist/mcp-server-6YG2QE6E.js.map +1 -0
  30. package/dist/module-host-KNMKS62R.js +13 -0
  31. package/dist/module-host-KNMKS62R.js.map +1 -0
  32. package/dist/module-loader-B3LYF3W7.js +14 -0
  33. package/dist/module-loader-B3LYF3W7.js.map +1 -0
  34. package/dist/plugin-discovery-XRXI3IJF.js +13 -0
  35. package/dist/plugin-discovery-XRXI3IJF.js.map +1 -0
  36. package/llms-full.txt +54 -0
  37. package/llms.txt +11 -0
  38. package/package.json +13 -2
  39. package/dist/chunk-DT3ZH4AU.js.map +0 -1
  40. package/dist/chunk-WB7WZKX4.js.map +0 -1
  41. package/dist/cli-router-DDHH4WQH.js.map +0 -1
  42. /package/dist/{json-index-reader-OXTRTGE6.js.map → internal.js.map} +0 -0
@@ -0,0 +1,1049 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ compareCodepoint
4
+ } from "./chunk-72CEQEQD.js";
5
+ import {
6
+ createLogger
7
+ } from "./chunk-5DSKZJNN.js";
8
+
9
+ // src/core/co-change/co-change-window.ts
10
+ var SINCE_PATTERN = /^(\d+)([dwmy])$/;
11
+ var SECONDS_PER_DAY = 86400;
12
+ function parseCoChangeSince(value) {
13
+ const match = SINCE_PATTERN.exec(value);
14
+ if (!match) return void 0;
15
+ const amount = Number.parseInt(match[1], 10);
16
+ if (!Number.isSafeInteger(amount)) return void 0;
17
+ return { amount, unit: match[2] };
18
+ }
19
+ function sinceCutoff(headCommittedAt, since) {
20
+ const cutoff = subtractSince(headCommittedAt, since);
21
+ return Number.isFinite(cutoff) ? cutoff : Number.NEGATIVE_INFINITY;
22
+ }
23
+ function subtractSince(headCommittedAt, since) {
24
+ switch (since.unit) {
25
+ case "d":
26
+ return headCommittedAt - since.amount * SECONDS_PER_DAY;
27
+ case "w":
28
+ return headCommittedAt - since.amount * 7 * SECONDS_PER_DAY;
29
+ case "m":
30
+ return subtractUtcMonths(headCommittedAt, since.amount);
31
+ case "y":
32
+ return subtractUtcMonths(headCommittedAt, since.amount * 12);
33
+ }
34
+ }
35
+ function subtractUtcMonths(epochSeconds, months) {
36
+ const date = new Date(epochSeconds * 1e3);
37
+ const dayOfMonth = date.getUTCDate();
38
+ date.setUTCDate(1);
39
+ date.setUTCMonth(date.getUTCMonth() - months);
40
+ date.setUTCDate(Math.min(dayOfMonth, daysInUtcMonth(date)));
41
+ return date.getTime() / 1e3;
42
+ }
43
+ function daysInUtcMonth(date) {
44
+ const lastDay = new Date(date.getTime());
45
+ lastDay.setUTCMonth(lastDay.getUTCMonth() + 1, 0);
46
+ return lastDay.getUTCDate();
47
+ }
48
+ function windowCutoff(coChange, headCommittedAt) {
49
+ const since = coChange.since !== void 0 ? parseCoChangeSince(coChange.since) : void 0;
50
+ return since !== void 0 ? sinceCutoff(headCommittedAt, since) : void 0;
51
+ }
52
+ function selectWindow(commits, maxCommits, cutoff) {
53
+ const newest = maxCommits === "all" ? [...commits] : commits.slice(0, wholeCommitCount(maxCommits));
54
+ return cutoff === void 0 ? newest : newest.filter((commit) => commit.committedAt >= cutoff);
55
+ }
56
+ function wholeCommitCount(maxCommits) {
57
+ return maxCommits >= 1 ? Math.floor(maxCommits) : 0;
58
+ }
59
+
60
+ // src/core/config/load-config.ts
61
+ import { existsSync, readFileSync } from "fs";
62
+ import { join } from "path";
63
+ import { parse as parseYaml } from "yaml";
64
+ import picomatch from "picomatch";
65
+
66
+ // src/core/config/config-schema.ts
67
+ import { z } from "zod";
68
+ var StatsConfigSchema = z.object({
69
+ enabled: z.boolean().optional()
70
+ }).strict();
71
+ var IndexConfigSchema = z.object({
72
+ ignore: z.array(z.string().min(1)).optional(),
73
+ ignoreProjects: z.array(z.string().min(1)).optional(),
74
+ facts: z.boolean().optional(),
75
+ tests: z.array(z.string().min(1)).optional()
76
+ }).strict();
77
+ var MaskingConfigSchema = z.object({
78
+ /**
79
+ * Glob patterns matched against community cluster labels. Matching labels
80
+ * are replaced with `[masked-cluster-N]` in MCP tool responses so
81
+ * org-sensitive folder names (e.g. `src/payment-vault`, `internal-*`)
82
+ * never surface to AI agents over the stdio channel.
83
+ */
84
+ clusterLabels: z.array(z.string().min(1)).optional()
85
+ }).strict();
86
+ var WatchConfigSchema = z.object({
87
+ /**
88
+ * Minimum number of files that must be re-indexed since the last
89
+ * community snapshot before `ctxo watch` recomputes one. Default: 5.
90
+ * On large monorepos Louvain + PageRank cost ~1 s of blocking CPU;
91
+ * bumping this (e.g. 25) avoids recomputing on every save.
92
+ */
93
+ snapshotMinFileChanges: z.number().int().min(1).optional()
94
+ }).strict();
95
+ var GateConfigSchema = z.object({
96
+ enabled: z.boolean().optional(),
97
+ sensitivity: z.enum(["strict", "balanced", "lenient"]).optional(),
98
+ /** Absolute floor: minimum confirmed+likely dependents before the gate can fire. */
99
+ minDependents: z.number().int().min(0).optional(),
100
+ /** Repo-relative significance: symbol must be in the top `percentile`% by PageRank. */
101
+ percentile: z.number().int().min(1).max(100).optional()
102
+ }).strict();
103
+ var CoChangeConfigSchema = z.object({
104
+ /**
105
+ * Size of the co-change window: the last N non-merge commits from HEAD, or "all".
106
+ * Default: 2000. Independent of `ctxo index --max-history`, which only sets how much
107
+ * per-file commit history is kept.
108
+ */
109
+ maxCommits: z.union([z.number().int().min(1), z.literal("all")], { error: 'must be a positive integer or "all"' }).optional(),
110
+ /**
111
+ * Narrows the window to a duration counted back from HEAD's committer date (never the wall
112
+ * clock): `30d`, `2w`, `12m` or `1y`. Applies together with `maxCommits`.
113
+ */
114
+ since: z.string().refine((value) => parseCoChangeSince(value) !== void 0, {
115
+ error: "must be a duration such as 30d, 2w, 12m or 1y"
116
+ }).optional(),
117
+ /** Commits touching more analysed files than this are skipped as mass changes. Default: 30. */
118
+ maxFilesPerCommit: z.number().int().min(1).optional(),
119
+ /** A file pair becomes an entry only after this many shared commits. Default: 2. */
120
+ minShared: z.number().int().min(1).optional(),
121
+ /** Extra tracked files to analyse that no language plugin indexes, such as SQL scripts (globs). */
122
+ include: z.array(z.string().min(1)).optional(),
123
+ /** Indexed files to leave out of the analysis (globs). */
124
+ exclude: z.array(z.string().min(1)).optional()
125
+ }).strict();
126
+ var CommunitiesConfigSchema = z.object({
127
+ /** Louvain resolution: higher gives more, smaller communities. Default: 1.0. */
128
+ resolution: z.number().positive().optional(),
129
+ /** Minimum number of communities a symbol must bridge to count as a god node. Default: 3. */
130
+ godNodeMinBridged: z.number().int().min(1).optional(),
131
+ /** Maximum number of god nodes reported. Default: 25. */
132
+ godNodeMaxResults: z.number().int().min(0).optional()
133
+ }).strict();
134
+ var ContextsViewConfigSchema = z.object({
135
+ /** Blend of the file-edge signals. Defaults: dependency 1.0, coChange 1.0, data 0 (shared tables are a finding, not a clustering weight). */
136
+ weights: z.object({
137
+ dependency: z.number().min(0).optional(),
138
+ coChange: z.number().min(0).optional(),
139
+ data: z.number().min(0).optional()
140
+ }).strict().optional(),
141
+ /** Files to leave out of the file graph (globs). */
142
+ exclude: z.array(z.string().min(1)).optional(),
143
+ hubs: z.object({
144
+ /** Detect hubs automatically (fan-in and god nodes). Default: true. */
145
+ auto: z.boolean().optional(),
146
+ /** A file is a fan-in hub when it is in the top `topPercent`% by dependents. Default: 1. */
147
+ topPercent: z.number().positive().max(100).optional(),
148
+ /** Minimum distinct dependent files for a fan-in hub. Default: 10. */
149
+ minDependents: z.number().int().min(1).optional(),
150
+ /** Files that lose all their edges (globs). */
151
+ paths: z.array(z.string().min(1)).optional()
152
+ }).strict().optional(),
153
+ /** Louvain resolution for the file graph. Default: 1.0. */
154
+ resolution: z.number().positive().optional(),
155
+ /** Candidates with fewer files fold into a neighbour. Default: 3. */
156
+ minCandidateFiles: z.number().int().min(1).optional(),
157
+ /** Words dropped from candidate terms; replaces the default list. */
158
+ technicalTerms: z.array(z.string().min(1)).optional()
159
+ }).strict();
160
+ var CtxoConfigSchema = z.object({
161
+ version: z.union([z.string(), z.number()]).optional(),
162
+ stats: StatsConfigSchema.optional(),
163
+ index: IndexConfigSchema.optional(),
164
+ masking: MaskingConfigSchema.optional(),
165
+ watch: WatchConfigSchema.optional(),
166
+ gate: GateConfigSchema.optional(),
167
+ cochange: CoChangeConfigSchema.optional(),
168
+ communities: CommunitiesConfigSchema.optional(),
169
+ contextsView: ContextsViewConfigSchema.optional()
170
+ }).passthrough();
171
+ var DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES = 5;
172
+ var DEFAULT_COCHANGE_MAX_COMMITS = 2e3;
173
+ var DEFAULT_COCHANGE_MAX_FILES_PER_COMMIT = 30;
174
+ var DEFAULT_COCHANGE_MIN_SHARED = 2;
175
+ var DEFAULT_TECHNICAL_TERMS = [
176
+ "Service",
177
+ "Repository",
178
+ "Controller",
179
+ "Handler",
180
+ "Manager",
181
+ "Dto",
182
+ "Impl",
183
+ "Async"
184
+ ];
185
+ var DEFAULT_CONFIG = {
186
+ version: "1.0",
187
+ stats: { enabled: true },
188
+ index: { ignore: [], ignoreProjects: [] },
189
+ masking: { clusterLabels: [] },
190
+ watch: { snapshotMinFileChanges: DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES },
191
+ gate: { enabled: true, sensitivity: "balanced" },
192
+ cochange: {
193
+ maxCommits: DEFAULT_COCHANGE_MAX_COMMITS,
194
+ maxFilesPerCommit: DEFAULT_COCHANGE_MAX_FILES_PER_COMMIT,
195
+ minShared: DEFAULT_COCHANGE_MIN_SHARED,
196
+ include: [],
197
+ exclude: []
198
+ }
199
+ };
200
+
201
+ // src/core/config/load-config.ts
202
+ var log = createLogger("ctxo:config");
203
+ function isValidGlob(pattern) {
204
+ try {
205
+ picomatch(pattern);
206
+ return true;
207
+ } catch {
208
+ return false;
209
+ }
210
+ }
211
+ function validateGlobs(patterns, field, invalid) {
212
+ if (!patterns) return;
213
+ for (const p of patterns) {
214
+ if (!isValidGlob(p)) invalid.push(`${field}: ${p}`);
215
+ }
216
+ }
217
+ function loadConfig(ctxoRoot) {
218
+ const path = join(ctxoRoot, "config.yaml");
219
+ const errors = [];
220
+ const invalidGlobs = [];
221
+ if (!existsSync(path)) {
222
+ return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: false };
223
+ }
224
+ let raw;
225
+ try {
226
+ raw = readFileSync(path, "utf-8");
227
+ } catch (err) {
228
+ errors.push(`Cannot read ${path}: ${err.message}`);
229
+ return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
230
+ }
231
+ let parsed;
232
+ try {
233
+ parsed = parseYaml(raw);
234
+ } catch (err) {
235
+ errors.push(`Invalid YAML in ${path}: ${err.message}`);
236
+ log.warn(`Invalid YAML in config.yaml, falling back to defaults: ${err.message}`);
237
+ return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
238
+ }
239
+ if (parsed === null || parsed === void 0) {
240
+ return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
241
+ }
242
+ const result = CtxoConfigSchema.safeParse(parsed);
243
+ if (!result.success) {
244
+ for (const issue of result.error.issues) {
245
+ errors.push(`${issue.path.join(".") || "<root>"}: ${issue.message}`);
246
+ }
247
+ log.warn(`Invalid config.yaml (${errors.length} issue(s)), falling back to defaults`);
248
+ return { config: DEFAULT_CONFIG, errors, invalidGlobs, path, exists: true };
249
+ }
250
+ validateGlobs(result.data.index?.ignore, "index.ignore", invalidGlobs);
251
+ validateGlobs(result.data.index?.ignoreProjects, "index.ignoreProjects", invalidGlobs);
252
+ validateGlobs(result.data.index?.tests, "index.tests", invalidGlobs);
253
+ validateGlobs(result.data.cochange?.include, "cochange.include", invalidGlobs);
254
+ validateGlobs(result.data.cochange?.exclude, "cochange.exclude", invalidGlobs);
255
+ validateGlobs(result.data.contextsView?.exclude, "contextsView.exclude", invalidGlobs);
256
+ validateGlobs(result.data.contextsView?.hubs?.paths, "contextsView.hubs.paths", invalidGlobs);
257
+ for (const pattern of invalidGlobs) {
258
+ log.warn(`Invalid glob pattern in config.yaml \u2014 ${pattern}`);
259
+ }
260
+ return { config: result.data, errors, invalidGlobs, path, exists: true };
261
+ }
262
+ function statsEnabled(config) {
263
+ return config.stats?.enabled !== false;
264
+ }
265
+ function indexIgnorePatterns(config) {
266
+ return (config.index?.ignore ?? []).filter(isValidGlob);
267
+ }
268
+ function indexIgnoreProjectPatterns(config) {
269
+ return (config.index?.ignoreProjects ?? []).filter(isValidGlob);
270
+ }
271
+ function factsRequested(config) {
272
+ return config.index?.facts === true;
273
+ }
274
+ function indexTestPatterns(config) {
275
+ return (config.index?.tests ?? []).filter(isValidGlob);
276
+ }
277
+ function communityDetectorOptions(config) {
278
+ const section = config.communities;
279
+ const resolution = section?.resolution;
280
+ const minBridged = section?.godNodeMinBridged;
281
+ const maxResults = section?.godNodeMaxResults;
282
+ return {
283
+ ...typeof resolution === "number" && Number.isFinite(resolution) && resolution > 0 ? { resolution } : {},
284
+ ...typeof minBridged === "number" && Number.isInteger(minBridged) && minBridged >= 1 ? { godNodeMinBridged: minBridged } : {},
285
+ ...typeof maxResults === "number" && Number.isInteger(maxResults) && maxResults >= 0 ? { godNodeMaxResults: maxResults } : {}
286
+ };
287
+ }
288
+ function maskingClusterLabelPatterns(config) {
289
+ return (config.masking?.clusterLabels ?? []).filter(isValidGlob);
290
+ }
291
+ function watchSnapshotMinFileChanges(config) {
292
+ const value = config.watch?.snapshotMinFileChanges;
293
+ if (typeof value === "number" && Number.isFinite(value) && value >= 1) return value;
294
+ return DEFAULT_WATCH_SNAPSHOT_MIN_FILE_CHANGES;
295
+ }
296
+ function isPositiveInteger(value) {
297
+ return typeof value === "number" && Number.isInteger(value) && value >= 1;
298
+ }
299
+ function positiveIntegerOr(value, fallback) {
300
+ return isPositiveInteger(value) ? value : fallback;
301
+ }
302
+ function validMaxCommits(value) {
303
+ if (value === "all") return "all";
304
+ return isPositiveInteger(value) ? value : void 0;
305
+ }
306
+ function validSince(value) {
307
+ return typeof value === "string" && parseCoChangeSince(value) !== void 0 ? value : void 0;
308
+ }
309
+ function resolveCoChangeConfig(config, overrides = {}) {
310
+ const section = config.cochange;
311
+ const since = validSince(overrides.since) ?? validSince(section?.since);
312
+ return {
313
+ maxCommits: validMaxCommits(overrides.maxCommits) ?? validMaxCommits(section?.maxCommits) ?? DEFAULT_COCHANGE_MAX_COMMITS,
314
+ ...since !== void 0 ? { since } : {},
315
+ maxFilesPerCommit: positiveIntegerOr(section?.maxFilesPerCommit, DEFAULT_COCHANGE_MAX_FILES_PER_COMMIT),
316
+ minShared: positiveIntegerOr(section?.minShared, DEFAULT_COCHANGE_MIN_SHARED),
317
+ include: (section?.include ?? []).filter(isValidGlob),
318
+ exclude: (section?.exclude ?? []).filter(isValidGlob)
319
+ };
320
+ }
321
+ function nonNegativeOr(value, fallback) {
322
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
323
+ }
324
+ function positiveOr(value, fallback) {
325
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
326
+ }
327
+ function resolveContextsViewConfig(config) {
328
+ const section = config.contextsView;
329
+ const hubs = section?.hubs;
330
+ const topPercent = hubs?.topPercent;
331
+ return {
332
+ weights: {
333
+ dependency: nonNegativeOr(section?.weights?.dependency, 1),
334
+ coChange: nonNegativeOr(section?.weights?.coChange, 1),
335
+ data: nonNegativeOr(section?.weights?.data, 0)
336
+ },
337
+ exclude: (section?.exclude ?? []).filter(isValidGlob),
338
+ hubs: {
339
+ auto: hubs?.auto !== false,
340
+ topPercent: typeof topPercent === "number" && topPercent > 0 && topPercent <= 100 ? topPercent : 1,
341
+ minDependents: positiveIntegerOr(hubs?.minDependents, 10),
342
+ paths: (hubs?.paths ?? []).filter(isValidGlob)
343
+ },
344
+ resolution: positiveOr(section?.resolution, 1),
345
+ minCandidateFiles: positiveIntegerOr(section?.minCandidateFiles, 3),
346
+ technicalTerms: section?.technicalTerms ? [...section.technicalTerms] : [...DEFAULT_TECHNICAL_TERMS]
347
+ };
348
+ }
349
+ function makeGlobMatcher(patterns) {
350
+ if (patterns.length === 0) return () => false;
351
+ const matchers = patterns.map((p) => picomatch(p, { dot: true }));
352
+ return (input) => {
353
+ const normalised = input.replace(/\\/g, "/");
354
+ return matchers.some((m) => m(normalised));
355
+ };
356
+ }
357
+
358
+ // src/core/test-files/test-file-classifier.ts
359
+ var DEFAULT_TEST_GLOBS = [
360
+ "**/__tests__/**",
361
+ "**/*.test.*",
362
+ "**/*.spec.*",
363
+ "**/test/**",
364
+ "**/tests/**",
365
+ "**/*_test.go",
366
+ "**/src/test/**",
367
+ "**/*.Tests/**",
368
+ "**/*.Test/**",
369
+ "**/*Tests.cs",
370
+ "**/*Test.cs",
371
+ "**/test_*.py",
372
+ "**/*_test.py"
373
+ ];
374
+ var NAME_ONLY_TEST_GLOBS = ["**/*Tests.cs", "**/*Test.cs"];
375
+ var TEST_FRAMEWORK_ANNOTATIONS = [
376
+ "Fact",
377
+ "Theory",
378
+ "SkippableFact",
379
+ "SkippableTheory",
380
+ "Test",
381
+ "TestCase",
382
+ "TestCaseSource",
383
+ "TestFixture",
384
+ "TestMethod",
385
+ "DataTestMethod",
386
+ "TestClass"
387
+ ];
388
+ function normalisePath(path) {
389
+ return path.replace(/\\/g, "/");
390
+ }
391
+ function filesWithTestAnnotations(indices) {
392
+ const names = new Set(TEST_FRAMEWORK_ANNOTATIONS);
393
+ const files = /* @__PURE__ */ new Set();
394
+ for (const index of indices) {
395
+ const marked = index.symbols.some((symbol) => symbol.annotations?.some((annotation) => names.has(annotation.name)));
396
+ if (marked) files.add(normalisePath(index.file));
397
+ }
398
+ return files;
399
+ }
400
+ function hasCSharpAnnotations(indices) {
401
+ return indices.some(
402
+ (index) => index.file.endsWith(".cs") && index.symbols.some((symbol) => symbol.annotations && symbol.annotations.length > 0)
403
+ );
404
+ }
405
+ function createTestFileClassifier(extraGlobs = [], annotatedFiles, options = {}) {
406
+ const nameOnly = new Set(NAME_ONLY_TEST_GLOBS);
407
+ const defaults = options.annotationAware === true ? DEFAULT_TEST_GLOBS.filter((glob) => !nameOnly.has(glob)) : DEFAULT_TEST_GLOBS;
408
+ const matchesGlob = makeGlobMatcher([...defaults, ...extraGlobs]);
409
+ return (path) => annotatedFiles?.has(normalisePath(path)) === true || matchesGlob(path);
410
+ }
411
+
412
+ // src/adapters/git/simple-git-adapter.ts
413
+ import { spawnSync } from "child_process";
414
+ import { simpleGit } from "simple-git";
415
+ var log2 = createLogger("ctxo:git");
416
+ var NUL = "\0";
417
+ var RECORD_SEPARATOR = "";
418
+ var FIELD_SEPARATOR = "";
419
+ var CHANGE_LOG_FORMAT = "--format=%x1e%H%x1f%aI%x1f%ct%x1f%an%x1f%s";
420
+ var STABLE_OUTPUT_CONFIG = [
421
+ "-c",
422
+ "core.quotepath=off",
423
+ "-c",
424
+ "log.showSignature=false"
425
+ ];
426
+ function toForwardSlashes(path) {
427
+ return path.replace(/\\/g, "/");
428
+ }
429
+ function isFileChangeStatus(value) {
430
+ return value === "A" || value === "M" || value === "D" || value === "R" || value === "C" || value === "T";
431
+ }
432
+ function parseNameStatus(tokens) {
433
+ const changes = [];
434
+ let i = 0;
435
+ while (i < tokens.length) {
436
+ const kind = (tokens[i] ?? "").trim().charAt(0);
437
+ if (kind === "") {
438
+ i += 1;
439
+ } else if (kind === "R" || kind === "C") {
440
+ const oldPath = tokens[i + 1];
441
+ const path = tokens[i + 2];
442
+ i += 3;
443
+ if (oldPath !== void 0 && path !== void 0) {
444
+ changes.push({ status: kind, path: toForwardSlashes(path), oldPath: toForwardSlashes(oldPath) });
445
+ }
446
+ } else {
447
+ const path = tokens[i + 1];
448
+ i += 2;
449
+ if (path !== void 0 && isFileChangeStatus(kind)) {
450
+ changes.push({ status: kind, path: toForwardSlashes(path) });
451
+ }
452
+ }
453
+ }
454
+ return changes;
455
+ }
456
+ function parseSkipWorktree(raw) {
457
+ const paths = /* @__PURE__ */ new Set();
458
+ for (const record of raw.split(NUL)) {
459
+ if (record.length > 2 && record.charAt(0) === "S" && record.charAt(1) === " ") {
460
+ paths.add(toForwardSlashes(record.slice(2)));
461
+ }
462
+ }
463
+ return paths;
464
+ }
465
+ function parseChangeLog(raw) {
466
+ const commits = [];
467
+ for (const record of raw.split(RECORD_SEPARATOR)) {
468
+ const [header = "", ...tokens] = record.split(NUL);
469
+ const [hash, date, time, author, ...subject] = header.split(FIELD_SEPARATOR);
470
+ const committedAt = Number.parseInt(time ?? "", 10);
471
+ if (!hash || date === void 0 || author === void 0 || Number.isNaN(committedAt)) continue;
472
+ commits.push({
473
+ hash,
474
+ message: subject.join(FIELD_SEPARATOR).trim(),
475
+ date,
476
+ author,
477
+ committedAt,
478
+ changes: parseNameStatus(tokens)
479
+ });
480
+ }
481
+ return commits;
482
+ }
483
+ function rangeLogFormat(trailerKey) {
484
+ const trailers = trailerKey === void 0 ? "" : `%(trailers:key=${trailerKey},valueonly)`;
485
+ return `--format=%x1e%H%x1f%P%x1f%s%x1f${trailers}%x1f`;
486
+ }
487
+ function parseRangeLog(raw) {
488
+ const commits = [];
489
+ for (const record of raw.split(RECORD_SEPARATOR)) {
490
+ const [header = "", ...tokens] = record.split(NUL);
491
+ const [hash, parents, subject, trailerText] = header.split(FIELD_SEPARATOR);
492
+ if (!hash || parents === void 0 || subject === void 0) continue;
493
+ const isMerge = parents.trim().split(" ").filter((p) => p.length > 0).length > 1;
494
+ commits.push({
495
+ hash,
496
+ subject,
497
+ isMerge,
498
+ trailers: (trailerText ?? "").split("\n").map((t) => t.trim()).filter((t) => t.length > 0),
499
+ changes: isMerge ? [] : parseNameStatus(tokens)
500
+ });
501
+ }
502
+ return commits;
503
+ }
504
+ function parseStatus(raw) {
505
+ const tokens = raw.split(NUL);
506
+ const changes = [];
507
+ for (let i = 0; i < tokens.length; i++) {
508
+ const entry = tokens[i] ?? "";
509
+ if (entry.length < 4) continue;
510
+ const x = entry.charAt(0);
511
+ const y = entry.charAt(1);
512
+ const path = toForwardSlashes(entry.slice(3));
513
+ if (x === "R" || x === "C" || y === "R" || y === "C") {
514
+ const oldPath = tokens[++i];
515
+ if (oldPath === void 0) continue;
516
+ const status = x === "R" || y === "R" ? "R" : "C";
517
+ changes.push({ status, path, oldPath: toForwardSlashes(oldPath) });
518
+ } else if (x === "?") {
519
+ changes.push({ status: "A", path });
520
+ } else if (x === "D" || y === "D") {
521
+ changes.push({ status: "D", path });
522
+ } else if (x === "A" || y === "A") {
523
+ changes.push({ status: "A", path });
524
+ } else {
525
+ changes.push({ status: "M", path });
526
+ }
527
+ }
528
+ return changes;
529
+ }
530
+ function parseBranchNames(raw) {
531
+ const local = /* @__PURE__ */ new Set();
532
+ const remote = /* @__PURE__ */ new Set();
533
+ for (const line of raw.split("\n")) {
534
+ const ref = line.trim();
535
+ if (ref.startsWith("refs/heads/")) {
536
+ local.add(ref.slice("refs/heads/".length));
537
+ } else if (ref.startsWith("refs/remotes/") && !/^refs\/remotes\/[^/]+\/HEAD$/.test(ref)) {
538
+ remote.add(ref.slice("refs/remotes/".length));
539
+ }
540
+ }
541
+ const sorted = (set) => [...set].sort(compareCodepoint);
542
+ return { local: sorted(local), remote: sorted(remote), all: sorted(/* @__PURE__ */ new Set([...local, ...remote])) };
543
+ }
544
+ var TRAILER_KEY = /^[A-Za-z0-9][A-Za-z0-9-]*$/;
545
+ var SimpleGitAdapter = class {
546
+ git;
547
+ projectRoot;
548
+ constructor(projectRoot) {
549
+ this.projectRoot = projectRoot;
550
+ this.git = simpleGit(projectRoot);
551
+ log2.debug("Initialized with root: %s", projectRoot);
552
+ }
553
+ async isAvailable() {
554
+ try {
555
+ await this.git.version();
556
+ return true;
557
+ } catch {
558
+ return false;
559
+ }
560
+ }
561
+ async getCommitHistory(filePath, maxCount) {
562
+ try {
563
+ const log3 = await this.git.log({
564
+ file: filePath,
565
+ "--follow": null,
566
+ ...maxCount ? { maxCount } : {}
567
+ });
568
+ return log3.all.map((entry) => ({
569
+ hash: entry.hash,
570
+ message: entry.message,
571
+ date: entry.date,
572
+ author: entry.author_name
573
+ }));
574
+ } catch (err) {
575
+ log2.error(` Failed to get history for ${filePath}: ${err.message}`);
576
+ return [];
577
+ }
578
+ }
579
+ async getChangedFiles(since) {
580
+ try {
581
+ const diff = await this.git.diffSummary([since]);
582
+ return diff.files.map((f) => f.file.replace(/\\/g, "/"));
583
+ } catch (err) {
584
+ log2.error(` Failed to get changed files since ${since}: ${err.message}`);
585
+ return [];
586
+ }
587
+ }
588
+ async getFileChurn(filePath) {
589
+ try {
590
+ const log3 = await this.git.log({ file: filePath, "--follow": null });
591
+ return {
592
+ filePath,
593
+ commitCount: log3.total
594
+ };
595
+ } catch (err) {
596
+ log2.error(` Failed to get churn for ${filePath}: ${err.message}`);
597
+ return { filePath, commitCount: 0 };
598
+ }
599
+ }
600
+ async getHeadSha() {
601
+ try {
602
+ const raw = await this.git.revparse(["--short", "HEAD"]);
603
+ const sha = raw.trim();
604
+ return sha.length > 0 ? sha : void 0;
605
+ } catch (err) {
606
+ log2.error(`Failed to resolve HEAD: ${err.message}`);
607
+ return void 0;
608
+ }
609
+ }
610
+ async countCommitsBetween(base) {
611
+ try {
612
+ const raw = await this.git.raw(["rev-list", "--count", `${base}..HEAD`]);
613
+ const n = parseInt(raw.trim(), 10);
614
+ return Number.isFinite(n) && n >= 0 ? n : void 0;
615
+ } catch (err) {
616
+ log2.error(`Failed to count commits since ${base}: ${err.message}`);
617
+ return void 0;
618
+ }
619
+ }
620
+ async getChangeLog(maxCommits) {
621
+ try {
622
+ const headRaw = await this.git.raw([...STABLE_OUTPUT_CONFIG, "log", "-1", "--format=%h%x1f%ct", "HEAD"]);
623
+ const [head, headTime] = headRaw.trim().split(FIELD_SEPARATOR);
624
+ const headCommittedAt = Number.parseInt(headTime ?? "", 10);
625
+ if (!head || Number.isNaN(headCommittedAt)) {
626
+ throw new Error(`unexpected HEAD description ${JSON.stringify(headRaw)}`);
627
+ }
628
+ const args = [...STABLE_OUTPUT_CONFIG, "log", "--no-merges", "-M", "--name-status", "-z", CHANGE_LOG_FORMAT];
629
+ if (maxCommits !== "all") args.push(`--max-count=${maxCommits}`);
630
+ args.push("HEAD");
631
+ return { head, headCommittedAt, commits: parseChangeLog(await this.git.raw(args)) };
632
+ } catch (err) {
633
+ log2.error(`Failed to read change log: ${err.message}`);
634
+ return void 0;
635
+ }
636
+ }
637
+ async getSkipWorktreePaths() {
638
+ try {
639
+ return parseSkipWorktree(await this.git.raw([...STABLE_OUTPUT_CONFIG, "ls-files", "-t", "-z"]));
640
+ } catch (err) {
641
+ log2.error(`Failed to list skip-worktree paths: ${err.message}`);
642
+ return void 0;
643
+ }
644
+ }
645
+ async getIgnoredPaths(paths) {
646
+ if (paths.length === 0) return /* @__PURE__ */ new Set();
647
+ const result = spawnSync("git", [...STABLE_OUTPUT_CONFIG, "check-ignore", "-z", "--stdin"], {
648
+ cwd: this.projectRoot,
649
+ input: paths.map((path) => `${path}${NUL}`).join(""),
650
+ encoding: "utf-8",
651
+ maxBuffer: 64 * 1024 * 1024
652
+ });
653
+ if (result.error === void 0 && result.status === 1) return /* @__PURE__ */ new Set();
654
+ if (result.error !== void 0 || result.status !== 0) {
655
+ const reason = (result.error?.message ?? result.stderr ?? "").replace(/\s+/g, " ").trim();
656
+ log2.error(`Failed to check ignored paths: ${reason}`);
657
+ return void 0;
658
+ }
659
+ return new Set(
660
+ result.stdout.split(NUL).filter((path) => path.length > 0).map(toForwardSlashes)
661
+ );
662
+ }
663
+ async resolveCommit(ref) {
664
+ if (this.refuseOptionLike("resolve", [ref])) return void 0;
665
+ try {
666
+ const sha = (await this.git.raw([...STABLE_OUTPUT_CONFIG, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`])).trim();
667
+ return sha.length > 0 ? sha : void 0;
668
+ } catch (err) {
669
+ log2.debug(`Cannot resolve ${ref}: ${err.message}`);
670
+ return void 0;
671
+ }
672
+ }
673
+ async mergeBase(a, b) {
674
+ if (this.refuseOptionLike("merge-base", [a, b])) return void 0;
675
+ try {
676
+ const sha = (await this.git.raw([...STABLE_OUTPUT_CONFIG, "merge-base", a, b])).trim();
677
+ return sha.length > 0 ? sha : void 0;
678
+ } catch (err) {
679
+ log2.debug(`No merge base for ${a} and ${b}: ${err.message}`);
680
+ return void 0;
681
+ }
682
+ }
683
+ async isAncestor(ancestor, descendant) {
684
+ if (this.refuseOptionLike("is-ancestor", [ancestor, descendant])) return void 0;
685
+ const result = spawnSync("git", [...STABLE_OUTPUT_CONFIG, "merge-base", "--is-ancestor", ancestor, descendant], {
686
+ cwd: this.projectRoot,
687
+ encoding: "utf-8"
688
+ });
689
+ if (result.error === void 0 && result.status === 0) return true;
690
+ if (result.error === void 0 && result.status === 1) return false;
691
+ log2.debug(`Cannot tell whether ${ancestor} is an ancestor of ${descendant}`);
692
+ return void 0;
693
+ }
694
+ async getRangeLog(range, options) {
695
+ if (this.refuseOptionLike("range log", [range.head, ...range.exclude])) return void 0;
696
+ const trailerKey = options?.trailerKey;
697
+ if (trailerKey !== void 0 && !TRAILER_KEY.test(trailerKey)) {
698
+ log2.error(`Refusing to read trailers for invalid key ${JSON.stringify(trailerKey)}`);
699
+ return void 0;
700
+ }
701
+ try {
702
+ const raw = await this.git.raw([
703
+ ...STABLE_OUTPUT_CONFIG,
704
+ "log",
705
+ "-M",
706
+ "--name-status",
707
+ "-z",
708
+ rangeLogFormat(trailerKey),
709
+ range.head,
710
+ ...range.exclude.map((ref) => `^${ref}`),
711
+ "--"
712
+ ]);
713
+ return parseRangeLog(raw);
714
+ } catch (err) {
715
+ log2.error(`Failed to read range log of ${range.head}: ${err.message}`);
716
+ return void 0;
717
+ }
718
+ }
719
+ async getWorkingTreeChanges(scope) {
720
+ try {
721
+ if (scope === "staged") {
722
+ const raw2 = await this.git.raw([...STABLE_OUTPUT_CONFIG, "diff", "--cached", "-M", "--name-status", "-z"]);
723
+ return parseNameStatus(raw2.split(NUL));
724
+ }
725
+ const raw = await this.git.raw([...STABLE_OUTPUT_CONFIG, "-c", "status.renames=true", "status", "--porcelain=v1", "-z", "--untracked-files=all"]);
726
+ return parseStatus(raw);
727
+ } catch (err) {
728
+ log2.error(`Failed to read ${scope} working-tree changes: ${err.message}`);
729
+ return void 0;
730
+ }
731
+ }
732
+ async getRefs() {
733
+ let names;
734
+ try {
735
+ names = parseBranchNames(
736
+ await this.git.raw([...STABLE_OUTPUT_CONFIG, "for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes"])
737
+ );
738
+ } catch (err) {
739
+ log2.error(`Failed to list branches: ${err.message}`);
740
+ return void 0;
741
+ }
742
+ const currentBranch = await this.tryRaw(["symbolic-ref", "--quiet", "--short", "HEAD"]);
743
+ const upstream = await this.tryRaw(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
744
+ const originHead = await this.tryRaw(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
745
+ return {
746
+ ...currentBranch !== void 0 ? { currentBranch } : {},
747
+ ...upstream !== void 0 ? { upstream } : {},
748
+ ...originHead !== void 0 ? { originHead } : {},
749
+ branches: names.all,
750
+ localBranches: names.local,
751
+ remoteBranches: names.remote
752
+ };
753
+ }
754
+ async listTrackedFiles() {
755
+ try {
756
+ const raw = await this.git.raw([...STABLE_OUTPUT_CONFIG, "ls-files", "-z"]);
757
+ return raw.split(NUL).filter((path) => path.length > 0).map(toForwardSlashes);
758
+ } catch (err) {
759
+ log2.error(`Failed to list tracked files: ${err.message}`);
760
+ return void 0;
761
+ }
762
+ }
763
+ /** Runs one git call whose failure only means "no such fact"; logged at debug, not error. */
764
+ async tryRaw(args) {
765
+ try {
766
+ const out = (await this.git.raw([...STABLE_OUTPUT_CONFIG, ...args])).trim();
767
+ return out.length > 0 ? out : void 0;
768
+ } catch (err) {
769
+ log2.debug(`git ${args[0]} unavailable: ${err.message}`);
770
+ return void 0;
771
+ }
772
+ }
773
+ /** A ref is never an option; true (and logged) when one would be parsed as such. */
774
+ refuseOptionLike(what, refs) {
775
+ const bad = refs.find((ref) => ref.startsWith("-"));
776
+ if (bad === void 0) return false;
777
+ log2.error(`Refusing ${what} for option-like ref ${bad}`);
778
+ return true;
779
+ }
780
+ async getCommitFileChanges(rev) {
781
+ if (rev.startsWith("-")) {
782
+ log2.error(`Refusing to read file changes for option-like revision ${rev}`);
783
+ return void 0;
784
+ }
785
+ try {
786
+ const raw = await this.git.raw([
787
+ ...STABLE_OUTPUT_CONFIG,
788
+ "diff-tree",
789
+ "--root",
790
+ "--no-commit-id",
791
+ "-r",
792
+ "-M",
793
+ "--name-status",
794
+ "-z",
795
+ rev
796
+ ]);
797
+ return parseNameStatus(raw.split(NUL));
798
+ } catch (err) {
799
+ log2.error(`Failed to read file changes of ${rev}: ${err.message}`);
800
+ return void 0;
801
+ }
802
+ }
803
+ };
804
+
805
+ // src/core/data-coupling/data-kinds.ts
806
+ import { DATA_RESOURCE_KINDS } from "@ctxo/plugin-api";
807
+ var DATA_EDGE_KINDS = /* @__PURE__ */ new Set(["reads", "writes", "executes"]);
808
+ var PROCEDURE_KINDS = ["procedure"];
809
+ function isDataResourceKind(kind) {
810
+ return DATA_RESOURCE_KINDS.includes(kind);
811
+ }
812
+ function dataReferenceKinds(kind) {
813
+ if (isDataResourceKind(kind)) return DATA_RESOURCE_KINDS;
814
+ if (kind === "procedure") return PROCEDURE_KINDS;
815
+ return void 0;
816
+ }
817
+
818
+ // src/core/graph/symbol-graph.ts
819
+ var isDefaultTestFile = createTestFileClassifier();
820
+ var DOCUMENTATION_FOLDERS = /* @__PURE__ */ new Set(["docs", "doc", "documentation"]);
821
+ function asciiLower(text) {
822
+ return text.replace(/[A-Z]/g, (c) => String.fromCharCode(c.charCodeAt(0) + 32));
823
+ }
824
+ function isUnderDocumentationFolder(file) {
825
+ const folders = file.replace(/\\/g, "/").split("/").slice(0, -1);
826
+ return folders.some((folder) => DOCUMENTATION_FOLDERS.has(asciiLower(folder)));
827
+ }
828
+ function definitionRank(symbolId) {
829
+ const at = symbolId.indexOf("::");
830
+ const file = at < 0 ? symbolId : symbolId.slice(0, at);
831
+ return (isDefaultTestFile(file) ? 2 : 0) + (isUnderDocumentationFolder(file) ? 1 : 0);
832
+ }
833
+ var SymbolGraph = class {
834
+ nodes = /* @__PURE__ */ new Map();
835
+ nodesByFileAndName = /* @__PURE__ */ new Map();
836
+ nodesByNameAndKind = /* @__PURE__ */ new Map();
837
+ forwardEdges = /* @__PURE__ */ new Map();
838
+ reverseEdges = /* @__PURE__ */ new Map();
839
+ definitionRanks = /* @__PURE__ */ new Map();
840
+ edgeSet = /* @__PURE__ */ new Set();
841
+ addNode(node) {
842
+ this.nodes.set(node.symbolId, node);
843
+ const parts = node.symbolId.split("::");
844
+ const [p0, p1] = parts;
845
+ if (p0 !== void 0 && p1 !== void 0) {
846
+ this.nodesByFileAndName.set(`${p0}::${p1}`, node);
847
+ }
848
+ if (parts.length === 3) {
849
+ const nk = `${parts[1]}::${parts[2]}`;
850
+ const list = this.nodesByNameAndKind.get(nk);
851
+ if (list) list.push(node);
852
+ else this.nodesByNameAndKind.set(nk, [node]);
853
+ }
854
+ }
855
+ addEdge(edge) {
856
+ const resolvedFrom = this.resolveNodeId(edge.from);
857
+ const resolvedTo = this.resolveNodeId(edge.to);
858
+ const resolvedEdge = { from: resolvedFrom, to: resolvedTo, kind: edge.kind };
859
+ const edgeKey = `${resolvedEdge.from}|${resolvedEdge.to}|${resolvedEdge.kind}`;
860
+ if (this.edgeSet.has(edgeKey)) return;
861
+ this.edgeSet.add(edgeKey);
862
+ const forward = this.forwardEdges.get(resolvedEdge.from) ?? [];
863
+ forward.push(resolvedEdge);
864
+ this.forwardEdges.set(resolvedEdge.from, forward);
865
+ const reverse = this.reverseEdges.get(resolvedEdge.to) ?? [];
866
+ reverse.push(resolvedEdge);
867
+ this.reverseEdges.set(resolvedEdge.to, reverse);
868
+ }
869
+ getNode(symbolId) {
870
+ return this.nodes.get(symbolId) ?? this.resolveNodeFuzzy(symbolId);
871
+ }
872
+ resolveNodeId(id) {
873
+ if (this.nodes.has(id)) return id;
874
+ const segs = id.split("::");
875
+ const [p0, p1] = segs;
876
+ if (p0 !== void 0 && p1 !== void 0) {
877
+ const fuzzyKey = `${p0}::${p1}`;
878
+ const match = this.nodesByFileAndName.get(fuzzyKey);
879
+ if (match) return match.symbolId;
880
+ const jsToTs = p0.replace(/\.js$/, ".ts");
881
+ if (jsToTs !== p0) {
882
+ const altKey = `${jsToTs}::${p1}`;
883
+ const altMatch = this.nodesByFileAndName.get(altKey);
884
+ if (altMatch) return altMatch.symbolId;
885
+ }
886
+ }
887
+ const dataMatch = this.resolveDataReference(segs);
888
+ if (dataMatch) return dataMatch.symbolId;
889
+ const nameKind = this.extractNameKind(segs);
890
+ if (nameKind) {
891
+ const candidates = this.nodesByNameAndKind.get(nameKind);
892
+ if (candidates && candidates.length === 1) return candidates[0].symbolId;
893
+ }
894
+ return id;
895
+ }
896
+ resolveNodeFuzzy(id) {
897
+ const segs = id.split("::");
898
+ const [p0, p1] = segs;
899
+ if (p0 !== void 0 && p1 !== void 0) {
900
+ const match = this.nodesByFileAndName.get(`${p0}::${p1}`);
901
+ if (match) return match;
902
+ const jsToTs = p0.replace(/\.js$/, ".ts");
903
+ if (jsToTs !== p0) {
904
+ const tsMatch = this.nodesByFileAndName.get(`${jsToTs}::${p1}`);
905
+ if (tsMatch) return tsMatch;
906
+ }
907
+ }
908
+ const dataMatch = this.resolveDataReference(segs);
909
+ if (dataMatch) return dataMatch;
910
+ const nameKind = this.extractNameKind(segs);
911
+ if (nameKind) {
912
+ const candidates = this.nodesByNameAndKind.get(nameKind);
913
+ if (candidates && candidates.length === 1) return candidates[0];
914
+ }
915
+ return void 0;
916
+ }
917
+ /**
918
+ * Resolves a 2-part data reference (`dbo.loans::table`). A table or view reference is kind-agnostic
919
+ * across data resource kinds, a procedure reference matches procedures only. Several definitions
920
+ * resolve by `definitionRank` (a production copy before a documentation copy before a test copy), then
921
+ * to the codepoint-smallest symbolId, which is the first file in sorted path order. The result does
922
+ * not depend on insertion order.
923
+ */
924
+ resolveDataReference(segs) {
925
+ if (segs.length !== 2) return void 0;
926
+ const [name, kind] = segs;
927
+ const kinds = dataReferenceKinds(kind);
928
+ if (!kinds) return void 0;
929
+ let best;
930
+ for (const k of kinds) {
931
+ for (const node of this.nodesByNameAndKind.get(`${name}::${k}`) ?? []) {
932
+ if (!best || this.compareDefinitions(node, best) < 0) best = node;
933
+ }
934
+ }
935
+ return best;
936
+ }
937
+ /** The tie-break order of same-name definitions: `definitionRank`, then the codepoint-smaller symbolId. */
938
+ compareDefinitions = (a, b) => this.definitionRankOf(a.symbolId) - this.definitionRankOf(b.symbolId) || compareCodepoint(a.symbolId, b.symbolId);
939
+ /**
940
+ * The identity of the table, view or procedure that `id` names: a full id of any of its definitions, or a
941
+ * 2-part reference such as `dbo.loans::table`. Undefined when `id` names no node, or a node of another
942
+ * kind. A table or view identity spans both kinds, as a 2-part `::table` reference does.
943
+ */
944
+ dataResourceIdentity(id) {
945
+ const node = this.getNode(id);
946
+ return node ? this.identityOf(node) : void 0;
947
+ }
948
+ identityOf(node) {
949
+ const segs = node.symbolId.split("::");
950
+ if (segs.length !== 3) return void 0;
951
+ const [, name, kind] = segs;
952
+ const kinds = dataReferenceKinds(kind);
953
+ if (!kinds) return void 0;
954
+ const byId = /* @__PURE__ */ new Map();
955
+ for (const k of kinds) {
956
+ for (const definition of this.nodesByNameAndKind.get(`${name}::${k}`) ?? []) {
957
+ byId.set(definition.symbolId, this.nodes.get(definition.symbolId) ?? definition);
958
+ }
959
+ }
960
+ const definitions = [...byId.values()].sort(this.compareDefinitions);
961
+ const winner = definitions[0] ?? node;
962
+ const production = definitions.filter((definition) => this.definitionRankOf(definition.symbolId) === 0);
963
+ return { canonicalId: `${name}::${winner.kind}`, winner, definitions, production };
964
+ }
965
+ /** `definitionRank`, cached per symbolId (the glob match runs once per definition). */
966
+ definitionRankOf(symbolId) {
967
+ let rank = this.definitionRanks.get(symbolId);
968
+ if (rank === void 0) {
969
+ rank = definitionRank(symbolId);
970
+ this.definitionRanks.set(symbolId, rank);
971
+ }
972
+ return rank;
973
+ }
974
+ /**
975
+ * Extracts a `name::kind` key from the segments of a reference ID, supporting:
976
+ * - 2-part: `Bar::class` → `Bar::class`
977
+ * - 3-part: `pkg.Bar::helper::method` → `helper::method`
978
+ * Returns undefined for 1-part IDs (no kind information available).
979
+ */
980
+ extractNameKind(segs) {
981
+ if (segs.length === 3) return `${segs[1]}::${segs[2]}`;
982
+ if (segs.length === 2) return `${segs[0]}::${segs[1]}`;
983
+ return void 0;
984
+ }
985
+ getForwardEdges(symbolId) {
986
+ return this.forwardEdges.get(symbolId) ?? [];
987
+ }
988
+ getReverseEdges(symbolId) {
989
+ return this.reverseEdges.get(symbolId) ?? [];
990
+ }
991
+ /**
992
+ * The dependents of a resolved symbolId, as the impact tools read them. For a definition of a table, view
993
+ * or procedure these are the incoming edges of every same-name definition, which in practice are the
994
+ * winner's: any copy then reports the dependents of the resource. For every other id they are its own
995
+ * incoming edges, as `getReverseEdges` gives them.
996
+ */
997
+ getDependentEdges(symbolId) {
998
+ const node = this.nodes.get(symbolId);
999
+ const definitions = node ? this.identityOf(node)?.definitions : void 0;
1000
+ if (!definitions || definitions.length < 2) return this.getReverseEdges(symbolId);
1001
+ return definitions.flatMap((definition) => this.getReverseEdges(definition.symbolId));
1002
+ }
1003
+ hasNode(symbolId) {
1004
+ return this.nodes.has(symbolId) || this.resolveNodeFuzzy(symbolId) !== void 0;
1005
+ }
1006
+ get nodeCount() {
1007
+ return this.nodes.size;
1008
+ }
1009
+ get edgeCount() {
1010
+ return this.edgeSet.size;
1011
+ }
1012
+ allNodes() {
1013
+ return [...this.nodes.values()];
1014
+ }
1015
+ allEdges() {
1016
+ const edges = [];
1017
+ for (const list of this.forwardEdges.values()) {
1018
+ edges.push(...list);
1019
+ }
1020
+ return edges;
1021
+ }
1022
+ };
1023
+
1024
+ export {
1025
+ parseCoChangeSince,
1026
+ windowCutoff,
1027
+ selectWindow,
1028
+ loadConfig,
1029
+ statsEnabled,
1030
+ indexIgnorePatterns,
1031
+ indexIgnoreProjectPatterns,
1032
+ factsRequested,
1033
+ indexTestPatterns,
1034
+ communityDetectorOptions,
1035
+ maskingClusterLabelPatterns,
1036
+ watchSnapshotMinFileChanges,
1037
+ resolveCoChangeConfig,
1038
+ resolveContextsViewConfig,
1039
+ makeGlobMatcher,
1040
+ TEST_FRAMEWORK_ANNOTATIONS,
1041
+ filesWithTestAnnotations,
1042
+ hasCSharpAnnotations,
1043
+ createTestFileClassifier,
1044
+ SimpleGitAdapter,
1045
+ DATA_EDGE_KINDS,
1046
+ isDataResourceKind,
1047
+ SymbolGraph
1048
+ };
1049
+ //# sourceMappingURL=chunk-7HSA5JVP.js.map