fix(ir): walk anonymous function args of call declarations#1
Conversation
|
Really nice work. Bounded traversal with both budgets, AST-first import extraction (the regex bug fix was a great catch), and 8 tests covering the real-world patterns make this easy to trust. Two small follow-ups before merge:
Once those are in, happy to merge. Thanks for the careful work. |
Surfaces handler methods hidden inside factory patterns like
const X = call(() => { return { a, b } }). This covers Effect.gen,
Rpc.toLayer(Effect.gen(...)).pipe(Layer.provide(...)), and similar
config-object DSLs across the ecosystem.
Also fixes multi-line import source extraction: the previous
regex-on-truncated-text approach lost the module name for imports
that exceeded 80 chars. Now extracts the source directly from the AST.
Tests: 155 existing pass; 8 new in tests/ir/factory-declarations.test.ts.
Handles { send, list } and { send(x) {} } returned from factory bodies,
common in Vite, TanStack, Zustand style APIs.
Addresses review feedback on mertcanaltin#1.
1b52d0d to
d625e58
Compare
|
Thanks for the thorough review! Addressed both follow-ups in d625e58:
All 166 tests pass (155 original + 8 factory + 3 shorthand/method). Ready for another look when you have a moment. |
#1 Prepend SUPERSEDED banners to blastradius-proof.md and -v2.md (linked from the site): the old 2-3 repo 'ship gate cleared' numbers did not generalize; 4-repo backtest shows recall 0.67-0.80 but precision caps ~0.55 (advisory, not a gate). Readers clicking through now land on the corrected story. #2 Add prepublishOnly: npm run build, so publish can never ship stale dist.
Fixes the real-world miss where --target silently fails on naming-convention mismatches (widget-list-v2 vs WidgetListV2) and the agent then reads raw. New resolveTarget tries identifier variants (kebab/snake -> Pascal/camel), then filename, then quoted-literal/call-site reference, and reports HOW it matched. context now prints a coverage line (high/medium/low/none) + a hint when missing; --json exposes a coverage field. Addresses live agent feedback (#1 coverage, #2 target reliability, #3 alias map).
TL;DR
Composto currently misses handler methods hidden inside factory patterns like
const X = call(() => ({ a, b })). This PR teaches the AST walker to descend into anonymous-function call arguments and returned handler objects, plus fixes multi-line import source extraction that got mangled by text truncation.Motivation
On Effect-TS codebases (and any DSL that takes a config object of callbacks), the real API surface lives inside expressions like:
The walker currently treats the outer call as opaque (
T4_DROPon deep call expressions,nullreturn forlexical_declarationwithcall_expressionvalue at line 464), so the three handlers are invisible in IR even though they're the only thing a caller needs to know.Example 1: minimal Effect.gen RPC
Input (~20 lines):
Before:
Import mangled (regex on truncated text can't find
from "..."). Declaration invisible. Zero handlers surfaced.After:
Example 2: real 669-line Effect RPC file
From a production codebase (
apps/nokta/app/rpc/chat-rpc-live.ts, 669 lines, 9 handlers).Before (L1 output):
Two useful lines. The module list contains garbage fragments (
import { ChatCreationError, ChatListError, ... type C...) because the truncation regex couldn't recover the source. All 9 handlers missing.After:
All 9 handlers with parameter signatures. Module list clean.
Example 3: async methods get tagged
Input:
After:
Example 4: sibling helpers still appear
Input:
After:
Helpers defined alongside the returned handler object aren't lost.
Scope - not Effect-specific
The pattern
const X = factoryCall(configOrCallback)is everywhere. The walker now sees into all of these:Effect.gen(function* () {...}),Rpc.toLayer(...).pipe(...),Layer.effect(...)createSlice({ reducers: {...} })createRouter,createRoutedefineConfig(() => ({...}))create((set, get) => ({...}))defineStore(id, () => ({...}))Implementation
Three focused changes in
src/ir/ast-walker.ts(+182/-6 lines, zero changes to existing behavior on non-factory code):findImportSource(node)- pulls module path fromimport_statement > string > string_fragmentvia AST fields. Replaces the pass-1 regex that ran on 80-char-truncated text and silently dropped source names for long named-import lists.collectAnonFnArgs(call)- walks a call-expression tree to find anonymous functions. Descends through:factory(fn))outer(inner(fn)), e.g.Rpc.toLayer(Effect.gen(fn)))X.pipe(...)wrappers so the factory insideXis reached)Budget-limited to 4 levels, cycle-guarded via
Set<node.id>.walkFactoryBody(fn, ...)+emitObjectMethods(obj, ...)- for each anon function found, locates itsreturn_statement > objectand emits eachpairwith anarrow_function/function_expressionvalue asMETHOD:<key><params>. Skips_and#prefixed keys (consistent withmethod_definitionhandling at line 337). TagsASYNC METHOD:where applicable. Walks the rest of the body normally so sibling helpers still appear.A gatekeeper
isFactoryDeclaration()ensures this logic only fires onconst X = call(fn)shape, not every T3lexical_declaration. Plainconst config = loadConfig({...})without anonymous function args is unaffected.Tests
tests/ir/factory-declarations.test.ts:.pipe(Layer.provide(...))wrapper unwrappingASYNC METHOD:tagging_/#private key filteringCompression impact
Benchmark on 26 Effect RPC files (99,342 raw tokens):
L1 grew by ~1,350 tokens (1.3pp) to include all the new
METHOD:signatures. That's the information callers actually needed and weren't getting before - the old IR hit 96% compression partly by discarding handler signatures. Net signal-per-token rises substantially: before, an LLM consuming the L1 IR of a 669-line RPC file saw zero handlers; after, it sees all nine with parameter types.L0 is unchanged because factory emissions at T3 don't add top-level declarations.