@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
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
|
+
|