Address PR review feedback (#3632)

- Destructuring defaults (`{ fetch = globalThis.fetch } = {}`, renamed,
  array and local forms) are alias evidence for their leaf binding and
  no longer count a second initialization, which also invalidated
  bindings like `[send = other] = values`.
- `export default (request)` records the default export: the value is
  unwrapped before the identifier check.
- The HTTP fallback records and the collapsed-environment test row now
  say what they stand for.

Regression rows cover each form in JS, TS and TSX and fail on the
previous source; two fixture consumers cover imported wrappers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-10-20 08:43:45 +00:00
parent 0ce9235a64
commit c263c4f720
10 changed files with 86 additions and 4 deletions

View file

@ -106,6 +106,7 @@ const INSPECTED_TYPES: ReadonlySet<string> = new Set([
'required_parameter',
'optional_parameter',
'assignment_pattern',
'object_assignment_pattern',
'catch_clause',
'assignment_expression',
'augmented_assignment_expression',
@ -305,6 +306,17 @@ export function createFetchWrapperContext(
if (pattern?.type === 'identifier' && namesFetch(value)) aliases.add(id);
}
};
// A destructuring default supplies a value for its leaf binding only; the
// enclosing declaration or parameter has already counted the initialization.
const destructuringDefault = (target: Node | null, value: Node | null) => {
if (
(target?.type === 'identifier' || target?.type === 'shorthand_property_identifier_pattern') &&
namesFetch(value)
) {
const id = stableBinding(target, false);
if (id) aliases.add(id);
}
};
const exported = (local: Node, name: string) => {
const id = stableBinding(local, false);
if (!id) return;
@ -503,8 +515,10 @@ export function createFetchWrapperContext(
initialize(name, node.childForFieldName('value'));
} else if (type === 'required_parameter' || type === 'optional_parameter')
initialize(parameterPattern(node), node.childForFieldName('value'), true);
else if (type === 'assignment_pattern')
else if (type === 'assignment_pattern' && parent.type === 'formal_parameters')
initialize(node.childForFieldName('left'), node.childForFieldName('right'), true);
else if (type === 'assignment_pattern' || type === 'object_assignment_pattern')
destructuringDefault(node.childForFieldName('left'), node.childForFieldName('right'));
else if (type === 'catch_clause') markPattern(node.childForFieldName('parameter'));
else if (type === 'assignment_expression') {
invalidate(node.childForFieldName('left'));
@ -529,7 +543,11 @@ export function createFetchWrapperContext(
for (const local of patternBindingNames(declarator.childForFieldName('name')))
exported(local, isDefault ? 'default' : local.text);
}
} else if (isDefault && value?.type === 'identifier') exported(value, 'default');
} else if (isDefault) {
// `export default (request)` exports the binding the parentheses wrap.
const target = unwrap(value);
if (target?.type === 'identifier') exported(target, 'default');
}
} else if (
type === 'export_specifier' &&
// `export { x } from './y'` declares no local export.

View file

@ -3272,8 +3272,9 @@ const processFileGroup = (
}
if (parsedFile && !httpFailed) for (const def of wrapperDefs) result.fetchWrapperDefs.push(def);
else
// Without this file's facts its functions still name exports, so a
// name-only match elsewhere declines instead of turning unique.
// These records stand for unknown facts. With `seed` false and nothing
// forwarded they never prove a wrapper; they only make a name-only
// match, a barrel or an import that may reach this file decline.
for (const { name, node } of wrapperFunctions)
result.fetchWrapperDefs.push({
filePath: file.path,

View file

@ -0,0 +1,6 @@
function parenRequest(url: string) {
return fetch(url);
}
// The parentheses are the case under test.
// prettier-ignore
export default (parenRequest);

View file

@ -0,0 +1,3 @@
export function destructuredRequest(url: string, { fetch = globalThis.fetch } = {}) {
return fetch(url);
}

View file

@ -26,3 +26,5 @@ app.get('/api/diamond', (_req, res) => res.json({}));
app.get('/api/diamond-distinct', (_req, res) => res.json({}));
app.get('/api/cjs-member', (_req, res) => res.json({}));
app.get('/api/cjs-arrow', (_req, res) => res.json({}));
app.get('/api/destructured', (_req, res) => res.json({}));
app.get('/api/default-paren', (_req, res) => res.json({}));

View file

@ -0,0 +1,2 @@
import parenRequest from '../lib/default-paren';
export const loadDefaultParen = () => parenRequest('/api/default-paren');

View file

@ -0,0 +1,2 @@
import { destructuredRequest } from '../lib/destructured';
export const loadDestructured = () => destructuredRequest('/api/destructured');

View file

@ -90,6 +90,8 @@ describe('Fetch wrapper lexical and project target identity', () => {
'web/cjs-member.js -> /api/cjs-arrow',
'web/cjs-member.js -> /api/cjs-member',
'web/cjs.js -> /api/cjs',
'web/default-paren.ts -> /api/default-paren',
'web/destructured.ts -> /api/destructured',
'web/diamond.ts -> /api/diamond',
'web/hoc.ts -> /api/hoc',
'web/multi.ts -> /api/multi',

View file

@ -39,6 +39,37 @@ for (const [language, grammar] of [
true,
true,
],
// A destructuring default is alias evidence for its leaf binding, counted once.
[
'destructured platform default',
'function load(url, { fetch = globalThis.fetch } = {}) { fetch(url); }',
true,
true,
],
[
'renamed destructured platform default',
'function load(url, { fetch: send = globalThis.fetch } = {}) { send(url); }',
true,
true,
],
[
'array destructured platform default',
'function load(url, [send = globalThis.fetch] = []) { send(url); }',
true,
true,
],
[
'destructured local default',
'function load(url, options) { const { fetch = globalThis.fetch } = options; fetch(url); }',
true,
true,
],
[
'destructured unknown default',
'function load(url, { fetch = other } = {}) { fetch(url); }',
false,
false,
],
['self default', 'function load(url, fetch = fetch) { fetch(url); }', false, false],
[
'shadowed default root',
@ -494,6 +525,18 @@ for (const [language, grammar] of [
]);
});
it.each([
['a plain', 'function request(url) { return fetch(url); }\nexport default request;'],
[
'a parenthesized',
'function request(url) { return fetch(url); }\nexport default (request);',
],
])('records %s default export of a declaration', (_shape, source) => {
const { tree, context } = analyze(source);
const request = tree.rootNode.descendantsOfType('function_declaration')[0];
expect(context.facts(request)).toMatchObject({ exportedNames: ['default'], seed: true });
});
it('recognizes an anonymous default export', () => {
const { tree, context } = analyze('export default function (url) { return fetch(url); }');
expect(context.facts(tree.rootNode.firstNamedChild!)).toMatchObject({

View file

@ -285,6 +285,9 @@ function handler() {} function other() {}`);
'function inner(app) { app = new Map(); }',
['GET /forwarded'],
],
// A parameter expression gives the body its own variable environment, which
// the scope model collapses, so inference declines a parameter that a body
// `var` redeclares (the documented boundary); runtime would keep its value.
['nested destructuring default', 'app, { value = 1 }', 'var app;', []],
['computed pattern key', 'app, { [key]: value }', 'var app;', []],
['rest parameter alone', 'app, ...rest', 'var app;', ['GET /forwarded']],