GitNexus/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts

192 lines
7.5 KiB
TypeScript

/**
* Golden capture-parity test for `emitPythonScopeCaptures`.
*
* Pins the exact capture output of `emitPythonScopeCaptures` across the whole
* `test/fixtures/lang-resolution/python-*` corpus plus a synthetic DAO-style
* source, so any future drift in the Python scope-capture path fails CI rather
* than only being caught by a coarse perf tripwire or pipeline-level resolver
* tests.
*
* This is the correctness anchor for the O(n^2) -> O(n) rewrite of
* emitPythonScopeCaptures (threading the tree-sitter query's captured node
* instead of re-deriving it with findNodeAtRange from the tree root — the same
* fix shipped for Go in #1848). It is a FORWARD-DRIFT guard: it locks in the
* current verified output as the baseline.
*
* Regenerate the golden intentionally with `UPDATE_GOLDEN=1` in the environment.
*
* Per fixture the snapshot stores `{ captureGroups, digest }`:
* - captureGroups: number of capture matches (makes a count change legible)
* - digest: sha256 of a match-grouped, order-independent canonicalization.
* Nothing path/time/id-dependent leaks in.
*
* Pattern: mirrors test/unit/scope-resolution/go/go-captures-golden.test.ts.
*/
import { describe, it, expect } from 'vitest';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { emitPythonScopeCaptures } from '../../../../src/core/ingestion/languages/python/index.js';
import type { CaptureMatch } from 'gitnexus-shared';
// This test lives at test/unit/scope-resolution/python/, so fixtures are FOUR
// levels up.
const FIXTURE_ROOT = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'lang-resolution');
const GOLDEN_DIR = path.resolve(__dirname, '..', '..', '..', 'fixtures', 'python-captures-golden');
const GOLDEN_FILE = path.join(GOLDEN_DIR, 'expected-captures.json');
const UPDATE = process.env.UPDATE_GOLDEN === '1';
interface FixtureSnapshot {
captureGroups: number;
digest: string;
}
type Snapshot = Record<string, FixtureSnapshot>;
/**
* Canonicalize ONE match. A CaptureMatch is a Record<tag, Capture> (multiple
* captures per match), so we group by match to preserve match identity:
* build one `tag|text|startLine:startCol-endLine:endCol` string per capture,
* sort them within the match, and join. We deliberately do NOT flatten every
* capture into one global list — that would lose match boundaries.
*/
function canonicalizeMatch(match: CaptureMatch): string {
const parts: string[] = [];
for (const tag of Object.keys(match)) {
// Scope-only lexical shadow metadata has dedicated extraction/resolution
// tests. Exclude it from the historical capture fingerprint so adding
// untyped parameter barriers does not rebaseline every Python fixture.
if (tag === '@scope.lexical-names') continue;
const cap = match[tag]!;
const r = cap.range;
parts.push(`${tag}|${cap.text}|${r.startLine}:${r.startCol}-${r.endLine}:${r.endCol}`);
}
parts.sort();
return parts.join(';');
}
/** Order-independent digest of a full capture result (match-grouped). */
function digestCaptures(matches: readonly CaptureMatch[]): string {
const matchStrings = matches.map(canonicalizeMatch).sort();
return crypto.createHash('sha256').update(matchStrings.join('\n')).digest('hex');
}
function snapshotOf(src: string, filePath: string): FixtureSnapshot {
const matches = emitPythonScopeCaptures(src, filePath);
return { captureGroups: matches.length, digest: digestCaptures(matches) };
}
/** All `.py` files under `lang-resolution/python-*`, as sorted repo-relative-ish keys. */
function collectPythonFixtures(): { key: string; absPath: string }[] {
const out: { key: string; absPath: string }[] = [];
for (const entry of fs.readdirSync(FIXTURE_ROOT, { withFileTypes: true })) {
if (!entry.isDirectory() || !entry.name.startsWith('python-')) continue;
const stack = [path.join(FIXTURE_ROOT, entry.name)];
while (stack.length) {
const dir = stack.pop()!;
for (const c of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, c.name);
if (c.isDirectory()) stack.push(p);
else if (c.name.endsWith('.py')) {
out.push({ key: path.relative(FIXTURE_ROOT, p).split(path.sep).join('/'), absPath: p });
}
}
}
}
out.sort((a, b) => a.key.localeCompare(b.key));
return out;
}
/**
* Small deterministic generated-DAO source — exercises imports, class scopes,
* methods (@scope.function / @declaration.function / receiver binding), and
* module functions, the shape that stresses the scope-capture path at scale.
*/
function generateDao(entityCount: number): string {
const lines: string[] = [];
for (let i = 0; i < 4; i++) {
lines.push(`from pkg.mod${i} import alpha${i}, beta${i} as b${i}`);
lines.push(`import top.level.module${i}`);
}
lines.push('');
for (let i = 0; i < entityCount; i++) {
const n = String(i).padStart(4, '0');
lines.push(
`class Entity${n}:`,
` def __init__(self, id: int, name: str):`,
` self.id = id`,
` self.name = name`,
` def get_id(self) -> int:`,
` return self.id`,
` @classmethod`,
` def make(cls, id: int):`,
` return cls(id, "x")`,
'',
`def build_entity${n}(id: int) -> Entity${n}:`,
` return Entity${n}(id, "x")`,
'',
);
}
return lines.join('\n');
}
function buildSnapshot(): Snapshot {
const snap: Snapshot = {};
for (const { key, absPath } of collectPythonFixtures()) {
snap[key] = snapshotOf(fs.readFileSync(absPath, 'utf8'), absPath);
}
snap['synthetic:dao-20'] = snapshotOf(generateDao(20), 'zz_generated_dao.py');
// Stable key order for deterministic JSON serialization.
return Object.fromEntries(
Object.keys(snap)
.sort()
.map((k) => [k, snap[k]!]),
);
}
function formatGolden(snap: Snapshot): string {
return JSON.stringify(snap, null, 2) + '\n';
}
describe('Python scope captures — golden parity', () => {
it('matches the committed golden snapshot across all python-* fixtures + DAO shape', () => {
const snapshot = buildSnapshot();
if (UPDATE || !fs.existsSync(GOLDEN_FILE)) {
fs.mkdirSync(GOLDEN_DIR, { recursive: true });
fs.writeFileSync(GOLDEN_FILE, formatGolden(snapshot), 'utf8');
console.log(
`[python-captures-golden] ${UPDATE ? 'Regenerated' : 'Created'} golden at ${GOLDEN_FILE}`,
);
return;
}
const expected: Snapshot = JSON.parse(fs.readFileSync(GOLDEN_FILE, 'utf8'));
expect(
snapshot,
'emitPythonScopeCaptures output drifted from the committed golden. If this drift is ' +
'intentional, regenerate with UPDATE_GOLDEN=1 npx vitest run ' +
'test/unit/scope-resolution/python/python-captures-golden.test.ts',
).toEqual(expected);
});
it('produces a deterministic digest across repeated runs', () => {
const src = generateDao(8);
expect(digestCaptures(emitPythonScopeCaptures(src, 'a.py'))).toBe(
digestCaptures(emitPythonScopeCaptures(src, 'a.py')),
);
});
it('digest is independent of capture-match array order', () => {
const matches = emitPythonScopeCaptures(generateDao(6), 'a.py');
const reversed = [...matches].reverse();
expect(digestCaptures(reversed)).toBe(digestCaptures(matches));
});
it('records a capture-group count for every fixture and the DAO shape', () => {
const snapshot = buildSnapshot();
const fixtureKeys = collectPythonFixtures().map((f) => f.key);
for (const k of fixtureKeys) expect(snapshot[k]).toBeDefined();
expect(snapshot['synthetic:dao-20']!.captureGroups).toBeGreaterThan(0);
});
});