From 7abeaf29c0df08d9db426c50117730268ede32ad Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Wed, 7 Oct 2026 10:19:44 -0500 Subject: [PATCH] fix(js,ts): a factory's argument is not the module's default export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `export default createStore(reducer)` exports a store, but the wrapper reading took `reducer` as the default, so `store.dispatch()` through the default import linked the reducer. A wrapper call now hands on its argument only when it is a component's name (`memo(Card)`, `observer(function Settings…)`) or ends a curried chain (`connect(mapState)(view)`, `traceFunction({…})(provision)`). Co-Authored-By: Claude Opus 5.5 --- __tests__/jsx-child-imports.test.ts | 32 +++++++++++++++++++++++++++++ src/resolution/import-resolver.ts | 31 ++++++++++++++++++++-------- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/__tests__/jsx-child-imports.test.ts b/__tests__/jsx-child-imports.test.ts index b7abf3d3a..809edde0c 100644 --- a/__tests__/jsx-child-imports.test.ts +++ b/__tests__/jsx-child-imports.test.ts @@ -358,6 +358,38 @@ export function start() { return makeApp(); } expect(callees).toEqual(['createApp']); }); + it('does not reach the argument of a factory a module exports by default', async () => { + fs.writeFileSync( + path.join(dir, 'factory.ts'), + `export function createStore(r: (s: unknown) => unknown) { return { dispatch(a: unknown) { return r(a); } }; }\n` + ); + fs.writeFileSync( + path.join(dir, 'store.ts'), + `import { createStore } from './factory'; +function reducer(state: unknown) { return state; } +export default createStore(reducer); +` + ); + fs.writeFileSync( + path.join(dir, 'main.ts'), + `import store from './store'; +export function start() { return store.dispatch({ type: 'go' }); } +` + ); + cg = await CodeGraph.init(dir, { silent: true }); + await cg.indexAll(); + const callees = (cg as any).db.db + .prepare( + `SELECT t.name AS n FROM edges e + JOIN nodes s ON s.id = e.source + JOIN nodes t ON t.id = e.target + WHERE s.name = 'start' AND e.kind = 'calls'` + ) + .all() + .map((r: any) => r.n); + expect(callees).not.toContain('reducer'); + }); + it('reaches the function a wrapper call hands on, and the methods of an exported instance', async () => { fs.writeFileSync( path.join(dir, 'tracing.ts'), diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 8e38be073..6201904d5 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -162,17 +162,19 @@ function skipTypeArguments(code: string, at: number): number { * function or class (`function Vans() {…}`, `class Baz …`), or what a wrapper * call wraps — the first argument of its last call, read the same way * (`memo(Foo)`, `connect(mapState)(Bar)`, `withRouter(connect(m)(Bar))`, - * `observer(function Settings() {…})`, `styled(Button)\`…\``), or the class of an - * instance (`new Storage()`, whose members are the class's). Null for one that - * names nothing: an arrow function, an object literal, `Primitive.Root`, a - * call on a literal (`requireNativeComponent('X')`). `code` is comment-free. + * `observer(function Settings() {…})`, `styled(Button)\`…\``) when it is a + * component's name or ends a curried chain (see {@link handsOn}) — or the + * class of an instance (`new Storage()`, whose members are the class's). Null + * for one that names nothing: an arrow function, an object literal, + * `Primitive.Root`, a call on a literal (`requireNativeComponent('X')`), a + * factory's argument (`createStore(reducer)`). `code` is comment-free. */ -function namedBindingAt(code: string, at: number, wrapped = false, depth = 0): NamedBinding | null { +function namedBindingAt(code: string, at: number, wrapped = false, depth = 0, curried = false): NamedBinding | null { const head = code.slice(at, at + 200); const declared = /^(?:async\s+)?function\b\s*\*?\s*([A-Za-z_$][\w$]*)?|^(?:abstract\s+)?class\b(?:\s+(?!extends\b|implements\b)([A-Za-z_$][\w$]*))?/.exec(head); if (declared) { const name = declared[1] ?? declared[2]; - return name ? { name, wrapped, nameAt: at + declared[0].length - name.length } : null; + return name && handsOn(name, wrapped, curried) ? { name, wrapped, nameAt: at + declared[0].length - name.length } : null; } const constructed = /^new\s+([A-Za-z_$][\w$]*)\b(?!\s*\.)/.exec(head); if (constructed) return { name: constructed[1]!, wrapped: true }; @@ -182,7 +184,7 @@ function namedBindingAt(code: string, at: number, wrapped = false, depth = 0): N while (code[i] === ' ' || code[i] === '\t') i++; // A name on its own, up to the end of its statement or argument. if (!callee.includes('.') && /^(?:[;,)\]}]|\r?\n|$|(?:as|satisfies)\b)/.test(code.slice(i, i + 12))) { - return { name: callee, wrapped }; + return handsOn(callee, wrapped, curried) ? { name: callee, wrapped } : null; } i = skipSpace(code, i); if (code[i] === '<') { @@ -193,9 +195,11 @@ function namedBindingAt(code: string, at: number, wrapped = false, depth = 0): N // The call chain: `(…)(…)` and tagged templates; the last argument list is the one that wraps. // One whose end is out of reach (an apostrophe in JSX text reads as a quote) is the last one seen. let args = -1; + let calls = 0; for (;;) { if (code[i] === '(') { args = i; + calls++; const close = matchBracket(code, i); if (close < 0) break; i = skipSpace(code, close + 1); @@ -207,7 +211,18 @@ function namedBindingAt(code: string, at: number, wrapped = false, depth = 0): N } // `createSlice(…).reducer` hands on a member of what the call returns. if (args < 0 || code[i] === '.' || depth >= 4) return null; - return namedBindingAt(code, skipSpace(code, args + 1), true, depth + 1); + return namedBindingAt(code, skipSpace(code, args + 1), true, depth + 1, calls > 1); +} + +/** + * Whether a wrapper call hands on the name it is given: a component's name + * (`memo(Card)`, `observer(function Settings() {…})`), or whatever ends a + * curried chain (`connect(mapState)(view)`, `traceFunction({…})(provision)`). + * `createStore(reducer)` hands on a store, not `reducer`. A name the + * expression holds on its own is always handed on. + */ +function handsOn(name: string, wrapped: boolean, curried: boolean): boolean { + return !wrapped || curried || /^[A-Z]/.test(name); } /**