GitNexus/gitnexus/test/unit/scope-resolution/wildcard-topLevelOnly-gate.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

152 lines
6.6 KiB
TypeScript

/**
* M17 — the `export *` wildcard fan-out in `populateFileClosure`
* (gitnexus-shared/src/scope-resolution/finalize-algorithm.ts) is gated by the
* SAME `namedImportsBindTopLevelOnly` hook as the named-import path:
*
* for (const [name, def] of (topLevelOnly ? indexTopLevelExportsByName : indexExportsByName)(...))
*
* Before this fix, the wildcard fan-out ALWAYS used the narrow (top-level-only)
* index, regardless of the hook — silently adopting ECMAScript's `export *`
* semantics (a class member can never be published by a bare wildcard
* re-export) for every language, including ones (Python, Java, ...) whose
* wildcard/star import legitimately republishes class members by name.
*
* This is below the extraction layer (RFC #909 Ring 2 PKG #921) — synthetic
* `ParsedFile` input against `finalizeScopeModel` with a FAKE resolver
* (`namedImportsBindTopLevelOnly` toggled directly), same technique as
* `finalize-orchestrator.test.ts`. No real language parser involved; the
* fixture below is deliberately language-agnostic (Vue, TypeScript, and
* JavaScript all opt in through their language-specific scope resolvers).
*
* Fixture shape, held constant across both hook settings:
* B.ts: class Foo with method `beta` — NO top-level `beta` declaration.
* A.ts: `export * from './B'` (wildcard re-export; populates A's closure).
* C.ts: `import { beta } from './A'` — resolves through A's closure, which
* the direct check on A's own (empty) localDefs never satisfies.
*/
import { describe, it, expect } from 'vitest';
import type { ParsedFile, ParsedImport, Scope, ScopeId, SymbolDefinition } from 'gitnexus-shared';
import { finalizeScopeModel } from '../../../src/core/ingestion/finalize-orchestrator.js';
import { vueScopeResolver } from '../../../src/core/ingestion/languages/vue/scope-resolver.js';
import { typescriptScopeResolver } from '../../../src/core/ingestion/languages/typescript/scope-resolver.js';
import { javascriptScopeResolver } from '../../../src/core/ingestion/languages/javascript/scope-resolver.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: overrides.scopes ?? [mkScope(`scope:${filePath}#module`, filePath)],
parsedImports: overrides.parsedImports ?? [],
localDefs: overrides.localDefs ?? [],
referenceSites: overrides.referenceSites ?? [],
});
function buildFixture(topLevelOnly: boolean) {
// B.ts: a class with a method `beta`, and NO top-level `beta` of any kind.
const fooClass: SymbolDefinition = {
nodeId: 'def:Foo',
filePath: 'B.ts',
type: 'Class',
qualifiedName: 'B.Foo',
};
const fooBetaMethod: SymbolDefinition = {
nodeId: 'def:Foo.beta',
filePath: 'B.ts',
type: 'Method',
ownerId: 'def:Foo',
qualifiedName: 'B.Foo.beta',
};
const fileB = mkFile('B.ts', { localDefs: [fooClass, fooBetaMethod] });
// A.ts: `export * from './B'` — a wildcard re-export, no local defs of its own.
const wildcardImport: ParsedImport = { kind: 'wildcard', targetRaw: 'B.ts' };
const fileA = mkFile('A.ts', { parsedImports: [wildcardImport] });
// C.ts: `import { beta } from './A'`.
const namedImport: ParsedImport = {
kind: 'named',
localName: 'beta',
importedName: 'beta',
targetRaw: 'A.ts',
};
const fileC = mkFile('C.ts', { parsedImports: [namedImport] });
const out = finalizeScopeModel([fileB, fileA, fileC], {
hooks: {
resolveImportTarget: (targetRaw) => targetRaw,
namedImportsBindTopLevelOnly: topLevelOnly,
},
});
const cImports = out.imports.get(fileC.moduleScope) ?? [];
return { out, fileC, fooBetaMethod, betaImport: cImports[0] };
}
describe('M17 — export * wildcard fan-out gated by namedImportsBindTopLevelOnly', () => {
it('a language that does NOT opt in (Python/Java-shaped: hook false) publishes the class method through the wildcard — wide index preserved', () => {
const { betaImport, fooBetaMethod } = buildFixture(false);
expect(betaImport).toBeDefined();
expect(betaImport!.linkStatus).toBeUndefined();
expect(betaImport!.targetFile).toBe('A.ts');
expect(betaImport!.targetDefId).toBe(fooBetaMethod.nodeId);
});
it('a language that DOES opt in (ECMAScript-shaped: hook true) refuses — the wildcard fan-out narrows to module-level declarations only', () => {
const { betaImport } = buildFixture(true);
expect(betaImport).toBeDefined();
// Neither A's own (empty) localDefs nor A's wildcard-populated closure
// (narrowed to MEMBER_LABELS-excluded defs) ever publish `beta` — the
// import stays unresolved rather than binding a class member no
// top-level name legitimizes.
expect(betaImport!.linkStatus).toBe('unresolved');
expect(betaImport!.targetDefId).toBeUndefined();
});
it('mutation check: a top-level (non-member) def behind the same wildcard still binds under EITHER setting', () => {
// Control — proves the gate narrows MEMBER labels specifically, not
// wildcard re-exports wholesale.
for (const topLevelOnly of [false, true]) {
const alphaVar: SymbolDefinition = {
nodeId: 'def:alpha',
filePath: 'B.ts',
type: 'Variable',
qualifiedName: 'B.alpha',
};
const fileB = mkFile('B.ts', { localDefs: [alphaVar] });
const fileA = mkFile('A.ts', {
parsedImports: [{ kind: 'wildcard', targetRaw: 'B.ts' }],
});
const fileC = mkFile('C.ts', {
parsedImports: [
{ kind: 'named', localName: 'alpha', importedName: 'alpha', targetRaw: 'A.ts' },
],
});
const out = finalizeScopeModel([fileB, fileA, fileC], {
hooks: {
resolveImportTarget: (targetRaw) => targetRaw,
namedImportsBindTopLevelOnly: topLevelOnly,
},
});
const edge = out.imports.get(fileC.moduleScope)?.[0];
expect(edge?.linkStatus, `topLevelOnly=${topLevelOnly}`).toBeUndefined();
expect(edge?.targetDefId, `topLevelOnly=${topLevelOnly}`).toBe('def:alpha');
}
});
it('Vue opts in (TS semantics); JS/TS themselves already do — every migrated resolver that sets the hook does so as `true`', () => {
expect(vueScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
expect(typescriptScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
expect(javascriptScopeResolver.namedImportsBindTopLevelOnly).toBe(true);
});
});