import ts from "typescript"; import { detectCallKind, getHandlerAppliedInnerCall, getLiftAppliedInnerCall, getPatternToolHoistablePatternCall, getWithPatternHoistablePatternCall, isHandlerAppliedCall, SYNTHETIC_HANDLER_HOIST_PREFIX, SYNTHETIC_LIFT_HOIST_PREFIX, SYNTHETIC_PATTERN_HOIST_PREFIX, } from "../ast/call-kind.ts"; import { HelpersOnlyTransformer, TransformationContext } from "../core/mod.ts"; /** * Hoist every reactive *builder call* to module scope: `lift` (CT-1644, Phase 2 * of derive→lift→selfcontained), then `handler` and `pattern` (CT-1655). Each * becomes a named, addressable, eventually-selfcontainable module-scope const — * the substrate Phase 3 wraps with `selfcontained(...)`. (Formerly named * `LiftHoistingTransformer` / `lift-hoisting.ts`, when it only owned lift.) * * The hoist mechanic differs by builder shape; see {@link HOISTABLE_BUILDERS}. * For the original lift case: after Phase 1 (CT-1615) and SchemaInjection, every * reactive lift-style computation in lowered output is the schema-injected * *lift-applied* shape: * * ```ts * __cfHelpers.lift(argSchema, resSchema, callback)(captures).for("result", true) * ``` * * where the inner `__cfHelpers.lift(...)` call builds the module factory and * the outer application supplies the captures object. This stage hoists the * **entire inner call** (schemas + callback) to a module-scope const and * rewrites the original site to apply the captures to the hoisted name: * * ```ts * // module scope: * const __cfLift_1 = __cfHelpers.lift(argSchema, resSchema, callback); * // original site: * __cfLift_1(captures).for("result", true) * ``` * * ## Why this runs AFTER SchemaInjection * * SchemaInjection derives the lift's ARGUMENT schema from the applied captures * object (`call.arguments[0]` of the outer application) — the closure * transformer assembled that object's full type upstream. If the lift were * hoisted to a bare `const __cfLift_N = lift(callback)` BEFORE injection, the * captures object would no longer be adjacent and injection would fall back to * the callback's parameter type alone — which silently drops capture * properties in nested / multi-capture callbacks (verified regression; see * `session_outputs/2026-06-02_lift-hoist-phase2/02-ordering-correction.md`). * Running after injection means the schema is already baked into the inner * `lift(...)` call before we relocate it, so the hoist is schema-transparent. * * Result: every reactive computation is a named, addressable module-scope * const — the substrate Phase 3 wraps with `selfcontained(...)`. * * ## The sole module-scope hoisting phase (CT-1585 subsumed) * * CT-1585 originally had a separate `BuilderCallbackHoistingTransformer` that * hoisted builder *callbacks* (the function argument) when the callback closed * solely over module-level symbols. That mechanic was redundant and actively * harmful once a builder's whole call is hoisted here: hoisting the call AND the * callback produced a double hoist whose two consts referenced each other out * of declaration order (TDZ `ReferenceError` at module load). As each builder * gained whole-call hoisting — `lift` (CT-1644), then `handler`, `pattern`, and * `patternTool` (CT-1655) — it was removed from CT-1585's set; with the set * emptied, `BuilderCallbackHoistingTransformer` was deleted. This stage is now * the single module-scope hoisting phase. * * ## Generality * * The hoist mechanic is builder-agnostic: only "which call expression is the * hoistable unit", "how the original site references the hoisted name", and * "what name prefix to bind it to" are builder-specific. Those live in * {@link HOISTABLE_BUILDERS}. Two shapes are registered: * - applied builders (`lift`, `handler`): `builder(...)(captures)` — hoist the * inner call, leave `name(captures)` (the default callee-swap rewrite); * - argument-position builders (`pattern`): the bare `pattern(...)` sits in * argument 0 of an enclosing `*WithPattern` or `patternTool` call — hoist it * and rewrite that argument (via {@link HoistableBuilderSpec.rewriteSite}). */ export class BuilderCallHoistingTransformer extends HelpersOnlyTransformer { override transform(context: TransformationContext): ts.SourceFile { return hoistBuilderCalls(context.sourceFile, context); } } /** * A hoistable builder: given a visited call expression, decide whether it is * this builder's hoistable unit and, if so, return the inner call to relocate * to module scope plus the name prefix to bind it under. * * `innerCall` is the expression bound to `const _N`. How the original * site is rewritten to reference that name depends on the builder shape: * * - **Applied builders** (`lift`/`handler`): the visited call IS the inner * call applied to captures — `inner(captures)`. The default rewrite swaps * the callee for the hoisted name, leaving the captures arguments and any * surrounding member chain (e.g. the `.for(...)` tail) anchored in place. * - **Argument-position builders** (`pattern`/`patternTool`): the visited * call is the *enclosing* `mapWithPattern` call and the inner pattern call * sits in one of its arguments. The default callee-swap is wrong here — the * callee (`.mapWithPattern`) and the other arguments must survive untouched. * Such builders provide {@link rewriteSite} to replace just the argument * that held the inner call with the hoisted name. */ interface HoistableBuilderSpec { readonly prefix: string; readonly resolveHoistable: ( call: ts.CallExpression, context: TransformationContext, ) => ts.CallExpression | undefined; /** * Optional: produce the replacement for the visited site once the inner call * has been hoisted to `hoistedName`. Omit for applied builders, which take * the default callee-swap. Provide it when the inner call sits in an argument * position (the visited call is an enclosing call whose callee must survive). */ readonly rewriteSite?: ( visited: ts.CallExpression, hoistedName: ts.Identifier, innerCall: ts.CallExpression, factory: ts.NodeFactory, ) => ts.Expression; } const LIFT_BUILDER: HoistableBuilderSpec = { prefix: SYNTHETIC_LIFT_HOIST_PREFIX, resolveHoistable: (call, context) => { // The lift-applied shape is `__cfHelpers.lift(...)(captures)`: an applied // call whose callee is itself the inner `lift(...)` call. detectCallKind // is the single source of truth for recognising it (it guards against // over-application chains like `lift(cb)(x)(y)`). if (detectCallKind(call, context.checker)?.kind !== "lift-applied") { return undefined; } return getLiftAppliedInnerCall(call); }, }; const HANDLER_BUILDER: HoistableBuilderSpec = { prefix: SYNTHETIC_HANDLER_HOIST_PREFIX, resolveHoistable: (call, context) => { // The handler-applied shape is // `__cfHelpers.handler(eventSchema, stateSchema, cb)(captures)` — // structurally the same single-application unit as lift, so the same hoist // mechanic applies: relocate the inner `handler(...)` call, leave // `__cfHandler_N(captures)` at the site (the `.for(...)` tail stays // anchored on the outer call). Unlike lift this is NOT a `lift-applied` // CallKind — `isHandlerAppliedCall` recognises it while keeping the applied // call classifying as `{ kind: "builder", builderName: "handler" }`, so // handler-specific downstream dispatchers are untouched (CT-1655). if (!isHandlerAppliedCall(call, context.checker)) { return undefined; } return getHandlerAppliedInnerCall(call); }, }; const PATTERN_BUILDER: HoistableBuilderSpec = { prefix: SYNTHETIC_PATTERN_HOIST_PREFIX, resolveHoistable: (call, context) => { // Pattern is NOT applied: the bare `__cfHelpers.pattern(cb, inSchema, // outSchema)` call sits in the FIRST argument of an enclosing call, with // per-instance values flowing through that call's remaining argument(s). // Two enclosing shapes carry a hoistable pattern (identical mechanic — // relocate argument 0, keep the rest): // - `receiver.mapWithPattern(pattern(...), { params })` (and the other // lowered `*WithPattern` array methods); captures in the params object. // - `patternTool(pattern(...), extraParams?)` (CT-1655); per-instance // values in extraParams, module-scoped reads absorbed by the pattern. // In both, the bare pattern call is capture-free and safe to relocate. The // top-level `export default pattern(...)` is a direct call (not an argument // to either), so it is naturally excluded. return getWithPatternHoistablePatternCall(call, context.checker) ?? getPatternToolHoistablePatternCall(call, context.checker); }, rewriteSite: (visited, hoistedName, _innerCall, factory) => { // Replace ONLY the first argument (the pattern call) with the hoisted name, // keeping the enclosing call's callee (`.mapWithPattern` / `patternTool`) // and the trailing argument(s) intact. (Applied builders take the default // callee-swap instead.) return factory.updateCallExpression( visited, visited.expression, visited.typeArguments, [hoistedName, ...visited.arguments.slice(1)], ); }, }; const HOISTABLE_BUILDERS: readonly HoistableBuilderSpec[] = [ LIFT_BUILDER, HANDLER_BUILDER, PATTERN_BUILDER, ]; function hoistBuilderCalls( sourceFile: ts.SourceFile, context: TransformationContext, ): ts.SourceFile { const factory = context.factory; // Hoisted consts produced while visiting the CURRENT top-level statement. // They are flushed immediately before that statement (see below), not pooled // into a single after-imports block. This keeps each hoisted const *after* // every module-scoped binding declared in an earlier top-level statement — // which the original use site necessarily followed, since you cannot // reference a binding before its declaration in valid source. That ordering // matters because `pattern(...)` INVOKES its callback eagerly at construction // (unlike `lift`/`handler`, whose callbacks are stored and run lazily): a // hoisted `const __cfPattern_N = pattern(cb)` whose `cb` reads a later // module-scoped `const onRemoveFavorite = handler(...)` would otherwise throw // a module-load TDZ `ReferenceError`. (Verified against // patterns/system/favorites-manager.tsx; the after-imports placement that is // safe for lift/handler is NOT safe for pattern.) let pendingHoists: ts.Statement[] = []; // Per-file counters keyed by builder prefix. Explicit counters + literal // suffixes (NOT `factory.createUniqueName`, whose `.text` carries only the // bare prefix and defers numeric suffixing to emit — so every hoisted // identifier would share the same `.text`, breaking the identity-by-text // lookups later stages rely on to match a `_N` call site back to its // hoisted const). const counters = new Map(); // Every hoisted builder-artifact name (`__cfPattern_N`, `__cfLift_N`, // `__cfHandler_N`), in creation order. After the whole file is visited we emit // a SINGLE trailing `__cfReg({ __cfPattern_1, __cfLift_1, … })` call so the // runtime can assign each a content-addressed `{ identity, symbol }` reference // (the property key is the symbol). A single trailing call — rather than // exporting each hoist or registering it inline — keeps the verifier's job to // "exactly one top-level `__cfReg` call" and lets a run-once trap reject any // injected duplicate. See PatternManager.registerHoistedValues / the // `__cfReg` factory parameter wired up by the module-record compiler. const registeredNames: string[] = []; const visit: ts.Visitor = (node: ts.Node): ts.Node => { const visited = ts.visitEachChild(node, visit, context.tsContext); if (!ts.isCallExpression(visited)) { return visited; } for (const builder of HOISTABLE_BUILDERS) { const innerCall = builder.resolveHoistable(visited, context); if (!innerCall) { continue; } const next = (counters.get(builder.prefix) ?? 0) + 1; counters.set(builder.prefix, next); const nameText = `${builder.prefix}_${next}`; const name = factory.createIdentifier(nameText); registeredNames.push(nameText); // Carry the hoisted call's identity on the synthetic call-site identifier. // The checker can't resolve a synthetic identifier to its const // initializer, so detectCallKind would otherwise fail to recognize // `__cfLift_N(captures)` as the lift-applied reactive origin it lowers // from — dropping e.g. the `.for(...)` tag ReactiveVariableFor attaches. // Pointing the identifier's original node at the inner lift call lets the // builder-kind resolver (`resolveBuilderExpressionKind`) fall back through // it and still classify `__cfLift_N(captures)` as lift-applied. ts.setOriginalNode(name, innerCall); // const _N = ; pendingHoists.push( factory.createVariableStatement( undefined, factory.createVariableDeclarationList( [ factory.createVariableDeclaration( name, undefined, undefined, innerCall, ), ], ts.NodeFlags.Const, ), ), ); // Rewrite the site to reference the hoisted name. Applied builders take // the default callee-swap: reuse the visited outer call's own node // identity (updateCallExpression) so any surrounding member chain — // notably the `.for(...)` tail that ReactiveVariableForTransformer later // expects on the result — stays anchored to the same position. // Argument-position builders (pattern/patternTool) override via // `rewriteSite` to replace just the argument that held the inner call. if (builder.rewriteSite) { return builder.rewriteSite(visited, name, innerCall, factory); } return factory.updateCallExpression( visited, name, visited.typeArguments, visited.arguments, ); } return visited; }; // Visit each top-level statement, flushing the hoists it produced immediately // BEFORE it. A statement's hoisted consts are placed after every preceding // top-level statement — and therefore after every module-scoped binding the // hoisted (eagerly-run) pattern callbacks reference, since those bindings had // to precede the original use site. Among hoists from the same statement, // post-order traversal already pushed inner/earlier calls first, so a hoist // that references another hoist (e.g. `__cfPattern` whose callback calls // `__cfLift`) sees it declared above. // Local names that leave the module through ANY export form — `export const`, // `export { x }` / `export { x as y }`, `export default x`. Such artifacts are // addressable through the module namespace by their export name, so they are // NOT also routed through `__cfReg` (and `export const` has no local binding // after CommonJS emit anyway). `__cfReg` covers exactly the gap: hoists and // non-exported top-level builder consts. const exportedLocalNames = collectExportedLocalNames(sourceFile); const resultStatements: ts.Statement[] = []; for (const statement of sourceFile.statements) { // Also register AUTHORED non-exported top-level builder artifacts // (`const foo = lift(...)`), so `__cfReg` covers every top-level builder // artifact that does not reach the namespace. Detect on the ORIGINAL // statement (the checker resolves real, not synthetic, nodes). Only a direct // builder CALL counts, so an import/alias (`const x = imported`) — whose value // belongs to another module — is never mis-attributed to this identity. collectTopLevelBuilderArtifactNames( statement, context, exportedLocalNames, registeredNames, ); pendingHoists = []; const visitedStatement = ts.visitNode(statement, visit) as ts.Statement; resultStatements.push(...pendingHoists, visitedStatement); } // Register every hoisted builder artifact with one trailing call. `__cfReg` is // a free identifier supplied by the module wrapper (the 4th factory parameter // under the ESM loader; a no-op global on the legacy/AMD path). The object uses // shorthand so each value is the module-level `const` binding itself — the // registrar receives `{ symbol -> live value }` and the runtime pairs it with // this module's content identity. Emitted only when there is something to // register, so hoist-free modules are unchanged. if (registeredNames.length > 0) { resultStatements.push( factory.createExpressionStatement( factory.createCallExpression( factory.createIdentifier("__cfReg"), undefined, [ factory.createObjectLiteralExpression( registeredNames.map((n) => factory.createShorthandPropertyAssignment( factory.createIdentifier(n), ) ), true, ), ], ), ), ); } return factory.updateSourceFile(sourceFile, resultStatements); } /** * Collect the names of top-level `const`/`let`/`var` declarations whose * initializer is a direct builder CALL (`pattern(...)`, `lift(...)`, * `handler(...)`, `computed(...)`, …) — i.e. authored module-scope builder * artifacts. These are added to the module's `__cfReg({ … })` registration so * they receive a content-addressed `{ identity, symbol }` reference (symbol = the * binding name), exactly like the synthetic hoists. * * Requiring a builder call (via `detectCallKind`) means a re-export / alias * (`const x = imported`) — whose value belongs to ANOTHER module — is never * registered here, so identity is never mis-attributed. Non-artifact builders are * harmlessly trust-filtered at registration time. Destructuring is skipped. * * Names in `exportedLocalNames` are skipped: they leave the module through an * export and are addressable by their export name through the namespace (and an * `export const` has no local binding after CommonJS emit anyway, so a shorthand * would read `undefined`). `__cfReg` covers exactly the gap — hoists and * non-exported top-level builder consts. */ function collectTopLevelBuilderArtifactNames( statement: ts.Statement, context: TransformationContext, exportedLocalNames: ReadonlySet, out: string[], ): void { if (!ts.isVariableStatement(statement)) return; for (const decl of statement.declarationList.declarations) { if (!ts.isIdentifier(decl.name)) continue; if (exportedLocalNames.has(decl.name.text)) continue; // Unwrap `as` / `satisfies` / parenthesized / type-assertion wrappers before // the call check: a cast-typed builder const (`const x = handler(...) as // XFactory`) has an AsExpression initializer, not a CallExpression. Without // this it is silently excluded from `__cfReg`, so a non-exported handler/lift // gets no content-addressed provenance and falls to the SES-source fallback at // resolve time — `navigateTo`/SubPattern imports then read undefined (CT-1743). const init = decl.initializer ? unwrapTypeWrappers(decl.initializer) : undefined; if (!init || !ts.isCallExpression(init)) continue; if (detectCallKind(init, context.checker)?.kind === "builder") { out.push(decl.name.text); } } } /** Strip parentheses and `as` / `satisfies` / type-assertion wrappers to reach * the underlying builder/identifier expression. Used both for `export default x` * recognition and for top-level `const foo = handler(...) as XFactory` * registration — without unwrapping, a cast-wrapped builder const is excluded * from `__cfReg`, loses content-addressed provenance, and falls to the SES * fallback at resolve time (CT-1743). */ function unwrapTypeWrappers(expr: ts.Expression): ts.Expression { let current = expr; while ( ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isTypeAssertionExpression(current) ) { current = current.expression; } return current; } /** * The set of LOCAL binding names that are exported from the module by any form: * `export const foo`, `export { foo }` / `export { foo as bar }` (the local name * `foo`), and `export default foo`. Used to keep exported builder artifacts out * of `__cfReg` (they are addressable through the module namespace instead). */ function collectExportedLocalNames(sourceFile: ts.SourceFile): Set { const names = new Set(); for (const statement of sourceFile.statements) { if ( ts.isVariableStatement(statement) && statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ) { for (const decl of statement.declarationList.declarations) { if (ts.isIdentifier(decl.name)) names.add(decl.name.text); } } else if ( ts.isExportDeclaration(statement) && !statement.moduleSpecifier && statement.exportClause && ts.isNamedExports(statement.exportClause) ) { // `export { local as exported }`: the LOCAL name is `propertyName` when // aliased, otherwise `name`. for (const el of statement.exportClause.elements) { names.add((el.propertyName ?? el.name).text); } } else if (ts.isExportAssignment(statement) && !statement.isExportEquals) { // `export default foo` — unwrap parens / `as` / `satisfies` so a wrapped // identifier (`export default (foo)`, `export default foo satisfies T`) is // still recognized as exporting the local `foo`. const expr = unwrapTypeWrappers(statement.expression); if (ts.isIdentifier(expr)) names.add(expr.text); } } return names; }