GitNexus/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts
Lucas van Staden 1c4993251c
fix(php): synthesize module scope for namespace-less PHP files (.phtml) (#1801)
* fix(php): phtml scope synthesis with full-file range + O(1) Step 4 lookup (#1801, #1803)

Address PR #1801 review findings and complete #1803 fix:

scope-extractor.ts:
- Synthetic Module scope uses full-file range (computed from existing
  drafts) so positionIndex containment works for top-level references
  in ERROR-root .phtml files
- Orphan scope re-parenting done on drafts in extract() by replacing
  with new drafts — no mutation of readonly fields, no PHP-specific
  logic in shared buildScopeTree
- Dead matchCount parameter removed from ensureModuleScope

namespace-siblings.ts:
- Step 4 parsedFiles.find() replaced with pre-built Map for O(1) lookup
  (was O(n²) with 16K files = ~256M comparisons)

* test(php): add pipeline benchmark for scaling regression detection

Synthetic PHP fixture generator (N files × M namespaces × K classes)
with cross-namespace imports and calls. Measures wall-clock, peak heap,
node/edge counts at 100/250/500 file scales with worker pool enabled.

Results on current branch:
- 100 files: 982ms, 65MB (9.8ms/file)
- 250 files: 1310ms, 70MB (5.2ms/file)
- 500 files: 2006ms, 92MB (4.0ms/file)
- Scaling: sublinear (0.53x-0.77x ratio)

Gated behind GITNEXUS_BENCH=1 so it does not run in normal CI.

* chore: trigger CI

* fix: prettier formatting + update scope-extractor test for synthesis behavior

* fix: extend synthetic Module range to all captures + update integration test

Address CI failure and review findings:
- ensureModuleScope now computes range from ALL captures (scope,
  declaration, reference, type-binding) not just scope drafts. This
  ensures top-level references after the last inner scope are covered.
- Update parse-worker-scope-integration test for synthesis behavior.
- Update extract() docstring to document synthesis contract.

---------

Co-authored-by: Test <test@example.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-24 20:37:17 +01:00

176 lines
6.9 KiB
TypeScript

/**
* Unit tests for `extractParsedFile` — the parse-worker → ScopeExtractor
* bridge (RFC #909 Ring 2 PKG #920).
*
* The goal is to pin three invariants:
*
* 1. When a provider does NOT implement `emitScopeCaptures`, the helper
* returns `undefined` silently. This is the state of every language
* today — `ParseWorkerResult.parsedFiles` stays empty and the legacy
* DAG continues unaffected.
* 2. When a provider DOES implement the hook, the helper threads its
* output through `ScopeExtractor.extract` and returns a `ParsedFile`.
* 3. Exceptions from either the hook or the extractor are caught
* locally. The helper returns `undefined` — scope-extraction
* failures must NEVER break legacy parsing on the same file.
*/
import { describe, it, expect } from 'vitest';
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { extractParsedFile } from '../../../src/core/ingestion/scope-extractor-bridge.js';
import type { LanguageProvider } from '../../../src/core/ingestion/language-provider.js';
// ─── Capture helpers ────────────────────────────────────────────────────────
const cap = (
name: string,
startLine: number,
startCol: number,
endLine: number,
endCol: number,
text = '',
): Capture => ({ name, range: { startLine, startCol, endLine, endCol }, text });
const moduleScopeMatch = (): CaptureMatch => ({
'@scope.module': cap('@scope.module', 1, 0, 100, 0),
});
/**
* Build a `LanguageProvider` whose shape is only as narrow as
* `extractParsedFile` reads. Tests cast to the full provider type since
* `extractParsedFile` is typed against `LanguageProvider` (not the narrow
* `ScopeExtractorHooks`); the real worker always has a full provider.
*/
function fakeProvider(
hooks: Partial<Pick<LanguageProvider, 'emitScopeCaptures' | 'resolveScopeKind'>>,
): LanguageProvider {
return hooks as unknown as LanguageProvider;
}
// ─── Tests ─────────────────────────────────────────────────────────────────
describe('extractParsedFile', () => {
describe('provider has NOT migrated (no emitScopeCaptures)', () => {
it('returns undefined — silent no-op for legacy languages', () => {
const provider = fakeProvider({}); // no hook
const result = extractParsedFile(provider, 'source text', 'src/file.ts');
expect(result).toBeUndefined();
});
it('never calls the scope extractor when the hook is absent — cannot throw', () => {
// If the extractor was wrongly invoked, it would complain about the
// missing Module scope for empty captures. This test proves the
// short-circuit actually fires.
const provider = fakeProvider({});
expect(() => extractParsedFile(provider, '', 'x.ts')).not.toThrow();
});
});
describe('provider HAS migrated', () => {
it('returns undefined without warning for whitespace-only source', () => {
const warnings: string[] = [];
let called = false;
const provider = fakeProvider({
emitScopeCaptures: () => {
called = true;
throw new Error('should not inspect empty source');
},
});
for (const src of ['', ' \n\t']) {
const result = extractParsedFile(provider, src, 'pkg/__init__.py', (msg) => {
warnings.push(msg);
});
expect(result).toBeUndefined();
}
expect(called).toBe(false);
expect(warnings).toEqual([]);
});
it('threads emitScopeCaptures output through ScopeExtractor', () => {
const provider = fakeProvider({
emitScopeCaptures: () => [moduleScopeMatch()],
});
const result = extractParsedFile(provider, 'source text', 'src/file.ts');
expect(result).toBeDefined();
expect(result!.filePath).toBe('src/file.ts');
expect(result!.scopes).toHaveLength(1);
expect(result!.scopes[0]!.kind).toBe('Module');
});
it('forwards the correct arguments to emitScopeCaptures', () => {
let seenText: string | undefined;
let seenPath: string | undefined;
let seenTree: unknown;
const cachedTree = { rootNode: {} };
const provider = fakeProvider({
emitScopeCaptures: (text, path, tree) => {
seenText = text;
seenPath = path;
seenTree = tree;
return [moduleScopeMatch()];
},
});
extractParsedFile(provider, 'the real text', 'deep/path/file.ts', undefined, cachedTree);
expect(seenText).toBe('the real text');
expect(seenPath).toBe('deep/path/file.ts');
expect(seenTree).toBe(cachedTree);
});
});
describe('error resilience — never breaks legacy parsing', () => {
it('returns undefined when emitScopeCaptures throws', () => {
const provider = fakeProvider({
emitScopeCaptures: () => {
throw new Error('provider boom');
},
});
const result = extractParsedFile(provider, 'src', 'a.ts');
expect(result).toBeUndefined();
});
it('routes errors through the onWarn callback when provided', () => {
const warnings: string[] = [];
const provider = fakeProvider({
emitScopeCaptures: () => {
throw new Error('provider boom');
},
});
const result = extractParsedFile(provider, 'src', 'path/to/file.ts', (msg) => {
warnings.push(msg);
});
expect(result).toBeUndefined();
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('path/to/file.ts');
expect(warnings[0]).toContain('provider boom');
});
it('synthesizes Module scope and re-parents orphan Class when no Module is emitted', () => {
const provider = fakeProvider({
emitScopeCaptures: () => [{ '@scope.class': cap('@scope.class', 5, 0, 10, 0) }],
});
const result = extractParsedFile(provider, 'src', 'a.ts');
expect(result).toBeDefined();
const moduleScope = result!.scopes.find((s) => s.kind === 'Module');
expect(moduleScope).toBeDefined();
const classScope = result!.scopes.find((s) => s.kind === 'Class');
expect(classScope).toBeDefined();
expect(classScope!.parent).toBe(moduleScope!.id);
});
it('returns undefined when ScopeExtractor throws on malformed captures (overlap)', () => {
// Siblings with overlapping ranges trip the ScopeTreeInvariantError
// from #912. The helper catches it and returns undefined.
const provider = fakeProvider({
emitScopeCaptures: () => [
moduleScopeMatch(),
{ '@scope.function': cap('@scope.function', 10, 0, 20, 0) },
{ '@scope.function': cap('@scope.function', 15, 0, 25, 0) }, // overlap
],
});
const result = extractParsedFile(provider, 'src', 'a.ts');
expect(result).toBeUndefined();
});
});
});