GitNexus/gitnexus/test/unit/scope-resolution/python/python-captures-golden.test.ts
Gergő Magyar 999cc9f913
fix(deps): upgrade native tree-sitter to 0.25.1 (#3531)
* fix(deps): upgrade native tree-sitter to 0.25.1

* fix(tree-sitter): preserve sync loading and update verified capture baselines

Route C# scope queries through the synchronous native grammar loader so the compiled analyze module remains require-compatible on Node 22.18. Refresh only the audited empty-Python-module and renamed-PHP-fixture captures, and pin the runtime assertion to 0.25.1.

Validated 479 targeted tests, all 16 scope-capture benchmark cases, Python capture/import guards, compiled C# worker parsing, build, typecheck and formatting. Performance budgets are unchanged.

* fix(tree-sitter): refresh upstream vendors and preserve parser semantics

* fix(ci): refresh prebuilds and support Windows ARM

Refresh macOS, Linux ARM, and Windows x64 grammar binaries from the successful native CI builds. Pin node-gyp 12.4.0 so the Windows ARM jobs recognize Visual Studio 2026.

Use local Git filtering for E2E to avoid PR-files API timeouts on generated parser diffs.

Validation: 143 focused tests, Actionlint, and all 16 scope-capture benchmark checks passed. Full npm test run started; native database suites still running.

* fix(ci): refresh Windows ARM grammar prebuilds

Import all seven Windows ARM grammar artifacts from successful native build run 37965761760. Artifact filenames and SHA-256 hashes verified; all 42 native build jobs passed.

Validation: 31 prebuild coverage tests passed.

* fix(bench): stabilize scope capture timing guard

Warm each input size seven times and use 21 median samples while retaining isolated timing blocks. Yield between languages so native finalizers release allocations outside the measured sections. Keep all fixture sizes, fingerprints, scaling formulas, and budgets unchanged.

Validation: all 16 language guards passed with a 2 GB heap limit (1.38 deep-chain ratio; 1518 MB peak RSS). Linear and quadratic controls passed their expected guard outcomes. Formatting and graph change analysis passed. Full npm test is running without failures so far.

* fix(parser): reduce TypeScript capture traversal cost

* fix(test): await parser worker readiness before teardown

* fix(parser): load C# synchronously in workers

* fix(grpc): resolve native proto detections against their own file

With the native proto grammar active, sibling .proto files that declare
the same short service name under different packages tied in the
directory-based resolveProtoConflict heuristic and both providers were
dropped. A proto-origin detection already knows its defining file, so
use that file's entry before falling back to the heuristic.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci(packaging): assert native grammars load from the installed tarball

The packaged-install smoke only ran `gitnexus --version`, so a grammar
that failed to load from the published package (bundled runtime, patched
peer manifests, vendored prebuilds, C# sync loader, Protobuf wrapper)
would ship silently; the dev-tree ABI smoke also accepts vendored
grammars that report unavailable. Parse a sample for every registered
grammar plus the native Protobuf plugin from the installed package, on
the main thread and in a worker thread.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* ci(tree-sitter): track compatibility workarounds instead of 0.25 readiness

The daily readiness report still targeted tree-sitter@0.25.0, so after the
upgrade it would keep reporting "Blocked" and never close its tracking issue.
Repurpose it to track what we carry for the bundled runtime: derive the target
from the package.json pin, list each pack-time peer patch from
prepare-tree-sitter-bundle.cjs with its drop condition (bump, wait for an
upstream release, transitive, or intentionally pinned), and reword the
buckets. The workflow adopts the issue opened under the old title.

Also update the grammar-update-monitor header and auto-PR text, which still
described the 0.21.1 runtime and a tree-sitter-c hold.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(dart): assert both nested labels target the loop; fix Zig call-shape doc

The nested-label CFG test only jumped to `outer` and checked reachability,
which still passes when a jump is retargeted to the inner loop (its exit
reaches the outer header). Jump to both labels and assert the exact
`continue` edges; dropping the inner label now fails the test.

Update the Zig callable-flow comment: call_expression now wraps actuals in a
required `arguments:` field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Codex <codex@openai.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 13:07:59 +03:00

283 lines
11 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 (
[
'@scope.lexical-names',
'@scope.name-claims',
'@scope.lookup-policy',
'@binding.scope',
'@reference.lookup-scope',
'@reference.caller-scope',
'@type.lookup-scope',
].includes(tag)
)
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) };
}
function callArity(src: string, name: string): string | undefined {
const match = emitPythonScopeCaptures(src, 'arity.py').find(
(candidate) =>
candidate['@reference.name']?.text === name &&
(candidate['@reference.call.free'] !== undefined ||
candidate['@reference.call.member'] !== undefined),
);
if (!match) throw new Error(`Missing call capture for ${name}`);
return match['@reference.arity']?.text;
}
function receiverBindingNames(src: string): string[] {
return emitPythonScopeCaptures(src, 'receiver.py')
.filter(
(match) =>
match['@type-binding.self'] !== undefined || match['@type-binding.cls'] !== undefined,
)
.map((match) => match['@type-binding.name']!.text)
.sort();
}
/** 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('captures an empty package module without inventing declarations or references', () => {
// Tree-sitter 0.25 matches the zero-width module root. The 15 empty
// __init__.py fixtures therefore gain this scope (with its lexical
// decorations) and no other captures.
const range = { startLine: 1, startCol: 0, endLine: 1, endCol: 0 };
expect(emitPythonScopeCaptures('', '__init__.py')).toEqual([
{
'@scope.module': { name: '@scope.module', text: '', range },
'@scope.name-claims': { name: '@scope.name-claims', text: '[]', range },
'@scope.lookup-policy': {
name: '@scope.lookup-policy',
text: '{"callerScopeIsAuthoritative":true}',
range,
},
},
]);
});
it('keeps ordinary Python call arity out of the generic reference schema', () => {
const src = [
'zero()',
'one(value)',
'keyword(value=1)',
'mixed(1, named=2)',
'obj.member(',
' # comments are not arguments',
' value,',
')',
].join('\n');
expect(callArity(src, 'zero')).toBeUndefined();
expect(callArity(src, 'one')).toBeUndefined();
expect(callArity(src, 'keyword')).toBeUndefined();
expect(callArity(src, 'mixed')).toBeUndefined();
expect(callArity(src, 'member')).toBeUndefined();
});
it('keeps call arity unknown when an argument splat is present', () => {
const src = ['from_list(*values)', 'from_dict(**values)', 'mixed(*values, named=1)'].join('\n');
expect(callArity(src, 'from_list')).toBeUndefined();
expect(callArity(src, 'from_dict')).toBeUndefined();
expect(callArity(src, 'mixed')).toBeUndefined();
});
it('synthesizes receiver bindings only for real positional parameters', () => {
const src = [
'class Example:',
' def ordinary(instance): pass',
' @classmethod',
' def factory(owner): pass',
' def variadic(*args): pass',
' def keyword_variadic(**kwargs): pass',
' def keyword_only(*, option): pass',
].join('\n');
expect(receiverBindingNames(src)).toEqual(['instance', 'owner']);
});
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);
});
});