import { execFileSync, execSync } from 'child_process'; import fs from 'fs/promises'; import path from 'path'; import { pathToFileURL } from 'url'; import { describe, it, expect, vi } from 'vitest'; import { resolveAnalyzerRunnerIdentity } from '../../src/core/analyzer-identity.js'; import { deriveEmbeddingMode, deriveEmbeddingCap, DEFAULT_EMBEDDING_NODE_LIMIT, } from '../../src/core/embedding-mode.js'; import { getStoragePaths, loadMeta, registerRepo, saveMeta, readRegistry, RegistryNameCollisionError, type RepoMeta, } from '../../src/storage/repo-manager.js'; import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js'; import { taintModelVersion } from '../../src/core/ingestion/taint/typescript-model.js'; import { createTempDir } from '../helpers/test-db.js'; import { isDetectRejectWarning } from '../helpers/detect-reject-warning.js'; import { readEmbeddingNodeIds } from '../helpers/embedding-seed.js'; import { getIndexIncompleteReasons } from '../../src/core/index-freshness.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; const CURRENT_ANALYSIS_FEATURES = { [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, }; const currentRunnerIdentity = () => resolveAnalyzerRunnerIdentity( pathToFileURL(path.resolve(__dirname, '../../src/core/run-analyze.ts')).href, ); describe('run-analyze module', () => { it('exports runFullAnalysis as a function', async () => { const mod = await import('../../src/core/run-analyze.js'); expect(typeof mod.runFullAnalysis).toBe('function'); }); it('does not import cli/analyze-config', async () => { const source = await fs.readFile( path.resolve(__dirname, '../../src/core/run-analyze.ts'), 'utf8', ); expect(source).not.toMatch(/cli\/analyze-config/); }); it('exports PHASE_LABELS', async () => { const mod = await import('../../src/core/run-analyze.js'); expect(mod.PHASE_LABELS).toBeDefined(); expect(mod.PHASE_LABELS.parsing).toBe('Parsing code'); }); it('creates .gitnexus/.gitignore on the already-up-to-date fast path (#1233)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-path-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); const meta: RepoMeta = { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), // Stamp current schema version so the run-analyze schema-mismatch // guard (#2289 P1) does not force a rebuild and short-circuit the // alreadyUpToDate fast path this test exercises. schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }; await saveMeta(storagePath, meta); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const result = await runFullAnalysis( tmpRepo.dbPath, {}, { onProgress: () => {}, }, ); expect(result.alreadyUpToDate).toBe(true); // A flat/primary index reports isPrimaryBranch true (#2106 R2). expect(result.isPrimaryBranch).toBe(true); await expect( fs.readFile(path.join(tmpRepo.dbPath, '.gitnexus', '.gitignore'), 'utf-8'), ).resolves.toBe('*\n'); } finally { await tmpRepo.cleanup(); } }); it('restamps FTS skipReason on the already-up-to-date path when only the discriminator changes', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-fts-restamp-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const indexedAt = '2026-01-01T00:00:00.000Z'; const { storagePath } = getStoragePaths(tmpRepo.dbPath); const meta: RepoMeta = { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt, schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), capabilities: { graph: { provider: 'ladybugdb', status: 'available' }, fts: { provider: 'ladybugdb-fts', status: 'unavailable', skipReason: 'disabled-by-env', }, vectorSearch: { provider: 'exact-scan', status: 'unavailable', exactScanLimit: 0 }, }, }; await saveMeta(storagePath, meta); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const result = await runFullAnalysis( tmpRepo.dbPath, { skipFts: true }, { onProgress: () => {} }, ); expect(result.alreadyUpToDate).toBe(true); expect(result.ftsSkipped).toBe(true); expect(result.ftsSkipReason).toBe('disabled-by-flag'); const restamped = await loadMeta(storagePath); expect(restamped?.indexedAt).toBe(indexedAt); expect(restamped?.lastCommit).toBe(currentCommit); expect(restamped?.incrementalInProgress).toBeUndefined(); expect(restamped?.capabilities?.fts).toEqual({ provider: 'ladybugdb-fts', status: 'unavailable', skipReason: 'disabled-by-flag', }); expect(restamped?.capabilities?.graph).toEqual(meta.capabilities?.graph); expect(restamped?.capabilities?.vectorSearch).toEqual(meta.capabilities?.vectorSearch); } finally { await tmpRepo.cleanup(); } }); it('applies analyze --name on the already-up-to-date path without --force', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-name-'); const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-home-'); const savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); const meta: RepoMeta = { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }; await saveMeta(storagePath, meta); await registerRepo(tmpRepo.dbPath, meta, { name: 'old' }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const logs: string[] = []; const result = await runFullAnalysis( tmpRepo.dbPath, { registryName: 'new' }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(result.alreadyUpToDate).toBe(true); expect(result.repoName).toBe('new'); const entries = await readRegistry(); expect(entries).toHaveLength(1); expect(entries[0].name).toBe('new'); expect(logs).toContain('Registry name changed: "old" -> "new".'); const agents = await fs.readFile(path.join(tmpRepo.dbPath, 'AGENTS.md'), 'utf-8'); expect(agents).toContain('**new**'); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } }); it('repeating the same --name on the fast path is a no-op, not an error', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-name-repeat-'); const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-repeat-home-'); const savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); const meta: RepoMeta = { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }; await saveMeta(storagePath, meta); await registerRepo(tmpRepo.dbPath, meta, { name: 'kept' }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const result = await runFullAnalysis( tmpRepo.dbPath, { registryName: 'kept' }, { onProgress: () => {} }, ); expect(result.alreadyUpToDate).toBe(true); expect((await readRegistry())[0].name).toBe('kept'); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } }); it('fast-path --name still collides when another path already owns the alias', async () => { const tmpA = await createTempDir('gitnexus-run-analyze-fast-name-col-a-'); const tmpB = await createTempDir('gitnexus-run-analyze-fast-name-col-b-'); const tmpHome = await createTempDir('gitnexus-run-analyze-fast-name-col-home-'); const savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; try { for (const tmp of [tmpA, tmpB]) { execSync('git init', { cwd: tmp.dbPath, stdio: 'pipe' }); execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', { cwd: tmp.dbPath, stdio: 'pipe', }); } const commitB = execSync('git rev-parse HEAD', { cwd: tmpB.dbPath, encoding: 'utf-8', }).trim(); const metaA: RepoMeta = { repoPath: tmpA.dbPath, lastCommit: 'aaaa', indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }; await registerRepo(tmpA.dbPath, metaA, { name: 'new' }); const { storagePath } = getStoragePaths(tmpB.dbPath); const metaB: RepoMeta = { repoPath: tmpB.dbPath, lastCommit: commitB, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }; await saveMeta(storagePath, metaB); await registerRepo(tmpB.dbPath, metaB, { name: 'old' }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await expect( runFullAnalysis(tmpB.dbPath, { registryName: 'new' }, { onProgress: () => {} }), ).rejects.toBeInstanceOf(RegistryNameCollisionError); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpA.cleanup(); await tmpB.cleanup(); } }); it('plain fast path does not call registerRepo when --name is absent', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-fast-no-name-'); const tmpHome = await createTempDir('gitnexus-run-analyze-fast-no-name-home-'); const savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); const meta: RepoMeta = { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }; await saveMeta(storagePath, meta); await registerRepo(tmpRepo.dbPath, meta, { name: 'original' }); const registerSpy = vi.spyOn( await import('../../src/storage/repo-manager.js'), 'registerRepo', ); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(result.alreadyUpToDate).toBe(true); expect(registerSpy).not.toHaveBeenCalled(); expect((await readRegistry())[0].name).toBe('original'); registerSpy.mockRestore(); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } }); it('resumes a matching embedding checkpoint instead of taking the clean fast path', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-embedding-checkpoint-'); const tmpHome = await createTempDir('gitnexus-run-analyze-embedding-checkpoint-home-'); const saved = { home: process.env.GITNEXUS_HOME, url: process.env.GITNEXUS_EMBEDDING_URL, model: process.env.GITNEXUS_EMBEDDING_MODEL, dims: process.env.GITNEXUS_EMBEDDING_DIMS, extension: process.env.GITNEXUS_LBUG_EXTENSION_INSTALL, }; try { process.env.GITNEXUS_HOME = tmpHome.dbPath; process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; process.env.GITNEXUS_EMBEDDING_DIMS = '384'; process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never'; const vector = Array.from({ length: 384 }, (_, i) => i / 384); const fetchMock = vi.fn().mockImplementation(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 })), }), }; }); vi.stubGlobal('fetch', fetchMock); await fs.writeFile( path.join(tmpRepo.dbPath, 'index.ts'), 'export function checkpointResume() { return "ready"; }\n', ); execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git add index.ts', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis( tmpRepo.dbPath, { embeddings: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ); const { storagePath } = getStoragePaths(tmpRepo.dbPath); const completed = await loadMeta(storagePath); expect(completed).not.toBeNull(); if (!completed) throw new Error('expected completed metadata'); const { resolveEmbeddingIdentity } = await import('../../src/core/embeddings/embedding-identity.js'); const embeddingIdentity = resolveEmbeddingIdentity(); await saveMeta(storagePath, { ...completed, embeddingCheckpoint: { at: new Date().toISOString(), nodesProcessed: 1, totalNodes: 1, chunksProcessed: 1, model: 'test-model', dimensions: 384, provider: embeddingIdentity.provider, }, } as RepoMeta); fetchMock.mockClear(); const logs: string[] = []; const resumed = await runFullAnalysis( tmpRepo.dbPath, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(resumed.alreadyUpToDate).not.toBe(true); expect(fetchMock).not.toHaveBeenCalled(); expect(logs.some((message) => message.includes('embedding checkpoint'))).toBe(true); expect((await loadMeta(storagePath))?.embeddingCheckpoint).toBeUndefined(); const finalized = await loadMeta(storagePath); if (!finalized) throw new Error('expected finalized metadata'); const [pendingNodeId] = await readEmbeddingNodeIds(tmpRepo.dbPath); if (!pendingNodeId) throw new Error('expected a persisted embedding node'); await saveMeta(storagePath, { ...finalized, embeddingCheckpoint: { at: new Date().toISOString(), nodesProcessed: 0, totalNodes: 1, chunksProcessed: 0, model: 'test-model', dimensions: 384, provider: embeddingIdentity.provider, pendingNodeIds: [pendingNodeId], }, }); fetchMock.mockClear(); await runFullAnalysis( tmpRepo.dbPath, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ); expect(fetchMock).toHaveBeenCalled(); expect((await loadMeta(storagePath))?.embeddingCheckpoint).toBeUndefined(); const resumedPending = await loadMeta(storagePath); if (!resumedPending) throw new Error('expected pending-window resume metadata'); fetchMock.mockClear(); // Both mismatch stages name a pending node. That is what an 'interrupted' // marker means — nodes that may hold a SUBSET of their chunks — and it is // what the fail-closed gate exists to protect: a marker with an empty // pending set has nothing to regenerate, so `decideEmbeddingResume` // (embedding-checkpoint.ts) clears it without consulting the identity. await saveMeta(storagePath, { ...resumedPending, embeddingCheckpoint: { at: new Date().toISOString(), nodesProcessed: 1, totalNodes: 2, chunksProcessed: 1, model: 'test-model', dimensions: 384, provider: 'http:different-provider-fingerprint', pendingNodeIds: [pendingNodeId], }, }); await expect( runFullAnalysis( tmpRepo.dbPath, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ), ).rejects.toThrow(/provider configuration differs/i); expect(fetchMock).not.toHaveBeenCalled(); await saveMeta(storagePath, { ...resumedPending, embeddingCheckpoint: { at: new Date().toISOString(), nodesProcessed: 1, totalNodes: 2, chunksProcessed: 1, model: 'different-model', dimensions: 384, provider: embeddingIdentity.provider, pendingNodeIds: [pendingNodeId], }, }); await expect( runFullAnalysis( tmpRepo.dbPath, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ), ).rejects.toThrow('Cannot resume embedding checkpoint'); expect(fetchMock).not.toHaveBeenCalled(); } finally { vi.unstubAllGlobals(); const restore = (key: string, value: string | undefined) => { if (value === undefined) delete process.env[key]; else process.env[key] = value; }; restore('GITNEXUS_HOME', saved.home); restore('GITNEXUS_EMBEDDING_URL', saved.url); restore('GITNEXUS_EMBEDDING_MODEL', saved.model); restore('GITNEXUS_EMBEDDING_DIMS', saved.dims); restore('GITNEXUS_LBUG_EXTENSION_INSTALL', saved.extension); await tmpRepo.cleanup(); await tmpHome.cleanup(); } }, 120_000); /** * #2790 regression: an embedding checkpoint must write ONLY the checkpoint. * * `onCheckpointWindowStart` fires at batchIndex 0 — before a single embedding * row exists — and used to persist a full SUCCESS-shaped meta: the new * `lastCommit`, the new `fileHashes`, and `incrementalInProgress: undefined`. * A Phase 4 crash then left a meta vouching for a graph that (on a full * rebuild) had just been thrown away with the staging DB, and the next run * hash-diffed to changed=0/added=0/deleted=0, took the incremental path and * logged "skipping wipe + N unchanged file rows preserved" over the OLD * graph — verbatim the symptom in the issue report — with the crash-recovery * dirty flag it had cleared no longer able to force the healing rebuild. * * Driven through the REAL pipeline rather than by calling the closure * directly: the mid-run meta is captured from inside the fetch mock, which * the embedder only reaches AFTER the window-start save has completed, so * the observation point is ordered by the code under test, not by timing. */ it('an embedding checkpoint does not advance lastCommit/fileHashes or clear the dirty flag (#2790)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-2790-checkpoint-'); const tmpHome = await createTempDir('gitnexus-run-analyze-2790-home-'); const saved = { home: process.env.GITNEXUS_HOME, url: process.env.GITNEXUS_EMBEDDING_URL, model: process.env.GITNEXUS_EMBEDDING_MODEL, dims: process.env.GITNEXUS_EMBEDDING_DIMS, extension: process.env.GITNEXUS_LBUG_EXTENSION_INSTALL, }; try { process.env.GITNEXUS_HOME = tmpHome.dbPath; process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; process.env.GITNEXUS_EMBEDDING_DIMS = '384'; process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never'; const vector = Array.from({ length: 384 }, (_, i) => i / 384); const { storagePath } = getStoragePaths(tmpRepo.dbPath); // Every embed request snapshots the on-disk meta. The checkpoint save is // awaited before the batch that issues these requests, so snapshot[0] is // the state a crash inside the first embedding window would leave behind. const midRunMetas: RepoMeta[] = []; let captureMidRunMeta = false; const fetchMock = vi.fn().mockImplementation(async (_input, init?: RequestInit) => { if (captureMidRunMeta) { const snapshot = await loadMeta(storagePath); if (snapshot) midRunMetas.push(snapshot); } 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 })), }), }; }); vi.stubGlobal('fetch', fetchMock); const git = (cmd: string) => execSync(cmd, { cwd: tmpRepo.dbPath, stdio: 'pipe' }); const headCommit = () => execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8' }).trim(); await fs.writeFile( path.join(tmpRepo.dbPath, 'index.ts'), 'export function first() { return "one"; }\n', ); git('git init'); git('git add index.ts'); git('git -c user.name=test -c user.email=test@test commit -m init'); const commitA = headCommit(); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis( tmpRepo.dbPath, { embeddings: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {} }, ); const baselineMeta = await loadMeta(storagePath); if (!baselineMeta) throw new Error('expected baseline metadata'); expect(baselineMeta.lastCommit).toBe(commitA); // A second commit gives the next run real incremental work AND a new // embeddable node, so the checkpoint window actually opens. await fs.writeFile( path.join(tmpRepo.dbPath, 'second.ts'), 'export function second() { return "two"; }\n', ); git('git add second.ts'); git('git -c user.name=test -c user.email=test@test commit -m second'); const commitB = headCommit(); expect(commitB).not.toBe(commitA); captureMidRunMeta = true; const incrementalLogs: string[] = []; await runFullAnalysis( tmpRepo.dbPath, { embeddings: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {}, onLog: (message) => incrementalLogs.push(message) }, ); captureMidRunMeta = false; // Pins the scenario: this run really took the incremental path, so the // pre-write dirty flag below is the incremental one. expect(incrementalLogs).toContainEqual(expect.stringContaining('Incremental: changed=')); const [midRunMeta] = midRunMetas; if (!midRunMeta) throw new Error('expected a mid-run metadata snapshot'); // The checkpoint is persisted… expect(midRunMeta).toMatchObject({ embeddingCheckpoint: { model: 'test-model', dimensions: 384 }, }); // …but NOTHING that certifies freshness moved: the graph is not published // yet, so the next run must still see this repo as changed and dirty. expect(midRunMeta).toMatchObject({ lastCommit: commitA, fileHashes: baselineMeta.fileHashes, incrementalInProgress: { startedAt: expect.any(Number) }, }); expect(midRunMeta.lastCommit).not.toBe(commitB); // The stale-count restatement is gone too: the window-start save leaves // whatever count is already on disk alone. expect(midRunMeta.stats?.embeddings).toBe(baselineMeta.stats?.embeddings); // ── The consequence ──────────────────────────────────────────────── // Restore exactly what a Phase 4 crash would have left on disk and run // again. The next run must NOT mistake the repo for unchanged. await saveMeta(storagePath, midRunMeta); const recoveryLogs: string[] = []; const recovered = await runFullAnalysis( tmpRepo.dbPath, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {}, onLog: (message) => recoveryLogs.push(message) }, ); expect(recovered.alreadyUpToDate).not.toBe(true); // #2790 was: hashes advanced mid-run, changed=0, "skipping wipe" preserved // the OLD graph. An FTS-phase stamp after the graph write can now recover // via incremental (graph already mutated) instead of a forced wipe — that // is not the #2790 bug as long as lastCommit is still stale and the // incremental write set is non-empty. A forced rebuild also heals. const skipWipe = recoveryLogs.find((message) => message.includes('skipping wipe')); if (skipWipe) { // #2790 was changed=0/added=0/deleted=0 over the old graph. A write // set with added files is a real incremental, not that bug. expect(skipWipe).not.toMatch(/changed=0, added=0, deleted=0/); } else { expect(recoveryLogs).toContainEqual( expect.stringContaining('forcing full rebuild to restore a known-good index'), ); } const healed = await loadMeta(storagePath); expect(healed).toMatchObject({ lastCommit: commitB }); expect(healed?.embeddingCheckpoint).toBeUndefined(); expect(healed?.incrementalInProgress).toBeUndefined(); } finally { vi.unstubAllGlobals(); const restore = (key: string, value: string | undefined) => { if (value === undefined) delete process.env[key]; else process.env[key] = value; }; restore('GITNEXUS_HOME', saved.home); restore('GITNEXUS_EMBEDDING_URL', saved.url); restore('GITNEXUS_EMBEDDING_MODEL', saved.model); restore('GITNEXUS_EMBEDDING_DIMS', saved.dims); restore('GITNEXUS_LBUG_EXTENSION_INSTALL', saved.extension); await tmpRepo.cleanup(); await tmpHome.cleanup(); } }, 120_000); /** * #2790 regression: a partial embedding run must actually self-heal. * * The pipeline now tolerates a failed sub-batch by DELETING the affected * nodes' rows and naming them in `failedNodeIds`. "Zero rows heals itself" is * false on its own: a plain `gitnexus analyze` over an already-embedded index * derives shouldGenerateEmbeddings = false and never calls the pipeline, so * the dropped nodes stayed missing until someone passed * --embeddings/--force/--drop-embeddings. Retaining the checkpoint restores * the pre-#2790 heal — the resume path forces generation regardless of flags. * * Driven through the REAL pipeline against a stubbed endpoint so the pending * set is produced by the failure path itself, not hand-written. */ it('a partially-failed embedding run is healed by the next PLAIN analyze (#2790)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-2790-heal-'); const tmpHome = await createTempDir('gitnexus-run-analyze-2790-heal-home-'); const saved = { home: process.env.GITNEXUS_HOME, url: process.env.GITNEXUS_EMBEDDING_URL, model: process.env.GITNEXUS_EMBEDDING_MODEL, dims: process.env.GITNEXUS_EMBEDDING_DIMS, extension: process.env.GITNEXUS_LBUG_EXTENSION_INSTALL, batch: process.env.GITNEXUS_EMBEDDING_BATCH_SIZE, subBatch: process.env.GITNEXUS_EMBEDDING_SUB_BATCH_SIZE, attempts: process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS, }; try { process.env.GITNEXUS_HOME = tmpHome.dbPath; process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; process.env.GITNEXUS_EMBEDDING_DIMS = '384'; process.env.GITNEXUS_LBUG_EXTENSION_INSTALL = 'never'; // One node per batch, one chunk per request: the failure lands on exactly // one sub-batch. maxAttempts 1 removes the retry loop, so a single stubbed // 503 is terminal without sleeping — and stays one failure, three short of // the shared circuit breaker's threshold, so no later batch is collaterally // failed. process.env.GITNEXUS_EMBEDDING_BATCH_SIZE = '1'; process.env.GITNEXUS_EMBEDDING_SUB_BATCH_SIZE = '1'; process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '1'; const vector = Array.from({ length: 384 }, (_, i) => i / 384); const { storagePath } = getStoragePaths(tmpRepo.dbPath); let failNextEmbedRequest = false; const fetchMock = vi.fn().mockImplementation(async (_input, init?: RequestInit) => { if (failNextEmbedRequest) { failNextEmbedRequest = false; return { ok: false, status: 503, text: async () => 'endpoint unavailable' }; } 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 })), }), }; }); vi.stubGlobal('fetch', fetchMock); const git = (cmd: string) => execSync(cmd, { cwd: tmpRepo.dbPath, stdio: 'pipe' }); // Several nodes so the run survives losing one — a run that persists // nothing is the Phase 5 gate's job, not this test's. for (const n of [1, 2, 3, 4, 5]) { await fs.writeFile( path.join(tmpRepo.dbPath, `mod${n}.ts`), `export function handler${n}(input: string): string {\n return \`${n}:\${input}\`;\n}\n`, ); } git('git init'); git('git add .'); git('git -c user.name=test -c user.email=test@test commit -m init'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); // ── Run 1: one sub-batch loses the endpoint ─────────────────────── failNextEmbedRequest = true; const partialLogs: string[] = []; await runFullAnalysis( tmpRepo.dbPath, { embeddings: true, skipAgentsMd: true, skipSkills: true }, { onProgress: () => {}, onLog: (message) => partialLogs.push(message) }, ); expect(failNextEmbedRequest).toBe(false); expect(partialLogs).toContainEqual( expect.stringContaining('lost their embeddings to embedding-endpoint failures'), ); const partialMeta = await loadMeta(storagePath); // The checkpoint survived finalize, carrying the dropped nodes… expect(partialMeta).toMatchObject({ embeddingCheckpoint: { model: 'test-model', dimensions: 384, pendingNodeIds: [expect.any(String)], }, }); // The marker a COMPLETED run writes is 'partial' (#2790 review, finding // 5a): its nodes provably hold zero rows, so a later run under a // different embedding identity warns and drops them instead of throwing // at the resume gate before any phase runs. expect(partialMeta).toMatchObject({ embeddingCheckpoint: { kind: 'partial' } }); const pendingNodeIds = partialMeta?.embeddingCheckpoint?.pendingNodeIds ?? []; // …and those nodes really hold no rows. const embeddedAfterPartial = await readEmbeddingNodeIds(tmpRepo.dbPath); expect(embeddedAfterPartial).toEqual(expect.not.arrayContaining(pendingNodeIds as string[])); // ── The containment claim, asserted EXACTLY ──────────────────────── // `toBeGreaterThan(0)` passed with 1 survivor out of 5 — it could not // tell a contained sub-batch failure from a run that lost most of the // index. The whole safety argument for shipping a partial index is the // COLLATERAL-DAMAGE direction: every node that did NOT fail kept its // rows. The fixture is fully determined — five exported functions, one // embeddable Function node each, one node per batch and one chunk per // request — so the surviving set is exactly the five handlers minus the // one whose sub-batch lost the endpoint. const ALL_HANDLERS = ['handler1', 'handler2', 'handler3', 'handler4', 'handler5']; const handlerOf = (nodeId: string): string => /handler\d/.exec(nodeId)?.[0] ?? nodeId; const survivingHandlers = [...new Set(embeddedAfterPartial.map(handlerOf))].sort(); const droppedHandlers = [...new Set(pendingNodeIds.map(handlerOf))].sort(); expect(droppedHandlers).toHaveLength(1); expect([...survivingHandlers, ...droppedHandlers].sort()).toEqual(ALL_HANDLERS); expect(survivingHandlers).toHaveLength(ALL_HANDLERS.length - 1); // ── Run 2: a PLAIN analyze — no --embeddings, no --force ────────── // Pre-fix this early-returned "already up to date" (or derived // shouldGenerateEmbeddings = false) and the dropped nodes never came back. const healLogs: string[] = []; await runFullAnalysis( tmpRepo.dbPath, { skipAgentsMd: true, skipSkills: true }, { onProgress: () => {}, onLog: (message) => healLogs.push(message) }, ); // The resume path is what forced generation on a flagless run. expect(healLogs).toContainEqual( expect.stringContaining('Previous analyze ended at an embedding checkpoint'), ); expect(healLogs).toContainEqual( expect.stringContaining(`regenerating ${pendingNodeIds.length} pending node(s)`), ); // The nodes are back, and the index no longer reports itself incomplete. const healedEmbedded = await readEmbeddingNodeIds(tmpRepo.dbPath); expect(healedEmbedded).toEqual(expect.arrayContaining(pendingNodeIds as string[])); const healedMeta = await loadMeta(storagePath); expect(healedMeta?.embeddingCheckpoint).toBeUndefined(); expect(getIndexIncompleteReasons(healedMeta)).toEqual([]); } finally { vi.unstubAllGlobals(); const restore = (key: string, value: string | undefined) => { if (value === undefined) delete process.env[key]; else process.env[key] = value; }; restore('GITNEXUS_HOME', saved.home); restore('GITNEXUS_EMBEDDING_URL', saved.url); restore('GITNEXUS_EMBEDDING_MODEL', saved.model); restore('GITNEXUS_EMBEDDING_DIMS', saved.dims); restore('GITNEXUS_LBUG_EXTENSION_INSTALL', saved.extension); restore('GITNEXUS_EMBEDDING_BATCH_SIZE', saved.batch); restore('GITNEXUS_EMBEDDING_SUB_BATCH_SIZE', saved.subBatch); restore('GITNEXUS_EMBEDDING_MAX_ATTEMPTS', saved.attempts); await tmpRepo.cleanup(); await tmpHome.cleanup(); } }, 180_000); it('plain analyze on another branch adopts the flat workspace slot (#2354)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-workspace-'); const tmpHome = await createTempDir('gitnexus-run-analyze-workspace-home-'); const savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git checkout -b feature/x', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); const commit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const runnerIdentity = currentRunnerIdentity(); // Flat slot last analyzed on main; feature/x also has a pinned sub-index. // Both metas stamp the current schema version so the run-analyze // schema-mismatch guard (#2289 P1) does not force a rebuild before the // fast path runs. const flat = getStoragePaths(tmpRepo.dbPath); const flatMetaSeed: RepoMeta = { repoPath: tmpRepo.dbPath, lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'main', schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity, }; await saveMeta(flat.storagePath, flatMetaSeed); const branch = getStoragePaths(tmpRepo.dbPath, 'feature/x'); await saveMeta(path.dirname(branch.metaPath), { repoPath: tmpRepo.dbPath, lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'feature/x', schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity, }); // Register the repo in an isolated registry: the shadow cleanup only // runs for registered repos (#2364 review F2 — unregistered repos must // never lose a pinned sub-index). await registerRepo(tmpRepo.dbPath, flatMetaSeed); await registerRepo( tmpRepo.dbPath, { ...flatMetaSeed, branch: 'feature/x' }, { branch: 'feature/x' }, ); // A plain analyze ignores the pinned sub-index and serves the flat // workspace slot; the same-commit clean-tree fast path restamps the // slot's branch label and removes the now-shadowed sub-index. const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(result.alreadyUpToDate).toBe(true); expect(result.isPrimaryBranch).toBe(true); const flatMeta = await loadMeta(flat.storagePath); expect(flatMeta?.branch).toBe('feature/x'); await expect(fs.access(path.dirname(branch.metaPath))).rejects.toThrow(); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } }); it('the fast-path restamp leaves an unregistered repo pinned sub-index intact (#2364 F2)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-unregistered-'); const tmpHome = await createTempDir('gitnexus-run-analyze-unregistered-home-'); const savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git checkout -b feature/x', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); const commit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const runnerIdentity = currentRunnerIdentity(); const flat = getStoragePaths(tmpRepo.dbPath); await saveMeta(flat.storagePath, { repoPath: tmpRepo.dbPath, lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'main', schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity, }); const branch = getStoragePaths(tmpRepo.dbPath, 'feature/x'); await saveMeta(path.dirname(branch.metaPath), { repoPath: tmpRepo.dbPath, lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'feature/x', schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity, }); // Deliberately NO registerRepo: the empty isolated registry makes this // repo unregistered, so the adopt must be a full no-op on disk // (#2264/#1169 no-self-heal, #2364 review F2). const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const result = await runFullAnalysis(tmpRepo.dbPath, {}, { onProgress: () => {} }); expect(result.alreadyUpToDate).toBe(true); const flatMeta = await loadMeta(flat.storagePath); // The informational flat label still restamps… expect(flatMeta?.branch).toBe('feature/x'); // …but the pinned sub-index survives untouched. await expect(fs.access(path.dirname(branch.metaPath))).resolves.toBeUndefined(); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } }); it('a detached HEAD at the same commit skips the fast-path restamp (#2364 F3 gap 6)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-detached-'); const tmpHome = await createTempDir('gitnexus-run-analyze-detached-home-'); const savedHome = process.env.GITNEXUS_HOME; process.env.GITNEXUS_HOME = tmpHome.dbPath; try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git checkout --detach', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); const commit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const runnerIdentity = currentRunnerIdentity(); const flat = getStoragePaths(tmpRepo.dbPath); await saveMeta(flat.storagePath, { repoPath: tmpRepo.dbPath, lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'main', schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity, }); // Detached HEAD → branchLabel is null → the restamp block must not // fire: the existing stamp survives, mirroring the end-of-run write. // Null detect (detached / getCurrentBranch null / non-git) must stay // silent — it is not a rejected checkout name. const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const logs: string[] = []; const result = await runFullAnalysis( tmpRepo.dbPath, {}, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(result.alreadyUpToDate).toBe(true); const flatMeta = await loadMeta(flat.storagePath); expect(flatMeta?.branch).toBe('main'); expect(logs.filter(isDetectRejectWarning)).toEqual([]); } finally { if (savedHome === undefined) delete process.env.GITNEXUS_HOME; else process.env.GITNEXUS_HOME = savedHome; await tmpHome.cleanup(); await tmpRepo.cleanup(); } }, 180_000); it('reports isPrimaryBranch false for an up-to-date explicit --branch run (#2106 R2)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-nonprimary-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=t -c user.email=t@t commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git checkout -b feature/x', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); const commit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const runnerIdentity = currentRunnerIdentity(); // Flat slot recorded for main; feature/x has its own up-to-date pinned // sub-index, so an explicit `--branch feature/x` run routes there. const flat = getStoragePaths(tmpRepo.dbPath); await saveMeta(flat.storagePath, { repoPath: tmpRepo.dbPath, lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'main', schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity, }); const branch = getStoragePaths(tmpRepo.dbPath, 'feature/x'); await saveMeta(path.dirname(branch.metaPath), { repoPath: tmpRepo.dbPath, lastCommit: commit, indexedAt: new Date().toISOString(), branch: 'feature/x', schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity, }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const result = await runFullAnalysis( tmpRepo.dbPath, { branch: 'feature/x' }, { onProgress: () => {} }, ); expect(result.alreadyUpToDate).toBe(true); expect(result.isPrimaryBranch).toBe(false); // The pinned sub-index is untouched by an explicit branch run. await expect(fs.access(path.dirname(branch.metaPath))).resolves.toBeUndefined(); } finally { await tmpRepo.cleanup(); } }); it('rejects --branch that does not match the checked-out branch (#2106)', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-branch-mismatch-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execSync('git branch -M main', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); // Checked out on main, but labelling the snapshot as feature/x would write // main's tree into feature/x's slot — must be refused before any indexing. await expect( runFullAnalysis(tmpRepo.dbPath, { branch: 'feature/x' }, { onProgress: () => {} }), ).rejects.toThrow(/does not match the checked-out branch/); } finally { await tmpRepo.cleanup(); } }); it('warns once when the checkout name is not a usable index label', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: tmpRepo.dbPath, stdio: 'pipe' }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const logs: string[] = []; const result = await runFullAnalysis( tmpRepo.dbPath, {}, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(result.alreadyUpToDate).toBe(true); expect((await loadMeta(storagePath))?.branch).toBeUndefined(); await expect(fs.access(path.join(storagePath, 'branches'))).rejects.toThrow(); const warnings = logs.filter(isDetectRejectWarning); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain('feat`x'); expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); expect(warnings[0]).not.toMatch(/stamp|cleared|unlabeled/i); } finally { await tmpRepo.cleanup(); } }, 180_000); it('applies an explicit --branch on a rejected checkout and still warns', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-pin-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execFileSync('git', ['branch', '-M', 'feat`x'], { cwd: tmpRepo.dbPath, stdio: 'pipe' }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const logs: string[] = []; const result = await runFullAnalysis( tmpRepo.dbPath, { branch: 'main' }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(result.alreadyUpToDate).toBe(true); expect((await loadMeta(storagePath))?.branch).toBe('main'); const warnings = logs.filter(isDetectRejectWarning); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain('feat`x'); } finally { await tmpRepo.cleanup(); } }, 180_000); it('keeps the detect-reject warning on one line for U+2028 checkout names', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-ls-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execFileSync('git', ['branch', '-M', 'foo\u2028bar'], { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const logs: string[] = []; const result = await runFullAnalysis( tmpRepo.dbPath, {}, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(result.alreadyUpToDate).toBe(true); const warnings = logs.filter(isDetectRejectWarning); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain('\\u2028'); expect(warnings[0]).not.toContain('\u2028'); expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); } finally { await tmpRepo.cleanup(); } }, 180_000); it('keeps the detect-reject warning on one line for NEL+backtick checkout names', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-nel-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execFileSync('git', ['branch', '-M', 'feat\u0085`'], { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const logs: string[] = []; const result = await runFullAnalysis( tmpRepo.dbPath, {}, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(result.alreadyUpToDate).toBe(true); const warnings = logs.filter(isDetectRejectWarning); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain('\\u0085'); expect(warnings[0]).not.toContain('\u0085'); expect(warnings[0]).toContain('`'); expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); } finally { await tmpRepo.cleanup(); } }, 180_000); it('keeps the detect-reject warning on one line for NBSP checkout names', async () => { const tmpRepo = await createTempDir('gitnexus-run-analyze-detect-reject-nbsp-'); try { execSync('git init', { cwd: tmpRepo.dbPath, stdio: 'pipe' }); execSync('git -c user.name=test -c user.email=test@test commit --allow-empty -m init', { cwd: tmpRepo.dbPath, stdio: 'pipe', }); execFileSync('git', ['branch', '-M', 'foo\u00a0bar'], { cwd: tmpRepo.dbPath, stdio: 'pipe', }); const currentCommit = execSync('git rev-parse HEAD', { cwd: tmpRepo.dbPath, encoding: 'utf-8', }).trim(); const { storagePath } = getStoragePaths(tmpRepo.dbPath); await saveMeta(storagePath, { repoPath: tmpRepo.dbPath, lastCommit: currentCommit, indexedAt: new Date().toISOString(), schemaFingerprint: SCHEMA_FINGERPRINT, analysisFeatures: CURRENT_ANALYSIS_FEATURES, runnerIdentity: currentRunnerIdentity(), }); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const logs: string[] = []; const result = await runFullAnalysis( tmpRepo.dbPath, {}, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(result.alreadyUpToDate).toBe(true); const warnings = logs.filter(isDetectRejectWarning); expect(warnings).toHaveLength(1); expect(warnings[0]).toContain('\\u00a0'); expect(warnings[0]).not.toContain('\u00a0'); expect(warnings[0].split(/\n|\r|\u2028|\u2029/)).toHaveLength(1); expect(warnings[0]).toMatch(/^Warning:.*continuing\.$/); } finally { await tmpRepo.cleanup(); } }, 180_000); }); describe('collectBranchCacheKeys (#2106 R6)', () => { const writeMeta = async (dir: string, cacheKeys: unknown, filename = 'gitnexus.json') => { await fs.mkdir(dir, { recursive: true }); await fs.writeFile(path.join(dir, filename), JSON.stringify({ cacheKeys })); }; it('collects sibling branch keys, excluding the current run dir', async () => { const tmp = await createTempDir('gnx-cachekeys-'); try { const storagePath = path.join(tmp.dbPath, '.gitnexus'); await writeMeta(storagePath, ['a', 'b']); // flat await writeMeta(path.join(storagePath, 'branches', 'feat'), ['c']); const { collectBranchCacheKeys } = await import('../../src/core/run-analyze.js'); // Excluding the flat dir → only the branch's keys. const r1 = await collectBranchCacheKeys(storagePath, storagePath); expect([...r1.keys].sort()).toEqual(['c']); expect(r1.complete).toBe(true); // Excluding the branch dir → only the flat keys. const r2 = await collectBranchCacheKeys( storagePath, path.join(storagePath, 'branches', 'feat'), ); expect([...r2.keys].sort()).toEqual(['a', 'b']); } finally { await tmp.cleanup(); } }); it('single-branch (flat only) excluded → empty (byte-identical prune)', async () => { const tmp = await createTempDir('gnx-cachekeys-solo-'); try { const storagePath = path.join(tmp.dbPath, '.gitnexus'); await writeMeta(storagePath, ['a', 'b']); const { collectBranchCacheKeys } = await import('../../src/core/run-analyze.js'); const r = await collectBranchCacheKeys(storagePath, storagePath); expect(r.keys.size).toBe(0); expect(r.complete).toBe(true); } finally { await tmp.cleanup(); } }); it('a corrupt sibling meta sets complete=false (fail-safe retention)', async () => { const tmp = await createTempDir('gnx-cachekeys-corrupt-'); try { const storagePath = path.join(tmp.dbPath, '.gitnexus'); await writeMeta(storagePath, ['a']); const branchDir = path.join(storagePath, 'branches', 'feat'); await fs.mkdir(branchDir, { recursive: true }); await fs.writeFile(path.join(branchDir, 'gitnexus.json'), '{ not valid json'); const { collectBranchCacheKeys } = await import('../../src/core/run-analyze.js'); const r = await collectBranchCacheKeys(storagePath, storagePath); expect(r.complete).toBe(false); } finally { await tmp.cleanup(); } }); it('falls back to legacy meta.json sibling keys during migration', async () => { const tmp = await createTempDir('gnx-cachekeys-legacy-'); try { const storagePath = path.join(tmp.dbPath, '.gitnexus'); await writeMeta(storagePath, ['a']); await writeMeta(path.join(storagePath, 'branches', 'legacy'), ['legacy'], 'meta.json'); const { collectBranchCacheKeys } = await import('../../src/core/run-analyze.js'); const r = await collectBranchCacheKeys(storagePath, storagePath); expect([...r.keys]).toEqual(['legacy']); expect(r.complete).toBe(true); } finally { await tmp.cleanup(); } }); }); describe('deriveEmbeddingMode', () => { // Default `analyze` on a repo with existing embeddings: must preserve, must // NOT regenerate, must load the cache so phase 3.5 can re-insert vectors. it('default + existing>0 → preserve only (load cache, no generation)', () => { const m = deriveEmbeddingMode({}, 1234); expect(m.preserveExistingEmbeddings).toBe(true); expect(m.shouldGenerateEmbeddings).toBe(false); expect(m.forceRegenerateEmbeddings).toBe(false); expect(m.shouldLoadCache).toBe(true); }); it('default + existing=0 → no-op (no preserve, no generation, no cache load)', () => { const m = deriveEmbeddingMode({}, 0); expect(m.preserveExistingEmbeddings).toBe(false); expect(m.shouldGenerateEmbeddings).toBe(false); expect(m.forceRegenerateEmbeddings).toBe(false); expect(m.shouldLoadCache).toBe(false); }); // The headline behavior change requested in PR feedback: --force on an // already-embedded repo must regenerate (top up new/changed nodes), not // silently downgrade to "preserve only". it('--force + existing>0 → forceRegenerate + generate + load cache', () => { const m = deriveEmbeddingMode({ force: true }, 500); expect(m.forceRegenerateEmbeddings).toBe(true); expect(m.shouldGenerateEmbeddings).toBe(true); expect(m.preserveExistingEmbeddings).toBe(false); expect(m.shouldLoadCache).toBe(true); }); it('--force + existing=0 → no embedding work (force keeps prior semantics)', () => { const m = deriveEmbeddingMode({ force: true }, 0); expect(m.forceRegenerateEmbeddings).toBe(false); expect(m.shouldGenerateEmbeddings).toBe(false); expect(m.preserveExistingEmbeddings).toBe(false); expect(m.shouldLoadCache).toBe(false); }); it('--embeddings → generate + load cache (incremental top-up)', () => { const m = deriveEmbeddingMode({ embeddings: true }, 500); expect(m.shouldGenerateEmbeddings).toBe(true); expect(m.preserveExistingEmbeddings).toBe(false); expect(m.shouldLoadCache).toBe(true); }); it('--embeddings + existing=0 → generate; cache load still fires (harmless empty load)', () => { const m = deriveEmbeddingMode({ embeddings: true }, 0); expect(m.shouldGenerateEmbeddings).toBe(true); // Cache load is gated at the call site by `existingMeta`, not by count; // when explicit `--embeddings` is set we always attempt the load so any // stray vectors from a partial prior run get picked up. expect(m.shouldLoadCache).toBe(true); }); // --drop-embeddings is the explicit wipe path; it must suppress cache load // even when --force is also set (the dominant escape hatch). it('--drop-embeddings → suppresses cache load, no generation', () => { const m = deriveEmbeddingMode({ dropEmbeddings: true }, 1234); expect(m.shouldLoadCache).toBe(false); expect(m.shouldGenerateEmbeddings).toBe(false); expect(m.preserveExistingEmbeddings).toBe(false); expect(m.forceRegenerateEmbeddings).toBe(false); }); it('--force + --drop-embeddings → drop wins (no cache load, no generation)', () => { const m = deriveEmbeddingMode({ force: true, dropEmbeddings: true }, 1234); expect(m.shouldLoadCache).toBe(false); expect(m.shouldGenerateEmbeddings).toBe(false); expect(m.forceRegenerateEmbeddings).toBe(false); }); it('--embeddings + --drop-embeddings → drop suppresses cache load (no preservation)', () => { // --embeddings still generates, but the prior vectors are wiped first. const m = deriveEmbeddingMode({ embeddings: true, dropEmbeddings: true }, 1234); expect(m.shouldLoadCache).toBe(false); expect(m.shouldGenerateEmbeddings).toBe(true); expect(m.preserveExistingEmbeddings).toBe(false); }); // Pure drop-shape derivation pin: `{ embeddings: false, dropEmbeddings: // true }` with existing=0 must force ALL FOUR flags false even against an // explicit `--embeddings` invocation — dropEmbeddings alone still // generates, and zeroing only the existing count would still load the // cache. (Historical note: run-analyze's dirty-recovery block derived this // exact shape between tri-review 4669518496 P2-3 and this shipping // review's FIX 1, which replaced it with a fail-fast LbugWipeError — see // run-analyze-fts-repair.test.ts. The derivation itself remains a real // deriveEmbeddingMode contract worth pinning.) it('drop shape kills an explicit --embeddings recovery invocation (all four flags false)', () => { const recoveryInvocation = { embeddings: true, force: true }; const m = deriveEmbeddingMode( { ...recoveryInvocation, embeddings: false, dropEmbeddings: true }, 0, ); expect(m).toEqual({ shouldGenerateEmbeddings: false, preserveExistingEmbeddings: false, forceRegenerateEmbeddings: false, shouldLoadCache: false, }); }); }); describe('deriveEmbeddingCap', () => { it('uses the default 50K cap when limit is undefined', () => { const d = deriveEmbeddingCap(10_000, undefined); expect(d.nodeLimit).toBe(DEFAULT_EMBEDDING_NODE_LIMIT); expect(d.capDisabled).toBe(false); expect(d.skipForCap).toBe(false); }); it('skips when node count exceeds the default cap', () => { const d = deriveEmbeddingCap(75_000, undefined); expect(d.skipForCap).toBe(true); expect(d.capDisabled).toBe(false); }); it('does not skip when node count equals the default cap (boundary)', () => { const d = deriveEmbeddingCap(DEFAULT_EMBEDDING_NODE_LIMIT, undefined); expect(d.skipForCap).toBe(false); }); it('limit=0 disables the cap regardless of node count', () => { const d = deriveEmbeddingCap(1_000_000, 0); expect(d.capDisabled).toBe(true); expect(d.skipForCap).toBe(false); expect(d.nodeLimit).toBe(0); }); it('honors a custom positive cap', () => { expect(deriveEmbeddingCap(99_999, 100_000).skipForCap).toBe(false); expect(deriveEmbeddingCap(100_001, 100_000).skipForCap).toBe(true); }); it('custom cap below default still applies', () => { expect(deriveEmbeddingCap(15_000, 10_000).skipForCap).toBe(true); }); }); describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => { // M2 (#2082) added the resolved REACHING_DEF cap to the stamp; M3 (#2083) // added the two taint caps + the built-in model digest. These tests model // M3 STEADY-STATE equality — this object is the DELIBERATE pin of the // resolved-record shape, updated per milestone. The era-stamp (field // absent) upgrade paths are pinned in pdg-mode-flip.test.ts. const DEFAULTS = { maxFunctionLines: 2000, maxEdgesPerFunction: 5000, maxReachingDefEdgesPerFunction: 4000, maxCdgEdgesPerFunction: 5000, maxTaintFindingsPerFunction: 200, maxTaintHops: 32, maxInterprocFindings: 2000, maxInterprocHops: 32, maxInterprocEdges: 1000, // Content digest, not a tunable cap — pinned via the exported constant // (its VALUE changes whenever the built-in model changes, by design). taintModelVersion, // Solver identity, not a tunable cap — always stamped on a pdg-on run // (#2201 review R3). Bumps when the reaching-defs solver's emitted facts // change; absence on a pre-#2201 stamp forces a re-analysis. reachingDefSolver: 'ssa-sparse-v1', // FU-C return-value-ascent layer presence — always stamped on a pdg-on run; // absence on a pre-FU-C (v3) stamp forces a re-analysis (key-union mismatch). hasCallSummary: true, }; it('resolvePdgConfig: pdg-off run resolves to undefined (the meta field is omitted)', async () => { const { resolvePdgConfig } = await import('../../src/core/run-analyze.js'); expect(resolvePdgConfig({})).toBeUndefined(); expect(resolvePdgConfig({ pdg: false })).toBeUndefined(); }); it('resolvePdgConfig: caps resolve to their defaults; 0 = unlimited is preserved', async () => { const { resolvePdgConfig } = await import('../../src/core/run-analyze.js'); expect(resolvePdgConfig({ pdg: true })).toEqual(DEFAULTS); expect( resolvePdgConfig({ pdg: true, pdgMaxFunctionLines: 0, pdgMaxEdgesPerFunction: 0, pdgMaxReachingDefEdgesPerFunction: 0, pdgMaxCdgEdgesPerFunction: 0, pdgMaxTaintFindingsPerFunction: 0, pdgMaxTaintHops: 0, pdgMaxInterprocFindings: 0, pdgMaxInterprocHops: 0, pdgMaxInterprocEdges: 0, }), ).toEqual({ maxFunctionLines: 0, maxEdgesPerFunction: 0, maxReachingDefEdgesPerFunction: 0, maxCdgEdgesPerFunction: 0, maxTaintFindingsPerFunction: 0, maxTaintHops: 0, maxInterprocFindings: 0, maxInterprocHops: 0, maxInterprocEdges: 0, taintModelVersion, // not a cap — always stamped on a pdg-on run reachingDefSolver: 'ssa-sparse-v1', // solver identity — always stamped (#2201 R3) hasCallSummary: true, // FU-C ascent layer — always stamped on a pdg-on run }); }); it('legacy meta (no recorded stamp) + plain run → no mismatch', async () => { const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); expect(pdgModeMismatch(undefined, {})).toBe(false); }); it('legacy meta + --pdg run → mismatch (the P1 trigger)', async () => { const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); expect(pdgModeMismatch(undefined, { pdg: true })).toBe(true); }); it('recorded stamp + plain run → mismatch (zombie-cleanup direction)', async () => { const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); expect(pdgModeMismatch(DEFAULTS, {})).toBe(true); }); it('explicit defaults compare equal to absent caps (KTD5 normalization)', async () => { const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); expect(pdgModeMismatch(DEFAULTS, { pdg: true })).toBe(false); expect( pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 2000, pdgMaxEdgesPerFunction: 5000, }), ).toBe(false); }); it('a cap change while pdg stays on → mismatch (persisted edges differ)', async () => { const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxEdgesPerFunction: 1 })).toBe(true); expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 500 })).toBe(true); // 0 = unlimited differs from the 2000-line default, too. expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 0 })).toBe(true); // The M3 taint caps participate identically (#2083). expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxTaintFindingsPerFunction: 1 })).toBe(true); expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxTaintHops: 1 })).toBe(true); expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxTaintFindingsPerFunction: 200 })).toBe( false, // explicit default ≡ default ); }); }); // cjkSegmentationModeMismatch's pure-function tests moved to // cjk-segmentation.test.ts (#2339) — it now lives in cjk-segmentation.ts, // not here, so callers that only need this comparator (e.g. the MCP query // path) don't have to import the full analyze-pipeline module. run-analyze.ts // still imports and uses it (see the mismatch check above the early-return).