syncstaff-mcp 0.2.3

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 (52) hide show
  1. package/README.md +86 -0
  2. package/dist/lib/agent-state.js +119 -0
  3. package/dist/lib/blast.js +462 -0
  4. package/dist/lib/client-config.js +81 -0
  5. package/dist/lib/env-compat.js +66 -0
  6. package/dist/lib/globs.js +0 -0
  7. package/dist/lib/ids.js +24 -0
  8. package/dist/lib/index/aliases.js +244 -0
  9. package/dist/lib/index/call-sites.js +178 -0
  10. package/dist/lib/index/checker-resolver.js +257 -0
  11. package/dist/lib/index/context-card.js +140 -0
  12. package/dist/lib/index/coverage.js +218 -0
  13. package/dist/lib/index/delivery.js +66 -0
  14. package/dist/lib/index/discovery.js +90 -0
  15. package/dist/lib/index/embedding.js +110 -0
  16. package/dist/lib/index/file-index.js +222 -0
  17. package/dist/lib/index/fingerprint.js +0 -0
  18. package/dist/lib/index/git-history.js +136 -0
  19. package/dist/lib/index/graph.js +234 -0
  20. package/dist/lib/index/impact.js +174 -0
  21. package/dist/lib/index/incremental.js +332 -0
  22. package/dist/lib/index/lexical.js +462 -0
  23. package/dist/lib/index/order.js +43 -0
  24. package/dist/lib/index/pages.js +357 -0
  25. package/dist/lib/index/persistence.js +233 -0
  26. package/dist/lib/index/pipeline.js +527 -0
  27. package/dist/lib/index/registry.js +106 -0
  28. package/dist/lib/index/resolve.js +280 -0
  29. package/dist/lib/index/semantic.js +381 -0
  30. package/dist/lib/index/surfaces.js +27 -0
  31. package/dist/lib/index/symbols.js +426 -0
  32. package/dist/lib/index/transformers-embedder.js +73 -0
  33. package/dist/lib/index/typescript-parser.js +532 -0
  34. package/dist/lib/index/vector-cache.js +176 -0
  35. package/dist/lib/index/verification.js +58 -0
  36. package/dist/lib/mcp-compaction.js +241 -0
  37. package/dist/lib/model-roles.js +206 -0
  38. package/dist/lib/path-warnings.js +90 -0
  39. package/dist/lib/protocol.js +95 -0
  40. package/dist/lib/types.js +69 -0
  41. package/dist/lib/version.js +21 -0
  42. package/dist/lib/worktree.js +211 -0
  43. package/dist/mcp/approval.js +0 -0
  44. package/dist/mcp/cloud-connector.js +99 -0
  45. package/dist/mcp/daemon-client.js +156 -0
  46. package/dist/mcp/daemon-protocol.js +100 -0
  47. package/dist/mcp/escalation-waiter.js +183 -0
  48. package/dist/mcp/graph-ops.js +169 -0
  49. package/dist/mcp/index.js +1151 -0
  50. package/dist/mcp/login.js +169 -0
  51. package/dist/mcp/setup.js +90 -0
  52. package/package.json +42 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Which files the local index is allowed to look at.
