GitNexus/gitnexus/test/integration/context-resource-staleness.test.ts
Copilot 6496c55564
fix(mcp): context resource reads stale lastCommit/stats after out-of-process analyze (#2438) (#2439)
* Initial plan

* fix(mcp): read fresh metadata from disk in context resource to fix stale staleness banner and stats (#2438)

The context resource (gitnexus://repo/{name}/context) was serving stale
lastCommit and stats after an out-of-process `analyze --index-only` refresh.
The LocalBackend's in-memory RepoHandle is cached and only refreshes on
registry misses — never on a disk update from an external process.

Fix: read fresh metadata via loadMeta(repo.storagePath) on every context
resource read, mirroring the ensureInitialized hot-swap pattern. Use the
fresh lastCommit for the staleness check and fresh stats for the stats
block; fall back to cached values when the disk read fails.

Tests:
- 5 new unit tests in resources.test.ts covering fresh meta, fallback behavior
- 3 new integration tests in context-resource-staleness.test.ts covering
  the exact reproduce sequence from the issue report

* fix(test): use @ladybugdb/core mock to prevent native addon load in integration test

The context-resource-staleness integration test used importOriginal() in
the vi.mock factories for pool-adapter.js and mcp/core/lbug-adapter.js.
That caused the real pool-adapter.ts to load @ladybugdb/core, which
tries to dlopen lbugjs.node — a native addon that requires postinstall
(not run with --ignore-scripts in CI).

Fix:
- Add a vi.mock('@ladybugdb/core') at the package boundary, matching the
  pattern in lbug-pool-fts-load.test.ts and pool-wal-recovery.test.ts.
- Replace the two importOriginal()-based lbug mocks with direct factories
  that provide all necessary exports (initLbug, executeQuery,
  executeParameterized, closeLbug, isLbugReady) without loading the real
  module.

All 3 integration tests now pass in CI (no lbugjs.node required).

* test(integration): run context staleness flow end-to-end without mocks

* Address PR review feedback (#2439)

- Restore libc selectors for optional native packages

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-07-16 09:20:18 +01:00

207 lines
8.3 KiB
TypeScript

/**
* Integration Tests: Context Resource Staleness Fix (#2438)
*
* End-to-end flow with real git and real registry/meta I/O.
*/
import { execFileSync } from 'child_process';
import { writeFileSync } from 'fs';
import path from 'path';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createTempDir } from '../helpers/test-db.js';
import type { RepoMeta } from '../../src/storage/repo-manager.js';
import { getStoragePaths, registerRepo, saveMeta } from '../../src/storage/repo-manager.js';
import { LocalBackend } from '../../src/mcp/local/local-backend.js';
import { readResource } from '../../src/mcp/resources.js';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function runGit(repoPath: string, ...args: string[]): string {
try {
return execFileSync('git', args, { cwd: repoPath, encoding: 'utf-8' }).trim();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`git ${args.join(' ')} failed in ${repoPath}: ${message}`);
}
}
/**
* Persist index metadata and register the repo in the global registry.
* `saveMeta` runs before `registerRepo` so registry validation can immediately
* see a readable metadata file for this entry.
* @param repoPath Absolute path to the git repository under test.
* @param storagePath Absolute path to the repo metadata directory.
* @param meta Metadata snapshot to write to gitnexus.json and registry.
* @param repoName Registry alias used by LocalBackend for this repo.
*/
async function seedIndexedRepo(
repoPath: string,
storagePath: string,
meta: RepoMeta,
repoName: string = 'test-repo',
): Promise<void> {
await saveMeta(storagePath, meta);
await registerRepo(repoPath, meta, { name: repoName });
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('context resource freshness — out-of-process analyze (#2438)', () => {
let tmpDir: Awaited<ReturnType<typeof createTempDir>>;
let repoPath: string;
let storagePath: string;
let savedHome: string | undefined;
beforeEach(async () => {
tmpDir = await createTempDir('gnx-ctx-staleness-');
repoPath = tmpDir.dbPath;
// Isolate the global registry from the developer's real ~/.gitnexus
savedHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = path.join(repoPath, '.gitnexus-home');
storagePath = getStoragePaths(repoPath).storagePath;
runGit(repoPath, 'init');
runGit(repoPath, 'config', 'user.name', 'GitNexus Test');
runGit(repoPath, 'config', 'user.email', 'gitnexus@example.com');
});
afterEach(async () => {
if (savedHome === undefined) delete process.env.GITNEXUS_HOME;
else process.env.GITNEXUS_HOME = savedHome;
await tmpDir.cleanup();
});
it('clears the staleness banner after out-of-process analyze updates gitnexus.json', async () => {
// ── STEP 1: Repository HEAD advances from C1 to C2 ───────────────────────
writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n');
runGit(repoPath, 'add', 'a.ts');
runGit(repoPath, 'commit', '-m', 'c1');
const c1 = runGit(repoPath, 'rev-parse', 'HEAD');
writeFileSync(path.join(repoPath, 'b.ts'), 'export const b = 2;\n');
runGit(repoPath, 'add', 'b.ts');
runGit(repoPath, 'commit', '-m', 'c2');
const c2 = runGit(repoPath, 'rev-parse', 'HEAD');
const oldStats = { files: 100, nodes: 500, processes: 10 };
const freshStats = { files: 120, nodes: 600, processes: 12 };
await seedIndexedRepo(repoPath, storagePath, {
repoPath,
lastCommit: c1,
indexedAt: '2024-01-01T00:00:00Z',
stats: oldStats,
});
const backend = new LocalBackend();
await backend.init();
// C1 is stale against current HEAD (C2)
const resultBefore = await readResource(`gitnexus://repo/test-repo/context`, backend);
expect(resultBefore).toContain('staleness:');
expect(resultBefore).toContain('1 commit behind');
// Stats reflect the old (C1-era) values from gitnexus.json
expect(resultBefore).toContain('files: 100');
expect(resultBefore).toContain('symbols: 500');
// ── STEP 3: Out-of-process analyze runs, updates gitnexus.json to C2 ───
// The MCP server (LocalBackend) is NOT restarted — this is the bug scenario.
await saveMeta(storagePath, {
repoPath,
lastCommit: c2,
indexedAt: new Date().toISOString(),
stats: freshStats,
});
// ── STEP 4: Re-read context resource WITHOUT restarting the MCP server ──
const resultAfter = await readResource(`gitnexus://repo/test-repo/context`, backend);
// Staleness banner MUST be gone — the fresh gitnexus.json has lastCommit = C2
expect(resultAfter).not.toContain('staleness:');
// Stats MUST be fresh — taken from the updated gitnexus.json
expect(resultAfter).toContain('files: 120');
expect(resultAfter).toContain('symbols: 600');
expect(resultAfter).toContain('processes: 12');
});
it('shows stale banner before analyze and clears it after — full reproduce sequence', async () => {
writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n');
runGit(repoPath, 'add', 'a.ts');
runGit(repoPath, 'commit', '-m', 'c1');
writeFileSync(path.join(repoPath, 'b.ts'), 'export const b = 2;\n');
runGit(repoPath, 'add', 'b.ts');
runGit(repoPath, 'commit', '-m', 'c2');
const c2 = runGit(repoPath, 'rev-parse', 'HEAD');
const stats = { files: 50, nodes: 200, processes: 5 };
await seedIndexedRepo(repoPath, storagePath, {
repoPath,
lastCommit: c2,
indexedAt: '2024-01-01T00:00:00Z',
stats,
});
const backend = new LocalBackend();
await backend.init();
// Pre-analyze: registry/meta are seeded at current HEAD (C2), so not stale
const r1 = await readResource(`gitnexus://repo/test-repo/context`, backend);
expect(r1).not.toContain('staleness:');
// New commit arrives; indexed commit (C2) is stale
writeFileSync(path.join(repoPath, 'c.ts'), 'export const c = 3;\n');
runGit(repoPath, 'add', 'c.ts');
runGit(repoPath, 'commit', '-m', 'c3');
const c3 = runGit(repoPath, 'rev-parse', 'HEAD');
const r2 = await readResource(`gitnexus://repo/test-repo/context`, backend);
expect(r2).toContain('staleness:');
expect(r2).toContain('1 commit behind');
// Out-of-process analyze --index-only completes; gitnexus.json updated to C3
const freshStats = { files: 60, nodes: 250, processes: 7 };
await saveMeta(storagePath, {
repoPath,
lastCommit: c3,
indexedAt: new Date().toISOString(),
stats: freshStats,
});
// Third read — MCP server still running, but context must reflect fresh state
const r3 = await readResource(`gitnexus://repo/test-repo/context`, backend);
expect(r3).not.toContain('staleness:'); // banner cleared
expect(r3).toContain('files: 60'); // fresh stats
expect(r3).toContain('symbols: 250');
expect(r3).toContain('processes: 7');
});
it('stat fields absent in disk meta fall through to cached context stats', async () => {
writeFileSync(path.join(repoPath, 'a.ts'), 'export const a = 1;\n');
runGit(repoPath, 'add', 'a.ts');
runGit(repoPath, 'commit', '-m', 'c1');
const c1 = runGit(repoPath, 'rev-parse', 'HEAD');
const oldStats = { files: 77, nodes: 333, processes: 4 };
await seedIndexedRepo(repoPath, storagePath, {
repoPath,
lastCommit: c1,
indexedAt: '2024-01-01T00:00:00Z',
stats: oldStats,
});
const backend = new LocalBackend();
await backend.init();
// Overwrite disk meta with NO stats (simulating an older/partial file)
await saveMeta(storagePath, {
repoPath,
lastCommit: c1,
indexedAt: new Date().toISOString(),
});
const result = await readResource(`gitnexus://repo/test-repo/context`, backend);
// Falls back to cached context stats (from registry entry)
expect(result).toContain('files: 77');
expect(result).toContain('symbols: 333');
expect(result).toContain('processes: 4');
});
});