diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index 1cd64a2851..c190670c2e 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -1202,10 +1202,14 @@ const analyzeCommandImpl = async ( if ( options.incrementalOnly && - (options.force || options.repairFts || options.repairVector || options.skills) + (options.force || + options.repairFts || + options.repairVector || + options.dropEmbeddings || + options.skills) ) { cliError( - ' Cannot combine `--incremental-only` with `--force`, `--repair-fts`, `--repair-vector`, or `--skills`. ' + + ' Cannot combine `--incremental-only` with `--force`, `--repair-fts`, `--repair-vector`, `--drop-embeddings`, or `--skills`. ' + 'The preservation contract refuses every option that can require a full rebuild.\n', ); process.exitCode = 1; @@ -1468,6 +1472,27 @@ const analyzeCommandImpl = async ( }, ); + if (result.recoveredPromotionOnly) { + await assertAnalysisFinalized(repoPath); + clearInterval(elapsedTimer); + process.removeListener('SIGINT', sigintHandler); + process.removeListener('SIGTERM', sigtermHandler); + await emitResourceEvent('complete', 'recovered-promotion-only', 100); + await closeResourceLog(); + console.log = origLog; + // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing + console.warn = origWarn; + // eslint-disable-next-line no-console -- restoring after intentional progress-bar routing + console.error = origError; + bar.stop(); + console.log(' Recovered the previous staged promotion.'); + console.log(' The current checkout was not analyzed in this recovery-only run.'); + console.log( + ' Run `gitnexus analyze --staged --drop-embeddings` to build and promote a clean generation for the current checkout.\n', + ); + return; + } + if (result.alreadyUpToDate) { // Even the fast path must prove the repo is discoverable. A prior // run can write meta.json and then fail before registerRepo(); in diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index fd28daaa3e..4bf95acca4 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -147,6 +147,7 @@ import { discardStagedWorkspace, getStagedAnalyzePaths, hasPendingPromotion, + inspectStagedWorkspaceSource, prepareStagedWorkspace, promoteStagedGeneration, validateStagedGeneration, @@ -462,6 +463,12 @@ export interface AnalyzeResult { embeddings?: number; }; alreadyUpToDate?: boolean; + /** + * True when this invocation only completed a previously journaled staged + * promotion. The recovered generation is durable and registered, but the + * current checkout was deliberately not analyzed in the same invocation. + */ + recoveredPromotionOnly?: boolean; /** The raw pipeline result — only populated when needed by callers (e.g. skill generation). */ pipelineResult?: any; /** True when analyze only repaired FTS indexes and skipped pipeline re-analysis. */ @@ -1118,16 +1125,69 @@ const runFullAnalysisImpl = async ( } progress('lbug', 1, 'Recovering staged promotion...'); await validatePendingPromotionEmbeddingCandidate(promotionPaths); - await promoteStagedGeneration(promotionPaths, commitStagedMetadataAndRegistry, { - readRepositoryIdentity, - }); + const recoveredPromotion = await promoteStagedGeneration( + promotionPaths, + commitStagedMetadataAndRegistry, + { + readRepositoryIdentity, + }, + ); log('Recovered and completed the previous staged promotion.'); + const recoveredMeta = await loadMeta(canonicalMetaDir); + if (!recoveredMeta) { + throw new Error('Recovered staged promotion is missing canonical metadata.'); + } + const recoveredRepoName = + recoveredPromotion.projectName ?? + options.registryName ?? + getInferredRepoName(repoPath) ?? + path.basename(resolveRepoIdentityRoot(repoPath)); + + // Recovery completes the same canonical promotion as the normal finalize + // path, so finish its source-side bookkeeping before returning the explicit + // recovery-only result. Context generation is best-effort, matching the + // ordinary successful path. + await ensureGitNexusIgnored(repoPath); + if (!placement.branch) { + try { + await generateAIContextFiles( + repoPath, + storagePath, + recoveredRepoName, + { + files: recoveredMeta.stats?.files, + nodes: recoveredMeta.stats?.nodes, + edges: recoveredMeta.stats?.edges, + communities: recoveredMeta.stats?.communities, + processes: recoveredMeta.stats?.processes, + }, + undefined, + { + skipAgentsMd: options.skipAgentsMd, + skipSkills: options.skipSkills, + noStats: options.noStats, + defaultBranch: options.defaultBranch, + hasPdg: recoveredMeta.pdg !== undefined, + }, + ); + } catch { + // Best-effort — the recovered canonical index remains valid. + } + } + progress('done', 100, 'Recovered staged promotion'); + return { + repoName: recoveredRepoName, + repoPath, + stats: recoveredMeta.stats ?? {}, + recoveredPromotionOnly: true, + isPrimaryBranch: !placement.branch, + }; } - // Analyze indexes the working tree, not an arbitrary ref. Recover a pending - // staged promotion first so a crash cannot leave the canonical pathname - // absent merely because the user switched branches before retrying. Once - // recovery is complete, refuse to start a new build for a mismatched label. + // Analyze indexes the working tree, not an arbitrary ref. A journal recovery + // above is intentionally terminal: combining crash recovery with a second + // source build recreates the ambiguous lifecycle that staged mode is meant to + // avoid. The caller must start a fresh explicit analyze for the current tree. if (options.branch && checkedOutBranch && options.branch !== checkedOutBranch) { throw new Error( `--branch "${options.branch}" does not match the checked-out branch "${checkedOutBranch}". ` + @@ -1139,9 +1199,44 @@ const runFullAnalysisImpl = async ( throw new Error('`--staged` cannot be combined with `--repair-fts`; repair is in-place only.'); } + let stagedWorkspaceExisted = false; + let canonicalDatabaseExistedAtStageStart = false; + let canonicalMetaAtStageStart: RepoMeta | null = null; if (stagedPaths) { - const canonicalMeta = await loadMeta(canonicalMetaDir); - const prepared = await prepareStagedWorkspace(stagedPaths, canonicalMeta, repositorySource); + canonicalMetaAtStageStart = await loadMeta(canonicalMetaDir); + const stagedSourceAtStart = await inspectStagedWorkspaceSource( + stagedPaths, + canonicalMetaAtStageStart, + repositorySource, + ); + const canonicalDatabaseStateAtStart = await lstatIfPresent(canonicalPaths.lbugPath); + stagedWorkspaceExisted = stagedSourceAtStart.exists; + canonicalDatabaseExistedAtStageStart = canonicalDatabaseStateAtStart !== null; + + if (stagedWorkspaceExisted && !options.dropEmbeddings) { + throw new Error( + 'An unjournaled staged generation already exists. It was left untouched for forensics ' + + 'and will not be promoted or resumed automatically. Re-run with `--drop-embeddings` ' + + 'only when replacing that derived stage with a clean isolated generation is intentional.', + ); + } + if ( + !stagedWorkspaceExisted && + canonicalDatabaseExistedAtStageStart && + !options.dropEmbeddings + ) { + throw new Error( + 'Starting staged work from an existing canonical database requires explicit ' + + '`--drop-embeddings`. The canonical index is unchanged. Semantic refreshes must use ' + + '`--staged --embeddings --drop-embeddings`; graph-only rebuilds must use ' + + '`--staged --drop-embeddings`.', + ); + } + const prepared = await prepareStagedWorkspace( + stagedPaths, + canonicalMetaAtStageStart, + repositorySource, + ); log( prepared.resumed ? 'Resuming the existing staged generation; the canonical index remains untouched.' @@ -1160,6 +1255,18 @@ const runFullAnalysisImpl = async ( // stores, inspect via LadybugDB, or touch sidecars until every invariant // needed for a surgical incremental write is established. let existingMeta = await loadMeta(metaDir); + if (stagedPaths) { + // electric.4-.7 proved that restoring or resuming embedding rows inside a + // staged copy is not a safe compatibility surface. Keep the useful part of + // staged analyze (isolated full generation + validated promotion). Every + // replacement of existing derived state is explicit and starts clean. + if (options.dropEmbeddings || (options.embeddings && !existingMeta)) { + // The target is the isolated staged DB. Forcing a full write wipes only + // that disposable copy; the canonical generation stays readable until + // the final integrity scan and journaled promotion both succeed. + options = { ...options, force: true }; + } + } if (options.incrementalOnly) { if (!existingMeta) { incrementalOnlyStop('no existing index metadata is available'); @@ -1601,7 +1708,6 @@ const runFullAnalysisImpl = async ( const healUnregistered = options.allowDuplicateName === true && !(await isRepoRegistered(repoPath)); if (!dirty && !healUnregistered) { - let promotedProjectName: string | undefined; // ── #2354: restamp the workspace label on a same-commit branch flip ── // The flat slot follows the checked-out working tree; a branch switch // at the SAME commit with a clean tree changes nothing the pipeline @@ -1644,21 +1750,7 @@ const runFullAnalysisImpl = async ( } } if (stagedPaths) { - const canonicalMeta = await loadMeta(canonicalMetaDir); - const stageWasFinalizedAfterItsCanonicalSource = - !canonicalMeta || - canonicalMeta.lastCommit !== existingMeta.lastCommit || - canonicalMeta.indexedAt !== existingMeta.indexedAt; - if (stageWasFinalizedAfterItsCanonicalSource) { - // The prior process can die after finalizing the staged DB/meta but - // before writing the promotion journal. A resumed run then reaches - // this same-commit fast path. Promote that validated generation - // instead of silently discarding completed work. - progress('lbug', 99, 'Promoting completed staged generation...'); - promotedProjectName = await promoteValidatedStage(stagedPaths); - } else { - await discardStagedWorkspace(stagedPaths); - } + await discardStagedWorkspace(stagedPaths); } else if (!options.incrementalOnly) { await ensureGitNexusIgnored(repoPath); } @@ -1667,7 +1759,6 @@ const runFullAnalysisImpl = async ( // canonical repo basename (#1259) but leaves arbitrary subdirs // and `--skip-git` paths unchanged (#1232/#1233 intent preserved). repoName: - promotedProjectName ?? options.registryName ?? getInferredRepoName(repoPath) ?? path.basename(resolveRepoIdentityRoot(repoPath)), @@ -3171,6 +3262,12 @@ export async function runFullAnalysis( options: AnalyzeOptions, callbacks: AnalyzeCallbacks, ): Promise { + if (options.incrementalOnly && options.dropEmbeddings) { + throw new Error( + 'Cannot combine `--incremental-only` with `--drop-embeddings`. ' + + 'The preservation contract refuses every option that can require a clean rebuild.', + ); + } if (options.repairVector) { const conflicts = [ options.force && '--force', diff --git a/gitnexus/src/core/staged-promotion.ts b/gitnexus/src/core/staged-promotion.ts index 48f0fd67a0..778841c513 100644 --- a/gitnexus/src/core/staged-promotion.ts +++ b/gitnexus/src/core/staged-promotion.ts @@ -377,6 +377,42 @@ export const withAnalyzeOwnershipLock = async ( /** @deprecated Use the common ownership lock so plain and staged writers cannot overlap. */ export const withStagedAnalyzeLock = withAnalyzeOwnershipLock; +/** + * Read-only preflight used by callers that must preserve a prior staged + * artifact instead of letting preparation replace source-mismatched derived + * state. A legacy manifest without complete source identity never matches. + */ +export const inspectStagedWorkspaceSource = async ( + paths: StagedAnalyzePaths, + canonicalMeta: RepoMeta | null, + sourceRepo: RepositorySourceIdentity, +): Promise<{ exists: boolean; matchesSource: boolean }> => { + let exists = false; + try { + await fs.lstat(paths.stageRoot); + exists = true; + } catch (error) { + if (!isMissingFilesystemError(error)) throw error; + } + if (!exists) return { exists: false, matchesSource: false }; + + const manifest = await readManifest(paths); + if (!manifest?.sourceMetaFiles || !manifest.sourceRepo) { + return { exists: true, matchesSource: false }; + } + const canonicalDb = await statRegularFile(paths.canonicalLbugPath); + const sourceMeta = canonicalMeta ? metaIdentity(canonicalMeta) : undefined; + const sourceMetaFiles = await statMetadataFiles(paths.canonicalMetaDir); + return { + exists: true, + matchesSource: + identitiesEqual(manifest.sourceMeta, sourceMeta) && + identitiesEqual(manifest.sourceMetaFiles, sourceMetaFiles) && + identitiesEqual(manifest.sourceDb, canonicalDb) && + identitiesEqual(manifest.sourceRepo, sourceRepo), + }; +}; + /** * Create or resume the isolated build workspace. A stage tree is removed only * when a complete canonical generation or a durable stage intent/manifest diff --git a/gitnexus/src/server/analyze-launch.ts b/gitnexus/src/server/analyze-launch.ts index b505cdfcf7..cf0e2ccfec 100644 --- a/gitnexus/src/server/analyze-launch.ts +++ b/gitnexus/src/server/analyze-launch.ts @@ -25,6 +25,7 @@ import { import { logger } from '../core/logger.js'; import type { JobManager } from './analyze-job.js'; import type { WorkerMessage } from './analyze-worker.js'; +import type { AnalyzeResultIpc } from './analyze-worker-ipc.js'; const _require = createRequire(import.meta.url); @@ -50,6 +51,24 @@ export interface LaunchOptions { const MAX_WORKER_RETRIES = 2; +const RECOVERY_ONLY_ERROR = + 'Recovered a previous staged promotion, but the current checkout was not analyzed. ' + + 'Start a new analysis with force=true and dropEmbeddings=true ' + + '(CLI: `gitnexus analyze --staged --drop-embeddings`).'; + +/** + * Translate the worker's successful terminal result into the server job + * contract. Recovery-only is deliberately a failed analyze request: the prior + * promotion is durable, but reporting ordinary completion would claim that the + * current checkout was indexed when it was not. + */ +export const completionUpdateForWorkerResult = ( + result: AnalyzeResultIpc, +): { status: 'complete' | 'failed'; repoName: string; error?: string } => + result.recoveredPromotionOnly + ? { status: 'failed', repoName: result.repoName, error: RECOVERY_ONLY_ERROR } + : { status: 'complete', repoName: result.repoName }; + /** * The worker reports `complete` over IPC before its on-disk finalization * (LadybugDB checkpoint + native handle release + metadata write) is visible @@ -182,19 +201,28 @@ export function createLaunchAnalysisWorker(deps: LaunchDeps) { }); } else if (msg.type === 'complete') { releaseRepoLock(analyzeLockKey); - // Before marking complete: (1) wait for the worker's on-disk + const terminalUpdate = completionUpdateForWorkerResult(msg.result); + // Before recording the terminal result: (1) wait for the worker's on-disk // finalization to settle (see waitForSettledIndex), (2) evict the // cached DB handle — same invalidation DELETE /api/repo performs, a // handle opened before the rewrite reads pre-rewrite state — and // only then (3) reinitialize the backend. This makes the ordering // comment below true in practice: the repo is actually queryable // when the client receives the SSE complete event. - waitForSettledIndex(targetPath, jobStartMs) + // A recovery-only result may merely clean a committed journal without + // rewriting the canonical DB/meta, so the normal mtime gate cannot + // prove this job's write and would wait the full timeout. The worker + // has already completed the journal transaction; evict/reload now, + // then fail the analyze request with explicit retry guidance. + const settle = msg.result.recoveredPromotionOnly + ? Promise.resolve() + : waitForSettledIndex(targetPath, jobStartMs); + settle .then(() => closeDbHandle()) .catch(() => {}) // best-effort: eviction failure must not fail the job .then(() => backend.init()) .then(() => { - jobManager.updateJob(job.id, { status: 'complete', repoName: msg.result.repoName }); + jobManager.updateJob(job.id, terminalUpdate); }) .catch((err) => { logger.error({ err }, 'backend.init() failed after analyze:'); diff --git a/gitnexus/src/server/analyze-worker-ipc.ts b/gitnexus/src/server/analyze-worker-ipc.ts index 335e8cb839..e7d8a8fa57 100644 --- a/gitnexus/src/server/analyze-worker-ipc.ts +++ b/gitnexus/src/server/analyze-worker-ipc.ts @@ -51,7 +51,13 @@ import type { AnalyzeResult } from '../core/run-analyze.js'; */ export type AnalyzeResultIpc = Pick< AnalyzeResult, - 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' + | 'repoName' + | 'repoPath' + | 'stats' + | 'alreadyUpToDate' + | 'recoveredPromotionOnly' + | 'ftsRepairedOnly' + | 'ftsSkipped' >; /** @@ -66,6 +72,7 @@ export function projectAnalyzeResultForIpc(result: AnalyzeResult): AnalyzeResult repoPath: result.repoPath, stats: result.stats, alreadyUpToDate: result.alreadyUpToDate, + recoveredPromotionOnly: result.recoveredPromotionOnly, ftsRepairedOnly: result.ftsRepairedOnly, ftsSkipped: result.ftsSkipped, }; diff --git a/gitnexus/test/unit/analyze-gitnexusrc.test.ts b/gitnexus/test/unit/analyze-gitnexusrc.test.ts index fa1b3f9fba..cf5af62aa1 100644 --- a/gitnexus/test/unit/analyze-gitnexusrc.test.ts +++ b/gitnexus/test/unit/analyze-gitnexusrc.test.ts @@ -117,6 +117,27 @@ describe('analyzeCommand .gitnexusrc wiring (#243)', () => { expect(opts.skipSkills).toBeFalsy(); }); + it('reports journal recovery as recovery-only instead of already up to date', async () => { + runFullAnalysisMock.mockResolvedValueOnce({ + repoName: 'repo', + repoPath: dir, + stats: {}, + recoveredPromotionOnly: true, + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + await analyzeCommand(dir, {}); + const output = logSpy.mock.calls.flat().join('\n'); + expect(output).toContain('Recovered the previous staged promotion.'); + expect(output).toContain('The current checkout was not analyzed'); + expect(output).toContain('gitnexus analyze --staged --drop-embeddings'); + expect(output).not.toContain('Already up to date'); + } finally { + logSpy.mockRestore(); + } + }); + it('indexOnly from config remains stronger than context/skills options', async () => { await writeRc({ indexOnly: true }); const { analyzeCommand } = await import('../../src/cli/analyze.js'); diff --git a/gitnexus/test/unit/analyze-launch-recovery.test.ts b/gitnexus/test/unit/analyze-launch-recovery.test.ts new file mode 100644 index 0000000000..31a0030e98 --- /dev/null +++ b/gitnexus/test/unit/analyze-launch-recovery.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; + +import { completionUpdateForWorkerResult } from '../../src/server/analyze-launch.js'; +import type { AnalyzeResultIpc } from '../../src/server/analyze-worker-ipc.js'; + +const result = (recoveredPromotionOnly?: boolean): AnalyzeResultIpc => ({ + repoName: 'demo', + repoPath: '/repos/demo', + stats: { nodes: 10, edges: 12 }, + alreadyUpToDate: undefined, + recoveredPromotionOnly, + ftsRepairedOnly: undefined, + ftsSkipped: undefined, +}); + +describe('analyze worker recovery-only parent contract', () => { + it('fails closed with retry guidance instead of reporting ordinary completion', () => { + expect(completionUpdateForWorkerResult(result(true))).toEqual({ + status: 'failed', + repoName: 'demo', + error: + 'Recovered a previous staged promotion, but the current checkout was not analyzed. ' + + 'Start a new analysis with force=true and dropEmbeddings=true ' + + '(CLI: `gitnexus analyze --staged --drop-embeddings`).', + }); + }); + + it('keeps an ordinary successful analysis complete', () => { + expect(completionUpdateForWorkerResult(result())).toEqual({ + status: 'complete', + repoName: 'demo', + }); + }); +}); diff --git a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts index d82047db69..0dc7cff823 100644 --- a/gitnexus/test/unit/analyze-no-stats-bridge.test.ts +++ b/gitnexus/test/unit/analyze-no-stats-bridge.test.ts @@ -200,6 +200,18 @@ describe('analyzeCommand commander → runFullAnalysis noStats bridge (#1477)', expect(opts.incrementalOnly).toBe(true); }); + it('rejects combining --incremental-only with --drop-embeddings', async () => { + const { analyzeCommand } = await import('../../src/cli/analyze.js'); + + await analyzeCommand(undefined, { incrementalOnly: true, dropEmbeddings: true }); + + expect(process.exitCode).toBe(1); + expect(cliErrorMock).toHaveBeenCalledWith( + expect.stringMatching(/cannot combine `--incremental-only`.*`--drop-embeddings`/i), + ); + expect(runFullAnalysisMock).not.toHaveBeenCalled(); + }); + it('passes --staged through to runFullAnalysis', async () => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); diff --git a/gitnexus/test/unit/analyze-worker-ipc.test.ts b/gitnexus/test/unit/analyze-worker-ipc.test.ts index 5d4738bee6..b0034b821e 100644 --- a/gitnexus/test/unit/analyze-worker-ipc.test.ts +++ b/gitnexus/test/unit/analyze-worker-ipc.test.ts @@ -46,6 +46,7 @@ describe('#2112: analyze-worker IPC projection', () => { repoPath: '/repos/demo', stats: { files: 3, nodes: 1, edges: 0 }, alreadyUpToDate: false, + recoveredPromotionOnly: undefined, ftsRepairedOnly: undefined, ftsSkipped: true, }); @@ -61,6 +62,19 @@ describe('#2112: analyze-worker IPC projection', () => { expect(roundTripped.stats.nodes).toBe(1); }); + it('preserves the recovery-only discriminator across the worker IPC boundary', () => { + const projected = projectAnalyzeResultForIpc({ + repoName: 'demo', + repoPath: '/repos/demo', + stats: {}, + recoveredPromotionOnly: true, + }); + expect(JSON.parse(JSON.stringify(projected))).toMatchObject({ + repoName: 'demo', + recoveredPromotionOnly: true, + }); + }); + it('anchors the hazard: serializing the RAW result throws (the bug the projection prevents)', () => { // Without the projection, `send({type:'complete', result})` would throw a // TypeError in the worker, get caught, and mis-report this success as a diff --git a/gitnexus/test/unit/hook-global-lock.test.ts b/gitnexus/test/unit/hook-global-lock.test.ts index c13bb27f10..46d9291410 100644 --- a/gitnexus/test/unit/hook-global-lock.test.ts +++ b/gitnexus/test/unit/hook-global-lock.test.ts @@ -42,9 +42,9 @@ describe('Claude hook machine-global concurrency cap', () => { const waitForGate = () => { if (!fs.existsSync(process.argv[3])) { setTimeout(waitForGate, 10); return; } const release = acquireHookSlot(process.argv[1]); - if (!release) { process.stdout.write('denied\\n'); process.exit(0); } + if (!release) { process.stdout.write('denied\\n'); return; } process.stdout.write('acquired\\n'); - setTimeout(() => { release(); process.exit(0); }, 2000); + setTimeout(() => { release(); }, 2000); }; waitForGate(); `; @@ -66,7 +66,7 @@ describe('Claude hook machine-global concurrency cap', () => { child.stdout.on('data', (chunk) => (stdout += String(chunk))); child.stderr.on('data', (chunk) => (stderr += String(chunk))); child.on('error', reject); - child.on('exit', (code) => { + child.on('close', (code) => { if (code !== 0) reject(new Error(stderr || `child exited ${code}`)); else resolve(stdout.trim()); }); diff --git a/gitnexus/test/unit/run-analyze-staged.test.ts b/gitnexus/test/unit/run-analyze-staged.test.ts index 039edec6e5..99c16fdac5 100644 --- a/gitnexus/test/unit/run-analyze-staged.test.ts +++ b/gitnexus/test/unit/run-analyze-staged.test.ts @@ -27,10 +27,16 @@ const repositoryIdentity = (repo: string): RepositorySourceIdentity => ({ describe('runFullAnalysis --staged', () => { let priorHome: string | undefined; let priorInstallPolicy: string | undefined; + let priorEmbeddingUrl: string | undefined; + let priorEmbeddingModel: string | undefined; + let priorEmbeddingDims: string | undefined; beforeEach(() => { priorHome = process.env.GITNEXUS_HOME; priorInstallPolicy = process.env.GITNEXUS_LBUG_EXTENSION_INSTALL; + priorEmbeddingUrl = process.env.GITNEXUS_EMBEDDING_URL; + priorEmbeddingModel = process.env.GITNEXUS_EMBEDDING_MODEL; + priorEmbeddingDims = process.env.GITNEXUS_EMBEDDING_DIMS; process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never'; }); @@ -39,11 +45,236 @@ describe('runFullAnalysis --staged', () => { else process.env.GITNEXUS_HOME = priorHome; if (priorInstallPolicy === undefined) delete process.env.GITNEXUS_LBUG_EXTENSION_INSTALL; else process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = priorInstallPolicy; + if (priorEmbeddingUrl === undefined) delete process.env.GITNEXUS_EMBEDDING_URL; + else process.env.GITNEXUS_EMBEDDING_URL = priorEmbeddingUrl; + if (priorEmbeddingModel === undefined) delete process.env.GITNEXUS_EMBEDDING_MODEL; + else process.env.GITNEXUS_EMBEDDING_MODEL = priorEmbeddingModel; + if (priorEmbeddingDims === undefined) delete process.env.GITNEXUS_EMBEDDING_DIMS; + else process.env.GITNEXUS_EMBEDDING_DIMS = priorEmbeddingDims; + vi.unstubAllGlobals(); await Promise.all( tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), ); }); + it('rejects incremental-only drop before creating staged storage', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-conflict-')); + tempDirs.push(repo); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + + await expect( + runFullAnalysis( + repo, + { staged: true, incrementalOnly: true, dropEmbeddings: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/cannot combine `--incremental-only` with `--drop-embeddings`/i); + await expect(fs.access(path.join(repo, '.gitnexus'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('refuses a full staged write when metadata hides physical embeddings', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-no-restore-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + const canonical = getStoragePaths(repo); + const meta = await loadMeta(canonical.storagePath); + if (!meta) throw new Error('expected canonical metadata'); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug(canonical.lbugPath); + try { + const [owner] = await adapter.executeQuery( + 'MATCH (n) WHERE n.id IS NOT NULL RETURN n.id AS id LIMIT 1', + ); + const nodeId = String(owner?.id ?? owner?.[0] ?? ''); + if (!nodeId) throw new Error('expected a graph node for the embedding fixture'); + await adapter.executeWithReusedStatement( + 'CREATE (e:CodeEmbedding {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, ' + + 'startLine: 1, endLine: 1, embedding: $embedding, contentHash: $contentHash})', + [ + { + id: `${nodeId}:0`, + nodeId, + chunkIndex: 0, + embedding: new Array(EMBEDDING_DIMS).fill(0.25), + contentHash: 'hidden-by-stale-meta', + }, + ], + ); + } finally { + await adapter.closeLbug(); + } + await saveMeta(canonical.storagePath, { + ...meta, + // Reproduce the observed stale-metadata shape: the database has one + // physical vector while metadata claims zero. + stats: { ...meta.stats, embeddings: 0 }, + }); + const before = await fs.stat(canonical.lbugPath); + + await expect( + runFullAnalysis( + repo, + { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/requires explicit `--drop-embeddings`/); + + const after = await fs.stat(canonical.lbugPath); + expect(after.ino).toBe(before.ino); + expect(after.mtimeMs).toBe(before.mtimeMs); + }); + + it('regenerates staged embeddings from an empty isolated table when drop is explicit', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-clean-embedding-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + process.env.GITNEXUS_EMBEDDING_URL = 'http://test.invalid/v1'; + process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; + process.env.GITNEXUS_EMBEDDING_DIMS = String(EMBEDDING_DIMS); + const vector = new Array(EMBEDDING_DIMS).fill(0.125); + vi.stubGlobal( + 'fetch', + vi.fn(async (_input, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')) as { input?: unknown[] }; + const count = Array.isArray(body.input) ? body.input.length : 1; + return { + ok: true, + json: async () => ({ + data: Array.from({ length: count }, () => ({ embedding: vector })), + }), + }; + }), + ); + await fs.writeFile( + path.join(repo, 'index.ts'), + 'export function cleanStageEmbedding() { return 1; }\n', + ); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + const canonical = getStoragePaths(repo); + const meta = await loadMeta(canonical.storagePath); + if (!meta) throw new Error('expected canonical metadata'); + await saveMeta(canonical.storagePath, { + ...meta, + stats: { ...meta.stats, embeddings: 1 }, + }); + + await runFullAnalysis( + repo, + { + staged: true, + embeddings: true, + dropEmbeddings: true, + skipAgentsMd: true, + skipSkills: true, + }, + { onProgress: () => {} }, + ); + + const finalMeta = await loadMeta(canonical.storagePath); + expect(finalMeta?.stats?.embeddings).toBeGreaterThan(0); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbugReadOnlyNonRecovering(canonical.lbugPath); + try { + await expect(adapter.inspectEmbeddingIntegrity()).resolves.toMatchObject({ + physicalRows: finalMeta?.stats?.embeddings, + emptyIdRows: 0, + emptyNodeIdRows: 0, + invalidChunkRows: 0, + noncanonicalIdRows: 0, + duplicateIdRows: 0, + duplicateSemanticRows: 0, + orphanRows: 0, + wrongDimensionRows: 0, + }); + } finally { + await adapter.closeLbug(); + } + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }, 120_000); + + it('never resumes a staged embedding checkpoint without an explicit clean rebuild', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-checkpoint-refusal-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + const canonical = getStoragePaths(repo); + const canonicalMeta = await loadMeta(canonical.storagePath); + if (!canonicalMeta) throw new Error('expected canonical metadata'); + const canonicalBefore = await fs.stat(canonical.lbugPath); + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + await saveMeta(staged.stagedMetaDir, { + ...canonicalMeta, + embeddingCheckpoint: { + at: new Date().toISOString(), + nodesProcessed: 0, + totalNodes: 1, + chunksProcessed: 0, + model: 'test-model', + dimensions: EMBEDDING_DIMS, + pendingNodeIds: ['Function:pending'], + }, + }); + const stagedBefore = await fs.stat(staged.stagedLbugPath); + const stagedMetaPath = path.join(staged.stagedMetaDir, 'gitnexus.json'); + const stagedMetaBefore = await fs.readFile(stagedMetaPath); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 2;\n'); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'advance-source'], + { cwd: repo }, + ); + + await expect( + runFullAnalysis( + repo, + { staged: true, embeddings: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/unjournaled staged generation already exists/i); + expect((await fs.stat(canonical.lbugPath)).ino).toBe(canonicalBefore.ino); + const stagedAfter = await fs.stat(staged.stagedLbugPath); + expect(stagedAfter.ino).toBe(stagedBefore.ino); + expect(stagedAfter.mtimeMs).toBe(stagedBefore.mtimeMs); + await expect(fs.readFile(stagedMetaPath)).resolves.toEqual(stagedMetaBefore); + }); + it('keeps the canonical DB inode unchanged until validated promotion', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-run-')); tempDirs.push(repo); @@ -72,7 +303,7 @@ describe('runFullAnalysis --staged', () => { let sawPrePromotion = false; const result = await runFullAnalysis( repo, - { staged: true, skipAgentsMd: true, skipSkills: true }, + { staged: true, dropEmbeddings: true, skipAgentsMd: true, skipSkills: true }, { onProgress: (_phase, percent) => { if (percent >= 99) return; @@ -147,7 +378,7 @@ describe('runFullAnalysis --staged', () => { await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('promotes a completed resumed stage when the process died before journaling', async () => { + it('refuses an unjournaled completed stage instead of auto-promoting it', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-prejournal-')); tempDirs.push(repo); const registryHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-registry-')); @@ -166,25 +397,191 @@ describe('runFullAnalysis --staged', () => { const canonical = getStoragePaths(repo); const canonicalMeta = await loadMeta(canonical.storagePath); if (!canonicalMeta) throw new Error('expected canonical metadata'); - const before = await fs.stat(canonical.lbugPath); + const canonicalBefore = await fs.stat(canonical.lbugPath); const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); await saveMeta(staged.stagedMetaDir, { ...canonicalMeta, indexedAt: '2026-07-20T12:00:00.000Z', }); + const stagedBefore = await fs.stat(staged.stagedLbugPath); + + await expect( + runFullAnalysis( + repo, + { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/unjournaled staged generation already exists/i); + + expect((await fs.stat(canonical.lbugPath)).ino).toBe(canonicalBefore.ino); + expect((await fs.stat(staged.stagedLbugPath)).ino).toBe(stagedBefore.ino); + expect((await loadMeta(staged.stagedMetaDir))?.indexedAt).toBe('2026-07-20T12:00:00.000Z'); + await expect(fs.access(staged.stageRoot)).resolves.toBeUndefined(); + await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('refuses a dirty completed stage with embeddings before snapshot preservation', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-dirty-force-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + + const canonical = getStoragePaths(repo); + const canonicalMeta = await loadMeta(canonical.storagePath); + if (!canonicalMeta) throw new Error('expected canonical metadata'); + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + await adapter.initLbug(staged.stagedLbugPath); + try { + const [owner] = await adapter.executeQuery( + 'MATCH (n) WHERE n.id IS NOT NULL RETURN n.id AS id LIMIT 1', + ); + const nodeId = String(owner?.id ?? owner?.[0] ?? ''); + if (!nodeId) throw new Error('expected a graph node for the embedding fixture'); + await adapter.executeWithReusedStatement( + 'CREATE (e:CodeEmbedding {id: $id, nodeId: $nodeId, chunkIndex: $chunkIndex, ' + + 'startLine: 1, endLine: 1, embedding: $embedding, contentHash: $contentHash})', + [ + { + id: `${nodeId}:0`, + nodeId, + chunkIndex: 0, + embedding: new Array(EMBEDDING_DIMS).fill(0.25), + contentHash: 'dirty-completed-stage', + }, + ], + ); + } finally { + await adapter.closeLbug(); + } + const completedAt = '2026-07-20T12:00:00.000Z'; + await saveMeta(staged.stagedMetaDir, { + ...canonicalMeta, + indexedAt: completedAt, + stats: { ...canonicalMeta.stats, embeddings: 1 }, + }); + const before = await fs.stat(staged.stagedLbugPath); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 2;\n'); + + await expect( + runFullAnalysis( + repo, + { staged: true, force: true, skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ), + ).rejects.toThrow(/unjournaled staged generation already exists/i); + + const after = await fs.stat(staged.stagedLbugPath); + expect(after.ino).toBe(before.ino); + expect(after.mtimeMs).toBe(before.mtimeMs); + expect(await loadMeta(staged.stagedMetaDir)).toMatchObject({ + indexedAt: completedAt, + stats: { embeddings: 1 }, + }); + await expect( + fs.access(path.join(staged.stagedMetaDir, 'embedding-preservation.jsonl')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('replaces rather than promotes a completed stage when drop is explicit', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-completed-drop-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + + const canonical = getStoragePaths(repo); + const canonicalMeta = await loadMeta(canonical.storagePath); + if (!canonicalMeta) throw new Error('expected canonical metadata'); + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + const completedAt = '2026-07-20T12:00:00.000Z'; + await saveMeta(staged.stagedMetaDir, { ...canonicalMeta, indexedAt: completedAt }); const result = await runFullAnalysis( repo, - { staged: true, skipAgentsMd: true, skipSkills: true }, + { + staged: true, + force: true, + dropEmbeddings: true, + skipAgentsMd: true, + skipSkills: true, + }, { onProgress: () => {} }, ); - expect(result.alreadyUpToDate).toBe(true); - expect((await fs.stat(canonical.lbugPath)).ino).not.toBe(before.ino); - expect((await loadMeta(canonical.storagePath))?.indexedAt).toBe('2026-07-20T12:00:00.000Z'); + expect(result.alreadyUpToDate).not.toBe(true); + expect((await loadMeta(canonical.storagePath))?.indexedAt).not.toBe(completedAt); + await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('returns success after recovering a pending staged promotion without drop', async () => { + const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-recovery-success-')); + tempDirs.push(repo); + process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); + execFileSync('git', ['init'], { cwd: repo }); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'initial'], + { cwd: repo }, + ); + await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + + const canonical = getStoragePaths(repo); + const canonicalMeta = await loadMeta(canonical.storagePath); + if (!canonicalMeta) throw new Error('expected canonical metadata'); + const staged = getStagedAnalyzePaths(canonical.lbugPath, canonical.storagePath); + await prepareStagedWorkspace(staged, canonicalMeta, repositoryIdentity(repo)); + const recoveredAt = '2026-07-20T12:00:00.000Z'; + await saveMeta(staged.stagedMetaDir, { ...canonicalMeta, indexedAt: recoveredAt }); + await expect( + promoteStagedGeneration(staged, async () => 'repo', { + afterBoundary: (boundary) => { + if (boundary === 'old-backed-up') throw new Error('simulated crash'); + }, + }), + ).rejects.toThrow('simulated crash'); + await fs.rm(path.join(canonical.storagePath, '.gitignore'), { force: true }); + await fs.rm(path.join(repo, 'AGENTS.md'), { force: true }); + await fs.rm(path.join(repo, 'CLAUDE.md'), { force: true }); + + const result = await runFullAnalysis( + repo, + { staged: true, skipSkills: true }, + { onProgress: () => {} }, + ); + + expect(result.recoveredPromotionOnly).toBe(true); + expect(result.alreadyUpToDate).not.toBe(true); + expect((await loadMeta(canonical.storagePath))?.indexedAt).toBe(recoveredAt); + await expect(fs.readFile(path.join(canonical.storagePath, '.gitignore'), 'utf8')).resolves.toBe( + '*\n', + ); + await expect(fs.readFile(path.join(repo, '.git', 'info', 'exclude'), 'utf8')).resolves.toMatch( + /\.gitnexus\//, + ); + await expect(fs.access(path.join(repo, 'AGENTS.md'))).resolves.toBeUndefined(); + await expect(fs.access(path.join(repo, 'CLAUDE.md'))).resolves.toBeUndefined(); await expect(fs.access(staged.stageRoot)).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(fs.access(staged.backupLbugPath)).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); @@ -226,16 +623,34 @@ describe('runFullAnalysis --staged', () => { await fs.rm(staged.stageRoot, { recursive: true, force: true }); await expect(fs.access(staged.journalPath)).resolves.toBeUndefined(); - await runFullAnalysis(repo, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }); + await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 2;\n'); + execFileSync('git', ['add', 'index.ts'], { cwd: repo }); + execFileSync( + 'git', + ['-c', 'user.name=test', '-c', 'user.email=test@test', 'commit', '-m', 'advanced'], + { cwd: repo }, + ); + const result = await runFullAnalysis( + repo, + { skipAgentsMd: true, skipSkills: true }, + { onProgress: () => {} }, + ); + + expect(result.recoveredPromotionOnly).toBe(true); + expect(result.alreadyUpToDate).not.toBe(true); + expect((await loadMeta(canonical.storagePath))?.lastCommit).not.toBe( + execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim(), + ); await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); await expect(fs.access(staged.backupLbugPath)).rejects.toMatchObject({ code: 'ENOENT' }); }); - it('refuses malformed staged embeddings before the first promotion journal', async () => { + it('refuses malformed staged embeddings while recovering a prepared promotion', async () => { const repo = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-integrity-')); - tempDirs.push(repo); - process.env.GITNEXUS_HOME = path.join(repo, '.registry-home'); + const registryHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gitnexus-staged-registry-')); + tempDirs.push(repo, registryHome); + process.env.GITNEXUS_HOME = registryHome; await fs.writeFile(path.join(repo, 'index.ts'), 'export const value = 1;\n'); execFileSync('git', ['init'], { cwd: repo }); execFileSync('git', ['add', 'index.ts'], { cwd: repo }); @@ -279,15 +694,24 @@ describe('runFullAnalysis --staged', () => { await adapter.closeLbug(); } + await expect( + promoteStagedGeneration(staged, async () => 'repo', { + afterBoundary: (boundary) => { + if (boundary === 'prepared') throw new Error('simulated crash'); + }, + }), + ).rejects.toThrow('simulated crash'); + await expect(fs.access(staged.journalPath)).resolves.toBeUndefined(); + await expect( runFullAnalysis( repo, { staged: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ), - ).rejects.toThrow(/staged DB validation failed embedding integrity/i); + ).rejects.toThrow(/pending staged promotion candidate.*embedding integrity/i); expect((await fs.stat(canonical.lbugPath)).ino).toBe(canonicalBefore.ino); - await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fs.access(staged.journalPath)).resolves.toBeUndefined(); }); it('recovers an old-backed-up journal during plain analyze before its fast path', async () => { @@ -339,7 +763,8 @@ describe('runFullAnalysis --staged', () => { { onProgress: () => {} }, ); - expect(result.alreadyUpToDate).toBe(true); + expect(result.recoveredPromotionOnly).toBe(true); + expect(result.alreadyUpToDate).not.toBe(true); await expect(fs.access(canonical.lbugPath)).resolves.toBeUndefined(); await expect(fs.access(staged.journalPath)).rejects.toMatchObject({ code: 'ENOENT' }); });