/** * Incremental git-based reindex for the coding-graph (issue #1553). * * Architecture: a PURE planner + a thin executor. * * planReindex(lastState, gitFacts) → ReindexPlan * * The planner is a pure function over plain data — unit-testable without * git or a real SQLite handle. It classifies the current situation into * one of four modes and returns the file set to (re)ingest: * * - "full" — no prior state (fresh DB); parse every candidate. * - "noop" — HEAD unchanged AND no working-tree dirt; zero writes. * - "incremental" — HEAD advanced; re-parse only the changed files. * - "hash_scan" — last_head is unreachable (rebase/force-push); * hash every candidate and re-parse only mismatches. * * The executor parses the plan's files (via an injected `ParseFile` fn), * runs the store's `upsertFileBatch`, and persists `last_indexed_head` ONLY * after the data transaction commits (rule 25 — a crash between must leave * the old head, tested with an injected mid-transaction failure). * * Post-write reindex invariant (AGENTS.md rule 31): direct-write paths that * bypass this pipeline — e.g. a caller using `store.upsertFileBatch` * directly — MUST trigger reindex afterward so the graph stays consistent * with the persisted content hashes. This module is the canonical way to * satisfy that invariant. * * Distinct from Track A's `session-delta.ts` last-seen-head: that tracks * the session's view of the repo for recall diffing; `last_indexed_head` * here tracks the GRAPH's persisted index state. They are separate concerns * that may legitimately disagree (e.g. a session attached before the first * reindex run). */ import { readFile as fsReadFile, realpath as fsRealpath } from "node:fs/promises"; import path from "node:path"; import { hashContent } from "./engine/emit.js"; import type { ParseFileInput, ParseResult } from "@remnic/core"; import type { CodingGitInvoker, GitFailure, NameStatusEntry } from "./git-invoker.js"; import type { FileIR, GraphStore, StoreFileIR, ReadMetaResult, ReadFileHashesResult, GraphStoreFailure, } from "./graph-store.js"; import { deriveHeuristicEdges, HEURISTIC_PROVENANCE_SCOPE, } from "./heuristic-resolution.js"; // ────────────────────────────────────────────────────────────────────────── // Public types — the planner's input and output // ────────────────────────────────────────────────────────────────────────── /** * The persisted index state the planner reasons about. * `lastHead: null` means "never indexed" → full reindex. */ export interface ReindexState { /** The HEAD SHA at the time of the last successful reindex, or `null`. */ readonly lastHead: string | null; /** * Per-file content hashes as of the last index. Used by hash_scan mode * to detect content drift without a reachable base commit. * Keyed by repo-relative forward-slash path. */ readonly fileHashes: ReadonlyMap; } /** Git facts the planner needs — gathered BEFORE the plan is computed. */ export interface ReindexGitFacts { /** Current `git rev-parse HEAD`. `null` when the repo has no commits. */ readonly currentHead: string | null; /** * Whether `lastHead` (when non-null) is still reachable in the repo. * `false` after a rebase/force-push that rewrote history past the old * head. `true` when `lastHead` is null (no prior state to reach). */ readonly lastHeadReachable: boolean; /** * Files changed between `lastHead` and `currentHead` (when both are * non-null and reachable). One entry per file from * `git diff --name-status`. Empty array for a noop or fresh repo. */ readonly changedFiles: readonly NameStatusEntry[]; } /** What the planner decided to do. */ export type ReindexPlan = | { readonly mode: "full"; readonly reason: string } | { readonly mode: "noop"; readonly reason: string } | { readonly mode: "incremental"; readonly changedPaths: readonly string[] } | { readonly mode: "hash_scan"; readonly reason: string; /** Paths whose on-disk hash differs from the stored hash. */ readonly mismatchedPaths: readonly string[]; }; /** Result of executing a reindex plan. */ export type ReindexResult = | { readonly ok: true; readonly mode: "full" | "noop" | "incremental" | "hash_scan"; /** Number of files actually parsed + ingested. */ readonly filesIngested: number; /** The new `last_indexed_head` persisted to meta (null on noop). */ readonly head: string | null; } | ({ readonly ok: false } & GitFailure) | { readonly ok: false; readonly code: "parse_failed"; readonly path: string; readonly message: string; } | { readonly ok: false; readonly code: "store_error"; readonly message: string; }; // ────────────────────────────────────────────────────────────────────────── // Meta-table keys — the store's `meta` table is a simple key/value store. // ────────────────────────────────────────────────────────────────────────── export const META_KEY_LAST_HEAD = "last_indexed_head" as const; /** * Meta key holding a JSON array of repo-relative paths that failed to * parse on the last run (rule 44: files that fail to parse do not update * their stored content hash and MUST retry on the next run). Because a * HEAD-unchanged run would otherwise plan `noop` and skip them, the * executor consults this set at the top of every run and re-ingests any * pending paths even when the plan is noop (cursor Bugbot: 'Parse skips * block future reindex'). */ export const META_KEY_PENDING_PARSE_FAILURES = "pending_parse_failures" as const; // ────────────────────────────────────────────────────────────────────────── // Pure planner — unit-testable without git or SQLite // ────────────────────────────────────────────────────────────────────────── /** * Decide what to do given the last state and current git facts. * * Decision tree: * 1. `currentHead === null` → noop (nothing to index; empty repo). * 2. `lastHead === null` → full (first index). * 3. `lastHead === currentHead` → noop (HEAD unchanged). * 4. `!lastHeadReachable` → hash_scan (rebase/force-push lost the base). * 5. Otherwise → incremental (re-parse changedFiles paths). * * Deleted files (status `D`) are included in `changedPaths` so the executor * can prune them from the store. Renames include both old and new paths. */ export function planReindex( lastState: ReindexState, facts: ReindexGitFacts, ): ReindexPlan { // Case 1: no HEAD at all — nothing to do. if (facts.currentHead === null) { return { mode: "noop", reason: "repo has no commits (HEAD is null)" }; } // Case 2: never indexed → full. if (lastState.lastHead === null) { return { mode: "full", reason: "no prior last_indexed_head — first index" }; } // Case 3: HEAD unchanged → noop. No writes, no parse. if (lastState.lastHead === facts.currentHead) { return { mode: "noop", reason: "HEAD unchanged since last index" }; } // Case 4: last_head is unreachable (rebase/force-push). Fall back to // hash_scan — the executor hashes every candidate and re-parses only // mismatches. Never crash, never silently full-reindex without saying so. if (!facts.lastHeadReachable) { return { mode: "hash_scan", reason: `last_indexed_head ${lastState.lastHead.slice(0, 12)} is unreachable (rebase/force-push)`, // mismatchedPaths is filled by the executor (needs to read files). mismatchedPaths: [], }; } // Case 5: incremental. The diff is trustworthy; re-parse changed files. const changedPaths: string[] = []; for (const entry of facts.changedFiles) { // `D` = deleted → include so executor prunes the file. // `A` = added, `M` = modified → include to (re)parse. // `R*`/`C*` = renamed/copied → include both old and new paths. changedPaths.push(entry.path); if (entry.oldPath !== undefined && entry.oldPath !== entry.path) { changedPaths.push(entry.oldPath); } } return { mode: "incremental", changedPaths }; } // ────────────────────────────────────────────────────────────────────────── // Executor — parses files, drives the store, updates meta // ────────────────────────────────────────────────────────────────────────── /** * A parse function — the engine seam. The executor calls this for each * file the plan says to (re)ingest. In production the orchestrator injects * the real `CodingGraphEngine.parseFile`; in tests a synthetic parser is * injected. This keeps the package decoupled from the engine implementation * (which is still a placeholder in #1551 PR1). */ export type ParseFileFn = (input: ParseFileInput) => Promise; /** Injectable file reader — defaults to `node:fs/promises`.readFile. */ export type ReadFileFn = (absPath: string) => Promise; /** * Read the persisted `last_indexed_head` from the store's meta table. * Returns `null` when the key is absent (fresh DB). */ export function readLastIndexedHead(store: GraphStore): ReadMetaResult { return store.readMeta(META_KEY_LAST_HEAD); } /** * Read every file row's path → content_hash from the store. Used by * hash_scan to detect content drift without a reachable base commit. */ export function readFileHashes(store: GraphStore): ReadFileHashesResult { return store.readFileHashes(); } /** * SHA-256 of raw bytes — single implementation lives in the engine layer * (`emit.ts`, where `FileIR.contentHash` is computed). Re-exported here * for backward compatibility with index.ts and consumers that import * from reindex.ts (rule 23: one hashing contract across engine + reindex). */ export { hashContent }; /** * Reject non-canonical repo-relative paths before they reach the disk. * Git diff output is always canonical, but defense-in-depth prevents a * crafted `..` path from reading outside repoRoot (cursor Bugbot: * 'Reindex reads non-canonical paths'). Mirrors the store's * `assertCanonicalFilePath` boundary. */ function isCanonicalRelativePath(p: string): boolean { if (typeof p !== "string" || p.length === 0) return false; if (p.includes("\\")) return false; // backslash → Windows separator if (p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p)) return false; // absolute if (p.split("/").some((seg) => seg === "." || seg === "..")) return false; return true; } /** * Probe a repo-relative path: canonical check + existence/content read that * distinguishes a CONFIRMED deletion (ENOENT) from a transient I/O error * (EACCES / EBUSY / timeout). A transient error must NOT be treated as a * deletion — otherwise a momentary lock/permission error would drop the * file's nodes from the graph while the file still exists on disk * (cursor Bugbot: 'Read errors trigger graph deletes'). Also enforces the * canonical-path guard BEFORE any read so a crafted `../` path cannot * escape repoRoot (cursor Bugbot: 'Non-canonical paths read early'). */ type ProbeRead = | { readonly kind: "exists"; readonly content: Uint8Array } | { readonly kind: "missing" } | { readonly kind: "unknown" } | { readonly kind: "skip" }; async function probeRead( repoRoot: string, relPath: string, readFile: ReadFileFn, ): Promise { if (!isCanonicalRelativePath(relPath)) return { kind: "skip" }; const probeAbs = resolveRepoPath(repoRoot, relPath); // Reject symlinks that escape repoRoot before reading (rule 3). if (await symlinkEscapesRoot(repoRoot, probeAbs)) return { kind: "skip" }; try { const content = await readFile(probeAbs); return { kind: "exists", content }; } catch (e) { const code = e && typeof e === "object" ? (e as { code?: unknown }).code : undefined; if (code === "ENOENT") return { kind: "missing" }; // Transient error — the file may still exist. Do not prune. return { kind: "unknown" }; } } /** Resolve a repo-relative forward-slash path to an absolute OS path. */ function resolveRepoPath(repoRoot: string, relPath: string): string { // Split on forward slashes and re-join with the platform separator so // Windows backslash-in-relPath is never accidentally treated as escape. return path.resolve(repoRoot, ...relPath.split("/")); } /** * Symlink-escape guard (AGENTS.md rule 3): a canonical repo-relative path can * still resolve — via a symlinked file OR any symlinked PARENT directory — to a * target outside repoRoot; following it would let a crafted/tracked symlink * read arbitrary files into the graph. `realpath` resolves every symlink in the * path (including parents), so we containment-check the fully-resolved target * against the resolved repoRoot. A non-existent path (realpath ENOENT) or any * realpath error returns false so the normal read path classifies it * (ENOENT→missing / transient→retry) and injected readers stay unaffected. */ async function symlinkEscapesRoot(repoRoot: string, absPath: string): Promise { try { const [realRoot, realAbs] = await Promise.all([ fsRealpath(repoRoot), fsRealpath(absPath), ]); const rel = path.relative(realRoot, realAbs); return rel === ".." || rel.startsWith(".." + path.sep) || path.isAbsolute(rel); } catch { return false; } } /** Default file reader: reads from disk via node:fs/promises. */ async function defaultReadFile(absPath: string): Promise { const buf = await fsReadFile(absPath); // Return a Uint8Array view over the same buffer (Buffer IS a // Uint8Array subclass; this copy-free view satisfies the FileIR contract). return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); } /** * Per-store reindex serialization. Concurrent `executeReindex` calls * against the same store are serialized end-to-end so a slower run that * indexed an older HEAD cannot finish later and overwrite * `last_indexed_head` with a stale SHA (cursor Bugbot: 'Stale head * after concurrent reindex'). The store's write queue already serializes * individual upserts, but the head/meta writes around them are not * ordered across runs without this gate. */ const reindexLocks = new WeakMap>(); /** * Execute a reindex against a store + git repo. * * Steps: * 1. Gather git facts (currentHead, reachable, changedFiles). * 2. Plan via {@link planReindex}. * 3. For full/incremental/hash_scan: parse each file, build a batch, * call `store.upsertFileBatch`. * 4. Persist `last_indexed_head` ONLY after the batch commits (rule 25). * * The reindex is serialized per-store via the store's own write queue. * A session-start trigger racing a manual CLI trigger coalesces (rule 40). * * `candidatePaths` is needed for full and hash_scan modes (the set of * files to consider). Typically from `git ls-files` or a glob. When * omitted, full/hash_scan operate over the union of stored files and * git-tracked files. */ export async function executeReindex(options: { readonly store: GraphStore; readonly git: CodingGitInvoker; readonly repoRoot: string; readonly parseFile: ParseFileFn; readonly candidatePaths?: readonly string[]; readonly readFile?: ReadFileFn; }): Promise { const { store, git, repoRoot, parseFile } = options; const readFile = options.readFile ?? defaultReadFile; // Serialize concurrent runs against the same store end-to-end. const prev = reindexLocks.get(store) ?? Promise.resolve(); let release!: () => void; const next = new Promise((resolve) => { release = resolve; }); reindexLocks.set(store, prev.then(() => next)); await prev; try { return await runReindex(store, git, repoRoot, parseFile, readFile, options); } finally { release(); } } /** Inner reindex body — runs under the per-store serialization lock. */ async function runReindex( store: GraphStore, git: CodingGitInvoker, repoRoot: string, parseFile: ParseFileFn, readFile: ReadFileFn, options: { readonly candidatePaths?: readonly string[]; }, ): Promise { // ── Gather git facts ────────────────────────────────────────────────── // readMeta now returns a tagged result (rule 22): a backend failure must // NOT be conflated with "never indexed" (null), which would send the // planner down a full-reindex path against a store it cannot read. Bail // before any mutation (cursor Bugbot: 'readMeta conflates absent key // with db failure'). const lastHeadRead = readLastIndexedHead(store); if (!lastHeadRead.ok) { return { ok: false, code: "store_error", message: `read last_indexed_head: ${lastHeadRead.code}`, }; } const lastHead = lastHeadRead.value; const headResult = git.revParseHead(repoRoot); if (!headResult.ok) return headResult; let reachable = true; if (lastHead !== null && headResult.head !== null) { const reachResult = git.isReachable(repoRoot, lastHead); if (!reachResult.ok) return reachResult; reachable = reachResult.reachable; } let changedFiles: NameStatusEntry[] = []; if ( lastHead !== null && headResult.head !== null && reachable && lastHead !== headResult.head ) { const diffResult = git.diffNameStatus( repoRoot, `${lastHead}..${headResult.head}`, ); if (!diffResult.ok) { // Diff failed but heads were "reachable" — degrade to hash_scan. reachable = false; } else { changedFiles = [...diffResult.entries]; } } const facts: ReindexGitFacts = { currentHead: headResult.head, lastHeadReachable: reachable, changedFiles, }; // Read the file-hash snapshot ONCE. A backend failure here must NOT be // treated as an empty index — that would either skip pruning while head // advances, or prune against a falsely-empty set. Bail before any mutation // (rule 22; cursor Bugbot HIGH: 'readFileHashes conflates error with // empty'). The same snapshot is reused for every prune decision below: // nothing mutates the store between here and the ingest, so a fresh read // at each site would only re-introduce the error/empty conflation. const fileHashesRead = readFileHashes(store); if (!fileHashesRead.ok) { return { ok: false, code: "store_error", message: `readFileHashes: ${fileHashesRead.code}`, }; } const fileHashes = fileHashesRead.hashes; const lastState: ReindexState = { lastHead, fileHashes, }; const plan = planReindex(lastState, facts); // ── Pending parse-failure retry (rule 44) ───────────────────────────── // Paths that failed to parse on a prior run must be retried even when // HEAD is unchanged (a noop plan would otherwise skip them). Read once; // we rewrite this set after every run with the CURRENT run's failures. const pendingRetryRead = readPendingParseFailures(store); if (!pendingRetryRead.ok) { return { ok: false, code: "store_error", message: `read pending_parse_failures: ${pendingRetryRead.code}`, }; } const pendingRetry = pendingRetryRead.paths; // ── Execute the plan ────────────────────────────────────────────────── switch (plan.mode) { case "noop": { if (pendingRetry.length === 0) { return { ok: true, mode: "noop", filesIngested: 0, head: lastHead }; } // HEAD unchanged but some files still need a parse retry. Re-ingest // ONLY the pending set (no diff, no deletes). Clears paths that now // parse; keeps failures for the next run. const ingestResult = await ingestFiles( store, repoRoot, parseFile, readFile, pendingRetry, ); if (!ingestResult.ok) return ingestResult; // Persist the updated pending set BEFORE any head write (rule 25: // meta updates after the data transaction commits). store.writeMeta( META_KEY_PENDING_PARSE_FAILURES, JSON.stringify(ingestResult.parseFailedPaths), ); return { ok: true, mode: "noop", filesIngested: ingestResult.count, head: lastHead, }; } case "full": { // An EMPTY explicit list is as insufficient as an omitted one: it is not // an authoritative "the repo has zero files" signal, so it must NOT drive // pruning or head advancement (cursor Bugbot HIGH: 'Empty candidate list // prunes graph'). Require a non-empty list to treat candidates as // authoritative. The genuine empty-repo case (no candidates, no retries) // is handled below as a noop. const candidatesProvided = options.candidatePaths !== undefined && options.candidatePaths.length > 0; const candidates = options.candidatePaths ?? []; if (candidates.length === 0 && pendingRetry.length === 0) { // Nothing to index and nothing to retry. Do NOT advance // last_indexed_head — without candidates we did not build an // index, so claiming freshness would be dishonest (an empty // repo reports mode:"empty" via index_status, which is correct). // (cursor Bugbot: 'Empty full run marks indexed'.) return { ok: true, mode: "noop", filesIngested: 0, head: lastHead }; } // Dedup: a path can appear in both candidates and pendingRetry; // upsertFileBatch rejects duplicate paths in one batch (cursor // Bugbot: 'Full reindex duplicate ingest paths'). const toIngest = [...new Set([...candidates, ...pendingRetry])]; // Prune stored files that are absent from the candidate set so a // full reindex against a pre-existing store (v1 store, or a crash // before the first meta write) does not leave deleted-file symbols // behind while marking the index current (chatgpt-codex-connector: // 'Prune absent files during full reindex'). // Only prune when the caller supplied an AUTHORITATIVE candidate list. // Without candidatePaths (e.g. a retry-only resume) we cannot know which // stored files are truly absent; pruning against a retry-only set would // destructively delete every successfully-indexed file. Likewise we must // not advance last_indexed_head in that case, since a partial (retry-only) // pass has not re-verified the whole tree (chatgpt-codex-connector: 'Do // not prune full indexes when candidates are absent'). let fullDelete: string[] = []; if (candidatesProvided) { const fullCandidateSet = new Set(toIngest); fullDelete = [...fileHashes.keys()].filter( (p) => !fullCandidateSet.has(p), ); } const ingestResult = await ingestFiles( store, repoRoot, parseFile, readFile, toIngest, fullDelete, ); if (!ingestResult.ok) return ingestResult; store.writeMeta( META_KEY_PENDING_PARSE_FAILURES, JSON.stringify( computeNextPending({ priorPending: pendingRetry, parseFailedPaths: ingestResult.parseFailedPaths, ingestedCandidates: toIngest, deleted: fullDelete, }), ), ); // Rule 25: persist head ONLY after data + pending-set commit — and ONLY // when candidates were authoritative (see above). if (candidatesProvided) { store.writeMeta(META_KEY_LAST_HEAD, headResult.head ?? ""); } return { ok: true, mode: "full", filesIngested: ingestResult.count, head: candidatesProvided ? headResult.head : lastHead, }; } case "incremental": { // Deduplicate (rule 49 — a file can appear staged AND in the commit // diff). Use a Set for dynamic runtime membership tracking. const seen = new Set(); for (const p2 of plan.changedPaths) seen.add(p2); // Pending parse-failures from a prior run are also candidates. for (const p2 of pendingRetry) seen.add(p2); // Determine which paths still exist on disk vs are deleted. Use a // probe that distinguishes a confirmed deletion (ENOENT) from a // transient I/O error (which must NOT prune the file). const knownFiles = fileHashes; const toDelete: string[] = []; const toIngest: string[] = []; // New candidate paths that probe missing on disk (sparse checkout / // partial worktree / list-vs-disk race): retained in pending so a later // run indexes them instead of advancing head as if done. const missingNew: string[] = []; for (const p2 of seen) { const probe = await probeRead(repoRoot, p2, readFile); if (probe.kind === "skip") { // A previously-indexed path that is now non-canonical or an escaping // symlink (probe skip) is no longer a readable repo file — prune its // stale nodes rather than leaving them while head advances // (chatgpt-codex-connector: 'Retain skipped symlink paths before // advancing'). if (knownFiles.has(p2)) toDelete.push(p2); continue; } if (probe.kind === "exists" || probe.kind === "unknown") { // exists → re-ingest; unknown (transient error) → route to // ingest so ingestFiles records it as a pending retry if the // read still fails, WITHOUT deleting the existing nodes. toIngest.push(p2); } else if (probe.kind === "missing") { // Confirmed deletion → prune if known; a NEW absent path → retain in // pending (cursor Bugbot: 'Missing new paths skip pending'). if (knownFiles.has(p2)) toDelete.push(p2); else missingNew.push(p2); } } // Pass toDelete into upsertFileBatch so the prune + re-ingest land // in ONE transaction (rule 22/25 — a mid-batch failure rolls both // back; cursor Bugbot: 'Deletes commit before ingest fails'). const ingestResult = await ingestFiles( store, repoRoot, parseFile, readFile, toIngest, toDelete, ); if (!ingestResult.ok) return ingestResult; store.writeMeta( META_KEY_PENDING_PARSE_FAILURES, JSON.stringify( computeNextPending({ priorPending: pendingRetry, parseFailedPaths: ingestResult.parseFailedPaths, extraRetry: missingNew, ingestedCandidates: toIngest, deleted: toDelete, }), ), ); // Rule 25: persist head ONLY after data commits. store.writeMeta(META_KEY_LAST_HEAD, headResult.head ?? ""); return { ok: true, mode: "incremental", filesIngested: ingestResult.count, head: headResult.head, }; } case "hash_scan": { // Hash every candidate, re-parse only mismatches. This path is // taken when last_head is unreachable (rebase/force-push) or when // the incremental diff itself failed. // Union caller-supplied candidates WITH the previously-indexed // files AND pending retries. Omitting stored files would miss // newly-deleted files (their symbols would linger while head // advances); omitting candidates would miss newly-added files not // yet in the index (chatgpt-codex-connector: 'Require candidates // before completing hash-scan' / 'Include indexed files in // hash-scan candidates'). An EMPTY explicit list is as insufficient // as an omitted one, so it must not drive head advancement. const hashScanCandidatesProvided = options.candidatePaths !== undefined && options.candidatePaths.length > 0; const candidateSet = new Set([ ...(options.candidatePaths ?? []), ...lastState.fileHashes.keys(), ...pendingRetry, ]); // Hash every candidate via the canonical+ENOENT-aware probe. A // transient read error must NOT be treated as a deletion. const knownFiles = fileHashes; const toDelete: string[] = []; const toIngest: string[] = []; const hashScanRetry: string[] = []; for (const candidatePath of candidateSet) { const probe = await probeRead(repoRoot, candidatePath, readFile); if (probe.kind === "skip") { // Previously-indexed path now non-canonical / escaping symlink → // prune stale nodes rather than leave them while head advances. if (knownFiles.has(candidatePath)) toDelete.push(candidatePath); continue; } if (probe.kind === "missing") { // Confirmed deletion of a known file → prune. A NEW candidate absent // on disk (sparse checkout / list-vs-disk race) → retain in pending so // a later run indexes it rather than advancing head as if done // (cursor Bugbot: 'Missing new paths skip pending'). if (knownFiles.has(candidatePath)) toDelete.push(candidatePath); else hashScanRetry.push(candidatePath); continue; } if (probe.kind === "unknown") { // Transient read error — keep the stored entry, do not prune, // and record the path for a pending retry so it is re-tried on // the next run even when HEAD is unchanged (chatgpt-codex- // connector: 'Retain hash-scan read failures for retry'). hashScanRetry.push(candidatePath); continue; } // exists — compare content hash. const currentHash = hashContent(probe.content); const storedHash = lastState.fileHashes.get(candidatePath); if (storedHash !== currentHash) { toIngest.push(candidatePath); } } const ingestResult = await ingestFiles( store, repoRoot, parseFile, readFile, toIngest, toDelete, ); if (!ingestResult.ok) return ingestResult; store.writeMeta( META_KEY_PENDING_PARSE_FAILURES, JSON.stringify( computeNextPending({ priorPending: pendingRetry, parseFailedPaths: ingestResult.parseFailedPaths, extraRetry: hashScanRetry, ingestedCandidates: toIngest, deleted: toDelete, }), ), ); // Rule 25: persist head ONLY after data commits — and ONLY when the // caller supplied candidates. Without candidatePaths the scan covers only // stored + pending paths, so a file newly added in the current HEAD would // be missed; advancing head would falsely report freshness (chatgpt-codex- // connector: 'Require current candidates before advancing hash-scan'). if (hashScanCandidatesProvided) { store.writeMeta(META_KEY_LAST_HEAD, headResult.head ?? ""); } return { ok: true, mode: "hash_scan", filesIngested: ingestResult.count, head: hashScanCandidatesProvided ? headResult.head : lastHead, }; } } } /** * Read the persisted set of paths that failed to parse on the last run * (rule 44). Returns an empty array when the key is absent or malformed. */ function readPendingParseFailures( store: GraphStore, ): { ok: true; paths: string[] } | ({ ok: false } & GraphStoreFailure) { const rawRead = store.readMeta(META_KEY_PENDING_PARSE_FAILURES); if (!rawRead.ok) return rawRead; const raw = rawRead.value; if (raw === null) return { ok: true, paths: [] }; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return { ok: true, paths: [] }; return { ok: true, paths: parsed.filter((p): p is string => typeof p === "string"), }; } catch { return { ok: true, paths: [] }; } } // ────────────────────────────────────────────────────────────────────────── // Helpers // ────────────────────────────────────────────────────────────────────────── /** * Compute the next `pending_parse_failures` set. A path must remain pending * until it is actually INGESTED (parsed + stored) or confirmed DELETED — * otherwise a pending path that was skipped (non-canonical), hash-matched in * hash_scan, or otherwise not re-ingested silently drops off the retry list * while `last_indexed_head` advances, so its symbols are never (re)built yet the * index reports fresh (cursor Bugbot HIGH: 'Pending retries cleared * incorrectly'). The next set is therefore the union of the prior pending set, * this run's parse failures, and any transient retries, MINUS the paths that * were successfully ingested or deleted this run. */ function computeNextPending(args: { readonly priorPending: readonly string[]; readonly parseFailedPaths: readonly string[]; readonly extraRetry?: readonly string[]; readonly ingestedCandidates: readonly string[]; readonly deleted: readonly string[]; }): string[] { const failed = new Set(args.parseFailedPaths); const successfullyIngested = new Set( args.ingestedCandidates.filter((p) => !failed.has(p)), ); const deleted = new Set(args.deleted); const next = new Set(); for (const path of [ ...args.priorPending, ...args.parseFailedPaths, ...(args.extraRetry ?? []), ]) { if (successfullyIngested.has(path) || deleted.has(path)) continue; next.add(path); } return [...next]; } /** * Narrow result type for the ingest helper — avoids union overlap with * {@link ReindexResult}'s success branches so the caller can cleanly * discriminate on `ok` and read `.count` without ambiguity. */ type IngestResult = | { readonly ok: true; readonly count: number; /** Paths that failed to parse (rule 44 — must retry next run). */ readonly parseFailedPaths: string[]; } | { readonly ok: false; readonly code: "store_error"; readonly message: string }; /** * Parse + ingest a list of files. Returns the count ingested or a tagged * failure. Files that fail to parse do NOT update their stored content * hash (rule 44 — they must retry next run). The caller records them in * the `pending_parse_failures` meta key so the NEXT run re-ingests them * even when HEAD is unchanged (a noop plan would otherwise skip them — * cursor Bugbot: 'Parse skips block future reindex'). */ async function ingestFiles( store: GraphStore, repoRoot: string, parseFile: ParseFileFn, readFile: ReadFileFn, paths: readonly string[], deletePaths: readonly string[] = [], ): Promise { const parsed: FileIR[] = []; const parseFailedPaths: string[] = []; for (const relPath of paths) { if (!isCanonicalRelativePath(relPath)) { // Non-canonical path would read outside repoRoot / be rejected by // the store anyway. Record + skip (rule 44 — retry-safe). parseFailedPaths.push(relPath); continue; } const ingestAbs = resolveRepoPath(repoRoot, relPath); // Reject symlinks that escape repoRoot before reading (rule 3). A skipped // escape is not a parse failure — do not retain it as a pending retry. if (await symlinkEscapesRoot(repoRoot, ingestAbs)) continue; let content: Uint8Array; try { content = await readFile(ingestAbs); } catch { // File unreadable (deleted between plan and execution, or a // transient I/O error). Record it for retry so a transient read // failure does not silently drop a path that was never ingested // (cursor Bugbot: 'Read errors drop pending retries'). A truly // deleted file is pruned via deletePaths when the caller detected // the deletion; here we only keep it retrying. parseFailedPaths.push(relPath); continue; } const parseResult = await parseFile({ path: relPath, content }); if (!parseResult.ok) { // Rule 44: record unparseable files so they retry next run; do // not fail the batch. parseFailedPaths.push(relPath); continue; } parsed.push(parseResult.ir); } // Phase A heuristic resolution (issue #1891): derive CALLS edge // assertions from the fresh parse before ingest. Each file carries an // explicit edges array plus a provenance scope of ["heuristic"], so // stale heuristic edges from a prior version are cleaned up while // trace/lsp edges owned by the file survive (rule 25). const derived = deriveHeuristicEdges(parsed); const batch: StoreFileIR[] = derived.files.map((file) => ({ ...file, assertedEdgeProvenances: HEURISTIC_PROVENANCE_SCOPE, })); // Even when batch is empty we must run the upsert so deletePaths are // pruned atomically (the store's transaction wraps both). A zero-file, // zero-delete call is a cheap no-op. const upsertResult = await store.upsertFileBatch(batch, deletePaths); if (!upsertResult.ok) { return { ok: false, code: "store_error", message: `upsertFileBatch failed: ${upsertResult.code}`, }; } return { ok: true, count: batch.length, parseFailedPaths }; }