Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions gitnexus/src/core/ingestion/heritage-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ const resolveHeritageId = (
}
return { id: resolved.candidates[0].nodeId, confidence: TIER_CONFIDENCE[resolved.tier] };
}
// Unresolved: use global-tier confidence as fallback
// Unresolved: use file-qualified fallback so same-file parent classes match the
// "Label:filePath:ClassName" node ID format used throughout the graph.
// When an explicit fallbackKey is provided (child class lookups), use it as-is.
return {
id: generateId(fallbackLabel, fallbackKey ?? name),
id: generateId(fallbackLabel, fallbackKey ?? `${filePath}:${name}`),
confidence: TIER_CONFIDENCE['global'],
};
};
Expand Down
6 changes: 5 additions & 1 deletion gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,11 @@ export async function runChunkedParseAndResolve(
if (shouldAccumulate(item.filePath)) deferredWorkerCalls.push(item);
}
for (const item of chunkWorkerData.heritage) {
if (shouldAccumulate(item.filePath)) deferredWorkerHeritage.push(item);
// Heritage (EXTENDS/IMPLEMENTS) is NOT routed through the
// registry-primary call-resolution DAG — accumulate unconditionally
// so same-file and cross-file inheritance edges are emitted for ALL
// languages, including those in MIGRATED_LANGUAGES (e.g. C#, Kotlin).
deferredWorkerHeritage.push(item);
}
for (const item of chunkWorkerData.constructorBindings) {
if (shouldAccumulate(item.filePath)) deferredConstructorBindings.push(item);
Expand Down
53 changes: 53 additions & 0 deletions gitnexus/test/integration/worker-sequential-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import {
} from './resolvers/helpers.js';

const FIXTURE = path.resolve(__dirname, '..', 'fixtures', 'cross-file-binding', 'ts-simple');
const HERITAGE_FIXTURE = path.resolve(
__dirname,
'..',
'fixtures',
'lang-resolution',
'typescript-child-extends-parent',
);

const runMode = (mode: 'worker' | 'sequential'): Promise<PipelineResult> =>
runPipelineFromRepo(FIXTURE, () => {}, {
Expand Down Expand Up @@ -77,3 +84,49 @@ describe('worker vs sequential parity (#1741 Problem D)', () => {
},
);
});

// ---------------------------------------------------------------------------
// Heritage worker-path regression (#1951): EXTENDS/IMPLEMENTS edges must
// survive the worker-pool accumulation path for registry-primary languages.
//
// Background: `shouldAccumulate` in the worker-path chunk loop used to skip
// heritage records for registry-primary languages (TypeScript, C#, etc.)
// because those languages route CALL resolution through the scope-resolution
// DAG. But EXTENDS/IMPLEMENTS are handled by `processHeritageFromExtracted`,
// NOT the registry-primary DAG, so they were silently dropped in worker mode
// while sequential mode produced them correctly.
// ---------------------------------------------------------------------------

describe('worker vs sequential parity — EXTENDS/IMPLEMENTS heritage (#1951)', () => {
let worker: PipelineResult;
let sequential: PipelineResult;

beforeAll(async () => {
const runMode = (mode: 'worker' | 'sequential'): Promise<PipelineResult> =>
runPipelineFromRepo(HERITAGE_FIXTURE, () => {}, {
skipGraphPhases: true,
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
...(mode === 'worker' ? { workerPoolSize: 2 } : { skipWorkers: true }),
});

[worker, sequential] = await Promise.all([runMode('worker'), runMode('sequential')]);
}, 120_000);

it('worker mode used the pool (guards against silent sequential fallback)', () => {
expect(worker.usedWorkerPool).toBe(true);
expect(sequential.usedWorkerPool).toBe(false);
});

it.each(['EXTENDS', 'CALLS', 'IMPORTS'])(
'produces identical %s edges in worker and sequential mode',
(relType) => {
const w = edgeSet(getRelationships(worker, relType));
const s = edgeSet(getRelationships(sequential, relType));
expect(w).toEqual(s);
},
);

it('worker mode emits at least 1 EXTENDS edge (regression guard)', () => {
expect(edgeSet(getRelationships(worker, 'EXTENDS')).length).toBeGreaterThan(0);
});
});
25 changes: 25 additions & 0 deletions gitnexus/test/unit/heritage-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,31 @@ describe('processHeritageFromExtracted', () => {
expect(rels[0].targetId).toContain('BaseUser');
});

it('generates file-qualified ID for unresolved same-file parent class', async () => {
// Regression test: parent class defined in the same file as the child was
// previously assigned a bare-name fallback ID ("Class:BaseError") instead of
// the file-qualified format used everywhere ("Class:src/errors.ts:BaseError").
// This caused lbug to drop all EXTENDS edges between same-file classes because
// the source and target node IDs never matched any indexed node.
const heritage: ExtractedHeritage[] = [
{
filePath: 'src/errors.ts',
className: 'WorkerError',
parentName: 'BaseError',
kind: 'extends',
},
];

await processHeritageFromExtracted(graph, heritage, ctx);

const rels = graph.relationships.filter((r) => r.type === 'EXTENDS');
expect(rels).toHaveLength(1);
// Child: explicit fallbackKey always was correct
expect(rels[0].sourceId).toBe('Class:src/errors.ts:WorkerError');
// Parent: must now use the file-qualified format, not bare "Class:BaseError"
expect(rels[0].targetId).toBe('Class:src/errors.ts:BaseError');
});

it('skips self-inheritance', async () => {
ctx.model.symbols.add('src/a.ts', 'Foo', 'Class:src/a.ts:Foo', 'Class');

Expand Down
Loading