@remnic/coding-graph 9.3.759

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 (86) hide show
  1. package/README.md +130 -0
  2. package/dist/chunk-5I2DBHOQ.js +1042 -0
  3. package/dist/chunk-5I2DBHOQ.js.map +1 -0
  4. package/dist/chunk-CPYJACC5.js +1838 -0
  5. package/dist/chunk-CPYJACC5.js.map +1 -0
  6. package/dist/chunk-ZVCMIM4T.js +216 -0
  7. package/dist/chunk-ZVCMIM4T.js.map +1 -0
  8. package/dist/cypher/query-parser.d.ts +253 -0
  9. package/dist/cypher/query-parser.js +17 -0
  10. package/dist/cypher/query-parser.js.map +1 -0
  11. package/dist/graph-schema.d.ts +84 -0
  12. package/dist/graph-schema.js +17 -0
  13. package/dist/graph-schema.js.map +1 -0
  14. package/dist/graph-store.d.ts +938 -0
  15. package/dist/graph-store.js +16 -0
  16. package/dist/graph-store.js.map +1 -0
  17. package/dist/index.d.ts +1953 -0
  18. package/dist/index.js +3509 -0
  19. package/dist/index.js.map +1 -0
  20. package/grammars/tree-sitter-bash.wasm +0 -0
  21. package/grammars/tree-sitter-c.wasm +0 -0
  22. package/grammars/tree-sitter-c_sharp.wasm +0 -0
  23. package/grammars/tree-sitter-cpp.wasm +0 -0
  24. package/grammars/tree-sitter-go.wasm +0 -0
  25. package/grammars/tree-sitter-java.wasm +0 -0
  26. package/grammars/tree-sitter-javascript.wasm +0 -0
  27. package/grammars/tree-sitter-kotlin.wasm +0 -0
  28. package/grammars/tree-sitter-php.wasm +0 -0
  29. package/grammars/tree-sitter-python.wasm +0 -0
  30. package/grammars/tree-sitter-ruby.wasm +0 -0
  31. package/grammars/tree-sitter-rust.wasm +0 -0
  32. package/grammars/tree-sitter-swift.wasm +0 -0
  33. package/grammars/tree-sitter-tsx.wasm +0 -0
  34. package/grammars/tree-sitter-typescript.wasm +0 -0
  35. package/package.json +79 -0
  36. package/src/co-change.test.ts +175 -0
  37. package/src/co-change.ts +167 -0
  38. package/src/cypher/query-parser.test.ts +1107 -0
  39. package/src/cypher/query-parser.ts +1692 -0
  40. package/src/detect-changes.test.ts +533 -0
  41. package/src/detect-changes.ts +367 -0
  42. package/src/engine/emit.ts +556 -0
  43. package/src/engine/engine.test.ts +1417 -0
  44. package/src/engine/engine.ts +182 -0
  45. package/src/engine/extractors.ts +486 -0
  46. package/src/engine/fixtures.ts +364 -0
  47. package/src/engine/language-sniff.ts +56 -0
  48. package/src/engine/parser-backend.ts +206 -0
  49. package/src/engine/utf16-offsets.ts +68 -0
  50. package/src/git-invoker.test.ts +116 -0
  51. package/src/git-invoker.ts +426 -0
  52. package/src/graph-schema.test.ts +541 -0
  53. package/src/graph-schema.ts +383 -0
  54. package/src/graph-store-pr2.test.ts +1879 -0
  55. package/src/graph-store.test.ts +1420 -0
  56. package/src/graph-store.ts +3489 -0
  57. package/src/index-status.test.ts +303 -0
  58. package/src/index-status.ts +135 -0
  59. package/src/index.ts +384 -0
  60. package/src/lsp/byte-position.ts +173 -0
  61. package/src/lsp/characterization.test.ts +174 -0
  62. package/src/lsp/client.test.ts +275 -0
  63. package/src/lsp/client.ts +484 -0
  64. package/src/lsp/config.ts +219 -0
  65. package/src/lsp/degradation.ts +86 -0
  66. package/src/lsp/fixtures/fake-server.mjs +198 -0
  67. package/src/lsp/framing.test.ts +180 -0
  68. package/src/lsp/framing.ts +177 -0
  69. package/src/lsp/resolution.test.ts +497 -0
  70. package/src/lsp/resolution.ts +483 -0
  71. package/src/lsp/status.ts +140 -0
  72. package/src/lsp/types.ts +167 -0
  73. package/src/reindex.test.ts +1038 -0
  74. package/src/reindex.ts +908 -0
  75. package/src/row-types.ts +45 -0
  76. package/src/semantic/canonical-text.test.ts +150 -0
  77. package/src/semantic/canonical-text.ts +219 -0
  78. package/src/semantic/config.ts +235 -0
  79. package/src/semantic/index.ts +78 -0
  80. package/src/semantic/minhash.test.ts +197 -0
  81. package/src/semantic/minhash.ts +261 -0
  82. package/src/semantic/semantic-query.ts +173 -0
  83. package/src/semantic/semantic.test.ts +1315 -0
  84. package/src/semantic/similarity.ts +268 -0
  85. package/src/semantic/types.ts +145 -0
  86. package/src/semantic/vectors.ts +235 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * git-invoker tests (issue #1554 threads 13/14).
3
+ *
4
+ * Covers:
5
+ * - defaultCodingGitInvoker is a FACTORY FUNCTION (thread 13: the runtime
6
+ * previously treated it as an object instance and degraded to
7
+ * runtime_unavailable).
8
+ * - listTrackedFiles returns the repo's tracked files as repo-relative
9
+ * forward-slash paths (thread 14: the runtime sources candidatePaths for
10
+ * executeReindex's full-reindex branch from this method).
11
+ * - listTrackedFiles degrades to a tagged git failure outside a worktree.
12
+ */
13
+ import assert from "node:assert/strict";
14
+ import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import test from "node:test";
18
+
19
+ import { launchProcessSync } from "@remnic/core/runtime/child-process";
20
+
21
+ import {
22
+ defaultCodingGitInvoker,
23
+ type CodingGitInvoker,
24
+ } from "./git-invoker.js";
25
+
26
+ async function makeTempRepo(): Promise<string> {
27
+ const dir = await mkdtemp(path.join(tmpdir(), "git-invoker-test-"));
28
+ const run = (args: readonly string[]) =>
29
+ launchProcessSync("git", [...args], { cwd: dir, encoding: "utf-8", shell: false });
30
+ run(["init", "-q"]);
31
+ run(["config", "user.email", "test@example.com"]);
32
+ run(["config", "user.name", "Test"]);
33
+ return dir;
34
+ }
35
+
36
+ test("defaultCodingGitInvoker is a factory function (thread 13)", () => {
37
+ // The runtime calls defaultCodingGitInvoker() — it must be a function
38
+ // returning the invoker, not the invoker instance itself.
39
+ assert.equal(typeof defaultCodingGitInvoker, "function");
40
+ const invoker = defaultCodingGitInvoker();
41
+ assert.ok(invoker && typeof invoker === "object");
42
+ assert.equal(typeof invoker.listTrackedFiles, "function");
43
+ assert.equal(typeof invoker.revParseHead, "function");
44
+ });
45
+
46
+ test("listTrackedFiles: returns tracked files as repo-relative forward-slash paths (thread 14)", async () => {
47
+ const dir = await makeTempRepo();
48
+ try {
49
+ // Nested file with a forward-slash path (cross-platform: use path.join to
50
+ // create, but git stores it with forward slashes).
51
+ await mkdir(path.join(dir, "src", "deep"), { recursive: true });
52
+ await writeFile(path.join(dir, "src", "deep", "a.ts"), "export const a = 1;\n");
53
+ await writeFile(path.join(dir, "root.md"), "# root\n");
54
+ // Untracked file — must NOT appear in listTrackedFiles output.
55
+ await writeFile(path.join(dir, "ignored.txt"), "nope\n");
56
+ const add = launchProcessSync("git", ["add", "src/deep/a.ts", "root.md"], {
57
+ cwd: dir,
58
+ encoding: "utf-8",
59
+ shell: false,
60
+ });
61
+ assert.equal(add.status, 0, "git add must succeed");
62
+
63
+ const invoker: CodingGitInvoker = defaultCodingGitInvoker();
64
+ const result = invoker.listTrackedFiles(dir);
65
+ assert.equal(result.ok, true);
66
+ if (result.ok) {
67
+ // Forward-slash repo-relative paths, sorted by git's output order.
68
+ assert.ok(result.paths.includes("src/deep/a.ts"));
69
+ assert.ok(result.paths.includes("root.md"));
70
+ // Untracked file excluded — this is the load-bearing assertion for the
71
+ // candidatePaths source: only tracked files belong in an authoritative
72
+ // full-reindex candidate set.
73
+ assert.ok(
74
+ !result.paths.includes("ignored.txt"),
75
+ "listTrackedFiles must not include untracked files",
76
+ );
77
+ // Every path uses forward slashes (no OS-native backslashes), matching
78
+ // the executor's repo-relative canonical-path contract.
79
+ for (const p of result.paths) {
80
+ assert.ok(!p.includes("\\"), `path must be forward-slash: ${p}`);
81
+ }
82
+ }
83
+ } finally {
84
+ await rm(dir, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test("listTrackedFiles: empty repo (no tracked files) → ok:true with empty path list", async () => {
89
+ const dir = await makeTempRepo();
90
+ try {
91
+ const invoker = defaultCodingGitInvoker();
92
+ const result = invoker.listTrackedFiles(dir);
93
+ assert.equal(result.ok, true);
94
+ if (result.ok) {
95
+ assert.equal(result.paths.length, 0);
96
+ }
97
+ } finally {
98
+ await rm(dir, { recursive: true, force: true });
99
+ }
100
+ });
101
+
102
+ test("listTrackedFiles: non-repo directory → tagged git failure (degrades, never throws)", async () => {
103
+ const dir = await mkdtemp(path.join(tmpdir(), "git-invoker-notrepo-"));
104
+ try {
105
+ const invoker = defaultCodingGitInvoker();
106
+ const result = invoker.listTrackedFiles(dir);
107
+ // Either git_unavailable (git not on PATH) or git_error (not a worktree).
108
+ // The contract is: never throws, always a tagged result.
109
+ assert.equal(result.ok, false);
110
+ if (!result.ok) {
111
+ assert.ok(result.code === "git_unavailable" || result.code === "git_error");
112
+ }
113
+ } finally {
114
+ await rm(dir, { recursive: true, force: true });
115
+ }
116
+ });
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Coding-graph git invocation discipline.
3
+ *
4
+ * Mirrors the argv-array + bounded-timeout + never-throws pattern from
5
+ * `packages/remnic-core/src/coding/git-context.ts` (rule 10: every git call
6
+ * is argv + env, never string interpolation; rule 34: tagged failure codes).
7
+ *
8
+ * The coding-graph package owns its own invoker rather than importing
9
+ * core's GitContextResolver because:
10
+ * - The resolver's public surface (resolveGitContext) is project-level
11
+ * detection, not diff/name-status/log plumbing.
12
+ * - The reindex pipeline needs `git diff --name-status`, `git rev-parse`,
13
+ * `git log --name-only`, and `git diff` (hunk headers) — none of which
14
+ * the resolver exposes.
15
+ * - Keeping the invoker local lets the package stay self-contained for
16
+ * its git needs while still consuming the shared `launchProcessSync`
17
+ * from `@remnic/core/runtime/child-process` (rule 23/38 — do not invent
18
+ * a new process-spawning pattern).
19
+ *
20
+ * Never throws. Every failure is a tagged `GitFailure` so callers can
21
+ * surface `git_unavailable` / `unreachable_head` distinctly (rule 34 +
22
+ * issue #1553 pitfall 34).
23
+ */
24
+ import { launchProcessSync } from "@remnic/core/runtime/child-process";
25
+
26
+ // ──────────────────────────────────────────────────────────────────────────
27
+ // Public types
28
+ // ──────────────────────────────────────────────────────────────────────────
29
+
30
+ /** A line from `git diff --name-status <range>`. */
31
+ export interface NameStatusEntry {
32
+ /**
33
+ * `git diff --name-status` status code: `A` (added), `M` (modified),
34
+ * `D` (deleted), `R100` (renamed, 100% similarity), `C75` (copied, 75%),
35
+ * etc. Renames carry the NEW path in `path` and the OLD path in
36
+ * `oldPath`.
37
+ */
38
+ readonly status: string;
39
+ /** Repo-relative forward-slash path of the file in the NEW tree. */
40
+ readonly path: string;
41
+ /** For renames/copies (`R*`/`C*`), the source path. Otherwise `undefined`. */
42
+ readonly oldPath?: string;
43
+ }
44
+
45
+ /** A hunk header from `git diff --unified=0` (`@@ -a,b +c,d @@`). */
46
+ export interface DiffHunk {
47
+ /** File path (repo-relative, forward slashes). */
48
+ readonly path: string;
49
+ /**
50
+ * Half-open `[startLine, endLine)` line range in the NEW (post-change)
51
+ * version of the file. `startLine` is 1-based; `endLine` is exclusive.
52
+ * A single-line change has `endLine === startLine + 1`.
53
+ */
54
+ readonly newRange: { readonly startLine: number; readonly endLine: number };
55
+ }
56
+
57
+ /** A co-change commit entry from `git log --name-only`. */
58
+ export interface LogFilesEntry {
59
+ /** Full commit SHA (40 hex chars). */
60
+ readonly sha: string;
61
+ /** Files changed in this commit (repo-relative, forward slashes, deduped). */
62
+ readonly files: readonly string[];
63
+ }
64
+
65
+ /**
66
+ * Tagged failure — `code` is the load-bearing signal for programmatic
67
+ * detection (rule 34). Never carries `error.message` (which may contain
68
+ * absolute paths — rule 11).
69
+ */
70
+ export interface GitFailure {
71
+ readonly ok: false;
72
+ readonly code: "git_unavailable" | "unreachable_head" | "git_error";
73
+ }
74
+
75
+ // ──────────────────────────────────────────────────────────────────────────
76
+ // Invoker
77
+ // ──────────────────────────────────────────────────────────────────────────
78
+
79
+ /**
80
+ * Injectable git-invocation surface. Each method runs a specific git
81
+ * subcommand with argv arrays (never string interpolation — rule 10) and
82
+ * returns parsed output or a tagged failure. Tests inject a mock to avoid
83
+ * spawning a real git process against synthetic fixture repos.
84
+ *
85
+ * The default implementation uses `launchProcessSync` with a 2-second
86
+ * timeout (matching `git-context.ts`'s `DEFAULT_GIT_TIMEOUT_MS`).
87
+ */
88
+ export interface CodingGitInvoker {
89
+ /**
90
+ * `git rev-parse HEAD` — the current HEAD SHA. Returns `null` when HEAD
91
+ * does not exist (empty repo / no commits yet).
92
+ */
93
+ revParseHead(cwd: string): { ok: true; head: string | null } | GitFailure;
94
+
95
+ /**
96
+ * `git rev-parse --verify <ref>^{commit}` — check whether a prior head
97
+ * is still reachable. Used to detect rebase/force-push scenarios
98
+ * (issue #1553 pitfall 34: `unreachable_head`).
99
+ */
100
+ isReachable(
101
+ cwd: string,
102
+ ref: string,
103
+ ): { ok: true; reachable: boolean } | GitFailure;
104
+
105
+ /**
106
+ * `git diff --name-status <range>` — the changed files between two
107
+ * commits (or between a commit and HEAD). Returns one entry per file.
108
+ */
109
+ diffNameStatus(
110
+ cwd: string,
111
+ range: string,
112
+ ): { ok: true; entries: readonly NameStatusEntry[] } | GitFailure;
113
+
114
+ /**
115
+ * Working-tree diff hunks with zero context lines. Captures BOTH staged
116
+ * and unstaged changes (compared against HEAD) so `detect_changes` maps
117
+ * every uncommitted edit to a symbol span. Each hunk carries its
118
+ * new-version line range.
119
+ */
120
+ diffHunks(
121
+ cwd: string,
122
+ paths: readonly string[],
123
+ ): { ok: true; hunks: readonly DiffHunk[] } | GitFailure;
124
+
125
+ /**
126
+ * `git log --name-only --format=%H -n <limit>` — commit SHAs + their
127
+ * changed files over a bounded window. Used by co-change mining.
128
+ */
129
+ logFiles(
130
+ cwd: string,
131
+ limit: number,
132
+ ): { ok: true; entries: readonly LogFilesEntry[] } | GitFailure;
133
+
134
+ /**
135
+ * `git ls-files` — the repo's tracked files as repo-relative forward-slash
136
+ * paths. Used by the codegraph runtime to source `candidatePaths` for a
137
+ * full reindex (issue #1554: the executor treats an omitted candidate list
138
+ * as non-authoritative and no-ops, so the runtime must supply one).
139
+ * Returns an empty path list for a repo with no tracked files; a git
140
+ * failure degrades to `{ ok: false, code: "git_unavailable" | "git_error" }`.
141
+ */
142
+ listTrackedFiles(
143
+ cwd: string,
144
+ ): { ok: true; paths: readonly string[] } | GitFailure;
145
+ }
146
+
147
+ // ──────────────────────────────────────────────────────────────────────────
148
+ // Default implementation — real git via launchProcessSync
149
+ // ──────────────────────────────────────────────────────────────────────────
150
+
151
+ const GIT_TIMEOUT_MS = 2_000;
152
+
153
+ /**
154
+ * Run `git <args>` synchronously with a bounded timeout. Never throws —
155
+ * spawn failures (git not on PATH, timeout) surface as
156
+ * `{ ok: false, code: "git_unavailable" }` so callers can degrade.
157
+ */
158
+ function runGit(
159
+ cwd: string,
160
+ args: readonly string[],
161
+ ): { ok: true; stdout: string; exitCode: number } | GitFailure {
162
+ const result = launchProcessSync("git", [...args], {
163
+ cwd,
164
+ encoding: "utf-8",
165
+ timeout: GIT_TIMEOUT_MS,
166
+ shell: false,
167
+ });
168
+ if (result.error) {
169
+ return { ok: false, code: "git_unavailable" };
170
+ }
171
+ return {
172
+ ok: true,
173
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
174
+ exitCode: typeof result.status === "number" ? result.status : 1,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Construct the default git invoker that spawns real `git` via
180
+ * `launchProcessSync`. Tests inject a mock instead.
181
+ */
182
+ export function defaultCodingGitInvoker(): CodingGitInvoker {
183
+ return {
184
+ revParseHead(cwd: string) {
185
+ const r = runGit(cwd, ["rev-parse", "HEAD"]);
186
+ if (!r.ok) return r;
187
+ const trimmed = r.stdout.trim();
188
+ if (r.exitCode === 0 && trimmed.length > 0) {
189
+ return { ok: true, head: trimmed };
190
+ }
191
+ // Non-zero exit: could be an UNBORN repo (no commits yet, HEAD is a
192
+ // symbolic ref to a non-existent branch) OR a directory that is not
193
+ // a git worktree at all. Distinguish them so a misconfigured workspace
194
+ // surfaces as a git failure instead of silently looking like an empty
195
+ // repo (chatgpt-codex-connector: 'Return a git failure for non-
196
+ // repository HEAD lookups').
197
+ const wt = runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
198
+ if (wt.ok && wt.exitCode === 0 && wt.stdout.trim() === "true") {
199
+ // Inside a worktree but HEAD has no commits → unborn, head null.
200
+ return { ok: true, head: null };
201
+ }
202
+ // Not a git worktree (or git failed to answer) → degrade visibly.
203
+ return { ok: false, code: "git_error" };
204
+ },
205
+
206
+ isReachable(cwd: string, ref: string) {
207
+ const r = runGit(cwd, ["rev-parse", "--verify", `${ref}^{commit}`]);
208
+ if (!r.ok) return r;
209
+ return { ok: true, reachable: r.exitCode === 0 };
210
+ },
211
+
212
+ diffNameStatus(cwd: string, range: string) {
213
+ const r = runGit(cwd, ["diff", "--name-status", range]);
214
+ if (!r.ok) return r;
215
+ if (r.exitCode !== 0) {
216
+ return { ok: false, code: "git_error" };
217
+ }
218
+ return { ok: true, entries: parseNameStatus(r.stdout) };
219
+ },
220
+
221
+ diffHunks(cwd: string, paths: readonly string[]) {
222
+ // `git diff HEAD` compares the working tree (staged + unstaged)
223
+ // against the last commit, so BOTH staged and unstaged edits are
224
+ // captured (chatgpt-codex-connector: 'Include staged hunks in
225
+ // diffHunks'). Plain `git diff` would miss staged-only changes.
226
+ // --unified=0 → zero context lines so hunks are tight.
227
+ // --no-color → no ANSI escape codes in output.
228
+ // -- separator before paths to disambiguate refs from paths.
229
+ const pathArgs: string[] = [];
230
+ if (paths.length > 0) {
231
+ pathArgs.push("--");
232
+ for (const p of paths) pathArgs.push(p);
233
+ }
234
+ const r = runGit(cwd, ["diff", "HEAD", "--unified=0", "--no-color", ...pathArgs]);
235
+ if (!r.ok) return r;
236
+ if (r.exitCode !== 0) {
237
+ // HEAD may not exist (unborn repo, no commits yet). Fall back to
238
+ // unstaged-only diff — an acceptable degradation for a repo that
239
+ // has never been indexed (there are no committed symbols anyway).
240
+ const fallback = runGit(cwd, ["diff", "--unified=0", "--no-color", ...pathArgs]);
241
+ if (!fallback.ok) return fallback;
242
+ if (fallback.exitCode !== 0) {
243
+ return { ok: false, code: "git_error" };
244
+ }
245
+ return { ok: true, hunks: parseHunks(fallback.stdout) };
246
+ }
247
+ return { ok: true, hunks: parseHunks(r.stdout) };
248
+ },
249
+
250
+ logFiles(cwd: string, limit: number) {
251
+ const r = runGit(cwd, [
252
+ "log",
253
+ `--format=%H`,
254
+ "--name-only",
255
+ `-n`,
256
+ String(limit),
257
+ ]);
258
+ if (!r.ok) return r;
259
+ if (r.exitCode !== 0) {
260
+ return { ok: false, code: "git_error" };
261
+ }
262
+ return { ok: true, entries: parseLogFiles(r.stdout) };
263
+ },
264
+
265
+ listTrackedFiles(cwd: string) {
266
+ // `git ls-files` lists the repo's tracked files (cached in the index)
267
+ // as repo-relative forward-slash paths — exactly the candidatePaths
268
+ // format executeReindex expects. --others is NOT used: only tracked
269
+ // files belong in an authoritative full-reindex candidate set.
270
+ const r = runGit(cwd, ["ls-files"]);
271
+ if (!r.ok) return r;
272
+ if (r.exitCode !== 0) {
273
+ return { ok: false, code: "git_error" };
274
+ }
275
+ const paths = r.stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
276
+ return { ok: true, paths };
277
+ },
278
+ };
279
+ }
280
+
281
+ // ──────────────────────────────────────────────────────────────────────────
282
+ // Output parsers — pure functions (unit-testable without git)
283
+ // ──────────────────────────────────────────────────────────────────────────
284
+
285
+ /**
286
+ * Parse `git diff --name-status` output. Each line is `<status>\t<path>`
287
+ * for add/modify/delete, or `<status>\t<old>\t<new>` for rename/copy.
288
+ *
289
+ * Empty lines are skipped (trailing newline). Lines that do not parse
290
+ * are skipped rather than crashing the whole diff (rule 44 — a single
291
+ * bad line should not make the index go stale silently; but a partial
292
+ * parse is better than a total failure because the executor will fall
293
+ * back to hash-scan if the file count looks wrong).
294
+ */
295
+ export function parseNameStatus(stdout: string): NameStatusEntry[] {
296
+ const out: NameStatusEntry[] = [];
297
+ const lines = stdout.split("\n");
298
+ for (const line of lines) {
299
+ if (line.length === 0) continue;
300
+ // Split on the first tab; the path may contain spaces but not tabs.
301
+ const tabIdx = line.indexOf("\t");
302
+ if (tabIdx < 0) continue;
303
+ const status = line.slice(0, tabIdx);
304
+ const rest = line.slice(tabIdx + 1);
305
+ // Rename/copy: `R100\told\tnew` — second tab splits old from new.
306
+ const secondTab = rest.indexOf("\t");
307
+ if (
308
+ (status.startsWith("R") || status.startsWith("C")) &&
309
+ secondTab >= 0
310
+ ) {
311
+ const oldPath = rest.slice(0, secondTab);
312
+ const newPath = rest.slice(secondTab + 1);
313
+ if (oldPath.length > 0 && newPath.length > 0) {
314
+ out.push({ status, path: newPath, oldPath });
315
+ }
316
+ } else {
317
+ if (rest.length > 0) {
318
+ out.push({ status, path: rest });
319
+ }
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+
325
+ /**
326
+ * Parse `git diff --unified=0` output into per-file hunks. Each hunk
327
+ * header is `@@ -a,b +c,d @@ <optional section>`. With `--unified=0`
328
+ * the OLD range `(a,b)` is not useful for blast-radius (we want the
329
+ * NEW range). We extract `+c,d` → `{ startLine: c, endLine: c + d }`.
330
+ *
331
+ * The file path comes from `diff --git a/path b/path` or
332
+ * `+++ b/path` lines. We track the current file as we walk.
333
+ */
334
+ export function parseHunks(stdout: string): DiffHunk[] {
335
+ const out: DiffHunk[] = [];
336
+ let currentPath: string | null = null;
337
+ const lines = stdout.split("\n");
338
+ for (const line of lines) {
339
+ // `+++ b/path` — the new-version path (post-rename). Prefer this
340
+ // over `diff --git` because it correctly handles renames.
341
+ if (line.startsWith("+++ ")) {
342
+ if (line.startsWith("+++ /dev/null")) {
343
+ // The new side of this file is empty → it was DELETED. Clear
344
+ // currentPath so a following `@@` hunk is not mis-attributed to
345
+ // the previous file (chatgpt-codex-connector: 'Clear hunk path
346
+ // for deleted files').
347
+ currentPath = null;
348
+ continue;
349
+ }
350
+ // Strip the leading `+++ b/` (or `+++ ` for non-`b/` forms).
351
+ const raw = line.slice(4);
352
+ currentPath = raw.startsWith("b/") ? raw.slice(2) : raw;
353
+ continue;
354
+ }
355
+ // `@@ -a,b +c,d @@` — hunk header.
356
+ if (line.startsWith("@@ ")) {
357
+ const match = line.match(/\+(\d+)(?:,(\d+))?/);
358
+ if (!match) continue;
359
+ // match[1] is guaranteed by the first capture group; match[2] is
360
+ // optional (omitted when the hunk is a single line). Guard both
361
+ // with a fallback to avoid unchecked non-null assertions.
362
+ const startStr = match[1] ?? "";
363
+ if (startStr.length === 0) continue;
364
+ const startLine = parseInt(startStr, 10);
365
+ const countStr = match[2] ?? "";
366
+ const count = countStr.length > 0 ? parseInt(countStr, 10) : 1;
367
+ if (!currentPath) continue;
368
+ // A deletion-only hunk emits a zero new-count (e.g. `@@ -2 +1,0 @@`).
369
+ // An empty [startLine, startLine) range can never overlap a symbol
370
+ // span, so pure deletions inside a function/class were missed.
371
+ // Treat zero-count as covering the adjacent new line (chatgpt-
372
+ // codex-connector: 'Map deletion-only hunks to a non-empty range').
373
+ out.push({
374
+ path: currentPath,
375
+ newRange: {
376
+ startLine,
377
+ endLine: startLine + Math.max(count, 1),
378
+ },
379
+ });
380
+ }
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * Parse `git log --format=%H --name-only` output into one entry per
387
+ * commit. Real git emits a blank line AFTER each SHA (before the file
388
+ * names) AND between commits, so a naive `\n\n` block split puts the
389
+ * SHA alone in one block and the file names in the next — the SHA-only
390
+ * block yields a commit with zero files and the file-name block fails
391
+ * SHA validation and is dropped. Walk line-by-line instead: a 40-hex
392
+ * line starts a new commit; subsequent non-empty lines are its files
393
+ * until the next SHA (chatgpt-codex-connector: 'Parse real git log
394
+ * records before mining co-changes'). Robust to both the synthetic
395
+ * (no blank-after-SHA) and real (blank-after-SHA) layouts.
396
+ */
397
+ export function parseLogFiles(stdout: string): LogFilesEntry[] {
398
+ const out: LogFilesEntry[] = [];
399
+ let currentSha: string | null = null;
400
+ let currentFiles: string[] = [];
401
+ const seenInCommit = new Set<string>();
402
+ const flush = (): void => {
403
+ if (currentSha !== null) {
404
+ out.push({ sha: currentSha, files: currentFiles });
405
+ }
406
+ currentSha = null;
407
+ currentFiles = [];
408
+ seenInCommit.clear();
409
+ };
410
+ for (const line of stdout.split("\n")) {
411
+ if (/^[0-9a-f]{40}$/.test(line)) {
412
+ flush();
413
+ currentSha = line;
414
+ } else if (line.length > 0 && currentSha !== null) {
415
+ if (!seenInCommit.has(line)) {
416
+ seenInCommit.add(line);
417
+ currentFiles.push(line);
418
+ }
419
+ }
420
+ // blank lines are ignored — they do not terminate a commit; the
421
+ // next SHA does.
422
+ }
423
+ flush();
424
+ return out;
425
+ }
426
+