3
+ *
4
+ * Git is asked first, and not for speed. `git ls-files` already knows the
5
+ * answer to the question that actually matters — what is tracked, and what
6
+ * .gitignore excludes — and it knows it the same way for every agent on every
7
+ * machine. A hand-rolled walk with a hand-maintained ignore list would drift
8
+ * from .gitignore the first time someone edited one and not the other, and two
9
+ * agents whose indexes disagree about which files exist will disagree about
10
+ * blast radius while both believe they are correct.
11
+ *
12
+ * The walk is the fallback for a checkout that is not a git repository, which
13
+ * happens in tests and in extracted tarballs.
14
+ */
15
+ import { execFileSync } from "node:child_process";
16
+ import { readdirSync, statSync } from "node:fs";
17
+ import { join, relative, sep } from "node:path";
18
+ /**
19
+ * Directories never worth indexing, used only by the non-git fallback.
20
+ *
21
+ * Deliberately short. Anything longer is a second ignore list competing with
22
+ * .gitignore, which is the drift this module exists to avoid.
23
+ */
24
+ export const DEFAULT_IGNORED_DIRECTORIES = [
25
+ ".git",
26
+ "node_modules",
27
+ "dist",
28
+ "build",
29
+ ".next",
30
+ "coverage",
31
+ "__pycache__",
32
+ ".venv",
33
+ ];
34
+ const runGitDefault = (args, cwd) => execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
35
+ /** POSIX separators regardless of host, so a fingerprint is portable. */
36
+ const posix = (path) => path.split(sep).join("/");
37
+ /**
38
+ * Every indexable file under `root`, repo-root-relative and sorted.
39
+ *
40
+ * Sorted because the order is an input to the index fingerprint, and readdir
41
+ * order is a property of the filesystem rather than of the repository. Two
42
+ * machines with identical content must produce an identical list.
43
+ */
44
+ export function discoverFiles(root, options = {}) {
45
+ const exclude = options.exclude ?? [];
46
+ const excluded = (path) => exclude.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
47
+ let files;
48
+ try {
49
+ const runGit = options.runGit ?? runGitDefault;
50
+ // -c: tracked. -o: untracked but not ignored — an agent's new file is real
51
+ // work and excluding it would make the index blind to exactly the code
52
+ // most likely to be under an active lease.
53
+ const output = runGit(["ls-files", "-co", "--exclude-standard"], root);
54
+ files = output.split("\n").map((line) => line.trim()).filter(Boolean);
55
+ }
56
+ catch {
57
+ files = walk(root, root);
58
+ }
59
+ const unique = [...new Set(files.map(posix))].filter((path) => !excluded(path)).sort();
60
+ return options.limit === undefined ? unique : unique.slice(0, options.limit);
61
+ }
62
+ function walk(root, dir) {
63
+ const found = [];
64
+ let entries;
65
+ try {
66
+ entries = readdirSync(dir);
67
+ }
68
+ catch {
69
+ // An unreadable directory is a gap in coverage, not a reason to abandon
70
+ // the whole index. The caller learns about it from the file count.
71
+ return found;
72
+ }
73
+ for (const entry of entries) {
74
+ if (DEFAULT_IGNORED_DIRECTORIES.includes(entry))
75
+ continue;
76
+ const full = join(dir, entry);
77
+ let stats;
78
+ try {
79
+ stats = statSync(full);
80
+ }
81
+ catch {
82
+ continue; // a symlink to nowhere, or a file deleted mid-walk
83
+ }
84
+ if (stats.isDirectory())
85
+ found.push(...walk(root, full));
86
+ else if (stats.isFile())
87
+ found.push(posix(relative(root, full)));
88
+ }
89
+ return found;
90
+ }
@@ -0,0 +1,110 @@
1
+ /** Dimensions. A power of two, small enough that 300 pages cost microseconds. */
2
+ const DIMENSIONS = 256;
3
+ /**
4
+ * FNV-1a, 32-bit. Chosen because it is short enough to read and verify, has no
5
+ * seed to get wrong, and is identical on every machine. Not a cryptographic
6
+ * hash and does not need to be: collisions cost a little precision, never
7
+ * correctness, and the signed trick below cancels much of their bias.
8
+ */
9
+ function fnv1a(text) {
10
+ let hash = 0x811c9dc5;
11
+ for (let i = 0; i < text.length; i += 1) {
12
+ hash ^= text.charCodeAt(i);
13
+ // >>> 0 keeps it an unsigned 32-bit value; Math.imul is the only
14
+ // multiplication that does not lose precision above 2^53 here.
15
+ hash = Math.imul(hash, 0x01000193) >>> 0;
16
+ }
17
+ return hash >>> 0;
18
+ }
19
+ /**
20
+ * Words, from prose or from an identifier.
21
+ *
22
+ * `buildContextCard` and `build_context_card` and `BUILD_CONTEXT_CARD` all
23
+ * yield the same three words. Without this an embedder over source metadata is
24
+ * mostly blind: identifiers are where the vocabulary of a codebase lives, and
25
+ * they are compound by convention rather than separated by spaces.
26
+ *
27
+ * `toLowerCase()` is called on ASCII-split fragments only. It is locale-
28
+ * sensitive in general — the Turkish dotless i is the classic example — so it
29
+ * must never touch a fragment that could contain one. The split below keeps
30
+ * only [A-Za-z0-9], which makes the lowering safe and machine-independent.
31
+ */
32
+ export function tokenize(text) {
33
+ const words = [];
34
+ for (const chunk of text.split(/[^A-Za-z0-9]+/)) {
35
+ if (!chunk)
36
+ continue;
37
+ // Split camelCase and PascalCase, keeping acronym runs together:
38
+ // "HTTPServer" -> ["HTTP", "Server"], "buildCard" -> ["build", "Card"].
39
+ for (const part of chunk.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z]+)(?=[A-Z][a-z])/)) {
40
+ if (part.length > 1)
41
+ words.push(part.toLowerCase());
42
+ }
43
+ }
44
+ return words;
45
+ }
46
+ /** Character trigrams of a word, padded, for tolerance of plurals and typos. */
47
+ function trigrams(word) {
48
+ const padded = `^${word}$`;
49
+ const grams = [];
50
+ for (let i = 0; i + 3 <= padded.length; i += 1)
51
+ grams.push(padded.slice(i, i + 3));
52
+ return grams;
53
+ }
54
+ /**
55
+ * Hash one feature into the vector.
56
+ *
57
+ * The sign comes from a second, independent bit of the hash. Feature hashing
58
+ * without it biases every collision upward, so unrelated documents drift
59
+ * toward a positive cosine and the relevance floor stops meaning anything;
60
+ * with it, collisions cancel in expectation.
61
+ */
62
+ function accumulate(vector, feature, weight) {
63
+ const hash = fnv1a(feature);
64
+ const bucket = hash % DIMENSIONS;
65
+ const sign = (hash >>> 31) === 1 ? -1 : 1;
66
+ vector[bucket] += sign * weight;
67
+ }
68
+ /**
69
+ * A deterministic, dependency-free embedder.
70
+ *
71
+ * Whole words carry more weight than trigrams: an exact identifier match
72
+ * should outrank a page that merely shares letters. The ratio is a judgement
73
+ * call, not a measured optimum, and it is stated here rather than buried so
74
+ * that anyone tuning it knows it was never fitted to anything.
75
+ */
76
+ export function createHashingEmbedder(options = {}) {
77
+ const wordWeight = options.wordWeight ?? 1;
78
+ const trigramWeight = options.trigramWeight ?? 0.35;
79
+ return {
80
+ id: HASHING_EMBEDDER_ID,
81
+ dimensions: DIMENSIONS,
82
+ async embed(text) {
83
+ const vector = new Float64Array(DIMENSIONS);
84
+ for (const word of tokenize(text)) {
85
+ accumulate(vector, word, wordWeight);
86
+ for (const gram of trigrams(word))
87
+ accumulate(vector, gram, trigramWeight);
88
+ }
89
+ // L2 normalise so cosine is a dot product and long pages do not outrank
90
+ // short ones for having more of everything. An all-zero vector — empty
91
+ // or punctuation-only text — is returned as zeros rather than divided by
92
+ // zero, and cosine() already treats a zero vector as similarity 0.
93
+ let norm = 0;
94
+ for (const value of vector)
95
+ norm += value * value;
96
+ if (norm === 0)
97
+ return Array.from(vector);
98
+ const scale = 1 / Math.sqrt(norm);
99
+ return Array.from(vector, (value) => value * scale);
100
+ },
101
+ };
102
+ }
103
+ /**
104
+ * The embedder the pipeline uses unless a caller supplies one.
105
+ *
106
+ * Named so a reader of a results file can tell which vectors produced it, and
107
+ * so `pipeline_coverage` in the Phase 0 benchmark can report something more
108
+ * useful than `vector: true`.
109
+ */
110
+ export const HASHING_EMBEDDER_ID = "hashing-v1(fnv1a,256d,word+trigram)";
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The local file index: discovery, parsing, and an honest account of both.
3
+ *
4
+ * `IndexCoverage` is the part that earns its place. Every consumer downstream
5
+ * of this — blast radius, surface deltas, context cards — is about to make a
6
+ * claim about a repository, and the difference between a trustworthy claim and
7
+ * `grep-v1` is whether the claim knows what it could not see. An index that
8
+ * understood 40% of a repository and one that understood 99% produce
9
+ * answer-shaped output either way; only the coverage figure distinguishes
10
+ * "there are no other call sites" from "I found no other call sites".
11
+ *
12
+ * So coverage is not a statistic for a dashboard. It is the caller's licence
13
+ * to say "none" instead of "none found".
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { Worker } from "node:worker_threads";
18
+ import os from "node:os";
19
+ import { aliasSignature, discoverAliases } from "./aliases.js";
20
+ import { discoverFiles } from "./discovery.js";
21
+ import { hashContent, indexFingerprint } from "./fingerprint.js";
22
+ const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
23
+ /** How far in to look for a NUL before calling a file text. Git uses the same trick. */
24
+ const BINARY_SNIFF_CHARS = 8000;
25
+ /**
26
+ * Is this file bytes rather than text?
27
+ *
28
+ * Discovery yields every tracked file, including PNGs, fonts, zips and
29
+ * certificates, and reading one as UTF-8 succeeds — it just produces mojibake.
30
+ * Handing that to the lexical index is not merely wasteful: on Playwright, 448
31
+ * such files carried 35MB of decoded rubbish and supplied 64k of the 124k terms
32
+ * in the vocabulary. Half the index was noise, every real term's IDF was
33
+ * computed against it, and `averageLength` was inflated enough to distort BM25
34
+ * length normalisation for every genuine file in the repository.
35
+ *
36
+ * A NUL byte in the first few KB is the standard test and costs one scan.
37
+ */
38
+ export function isProbablyBinary(source) {
39
+ const limit = Math.min(source.length, BINARY_SNIFF_CHARS);
40
+ for (let index = 0; index < limit; index += 1) {
41
+ if (source.charCodeAt(index) === 0)
42
+ return true;
43
+ }
44
+ return false;
45
+ }
46
+ export function buildFileIndex(root, registry, options = {}) {
47
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
48
+ const readFile = options.readFile ?? ((path) => readFileSync(path, "utf8"));
49
+ const paths = discoverFiles(root, options);
50
+ const files = [];
51
+ const blindSpots = [];
52
+ const counts = { parsed: 0, partial: 0, failed: 0, unsupported: 0 };
53
+ for (const path of paths) {
54
+ const parser = registry.parserFor(path);
55
+ let source;
56
+ try {
57
+ source = readFile(join(root, path));
58
+ }
59
+ catch (error) {
60
+ // Unreadable is not unsupported. A file the registry claims it can parse
61
+ // but nobody can open is a hole in the graph, and reporting it as
62
+ // "unsupported" would file it alongside README.md and hide it.
63
+ counts.failed += 1;
64
+ const reason = `unreadable: ${error instanceof Error ? error.message : String(error)}`;
65
+ blindSpots.push({ path, reason });
66
+ files.push({
67
+ path,
68
+ contentHash: "",
69
+ language: parser?.language ?? null,
70
+ parser: parser ? `${parser.id}@${parser.version}` : null,
71
+ result: { status: "failed", imports: [], exports: [], diagnostics: [{ code: "unreadable", message: reason }] },
72
+ });
73
+ continue;
74
+ }
75
+ if (source.length > maxFileBytes) {
76
+ counts.unsupported += 1;
77
+ files.push({
78
+ path,
79
+ contentHash: hashContent(source),
80
+ language: parser?.language ?? null,
81
+ parser: null,
82
+ result: {
83
+ status: "unsupported",
84
+ imports: [],
85
+ exports: [],
86
+ diagnostics: [{ code: "too-large", message: `${source.length} bytes exceeds ${maxFileBytes}` }],
87
+ },
88
+ });
89
+ if (parser)
90
+ blindSpots.push({ path, reason: `too large to parse (${source.length} bytes)` });
91
+ continue;
92
+ }
93
+ const result = registry.parse(path, source);
94
+ counts[result.status] += 1;
95
+ if (result.status === "failed" || result.status === "partial") {
96
+ blindSpots.push({
97
+ path,
98
+ reason: result.diagnostics[0]?.message ?? result.status,
99
+ });
100
+ }
101
+ files.push({
102
+ path,
103
+ contentHash: hashContent(source),
104
+ language: parser?.language ?? null,
105
+ // Only a backend that actually ran is part of the identity.
106
+ parser: parser && result.status !== "unsupported" ? `${parser.id}@${parser.version}` : null,
107
+ result,
108
+ ...(isProbablyBinary(source) ? {} : { searchText: source }),
109
+ });
110
+ }
111
+ // The denominator is files a backend claimed, not every file in the
112
+ // repository. Counting README.md and package-lock.json against coverage
113
+ // would make a healthy index look broken and, worse, make the number
114
+ // meaningless as a signal about the code that was analysed.
115
+ const claimed = counts.parsed + counts.partial + counts.failed;
116
+ const coverage = {
117
+ total: paths.length,
118
+ ...counts,
119
+ ratio: claimed === 0 ? 1 : (counts.parsed + counts.partial) / claimed,
120
+ };
121
+ // Discovered from the files already listed: the index never reaches outside
122
+ // what discovery gave it, which keeps this consistent with the parser's rule
123
+ // about ambient authority.
124
+ const aliases = discoverAliases(paths, { readFile: (path) => readFile(join(root, path)) });
125
+ const fingerprints = files.map((file) => ({
126
+ path: file.path,
127
+ contentHash: file.contentHash,
128
+ parser: file.parser,
129
+ }));
130
+ return {
131
+ root,
132
+ fingerprint: indexFingerprint({
133
+ files: fingerprints,
134
+ registrySignature: registry.signature(),
135
+ resolverSignature: aliasSignature(aliases),
136
+ }),
137
+ aliases,
138
+ files,
139
+ coverage,
140
+ blindSpots,
141
+ };
142
+ }
143
+ export async function buildFileIndexAsync(root, registry, options = {}) {
144
+ const paths = discoverFiles(root, options);
145
+ if (options.readFile || paths.length < 100) {
146
+ return buildFileIndex(root, registry, options);
147
+ }
148
+ const maxFileBytes = options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
149
+ const numWorkers = Math.max(1, Math.min(8, os.cpus().length - 1));
150
+ const chunks = Array.from({ length: numWorkers }, () => []);
151
+ for (let i = 0; i < paths.length; i++) {
152
+ chunks[i % numWorkers].push(paths[i]);
153
+ }
154
+ const workerFile = new URL("./parser-worker.js", import.meta.url);
155
+ const workerPromises = chunks.filter(c => c.length > 0).map((chunk) => {
156
+ return new Promise((resolve, reject) => {
157
+ const worker = new Worker(workerFile, {
158
+ workerData: { root, paths: chunk, maxFileBytes },
159
+ });
160
+ const resultFiles = [];
161
+ const resultBlindSpots = [];
162
+ const resultCounts = { parsed: 0, partial: 0, failed: 0, unsupported: 0 };
163
+ worker.on("message", (msg) => {
164
+ if (msg.type === "chunk") {
165
+ resultFiles.push(...msg.files);
166
+ resultBlindSpots.push(...msg.blindSpots);
167
+ resultCounts.parsed += msg.counts.parsed;
168
+ resultCounts.partial += msg.counts.partial;
169
+ resultCounts.failed += msg.counts.failed;
170
+ resultCounts.unsupported += msg.counts.unsupported;
171
+ }
172
+ else if (msg.type === "done") {
173
+ worker.terminate();
174
+ resolve({ files: resultFiles, blindSpots: resultBlindSpots, counts: resultCounts });
175
+ }
176
+ });
177
+ worker.on("error", reject);
178
+ worker.on("exit", (code) => {
179
+ if (code !== 0)
180
+ reject(new Error(`Worker stopped with exit code ${code}`));
181
+ });
182
+ });
183
+ });
184
+ const results = await Promise.all(workerPromises);
185
+ const files = [];
186
+ const blindSpots = [];
187
+ const counts = { parsed: 0, partial: 0, failed: 0, unsupported: 0 };
188
+ for (const res of results) {
189
+ files.push(...res.files);
190
+ blindSpots.push(...res.blindSpots);
191
+ counts.parsed += res.counts.parsed;
192
+ counts.partial += res.counts.partial;
193
+ counts.failed += res.counts.failed;
194
+ counts.unsupported += res.counts.unsupported;
195
+ }
196
+ files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
197
+ blindSpots.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
198
+ const claimed = counts.parsed + counts.partial + counts.failed;
199
+ const coverage = {
200
+ total: paths.length,
201
+ ...counts,
202
+ ratio: claimed === 0 ? 1 : (counts.parsed + counts.partial) / claimed,
203
+ };
204
+ const aliases = discoverAliases(paths, { readFile: (path) => readFileSync(join(root, path), "utf8") });
205
+ const fingerprints = files.map((file) => ({
206
+ path: file.path,
207
+ contentHash: file.contentHash,
208
+ parser: file.parser,
209
+ }));
210
+ return {
211
+ root,
212
+ fingerprint: indexFingerprint({
213
+ files: fingerprints,
214
+ registrySignature: registry.signature(),
215
+ resolverSignature: aliasSignature(aliases),
216
+ }),
217
+ aliases,
218
+ files,
219
+ coverage,
220
+ blindSpots,
221
+ };
222
+ }
Binary file
@@ -0,0 +1,136 @@
1
+ import { execFileSync } from "node:child_process";
2
+ const DEFAULT_MAX_COMMITS = 10_000;
3
+ const DEFAULT_MAX_FILES_PER_COMMIT = 200;
4
+ // A mature repository's bounded history can exceed Node's 1 MiB default even
5
+ // when the pair index itself is bounded. Keep the output local, but size the
6
+ // subprocess buffer for a realistic temporal corpus instead of failing before
7
+ // retrieval starts. The history and file-count budgets remain the stronger
8
+ // controls on CPU and memory used to build the index.
9
+ const GIT_HISTORY_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
10
+ const runGitDefault = (args, cwd) => execFileSync("git", [...args], {
11
+ cwd,
12
+ encoding: "utf8",
13
+ maxBuffer: GIT_HISTORY_MAX_BUFFER_BYTES,
14
+ stdio: ["ignore", "pipe", "pipe"],
15
+ });
16
+ const ref = (value, label) => {
17
+ const trimmed = value.trim();
18
+ if (!trimmed || trimmed.startsWith("-"))
19
+ throw new Error(`Git co-change ${label} must be a non-empty revision`);
20
+ return trimmed;
21
+ };
22
+ const commitFiles = (output) => {
23
+ const commits = [];
24
+ let current = null;
25
+ for (const line of output.split(/\r?\n/)) {
26
+ if (line.startsWith("commit:")) {
27
+ if (current)
28
+ commits.push(current);
29
+ current = [];
30
+ continue;
31
+ }
32
+ const path = line.trim();
33
+ if (current && path)
34
+ current.push(path);
35
+ }
36
+ if (current)
37
+ commits.push(current);
38
+ return commits;
39
+ };
40
+ const pairKey = (left, right) => `${left}\0${right}`;
41
+ /**
42
+ * Build query-independent historical evidence for the local retrieval client.
43
+ *
44
+ * `before` is deliberately mandatory and must resolve to the checked-out
45
+ * corpus HEAD. A caller cannot accidentally index today's history while
46
+ * grading a past snapshot: the mismatch is an error, not a caveat. The
47
+ * server never sees this index or the raw commit list.
48
+ */
49
+ export function buildCoChangeIndex(root, eligiblePaths, options) {
50
+ const runGit = options.runGit ?? runGitDefault;
51
+ const beforeRef = ref(options.before, "before revision");
52
+ const maxCommits = options.maxCommits ?? DEFAULT_MAX_COMMITS;
53
+ const maxFilesPerCommit = options.maxFilesPerCommit ?? DEFAULT_MAX_FILES_PER_COMMIT;
54
+ if (!Number.isInteger(maxCommits) || maxCommits < 1)
55
+ throw new Error("Git co-change maxCommits must be a positive integer");
56
+ if (!Number.isInteger(maxFilesPerCommit) || maxFilesPerCommit < 2)
57
+ throw new Error("Git co-change maxFilesPerCommit must be at least 2");
58
+ const head = runGit(["rev-parse", "HEAD"], root).trim();
59
+ const before = runGit(["rev-parse", `${beforeRef}^{commit}`], root).trim();
60
+ if (!head || !before)
61
+ throw new Error("Git co-change could not resolve the checkout HEAD and before revision");
62
+ if (head !== before) {
63
+ throw new Error(`Git co-change cutoff ${before} does not match checkout HEAD ${head}; index the corpus at the before revision`);
64
+ }
65
+ if (options.corpusCommit) {
66
+ const corpusCommit = runGit(["rev-parse", `${ref(options.corpusCommit, "corpus revision")}^{commit}`], root).trim();
67
+ if (corpusCommit !== before) {
68
+ throw new Error(`Git co-change corpus commit ${corpusCommit} does not match cutoff ${before}`);
69
+ }
70
+ }
71
+ // Ask for one extra commit so truncation is observable in the returned
72
+ // metadata. Every revision and path remains an argument, never shell text.
73
+ const history = runGit([
74
+ "log",
75
+ "--no-merges",
76
+ "--format=commit:%H",
77
+ "--name-only",
78
+ "--diff-filter=ACDMRT",
79
+ `--max-count=${maxCommits + 1}`,
80
+ before,
81
+ "--",
82
+ ], root);
83
+ const parsedCommits = commitFiles(history);
84
+ const commits = parsedCommits.slice(0, maxCommits);
85
+ const truncated = parsedCommits.length > maxCommits;
86
+ const activity = new Map();
87
+ const pairs = new Map();
88
+ // `git log` is newest-first. Keep this metadata private to the local index:
89
+ // it is only a deterministic tie-break, never a separately fused ranking
90
+ // leg or a value that crosses the client/server boundary.
91
+ const lastTouched = new Map();
92
+ for (const [commitIndex, changed] of commits.entries()) {
93
+ const files = [...new Set(changed.filter((path) => eligiblePaths.has(path)))].sort();
94
+ const bounded = files.slice(0, maxFilesPerCommit);
95
+ for (const path of bounded) {
96
+ activity.set(path, (activity.get(path) ?? 0) + 1);
97
+ if (!lastTouched.has(path))
98
+ lastTouched.set(path, commitIndex);
99
+ }
100
+ for (let left = 0; left < bounded.length; left += 1) {
101
+ for (let right = left + 1; right < bounded.length; right += 1) {
102
+ const key = pairKey(bounded[left], bounded[right]);
103
+ pairs.set(key, (pairs.get(key) ?? 0) + 1);
104
+ }
105
+ }
106
+ }
107
+ const relatedByPath = new Map();
108
+ for (const [key, count] of pairs) {
109
+ const [left, right] = key.split("\0");
110
+ const score = count / Math.sqrt(Math.max(1, activity.get(left) ?? 1) * Math.max(1, activity.get(right) ?? 1));
111
+ const leftHits = relatedByPath.get(left) ?? [];
112
+ const rightHits = relatedByPath.get(right) ?? [];
113
+ leftHits.push({ path: right, score, co_change_count: count });
114
+ rightHits.push({ path: left, score, co_change_count: count });
115
+ relatedByPath.set(left, leftHits);
116
+ relatedByPath.set(right, rightHits);
117
+ }
118
+ for (const hits of relatedByPath.values()) {
119
+ hits.sort((a, b) => b.score - a.score
120
+ || b.co_change_count - a.co_change_count
121
+ || (lastTouched.get(a.path) ?? Number.POSITIVE_INFINITY) - (lastTouched.get(b.path) ?? Number.POSITIVE_INFINITY)
122
+ || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
123
+ }
124
+ return {
125
+ before,
126
+ corpus_commit: head,
127
+ commit_count: commits.length,
128
+ truncated,
129
+ files: new Set(eligiblePaths),
130
+ related(seed, limit = 20) {
131
+ if (!Number.isInteger(limit) || limit < 1)
132
+ return [];
133
+ return (relatedByPath.get(seed) ?? []).slice(0, limit).map((hit) => ({ ...hit }));
134
+ },
135
+ };
136
+ }