From c263c4f72089cb07ce1de9033f7aa6b564c914ce Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Tue, 20 Oct 2026 08:43:45 +0000 Subject: [PATCH] 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 --- .../route-extractors/fetch-wrapper.ts | 22 +++++++++- .../core/ingestion/workers/parse-worker.ts | 5 ++- .../lib/default-paren.ts | 6 +++ .../lib/destructured.ts | 3 ++ .../server/api.ts | 2 + .../web/default-paren.ts | 2 + .../web/destructured.ts | 2 + .../resolvers/fetch-wrapper-consumers.test.ts | 2 + .../unit/fetch-wrapper-provenance.test.ts | 43 +++++++++++++++++++ .../unit/framework-route-provenance.test.ts | 3 ++ 10 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib/default-paren.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib/destructured.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web/default-paren.ts create mode 100644 gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web/destructured.ts diff --git a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts index 481d13126..556382279 100644 --- a/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts +++ b/gitnexus/src/core/ingestion/route-extractors/fetch-wrapper.ts @@ -106,6 +106,7 @@ const INSPECTED_TYPES: ReadonlySet = 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. diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index ae53fdbee..65c8c71d1 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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, diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib/default-paren.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib/default-paren.ts new file mode 100644 index 000000000..8f9e2bd42 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib/default-paren.ts @@ -0,0 +1,6 @@ +function parenRequest(url: string) { + return fetch(url); +} +// The parentheses are the case under test. +// prettier-ignore +export default (parenRequest); diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib/destructured.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib/destructured.ts new file mode 100644 index 000000000..5e2a44b11 --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/lib/destructured.ts @@ -0,0 +1,3 @@ +export function destructuredRequest(url: string, { fetch = globalThis.fetch } = {}) { + return fetch(url); +} diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/server/api.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/server/api.ts index 4c9bd8b19..ce2f2a59b 100644 --- a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/server/api.ts +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/server/api.ts @@ -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({})); diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web/default-paren.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web/default-paren.ts new file mode 100644 index 000000000..9eb8bf4ac --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web/default-paren.ts @@ -0,0 +1,2 @@ +import parenRequest from '../lib/default-paren'; +export const loadDefaultParen = () => parenRequest('/api/default-paren'); diff --git a/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web/destructured.ts b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web/destructured.ts new file mode 100644 index 000000000..3c82cfd1e --- /dev/null +++ b/gitnexus/test/fixtures/lang-resolution/fetch-wrapper-target-identity/web/destructured.ts @@ -0,0 +1,2 @@ +import { destructuredRequest } from '../lib/destructured'; +export const loadDestructured = () => destructuredRequest('/api/destructured'); diff --git a/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts b/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts index 10dfba84f..a72641483 100644 --- a/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts +++ b/gitnexus/test/integration/resolvers/fetch-wrapper-consumers.test.ts @@ -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', diff --git a/gitnexus/test/unit/fetch-wrapper-provenance.test.ts b/gitnexus/test/unit/fetch-wrapper-provenance.test.ts index 22277ffa5..9c577a416 100644 --- a/gitnexus/test/unit/fetch-wrapper-provenance.test.ts +++ b/gitnexus/test/unit/fetch-wrapper-provenance.test.ts @@ -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({ diff --git a/gitnexus/test/unit/framework-route-provenance.test.ts b/gitnexus/test/unit/framework-route-provenance.test.ts index 6a1034962..e5caa74c6 100644 --- a/gitnexus/test/unit/framework-route-provenance.test.ts +++ b/gitnexus/test/unit/framework-route-provenance.test.ts @@ -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']],