GitNexus/gitnexus/test/unit/scope-resolution/wildcard-collision-export-evidence.test.ts
Abhinav Pandey 2220f4d851
fix(resolution): label fallback guesses and preserve export visibility (#3190)
* fix(resolution): distinguish name guesses and preserve export visibility

* test(go): keep method enrichment fixture in one package

* fix(resolution): address split review edge cases and evidence reporting

* fix(exports): recognize imported and expression-local receivers

* fix(resolution): align export and target evidence with language scope

* fix(ingestion): preserve lexical import provenance through resolution

* test(ci): rebalance Windows shards from measured slow suites

* Address PR review feedback (#3190)

- Label constructor unique-name guesses as global-name-fallback and run language vetoes
- Tighten Go qualified, Rust crate::, and Ruby class-reopen fallback guards
- Ignore for-loop shadowed CommonJS receivers and exclude guesses from the resolved-call census
- Refresh FinalizeOutput and hook docs for lexical binding scopes

Co-authored-by: Cursor <cursoragent@cursor.com>

* Tighten review-feedback leftovers on fallback visibility.

Qualified Go calls still respect export and test-package rules, nested Rust src/ stays a module segment, and top-level conditional this.x is treated as CommonJS.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* Address PR review feedback (#3190)

- Distinguish Swift package prefixes when comparing target modules
- Document lexical import binding and handledSites refusal marking
- Drop the stale ci-scope-parity workflow claim and prototype-safe export verdicts

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3190)

Supply caller source on Ruby visibility cases so they exercise the named allow branches instead of the missing-text bypass.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): label unique constructor types as name guesses

A workspace-unique class hit in findClassBindingInScope was treated as
an in-scope bind, so Go/JS constructor-form sites skipped the guess
label and the Go unexported veto.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): keep qualified constructors precise after unique-name split

Bare constructor unique-name hits stay guesses so Go can veto an
unexported type. A written qualifier is now carried as rawQualifiedName
so `new pkg.Foo()` and `models.Box[T]{}` can still recover the unique
class without that veto.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(bench): rebaseline Go/Java scope-capture fingerprints for constructor qualifiers

Generic Go composite literals and qualified Java `new pkg.Foo()` now
carry @reference.qualified-name on existing constructor matches.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): treat import-reached unique constructors as precise

C++ #include and Rust re-exports do not mint a lexical class binding.
A unique type in an imported file (or imported directory) is therefore
a real bind, not a name guess, so those CALLS edges stay import-resolved.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(resolution): require named or resolved imports for constructor precision

Bare Go Box[T]{} is not package-qualified, and a sibling-file import of a different name is not constructor visibility.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-09-10 11:38:19 +01:00

228 lines
8.2 KiB
TypeScript

