mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-25 01:01:28 +00:00
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`
166 lines
6.7 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|