mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
Deterministic fix for the Windows-flaky pipeline-graph-golden test.
Root cause
cli-e2e.test.ts wrote into the SHARED fixture directory
(test/fixtures/mini-repo/) — git init, analyze run that creates
AGENTS.md, CLAUDE.md, .claude/, .gitnexus/. When pipeline-graph-golden
ran in parallel, its `cpSync` of the source directory could capture
the mid-flight pollution before cli-e2e's afterAll cleanup fired.
macOS/Ubuntu won the race often enough that the flake presented as
Windows-only.
Fix
cli-e2e now copies mini-repo into a fresh `mkdtemp`'d parent whose
basename is `mini-repo` (preserving `--repo mini-repo` CLI lookup by
basename), runs git-init there, and rm's the whole tmpdir in afterAll.
The shared fixture source is never touched.
Fallout from the cwd change: bare `--import tsx` specifiers (2
spawnSync + 1 spawn) can't resolve `tsx` from an os.tmpdir cwd where
there is no node_modules. Switched them to the already-existing
`tsxImportUrl` (absolute file:// URL to the tsx loader), matching
the `runCliOutsideProject` pattern that was already set up for this
exact case.
Updated the "MINI_REPO is inside the project tree" comment in the
`status on non-indexed repo` test — MINI_REPO is now in os.tmpdir,
so the rationale for using a separate throwaway tmp git repo is
different (but still valid: previous tests in the suite create
MINI_REPO/.gitnexus, which findRepo() would pick up).
Also updated pipeline-graph-golden's comment explaining WHY it
copies to tmp — it's now defense-in-depth rather than a necessity,
so a future test that adds files to the source can't silently
regress the golden.
Verification
- 5x consecutive `cli-e2e + pipeline-graph-golden` runs: 20/20 pass
(deterministic)
- 3x full suite including pipeline.test: 27/27 pass
- test/fixtures/mini-repo/ post-run contents: only `src/` —
zero pollution from any test
- macOS/Ubuntu behavior unchanged (they were passing; tmpdir
isolation is purely additive)
209 lines
7.8 KiB
TypeScript
209 lines
7.8 KiB
TypeScript
/**
|
||
* Golden-file graph-parity test.
|
||
*
|
||
* Runs the full ingestion pipeline on the `mini-repo` fixture and compares
|
||
* the resulting graph against a committed golden JSON. Guards against silent
|
||
* behavioural drift from future refactors (post-U1–U7).
|
||
*
|
||
* Regenerate the golden file intentionally by running the test with
|
||
* `UPDATE_GOLDEN=1` in the environment.
|
||
*
|
||
* The snapshot captures:
|
||
* - totalFileCount
|
||
* - symbols (node count) and relationships (edge count)
|
||
* - byType: sorted map of NodeLabel -> count
|
||
* - byRelType: sorted map of RelationshipType -> count
|
||
* - processes count
|
||
* - edgeDigest: sha256 of deterministically-sorted `"type|src|dst"` strings
|
||
*
|
||
* Nothing path-dependent, time-dependent, or id-opaque leaks into the snapshot.
|
||
*/
|
||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
import os from 'os';
|
||
import crypto from 'crypto';
|
||
import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js';
|
||
import type { PipelineResult } from '../../src/types/pipeline.js';
|
||
|
||
const FIXTURE_SRC = path.resolve(__dirname, '..', 'fixtures', 'mini-repo');
|
||
const GOLDEN_DIR = path.resolve(__dirname, '..', 'fixtures', 'pipeline-golden', 'mini-repo');
|
||
const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-graph.json');
|
||
|
||
const UPDATE = process.env.UPDATE_GOLDEN === '1';
|
||
|
||
interface GraphSnapshot {
|
||
/**
|
||
* Tag to clarify provenance and to prime reviewers when the file first
|
||
* lands. Rewriting this string has no semantic meaning — only drift of
|
||
* the other fields matters for test pass/fail.
|
||
*/
|
||
capture: string;
|
||
fixture: string;
|
||
totalFileCount: number;
|
||
symbols: number;
|
||
relationships: number;
|
||
processes: number;
|
||
byType: Record<string, number>;
|
||
byRelType: Record<string, number>;
|
||
edgeDigest: string;
|
||
}
|
||
|
||
/**
|
||
* Build a deterministic snapshot from a pipeline result.
|
||
*
|
||
* All maps are sorted by key so JSON serialization is stable. Edge digest
|
||
* is a sha256 over newline-joined, lexicographically-sorted `type|src|dst`
|
||
* triples — where `src` and `dst` are symbolic names (node label + property
|
||
* name + filePath for files) so that id generation changes don't cause
|
||
* spurious digest churn if they don't change the semantic edge set.
|
||
*/
|
||
function buildSnapshot(result: PipelineResult): GraphSnapshot {
|
||
const byType: Record<string, number> = {};
|
||
const byRelType: Record<string, number> = {};
|
||
|
||
// Node id -> stable symbolic key for digest
|
||
const nodeKey = new Map<string, string>();
|
||
|
||
result.graph.forEachNode((n) => {
|
||
byType[n.label] = (byType[n.label] ?? 0) + 1;
|
||
const props = n.properties as Record<string, unknown>;
|
||
const fp = (props.filePath as string | undefined) ?? '';
|
||
const nm = (props.name as string | undefined) ?? '';
|
||
// Symbolic key: stable across id-format refactors as long as the
|
||
// (label, name, filePath) tuple is unchanged.
|
||
nodeKey.set(n.id, `${n.label}:${nm}@${fp}`);
|
||
});
|
||
|
||
const edgeTriples: string[] = [];
|
||
for (const rel of result.graph.iterRelationships()) {
|
||
byRelType[rel.type] = (byRelType[rel.type] ?? 0) + 1;
|
||
const src = nodeKey.get(rel.sourceId) ?? `?:${rel.sourceId}`;
|
||
const dst = nodeKey.get(rel.targetId) ?? `?:${rel.targetId}`;
|
||
// step included for STEP_IN_PROCESS so the digest captures ordering
|
||
const step = rel.step !== undefined ? `#${rel.step}` : '';
|
||
edgeTriples.push(`${rel.type}${step}|${src}|${dst}`);
|
||
}
|
||
edgeTriples.sort();
|
||
|
||
const digest = crypto.createHash('sha256').update(edgeTriples.join('\n')).digest('hex');
|
||
|
||
return {
|
||
capture: 'initial capture (U8, post-U1–U7)',
|
||
fixture: 'mini-repo',
|
||
totalFileCount: result.totalFileCount,
|
||
symbols: result.graph.nodeCount,
|
||
relationships: result.graph.relationshipCount,
|
||
processes: result.processResult?.stats.totalProcesses ?? 0,
|
||
byType: sortObject(byType),
|
||
byRelType: sortObject(byRelType),
|
||
edgeDigest: digest,
|
||
};
|
||
}
|
||
|
||
function sortObject(obj: Record<string, number>): Record<string, number> {
|
||
const out: Record<string, number> = {};
|
||
for (const k of Object.keys(obj).sort()) out[k] = obj[k];
|
||
return out;
|
||
}
|
||
|
||
function formatGolden(snapshot: GraphSnapshot): string {
|
||
return JSON.stringify(snapshot, null, 2) + '\n';
|
||
}
|
||
|
||
function diffCounts(
|
||
label: string,
|
||
actual: Record<string, number>,
|
||
expected: Record<string, number>,
|
||
): string[] {
|
||
const lines: string[] = [];
|
||
const keys = new Set([...Object.keys(actual), ...Object.keys(expected)]);
|
||
for (const k of [...keys].sort()) {
|
||
const a = actual[k] ?? 0;
|
||
const e = expected[k] ?? 0;
|
||
if (a !== e) lines.push(` ${label}.${k}: expected ${e}, got ${a}`);
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
describe('pipeline graph golden', () => {
|
||
let result: PipelineResult;
|
||
let snapshot: GraphSnapshot;
|
||
let tmpDir: string;
|
||
|
||
beforeAll(async () => {
|
||
// Copy the fixture to a temp directory as defense-in-depth against
|
||
// parallel tests writing into the shared fixture source. cli-e2e
|
||
// was the historical offender — it now copies to its own tmpdir
|
||
// (see test/integration/cli-e2e.test.ts `beforeAll`) — but this
|
||
// cpSync stays as a belt-and-suspenders guarantee that any future
|
||
// test adding files to the source won't pollute the golden snapshot.
|
||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-golden-'));
|
||
fs.cpSync(FIXTURE_SRC, tmpDir, { recursive: true });
|
||
|
||
result = await runPipelineFromRepo(tmpDir, () => {});
|
||
snapshot = buildSnapshot(result);
|
||
}, 60000);
|
||
|
||
afterAll(() => {
|
||
if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true });
|
||
});
|
||
|
||
it('matches committed golden snapshot on mini-repo', () => {
|
||
if (UPDATE || !fs.existsSync(GOLDEN_FILE)) {
|
||
fs.mkdirSync(GOLDEN_DIR, { recursive: true });
|
||
fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8');
|
||
// First run / intentional regen: succeed and report.
|
||
// Subsequent CI runs without UPDATE_GOLDEN will diff against this file.
|
||
console.log(
|
||
`[pipeline-graph-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden file at ${GOLDEN_FILE}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const rawExpected = fs.readFileSync(GOLDEN_FILE, 'utf8');
|
||
const expected = JSON.parse(rawExpected) as GraphSnapshot;
|
||
|
||
const diffs: string[] = [];
|
||
if (snapshot.totalFileCount !== expected.totalFileCount) {
|
||
diffs.push(
|
||
` totalFileCount: expected ${expected.totalFileCount}, got ${snapshot.totalFileCount}`,
|
||
);
|
||
}
|
||
if (snapshot.symbols !== expected.symbols) {
|
||
diffs.push(` symbols (nodeCount): expected ${expected.symbols}, got ${snapshot.symbols}`);
|
||
}
|
||
if (snapshot.relationships !== expected.relationships) {
|
||
diffs.push(
|
||
` relationships (edgeCount): expected ${expected.relationships}, got ${snapshot.relationships}`,
|
||
);
|
||
}
|
||
if (snapshot.processes !== expected.processes) {
|
||
diffs.push(` processes: expected ${expected.processes}, got ${snapshot.processes}`);
|
||
}
|
||
diffs.push(...diffCounts('byType', snapshot.byType, expected.byType));
|
||
diffs.push(...diffCounts('byRelType', snapshot.byRelType, expected.byRelType));
|
||
if (snapshot.edgeDigest !== expected.edgeDigest) {
|
||
diffs.push(
|
||
` edgeDigest changed: the set of (type, source-symbol, target-symbol) edge triples differs from golden. ` +
|
||
`Counts may match while edges are rewired — inspect graph manually or re-run with UPDATE_GOLDEN=1 if the change is intentional.`,
|
||
);
|
||
}
|
||
|
||
if (diffs.length > 0) {
|
||
const msg = [
|
||
'Pipeline graph output drifted from golden snapshot.',
|
||
`Golden file: ${GOLDEN_FILE}`,
|
||
'Changes:',
|
||
...diffs,
|
||
'',
|
||
'If this drift is intentional, regenerate the golden file with:',
|
||
' UPDATE_GOLDEN=1 npm --prefix gitnexus test -- pipeline-graph-golden',
|
||
].join('\n');
|
||
throw new Error(msg);
|
||
}
|
||
|
||
// Belt-and-suspenders: if nothing diffed, the serialized forms should match too.
|
||
expect(formatGolden(snapshot)).toBe(rawExpected);
|
||
});
|
||
});
|