mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-28 06:22:22 +00:00
438 lines
18 KiB
TypeScript
438 lines
18 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import { readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { setupMiniRepo } from '../helpers/mini-repo.js';
|
|
import { createTempDir } from '../helpers/test-db.js';
|
|
import { analyzeRuntime, copyAnalysisRuntime } from '../helpers/parse-identity-runtime.js';
|
|
|
|
async function fixture() {
|
|
const artifacts = await createTempDir();
|
|
const repo = await setupMiniRepo();
|
|
const runtime = await copyAnalysisRuntime(artifacts.dbPath);
|
|
await writeFile(
|
|
path.join(repo.dbPath, 'src/probe.ts'),
|
|
'export function parserProbe(value: number) { return value + 1; }\n',
|
|
);
|
|
execFileSync('git', ['add', '-A'], { cwd: repo.dbPath });
|
|
execFileSync(
|
|
'git',
|
|
[
|
|
'-c',
|
|
'user.name=test',
|
|
'-c',
|
|
'user.email=t@t',
|
|
'-c',
|
|
'commit.gpgsign=false',
|
|
'commit',
|
|
'-qm',
|
|
'probe',
|
|
],
|
|
{ cwd: repo.dbPath },
|
|
);
|
|
return {
|
|
runtime,
|
|
repoPath: repo.dbPath,
|
|
home: path.join(artifacts.dbPath, 'home'),
|
|
sibling: path.join(artifacts.dbPath, 'sibling'),
|
|
run: (request: Parameters<typeof analyzeRuntime>[3] = {}) =>
|
|
analyzeRuntime(runtime, repo.dbPath, path.join(artifacts.dbPath, 'home'), {
|
|
source: true,
|
|
...request,
|
|
}),
|
|
cleanup: async () => {
|
|
await repo.cleanup();
|
|
await artifacts.cleanup();
|
|
},
|
|
};
|
|
}
|
|
|
|
interface ParseBarrier {
|
|
storagePath: string;
|
|
cacheRoot: string;
|
|
stagingPaths: string[];
|
|
}
|
|
|
|
async function waitForBarrier(barrier: string): Promise<ParseBarrier> {
|
|
await expect.poll(() => existsSync(`${barrier}.ready`), { timeout: 90_000 }).toBe(true);
|
|
return JSON.parse(await readFile(`${barrier}.ready`, 'utf8'));
|
|
}
|
|
|
|
async function releaseBarrier(barrier: string): Promise<void> {
|
|
await writeFile(`${barrier}.release`, 'continue');
|
|
}
|
|
|
|
async function cacheKeys(cacheRoot: string, directory: string): Promise<string[]> {
|
|
const index = JSON.parse(await readFile(path.join(cacheRoot, directory, 'index.json'), 'utf8'));
|
|
return directory === 'parse-cache' ? index.keys : Object.keys(index.entries);
|
|
}
|
|
|
|
describe('parser identity across real analysis processes', () => {
|
|
it('keeps unverifiable rebuilt parser facts private at an unchanged shared commit', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
execFileSync('git', ['worktree', 'add', '-q', '-b', 'sibling', f.sibling], {
|
|
cwd: f.repoPath,
|
|
});
|
|
await rm(path.join(f.runtime, 'dist/core/analyzer-identity.js'));
|
|
const a = await f.run({ sharedStore: true });
|
|
expect(a.error).toBeUndefined();
|
|
expect(a.meta?.parseCacheIdentity).toBeUndefined();
|
|
expect(a.facts).toContainEqual({ kind: 'Function', name: 'parserProbe' });
|
|
const query = path.join(f.runtime, 'dist/core/ingestion/tree-sitter-queries.js');
|
|
const original = await readFile(query, 'utf8');
|
|
const modified = original.replace(
|
|
'(function_declaration\n name: (identifier) @name) @definition.function',
|
|
'(function_declaration\n name: (identifier) @name) @definition.class',
|
|
);
|
|
expect(modified).not.toBe(original);
|
|
await writeFile(query, modified);
|
|
const b = await analyzeRuntime(f.runtime, f.sibling, f.home, {
|
|
source: true,
|
|
sharedStore: true,
|
|
});
|
|
expect(b.error).toBeUndefined();
|
|
expect(b.meta?.lastCommit).toBe(a.meta?.lastCommit);
|
|
expect(b.meta?.runnerIdentity?.build.digest).toBe(a.meta?.runnerIdentity?.build.digest);
|
|
expect(b.meta?.parseCacheIdentity).toBeUndefined();
|
|
expect(b.facts).toContainEqual({ kind: 'Class', name: 'parserProbe' });
|
|
expect(b.facts).not.toContainEqual({ kind: 'Function', name: 'parserProbe' });
|
|
expect(a.meta?.graphPath).toBeUndefined();
|
|
expect(b.meta?.graphPath).toBeUndefined();
|
|
} finally {
|
|
await f.cleanup();
|
|
}
|
|
}, 180_000);
|
|
|
|
it('retains compatible keys published by a sibling after a regular run loaded its cache', async () => {
|
|
const f = await fixture();
|
|
const barrier = path.join(f.home, 'regular-publication');
|
|
let pending: ReturnType<typeof f.run> | undefined;
|
|
try {
|
|
execFileSync('git', ['worktree', 'add', '-q', '-b', 'sibling', f.sibling], {
|
|
cwd: f.repoPath,
|
|
});
|
|
const initial = await f.run({ sharedStore: true });
|
|
expect(initial.error).toBeUndefined();
|
|
pending = f.run({ sharedStore: true, options: { force: true }, barrier });
|
|
const paused = await waitForBarrier(barrier);
|
|
await writeFile(
|
|
path.join(f.sibling, 'src/sibling.ts'),
|
|
'export function siblingOnly() { return 42; }\n',
|
|
);
|
|
const b = await analyzeRuntime(f.runtime, f.sibling, f.home, {
|
|
source: true,
|
|
sharedStore: true,
|
|
});
|
|
expect(b.error).toBeUndefined();
|
|
const newKeys = (b.meta?.cacheKeys ?? []).filter(
|
|
(key) => !initial.meta?.cacheKeys?.includes(key),
|
|
);
|
|
expect(newKeys.length).toBeGreaterThan(0);
|
|
await releaseBarrier(barrier);
|
|
const a = await pending;
|
|
expect(a.error).toBeUndefined();
|
|
for (const directory of ['parse-cache', 'parsedfile-cache']) {
|
|
const saved = await cacheKeys(paused.cacheRoot, directory);
|
|
for (const key of newKeys) expect(saved).toContain(key);
|
|
}
|
|
const warm = await analyzeRuntime(f.runtime, f.sibling, f.home, {
|
|
source: true,
|
|
sharedStore: true,
|
|
options: { force: true },
|
|
});
|
|
expect(warm.error).toBeUndefined();
|
|
expect(warm.reparsed).toBe(0);
|
|
expect(warm.hits).toBe((b.hits ?? 0) + (b.reparsed ?? 0));
|
|
} finally {
|
|
await releaseBarrier(barrier);
|
|
await pending;
|
|
await f.cleanup();
|
|
}
|
|
}, 240_000);
|
|
|
|
it('reclaims interrupted parser staging while preserving an active sibling run', async () => {
|
|
const f = await fixture();
|
|
const interruptedBarrier = path.join(f.home, 'interrupted');
|
|
const activeBarrier = path.join(f.home, 'active-sibling');
|
|
let pending: ReturnType<typeof f.run> | undefined;
|
|
try {
|
|
execFileSync('git', ['worktree', 'add', '-q', '-b', 'sibling', f.sibling], {
|
|
cwd: f.repoPath,
|
|
});
|
|
const interrupted = await f.run({
|
|
sharedStore: true,
|
|
barrier: interruptedBarrier,
|
|
interruptAfterParse: true,
|
|
});
|
|
expect(interrupted.interrupted).toBe(true);
|
|
const orphan = await waitForBarrier(interruptedBarrier);
|
|
expect(orphan.stagingPaths).toHaveLength(1);
|
|
expect((await readdir(orphan.stagingPaths[0])).length).toBeGreaterThan(0);
|
|
pending = analyzeRuntime(f.runtime, f.sibling, f.home, {
|
|
source: true,
|
|
sharedStore: true,
|
|
barrier: activeBarrier,
|
|
});
|
|
const active = await waitForBarrier(activeBarrier);
|
|
const siblingStages = active.stagingPaths.filter(
|
|
(stage) => !orphan.stagingPaths.includes(stage),
|
|
);
|
|
expect(siblingStages).toHaveLength(1);
|
|
const before = await readdir(siblingStages[0]);
|
|
const recovered = await f.run({ sharedStore: true });
|
|
expect(recovered.error).toBeUndefined();
|
|
for (const stage of orphan.stagingPaths) expect(existsSync(stage)).toBe(false);
|
|
expect(await readdir(siblingStages[0])).toEqual(before);
|
|
await releaseBarrier(activeBarrier);
|
|
const sibling = await pending;
|
|
expect(sibling.error).toBeUndefined();
|
|
expect(sibling.facts).toEqual(recovered.facts);
|
|
for (const stage of siblingStages) expect(existsSync(stage)).toBe(false);
|
|
} finally {
|
|
await releaseBarrier(activeBarrier);
|
|
await pending;
|
|
await f.cleanup();
|
|
}
|
|
}, 240_000);
|
|
|
|
it('keeps two parser identities isolated while their shared-store runs overlap', async () => {
|
|
const f = await fixture();
|
|
const barrier = path.join(f.home, 'identity-overlap');
|
|
let pending: ReturnType<typeof f.run> | undefined;
|
|
try {
|
|
execFileSync('git', ['worktree', 'add', '-q', '-b', 'sibling', f.sibling], {
|
|
cwd: f.repoPath,
|
|
});
|
|
const runtimeB = await copyAnalysisRuntime(path.join(path.dirname(f.runtime), 'runtime-b'));
|
|
const query = path.join(runtimeB, 'dist/core/ingestion/tree-sitter-queries.js');
|
|
const original = await readFile(query, 'utf8');
|
|
const modified = original.replace(
|
|
'(function_declaration\n name: (identifier) @name) @definition.function',
|
|
'(function_declaration\n name: (identifier) @name) @definition.class',
|
|
);
|
|
expect(modified).not.toBe(original);
|
|
await writeFile(query, modified);
|
|
pending = f.run({ sharedStore: true, barrier });
|
|
const paused = await waitForBarrier(barrier);
|
|
const b = await analyzeRuntime(runtimeB, f.sibling, f.home, {
|
|
source: true,
|
|
sharedStore: true,
|
|
});
|
|
expect(b.error).toBeUndefined();
|
|
await releaseBarrier(barrier);
|
|
const a = await pending;
|
|
expect(a.error).toBeUndefined();
|
|
expect(a.meta?.parseCacheIdentity).not.toEqual(b.meta?.parseCacheIdentity);
|
|
expect(a.meta?.graphPath).not.toBe(b.meta?.graphPath);
|
|
expect(a.facts).toEqual([{ kind: 'Function', name: 'parserProbe' }]);
|
|
expect(b.facts).toEqual([{ kind: 'Class', name: 'parserProbe' }]);
|
|
const aWarm = await f.run({ sharedStore: true, options: { force: true } });
|
|
expect(aWarm.error).toBeUndefined();
|
|
expect(aWarm.reparsed).toBe(0);
|
|
expect(aWarm.facts).toEqual(a.facts);
|
|
const bCold = await analyzeRuntime(runtimeB, f.sibling, f.home, {
|
|
source: true,
|
|
sharedStore: true,
|
|
options: { force: true },
|
|
});
|
|
expect(bCold.error).toBeUndefined();
|
|
expect(bCold.hits).toBe(0);
|
|
expect(bCold.reparsed).toBeGreaterThan(0);
|
|
expect(bCold.facts).toEqual(b.facts);
|
|
for (const directory of ['parse-cache', 'parsedfile-cache']) {
|
|
expect((await cacheKeys(paused.cacheRoot, directory)).sort()).toEqual(
|
|
[...bCold.meta!.cacheKeys!].sort(),
|
|
);
|
|
}
|
|
} finally {
|
|
await releaseBarrier(barrier);
|
|
await pending;
|
|
await f.cleanup();
|
|
}
|
|
}, 240_000);
|
|
|
|
it('invalidates source edits before rebuild, rebuilt worker facts, and compiled shared inputs', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const cold = await f.run();
|
|
expect(cold.error).toBeUndefined();
|
|
expect(cold.reparsed).toBeGreaterThan(0);
|
|
expect(cold.facts).toContainEqual({ kind: 'Function', name: 'parserProbe' });
|
|
const warm = await f.run({ options: { force: true } });
|
|
expect(warm.error).toBeUndefined();
|
|
expect(warm.reparsed).toBe(0);
|
|
expect(warm.hits).toBe(cold.reparsed);
|
|
expect(warm.facts).toEqual(cold.facts);
|
|
expect((await f.run()).alreadyUpToDate).toBe(true);
|
|
|
|
const query = path.join(f.runtime, 'src/core/ingestion/tree-sitter-queries.ts');
|
|
const original = await readFile(query, 'utf8');
|
|
const modified = original.replace(
|
|
'(function_declaration\n name: (identifier) @name) @definition.function',
|
|
'(function_declaration\n name: (identifier) @name) @definition.class',
|
|
);
|
|
expect(modified).not.toBe(original);
|
|
await writeFile(query, modified);
|
|
const oldWorker = await f.run();
|
|
expect(oldWorker.error).toBeUndefined();
|
|
expect(oldWorker.alreadyUpToDate).toBe(false);
|
|
expect(oldWorker.rebuildReasons).toContain('parser-identity');
|
|
expect(oldWorker.hits).toBe(0);
|
|
expect(oldWorker.meta?.parseCacheIdentity?.base).not.toBe(
|
|
cold.meta?.parseCacheIdentity?.base,
|
|
);
|
|
// Source edits invalidate immediately; they do not compile the worker.
|
|
expect(oldWorker.facts).toEqual(cold.facts);
|
|
|
|
const rebuild = () =>
|
|
execFileSync(process.execPath, ['scripts/build.js'], {
|
|
cwd: f.runtime,
|
|
stdio: 'pipe',
|
|
timeout: 120_000,
|
|
});
|
|
rebuild();
|
|
const rebuilt = await f.run();
|
|
expect(rebuilt.error).toBeUndefined();
|
|
expect(rebuilt.hits).toBe(0);
|
|
expect(rebuilt.meta?.parseCacheIdentity?.base).not.toBe(
|
|
oldWorker.meta?.parseCacheIdentity?.base,
|
|
);
|
|
expect(rebuilt.facts).toContainEqual({ kind: 'Class', name: 'parserProbe' });
|
|
expect(rebuilt.facts).not.toContainEqual({ kind: 'Function', name: 'parserProbe' });
|
|
|
|
// A -> B -> A shares a physical cache root, but cannot admit B's facts as A.
|
|
await writeFile(query, original);
|
|
rebuild();
|
|
const restored = await f.run();
|
|
expect(restored.error).toBeUndefined();
|
|
expect(restored.hits).toBe(0);
|
|
expect(restored.meta?.parseCacheIdentity).toEqual(cold.meta?.parseCacheIdentity);
|
|
expect(restored.facts).toEqual(cold.facts);
|
|
|
|
const shared = path.join(f.runtime, 'dist/_shared/index.js');
|
|
await writeFile(shared, `${await readFile(shared, 'utf8')}\n// rebuilt shared helper\n`);
|
|
const changedShared = await f.run();
|
|
expect(changedShared.error).toBeUndefined();
|
|
expect(changedShared.hits).toBe(0);
|
|
expect(changedShared.meta?.parseCacheIdentity).not.toEqual(restored.meta?.parseCacheIdentity);
|
|
expect(changedShared.facts).toEqual(cold.facts);
|
|
} finally {
|
|
await f.cleanup();
|
|
}
|
|
}, 300_000);
|
|
|
|
it('isolates two real processes sharing worktree storage at the same commit', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
execFileSync('git', ['worktree', 'add', '-q', '-b', 'sibling', f.sibling], {
|
|
cwd: f.repoPath,
|
|
});
|
|
const a = await f.run({ sharedStore: true });
|
|
expect(a.error).toBeUndefined();
|
|
expect(a.meta?.graphPath).toBeTruthy();
|
|
const query = path.join(f.runtime, 'dist/core/ingestion/tree-sitter-queries.js');
|
|
const original = await readFile(query, 'utf8');
|
|
await writeFile(
|
|
query,
|
|
original.replace(
|
|
'(function_declaration\n name: (identifier) @name) @definition.function',
|
|
'(function_declaration\n name: (identifier) @name) @definition.class',
|
|
),
|
|
);
|
|
const b = await analyzeRuntime(f.runtime, f.sibling, f.home, {
|
|
source: true,
|
|
sharedStore: true,
|
|
});
|
|
expect(b.error).toBeUndefined();
|
|
expect(b.alreadyUpToDate).toBe(false);
|
|
expect(b.hits).toBe(0);
|
|
expect(b.meta?.runnerIdentity?.build.digest).toBe(a.meta?.runnerIdentity?.build.digest);
|
|
expect(b.meta?.parseCacheIdentity).not.toEqual(a.meta?.parseCacheIdentity);
|
|
expect(b.meta?.graphPath).not.toBe(a.meta?.graphPath);
|
|
expect(b.facts).toContainEqual({ kind: 'Class', name: 'parserProbe' });
|
|
expect(b.facts).not.toContainEqual({ kind: 'Function', name: 'parserProbe' });
|
|
await writeFile(query, original);
|
|
const returned = await f.run({ sharedStore: true, options: { force: true } });
|
|
expect(returned.error).toBeUndefined();
|
|
expect(returned.hits).toBe(0);
|
|
expect(returned.meta?.parseCacheIdentity).toEqual(a.meta?.parseCacheIdentity);
|
|
expect(returned.facts).toEqual(a.facts);
|
|
} finally {
|
|
await f.cleanup();
|
|
}
|
|
}, 180_000);
|
|
|
|
it('rejects a compiled-worker mutation before graph/cache publication', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const initial = await f.run();
|
|
expect(initial.error).toBeUndefined();
|
|
const indexes = ['parse-cache/index.json', 'parsedfile-cache/index.json'];
|
|
const before = await Promise.all(
|
|
indexes.map((file) => readFile(path.join(initial.storagePath, file), 'utf8')),
|
|
);
|
|
const failed = await f.run({
|
|
options: { force: true, useParseCache: false },
|
|
mutate: path.join(f.runtime, 'dist/core/ingestion/tree-sitter-queries.js'),
|
|
});
|
|
expect(failed.error).toMatch(/Parser build changed during analysis/i);
|
|
expect(failed.meta?.indexedAt).toBe(initial.meta?.indexedAt);
|
|
expect(
|
|
await Promise.all(
|
|
indexes.map((file) => readFile(path.join(initial.storagePath, file), 'utf8')),
|
|
),
|
|
).toEqual(before);
|
|
expect(
|
|
(await readdir(initial.storagePath)).filter((name) => name.startsWith('parse-rebuild')),
|
|
).toEqual([]);
|
|
const recovered = await f.run();
|
|
expect(recovered.error).toBeUndefined();
|
|
expect(recovered.reparsed).toBeGreaterThan(0);
|
|
expect(recovered.facts).toEqual(initial.facts);
|
|
} finally {
|
|
await f.cleanup();
|
|
}
|
|
}, 180_000);
|
|
|
|
it('runs uncached when identity is unverifiable and recovers from a later cache-save failure', async () => {
|
|
const f = await fixture();
|
|
try {
|
|
const initial = await f.run();
|
|
expect(initial.error).toBeUndefined();
|
|
const index = path.join(initial.storagePath, 'parse-cache/index.json');
|
|
const previousIndex = await readFile(index, 'utf8');
|
|
// Source analysis does not execute this compiled coordinator, but it is
|
|
// required evidence for the worker artifact tree. Parsing still works.
|
|
const absent = path.join(f.runtime, 'dist/core/analyzer-identity.js');
|
|
const original = await readFile(absent);
|
|
await rm(absent);
|
|
const uncached = await f.run({ options: { force: true } });
|
|
expect(uncached.error).toBeUndefined();
|
|
expect(uncached.hits).toBe(0);
|
|
expect(uncached.reparsed).toBe(initial.reparsed);
|
|
expect(uncached.meta?.parseCacheIdentity).toBeUndefined();
|
|
expect(uncached.facts).toEqual(initial.facts);
|
|
expect(await readFile(index, 'utf8')).toBe(previousIndex);
|
|
await writeFile(absent, original);
|
|
|
|
const failedSave = await f.run({
|
|
options: { force: true, useParseCache: false },
|
|
failCacheSave: true,
|
|
});
|
|
expect(failedSave.error).toBeUndefined();
|
|
expect(failedSave.facts).toEqual(initial.facts);
|
|
expect(failedSave.logs.join('\n')).toMatch(/could not save parse cache/i);
|
|
expect(failedSave.meta?.parseCacheIdentity).toEqual(initial.meta?.parseCacheIdentity);
|
|
await rm(path.join(initial.storagePath, 'parsedfile-cache/index.json'), { recursive: true });
|
|
const retry = await f.run({ options: { force: true } });
|
|
expect(retry.error).toBeUndefined();
|
|
expect(retry.hits).toBe(0);
|
|
expect(retry.reparsed).toBe(initial.reparsed);
|
|
expect(retry.facts).toEqual(initial.facts);
|
|
} finally {
|
|
await f.cleanup();
|
|
}
|
|
}, 180_000);
|
|
});
|