@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
package/src/reindex.ts ADDED
@@ -0,0 +1,908 @@
1
+ /**
2
+ * Incremental git-based reindex for the coding-graph (issue #1553).
3
+ *
4
+ * Architecture: a PURE planner + a thin executor.
5
+ *
6
+ * planReindex(lastState, gitFacts) → ReindexPlan
7
+ *
8
+ * The planner is a pure function over plain data — unit-testable without
9
+ * git or a real SQLite handle. It classifies the current situation into
10
+ * one of four modes and returns the file set to (re)ingest:
11
+ *
12
+ * - "full" — no prior state (fresh DB); parse every candidate.
13
+ * - "noop" — HEAD unchanged AND no working-tree dirt; zero writes.
14
+ * - "incremental" — HEAD advanced; re-parse only the changed files.
15
+ * - "hash_scan" — last_head is unreachable (rebase/force-push);
16
+ * hash every candidate and re-parse only mismatches.
17
+ *
18
+ * The executor parses the plan's files (via an injected `ParseFile` fn),
19
+ * runs the store's `upsertFileBatch`, and persists `last_indexed_head` ONLY
20
+ * after the data transaction commits (rule 25 — a crash between must leave
21
+ * the old head, tested with an injected mid-transaction failure).
22
+ *
23
+ * Post-write reindex invariant (AGENTS.md rule 31): direct-write paths that
24
+ * bypass this pipeline — e.g. a caller using `store.upsertFileBatch`
25
+ * directly — MUST trigger reindex afterward so the graph stays consistent
26
+ * with the persisted content hashes. This module is the canonical way to
27
+ * satisfy that invariant.
28
+ *
29
+ * Distinct from Track A's `session-delta.ts` last-seen-head: that tracks
30
+ * the session's view of the repo for recall diffing; `last_indexed_head`
31
+ * here tracks the GRAPH's persisted index state. They are separate concerns
32
+ * that may legitimately disagree (e.g. a session attached before the first
33
+ * reindex run).
34
+ */
35
+ import { readFile as fsReadFile, realpath as fsRealpath } from "node:fs/promises";
36
+ import path from "node:path";
37
+
38
+ import { hashContent } from "./engine/emit.js";
39
+
40
+ import type { ParseFileInput, ParseResult } from "@remnic/core";
41
+
42
+ import type { CodingGitInvoker, GitFailure, NameStatusEntry } from "./git-invoker.js";
43
+ import type {
44
+ GraphStore,
45
+ StoreFileIR,
46
+ ReadMetaResult,
47
+ ReadFileHashesResult,
48
+ } from "./graph-store.js";
49
+ import type { GraphStoreFailure } from "./graph-store.js";
50
+
51
+ // ──────────────────────────────────────────────────────────────────────────
52
+ // Public types — the planner's input and output
53
+ // ──────────────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * The persisted index state the planner reasons about.
57
+ * `lastHead: null` means "never indexed" → full reindex.
58
+ */
59
+ export interface ReindexState {
60
+ /** The HEAD SHA at the time of the last successful reindex, or `null`. */
61
+ readonly lastHead: string | null;
62
+ /**
63
+ * Per-file content hashes as of the last index. Used by hash_scan mode
64
+ * to detect content drift without a reachable base commit.
65
+ * Keyed by repo-relative forward-slash path.
66
+ */
67
+ readonly fileHashes: ReadonlyMap<string, string>;
68
+ }
69
+
70
+ /** Git facts the planner needs — gathered BEFORE the plan is computed. */
71
+ export interface ReindexGitFacts {
72
+ /** Current `git rev-parse HEAD`. `null` when the repo has no commits. */
73
+ readonly currentHead: string | null;
74
+ /**
75
+ * Whether `lastHead` (when non-null) is still reachable in the repo.
76
+ * `false` after a rebase/force-push that rewrote history past the old
77
+ * head. `true` when `lastHead` is null (no prior state to reach).
78
+ */
79
+ readonly lastHeadReachable: boolean;
80
+ /**
81
+ * Files changed between `lastHead` and `currentHead` (when both are
82
+ * non-null and reachable). One entry per file from
83
+ * `git diff --name-status`. Empty array for a noop or fresh repo.
84
+ */
85
+ readonly changedFiles: readonly NameStatusEntry[];
86
+ }
87
+
88
+ /** What the planner decided to do. */
89
+ export type ReindexPlan =
90
+ | { readonly mode: "full"; readonly reason: string }
91
+ | { readonly mode: "noop"; readonly reason: string }
92
+ | { readonly mode: "incremental"; readonly changedPaths: readonly string[] }
93
+ | {
94
+ readonly mode: "hash_scan";
95
+ readonly reason: string;
96
+ /** Paths whose on-disk hash differs from the stored hash. */
97
+ readonly mismatchedPaths: readonly string[];
98
+ };
99
+
100
+ /** Result of executing a reindex plan. */
101
+ export type ReindexResult =
102
+ | {
103
+ readonly ok: true;
104
+ readonly mode: "full" | "noop" | "incremental" | "hash_scan";
105
+ /** Number of files actually parsed + ingested. */
106
+ readonly filesIngested: number;
107
+ /** The new `last_indexed_head` persisted to meta (null on noop). */
108
+ readonly head: string | null;
109
+ }
110
+ | ({ readonly ok: false } & GitFailure)
111
+ | {
112
+ readonly ok: false;
113
+ readonly code: "parse_failed";
114
+ readonly path: string;
115
+ readonly message: string;
116
+ }
117
+ | {
118
+ readonly ok: false;
119
+ readonly code: "store_error";
120
+ readonly message: string;
121
+ };
122
+
123
+ // ──────────────────────────────────────────────────────────────────────────
124
+ // Meta-table keys — the store's `meta` table is a simple key/value store.
125
+ // ──────────────────────────────────────────────────────────────────────────
126
+
127
+ export const META_KEY_LAST_HEAD = "last_indexed_head" as const;
128
+
129
+ /**
130
+ * Meta key holding a JSON array of repo-relative paths that failed to
131
+ * parse on the last run (rule 44: files that fail to parse do not update
132
+ * their stored content hash and MUST retry on the next run). Because a
133
+ * HEAD-unchanged run would otherwise plan `noop` and skip them, the
134
+ * executor consults this set at the top of every run and re-ingests any
135
+ * pending paths even when the plan is noop (cursor Bugbot: 'Parse skips
136
+ * block future reindex').
137
+ */
138
+ export const META_KEY_PENDING_PARSE_FAILURES =
139
+ "pending_parse_failures" as const;
140
+
141
+ // ──────────────────────────────────────────────────────────────────────────
142
+ // Pure planner — unit-testable without git or SQLite
143
+ // ──────────────────────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Decide what to do given the last state and current git facts.
147
+ *
148
+ * Decision tree:
149
+ * 1. `currentHead === null` → noop (nothing to index; empty repo).
150
+ * 2. `lastHead === null` → full (first index).
151
+ * 3. `lastHead === currentHead` → noop (HEAD unchanged).
152
+ * 4. `!lastHeadReachable` → hash_scan (rebase/force-push lost the base).
153
+ * 5. Otherwise → incremental (re-parse changedFiles paths).
154
+ *
155
+ * Deleted files (status `D`) are included in `changedPaths` so the executor
156
+ * can prune them from the store. Renames include both old and new paths.
157
+ */
158
+ export function planReindex(
159
+ lastState: ReindexState,
160
+ facts: ReindexGitFacts,
161
+ ): ReindexPlan {
162
+ // Case 1: no HEAD at all — nothing to do.
163
+ if (facts.currentHead === null) {
164
+ return { mode: "noop", reason: "repo has no commits (HEAD is null)" };
165
+ }
166
+
167
+ // Case 2: never indexed → full.
168
+ if (lastState.lastHead === null) {
169
+ return { mode: "full", reason: "no prior last_indexed_head — first index" };
170
+ }
171
+
172
+ // Case 3: HEAD unchanged → noop. No writes, no parse.
173
+ if (lastState.lastHead === facts.currentHead) {
174
+ return { mode: "noop", reason: "HEAD unchanged since last index" };
175
+ }
176
+
177
+ // Case 4: last_head is unreachable (rebase/force-push). Fall back to
178
+ // hash_scan — the executor hashes every candidate and re-parses only
179
+ // mismatches. Never crash, never silently full-reindex without saying so.
180
+ if (!facts.lastHeadReachable) {
181
+ return {
182
+ mode: "hash_scan",
183
+ reason: `last_indexed_head ${lastState.lastHead.slice(0, 12)} is unreachable (rebase/force-push)`,
184
+ // mismatchedPaths is filled by the executor (needs to read files).
185
+ mismatchedPaths: [],
186
+ };
187
+ }
188
+
189
+ // Case 5: incremental. The diff is trustworthy; re-parse changed files.
190
+ const changedPaths: string[] = [];
191
+ for (const entry of facts.changedFiles) {
192
+ // `D` = deleted → include so executor prunes the file.
193
+ // `A` = added, `M` = modified → include to (re)parse.
194
+ // `R*`/`C*` = renamed/copied → include both old and new paths.
195
+ changedPaths.push(entry.path);
196
+ if (entry.oldPath !== undefined && entry.oldPath !== entry.path) {
197
+ changedPaths.push(entry.oldPath);
198
+ }
199
+ }
200
+ return { mode: "incremental", changedPaths };
201
+ }
202
+
203
+ // ──────────────────────────────────────────────────────────────────────────
204
+ // Executor — parses files, drives the store, updates meta
205
+ // ──────────────────────────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * A parse function — the engine seam. The executor calls this for each
209
+ * file the plan says to (re)ingest. In production the orchestrator injects
210
+ * the real `CodingGraphEngine.parseFile`; in tests a synthetic parser is
211
+ * injected. This keeps the package decoupled from the engine implementation
212
+ * (which is still a placeholder in #1551 PR1).
213
+ */
214
+ export type ParseFileFn = (input: ParseFileInput) => Promise<ParseResult>;
215
+
216
+ /** Injectable file reader — defaults to `node:fs/promises`.readFile. */
217
+ export type ReadFileFn = (absPath: string) => Promise<Uint8Array>;
218
+
219
+ /**
220
+ * Read the persisted `last_indexed_head` from the store's meta table.
221
+ * Returns `null` when the key is absent (fresh DB).
222
+ */
223
+ export function readLastIndexedHead(store: GraphStore): ReadMetaResult {
224
+ return store.readMeta(META_KEY_LAST_HEAD);
225
+ }
226
+
227
+ /**
228
+ * Read every file row's path → content_hash from the store. Used by
229
+ * hash_scan to detect content drift without a reachable base commit.
230
+ */
231
+ export function readFileHashes(store: GraphStore): ReadFileHashesResult {
232
+ return store.readFileHashes();
233
+ }
234
+
235
+ /**
236
+ * SHA-256 of raw bytes — single implementation lives in the engine layer
237
+ * (`emit.ts`, where `FileIR.contentHash` is computed). Re-exported here
238
+ * for backward compatibility with index.ts and consumers that import
239
+ * from reindex.ts (rule 23: one hashing contract across engine + reindex).
240
+ */
241
+ export { hashContent };
242
+
243
+ /**
244
+ * Reject non-canonical repo-relative paths before they reach the disk.
245
+ * Git diff output is always canonical, but defense-in-depth prevents a
246
+ * crafted `..` path from reading outside repoRoot (cursor Bugbot:
247
+ * 'Reindex reads non-canonical paths'). Mirrors the store's
248
+ * `assertCanonicalFilePath` boundary.
249
+ */
250
+ function isCanonicalRelativePath(p: string): boolean {
251
+ if (typeof p !== "string" || p.length === 0) return false;
252
+ if (p.includes("\\")) return false; // backslash → Windows separator
253
+ if (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)) return false; // absolute
254
+ if (p.split("/").some((seg) => seg === "." || seg === "..")) return false;
255
+ return true;
256
+ }
257
+
258
+ /**
259
+ * Probe a repo-relative path: canonical check + existence/content read that
260
+ * distinguishes a CONFIRMED deletion (ENOENT) from a transient I/O error
261
+ * (EACCES / EBUSY / timeout). A transient error must NOT be treated as a
262
+ * deletion — otherwise a momentary lock/permission error would drop the
263
+ * file's nodes from the graph while the file still exists on disk
264
+ * (cursor Bugbot: 'Read errors trigger graph deletes'). Also enforces the
265
+ * canonical-path guard BEFORE any read so a crafted `../` path cannot
266
+ * escape repoRoot (cursor Bugbot: 'Non-canonical paths read early').
267
+ */
268
+ type ProbeRead =
269
+ | { readonly kind: "exists"; readonly content: Uint8Array }
270
+ | { readonly kind: "missing" }
271
+ | { readonly kind: "unknown" }
272
+ | { readonly kind: "skip" };
273
+ async function probeRead(
274
+ repoRoot: string,
275
+ relPath: string,
276
+ readFile: ReadFileFn,
277
+ ): Promise<ProbeRead> {
278
+ if (!isCanonicalRelativePath(relPath)) return { kind: "skip" };
279
+ const probeAbs = resolveRepoPath(repoRoot, relPath);
280
+ // Reject symlinks that escape repoRoot before reading (rule 3).
281
+ if (await symlinkEscapesRoot(repoRoot, probeAbs)) return { kind: "skip" };
282
+ try {
283
+ const content = await readFile(probeAbs);
284
+ return { kind: "exists", content };
285
+ } catch (e) {
286
+ const code =
287
+ e && typeof e === "object"
288
+ ? (e as { code?: unknown }).code
289
+ : undefined;
290
+ if (code === "ENOENT") return { kind: "missing" };
291
+ // Transient error — the file may still exist. Do not prune.
292
+ return { kind: "unknown" };
293
+ }
294
+ }
295
+
296
+ /** Resolve a repo-relative forward-slash path to an absolute OS path. */
297
+ function resolveRepoPath(repoRoot: string, relPath: string): string {
298
+ // Split on forward slashes and re-join with the platform separator so
299
+ // Windows backslash-in-relPath is never accidentally treated as escape.
300
+ return path.resolve(repoRoot, ...relPath.split("/"));
301
+ }
302
+
303
+ /**
304
+ * Symlink-escape guard (AGENTS.md rule 3): a canonical repo-relative path can
305
+ * still resolve — via a symlinked file OR any symlinked PARENT directory — to a
306
+ * target outside repoRoot; following it would let a crafted/tracked symlink
307
+ * read arbitrary files into the graph. `realpath` resolves every symlink in the
308
+ * path (including parents), so we containment-check the fully-resolved target
309
+ * against the resolved repoRoot. A non-existent path (realpath ENOENT) or any
310
+ * realpath error returns false so the normal read path classifies it
311
+ * (ENOENT→missing / transient→retry) and injected readers stay unaffected.
312
+ */
313
+ async function symlinkEscapesRoot(repoRoot: string, absPath: string): Promise<boolean> {
314
+ try {
315
+ const [realRoot, realAbs] = await Promise.all([
316
+ fsRealpath(repoRoot),
317
+ fsRealpath(absPath),
318
+ ]);
319
+ const rel = path.relative(realRoot, realAbs);
320
+ return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel);
321
+ } catch {
322
+ return false;
323
+ }
324
+ }
325
+
326
+ /** Default file reader: reads from disk via node:fs/promises. */
327
+ async function defaultReadFile(absPath: string): Promise<Uint8Array> {
328
+ const buf = await fsReadFile(absPath);
329
+ // Return a Uint8Array view over the same buffer (Buffer IS a
330
+ // Uint8Array subclass; this copy-free view satisfies the FileIR contract).
331
+ return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
332
+ }
333
+
334
+ /**
335
+ * Per-store reindex serialization. Concurrent `executeReindex` calls
336
+ * against the same store are serialized end-to-end so a slower run that
337
+ * indexed an older HEAD cannot finish later and overwrite
338
+ * `last_indexed_head` with a stale SHA (cursor Bugbot: 'Stale head
339
+ * after concurrent reindex'). The store's write queue already serializes
340
+ * individual upserts, but the head/meta writes around them are not
341
+ * ordered across runs without this gate.
342
+ */
343
+ const reindexLocks = new WeakMap<GraphStore, Promise<unknown>>();
344
+
345
+ /**
346
+ * Execute a reindex against a store + git repo.
347
+ *
348
+ * Steps:
349
+ * 1. Gather git facts (currentHead, reachable, changedFiles).
350
+ * 2. Plan via {@link planReindex}.
351
+ * 3. For full/incremental/hash_scan: parse each file, build a batch,
352
+ * call `store.upsertFileBatch`.
353
+ * 4. Persist `last_indexed_head` ONLY after the batch commits (rule 25).
354
+ *
355
+ * The reindex is serialized per-store via the store's own write queue.
356
+ * A session-start trigger racing a manual CLI trigger coalesces (rule 40).
357
+ *
358
+ * `candidatePaths` is needed for full and hash_scan modes (the set of
359
+ * files to consider). Typically from `git ls-files` or a glob. When
360
+ * omitted, full/hash_scan operate over the union of stored files and
361
+ * git-tracked files.
362
+ */
363
+ export async function executeReindex(options: {
364
+ readonly store: GraphStore;
365
+ readonly git: CodingGitInvoker;
366
+ readonly repoRoot: string;
367
+ readonly parseFile: ParseFileFn;
368
+ readonly candidatePaths?: readonly string[];
369
+ readonly readFile?: ReadFileFn;
370
+ }): Promise<ReindexResult> {
371
+ const { store, git, repoRoot, parseFile } = options;
372
+ const readFile = options.readFile ?? defaultReadFile;
373
+
374
+ // Serialize concurrent runs against the same store end-to-end.
375
+ const prev = reindexLocks.get(store) ?? Promise.resolve();
376
+ let release!: () => void;
377
+ const next = new Promise<void>((resolve) => {
378
+ release = resolve;
379
+ });
380
+ reindexLocks.set(store, prev.then(() => next));
381
+ await prev;
382
+ try {
383
+ return await runReindex(store, git, repoRoot, parseFile, readFile, options);
384
+ } finally {
385
+ release();
386
+ }
387
+ }
388
+
389
+ /** Inner reindex body — runs under the per-store serialization lock. */
390
+ async function runReindex(
391
+ store: GraphStore,
392
+ git: CodingGitInvoker,
393
+ repoRoot: string,
394
+ parseFile: ParseFileFn,
395
+ readFile: ReadFileFn,
396
+ options: {
397
+ readonly candidatePaths?: readonly string[];
398
+ },
399
+ ): Promise<ReindexResult> {
400
+
401
+ // ── Gather git facts ──────────────────────────────────────────────────
402
+ // readMeta now returns a tagged result (rule 22): a backend failure must
403
+ // NOT be conflated with "never indexed" (null), which would send the
404
+ // planner down a full-reindex path against a store it cannot read. Bail
405
+ // before any mutation (cursor Bugbot: 'readMeta conflates absent key
406
+ // with db failure').
407
+ const lastHeadRead = readLastIndexedHead(store);
408
+ if (!lastHeadRead.ok) {
409
+ return {
410
+ ok: false,
411
+ code: "store_error",
412
+ message: `read last_indexed_head: ${lastHeadRead.code}`,
413
+ };
414
+ }
415
+ const lastHead = lastHeadRead.value;
416
+ const headResult = git.revParseHead(repoRoot);
417
+ if (!headResult.ok) return headResult;
418
+
419
+ let reachable = true;
420
+ if (lastHead !== null && headResult.head !== null) {
421
+ const reachResult = git.isReachable(repoRoot, lastHead);
422
+ if (!reachResult.ok) return reachResult;
423
+ reachable = reachResult.reachable;
424
+ }
425
+
426
+ let changedFiles: NameStatusEntry[] = [];
427
+ if (
428
+ lastHead !== null &&
429
+ headResult.head !== null &&
430
+ reachable &&
431
+ lastHead !== headResult.head
432
+ ) {
433
+ const diffResult = git.diffNameStatus(
434
+ repoRoot,
435
+ `${lastHead}..${headResult.head}`,
436
+ );
437
+ if (!diffResult.ok) {
438
+ // Diff failed but heads were "reachable" — degrade to hash_scan.
439
+ reachable = false;
440
+ } else {
441
+ changedFiles = [...diffResult.entries];
442
+ }
443
+ }
444
+
445
+ const facts: ReindexGitFacts = {
446
+ currentHead: headResult.head,
447
+ lastHeadReachable: reachable,
448
+ changedFiles,
449
+ };
450
+ // Read the file-hash snapshot ONCE. A backend failure here must NOT be
451
+ // treated as an empty index — that would either skip pruning while head
452
+ // advances, or prune against a falsely-empty set. Bail before any mutation
453
+ // (rule 22; cursor Bugbot HIGH: 'readFileHashes conflates error with
454
+ // empty'). The same snapshot is reused for every prune decision below:
455
+ // nothing mutates the store between here and the ingest, so a fresh read
456
+ // at each site would only re-introduce the error/empty conflation.
457
+ const fileHashesRead = readFileHashes(store);
458
+ if (!fileHashesRead.ok) {
459
+ return {
460
+ ok: false,
461
+ code: "store_error",
462
+ message: `readFileHashes: ${fileHashesRead.code}`,
463
+ };
464
+ }
465
+ const fileHashes = fileHashesRead.hashes;
466
+
467
+ const lastState: ReindexState = {
468
+ lastHead,
469
+ fileHashes,
470
+ };
471
+
472
+ const plan = planReindex(lastState, facts);
473
+
474
+ // ── Pending parse-failure retry (rule 44) ─────────────────────────────
475
+ // Paths that failed to parse on a prior run must be retried even when
476
+ // HEAD is unchanged (a noop plan would otherwise skip them). Read once;
477
+ // we rewrite this set after every run with the CURRENT run's failures.
478
+ const pendingRetryRead = readPendingParseFailures(store);
479
+ if (!pendingRetryRead.ok) {
480
+ return {
481
+ ok: false,
482
+ code: "store_error",
483
+ message: `read pending_parse_failures: ${pendingRetryRead.code}`,
484
+ };
485
+ }
486
+ const pendingRetry = pendingRetryRead.paths;
487
+
488
+ // ── Execute the plan ──────────────────────────────────────────────────
489
+ switch (plan.mode) {
490
+ case "noop": {
491
+ if (pendingRetry.length === 0) {
492
+ return { ok: true, mode: "noop", filesIngested: 0, head: lastHead };
493
+ }
494
+ // HEAD unchanged but some files still need a parse retry. Re-ingest
495
+ // ONLY the pending set (no diff, no deletes). Clears paths that now
496
+ // parse; keeps failures for the next run.
497
+ const ingestResult = await ingestFiles(
498
+ store,
499
+ repoRoot,
500
+ parseFile,
501
+ readFile,
502
+ pendingRetry,
503
+ );
504
+ if (!ingestResult.ok) return ingestResult;
505
+ // Persist the updated pending set BEFORE any head write (rule 25:
506
+ // meta updates after the data transaction commits).
507
+ store.writeMeta(
508
+ META_KEY_PENDING_PARSE_FAILURES,
509
+ JSON.stringify(ingestResult.parseFailedPaths),
510
+ );
511
+ return {
512
+ ok: true,
513
+ mode: "noop",
514
+ filesIngested: ingestResult.count,
515
+ head: lastHead,
516
+ };
517
+ }
518
+
519
+ case "full": {
520
+ // An EMPTY explicit list is as insufficient as an omitted one: it is not
521
+ // an authoritative "the repo has zero files" signal, so it must NOT drive
522
+ // pruning or head advancement (cursor Bugbot HIGH: 'Empty candidate list
523
+ // prunes graph'). Require a non-empty list to treat candidates as
524
+ // authoritative. The genuine empty-repo case (no candidates, no retries)
525
+ // is handled below as a noop.
526
+ const candidatesProvided =
527
+ options.candidatePaths !== undefined && options.candidatePaths.length > 0;
528
+ const candidates = options.candidatePaths ?? [];
529
+ if (candidates.length === 0 && pendingRetry.length === 0) {
530
+ // Nothing to index and nothing to retry. Do NOT advance
531
+ // last_indexed_head — without candidates we did not build an
532
+ // index, so claiming freshness would be dishonest (an empty
533
+ // repo reports mode:"empty" via index_status, which is correct).
534
+ // (cursor Bugbot: 'Empty full run marks indexed'.)
535
+ return { ok: true, mode: "noop", filesIngested: 0, head: lastHead };
536
+ }
537
+ // Dedup: a path can appear in both candidates and pendingRetry;
538
+ // upsertFileBatch rejects duplicate paths in one batch (cursor
539
+ // Bugbot: 'Full reindex duplicate ingest paths').
540
+ const toIngest = [...new Set([...candidates, ...pendingRetry])];
541
+ // Prune stored files that are absent from the candidate set so a
542
+ // full reindex against a pre-existing store (v1 store, or a crash
543
+ // before the first meta write) does not leave deleted-file symbols
544
+ // behind while marking the index current (chatgpt-codex-connector:
545
+ // 'Prune absent files during full reindex').
546
+ // Only prune when the caller supplied an AUTHORITATIVE candidate list.
547
+ // Without candidatePaths (e.g. a retry-only resume) we cannot know which
548
+ // stored files are truly absent; pruning against a retry-only set would
549
+ // destructively delete every successfully-indexed file. Likewise we must
550
+ // not advance last_indexed_head in that case, since a partial (retry-only)
551
+ // pass has not re-verified the whole tree (chatgpt-codex-connector: 'Do
552
+ // not prune full indexes when candidates are absent').
553
+ let fullDelete: string[] = [];
554
+ if (candidatesProvided) {
555
+ const fullCandidateSet = new Set(toIngest);
556
+ fullDelete = [...fileHashes.keys()].filter(
557
+ (p) => !fullCandidateSet.has(p),
558
+ );
559
+ }
560
+ const ingestResult = await ingestFiles(
561
+ store,
562
+ repoRoot,
563
+ parseFile,
564
+ readFile,
565
+ toIngest,
566
+ fullDelete,
567
+ );
568
+ if (!ingestResult.ok) return ingestResult;
569
+ store.writeMeta(
570
+ META_KEY_PENDING_PARSE_FAILURES,
571
+ JSON.stringify(
572
+ computeNextPending({
573
+ priorPending: pendingRetry,
574
+ parseFailedPaths: ingestResult.parseFailedPaths,
575
+ ingestedCandidates: toIngest,
576
+ deleted: fullDelete,
577
+ }),
578
+ ),
579
+ );
580
+ // Rule 25: persist head ONLY after data + pending-set commit — and ONLY
581
+ // when candidates were authoritative (see above).
582
+ if (candidatesProvided) {
583
+ store.writeMeta(META_KEY_LAST_HEAD, headResult.head ?? "");
584
+ }
585
+ return {
586
+ ok: true,
587
+ mode: "full",
588
+ filesIngested: ingestResult.count,
589
+ head: candidatesProvided ? headResult.head : lastHead,
590
+ };
591
+ }
592
+
593
+ case "incremental": {
594
+ // Deduplicate (rule 49 — a file can appear staged AND in the commit
595
+ // diff). Use a Set for dynamic runtime membership tracking.
596
+ const seen = new Set<string>();
597
+ for (const p2 of plan.changedPaths) seen.add(p2);
598
+ // Pending parse-failures from a prior run are also candidates.
599
+ for (const p2 of pendingRetry) seen.add(p2);
600
+
601
+ // Determine which paths still exist on disk vs are deleted. Use a
602
+ // probe that distinguishes a confirmed deletion (ENOENT) from a
603
+ // transient I/O error (which must NOT prune the file).
604
+ const knownFiles = fileHashes;
605
+ const toDelete: string[] = [];
606
+ const toIngest: string[] = [];
607
+ // New candidate paths that probe missing on disk (sparse checkout /
608
+ // partial worktree / list-vs-disk race): retained in pending so a later
609
+ // run indexes them instead of advancing head as if done.
610
+ const missingNew: string[] = [];
611
+ for (const p2 of seen) {
612
+ const probe = await probeRead(repoRoot, p2, readFile);
613
+ if (probe.kind === "skip") {
614
+ // A previously-indexed path that is now non-canonical or an escaping
615
+ // symlink (probe skip) is no longer a readable repo file — prune its
616
+ // stale nodes rather than leaving them while head advances
617
+ // (chatgpt-codex-connector: 'Retain skipped symlink paths before
618
+ // advancing').
619
+ if (knownFiles.has(p2)) toDelete.push(p2);
620
+ continue;
621
+ }
622
+ if (probe.kind === "exists" || probe.kind === "unknown") {
623
+ // exists → re-ingest; unknown (transient error) → route to
624
+ // ingest so ingestFiles records it as a pending retry if the
625
+ // read still fails, WITHOUT deleting the existing nodes.
626
+ toIngest.push(p2);
627
+ } else if (probe.kind === "missing") {
628
+ // Confirmed deletion → prune if known; a NEW absent path → retain in
629
+ // pending (cursor Bugbot: 'Missing new paths skip pending').
630
+ if (knownFiles.has(p2)) toDelete.push(p2);
631
+ else missingNew.push(p2);
632
+ }
633
+ }
634
+
635
+ // Pass toDelete into upsertFileBatch so the prune + re-ingest land
636
+ // in ONE transaction (rule 22/25 — a mid-batch failure rolls both
637
+ // back; cursor Bugbot: 'Deletes commit before ingest fails').
638
+ const ingestResult = await ingestFiles(
639
+ store,
640
+ repoRoot,
641
+ parseFile,
642
+ readFile,
643
+ toIngest,
644
+ toDelete,
645
+ );
646
+ if (!ingestResult.ok) return ingestResult;
647
+ store.writeMeta(
648
+ META_KEY_PENDING_PARSE_FAILURES,
649
+ JSON.stringify(
650
+ computeNextPending({
651
+ priorPending: pendingRetry,
652
+ parseFailedPaths: ingestResult.parseFailedPaths,
653
+ extraRetry: missingNew,
654
+ ingestedCandidates: toIngest,
655
+ deleted: toDelete,
656
+ }),
657
+ ),
658
+ );
659
+ // Rule 25: persist head ONLY after data commits.
660
+ store.writeMeta(META_KEY_LAST_HEAD, headResult.head ?? "");
661
+ return {
662
+ ok: true,
663
+ mode: "incremental",
664
+ filesIngested: ingestResult.count,
665
+ head: headResult.head,
666
+ };
667
+ }
668
+
669
+ case "hash_scan": {
670
+ // Hash every candidate, re-parse only mismatches. This path is
671
+ // taken when last_head is unreachable (rebase/force-push) or when
672
+ // the incremental diff itself failed.
673
+ // Union caller-supplied candidates WITH the previously-indexed
674
+ // files AND pending retries. Omitting stored files would miss
675
+ // newly-deleted files (their symbols would linger while head
676
+ // advances); omitting candidates would miss newly-added files not
677
+ // yet in the index (chatgpt-codex-connector: 'Require candidates
678
+ // before completing hash-scan' / 'Include indexed files in
679
+ // hash-scan candidates'). An EMPTY explicit list is as insufficient
680
+ // as an omitted one, so it must not drive head advancement.
681
+ const hashScanCandidatesProvided =
682
+ options.candidatePaths !== undefined && options.candidatePaths.length > 0;
683
+ const candidateSet = new Set<string>([
684
+ ...(options.candidatePaths ?? []),
685
+ ...lastState.fileHashes.keys(),
686
+ ...pendingRetry,
687
+ ]);
688
+ // Hash every candidate via the canonical+ENOENT-aware probe. A
689
+ // transient read error must NOT be treated as a deletion.
690
+ const knownFiles = fileHashes;
691
+ const toDelete: string[] = [];
692
+ const toIngest: string[] = [];
693
+ const hashScanRetry: string[] = [];
694
+ for (const candidatePath of candidateSet) {
695
+ const probe = await probeRead(repoRoot, candidatePath, readFile);
696
+ if (probe.kind === "skip") {
697
+ // Previously-indexed path now non-canonical / escaping symlink →
698
+ // prune stale nodes rather than leave them while head advances.
699
+ if (knownFiles.has(candidatePath)) toDelete.push(candidatePath);
700
+ continue;
701
+ }
702
+ if (probe.kind === "missing") {
703
+ // Confirmed deletion of a known file → prune. A NEW candidate absent
704
+ // on disk (sparse checkout / list-vs-disk race) → retain in pending so
705
+ // a later run indexes it rather than advancing head as if done
706
+ // (cursor Bugbot: 'Missing new paths skip pending').
707
+ if (knownFiles.has(candidatePath)) toDelete.push(candidatePath);
708
+ else hashScanRetry.push(candidatePath);
709
+ continue;
710
+ }
711
+ if (probe.kind === "unknown") {
712
+ // Transient read error — keep the stored entry, do not prune,
713
+ // and record the path for a pending retry so it is re-tried on
714
+ // the next run even when HEAD is unchanged (chatgpt-codex-
715
+ // connector: 'Retain hash-scan read failures for retry').
716
+ hashScanRetry.push(candidatePath);
717
+ continue;
718
+ }
719
+ // exists — compare content hash.
720
+ const currentHash = hashContent(probe.content);
721
+ const storedHash = lastState.fileHashes.get(candidatePath);
722
+ if (storedHash !== currentHash) {
723
+ toIngest.push(candidatePath);
724
+ }
725
+ }
726
+
727
+ const ingestResult = await ingestFiles(
728
+ store,
729
+ repoRoot,
730
+ parseFile,
731
+ readFile,
732
+ toIngest,
733
+ toDelete,
734
+ );
735
+ if (!ingestResult.ok) return ingestResult;
736
+ store.writeMeta(
737
+ META_KEY_PENDING_PARSE_FAILURES,
738
+ JSON.stringify(
739
+ computeNextPending({
740
+ priorPending: pendingRetry,
741
+ parseFailedPaths: ingestResult.parseFailedPaths,
742
+ extraRetry: hashScanRetry,
743
+ ingestedCandidates: toIngest,
744
+ deleted: toDelete,
745
+ }),
746
+ ),
747
+ );
748
+ // Rule 25: persist head ONLY after data commits — and ONLY when the
749
+ // caller supplied candidates. Without candidatePaths the scan covers only
750
+ // stored + pending paths, so a file newly added in the current HEAD would
751
+ // be missed; advancing head would falsely report freshness (chatgpt-codex-
752
+ // connector: 'Require current candidates before advancing hash-scan').
753
+ if (hashScanCandidatesProvided) {
754
+ store.writeMeta(META_KEY_LAST_HEAD, headResult.head ?? "");
755
+ }
756
+ return {
757
+ ok: true,
758
+ mode: "hash_scan",
759
+ filesIngested: ingestResult.count,
760
+ head: hashScanCandidatesProvided ? headResult.head : lastHead,
761
+ };
762
+ }
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Read the persisted set of paths that failed to parse on the last run
768
+ * (rule 44). Returns an empty array when the key is absent or malformed.
769
+ */
770
+ function readPendingParseFailures(
771
+ store: GraphStore,
772
+ ): { ok: true; paths: string[] } | ({ ok: false } & GraphStoreFailure) {
773
+ const rawRead = store.readMeta(META_KEY_PENDING_PARSE_FAILURES);
774
+ if (!rawRead.ok) return rawRead;
775
+ const raw = rawRead.value;
776
+ if (raw === null) return { ok: true, paths: [] };
777
+ try {
778
+ const parsed = JSON.parse(raw);
779
+ if (!Array.isArray(parsed)) return { ok: true, paths: [] };
780
+ return {
781
+ ok: true,
782
+ paths: parsed.filter((p): p is string => typeof p === "string"),
783
+ };
784
+ } catch {
785
+ return { ok: true, paths: [] };
786
+ }
787
+ }
788
+
789
+ // ──────────────────────────────────────────────────────────────────────────
790
+ // Helpers
791
+ // ──────────────────────────────────────────────────────────────────────────
792
+
793
+ /**
794
+ * Compute the next `pending_parse_failures` set. A path must remain pending
795
+ * until it is actually INGESTED (parsed + stored) or confirmed DELETED —
796
+ * otherwise a pending path that was skipped (non-canonical), hash-matched in
797
+ * hash_scan, or otherwise not re-ingested silently drops off the retry list
798
+ * while `last_indexed_head` advances, so its symbols are never (re)built yet the
799
+ * index reports fresh (cursor Bugbot HIGH: 'Pending retries cleared
800
+ * incorrectly'). The next set is therefore the union of the prior pending set,
801
+ * this run's parse failures, and any transient retries, MINUS the paths that
802
+ * were successfully ingested or deleted this run.
803
+ */
804
+ function computeNextPending(args: {
805
+ readonly priorPending: readonly string[];
806
+ readonly parseFailedPaths: readonly string[];
807
+ readonly extraRetry?: readonly string[];
808
+ readonly ingestedCandidates: readonly string[];
809
+ readonly deleted: readonly string[];
810
+ }): string[] {
811
+ const failed = new Set(args.parseFailedPaths);
812
+ const successfullyIngested = new Set(
813
+ args.ingestedCandidates.filter((p) => !failed.has(p)),
814
+ );
815
+ const deleted = new Set(args.deleted);
816
+ const next = new Set<string>();
817
+ for (const path of [
818
+ ...args.priorPending,
819
+ ...args.parseFailedPaths,
820
+ ...(args.extraRetry ?? []),
821
+ ]) {
822
+ if (successfullyIngested.has(path) || deleted.has(path)) continue;
823
+ next.add(path);
824
+ }
825
+ return [...next];
826
+ }
827
+
828
+ /**
829
+ * Narrow result type for the ingest helper — avoids union overlap with
830
+ * {@link ReindexResult}'s success branches so the caller can cleanly
831
+ * discriminate on `ok` and read `.count` without ambiguity.
832
+ */
833
+ type IngestResult =
834
+ | {
835
+ readonly ok: true;
836
+ readonly count: number;
837
+ /** Paths that failed to parse (rule 44 — must retry next run). */
838
+ readonly parseFailedPaths: string[];
839
+ }
840
+ | { readonly ok: false; readonly code: "store_error"; readonly message: string };
841
+ /**
842
+ * Parse + ingest a list of files. Returns the count ingested or a tagged
843
+ * failure. Files that fail to parse do NOT update their stored content
844
+ * hash (rule 44 — they must retry next run). The caller records them in
845
+ * the `pending_parse_failures` meta key so the NEXT run re-ingests them
846
+ * even when HEAD is unchanged (a noop plan would otherwise skip them —
847
+ * cursor Bugbot: 'Parse skips block future reindex').
848
+ */
849
+ async function ingestFiles(
850
+ store: GraphStore,
851
+ repoRoot: string,
852
+ parseFile: ParseFileFn,
853
+ readFile: ReadFileFn,
854
+ paths: readonly string[],
855
+ deletePaths: readonly string[] = [],
856
+ ): Promise<IngestResult> {
857
+ const batch: StoreFileIR[] = [];
858
+ const parseFailedPaths: string[] = [];
859
+ for (const relPath of paths) {
860
+ if (!isCanonicalRelativePath(relPath)) {
861
+ // Non-canonical path would read outside repoRoot / be rejected by
862
+ // the store anyway. Record + skip (rule 44 — retry-safe).
863
+ parseFailedPaths.push(relPath);
864
+ continue;
865
+ }
866
+ const ingestAbs = resolveRepoPath(repoRoot, relPath);
867
+ // Reject symlinks that escape repoRoot before reading (rule 3). A skipped
868
+ // escape is not a parse failure — do not retain it as a pending retry.
869
+ if (await symlinkEscapesRoot(repoRoot, ingestAbs)) continue;
870
+ let content: Uint8Array;
871
+ try {
872
+ content = await readFile(ingestAbs);
873
+ } catch {
874
+ // File unreadable (deleted between plan and execution, or a
875
+ // transient I/O error). Record it for retry so a transient read
876
+ // failure does not silently drop a path that was never ingested
877
+ // (cursor Bugbot: 'Read errors drop pending retries'). A truly
878
+ // deleted file is pruned via deletePaths when the caller detected
879
+ // the deletion; here we only keep it retrying.
880
+ parseFailedPaths.push(relPath);
881
+ continue;
882
+ }
883
+ const parseResult = await parseFile({ path: relPath, content });
884
+ if (!parseResult.ok) {
885
+ // Rule 44: record unparseable files so they retry next run; do
886
+ // not fail the batch.
887
+ parseFailedPaths.push(relPath);
888
+ continue;
889
+ }
890
+ // FileIR is structurally assignable to StoreFileIR — the store only
891
+ // reads the fields it needs and ignores extra FileIR fields (imports,
892
+ // callSites). No cast needed.
893
+ batch.push(parseResult.ir);
894
+ }
895
+ // Even when batch is empty we must run the upsert so deletePaths are
896
+ // pruned atomically (the store's transaction wraps both). A zero-file,
897
+ // zero-delete call is a cheap no-op.
898
+ const upsertResult = await store.upsertFileBatch(batch, deletePaths);
899
+ if (!upsertResult.ok) {
900
+ return {
901
+ ok: false,
902
+ code: "store_error",
903
+ message: `upsertFileBatch failed: ${upsertResult.code}`,
904
+ };
905
+ }
906
+ return { ok: true, count: batch.length, parseFailedPaths };
907
+ }
908
+