Skip to content

fix(ir): walk anonymous function args of call declarations#1

Open
Necmttn wants to merge 2 commits into
mertcanaltin:masterfrom
Necmttn:fix/walk-factory-calls
Open

fix(ir): walk anonymous function args of call declarations#1
Necmttn wants to merge 2 commits into
mertcanaltin:masterfrom
Necmttn:fix/walk-factory-calls

Conversation

@Necmttn

@Necmttn Necmttn commented Apr 13, 2026

Copy link
Copy Markdown

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:

export const ChatRpcLive = ChatRpc.toLayer(
  Effect.gen(function* () {
    const repo = yield* ChatRepo;
    return {
      chat_list: (input) => Effect.gen(function* () { ... }),
      chat_create: (input) => Effect.gen(function* () { ... }),
      chat_delete: (input) => Effect.gen(function* () { ... }),
    };
  })
).pipe(Layer.provide(ChatRepo.Default));

The walker currently treats the outer call as opaque (T4_DROP on deep call expressions, null return for lexical_declaration with call_expression value 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):

import {
  ChatCreationError, ChatListError, ChatNotFoundError, ChatRpc,
  type CreateChatInput, type ListChatsInput,
} from "@nokta/rpc";

export const ChatRpcLive = ChatRpc.toLayer(
  Effect.gen(function* () {
    const repo = yield* ChatRepo;
    return {
      chat_list:   ({ input }: { input: ListChatsInput })   => Effect.gen(function* () { ... }),
      chat_create: ({ input }: { input: CreateChatInput })  => Effect.gen(function* () { ... }),
      chat_delete: ({ chatId }: { chatId: ChatId })         => Effect.gen(function* () { ... }),
    };
  })
).pipe(Layer.provide(ChatRepo.Default));

Before:

