/** * Integration coverage for the `runFullAnalysis` incremental-orchestration * wiring (Claude PR-review Finding 2). * * These tests exercise the *real runtime path* — they call * `runFullAnalysis` against a real on-disk git repo backed by a real * LadybugDB at `/.gitnexus/`, and assert behaviours that pure * unit tests on `diffFileHashes` / `extractChangedSubgraph` cannot * catch: * * - the `isIncremental` decision (post-pipeline eligibility check) * - `incrementalInProgress` dirty-flag set-before-mutation and * clear-on-success * - the importer-closure expansion (1-hop reached via the writable * set, transitive reachable via bounded BFS) * - the "forced full rebuild on dirty-flag-from-prior-crash" path * * Each test creates a temporary git repo, runs the analyzer, and asserts * on the resulting `meta.json` and graph state. Cleanup is best-effort * (Windows LadybugDB handle release can lag; `cleanupTempDir` retries). */ import { execSync } from 'child_process'; import { writeFile, readFile, mkdir, rm } from 'fs/promises'; import path from 'path'; import { afterEach, beforeAll, beforeEach, describe, it, expect, vi } from 'vitest'; import { getStoragePaths, saveMeta, loadMeta, type RepoMeta, } from '../../src/storage/repo-manager.js'; import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js'; import { createTempDir } from '../helpers/test-db.js'; // Shared embedding-seed trio (this shipping review, FIX 8) — the KTD9 // zero-vector seeding pattern lives in one helper module now instead of two // divergent copies here and in incremental-dirty-recovery.test.ts. import { readEmbeddingNodeIds, seedEmbeddingForNodeId, seedEmbeddingsForFiles, stampEmbeddingCount, } from '../helpers/embedding-seed.js'; import { CLASS_FRAMEWORK_ANNOTATIONS_FEATURE } from '../../src/core/analysis-features.js'; import { RebuildReasonCollector } from '../../src/core/rebuild-reasons.js'; import { SCHEMA_FINGERPRINT } from '../../src/core/lbug/schema.js'; import { isLanguageAvailable, loadParser, loadLanguage, } from '../../src/core/tree-sitter/parser-loader.js'; import { SupportedLanguages } from '../../src/config/supported-languages.js'; import { DART_PACKAGE_IDENTITY_REASON } from '../../src/core/ingestion/languages/dart/package-dependencies.js'; import { SPRING_AOP_FEATURE, SPRING_BEAN_INVENTORY_FEATURE, SPRING_CONDITIONALS_FEATURE, SPRING_NON_HTTP_HANDLERS_FEATURE, SPRING_ROUTE_BINDINGS_FEATURE, } from '../../src/core/ingestion/frameworks/spring/analysis-features.js'; import { springVendorPrefixesKey } from '../../src/core/ingestion/frameworks/spring/vendor-prefixes.js'; import { decodeSpringAopReason, SPRING_AOP_EVIDENCE_ID_PREFIX, } from '../../src/core/ingestion/frameworks/spring/aop.js'; import { SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX } from '../../src/core/ingestion/frameworks/spring/auto-configuration.js'; import { JAVA_ENUM_INTERFACE_HERITAGE_FEATURE, SPRING_CONFIG_BINDINGS_FEATURE, } from '../../src/core/ingestion/languages/java/analysis-features.js'; const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-'); /** Stage + commit everything in the temp repo (mirrors mini-repo.ts's git calls). */ const gitCommitAll = (cwd: string, message: string): void => { execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', { cwd, stdio: 'pipe', }); execSync( `git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m "${message}"`, { cwd, stdio: 'pipe' }, ); }; const SPRING_SERVICE = 'org.springframework.stereotype.Service'; /** * The collector's own output shapes, taken from the real formatter rather than * re-typed: a single-reason summary, a numbered summary's header for `count` * reasons, and the post-pipeline follow-up line. A reason with empty text * reduces each to its fixed part. */ const rebuildLineShapes = (count: number) => { const single = new RebuildReasonCollector(); single.add({ key: 'user-force', text: '' }); const numbered = new RebuildReasonCollector(); const keys = ['user-force', 'skills', 'parse-cache-bypass', 'drop-embeddings'] as const; for (const key of keys.slice(0, count)) numbered.add({ key, text: '' }); const late = new RebuildReasonCollector(); late.formatSummary(); late.add({ key: 'user-force', text: '' }); const lateNonForcing = new RebuildReasonCollector(); lateNonForcing.formatSummary(); lateNonForcing.add({ key: 'escalated-full-write', text: '', forcing: false }); return { singleSummary: single.formatSummary() ?? '', numberedHeader: (numbered.formatSummary() ?? '').split('\n')[0], followUp: late.formatFollowUp() ?? '', nonForcingFollowUp: lateNonForcing.formatFollowUp() ?? '', }; }; function withoutAnalysisFeature(meta: RepoMeta, featureId: string): RepoMeta { return { ...meta, analysisFeatures: Object.fromEntries( Object.entries(meta.analysisFeatures ?? {}).filter(([id]) => id !== featureId), ), }; } async function setupSpringBeanIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-bean-'); const src = path.join(repo.dbPath, 'src', 'com', 'other'); await mkdir(src, { recursive: true }); await writeFile( path.join(src, 'WildcardService.java'), 'package com.other;\n' + 'import org.springframework.stereotype.*;\n\n' + '@Service public class WildcardService {}\n', 'utf-8', ); execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); gitCommitAll(repo.dbPath, 'initial spring bean candidate'); return repo; } async function setupJavaEnumHeritageIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-java-enum-heritage-'); const src = path.join(repo.dbPath, 'src'); await mkdir(src, { recursive: true }); await writeFile( path.join(src, 'Status.java'), 'interface Named { String label(); }\n' + 'enum Status implements Named {\n' + ' ACTIVE;\n' + ' public String label() { return "active"; }\n' + '}\n', 'utf-8', ); execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); gitCommitAll(repo.dbPath, 'initial Java enum heritage'); return repo; } async function setupKotlinSpringBeanIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-bean-kotlin-'); const src = path.join(repo.dbPath, 'src', 'com', 'other'); await mkdir(src, { recursive: true }); await writeFile( path.join(src, 'WildcardService.kt'), 'package com.other\n' + 'import org.springframework.stereotype.*\n\n' + '@Service class WildcardService\n', 'utf-8', ); execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); gitCommitAll(repo.dbPath, 'initial Kotlin spring bean candidate'); return repo; } const springAopAspectSource = (pointcut: string): string => `package com.example;\n` + `import org.aspectj.lang.annotation.Aspect;\n` + `import org.aspectj.lang.annotation.Before;\n\n` + `@Aspect public class TraceAspect {\n` + ` @Before("${pointcut}") public void trace() {}\n` + `}\n`; async function setupSpringAopIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-aop-'); const javaSrc = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example'); const kotlinSrc = path.join(repo.dbPath, 'src', 'main', 'kotlin', 'com', 'example'); const resources = path.join(repo.dbPath, 'src', 'main', 'resources'); await Promise.all([ mkdir(javaSrc, { recursive: true }), mkdir(kotlinSrc, { recursive: true }), mkdir(resources, { recursive: true }), ]); await Promise.all([ writeFile(path.join(repo.dbPath, '.gitignore'), '.gitnexus/\n', 'utf-8'), writeFile( path.join(javaSrc, 'FirstService.java'), 'package com.example;\n\n' + 'public class FirstService {\n' + ' public void first() {}\n' + '}\n', 'utf-8', ), writeFile( path.join(javaSrc, 'TraceAspect.java'), springAopAspectSource('within(com.example.FirstService)'), 'utf-8', ), writeFile( path.join(kotlinSrc, 'KotlinService.kt'), 'package com.example\n\n' + 'import org.springframework.transaction.annotation.Transactional as Tx\n\n' + 'class KotlinService {\n' + ' @Tx fun kotlinTx() {}\n' + '}\n', 'utf-8', ), writeFile(path.join(resources, 'application.properties'), 'feature.enabled=true\n', 'utf-8'), ]); execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); gitCommitAll(repo.dbPath, 'initial spring aop fixture'); return repo; } async function setupSpringBeanFactoryIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-bean-factory-'); const src = path.join(repo.dbPath, 'src', 'com', 'other'); await mkdir(src, { recursive: true }); await writeFile( path.join(src, 'WildcardConfiguration.java'), 'package com.other;\n' + 'import org.springframework.context.annotation.*;\n\n' + '@Configuration class WildcardConfiguration {\n' + ' @Bean Gateway gateway() { return new DefaultGateway(); }\n' + '}\n' + 'interface Gateway {}\n' + 'class DefaultGateway implements Gateway {}\n', 'utf-8', ); execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); gitCommitAll(repo.dbPath, 'initial spring bean factory'); return repo; } async function setupSpringConfigIncrementalRepo() { const repo = await createTempDir('gitnexus-incr-spring-config-'); const resources = path.join(repo.dbPath, 'src', 'main', 'resources'); await mkdir(resources, { recursive: true }); await writeFile(path.join(resources, 'application.properties'), 'service.timeout=30\n', 'utf-8'); execSync('git init', { cwd: repo.dbPath, stdio: 'pipe' }); gitCommitAll(repo.dbPath, 'initial spring configuration'); return repo; } async function setupKotlinSpringConfigConsumerIncrementalRepo() { const repo = await setupSpringConfigIncrementalRepo(); const kotlin = path.join(repo.dbPath, 'src', 'main', 'kotlin', 'com', 'example'); await mkdir(kotlin, { recursive: true }); await writeFile( path.join(kotlin, 'ConfigConsumer.kt'), 'package com.example\n' + 'import org.springframework.beans.factory.annotation.Value\n\n' + 'class ConfigConsumer {\n' + ' @Value("\\${service.timeout}")\n' + ' var timeout: Int = 0\n' + '}\n', 'utf-8', ); gitCommitAll(repo.dbPath, 'add Kotlin Spring config consumer'); return repo; } async function readWildcardServiceAnnotations(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( "MATCH (c:Class) WHERE c.name = 'WildcardService' " + 'RETURN c.frameworkAnnotations AS frameworkAnnotations LIMIT 1', )) as Array<{ frameworkAnnotations?: unknown }>; const value = rows[0]?.frameworkAnnotations; return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; } finally { await adapter.closeLbug(); } } async function readSpringConfigPropertyNames(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( "MATCH (p:Property) WHERE p.filePath = 'src/main/resources/application.properties' " + 'RETURN p.name AS name ORDER BY p.name', )) as Array<{ name?: unknown }>; return rows.map((row) => String(row.name)); } finally { await adapter.closeLbug(); } } async function readKotlinConfigConsumerState(repoPath: string): Promise<{ description: string; bindingCount: number; }> { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( "MATCH (p:Property) WHERE p.name = 'timeout' " + 'RETURN p.description AS description LIMIT 1', )) as Array<{ description?: unknown }>; const bindings = (await adapter.executeQuery( "MATCH (p:Property {name: 'timeout'})-[r:CodeRelation]->(c:Property) " + "WHERE r.type = 'USES' AND r.reason STARTS WITH 'spring-config:' " + 'RETURN count(r) AS count', )) as Array<{ count?: number | bigint }>; return { description: String(rows[0]?.description ?? ''), bindingCount: Number(bindings[0]?.count ?? 0), }; } finally { await adapter.closeLbug(); } } async function readActuatorSnapshotLeakRows( repoPath: string, snapshotPath: string, secretValue: string, ): Promise> { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( `MATCH (f:File) RETURN f.filePath AS filePath, f.content AS content`, )) as Array<{ filePath?: unknown; content?: unknown }>; return rows.filter( (row) => row.filePath === snapshotPath || (typeof row.content === 'string' && row.content.includes(secretValue)), ); } finally { await adapter.closeLbug(); } } async function readRuntimePropertyEvidence(repoPath: string): Promise<{ description: string; reasons: string[]; }> { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const propertyRows = (await adapter.executeQuery( `MATCH (p:Property) WHERE p.name = 'runtime.secret' ` + `RETURN p.description AS description LIMIT 1`, )) as Array<{ description?: unknown }>; const relationshipRows = (await adapter.executeQuery( `MATCH (:File)-[r:CodeRelation]->(p:Property) WHERE p.name = 'runtime.secret' ` + `AND r.type = 'DECLARES' RETURN r.reason AS reason ORDER BY reason`, )) as Array<{ reason?: unknown }>; return { description: String(propertyRows[0]?.description ?? ''), reasons: relationshipRows.map((row) => String(row.reason ?? '')), }; } finally { await adapter.closeLbug(); } } async function countStatusImplementsNamed(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( "MATCH (e:Enum {name: 'Status'})-[r:CodeRelation]->(i:Interface {name: 'Named'}) " + "WHERE r.type = 'IMPLEMENTS' RETURN count(r) AS c", )) as Array<{ c: number | bigint }>; return Number(rows[0]?.c ?? 0); } finally { await adapter.closeLbug(); } } async function deleteStatusImplementsNamed(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { await adapter.executeQuery( "MATCH (e:Enum {name: 'Status'})-[r:CodeRelation]->(i:Interface {name: 'Named'}) " + "WHERE r.type = 'IMPLEMENTS' DELETE r", ); } finally { await adapter.closeLbug(); } } /** * Direct count over INJECTS CodeRelation rows — mirrors pdg-mode-flip's * countBasicBlocks: reopen the repo DB, count, close (runFullAnalysis closes * the singleton on completion, so each count owns its own open/close). */ async function countInjects(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'INJECTS' RETURN count(r) AS c`, )) as Array<{ c: number | bigint }>; return Number(rows[0]?.c ?? 0); } finally { await adapter.closeLbug(); } } async function countSpringAutoConfigurationDeclarations(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' ` + `AND (r.reason = 'spring-auto-configuration-import' ` + `OR r.reason = 'spring-auto-configuration-factory') RETURN count(r) AS c`, )) as Array<{ c: number | bigint }>; return Number(rows[0]?.c ?? 0); } finally { await adapter.closeLbug(); } } async function countSpringBeanFactoryDeclarations(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( `MATCH ()-[r:CodeRelation]->() WHERE r.type = 'DECLARES' ` + `AND r.reason STARTS WITH 'spring-bean-factory:' RETURN count(r) AS c`, )) as Array<{ c: number | bigint }>; return Number(rows[0]?.c ?? 0); } finally { await adapter.closeLbug(); } } async function countSpringAutoConfigurationSyntheticClasses(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( `MATCH (n:Class) WHERE n.id STARTS WITH ` + `'${SPRING_AUTO_CONFIGURATION_SYNTHETIC_ID_PREFIX}' ` + `RETURN count(n) AS c`, )) as Array<{ c: number | bigint }>; return Number(rows[0]?.c ?? 0); } finally { await adapter.closeLbug(); } } interface SpringAopPersistedRelationship { readonly relType: string; readonly sourceId: string; readonly sourceName: string; readonly targetId: string; readonly targetName: string; readonly reason: string; } interface SpringAopPersistedEvidence { readonly id: string; readonly description: string; } interface SpringAopPersistedSnapshot { readonly relationships: readonly SpringAopPersistedRelationship[]; readonly evidence: readonly SpringAopPersistedEvidence[]; } async function readSpringAopSnapshot(repoPath: string): Promise { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const relationships = (await adapter.executeQuery( `MATCH (s)-[r:CodeRelation]->(t) ` + `WHERE (r.type = 'ADVISED_BY' OR r.type = 'DECLARES') ` + `AND r.reason STARTS WITH 'spring-aop:v1:' ` + `RETURN r.type AS relType, s.id AS sourceId, s.name AS sourceName, ` + `t.id AS targetId, t.name AS targetName, r.reason AS reason ` + `ORDER BY relType, sourceId, targetId, reason`, )) as SpringAopPersistedRelationship[]; const evidence = (await adapter.executeQuery( `MATCH (n:CodeElement) WHERE n.id STARTS WITH '${SPRING_AOP_EVIDENCE_ID_PREFIX}' ` + `RETURN n.id AS id, n.description AS description ORDER BY id`, )) as SpringAopPersistedEvidence[]; return { relationships, evidence }; } finally { await adapter.closeLbug(); } } function assertSpringAopSnapshotShape( snapshot: SpringAopPersistedSnapshot, expectedAdviceSource: string, ): void { expect(snapshot.relationships).toHaveLength(4); expect(snapshot.evidence).toHaveLength(3); const decoded = snapshot.relationships.map((relationship) => ({ relationship, reason: decodeSpringAopReason(relationship.reason), })); expect(decoded.map(({ reason }) => reason?.kind).sort()).toEqual([ 'advice', 'aspect', 'behavior', 'pointcut', ]); expect(decoded.find(({ reason }) => reason?.kind === 'behavior')?.relationship.sourceName).toBe( 'kotlinTx', ); const advice = decoded.find(({ reason }) => reason?.kind === 'advice')?.relationship; expect(advice?.sourceName).toBe(expectedAdviceSource); expect(advice?.targetName).toBe('trace'); expect( new Set( snapshot.relationships.map( (relationship) => `${relationship.relType}\0${relationship.sourceId}\0${relationship.targetId}\0${relationship.reason}`, ), ).size, ).toBe(snapshot.relationships.length); expect(new Set(snapshot.evidence.map(({ id }) => id)).size).toBe(snapshot.evidence.length); } /** Java DI fixture (#2200): `@Autowired List` + 2 implementers ⇒ exactly * 2 INJECTS edges (Consumer→FooA, Consumer→FooB). Same shapes as the * spring-di-pipeline integration fixture. */ const JAVA_DI_FIXTURE: ReadonlyArray = [ ['IFoo.java', 'package com.example;\n\npublic interface IFoo {}\n'], ['FooA.java', 'package com.example;\n\npublic class FooA implements IFoo {}\n'], ['FooB.java', 'package com.example;\n\npublic class FooB implements IFoo {}\n'], [ 'Consumer.java', 'package com.example;\n' + 'import java.util.List;\n' + 'import org.springframework.beans.factory.annotation.Autowired;\n' + '\n' + 'public class Consumer {\n' + ' @Autowired private List foos;\n' + '}\n', ], ]; describe('runFullAnalysis — incremental orchestration', () => { afterEach(() => { vi.unstubAllEnvs(); }); it('first run populates fileHashes + schemaVersion and clears incrementalInProgress on success', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta).not.toBeNull(); expect(meta!.schemaFingerprint).toBe(SCHEMA_FINGERPRINT); expect(meta!.fileHashes).toBeDefined(); expect(Object.keys(meta!.fileHashes ?? {}).length).toBeGreaterThan(0); expect(meta!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, }); // Dirty flag MUST be cleared after a successful run. expect(meta!.incrementalInProgress).toBeUndefined(); } finally { await repo.cleanup(); } }, 180_000); it('second run on unchanged state takes the alreadyUpToDate fast path', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const first = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(first.alreadyUpToDate).toBeUndefined(); const second = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); // lastCommit==HEAD && working tree clean (mod GitNexus output) → // early-return fast path. expect(second.alreadyUpToDate).toBe(true); } finally { await repo.cleanup(); } }, 300_000); it('useParseCache:false bypasses the alreadyUpToDate fast path without --force', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const logs: string[] = []; const cold = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true, useParseCache: false }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(cold.alreadyUpToDate).toBeUndefined(); expect(cold.pipelineResult?.parseCacheHitFileCount ?? 0).toBe(0); expect(cold.pipelineResult?.reparsedFileCount).toBe(7); // Its own reason, not a borrowed --force. expect(cold.rebuildReasons).toEqual(['parse-cache-bypass']); const { singleSummary } = rebuildLineShapes(1); expect(logs.filter((m) => m.startsWith(singleSummary))).toHaveLength(1); } finally { await repo.cleanup(); } }, 300_000); it('rebuilds for Actuator snapshots and once more when runtime enrichment is disabled', async () => { const repo = await setupMiniRepo(); const runtimeInput = 'runtime-actuator'; const runtimeInputDir = path.join(repo.dbPath, runtimeInput); const secretValue = 'ACTUATOR_META_SECRET_2418'; try { await mkdir(runtimeInputDir, { recursive: true }); await writeFile( path.join(runtimeInputDir, 'env.json'), JSON.stringify({ propertySources: [ { name: 'systemEnvironment', properties: { 'runtime.secret': { value: secretValue, origin: 'env' } }, }, ], }), 'utf-8', ); gitCommitAll(repo.dbPath, 'add actuator runtime snapshot'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const enabled = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true, springActuatorPath: runtimeInput }, { onProgress: () => {} }, ); expect(enabled.alreadyUpToDate).toBeUndefined(); expect(enabled.rebuildReasons).toEqual(['spring-actuator']); const { storagePath } = getStoragePaths(repo.dbPath); const enabledMeta = await loadMeta(storagePath); if (enabledMeta === null) throw new Error('Expected Actuator metadata after enabled analysis'); expect(enabledMeta.springActuator).toEqual({ enabled: true, repoRelativeInputs: [runtimeInput], }); expect(JSON.stringify(enabledMeta)).not.toContain(secretValue); expect(Object.keys(enabledMeta.fileHashes ?? {})).not.toContain(`${runtimeInput}/env.json`); expect(await readRuntimePropertyEvidence(repo.dbPath)).toEqual({ description: expect.stringContaining('Spring Actuator env runtime-confirmed'), reasons: ['spring-actuator:env:runtime-confirmed'], }); expect( await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue), ).toEqual([]); await saveMeta(storagePath, { ...enabledMeta, springActuator: { enabled: true, repoRelativeInputs: [runtimeInput, 42], }, } as RepoMeta); await expect( runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }), ).rejects.toThrow('Cannot safely disable Spring Actuator runtime enrichment'); await saveMeta(storagePath, enabledMeta); const disableLogs: string[] = []; const disabled = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => disableLogs.push(message) }, ); expect(disabled.alreadyUpToDate).toBeUndefined(); expect(disabled.rebuildReasons).toEqual(['spring-actuator']); expect( disableLogs.filter((m) => m.startsWith(rebuildLineShapes(1).singleSummary)), ).toHaveLength(1); expect((await loadMeta(storagePath))?.springActuator).toEqual({ enabled: false, repoRelativeInputs: [runtimeInput], }); expect( await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue), ).toEqual([]); const steady = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(steady.alreadyUpToDate).toBe(true); const forceLogs: string[] = []; const forcedSteady = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true, force: true }, { onProgress: () => {}, onLog: (message) => forceLogs.push(message) }, ); expect(forcedSteady.alreadyUpToDate).toBeUndefined(); expect(forcedSteady.rebuildReasons).toEqual(['user-force']); expect(forceLogs.join('\n')).toContain( 'Rebuilt the graph and FTS while reusing cached parser output', ); expect(forceLogs.join('\n')).toContain('increment SCHEMA_BUMP'); expect( await readActuatorSnapshotLeakRows(repo.dbPath, `${runtimeInput}/env.json`, secretValue), ).toEqual([]); expect((await loadMeta(storagePath))?.springActuator).toEqual({ enabled: false, repoRelativeInputs: [runtimeInput], }); } finally { await repo.cleanup(); } }, 300_000); it('a same-commit v8 index missing the global Class capability rebuilds before the fast path', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta!.schemaFingerprint).toBe(SCHEMA_FINGERPRINT); await saveMeta( storagePath, withoutAnalysisFeature(meta!, CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id), ); const logs: string[] = []; const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(reanalyzed.alreadyUpToDate).toBeUndefined(); expect(logs.join('\n')).toContain(`missing:${CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id}`); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, }); } finally { await repo.cleanup(); } }, 300_000); it('a Java index missing enum heritage evidence rebuilds before the fast path (#2918)', async () => { const repo = await setupJavaEnumHeritageIncrementalRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta!.analysisFeatures).toMatchObject({ [JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id]: JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.version, }); expect(await countStatusImplementsNamed(repo.dbPath)).toBe(1); await deleteStatusImplementsNamed(repo.dbPath); expect(await countStatusImplementsNamed(repo.dbPath)).toBe(0); await saveMeta( storagePath, withoutAnalysisFeature(meta!, JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id), ); const logs: string[] = []; const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(reanalyzed.alreadyUpToDate).toBeUndefined(); expect(logs.join('\n')).toContain(`missing:${JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id}`); expect((await loadMeta(storagePath))!.analysisFeatures).toMatchObject({ [JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.id]: JAVA_ENUM_INTERFACE_HERITAGE_FEATURE.version, }); expect(await countStatusImplementsNamed(repo.dbPath)).toBe(1); } finally { await repo.cleanup(); } }, 300_000); it('a same-commit index with NO fingerprint (pre-#2798) rebuilds once, not grandfathered', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); // Every index built before the field existed. Grandfathering absence // would stamp a fresh fingerprint onto a database whose DDL was never // verified — permanently certifying the very index this guard catches. await saveMeta(storagePath, { ...meta!, schemaFingerprint: undefined }); const logs: string[] = []; const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(reanalyzed.alreadyUpToDate).toBeUndefined(); // An absent stamp is unattributable — this build cannot tell a pre-#2798 // index from a hand-cleared one — so the notice names no version. expect(logs.join('\n')).toContain( 'index schema changed (built by an unidentified GitNexus build,', ); // The extra "non-git repositories never record a schema fingerprint" // sentence is conditional on the repo having no git dir. setupMiniRepo // builds a real git repo, so appending it here would be a false // explanation for an absence this build is genuinely responsible for. expect(logs.join('\n')).not.toContain('Non-git repositories never record'); // One-time: the rebuild restamps it, so the next run is eligible again. expect(await loadMeta(storagePath)).toMatchObject({ schemaFingerprint: SCHEMA_FINGERPRINT, }); } finally { await repo.cleanup(); } }, 300_000); it('a JVM index missing Bean inventory evidence rebuilds and restores the scoped stamp', async () => { const repo = await setupKotlinSpringBeanIncrementalRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, [SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version, }); await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_BEAN_INVENTORY_FEATURE.id)); const logs: string[] = []; const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(reanalyzed.alreadyUpToDate).toBeUndefined(); expect(logs.join('\n')).toContain(`missing:${SPRING_BEAN_INVENTORY_FEATURE.id}`); expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, [SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version, }); } finally { await repo.cleanup(); } }, 300_000); it('rebuilds a JVM index when the registered Spring vendor prefixes change', async () => { const repo = await setupSpringBeanIncrementalRepo(); try { vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Win'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); expect((await loadMeta(storagePath))?.springVendorPrefixes).toBe(springVendorPrefixesKey()); vi.stubEnv('GITNEXUS_SPRING_VENDOR_PREFIXES', 'Acme,Win'); const logs: string[] = []; const rebuilt = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(rebuilt.alreadyUpToDate).toBeUndefined(); expect(rebuilt.rebuildReasons).toContain('spring-vendor-prefixes'); expect(logs.join('\n')).toContain('Spring vendor mapping prefixes changed'); expect((await loadMeta(storagePath))?.springVendorPrefixes).toBe(springVendorPrefixesKey()); const steady = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(steady.alreadyUpToDate).toBe(true); } finally { await repo.cleanup(); } }, 300_000); it('a config-only index missing Spring config evidence rebuilds and restores the scoped stamp', async () => { const repo = await setupSpringConfigIncrementalRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, }); await saveMeta(storagePath, withoutAnalysisFeature(meta!, SPRING_CONFIG_BINDINGS_FEATURE.id)); const logs: string[] = []; const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(reanalyzed.alreadyUpToDate).toBeUndefined(); expect(logs.join('\n')).toContain(`missing:${SPRING_CONFIG_BINDINGS_FEATURE.id}`); expect(await readSpringConfigPropertyNames(repo.dbPath)).toEqual(['service.timeout']); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, }); } finally { await repo.cleanup(); } }, 300_000); it('persists Kotlin unresolved markers when a Spring config key is deleted incrementally', async () => { const repo = await setupKotlinSpringConfigConsumerIncrementalRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await readKotlinConfigConsumerState(repo.dbPath)).toEqual({ description: '', bindingCount: 1, }); const configPath = path.join( repo.dbPath, 'src', 'main', 'resources', 'application.properties', ); await writeFile(configPath, '', 'utf-8'); gitCommitAll(repo.dbPath, 'delete Spring config key'); const logs: string[] = []; await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(logs.join('\n')).toContain('Spring config consumer property drift'); expect(await readKotlinConfigConsumerState(repo.dbPath)).toEqual({ description: 'Spring config unresolved: service.timeout', bindingCount: 0, }); } finally { await repo.cleanup(); } }, 300_000); it('adding the first JVM file re-evaluates capabilities after the pipeline and avoids a top-up', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, }); await writeFile( path.join(repo.dbPath, 'src', 'FirstBean.kt'), 'import org.springframework.stereotype.Service\n\n@Service class FirstBean\n', 'utf-8', ); gitCommitAll(repo.dbPath, 'add first JVM source file'); const logs: string[] = []; const result = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); // Found only after the pipeline: no summary, exactly one follow-up line. expect(result.rebuildReasons).toEqual(['analysis-features']); const { singleSummary, followUp } = rebuildLineShapes(1); const followUps = logs.filter((m) => m.startsWith(followUp)); expect(followUps).toHaveLength(1); expect(followUps[0]).toContain(`missing:${SPRING_BEAN_INVENTORY_FEATURE.id}`); expect(logs.filter((m) => m.startsWith(singleSummary))).toEqual([]); expect(logs.join('\n')).not.toContain('Incremental:'); expect((await loadMeta(storagePath))!.analysisFeatures).toEqual({ [CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.id]: CLASS_FRAMEWORK_ANNOTATIONS_FEATURE.version, [SPRING_AOP_FEATURE.id]: SPRING_AOP_FEATURE.version, [SPRING_BEAN_INVENTORY_FEATURE.id]: SPRING_BEAN_INVENTORY_FEATURE.version, [SPRING_CONDITIONALS_FEATURE.id]: SPRING_CONDITIONALS_FEATURE.version, [SPRING_CONFIG_BINDINGS_FEATURE.id]: SPRING_CONFIG_BINDINGS_FEATURE.version, [SPRING_NON_HTTP_HANDLERS_FEATURE.id]: SPRING_NON_HTTP_HANDLERS_FEATURE.version, [SPRING_ROUTE_BINDINGS_FEATURE.id]: SPRING_ROUTE_BINDINGS_FEATURE.version, }); } finally { await repo.cleanup(); } }, 300_000); it('replaces Spring AOP evidence across real incremental runs without duplicates', async () => { const repo = await setupSpringAopIncrementalRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const firstSnapshot = await readSpringAopSnapshot(repo.dbPath); assertSpringAopSnapshotShape(firstSnapshot, 'first'); const firstPointcutEvidenceId = firstSnapshot.relationships.find( ({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut', )?.targetId; expect(firstPointcutEvidenceId).toBeDefined(); const aspectPath = path.join( repo.dbPath, 'src', 'main', 'java', 'com', 'example', 'TraceAspect.java', ); await writeFile( aspectPath, springAopAspectSource( '@annotation(org.springframework.transaction.annotation.Transactional)', ), 'utf-8', ); gitCommitAll(repo.dbPath, 'retarget spring advice'); const retargetLogs: string[] = []; const retargeted = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => retargetLogs.push(message) }, ); expect(retargeted.alreadyUpToDate).toBeUndefined(); expect(retargetLogs.join('\n')).toContain('Incremental: changed=1'); expect(retargetLogs.join('\n')).not.toContain('switching to a full DB write'); const secondSnapshot = await readSpringAopSnapshot(repo.dbPath); assertSpringAopSnapshotShape(secondSnapshot, 'kotlinTx'); const secondPointcutEvidenceId = secondSnapshot.relationships.find( ({ reason }) => decodeSpringAopReason(reason)?.kind === 'pointcut', )?.targetId; expect(secondPointcutEvidenceId).toBeDefined(); expect(secondPointcutEvidenceId).not.toBe(firstPointcutEvidenceId); expect(secondSnapshot.evidence.map(({ id }) => id)).not.toContain(firstPointcutEvidenceId); const propertyPath = path.join( repo.dbPath, 'src', 'main', 'resources', 'application.properties', ); await writeFile(propertyPath, 'feature.enabled=false\n', 'utf-8'); gitCommitAll(repo.dbPath, 'change unrelated resource'); const replayLogs: string[] = []; const replayed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onLog: (message) => replayLogs.push(message), onProgress: () => {}, }, ); expect(replayed.alreadyUpToDate).toBeUndefined(); expect(replayLogs.join('\n')).toContain('Incremental: changed=1'); expect(replayLogs.join('\n')).not.toContain('switching to a full DB write'); expect(await readSpringAopSnapshot(repo.dbPath)).toEqual(secondSnapshot); } finally { await repo.cleanup(); } }, 600_000); it('second run after a comment-only edit takes the incremental path, clears the dirty flag, and preserves graph stats exactly', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const firstMeta = await loadMeta(storagePath); // Modify a source file with a COMMENT-ONLY edit — by construction // this changes the content hash (driving the incremental code path) // without changing any symbol, scope binding, call edge, import, // or community membership. Therefore every graph-stat invariant // (files / nodes / edges / communities / processes) MUST be // bit-identical to the first run. Anything else is a regression. const target = path.join(repo.dbPath, 'src', 'logger.ts'); const before = await readFile(target, 'utf-8'); await writeFile(target, before + '\n// touched by test\n', 'utf-8'); const second = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); // The early-return alreadyUpToDate path must NOT fire (the dirty // tree should kick the run through to incremental writeback). expect(second.alreadyUpToDate).toBeUndefined(); const secondMeta = await loadMeta(storagePath); expect(secondMeta).not.toBeNull(); // Dirty flag must be cleared on success. expect(secondMeta!.incrementalInProgress).toBeUndefined(); // fileHashes[logger.ts] must have rotated to the new content. expect(secondMeta!.fileHashes?.['src/logger.ts']).toBeDefined(); expect(secondMeta!.fileHashes?.['src/logger.ts']).not.toBe( firstMeta!.fileHashes?.['src/logger.ts'], ); // Exact-equality stats invariant. DoD §2.7: avoid bounds-only // assertions that would mask a regression dropping half the graph. expect(secondMeta!.stats?.files).toBe(firstMeta!.stats?.files); expect(secondMeta!.stats?.nodes).toBe(firstMeta!.stats?.nodes); expect(secondMeta!.stats?.edges).toBe(firstMeta!.stats?.edges); expect(secondMeta!.stats?.communities).toBe(firstMeta!.stats?.communities); expect(secondMeta!.stats?.processes).toBe(firstMeta!.stats?.processes); } finally { await repo.cleanup(); } }, 300_000); it('skips the framework annotation drift query when no Bean source changed', async () => { const repo = await setupMiniRepo(); try { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const target = path.join(repo.dbPath, 'src', 'logger.ts'); const before = await readFile(target, 'utf-8'); await writeFile(target, before + '\n// non-bean-source incremental touch\n', 'utf-8'); const querySpy = vi.spyOn(adapter, 'executeQuery'); try { const incremental = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(incremental.alreadyUpToDate).toBeUndefined(); expect(incremental.incrementalStats).toMatchObject({ changedFiles: 1, affectedDependents: 2, deletedFiles: 0, writeMode: 'incremental', }); expect(incremental.incrementalStats?.reparsedFiles).toBe(1); expect( querySpy.mock.calls.some( ([query]) => typeof query === 'string' && query.includes('RETURN c.id AS id, c.frameworkAnnotations AS frameworkAnnotations'), ), ).toBe(false); } finally { querySpy.mockRestore(); } } finally { await repo.cleanup(); } }, 300_000); it('incremental output is byte-equivalent to a full rebuild (incremental ≡ --force on the same repo state)', async () => { // The central correctness contract of this PR: an incremental run // and a full rebuild from the same repo state must produce identical // graph stats. We exercise it end-to-end: // // 1. setup mini-repo + run analyze (populates the index) // 2. edit one source file (comment-only — same graph) // 3. run incremental analyze → record secondMeta // 4. run analyze --force from the same state → record forceMeta // 5. assert every stats invariant is exactly equal. // // Steps 3 and 4 share the same on-disk file contents, so any // divergence is purely an artifact of the writeback strategy. If // any invariant differs, the PR's load-bearing claim is violated. const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); // Step 1: initial index. await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); // Step 2: comment-only edit, same as the test above. const target = path.join(repo.dbPath, 'src', 'logger.ts'); const original = await readFile(target, 'utf-8'); await writeFile(target, original + '\n// equivalence test touch\n', 'utf-8'); // Step 3: incremental writeback for the edited file. const incremental = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(incremental.alreadyUpToDate).toBeUndefined(); const { storagePath } = getStoragePaths(repo.dbPath); const secondMeta = await loadMeta(storagePath); expect(secondMeta).not.toBeNull(); // Step 4: force a full rebuild from the SAME on-disk file state. const forced = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true, force: true }, { onProgress: () => {} }, ); expect(forced.alreadyUpToDate).toBeUndefined(); const forceMeta = await loadMeta(storagePath); expect(forceMeta).not.toBeNull(); // Step 5: exact-equality across every stat. `toEqual` would also // work but `toBe` per-field makes a failure pinpoint the field. expect(secondMeta!.stats?.files).toBe(forceMeta!.stats?.files); expect(secondMeta!.stats?.nodes).toBe(forceMeta!.stats?.nodes); expect(secondMeta!.stats?.edges).toBe(forceMeta!.stats?.edges); expect(secondMeta!.stats?.communities).toBe(forceMeta!.stats?.communities); expect(secondMeta!.stats?.processes).toBe(forceMeta!.stats?.processes); } finally { await repo.cleanup(); } }, 600_000); it('rewrites unchanged Spring bean metadata when same-package shadowing changes', async () => { const repo = await setupSpringBeanIncrementalRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]); const shadow = path.join(repo.dbPath, 'src', 'com', 'other', 'Service.java'); await writeFile(shadow, 'package com.other;\npublic @interface Service {}\n', 'utf-8'); gitCommitAll(repo.dbPath, 'add same-package annotation shadow'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([]); await rm(shadow); gitCommitAll(repo.dbPath, 'remove same-package annotation shadow'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]); } finally { await repo.cleanup(); } }, 600_000); it('rewrites unchanged Kotlin Spring bean metadata when same-package shadowing changes', async () => { const repo = await setupKotlinSpringBeanIncrementalRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]); const shadow = path.join(repo.dbPath, 'src', 'com', 'other', 'Service.kt'); await writeFile(shadow, 'package com.other\nannotation class Service\n', 'utf-8'); gitCommitAll(repo.dbPath, 'add same-package Kotlin annotation shadow'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([]); await rm(shadow); gitCommitAll(repo.dbPath, 'remove same-package Kotlin annotation shadow'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await readWildcardServiceAnnotations(repo.dbPath)).toEqual([SPRING_SERVICE]); } finally { await repo.cleanup(); } }, 600_000); it('rewrites unchanged Bean factory declarations when same-package shadowing changes', async () => { const repo = await setupSpringBeanFactoryIncrementalRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(1); const shadow = path.join(repo.dbPath, 'src', 'com', 'other', 'Bean.java'); await writeFile(shadow, 'package com.other;\npublic @interface Bean {}\n', 'utf-8'); gitCommitAll(repo.dbPath, 'add same-package Bean annotation shadow'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(0); await rm(shadow); gitCommitAll(repo.dbPath, 'remove same-package Bean annotation shadow'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await countSpringBeanFactoryDeclarations(repo.dbPath)).toBe(1); } finally { await repo.cleanup(); } }, 600_000); // #2409: a large-fraction effective write set must escalate to the full DB // write plan (wipe + bulk COPY of the already-built graph) instead of the // surgical per-file writeback — at that size the surgical plan measured // SLOWER than a full load and its delete storm is the write pattern behind // the reported native mid-writeback deaths. The escalated result must be // indistinguishable from a --force rebuild of the same state. it('a hub edit whose write set covers most of a large repo escalates to the full DB write plan (#2409)', async () => { const repo = await setupMiniRepo(); try { // Grow the repo past INCREMENTAL_ESCALATION_MIN_FILES (50) with a hub // imported by every generated file: touching the hub pulls the whole // family into the importer closure → write fraction ≈ 100% > 50%. const src = path.join(repo.dbPath, 'src'); await writeFile( path.join(src, 'hub.ts'), 'export function hubValue(x: number): number {\n return x + 1;\n}\n', 'utf-8', ); for (let i = 0; i < 60; i++) { await writeFile( path.join(src, `spoke-${String(i).padStart(3, '0')}.ts`), `import { hubValue } from './hub';\n\nexport function spoke${i}(): number {\n return hubValue(${i});\n}\n`, 'utf-8', ); } gitCommitAll(repo.dbPath, 'add hub + spokes'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); // Touch the hub — comment-only, so graph stats must be preserved. const hub = path.join(src, 'hub.ts'); await writeFile(hub, (await readFile(hub, 'utf-8')) + '// escalation touch\n', 'utf-8'); const logs: string[] = []; const incremental = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (m) => logs.push(m) }, ); expect(incremental.alreadyUpToDate).toBeUndefined(); const joined = logs.join('\n'); // The importer expansion fired AND the valve rerouted the write plan. expect(joined).toContain('importer(s) added to writable set'); expect(joined).toContain('switching to a full DB write'); // Announced as the one follow-up line, carrying the escalation's own // text, and non-forcing: the run stayed on the incremental branch // (`incrementalStats` exists only there) and wrote the full plan. expect(incremental.rebuildReasons).toEqual(['escalated-full-write']); const { singleSummary, followUp, nonForcingFollowUp } = rebuildLineShapes(1); const followUps = logs.filter((m) => m.startsWith(nonForcingFollowUp)); expect(followUps).toHaveLength(1); expect(followUps[0]).toContain('switching to a full DB write'); expect(logs.filter((m) => m.startsWith(followUp))).toEqual([]); expect(logs.filter((m) => m.startsWith(singleSummary))).toEqual([]); expect(incremental.incrementalStats?.writeMode).toBe('full'); const { storagePath } = getStoragePaths(repo.dbPath); const escalatedMeta = await loadMeta(storagePath); expect(escalatedMeta).not.toBeNull(); // Dirty flag cleared on success — the escalated plan converges on the // same meta-save as every other successful run. expect(escalatedMeta!.incrementalInProgress).toBeUndefined(); // The escalated write must be indistinguishable from --force on the // same state: any stale surviving row would show up as a stats delta. await runFullAnalysis( repo.dbPath, { skipAgentsMd: true, force: true }, { onProgress: () => {} }, ); const forcedMeta = await loadMeta(storagePath); expect(escalatedMeta!.stats?.files).toBe(forcedMeta!.stats?.files); expect(escalatedMeta!.stats?.nodes).toBe(forcedMeta!.stats?.nodes); expect(escalatedMeta!.stats?.edges).toBe(forcedMeta!.stats?.edges); expect(escalatedMeta!.stats?.communities).toBe(forcedMeta!.stats?.communities); expect(escalatedMeta!.stats?.processes).toBe(forcedMeta!.stats?.processes); } finally { await repo.cleanup(); } }, 600_000); // U4 / KTD10 (tri-review 4669518496): a SURGICAL preserve-mode run (below // both valve gates) must keep embedding rows in lockstep with their files // now that deleteNodesForFiles really deletes embedding rows via the // nodeId join: // - changed-file rows are deleted with their nodes and RESTORED from the // cache (the old insert-all restore lost them when a surviving row's // PK conflict aborted the rest of the batch), // - deleted-file rows are gone (join-delete) and NOT resurrected by the // restore (live-graph filter), // - unchanged rows are untouched (restore-scope filter, no conflicts), // - a LEGACY ORPHAN row — stranded while the embedding delete was a // no-op, unreachable by the node join forever — is swept by exact id // (this shipping review, FIX 3). it('surgical incremental run keeps embedding rows in lockstep: changed restored, deleted gone, unchanged intact, legacy orphan swept (tri-review 4669518496 KTD10 + FIX 3)', async () => { const repo = await setupMiniRepo(); try { const CHANGED_FILE = 'src/logger.ts'; const UNCHANGED_FILE = 'src/db.ts'; const DELETED_FILE = 'src/formatter.ts'; // A fabricated nodeId no graph will ever contain: the P2-1-era no-op // delete left rows like this stranded in real DBs (schema version // stays 6, so they are still out there). const LEGACY_ORPHAN_NODE_ID = 'Function:src/ghost.ts:ghost:1'; const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const idsByFile = await seedEmbeddingsForFiles( repo.dbPath, [CHANGED_FILE, UNCHANGED_FILE, DELETED_FILE], 3, ); for (const fp of [CHANGED_FILE, UNCHANGED_FILE, DELETED_FILE]) { expect((idsByFile.get(fp) ?? []).length).toBeGreaterThan(0); } await seedEmbeddingForNodeId(repo.dbPath, LEGACY_ORPHAN_NODE_ID); const seededTotal = [...idsByFile.values()].flat().length + 1; await stampEmbeddingCount(storagePath, seededTotal); // One file modified (comment-only, appended at EOF so node ids keep // their line numbers), one file deleted — committed so lastCommit moves. const target = path.join(repo.dbPath, CHANGED_FILE); await writeFile( target, (await readFile(target, 'utf-8')) + '\n// embeddings parity touch\n', 'utf-8', ); await rm(path.join(repo.dbPath, DELETED_FILE)); gitCommitAll(repo.dbPath, 'modify logger + delete formatter'); const logs: string[] = []; const run = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (m) => logs.push(m) }, ); expect(run.alreadyUpToDate).toBeUndefined(); // 7-file repo — far below the 50-file valve floor: this MUST have been // the surgical write plan, or every assertion below is vacuously about // the escalated path instead. expect(logs.join('\n')).not.toContain('switching to a full DB write'); // The surgical run swept the fabricated legacy orphan by exact id // (FIX 3). The logged count also includes DELETED_FILE's cached rows — // live-graph rejects whose DB rows were already join-deleted with the // file, so their exact-id DELETEs match nothing (documented no-op). const expectedSweepCount = 1 + (idsByFile.get(DELETED_FILE) ?? []).length; expect(logs.join('\n')).toContain( `Swept ${expectedSweepCount} cached embedding row(s) with no live owning node`, ); const after = await loadMeta(storagePath); expect(after!.incrementalInProgress).toBeUndefined(); const expectedSurvivors = [ ...(idsByFile.get(CHANGED_FILE) ?? []), ...(idsByFile.get(UNCHANGED_FILE) ?? []), ]; // stats.embeddings excludes both the deleted-file rows AND the swept // legacy orphan. expect(after!.stats?.embeddings).toBe(expectedSurvivors.length); // Exact surviving nodeId set — pins all four behaviors at once (a // batch-abort loss, a leaked deleted-file row, a dropped unchanged // row, or a lingering legacy orphan each break set equality). expect((await readEmbeddingNodeIds(repo.dbPath)).sort()).toEqual( [...expectedSurvivors].sort(), ); } finally { await repo.cleanup(); } }, 600_000); // #2409 defect 2 (dirty-flag recovery parks WAL/shadow sidecars before any // open) is covered in incremental-dirty-recovery.test.ts — its own file so // the cross-platform CI matrix runs it on windows-latest without pulling in // this whole suite. it('a stale incrementalInProgress flag at startup forces a full rebuild that clears it', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); // First run lays down a normal index. await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); // Manually corrupt meta.json with a stale dirty flag — simulates // a crashed previous incremental run. const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta).not.toBeNull(); const tampered: RepoMeta = { ...meta!, incrementalInProgress: { startedAt: Date.now() - 60_000, toWriteCount: 3, phase: 'load-graph', importerExpansion: 153, effectiveWriteCount: 167, deleteCount: 169, }, }; await saveMeta(storagePath, tampered); const logs: string[] = []; // Next run must detect the flag, force a full rebuild (which // overwrites meta), and clear the flag. const recovered = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); // A full rebuild was taken — the alreadyUpToDate fast path // explicitly cannot fire because the dirty-flag check rewrote // `options.force` to true. expect(recovered.alreadyUpToDate).toBeUndefined(); expect(recovered.rebuildReasons).toEqual(['interrupted-rebuild']); const after = await loadMeta(storagePath); expect(after!.incrementalInProgress).toBeUndefined(); expect(logs.join('\n')).toContain( 'last dirty state: phase=load-graph, toWrite=3, importerExpansion=153, effectiveWrite=167, deleteCount=169', ); } finally { await repo.cleanup(); } }, 300_000); // #2798/#3041: the invariant the INCREMENTAL_SCHEMA_VERSION ladder used to // backstop. It is implicit nowhere else: no other gate observes analyzer code // that emits no DDL, so dropping the runner-identity reason silently re-opens // same-commit top-ups across an analyzer that changed how the graph is // shaped. Pinned on the returned reasons (it used to be a source regex over // run-analyze.ts). The predicate's OWN behaviour — a moved build digest with // unmoved DDL, an absent/null/legacy/malformed receipt, an alternate // diagnostic entrypoint — is asserted in analyzer-identity.test.ts. it('a stamped runner identity that differs forces a full rebuild with the runner-identity reason', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); if (meta?.runnerIdentity === undefined) throw new Error('first run stamped no identity'); // Same commit, clean tree, same DDL: only the analyzer build digest moved. const { invokedArtifact } = meta.runnerIdentity; await saveMeta(storagePath, { ...meta, runnerIdentity: { ...meta.runnerIdentity, invokedArtifact: { ...invokedArtifact, digest: '0'.repeat(64) }, }, }); const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(reanalyzed.alreadyUpToDate).toBeUndefined(); expect(reanalyzed.rebuildReasons).toEqual(['runner-identity']); } finally { await repo.cleanup(); } }, 300_000); // an upgrade that trips several reasons at once names them in ONE // numbered block and prints no other rebuild line. it('schema, runner identity, and a new Actuator request print one numbered block of three', async () => { const repo = await setupMiniRepo(); const runtimeInput = 'runtime-actuator'; try { await mkdir(path.join(repo.dbPath, runtimeInput), { recursive: true }); await writeFile( path.join(repo.dbPath, runtimeInput, 'env.json'), JSON.stringify({ propertySources: [] }), 'utf-8', ); gitCommitAll(repo.dbPath, 'add actuator runtime snapshot'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); if (meta?.runnerIdentity === undefined) throw new Error('first run stamped no identity'); const { invokedArtifact } = meta.runnerIdentity; await saveMeta(storagePath, { ...meta, schemaFingerprint: 'b1c2d3e4f5a6', runnerIdentity: { ...meta.runnerIdentity, invokedArtifact: { ...invokedArtifact, digest: '0'.repeat(64) }, }, }); const logs: string[] = []; const upgraded = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true, springActuatorPath: runtimeInput }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(upgraded.rebuildReasons).toEqual([ 'schema-fingerprint', 'runner-identity', 'spring-actuator', ]); const { singleSummary, numberedHeader, followUp } = rebuildLineShapes(3); const blocks = logs.filter((m) => m.startsWith(numberedHeader)); expect(blocks).toHaveLength(1); expect(blocks[0].split('\n')).toHaveLength(4); expect(logs.filter((m) => m.startsWith(singleSummary) || m.startsWith(followUp))).toEqual([]); } finally { await repo.cleanup(); } }, 300_000); // An index carrying a schema stamp that is not this build's must not take the // alreadyUpToDate fast path. The schema mismatch guard runs before lastCommit // equality can short-circuit the pipeline, so node-identity migrations receive // a full rebuild. Pinned on the RESULT (no fast path, restamped meta) rather // than the log line, so the ordering invariant survives a reworded notice. it('a foreign schema fingerprint forces a full rebuild on an unchanged-commit re-analyze', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); // First run stamps the digest of the DDL this build creates. await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta).not.toBeNull(); expect(meta!.schemaFingerprint).toBe(SCHEMA_FINGERPRINT); // Simulate an index whose tables were created from a different DDL, at // the same commit with a clean tree. Well-formed (12 lowercase hex, so it // clears the echo-shape gate) but not this build's — every other fast-path // condition holds, so only the schema guard can stop the early return. const downgraded: RepoMeta = { ...meta!, schemaFingerprint: 'b1c2d3e4f5a6' }; await saveMeta(storagePath, downgraded); const logs: string[] = []; const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); // Pipeline actually ran (schemaFingerprint mismatch → force=true), and the // notice names the stamp it rejected rather than a generic placeholder. expect(reanalyzed.alreadyUpToDate).toBeUndefined(); expect(reanalyzed.rebuildReasons).toEqual(['schema-fingerprint']); expect(logs.join('\n')).toContain('index schema changed (built by b1c2d3e4f5a6,'); // And the rebuild restamped this build's digest (that path runs saveMeta). const restamped = await loadMeta(storagePath); expect(restamped!.schemaFingerprint).toBe(SCHEMA_FINGERPRINT); } finally { await repo.cleanup(); } }, 300_000); // A recorded graph-write collapse at an unchanged commit must not take the // alreadyUpToDate fast path: the gate forces a full rebuild and names it. it('a recorded graphWriteCollapsed stamp forces a full rebuild on an unchanged-commit re-analyze', async () => { const repo = await setupMiniRepo(); try { const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta?.graphWriteCollapsed).toBeUndefined(); expect(meta).toBeTruthy(); // Same commit, clean tree, same DDL: only the collapse stamp differs. const collapsed: RepoMeta = { ...(meta as RepoMeta), graphWriteCollapsed: { expected: 500, persisted: 3 }, }; await saveMeta(storagePath, collapsed); const logs: string[] = []; const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => logs.push(message) }, ); expect(reanalyzed.alreadyUpToDate).toBeUndefined(); expect(reanalyzed.rebuildReasons).toContain('graph-write-collapse'); expect(logs.join('\n')).toContain('previous run persisted 3 of 500 expected relationships'); } finally { await repo.cleanup(); } }, 300_000); // #2331/#2339: mirrors the schema-fingerprint mismatch test above, but for // the CJK segmentation mode stamp. Uses a non-default mode ('bigram') rather // than 'none' — with the default, (undefined ?? 'none') !== 'none' is // false regardless of whether the stamp was ever actually written, so a // dropped-stamp bug would pass this test vacuously. 'bigram' makes an // omitted stamp manifest as a real comparator mismatch instead. it('a stale cjkSegmentation stamp forces a full rebuild on an unchanged-commit re-analyze', async () => { const repo = await setupMiniRepo(); try { vi.stubEnv('GITNEXUS_FTS_CJK_SEGMENTATION', 'bigram'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); const { storagePath } = getStoragePaths(repo.dbPath); const meta = await loadMeta(storagePath); expect(meta).not.toBeNull(); expect(meta!.cjkSegmentation).toBe('bigram'); // Simulate a repo indexed under 'none' (or a pre-#2339 build with no // stamp at all) that's now being served/re-analyzed with bigram mode. const downgraded: RepoMeta = { ...meta!, cjkSegmentation: 'none' }; await saveMeta(storagePath, downgraded); const reanalyzed = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); // Pipeline actually ran (cjkSegmentation mismatch → force=true). expect(reanalyzed.alreadyUpToDate).toBeUndefined(); expect(reanalyzed.rebuildReasons).toContain('cjk-segmentation'); // And the meta is restamped to the live resolved mode. const restamped = await loadMeta(storagePath); expect(restamped!.cjkSegmentation).toBe('bigram'); } finally { await repo.cleanup(); } }, 300_000); it('first-ever analyze of a brand-new repo proceeds without a spurious CJK mode force-rebuild', async () => { const repo = await setupMiniRepo(); try { const { storagePath } = getStoragePaths(repo.dbPath); // No meta.json exists yet — existingMeta is falsy, so the // cjkSegmentationModeMismatch guard is skipped entirely (never calls // the comparator), same as the pdg/schemaVersion guards above it. expect(await loadMeta(storagePath)).toBeNull(); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const result = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(result.alreadyUpToDate).toBeUndefined(); const meta = await loadMeta(storagePath); expect(meta!.cjkSegmentation).toBe('none'); } finally { await repo.cleanup(); } }, 300_000); // U7 (#2200): the INJECTS delete-before-writeback must be UNCONDITIONAL. // extractChangedSubgraph re-includes ALL INJECTS edges from the fresh graph // on every incremental run (isGraphWideRelType), and CodeRelation has no PK // and no read-side dedup — so a pdg-gated delete (literal TAINT_PATH // mirroring) would append without deleting on every non-pdg incremental // run: N runs = N copies of every INJECTS row. This test is the assertion // that catches exactly that mistake. it('incremental runs neither strand nor duplicate INJECTS edges (delete-all is not pdg-gated) (#2200)', async () => { const repo = await setupMiniRepo(); try { const src = path.join(repo.dbPath, 'src'); for (const [name, content] of JAVA_DI_FIXTURE) { await writeFile(path.join(src, name), content, 'utf-8'); } gitCommitAll(repo.dbPath, 'add java di fixture'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); // Full index: Consumer.foos fans out to the two IFoo implementers. await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await countInjects(repo.dbPath)).toBe(2); // Incremental run 1: comment-only touch of an UNRELATED file (none of // the Java DI files change), committed so lastCommit moves. const target = path.join(src, 'logger.ts'); const beforeFirstTouch = await readFile(target, 'utf-8'); await writeFile(target, beforeFirstTouch + '\n// di idempotency touch 1\n', 'utf-8'); gitCommitAll(repo.dbPath, 'unrelated touch 1'); const run1 = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(run1.alreadyUpToDate).toBeUndefined(); expect(await countInjects(repo.dbPath)).toBe(2); // Incremental run 2: second unrelated touch. A gated delete would have // appended two more rows per writeback (4 by now) — must still be 2. const beforeSecondTouch = await readFile(target, 'utf-8'); await writeFile(target, beforeSecondTouch + '\n// di idempotency touch 2\n', 'utf-8'); gitCommitAll(repo.dbPath, 'unrelated touch 2'); const run2 = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(run2.alreadyUpToDate).toBeUndefined(); expect(await countInjects(repo.dbPath)).toBe(2); } finally { await repo.cleanup(); } }, 600_000); it('incremental runs do not duplicate repository-wide Spring DECLARES edges (#2415)', async () => { const repo = await setupMiniRepo(); try { const sourceDir = path.join(repo.dbPath, 'src', 'main', 'java', 'com', 'example'); const metadataDir = path.join(repo.dbPath, 'src', 'main', 'resources', 'META-INF', 'spring'); await mkdir(sourceDir, { recursive: true }); await mkdir(metadataDir, { recursive: true }); await writeFile( path.join(metadataDir, 'org.springframework.boot.autoconfigure.AutoConfiguration.imports'), 'com.example.ExampleAutoConfiguration\n', 'utf-8', ); gitCommitAll(repo.dbPath, 'add auto configuration metadata'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1); expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(1); const target = path.join(repo.dbPath, 'src', 'logger.ts'); for (const run of [1, 2]) { const before = await readFile(target, 'utf-8'); await writeFile(target, `${before}\n// auto-register idempotency touch ${run}\n`, 'utf-8'); gitCommitAll(repo.dbPath, `unrelated auto-register touch ${run}`); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1); expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(1); } await writeFile( path.join(sourceDir, 'ExampleAutoConfiguration.java'), 'package com.example;\npublic class ExampleAutoConfiguration {}\n', 'utf-8', ); gitCommitAll(repo.dbPath, 'add source for metadata-only auto configuration'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); expect(await countSpringAutoConfigurationDeclarations(repo.dbPath)).toBe(1); expect(await countSpringAutoConfigurationSyntheticClasses(repo.dbPath)).toBe(0); } finally { await repo.cleanup(); } }, 600_000); }); /** * U3 (tri-review 4669518496 P1): the #2409 escalation valve wipes the DB * files — HNSW vector index included. The Phase 3.5 restore brought the * embedding ROWS back, but nothing recreated the index and meta still * stamped `vector-index`: semantic search on a >10k-embedding repo silently * lost its vector lane while meta certified otherwise. This suite pins the * fix end-to-end: escalated preserve-mode run → index recreated → meta honest. * * SEPARATE from the `--force` escalation parity test above (KTD9): seeding * embeddings there would make its force leg derive forceRegenerateEmbeddings * and boot a real embedder in CI. This run stays preserve-only (no force). * * Skip-gated on VECTOR availability (the lbug-vector-extension.test.ts * pattern): skipped only where the extension genuinely cannot load — * no platform is categorically excluded any more (#2623 follow-up) — and * where it cannot, the honest stamp is * 'exact-scan', which the unit-level wiring pin in * run-analyze-fts-repair.test.ts covers platform-independently. */ let dartAvailable = isLanguageAvailable(SupportedLanguages.Dart); if (dartAvailable) { try { await loadParser(); await loadLanguage(SupportedLanguages.Dart); } catch { dartAvailable = false; } } describe.skipIf(!dartAvailable)('Dart pubspec-only incremental persistence (#2963)', () => { it.each([ { name: 'rename', initial: 'name: app', next: 'name: renamed', file: 'pubspec.yaml', nextResolves: false, }, { name: 'addition', initial: null, next: 'name: app', file: 'pubspec.yaml', nextResolves: true, }, { name: 'duplicate', initial: 'name: app', next: 'name: app', file: 'nested/pubspec.yaml', nextResolves: false, }, { name: 'repair', initial: 'name: [invalid', next: 'name: app', file: 'pubspec.yaml', nextResolves: true, }, ])( 'persists $name and its reversal with forced-rebuild parity', async ({ initial, next, file, nextResolves }) => { const repo = await createTempDir(); const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const options = { skipAgentsMd: true, skipFts: true }; const progress = { onProgress: () => {} }; const readEdges = async () => { await adapter.initLbug(getStoragePaths(repo.dbPath).lbugPath, { skipFts: true }); try { return await adapter.executeQuery( `MATCH (a)-[r:CodeRelation]->(b) WHERE r.type IN ['IMPORTS', 'CALLS'] ` + `RETURN a.id AS source, b.id AS target, r.type AS type, r.reason AS reason, ` + `a.filePath AS sourceFile, b.filePath AS targetFile, b.name AS targetName ` + `ORDER BY source, target, type, reason`, ); } finally { await adapter.closeLbug(); } }; try { await mkdir(path.join(repo.dbPath, 'lib'), { recursive: true }); await mkdir(path.join(repo.dbPath, 'nested'), { recursive: true }); await writeFile(path.join(repo.dbPath, '.gitignore'), '.gitnexus/\n'); await writeFile( path.join(repo.dbPath, 'lib/model.dart'), 'class Model { void save() {} }\n', ); await writeFile( path.join(repo.dbPath, 'lib/bridge.dart'), "import 'package:app/model.dart';\nModel buildModel() => Model();\n", ); await writeFile( path.join(repo.dbPath, 'lib/main.dart'), "import './bridge.dart';\nvoid run() { buildModel().save(); }\n", ); await writeFile( path.join(repo.dbPath, 'nested/decoy.dart'), 'class Model { void save() {} }\n', ); if (initial !== null) await writeFile(path.join(repo.dbPath, 'pubspec.yaml'), initial); execSync('git init -q', { cwd: repo.dbPath, stdio: 'pipe' }); gitCommitAll(repo.dbPath, 'Dart package fixture'); await runFullAnalysis(repo.dbPath, options, progress); const initialEdges = await readEdges(); expect( initialEdges.filter( (edge) => edge.type === 'IMPORTS' && edge.source === 'File:lib/bridge.dart' && edge.target === 'File:lib/model.dart', ), ).toHaveLength(initial === 'name: app' ? 1 : 0); for (const [content, resolves] of [ [next, nextResolves], [file === 'pubspec.yaml' ? initial : null, initial === 'name: app'], ] as const) { const target = path.join(repo.dbPath, file); if (content === null) await rm(target); else await writeFile(target, content); gitCommitAll(repo.dbPath, 'Update package identity'); const incremental = await runFullAnalysis(repo.dbPath, options, progress); expect(incremental.incrementalStats?.writeMode).toBe('incremental'); const incrementalEdges = await readEdges(); const imports = incrementalEdges.filter( (edge) => edge.type === 'IMPORTS' && edge.source === 'File:lib/bridge.dart' && edge.target === 'File:lib/model.dart', ); expect(imports).toHaveLength(resolves ? 1 : 0); expect( incrementalEdges.filter( (edge) => edge.type === 'CALLS' && edge.sourceFile === 'lib/main.dart' && edge.targetFile === 'lib/model.dart' && edge.targetName === 'save', ), ).toHaveLength(resolves ? 1 : 0); const meta = await loadMeta(getStoragePaths(repo.dbPath).storagePath); expect(Object.hasOwn(meta?.fileHashes ?? {}, file)).toBe(content !== null); const identityToFile = incrementalEdges.filter( (edge) => edge.type === 'IMPORTS' && edge.reason === DART_PACKAGE_IDENTITY_REASON && edge.source === 'File:lib/main.dart' && edge.target === `File:${file}`, ); if (content === 'name: app') { expect(identityToFile).toEqual([ { source: 'File:lib/main.dart', target: `File:${file}`, type: 'IMPORTS', reason: DART_PACKAGE_IDENTITY_REASON, sourceFile: 'lib/main.dart', targetFile: file, targetName: 'pubspec.yaml', }, ]); } else { // Rename and deletion must drop the identity edge. A stale edge // would keep rewriting importers after the package name is gone. expect(identityToFile).toEqual([]); } await runFullAnalysis(repo.dbPath, { ...options, force: true }, progress); expect(await readEdges()).toEqual(incrementalEdges); } } finally { await adapter.closeLbug(); await repo.cleanup(); } }, 300_000, ); }); describe('runFullAnalysis — escalated wipe recreates the vector index (#2409, tri-review 4669518496 P1)', () => { let vectorAvailable = false; let skipWarned = false; beforeAll(async () => { // Probe VECTOR the way the analyze write path loads it. loadVectorExtension // needs an open connection, and this suite (unlike the withTestLbugDB // vector suites) has no ambient DB — probe against a scratch one. const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { resolveAnalyzeInstallPolicy } = await import('../../src/core/lbug/extension-loader.js'); const tmp = await createTempDir('gitnexus-incr-orch-vector-probe-'); try { await adapter.initLbug(path.join(tmp.dbPath, 'probe-lbug')); vectorAvailable = await adapter.loadVectorExtension(undefined, { policy: resolveAnalyzeInstallPolicy(), }); } finally { await adapter.closeLbug(); await tmp.cleanup(); } }, 120_000); beforeEach((ctx) => { if (!vectorAvailable) { if (!skipWarned) { skipWarned = true; console.warn( '[incremental-orchestration] Skipping vector-index recreation test — the ' + 'LadybugDB VECTOR extension is unavailable (unsupported platform or ' + 'could not be installed).', ); } ctx.skip(); } }); it('recreates the HNSW index after an escalated wipe-and-restore and stamps meta honestly (tri-review 4669518496 P1)', async () => { const repo = await setupMiniRepo(); try { // Hub+spokes repo shape VERBATIM from the escalation parity test above: // the escalated run must clear BOTH valve gates (deleteCount ≥ 50 AND // fraction > 0.5). A smaller fixture would silently take the surgical // path, whose surviving index makes every assertion below pass // vacuously. const src = path.join(repo.dbPath, 'src'); await writeFile( path.join(src, 'hub.ts'), 'export function hubValue(x: number): number {\n return x + 1;\n}\n', 'utf-8', ); for (let i = 0; i < 60; i++) { await writeFile( path.join(src, `spoke-${String(i).padStart(3, '0')}.ts`), `import { hubValue } from './hub';\n\nexport function spoke${i}(): number {\n return hubValue(${i});\n}\n`, 'utf-8', ); } gitCommitAll(repo.dbPath, 'add hub + spokes'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }); // 20 zero-vector embeddings on real Function nodes (one per spoke — // fabricated ids would be dropped by the Phase 3.5 live-graph filter) // + a stats stamp so deriveEmbeddingMode sees an embedded repo // (preserve mode — the run below passes NO force flag, so no embedder // ever fires; KTD9). const SEED_COUNT = 20; const { storagePath, lbugPath } = getStoragePaths(repo.dbPath); const seedFiles: string[] = []; for (let i = 0; i < SEED_COUNT; i++) { seedFiles.push(`src/spoke-${String(i).padStart(3, '0')}.ts`); } const idsByFile = await seedEmbeddingsForFiles(repo.dbPath, seedFiles, 1); expect([...idsByFile.values()].flat().length).toBe(SEED_COUNT); await stampEmbeddingCount(storagePath, SEED_COUNT); // Touch the hub — the importer closure covers the whole family, the // valve fires, and the DB (index included) is wiped mid-run. const hub = path.join(src, 'hub.ts'); await writeFile(hub, (await readFile(hub, 'utf-8')) + '// index recreation touch\n', 'utf-8'); const logs: string[] = []; const escalated = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (m) => logs.push(m) }, ); expect(escalated.alreadyUpToDate).toBeUndefined(); // The valve rerouted the write plan — without this the index assertions // below test the surgical path's surviving index, not the recreation. expect(logs.join('\n')).toContain('switching to a full DB write'); // Every cached row was restored across the wipe… const after = await loadMeta(storagePath); expect(after!.incrementalInProgress).toBeUndefined(); expect(after!.stats?.embeddings).toBe(SEED_COUNT); // …meta stamps what the DB actually holds… expect(after!.capabilities?.vectorSearch.status).toBe('vector-index'); // …and the DB really does hold a recreated HNSW index (SHOW_INDEXES // straight off the reopened store — the assertion that fails when the // wipe destroys the index and nothing rebuilds it). const adapter = await import('../../src/core/lbug/lbug-adapter.js'); await adapter.initLbug(lbugPath); try { const idxRows = (await adapter.executeQuery('CALL SHOW_INDEXES() RETURN *')) as Array<{ index_name?: string; index_type?: string; }>; const idx = idxRows.find((r) => r.index_name === 'code_embedding_idx'); expect(idx).toBeDefined(); expect(idx!.index_type).toBe('HNSW'); } finally { await adapter.closeLbug(); } } finally { await repo.cleanup(); } }, 600_000); }); /** Document-derived destinations currently in the graph, address + provenance. */ async function readDocumentDestinations( repoPath: string, ): Promise> { const adapter = await import('../../src/core/lbug/lbug-adapter.js'); const { lbugPath } = getStoragePaths(repoPath); await adapter.initLbug(lbugPath); try { const rows = (await adapter.executeQuery( `MATCH (d:Destination) WHERE d.resolution = 'asyncapi-document' ` + `RETURN d.address AS address, d.broker AS broker, d.resolution AS resolution ` + `ORDER BY address`, )) as Array<{ address?: unknown; broker?: unknown; resolution?: unknown }>; return rows.map((row) => ({ address: String(row.address ?? ''), broker: String(row.broker ?? ''), resolution: String(row.resolution ?? ''), })); } finally { await adapter.closeLbug(); } } describe('runFullAnalysis — AsyncAPI document reading', () => { /** * Drives the REAL `runFullAnalysis` with `asyncApiSpecPath` set. * * Three separate things were individually deletable with the whole suite * green before this existed: the forward from `run-analyze` into * `PipelineOptions`, the forced rebuild while the option is enabled, and the * cleanup rebuild when it is dropped. Each of them makes the feature * partially or wholly inert, and none of them is visible one layer up, where * the CLI test asserts on a mock's arguments. * * The document lives OUTSIDE the repository on purpose. That is the workflow * the option is documented for — a cache written by other tooling — and it is * the only one where the hazard is real: editing a tracked file dirties the * tree and would force a rebuild anyway, so an in-repo fixture would pass * even with the freshness fix reverted. */ it('forces a rebuild while enabled, re-reads a changed document, and cleans up once on disable', async () => { const repo = await setupMiniRepo(); // A SIBLING of the repository, not a child: the document must be outside // the working tree, or editing it would dirty the tree and force a rebuild // on its own, and the test would pass with the freshness fix reverted. const specDir = path.join(repo.dbPath, '..', 'gnx-asyncapi-spec'); await mkdir(specDir, { recursive: true }); const specFile = path.join(specDir, 'orders.yaml'); const documentFor = (address: string): string => [ 'asyncapi: 3.0.0', 'info: { title: Order Service, version: 1.0.0 }', 'servers: { broker: { host: "example:9092", protocol: kafka } }', `channels: { c: { address: ${address}, servers: [{ $ref: "#/servers/broker" }] } }`, 'operations: { publishOrder: { action: send, channel: { $ref: "#/channels/c" } } }', '', ].join('\n'); try { await writeFile(specFile, documentFor('orders.v1'), 'utf-8'); const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); const { storagePath } = getStoragePaths(repo.dbPath); const enabledLogs: string[] = []; const enabled = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true, asyncApiSpecPath: specDir }, { onProgress: () => {}, onLog: (message) => enabledLogs.push(message) }, ); expect(enabled.alreadyUpToDate).toBeUndefined(); expect(enabled.rebuildReasons).toEqual(['asyncapi']); expect( enabledLogs.filter((m) => m.startsWith(rebuildLineShapes(1).singleSummary)), ).toHaveLength(1); // The forward into PipelineOptions is what puts this node in the graph; // without it the flag parses and nothing else happens. expect(await readDocumentDestinations(repo.dbPath)).toEqual([ { address: 'orders.v1', broker: 'kafka', resolution: 'asyncapi-document' }, ]); expect((await loadMeta(storagePath))?.asyncApiSpec).toEqual({ enabled: true }); // Same commit, clean tree, only the out-of-tree document changed. Git // freshness cannot see this, so without the forced rebuild the run is a // no-op that reports success and serves the previous address. await writeFile(specFile, documentFor('orders.v2'), 'utf-8'); const rereadRun = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true, asyncApiSpecPath: specDir }, { onProgress: () => {} }, ); expect(rereadRun.alreadyUpToDate).toBeUndefined(); expect(await readDocumentDestinations(repo.dbPath)).toEqual([ { address: 'orders.v2', broker: 'kafka', resolution: 'asyncapi-document' }, ]); const disableLogs: string[] = []; const disabled = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {}, onLog: (message) => disableLogs.push(message) }, ); expect(disabled.alreadyUpToDate).toBeUndefined(); expect(disabled.rebuildReasons).toEqual(['asyncapi']); expect( disableLogs.filter((m) => m.startsWith(rebuildLineShapes(1).singleSummary)), ).toHaveLength(1); expect(await readDocumentDestinations(repo.dbPath)).toEqual([]); expect((await loadMeta(storagePath))?.asyncApiSpec).toBeUndefined(); // Exactly ONE cleanup rebuild: the metadata write drops the flag, so the // next run has nothing to react to. A merge-over-previous write here // would rebuild forever with nothing failing. const steady = await runFullAnalysis( repo.dbPath, { skipAgentsMd: true }, { onProgress: () => {} }, ); expect(steady.alreadyUpToDate).toBe(true); } finally { // The document directory is a sibling of the repository, so the repo's // own cleanup does not reach it — both owners have to be called here. await rm(specDir, { recursive: true, force: true }); await repo.cleanup(); } }, 180_000); });