@@ -179,18 +179,44 @@ function rootCall(call: ts.CallExpression): ts.CallExpression {
179179 }
180180}
181181
182- function fromInitializer ( expr : ts . Expression , out : EntryFunction [ ] ) : void {
182+ /**
183+ * The handler functions an export's initializer resolves to.
184+ *
185+ * `locals` is consulted for the two indirect spellings, both of which `scan.ts` resolves and
186+ * neither of which this reached: `export const action = route.action` beside
187+ * `const route = createActionApiRoute(...)`, which is 7 of the tree's API routes, and
188+ * `export const action = handleThing` naming a local. `seen` stops `const a = b; const b = a`.
189+ */
190+ function fromInitializer (
191+ expr : ts . Expression ,
192+ out : EntryFunction [ ] ,
193+ locals : LocalDeclarations ,
194+ seen : Set < string > = new Set ( )
195+ ) : void {
183196 const target = unwrap ( expr ) ;
184197 if ( isEntryFunction ( target ) ) {
185198 out . push ( target ) ;
186199 return ;
187200 }
188- if ( ! ts . isCallExpression ( target ) ) return ;
189- for ( const arg of rootCall ( target ) . arguments ) {
190- const unwrapped = unwrap ( arg ) ;
191- if ( isEntryFunction ( unwrapped ) ) out . push ( unwrapped ) ;
192- else if ( ts . isObjectLiteralExpression ( unwrapped ) ) collectNamedHandlers ( unwrapped , out ) ;
201+ if ( ts . isCallExpression ( target ) ) {
202+ for ( const arg of rootCall ( target ) . arguments ) {
203+ const unwrapped = unwrap ( arg ) ;
204+ if ( isEntryFunction ( unwrapped ) ) out . push ( unwrapped ) ;
205+ else if ( ts . isObjectLiteralExpression ( unwrapped ) ) collectNamedHandlers ( unwrapped , out ) ;
206+ }
207+ return ;
193208 }
209+ // `route.action`, and any longer chain, is resolved from whatever declared its root identifier.
210+ let root : ts . Expression = target ;
211+ while ( ts . isPropertyAccessExpression ( root ) || ts . isElementAccessExpression ( root ) ) {
212+ root = unwrap ( root . expression ) ;
213+ }
214+ if ( ! ts . isIdentifier ( root ) || seen . has ( root . text ) ) return ;
215+ const declaration = locals . get ( root . text ) ;
216+ if ( declaration === undefined ) return ;
217+ seen . add ( root . text ) ;
218+ if ( ts . isFunctionDeclaration ( declaration ) ) out . push ( declaration ) ;
219+ else fromInitializer ( declaration , out , locals , seen ) ;
194220}
195221
196222const ENTRY_NAMES = new Set ( [ "loader" , "action" ] ) ;
@@ -202,23 +228,109 @@ function isExported(node: ts.Node): boolean {
202228 ) ;
203229}
204230
205- /** Block bodies of the exported `loader`/`action` handlers, the region a whole-body wrapper wraps. */
206- function entryBodies ( sf : ts . SourceFile ) : ts . Block [ ] {
207- const functions : EntryFunction [ ] = [ ] ;
231+ /**
232+ * Top-level declarations by binding name, so a named export clause (`export { action }`) resolves
233+ * back to the initializer it came from. Object binding patterns are read element by element, which
234+ * is what makes `const { action, loader } = createActionApiRoute(...)` resolvable.
235+ */
236+ type LocalDeclarations = Map < string , ts . Expression | ts . FunctionDeclaration > ;
237+
238+ function localDeclarations ( sf : ts . SourceFile ) : LocalDeclarations {
239+ const locals : LocalDeclarations = new Map ( ) ;
208240 for ( const statement of sf . statements ) {
209- if ( ! isExported ( statement ) ) continue ;
210241 if ( ts . isFunctionDeclaration ( statement ) && statement . name ) {
211- if ( ENTRY_NAMES . has ( statement . name . text ) ) functions . push ( statement ) ;
242+ locals . set ( statement . name . text , statement ) ;
212243 continue ;
213244 }
214245 if ( ! ts . isVariableStatement ( statement ) ) continue ;
215246 for ( const decl of statement . declarationList . declarations ) {
216- if ( ! decl . initializer || ! ts . isIdentifier ( decl . name ) ) continue ;
217- if ( ENTRY_NAMES . has ( decl . name . text ) ) fromInitializer ( decl . initializer , functions ) ;
247+ if ( ! decl . initializer ) continue ;
248+ if ( ts . isIdentifier ( decl . name ) ) {
249+ locals . set ( decl . name . text , decl . initializer ) ;
250+ continue ;
251+ }
252+ if ( ts . isObjectBindingPattern ( decl . name ) ) {
253+ for ( const element of decl . name . elements ) {
254+ if ( ts . isIdentifier ( element . name ) ) locals . set ( element . name . text , decl . initializer ) ;
255+ }
256+ }
218257 }
219258 }
259+ return locals ;
260+ }
261+
262+ /**
263+ * Block bodies of the exported `loader`/`action` handlers, the region a whole-body wrapper wraps.
264+ *
265+ * Reads the same four export forms `scan.ts` reads: an exported function declaration, an exported
266+ * `const`, an exported object binding pattern, and a named export clause resolved back through a
267+ * local. It read only the first two, which is the shape of every API route in the tree
268+ * (`const { action, loader } = createActionApiRoute(...); export { action, loader };` and the
269+ * direct `export const { action } = ...`), so `wrapEveryBody` and the other whole-body entries
270+ * silently skipped 36 of the 427 entry points while reporting a file count that suggested
271+ * otherwise. `mutationCorpus.test.ts` pins the population now ("wraps a body in every
272+ * non-delegating entry point the scanner finds"), so the harness cannot lag the scanner here again
273+ * without going red.
274+ *
275+ * This is NOT a retreat from the deliberate independence `collectNamedHandlers` documents. That
276+ * independence is about disagreeing over where a HANDLER sits inside a builder's argument, which is
277+ * a judgement the corpus has to be able to make for itself. Which exports exist is not a judgement,
278+ * and the harness was simply behind.
279+ */
280+ function entryBodies ( sf : ts . SourceFile ) : ts . Block [ ] {
281+ const functions : EntryFunction [ ] = [ ] ;
282+ const locals = localDeclarations ( sf ) ;
283+ const fromLocal = ( name : string ) => {
284+ const decl = locals . get ( name ) ;
285+ if ( decl === undefined ) return ;
286+ if ( ts . isFunctionDeclaration ( decl ) ) functions . push ( decl ) ;
287+ else fromInitializer ( decl , functions , locals ) ;
288+ } ;
289+
290+ for ( const statement of sf . statements ) {
291+ if ( ts . isFunctionDeclaration ( statement ) && statement . name && isExported ( statement ) ) {
292+ if ( ENTRY_NAMES . has ( statement . name . text ) ) functions . push ( statement ) ;
293+ continue ;
294+ }
295+
296+ if ( ts . isVariableStatement ( statement ) && isExported ( statement ) ) {
297+ for ( const decl of statement . declarationList . declarations ) {
298+ if ( ! decl . initializer ) continue ;
299+ if ( ts . isIdentifier ( decl . name ) ) {
300+ if ( ENTRY_NAMES . has ( decl . name . text ) ) fromInitializer ( decl . initializer , functions , locals ) ;
301+ continue ;
302+ }
303+ if ( ! ts . isObjectBindingPattern ( decl . name ) ) continue ;
304+ for ( const element of decl . name . elements ) {
305+ if ( ts . isIdentifier ( element . name ) && ENTRY_NAMES . has ( element . name . text ) ) {
306+ fromInitializer ( decl . initializer , functions , locals ) ;
307+ }
308+ }
309+ }
310+ continue ;
311+ }
312+
313+ // A re-export (`export { loader } from "./x"`) has no local binding to resolve, and a namespace
314+ // clause cannot name a loader or an action.
315+ if (
316+ ts . isExportDeclaration ( statement ) &&
317+ statement . exportClause &&
318+ ! statement . moduleSpecifier &&
319+ ts . isNamedExports ( statement . exportClause )
320+ ) {
321+ for ( const element of statement . exportClause . elements ) {
322+ if ( ! ENTRY_NAMES . has ( element . name . text ) ) continue ;
323+ fromLocal ( element . propertyName ?. text ?? element . name . text ) ;
324+ }
325+ }
326+ }
327+
328+ // One handler can serve both exports, and both reach it by their own road: the loader and the
329+ // action of `const { loader, action } = createActionApiRoute({ handler })` resolve to the same
330+ // node. Wrapping it twice would splice the same text in twice at the same offset, because
331+ // `applyEdits` treats two zero-width inserts at one position as non-overlapping.
220332 const bodies : ts . Block [ ] = [ ] ;
221- for ( const fn of functions ) {
333+ for ( const fn of new Set ( functions ) ) {
222334 if ( fn . body && ts . isBlock ( fn . body ) ) bodies . push ( fn . body ) ;
223335 }
224336 return bodies ;
0 commit comments