GitNexus/gitnexus/test/integration/inheritance-source-visibility-review.test.ts
Abhigyan Patwari c30449394b
fix(scope): bind same-package inheritance bases when the simple name is reused elsewhere (#3659)
* fix(scope): bind same-namespace inheritance bases before sibling visibility exists

Cause: same-package / same-namespace visibility (`populateNamespaceSiblings`)
is populated after the inheritance pre-pass and `buildMro`. A base written
without an import (`class C extends B`, B in another file of the package)
could bind only through the workspace-unique simple-name fallback, which any
other package declaring a `B` defeats: no EXTENDS edge, an empty MRO, and
lost calls through inherited members. In the combined lang-resolution corpus
`java-grandparent-resolution` `c.greet().save()` dropped because
`java-unrelated-method-collision` declares default-package A and B.

Moving the sibling hook ahead of the pre-pass was measured and rejected. It
retargeted correct edges and minted wrong ones, because the sibling channels
carry candidates the pre-pass must not see: C# global-namespace types are
consulted at every scope (same-file `Cat : Animal` bound another fixture's
global Animal), namespace buckets merge across source roots with no
proximity order (PHP, C#), and a qualified `extends app.base.Box` bound
through a same-package `app.Box`.

Fix: an optional `ScopeResolver.resolveImplicitInheritanceBase`, consulted by
the pre-pass only when the shared chain bound nothing, the base was written
bare (no qualifier, name capture at the start of the base expression), and
no import claims the name. It adds edges and never retargets one.
`resolveSameNamespaceInheritanceBase` picks the candidate in the deriving
class's namespace, nearest by shared path prefix, refusing a tie. Java and
Kotlin answer from their package facts, C# from the exact `namespacePrefix`.

Corpus A/B (main vs this, same corpus): EXTENDS/IMPLEMENTS +42, all within
their own fixture, 0 removed. CALLS: six `super.save()` edges keep their
target but are now MRO-dispatched (`global`, as in isolation), one moves off
another fixture's BaseModel.Save onto its own, and 7 are new (both
grandparent `greet` calls, a default-method `validate`, the new fixtures).
Receiver bench: call drops 128 -> 127, all kinds 192 -> 191, shape arm
unchanged. Scope-capture fingerprints re-baselined for the added fixtures
only (parked, --check passes on the old ones).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(java): resolve a duplicated package+type import to the importer's own root

Cause: `buildJavaPackageIndex` kept the first file declaring each
package+type. One repository can hold several build modules or sample
projects that each declare `models.User`, so every `import models.User;`
bound whichever copy was parsed first, a wrong IMPORTS edge at full
confidence that carries the receiver types behind it. In the combined
lang-resolution corpus 24 fixtures declare `models.User`, and e.g.
`java-heritage` UserService.processUser called
`java-assignment-chain/models/User.save`.

Fix: the index keeps every declaring file in a sparse `duplicateTypes` map
(only colliding pairs, so a normal workspace pays nothing), and a type import
resolves to the declaring file sharing the longest leading path with the
importer (its own module or source root). A tie, or an unknown importer,
refuses rather than guessing. Wildcard imports are unchanged.

Corpus A/B (previous commit vs this, same corpus): 40 CALLS edges that
crossed into another fixture's User/Repo/Address/UserService are gone, 55
CALLS/ACCESSES edges now land in their own fixture (0 cross-fixture), and
`java-parent-resolution` User IMPLEMENTS Serializable moves off
`java-heritage` onto its own interface. Receiver-unresolved drops unchanged;
import-target and receiver benches pass. Scope-capture java fingerprint
re-baselined for the added fixture only (parked, --check passes on the old
one).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(scope): respect base candidate visibility

* fix(scope): match written JVM base arity

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 12:45:11 +00:00

100 lines
5.1 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
getRelationships,
runPipelineFromRepo,
writeFixtureRepo,
type PipelineResult,
} from './resolvers/helpers.js';
describe('review probes for candidate visibility', () => {
let root: string;
let result: PipelineResult;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-source-visibility-'));
writeFixtureRepo(root, {
'alpha/src/main/java/models/User.java':
'package models; public class User { public void save() {} }',
'beta/src/test/java/models/User.java':
'package models; public class User { public void save() {} }',
'beta/src/main/java/services/Service.java':
'package services; import models.User; public class Service { void run(User value) { value.save(); } }',
'alpha/src/main/java/parents/Base.java': 'package parents; public class Base {}',
'beta/src/test/java/parents/Base.java': 'package parents; public class Base {}',
'beta/src/main/java/parents/Child.java':
'package parents; public class Child extends Base {}',
'gamma/src/main/java/nested/A.java':
'package nested; public class A { public static class B {} }',
'gamma/src/main/java/nested/C.java':
'package nested; public class C extends A { public static class D extends B {} }',
'gamma/src/main/java/nested/B.java': 'package nested; public class B {}',
'gamma/src/main/java/other/B.java': 'package other; public class B {}',
});
writeFixtureRepo(root, {
'alias/p/B.kt': 'package aliasp\nopen class B',
'alias/p/C.kt': 'package aliasp\nimport aliasq.B\nclass AliasChild : B()',
'alias/q/Types.kt': 'package aliasq\nopen class Real\ntypealias B = Real',
'claims/p/B.java': 'package claimsp; public class B {}',
'claims/q/B.java': 'package claimsq; public class B {}',
'claims/p/C.java':
'package claimsp; import external.B; public class ExternalChild extends B {}',
'beta/src/test/java/services/TestService.java':
'package services; import models.User; public class TestService { void run(User value) { value.save(); } }',
'beta/src/test/java/parents/TestChild.java':
'package parents; public class TestChild extends Base {}',
'alpha/src/main/kotlin/ktparents/Base.kt': 'package ktparents\nopen class Base',
'beta/src/test/kotlin/ktparents/Base.kt': 'package ktparents\nopen class Base',
'beta/src/main/kotlin/ktparents/Child.kt': 'package ktparents\nclass Child : Base()',
});
result = await runPipelineFromRepo(root, () => {}, {});
}, 120000);
afterAll(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
});
it('does not dispatch production receivers to a test-only duplicate', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) => e.sourceFilePath === 'beta/src/main/java/services/Service.java' && e.target === 'save',
);
expect(edges.map((e) => e.targetFilePath)).toEqual(['alpha/src/main/java/models/User.java']);
});
it('does not bind a production base to a test-only duplicate', () => {
const edges = getRelationships(result, 'EXTENDS').filter(
(e) => e.source === 'Child' && e.sourceFilePath.endsWith('.java'),
);
expect(edges.map((e) => e.targetFilePath)).toEqual(['alpha/src/main/java/parents/Base.java']);
});
it('does not override a resolved Kotlin typealias import with a package sibling', () => {
const edges = getRelationships(result, 'EXTENDS').filter((e) => e.source === 'AliasChild');
expect(edges).not.toContainEqual(expect.objectContaining({ targetFilePath: 'alias/p/B.kt' }));
});
it('does not override an unresolved explicit Java import with a package sibling', () => {
expect(getRelationships(result, 'EXTENDS').filter((e) => e.source === 'ExternalChild')).toEqual(
[],
);
});
it('preserves the nearer test declaration for a test importer', () => {
const edges = getRelationships(result, 'CALLS').filter(
(e) =>
e.sourceFilePath === 'beta/src/test/java/services/TestService.java' && e.target === 'save',
);
expect(edges.map((e) => e.targetFilePath)).toEqual(['beta/src/test/java/models/User.java']);
});
it('preserves the nearer test base for a test deriving class', () => {
const edges = getRelationships(result, 'EXTENDS').filter((e) => e.source === 'TestChild');
expect(edges.map((e) => e.targetFilePath)).toEqual(['beta/src/test/java/parents/Base.java']);
});
it('excludes test-only Kotlin bases from production inheritance', () => {
const edges = getRelationships(result, 'EXTENDS').filter(
(e) => e.source === 'Child' && e.sourceFilePath.endsWith('.kt'),
);
expect(edges.map((e) => e.targetFilePath)).toEqual(['alpha/src/main/kotlin/ktparents/Base.kt']);
});
it('does not replace an inherited nested base with a package-level homonym', () => {
const edges = getRelationships(result, 'EXTENDS').filter((e) => e.source === 'D');
expect(edges).not.toContainEqual(
expect.objectContaining({ targetFilePath: 'gamma/src/main/java/nested/B.java' }),
);
});
});