USE:import { ChatCreationError, ChatListError, ChatNotFoundError, ChatRpc, type C...

Import mangled (regex on truncated text can't find from "..."). Declaration invisible. Zero handlers surfaced.

After:

USE:@nokta/rpc
    OUT FN:ChatRpcLive
      METHOD:chat_list({ input }: { input: ListChatsInput })
      METHOD:chat_create({ input }: { input: CreateChatInput })
      METHOD:chat_delete({ chatId }: { chatId: ChatId })

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):

USE:[drizzle-orm, effect, effect/Schema, noktaconnect, @nokta/domain/user, @nokta/prompt-composer, @nokta/auth, @nokta/config, @nokta/database, @nokta/domain, @nokta/domain/chat, @nokta/domain/context, @nokta/domain/message, @nokta/domain/team, @nokta/domain/team-member, @nokta/prompt-composer, @nokta/providers, import { ChatCreationError, ChatListError, ChatNotFoundError, ChatRpc, type C..., ../policies/chat.policy.ts, ../policies/index.ts, ../processors/system-status.ts, import { renderWorkingMemoryBlock, WorkingMemorySchema } from "../processors/..., ./chat-streaming-rpc-live.ts, ./mappers/pg-chat.mapper]
  FN:truncatePreview(text: string, max = 300) => ...

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:

USE:[drizzle-orm, effect, effect/Schema, noktaconnect, @nokta/domain/user, @nokta/prompt-composer, @nokta/auth, @nokta/config, @nokta/database, @nokta/domain, @nokta/domain/chat, @nokta/domain/context, @nokta/domain/message, @nokta/domain/team, @nokta/domain/team-member, @nokta/prompt-composer, @nokta/providers, @nokta/rpc, ../policies/chat.policy.ts, ../policies/index.ts, ../processors/system-status.ts, ../processors/working-memory.ts, ./chat-streaming-rpc-live.ts, ./mappers/pg-chat.mapper]
  FN:truncatePreview(text: string, max = 300) => ...
    OUT FN:ChatRpcLive
      METHOD:chat_list({ input }: { input: ListChatsInput })
      METHOD:chat_get({ chatId }: { chatId: ChatId })
      METHOD:chat_create({ input }: { input: CreateChatInput })
      METHOD:chat_update({ input }: { input: UpdateChatInput })
      METHOD:chat_delete({ chatId }: { chatId: ChatId })
      METHOD:chat_listTeamMembers({ teamId: _teamId }: { teamId: TeamI...
      METHOD:chat_availableSources({ teamSlug }: { teamSlug: string })
      METHOD:debug_prompt_view({ chatId }: { chatId: ChatId })
      METHOD:chat_resolveSourceUrl({ sourceType, sourceId }: { sourceTy...

All 9 handlers with parameter signatures. Module list clean.

Example 3: async methods get tagged

Input:

export const X = make(Effect.gen(function* () {
  return { fetch: async (id: string) => db.get(id) };
}));

After:

OUT FN:X
  ASYNC METHOD:fetch(id: string)

Example 4: sibling helpers still appear

Input:

export const Live = make(Effect.gen(function* () {
  const repo = yield* Repo;
  function helper(x: number) { return x + 1; }
  return {
    run: (input: string) => helper(input.length),
  };
}));

After:

OUT FN:Live
  METHOD:run(input: string)
      FN:helper(x: number)

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(...)
  • Redux Toolkit createSlice({ reducers: {...} })
  • TanStack createRouter, createRoute
  • Vite/Vitest defineConfig(() => ({...}))
  • Zustand create((set, get) => ({...}))
  • Pinia defineStore(id, () => ({...}))
  • any RxJS operator chain, any DSL taking callbacks

Implementation

Three focused changes in src/ir/ast-walker.ts (+182/-6 lines, zero changes to existing behavior on non-factory code):

  1. findImportSource(node) - pulls module path from import_statement > string > string_fragment via AST fields. Replaces the pass-1 regex that ran on 80-char-truncated text and silently dropped source names for long named-import lists.

  2. collectAnonFnArgs(call) - walks a call-expression tree to find anonymous functions. Descends through:

    • call arguments (covers factory(fn))
    • nested call arguments (covers outer(inner(fn)), e.g. Rpc.toLayer(Effect.gen(fn)))
    • member-expression receivers (covers X.pipe(...) wrappers so the factory inside X is reached)
    • parenthesized expressions

    Budget-limited to 4 levels, cycle-guarded via Set<node.id>.

  3. walkFactoryBody(fn, ...) + emitObjectMethods(obj, ...) - for each anon function found, locates its return_statement > object and emits each pair with an arrow_function / function_expression value as METHOD:<key><params>. Skips _ and # prefixed keys (consistent with method_definition handling at line 337). Tags ASYNC METHOD: where applicable. Walks the rest of the body normally so sibling helpers still appear.

A gatekeeper isFactoryDeclaration() ensures this logic only fires on const X = call(fn) shape, not every T3 lexical_declaration. Plain const config = loadConfig({...}) without anonymous function args is unaffected.

Tests

  • 155 existing tests pass unchanged.
  • 8 new tests in tests/ir/factory-declarations.test.ts:
    • multi-line import source preservation (the original mangle bug)
    • Effect.gen handler surfacing
    • .pipe(Layer.provide(...)) wrapper unwrapping
    • ASYNC METHOD: tagging
    • _ / # private key filtering
    • non-regression on plain call declarations
    • sibling helpers + handlers coexist

Compression impact

Benchmark on 26 Effect RPC files (99,342 raw tokens):

Before After
L0 (structure) 414 tokens (99.6%) 414 tokens (99.6%)
L1 (full IR) 3,568 tokens (96.4%) 4,916 tokens (95.1%)

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.

@Necmttn Necmttn marked this pull request as ready for review April 13, 2026 08:18
@mertcanaltin

Copy link
Copy Markdown
Owner

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:

  1. emitObjectMethods only handles pair nodes. Could you add shorthand_property_identifier and method_definition cases? { send, list } and { send(x) {} } are common in Vite/TanStack/Zustand.

  2. The README has a conflict with the MCP install section we updated yesterday. A quick rebase off master should sort it.

Once those are in, happy to merge. Thanks for the careful work.

Claude and others added 2 commits April 14, 2026 09:56
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.
@Necmttn Necmttn force-pushed the fix/walk-factory-calls branch from 1b52d0d to d625e58 Compare April 14, 2026 01:56
@Necmttn

Necmttn commented Apr 14, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review!

Addressed both follow-ups in d625e58:

  1. emitObjectMethods extended to handle shorthand_property_identifier ({ send, list }) and method_definition ({ send(x) {} }, including async). Added 3 tests covering Vite/TanStack/Zustand shapes and private-key filtering for each.
  2. Rebased onto upstream/master - no conflicts (the README/MCP changes and gitignore fix landed cleanly above my patch).

All 166 tests pass (155 original + 8 factory + 3 shorthand/method). Ready for another look when you have a moment.

mertcanaltin added a commit that referenced this pull request Jun 21, 2026
#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.
mertcanaltin added a commit that referenced this pull request Jun 22, 2026
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants