fix(routes): treat export aliases of one wrapper as one star-export target

Star-export candidates were deduplicated by the resolved export name, so
`export { request, request as send }` reached through two barrels counted
as two targets and blocked the import, dropping the consumer's FETCHES
edge. Candidates are now keyed by the declaration each export name names,
matching ECMAScript ResolveExport's binding comparison; distinct
declarations exported under the same name stay ambiguous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-10-19 15:49:47 +00:00
parent e85795b78e
commit 4d62bd0451
13 changed files with 67 additions and 4 deletions

View file

@ -141,10 +141,17 @@ export function resolveFetchWrapperTargets(
}
return targets;
};
const declared = new Set<string>();
// Each export name maps to the declaration it names; a name two declarations
// claim keeps a name-only identity, so it never merges with another export.
const declared = new Map<string, string>();
for (const definition of definitions)
if (definition.moduleScope)
for (const name of definition.exportedNames) declared.add(`${definition.filePath}\0${name}`);
for (const name of definition.exportedNames) {
const key = `${definition.filePath}\0${name}`;
const identity = `${definition.filePath}\0${definition.definitionId}`;
const existing = declared.get(key);
declared.set(key, existing === undefined || existing === identity ? identity : `${key}\0`);
}
const named = new Map<string, FetchWrapperReexport[]>();
const stars = new Map<string, FetchWrapperReexport[]>();
for (const reexport of reexports) {
@ -181,8 +188,16 @@ export function resolveFetchWrapperTargets(
named.get(key) ?? (name === 'default' ? undefined : stars.get(filePath)) ?? [];
return { frame: { filePath, name, candidates, cursor: 0, found: new Map() } };
};
// Star exports conflict only when they reach different bindings, so two
// export names of one declaration are one candidate.
const record = (frame: ExportFrame, target: CalleeTarget | undefined) => {
if (target) frame.found.set(JSON.stringify(target), target);
if (!target) return;
const identity =
target.kind === 'resolved-import'
? declared.get(`${target.filePath}\0${target.importedName}`)
: undefined;
const key = identity ?? JSON.stringify(target);
if (!frame.found.has(key)) frame.found.set(key, target);
};
const first = open(startFile, startName);
if ('value' in first) return first.value;

View file

@ -0,0 +1,6 @@
export function distinctRequest(url: string) {
return fetch(url);
}
export function distinctSend(url: string) {
return fetch(url);
}

View file

@ -0,0 +1,2 @@
export * from './left';
export * from './right';

View file

@ -0,0 +1 @@
export { distinctRequest } from './http';

View file

@ -0,0 +1 @@
export { distinctSend as distinctRequest } from './http';

View file

@ -0,0 +1,4 @@
function diamondRequest(url: string) {
return fetch(url);
}
export { diamondRequest, diamondRequest as diamondSend };

View file

@ -0,0 +1,2 @@
export * from './left';
export * from './right';

View file

@ -0,0 +1 @@
export { diamondRequest } from './http';

View file

@ -0,0 +1 @@
export { diamondSend as diamondRequest } from './http';

View file

@ -22,3 +22,5 @@ app.get('/api/hoc', (_req, res) => res.json({}));
app.get('/api/cjs', (_req, res) => res.json({}));
app.get('/api/key', (_req, res) => res.json({}));
app.get('/api/node', (_req, res) => res.json({}));
app.get('/api/diamond', (_req, res) => res.json({}));
app.get('/api/diamond-distinct', (_req, res) => res.json({}));

View file

@ -0,0 +1,4 @@
import { diamondRequest } from '../lib/diamond';
import { distinctRequest } from '../lib/diamond-distinct';
export const loadDiamond = () => diamondRequest('/api/diamond');
export const loadDistinct = () => distinctRequest('/api/diamond-distinct');

View file

@ -70,7 +70,7 @@ describe('Base-URL wrapper consumer FETCHES edge extraction', () => {
});
describe('Fetch wrapper lexical and project target identity', () => {
it('resolves relative, alias, workspace, barrel, overload, multi-declarator, HOC, CommonJS and package-fetch targets while rejecting shadowed and ambiguous names', async () => {
it('resolves relative, alias, workspace, barrel, export-alias diamond, overload, multi-declarator, HOC, CommonJS and package-fetch targets while rejecting shadowed and ambiguous names', async () => {
const result = await runPipelineFromRepo(
path.join(FIXTURES, 'fetch-wrapper-target-identity'),
() => {},
@ -88,6 +88,7 @@ describe('Fetch wrapper lexical and project target identity', () => {
'web/barrel.ts -> /api/barrel-imported',
'web/barrel.ts -> /api/barrel-star',
'web/cjs.js -> /api/cjs',
'web/diamond.ts -> /api/diamond',
'web/hoc.ts -> /api/hoc',
'web/multi.ts -> /api/multi',
'web/nested.ts -> /api/nested',

View file

@ -388,6 +388,29 @@ describe('fetch wrapper barrel resolution', () => {
expect(resolveIn(reexports)).toEqual(expected);
});
// `export * from './left'; export * from './right'`, where left re-exports
// `request` and right re-exports `send as request` from the same module.
const diamond = [
forward('index.ts', '*', './left', '*'),
forward('index.ts', '*', './right', '*'),
forward('left.ts', 'request', './http'),
forward('right.ts', 'request', './http', 'send'),
];
it.each([
[
'two export names of one declaration',
[{ ...declares('http.ts', 'request'), exportedNames: ['request', 'send'] }],
at('http.ts'),
],
[
'two declarations',
[declares('http.ts', 'request'), declares('http.ts', 'send')],
{ kind: 'blocked' },
],
])('resolves a star-export diamond over %s by binding identity', (_shape, defs, expected) => {
expect(resolveIn(diamond, 'request', defs)).toEqual(expected);
});
it('never forwards default through a star export', () => {
expect(
resolveIn([forward('index.ts', '*', './request', '*')], 'default', [