/**
* `export *` collision detection honours EXPORT EVIDENCE (`SymbolDefinition.
* isExported`, tri-state) — review findings on #3182 (finalize-algorithm.ts:1026
* and typescript/scope-resolver.ts:138).
*
* Two defects, one mechanism:
*
* 1. `Variable` was excluded from the collision candidates while the closure
* path (`indexTopLevelExportsByName`) retained it, so two sources each
* exporting `const alpha` were BOTH published and first-wins silently bound
* one of them despite `exclusiveWildcardReexports`.
* 2. A module-PRIVATE `function foo` in one source counted as a provider, so a
* genuinely exported `foo` in the other source was refused as a collision —
* and, without the refusal, the private one could have been the closure's
* first-listed winner.
*
* With evidence: an exported `Variable` collides; a private `function` neither
* collides nor binds. Without evidence the prior behaviour is unchanged.
*/
import { describe, it, expect } from 'vitest';
import type { ParsedFile, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
const mkScope = (id: ScopeId, filePath: string): Scope => ({
id,
parent: null,
kind: 'Module',
range: { startLine: 1, startCol: 0, endLine: 100, endCol: 0 },
filePath,
bindings: new Map(),
ownedDefs: [],
imports: [],
typeBindings: new Map(),
});
const mkFile = (filePath: string, overrides: Partial<ParsedFile> = {}): ParsedFile => ({
filePath,
moduleScope: `scope:${filePath}#module`,
scopes: [mkScope(`scope:${filePath}#module`, filePath)],
parsedImports: overrides.parsedImports ?? [],
localDefs: overrides.localDefs ?? [],
referenceSites: [],
});
const def = (
nodeId: string,
filePath: string,
type: SymbolDefinition['type'],
name: string,
isExported?: boolean,
): SymbolDefinition => ({
nodeId,
filePath,
type,
qualifiedName: name,
...(isExported !== undefined ? { isExported } : {}),
});
/** barrel.ts: `export * from './a'; export * from './b'`; c.ts imports `name` from it. */
function run(aDefs: SymbolDefinition[], bDefs: SymbolDefinition[], name: string) {
const a = mkFile('a.ts', { localDefs: aDefs });
const b = mkFile('b.ts', { localDefs: bDefs });
const barrel = mkFile('barrel.ts', {
parsedImports: [
{ kind: 'wildcard', targetRaw: 'a.ts' },
{ kind: 'wildcard', targetRaw: 'b.ts' },
],
});
const c = mkFile('c.ts', {
parsedImports: [{ kind: 'named', localName: name, importedName: name, targetRaw: 'barrel.ts' }],
});
const out = finalizeScopeModel([a, b, barrel, c], {
hooks: {
resolveImportTarget: (targetRaw) => targetRaw,
namedImportsBindTopLevelOnly: true,
wildcardCollisionIsAmbiguous: true,
},
});
return {
edge: out.imports.get(c.moduleScope)?.[0],
ambiguous: out.stats.ambiguousWildcardExports,
};
}
describe('export * collisions with export evidence', () => {
it('refuses conflicting named re-exports without reporting an export-star collision', () => {
const a = mkFile('a.ts', { localDefs: [def('a:alpha', 'a.ts', 'Function', 'alpha', true)] });
const b = mkFile('b.ts', { localDefs: [def('b:alpha', 'b.ts', 'Function', 'alpha', true)] });
const barrel = mkFile('barrel.ts', {
parsedImports: [
{
kind: 'named',
targetRaw: 'a.ts',
localName: 'alpha',
importedName: 'alpha',
reexportsName: true,
},
{
kind: 'named',
targetRaw: 'b.ts',
localName: 'alpha',
importedName: 'alpha',
reexportsName: true,
},
],
});
const caller = mkFile('caller.ts', {
parsedImports: [
{ kind: 'named', targetRaw: 'barrel.ts', localName: 'alpha', importedName: 'alpha' },
],
});
const out = finalizeScopeModel([a, b, barrel, caller], {
hooks: {
resolveImportTarget: (targetRaw) => targetRaw,
namedImportsBindTopLevelOnly: true,
wildcardCollisionIsAmbiguous: true,
},
});
expect(out.imports.get(caller.moduleScope)?.[0]?.linkStatus).toBe('unresolved');
expect(out.stats.ambiguousWildcardExports).toEqual([]);
});
it('two sources each EXPORTING `const alpha` collide — refused, not first-wins', () => {
const { edge, ambiguous } = run(
[def('def:a.alpha', 'a.ts', 'Variable', 'alpha', true)],
[def('def:b.alpha', 'b.ts', 'Variable', 'alpha', true)],
'alpha',
);
expect(edge?.linkStatus).toBe('unresolved');
expect(edge?.targetDefId).toBeUndefined();
expect(ambiguous.map((x) => x.name)).toEqual(['alpha']);
expect([...(ambiguous[0]?.candidateDefIds ?? [])].sort()).toEqual([
'def:a.alpha',
'def:b.alpha',
]);
});
it('a module-PRIVATE `function foo` beside an exported one is not a provider: the export binds', () => {
const { edge, ambiguous } = run(
[def('def:a.foo', 'a.ts', 'Function', 'foo', true)],
[def('def:b.foo', 'b.ts', 'Function', 'foo', false)],
'foo',
);
expect(ambiguous).toEqual([]);
expect(edge?.linkStatus).toBeUndefined();
expect(edge?.targetDefId).toBe('def:a.foo');
});
it('the private one is never the closure winner either, whichever source is listed first', () => {
// b (private) is listed AFTER a here, but a is the one that exports — swap
// the roles so the private def sits in the FIRST wildcard source.
const { edge } = run(
[def('def:a.foo', 'a.ts', 'Function', 'foo', false)],
[def('def:b.foo', 'b.ts', 'Function', 'foo', true)],
'foo',
);
expect(edge?.targetDefId).toBe('def:b.foo');
});
it('a private def alone behind the barrel is NOT published through `export *`', () => {
const { edge } = run([def('def:a.foo', 'a.ts', 'Function', 'foo', false)], [], 'foo');
expect(edge?.linkStatus).toBe('unresolved');
});
it('a class MEMBER of the barrel named like the collision does not shadow it (magyargergo)', () => {
// `export class Unrelated { clash() {} }` in the barrel made `clash` a local
// name, switched the collision check off, and a confident edge to a.ts went out.
const a = mkFile('a.ts', {
localDefs: [def('def:a.clash', 'a.ts', 'Function', 'clash', true)],
});
const b = mkFile('b.ts', {
localDefs: [def('def:b.clash', 'b.ts', 'Function', 'clash', true)],
});
const unrelated = def('def:Unrelated', 'barrel.ts', 'Class', 'Unrelated', true);
const member: SymbolDefinition = {
nodeId: 'def:Unrelated.clash',
filePath: 'barrel.ts',
type: 'Method',
qualifiedName: 'Unrelated.clash',
ownerId: 'def:Unrelated',
isExported: false,
};
const barrel = mkFile('barrel.ts', {
localDefs: [unrelated, member],
parsedImports: [
{ kind: 'wildcard', targetRaw: 'a.ts' },
{ kind: 'wildcard', targetRaw: 'b.ts' },
],
});
const c = mkFile('c.ts', {
parsedImports: [
{ kind: 'named', localName: 'clash', importedName: 'clash', targetRaw: 'barrel.ts' },
],
});
for (const memberEvidence of [member, { ...member, isExported: undefined }]) {
const out = finalizeScopeModel(
[a, b, { ...barrel, localDefs: [unrelated, memberEvidence] }, c],
{
hooks: {
resolveImportTarget: (targetRaw) => targetRaw,
namedImportsBindTopLevelOnly: true,
wildcardCollisionIsAmbiguous: true,
},
},
);
const edge = out.imports.get(c.moduleScope)?.[0];
expect(edge?.linkStatus).toBe('unresolved');
expect(out.stats.ambiguousWildcardExports.map((x) => x.name)).toEqual(['clash']);
}
});
it('without evidence, behaviour is unchanged: functions collide, Variables do not', () => {
const fns = run(
[def('def:a.foo', 'a.ts', 'Function', 'foo')],
[def('def:b.foo', 'b.ts', 'Function', 'foo')],
'foo',
);
expect(fns.edge?.linkStatus).toBe('unresolved');
expect(fns.ambiguous.map((x) => x.name)).toEqual(['foo']);
const vars = run(
[def('def:a.alpha', 'a.ts', 'Variable', 'alpha')],
[def('def:b.alpha', 'b.ts', 'Variable', 'alpha')],
'alpha',
);
expect(vars.ambiguous).toEqual([]);
expect(vars.edge?.targetDefId).toBe('def:a.alpha');
});
});