@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.
- package/README.md +130 -0
- package/dist/chunk-5I2DBHOQ.js +1042 -0
- package/dist/chunk-5I2DBHOQ.js.map +1 -0
- package/dist/chunk-CPYJACC5.js +1838 -0
- package/dist/chunk-CPYJACC5.js.map +1 -0
- package/dist/chunk-ZVCMIM4T.js +216 -0
- package/dist/chunk-ZVCMIM4T.js.map +1 -0
- package/dist/cypher/query-parser.d.ts +253 -0
- package/dist/cypher/query-parser.js +17 -0
- package/dist/cypher/query-parser.js.map +1 -0
- package/dist/graph-schema.d.ts +84 -0
- package/dist/graph-schema.js +17 -0
- package/dist/graph-schema.js.map +1 -0
- package/dist/graph-store.d.ts +938 -0
- package/dist/graph-store.js +16 -0
- package/dist/graph-store.js.map +1 -0
- package/dist/index.d.ts +1953 -0
- package/dist/index.js +3509 -0
- package/dist/index.js.map +1 -0
- package/grammars/tree-sitter-bash.wasm +0 -0
- package/grammars/tree-sitter-c.wasm +0 -0
- package/grammars/tree-sitter-c_sharp.wasm +0 -0
- package/grammars/tree-sitter-cpp.wasm +0 -0
- package/grammars/tree-sitter-go.wasm +0 -0
- package/grammars/tree-sitter-java.wasm +0 -0
- package/grammars/tree-sitter-javascript.wasm +0 -0
- package/grammars/tree-sitter-kotlin.wasm +0 -0
- package/grammars/tree-sitter-php.wasm +0 -0
- package/grammars/tree-sitter-python.wasm +0 -0
- package/grammars/tree-sitter-ruby.wasm +0 -0
- package/grammars/tree-sitter-rust.wasm +0 -0
- package/grammars/tree-sitter-swift.wasm +0 -0
- package/grammars/tree-sitter-tsx.wasm +0 -0
- package/grammars/tree-sitter-typescript.wasm +0 -0
- package/package.json +79 -0
- package/src/co-change.test.ts +175 -0
- package/src/co-change.ts +167 -0
- package/src/cypher/query-parser.test.ts +1107 -0
- package/src/cypher/query-parser.ts +1692 -0
- package/src/detect-changes.test.ts +533 -0
- package/src/detect-changes.ts +367 -0
- package/src/engine/emit.ts +556 -0
- package/src/engine/engine.test.ts +1417 -0
- package/src/engine/engine.ts +182 -0
- package/src/engine/extractors.ts +486 -0
- package/src/engine/fixtures.ts +364 -0
- package/src/engine/language-sniff.ts +56 -0
- package/src/engine/parser-backend.ts +206 -0
- package/src/engine/utf16-offsets.ts +68 -0
- package/src/git-invoker.test.ts +116 -0
- package/src/git-invoker.ts +426 -0
- package/src/graph-schema.test.ts +541 -0
- package/src/graph-schema.ts +383 -0
- package/src/graph-store-pr2.test.ts +1879 -0
- package/src/graph-store.test.ts +1420 -0
- package/src/graph-store.ts +3489 -0
- package/src/index-status.test.ts +303 -0
- package/src/index-status.ts +135 -0
- package/src/index.ts +384 -0
- package/src/lsp/byte-position.ts +173 -0
- package/src/lsp/characterization.test.ts +174 -0
- package/src/lsp/client.test.ts +275 -0
- package/src/lsp/client.ts +484 -0
- package/src/lsp/config.ts +219 -0
- package/src/lsp/degradation.ts +86 -0
- package/src/lsp/fixtures/fake-server.mjs +198 -0
- package/src/lsp/framing.test.ts +180 -0
- package/src/lsp/framing.ts +177 -0
- package/src/lsp/resolution.test.ts +497 -0
- package/src/lsp/resolution.ts +483 -0
- package/src/lsp/status.ts +140 -0
- package/src/lsp/types.ts +167 -0
- package/src/reindex.test.ts +1038 -0
- package/src/reindex.ts +908 -0
- package/src/row-types.ts +45 -0
- package/src/semantic/canonical-text.test.ts +150 -0
- package/src/semantic/canonical-text.ts +219 -0
- package/src/semantic/config.ts +235 -0
- package/src/semantic/index.ts +78 -0
- package/src/semantic/minhash.test.ts +197 -0
- package/src/semantic/minhash.ts +261 -0
- package/src/semantic/semantic-query.ts +173 -0
- package/src/semantic/semantic.test.ts +1315 -0
- package/src/semantic/similarity.ts +268 -0
- package/src/semantic/types.ts +145 -0
- 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
|
+
|