test(incremental): exact-equality stats invariant + analyze ≡ analyze --force

Addresses the only remaining Claude production-readiness review finding
on PR #1479 (Low-Medium, test-quality only — Claude itself said it does
NOT block merge, but the central PR claim "incremental ≡ full rebuild"
deserves explicit CI coverage rather than implicit trust).

Changes to gitnexus/test/unit/incremental-orchestration.test.ts:

1) Tighten the existing "comment-only edit takes incremental path" test.
   - Replace toBeGreaterThan(0) bounds assertions on stats.files and
     stats.nodes with exact toBe(firstMeta) per-field equality across
     files / nodes / edges / communities / processes. DoD §2.7 calls
     out bounds-only assertions as masking regressions that drop half
     the graph; this swap closes that gap.
   - Rationale: a comment-only edit must change the file content hash
     (driving the incremental path) without changing any graph data.
     Therefore every stat MUST be identical to the first run. Anything
     else is a regression.

2) New test: incremental output is byte-equivalent to a full rebuild.
   - Run analyze → comment-only edit → analyze (incremental writeback)
     → analyze --force (full rebuild from same on-disk state).
   - Assert files / nodes / edges / communities / processes are exactly
     equal across the incremental and the --force passes.
   - This is the PR's central correctness contract, now proven by a
     test that exercises the real runtime path end-to-end against a
     real on-disk LadybugDB.

All 5 orchestration tests pass locally (52s), including the new
equivalence test — every stat field matches exactly between incremental
and --force on the mini-repo fixture.

tsc --noEmit clean.
This commit is contained in:
abhigyanpatwari 2026-05-11 17:40:50 +05:30
parent 08e00e573a
commit 48a62cf5ea

View file

@ -114,7 +114,7 @@ describe('runFullAnalysis — incremental orchestration', () => {
}
}, 300_000);
it('second run after a source edit takes the incremental path (not full rebuild) and clears the dirty flag', async () => {
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');
@ -122,8 +122,12 @@ describe('runFullAnalysis — incremental orchestration', () => {
const { storagePath } = getStoragePaths(repo.dbPath);
const firstMeta = await loadMeta(storagePath);
// Modify a source file — body-only edit is enough to register a
// content-hash change.
// 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');
@ -146,14 +150,77 @@ describe('runFullAnalysis — incremental orchestration', () => {
expect(secondMeta!.fileHashes?.['src/logger.ts']).not.toBe(
firstMeta!.fileHashes?.['src/logger.ts'],
);
// Stats should still be populated.
expect(secondMeta!.stats?.files).toBeGreaterThan(0);
expect(secondMeta!.stats?.nodes).toBeGreaterThan(0);
// 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('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('a stale incrementalInProgress flag at startup forces a full rebuild that clears it', async () => {
const repo = await setupMiniRepo();
try {