GitNexus/gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts
Gergő Magyar 39b5d295c7
feat(ingestion): wire ScopeExtractor into parse-worker + processor (#920, RFC #909 Ring 2 PKG) (#969)
Plumbs the ScopeExtractor (#919) into the real parsing pipeline.
`ParsedFile` artifacts now flow from workers to the parsing-processor
without changing any legacy-DAG behavior.

## Shipped

### `gitnexus/src/core/ingestion/scope-extractor-bridge.ts` (new)

  - `extractParsedFile(provider, sourceText, filePath, onWarn?)`
  - Short-circuits (returns `undefined`) when the provider has not
    implemented `emitScopeCaptures`. True for every language today —
    this is the default no-op path.
  - Invokes the hook + `ScopeExtractor.extract`, returns a `ParsedFile`.
  - **Swallows exceptions on both sides.** Failures route through the
    optional `onWarn` callback (or `console.warn`) and return
    `undefined`. Scope-extraction errors NEVER break legacy parsing on
    the same file.
  - Standalone module (not nested in `parse-worker.ts`) so tests can
    import it directly without triggering the worker's top-level
    `parentPort!.on(...)`.

### `gitnexus/src/core/ingestion/workers/parse-worker.ts`

  - `ParseWorkerResult.parsedFiles: ParsedFile[]` added.
  - `processFileGroup` calls `extractParsedFile` AFTER tree parse,
    BEFORE legacy extraction. Worker provides an `onWarn` callback that
    routes bridge warnings through `parentPort.postMessage({ type:
    'warning', message })`.
  - `mergeResult` includes `parsedFiles` in the sub-batch merge.
  - Initial + reset accumulator templates include `parsedFiles: []`.

### `gitnexus/src/core/ingestion/parsing-processor.ts`

  - `WorkerExtractedData.parsedFiles: ParsedFile[]` added.
  - Empty-result branch and the across-chunk aggregation both include
    `parsedFiles`. Aggregation is tolerant of workers that don't emit
    the field (older builds / partial rollouts).

### Ring 1 tweak: `emitScopeCaptures` sync return

`readonly CaptureMatch[]` (was `Promise<readonly CaptureMatch[]>`).
Tree-sitter and COBOL's regex tagger are both synchronous; no
foreseeable need for async work inside this hook. Sync lets the
already-sync worker pipeline invoke it inline without cascading
`async` up through the batch driver + IPC handler.

## Tests (9 new; full suite 311/311)

`gitnexus/test/unit/scope-resolution/parse-worker-scope-integration.test.ts`:
  - Not-migrated (2): undefined-returning hook · never-invokes-extractor
  - Migrated (3): happy path · argument threading · honors
    `shouldCreateScope` override
  - Error resilience (4): hook throws · extractor throws (no Module) ·
    extractor throws (sibling overlap) · `onWarn` gets routed
    message with filePath + error body

## Verification

  - `tsc --noEmit` clean in both packages
  - `gitnexus-shared` build clean
  - 311/311 combined scope-resolution / shadow / model / flag suite
  - 9/9 new bridge tests

## What's NOT in this PR (still deferred to #921)

  - Actually using the `parsedFiles` — that's the finalize orchestrator.
  - `ModuleScopeIndex.byFilePath` materialization — belongs alongside
    the rest of the SemanticModel indexes in #921.

## Closes part of #909. Unblocks
  - #921 finalize-orchestrator — consumes `WorkerExtractedData.parsedFiles`
2026-04-18 20:27:56 +01:00

166 lines
6.7 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' | 'shouldCreateScope' | '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('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;
const provider = fakeProvider({
emitScopeCaptures: (text, path) => {
seenText = text;
seenPath = path;
return [moduleScopeMatch()];
},
});
extractParsedFile(provider, 'the real text', 'deep/path/file.ts');
expect(seenText).toBe('the real text');
expect(seenPath).toBe('deep/path/file.ts');
});
it('honors provider hooks beyond emitScopeCaptures (shouldCreateScope)', () => {
// A Block scope the provider declines to create — the resulting
// ParsedFile should have only the Module scope, not the Block.
const provider = fakeProvider({
emitScopeCaptures: () => [
moduleScopeMatch(),
{ '@scope.block': cap('@scope.block', 10, 0, 20, 0) },
],
shouldCreateScope: (match) => match['@scope.block'] === undefined,
});
const result = extractParsedFile(provider, 'src', 'a.ts');
expect(result!.scopes).toHaveLength(1);
expect(result!.scopes[0]!.kind).toBe('Module');
});
});
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('returns undefined when ScopeExtractor throws (missing Module scope)', () => {
// Emits a Class scope but no Module — extractor throws; helper
// swallows and returns undefined. Legacy parsing on the same file
// continues unaffected by this failure.
const provider = fakeProvider({
emitScopeCaptures: () => [{ '@scope.class': cap('@scope.class', 5, 0, 10, 0) }],
});
const result = extractParsedFile(provider, 'src', 'a.ts');
expect(result).toBeUndefined();
});
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();
});
});
});