# TypeScript Transformers Current Behavior Specification **Status:** Implemented (current behavior)\ **Package:** `@commonfabric/ts-transformers`\ **Effective date:** April 6, 2026\ **Scope:** Compile-time behavior implemented in `packages/ts-transformers/src` and exercised by current tests/fixtures. **Related:** - `docs/specs/ts-transformer/ts_transformers_target_pattern_language_spec.md` - `docs/specs/ts-transformer/ts_transformers_lowering_contract.md` - `docs/specs/ts-transformer/ts_transformers_goals.md` - `docs/specs/ts-transformer/cfc_authoring_contract.md` (draft, not current implemented behavior) - `docs/specs/ts-transformer/cfc_ui_helper_contract.md` (draft, not current implemented behavior) ## 1. Scope And Source Of Truth This document specifies what the transformer system currently does, not what it is intended to do in future design docs. Authoritative implementation sources: - `packages/ts-transformers/src/**` - `packages/ts-transformers/test/**` - fixture corpus under `packages/ts-transformers/test/fixtures/**` If this document conflicts with code or passing tests, code/tests win. ## 2. Activation And Entry Conditions ### 2.1 Default-on pre-transform Before AST transforms, `transformCfDirective()`: 1. Scans the first non-empty source line for transform directives. 2. Unless that line is `/// `, injects: - `import { __cfHelpers } from "commonfabric";` (a named import of the internal helper binding, not a namespace import) - a forwarding `h(...)` helper delegating to `__cfHelpers.h` (so authors need not import the JSX factory manually, and so the helper module is not tree-shaken before binding). 3. Rejects sources that contain identifier `__cfHelpers` anywhere in the AST. 4. Strips opt-out `/// ` from the source before later stages. These string-level steps run in `transformCfDirective()` (`src/core/cf-helpers.ts`) before any AST transformer, because symbol binding happens before the transformer pipeline runs. Opt-out note: - `/// ` is the explicit opt-out. ### 2.2 Pipeline object and cross-stage state `CommonFabricTransformerPipeline` (`src/cf-pipeline.ts`) constructs one ordered pipeline from `CFC_TRANSFORMER_STAGE_SPECS`. Every stage shares: - a single `diagnosticsCollector: TransformationDiagnostic[]` - a single `CrossStageState` instance (`src/core/cross-stage-state.ts`), which is the sole owner of cross-transformer communication. It replaced the formerly-separate registry fields on `TransformationOptions`. `CrossStageState` organizes its registries into three deliberate families (mirroring the TypeScript compiler's `NodeLinks` pattern): 1. **Bare cross-package maps** — the published boundary contract, read directly as plain `WeakMap`s by the separate schema-generator package, which must not depend on `CrossStageState`: - `typeRegistry: WeakMap` - `schemaHints: WeakMap` 2. **`nodeLinks` side table** — a `WeakMap` for transformer-internal, non-cache-invalidating per-node channels, reached only through record/lookup/mark/is accessors: - `capabilitySummary` (formerly the separate `capabilitySummaryRegistry`) - `schemaInjected` — a presence flag marking builder call/`new` nodes that SchemaInjection has finalized, replacing the scattered arg-count idempotency guards. It uses a plain presence check with **no** `getOriginalNode` fallback (it tags synthetic nodes whose original is the pre-injection user call). 3. **Marker family** — node/symbol-keyed `WeakSet`s whose membership checks fall back through `getOriginalNode`, and whose mutators are coupled to the context's reactive-analysis cache invalidation (invalidation is a `TransformationContext` concern; `CrossStageState` stays a pure data holder): - `mapCallbackRegistry` (transformed array-method callbacks) - `syntheticComputeCallbackRegistry` - `syntheticComputeOwnedNodeRegistry` - `syntheticReactiveCollectionRegistry` (keyed by `ts.Symbol`) ## 3. Pipeline Order (Normative) The authoritative ordering lives in `CFC_TRANSFORMER_STAGE_SPECS` / `CFC_TRANSFORMER_STAGE_NAMES` in `src/cf-pipeline.ts`. Transformers always run in this order (19 stages): 1. `CastValidationTransformer` 2. `EmptyArrayOfValidationTransformer` 3. `OpaqueGetValidationTransformer` 4. `PatternContextValidationTransformer` 5. `JsxExpressionSiteRouterTransformer` 6. `LiftLoweringTransformer` 7. `ClosureTransformer` 8. `PatternOwnedExpressionSiteLoweringTransformer` 9. `HelperOwnedExpressionSiteLoweringTransformer` 10. `WriteAuthorizedByValidationTransformer` 11. `PatternCallbackLoweringTransformer` 12. `SchemaInjectionTransformer` 13. `BuilderCallHoistingTransformer` 14. `SchemaGeneratorTransformer` 15. `ReactiveVariableForTransformer` 16. `ModuleScopeShadowingTransformer` 17. `ModuleScopeCfDataTransformer` 18. `PatternCoverageTransformer` 19. `ModuleScopeFunctionHardeningTransformer` The order is behaviorally significant (invariant C-002). Two ordering facts worth calling out: - `BuilderCallHoistingTransformer` (stage 13) runs **after** `SchemaInjectionTransformer` (stage 12) so each builder call it relocates to module scope already carries its injected schemas — see CT-1644 and `packages/ts-transformers/docs/derive-to-lift-design.md`. This stage hoists `lift`, `handler`, and `pattern` builder calls. It absorbed and replaced the former separate `LiftHoistingTransformer` (which hoisted only `lift`); the even-older `BuilderCallbackHoistingTransformer` was deleted (#3864). Earlier spec revisions listing those two as distinct stages are obsolete. - The final five stages (15–19) run last so they operate on fully lowered and schema-injected output. - `PatternCoverageTransformer` (stage 18) does no work unless pattern runtime coverage is enabled. When enabled, it runs before `ModuleScopeFunctionHardeningTransformer` so coverage counters are added to authored bodies before hardening helpers are emitted. ## 4. Global Modes `TransformationOptions.mode` supports: - `transform` (default) - `error` Current mode-sensitive behavior: - `JsxExpressionSiteRouterTransformer` in `error` mode reports diagnostics instead of rewriting JSX expressions that would require opaque-ref rewrites in non-compute contexts. - Other transformers currently do not branch on mode. ## 5. Call Kind Detection Contract `detectCallKind()` drives multiple transformers. The set of recognized Common Fabric runtime exports — and, for each, its call category and whether it is a **reactive origin** — is defined in one place: `COMMONFABRIC_RUNTIME_EXPORT_REGISTRY` (`src/core/commonfabric-runtime-registry.ts`). A guard test (`test/core/commonfabric-runtime-registry.test.ts`) asserts the registry covers every callable the runner's builder factory injects, so the registry — not this list — is the authoritative source. As of this writing it recognizes: - builders (all reactive-origin): `pattern`, `handler`, `action`, `lift`, `computed`, `render`. (`byRef` is a registered-but-`ignored` export.) - conditional-helper calls: `ifElse`, `when`, `unless` - reactive array calls (`map`, `mapWithPattern`, `filter`, `filterWithPattern`, `flatMap`, `flatMapWithPattern`) - cell factories (`cell`, `new Cell`, `new OpaqueCell`, `new Stream`, etc.), with legacy `.of(...)` still accepted - `Cell.for`-style calls - `wish` - `generateObject` and `generateText` - the `runtime-call` family — tagged-call / function runtime origins: `str`, `llm`, `llmDialog`, `fetchData`, `fetchProgram`, `streamData`, `compileAndRun`, `navigateTo`, and the SQLite builtins `sqliteDatabase` / `sqliteQuery` (`sqliteQuery` additionally gets dedicated type-argument schema injection) - `patternTool` — recognized, but explicitly **not** a reactive origin (`reactiveOrigin: false`) Detection is provenance-first: 1. symbol resolution against Common Fabric declarations/imports 2. stable alias/signature following (`const alias = computed`, `declare const alias: typeof ifElse`) 3. synthetic helper support for `__cfHelpers.*` nodes introduced by earlier passes Remaining fallback behavior is intentionally narrow: - unresolved bare builder identifiers can still match builders - ambient builder declarations and ambient call-signature aliases can still classify as builders in type-only environments - shadowed local helpers and object methods with Common Fabric-like names are not classified Builder-placement validation uses `detectDirectBuilderCall()`, so calls to functions returned by builders are not reclassified as direct `lift()` or `handler()` invocations. ## 6. Validation Transformers ### 6.1 Cast validation Validates `as` and angle-bracket assertions: - **Error** `cast-validation:double-unknown` - `expr as unknown as X` - `expr` - parenthesized/mixed equivalents - **Error** `cast-validation:forbidden-cast` - casts to `OpaqueRef<...>` - **Warning** `cast-validation:cell-cast` - casts to cell-like types: `Cell`, `OpaqueCell`, `Stream`, `ComparableCell`, `ReadonlyCell`, `WriteonlyCell`, `Writable`, `CellTypeConstructor` ### 6.2 Empty Array Cell-Factory Validation On cell-factory calls with an empty array literal and no explicit type argument: - `new Cell([])`, `new Writable([])`, `new OpaqueCell([])`, `new Stream([])`, deprecated `cell([])`, and other recognized cell factories - **Error** `cell-factory:empty-array` - explains that `[]` infers to `never[]` and suggests `new Cell([])`-style explicit type arguments. No error when: - explicit type arguments are provided - array literal is non-empty - first argument is not an array literal - `.of()` has no first argument ### 6.3 Opaque `.get()` validation On call `receiver.get()` (no args): - if receiver cell kind resolves to `"cell"` or `"stream"`: - no diagnostic (these types legitimately have `.get()`) - otherwise if receiver either: - resolves to opaque cell kind (`"opaque"`) via `getCellKind()`, or - is structurally reactive — `isReactiveExpression` walks the receiver and treats it as reactive when it is (or its property/element-access root is): - a **`pattern` / `render` callback parameter** (including via destructured binding elements), or - a local variable whose initializer is a **reactive-origin call** (after stripping non-null/parenthesized/cast wrappers and property/element-access tails), as defined by `isReactiveOriginCall` / the runtime registry (§5): reactive-origin builders (`pattern`, `computed`, `lift`, `handler`, `action`, `render`), the lift-applied shape, `ifElse` / `when` / `unless`, cell factories / `Cell.for`, `wish`, `generateObject`, `generateText`, and the `runtime-call` family (`str`, `llm`, …) - **Error** `opaque-get:invalid-call` - message instructs direct access, clarifies only `Writable`/`Cell` reads require `.get()`. Deliberate non-coverage of the structural fallback: `lift` / `handler` / `action` callback **parameters** are not inferred as opaque from structure alone — they keep their declared cell semantics. The structural inference exists only for the `OpaqueRef = T` identity-alias case where the cell brand is gone. Same-named local helpers are not treated as reactive origins unless the call itself resolves through the Common Fabric provenance rules in §5. ### 6.4 Schema shrink validation Validates that property paths detected by capability analysis can actually resolve against the declared parameter type during schema shrinking. Detection occurs in `applyShrinkAndWrap` and `validateShrinkCoverage` (both in `type-shrinking.ts`; `schema-injection.ts` and `lift-applied-strategy.ts` call into them), including the `defaults_only` branch of `applyCapabilitySummaryToArgument`. After shrinking completes, `validateShrinkCoverage` compares requested top-level path heads against what was materialized in the shrunk result. Path extraction (`extractAccessPath` in capability-analysis.ts) sees through the non-semantic wrappers `unwrapExpression` strips — parenthesization, `as` assertions, angle-bracket type assertions, `satisfies`, and non-null (`!`) — at every level of property/element access chains. `unwrapExpression` is applied to the receiver after each property/element step, so for example `(state as any) .foo` resolves to a read of `state.foo`. This means single casts (or `satisfies`/`!` wrappers) do not hide property accesses from capability analysis. When interprocedural analysis is enabled (compute-context builders like `lift`, `handler`), read paths discovered in helper function bodies propagate back to the caller's parameter summary, but the current MVP intentionally only does this for resolved helper bodies in the same source file. Cross-file or otherwise unsupported helper calls fall back to the conservative wildcard path instead of taking partial transitive precision. This means a `lift` callback that delegates to a local helper which reads `(x as any).foo` will trigger shrink validation on the caller's parameter type, while the same helper body in another file will conservatively disable shrinking for that parameter. When a wildcard parameter (one passed to an opaque/unanalyzable function like `console.log`) is typed `unknown`, validation emits `schema:unknown-type-access` because the generated schema cannot express what data to fetch. This does not apply to `any`-typed parameters (which fetch everything at runtime) or to concrete types (which already describe the expected shape). Declared members typed as `unknown` also trigger `schema:unknown-type-access` when accessed, even if the property name exists in the declared interface/type literal. This catches cases like `{ data?: unknown }` where the schema would otherwise degrade to `{ type: "unknown" }` for that property and the runtime could not express the required fetch shape. Guards that skip validation: - wildcard parameters with a non-`unknown` base type (full-shape access, shrinking is disabled) - parameters with no read/write paths and no wildcard flag - synthetic parameters injected by the pipeline (`__ct_pattern_input`, `__param0`, etc. — names starting with `__`) - `never`-typed parameters (bottom type, vacuously valid) - `any`-typed parameters (top type; the runtime fetches everything, so every key is reachable — this is what distinguishes `any` from `unknown`) - paths whose head is `"key"` (reactive proxy accessor injected by `PatternCallbackLoweringTransformer`) Diagnostics: - **Error** `schema:unknown-type-access` - fires in any of these cases: - parameter base type is `unknown` and the code accesses properties - parameter base type is an **uninstantiated generic type parameter** and the code accesses properties (the `isUnknownBase` check treats `TypeFlags.TypeParameter` like `Unknown` — an unresolved `` base cannot express a fetch shape any more than `unknown` can) - one or more accessed property heads resolve to `unknown`-typed **members** on an otherwise concrete type (e.g. `{ data?: unknown }`) - a **wildcard** parameter (passed to an opaque/unanalyzable function) whose base type is `unknown` (see the wildcard note above) - message lists the accessed property heads and instructs the author to replace `unknown` with a concrete type - **Error** `schema:path-not-in-type` - parameter has a concrete type but one or more accessed properties are not present in that type - message lists missing properties and the declared type text, instructs the author to add them When `shrunk` is `undefined` (as in `defaults_only` mode where full shrinking is skipped), validation falls back to inspecting the base type node directly. Union and array type support: validation uses `typeHasProperty()` which checks whether a specific property head resolves against the type through three layers: the shrunk TypeNode, the base TypeNode, and the resolved `ts.Type`. For union types (e.g. `{ amount?: number } | undefined`), non-nullish constituents are checked individually — a head is valid if ANY non-nullish member has it. For array types (e.g. `number[]`), numeric heads like `"0"` are valid when the type has a numeric index signature. TypeReferences within unions are resolved to their declaration members. This eliminates false `schema:path-not-in-type` errors on nullable/optional union patterns and array index accesses. ### 6.5 Pattern-context validation Enforces restricted reactive context rules. Restricted contexts are callbacks of: - `pattern` - `render` - transformed array-method callbacks (`.map(...)`, `.filter(...)`, `.flatMap(...)` and their `...WithPattern(...)` forms) Compute wrappers override restrictions: - `computed`, `action`, `lift`, `handler` callbacks - inline JSX `on*` handlers - standalone function definitions - JSX expressions (handled by opaque-ref JSX transformer) - the SQLite `table(columns, (row) => ({...}))` row-label rule callback — classified as the supported `sqlite-row-label-rule` compute boundary (`callback-boundary.ts`). `table()` is recognized by name **plus** the `SqliteTableFunction` type alias from Common Fabric's own typings (so an unrelated user function named `table` does not match). The rule callback is evaluated eagerly at pattern build into a serialized JSON AST, so it is compute-owned and is deliberately exempt from SES self-containment validation (see the `ses-callback:callable-capture` exclusion below). Diagnostics emitted in all modes: - **Error** `pattern-context:get-call` - `.get()` call in restricted reactive context - **Error** `pattern-context:function-creation` - function creation in pattern context unless inside compute wrappers/JSX/allowed callbacks - class expression or declaration in pattern context unless inside compute wrappers; the whole class is flagged once with a class-specific message - **Error** `pattern-context:object-member` - a function-valued member of an object literal in pattern or render context: a method, getter, setter, or a property whose value is an arrow/function expression (including inside JSX data positions, and when the function is wrapped in transparent expressions — parentheses, `as`, `satisfies`, `!`, ``) - rejected regardless of the body, because the reactive-read lowering pass does not descend into function bodies; the sole exception is a `toJSON` member, which is reported only when its body reads a reactive value - the message names the mechanism per kind: a getter or `toJSON()` member runs once when the result is stored and freezes its return to a snapshot; a method, setter, or function-valued property is a function value the reactive data model cannot store (it throws `Cannot store function per se`) - exempt: members inside compute wrappers (computed/lift/handler/action), object literals outside pattern/render context, JSX event handlers, array-method/render callbacks, and a `toJSON` member that reads no reactive value (a toJSON-bearing object is storable — the data model converts it via `toJSON()`); class members are covered separately by `pattern-context:function-creation` - **Error** `pattern-context:builder-placement` - direct `lift()` or `handler()` inside restricted context - special message for immediate `lift(fn)(args)` suggesting `computed()` - **Error** `standalone-function:reactive-operation` - in standalone functions (except inline first arg to `patternTool`): `computed(...)`, `lift(...)`, or reactive collection methods on reactive receivers - collection-method diagnostics currently use `.map(...)`-style guidance and suggest eager `.get().map(...)` when explicit eager mapping is acceptable - **Error** `compute-context:local-reactive-use` - inside a `computed(...)`/`lift(...)` callback, a reactive value created in that same callback is consumed as a plain value in control-flow or another non-lowered computation site - typical culprits are local `computed(...)`, `lift(...)`, `wish(...)`, or reactive collection aliases and their property accesses - message instructs the author to move the use into a nested `computed(() => ...)` or module-scope `lift()` - **Error** `pattern-context:optional-chaining` - optional calls in restricted reactive context (outside JSX) - optional property / element access that appears outside a supported lowerable expression site - **Error** `pattern-context:computation` - binary/unary/conditional computations using opaque dependencies outside wrappers - also the catch-all for other non-lowerable reactive reads at a top-level pattern site — notably **bare dynamic key (element) access** like `scopes[key]` directly in a pattern-body value position (the target-language spec's "bare dynamic key access in top-level pattern-facing code" = Unsupported). The same access is fine inside JSX, a computation callback, a collection callback, or a structural binding form. - validation first checks the shared lowerable-expression-site policy; only non-lowerable computation sites still report this error (so `items[0].name`, `name.toUpperCase()` at lowerable top-level sites, and dynamic keys inside supported contexts validate clean) - **Error** `pattern-context:callback-container` - a callback passed to an **unsupported container** in pattern-facing JSX (the callback-boundary decision is `unsupported` with `boundaryDiagnostic === "callback-container"`) — e.g. a foreign imperative container root like `[0, 1].forEach(() => list.map(...))`. This is the diagnostic counterpart of the target-language spec's "foreign callback / imperative container roots" unsupported bucket. Guidance: use a supported array-method/value call, an event handler, or move the work into `computed(() => ...)`, module-scope `lift()`, or a helper. - **Error** `pattern-context:patterntool-requires-pattern` - `patternTool(fn, ...)` where the first argument is a bare callback (arrow / function expression) rather than a `pattern(...)`. The runtime/transformer auto-wrapping (`pattern(fn)`) and auto-capture were removed in CT-1655; authors now wrap explicitly: `patternTool(pattern(fn), extraParams?)`. The diagnostic is reported on the bare-callback argument. - **Error** `ses-callback:callable-capture` - a callback at an SES-self-contained boundary captures a **callable** declared in an enclosing function scope. The boundary kinds that require self-containment are `SES_SELF_CONTAINED_CALLBACK_BOUNDARIES`: `event-handler`, `reactive-array-method`, `pattern-tool`, `pattern-builder`, `render-builder`, `lift-applied`, `computed-builder`, `action-builder`, `lift-builder`, `handler-builder`. (`sqlite-row-label-rule` is deliberately excluded — `table()` evaluates its rule callback eagerly at pattern build into a serialized AST, so capture is harmless there.) SES callback implementations must be self-contained. Guidance: move callable helpers to module scope, or pass serializable data through explicit inputs/state. Capturing non-callable reactive data is still allowed. Removed diagnostic (behavior change, PR #3154 pattern-language-boundary): the former `pattern-context:map-on-fallback` error no longer exists. Fallback-guarded reactive collection forms such as `(items ?? []).map(...)`, `(items || []).filter(...)`, and `(items ?? []).flatMap(...)` — including cast-/`satisfies`-wrapped reactive left sides — are now **supported** and emit no fallback-specific diagnostic. `test/validation.test.ts` retains these as regression guards asserting the forms validate clean. ### 6.6 Pattern Result Schema Inference When `pattern()` is called with zero or one type parameters and the result schema must be inferred, CTS requires the inferred top-level result shape to be structurally representable. - structurally recoverable object-literal returns still emit concrete object schemas even when some individual property values come from `any`-typed expressions - direct top-level `any` / `unknown` result inference emits `pattern:any-result-schema` - authors who intentionally want a permissive/opaque output boundary must make it explicit with `pattern(...)` This inference runs through `collectFunctionSchemaTypeNodes` via `inferReturnType`, object-literal recovery, and direct projection recovery. ### 6.7 Lowerable Expression-Site Categories The shared expression-site policy recognizes seven authored container kinds via `getExpressionContainerKind` (the `ExpressionContainerKind` union in `expression-site-types.ts`): - `jsx-expression` - `template-span` (an interpolated `${…}` span inside a tagged template, e.g. a `str`-tagged template) - `return-expression` - `variable-initializer` - `call-argument` - `object-property` - `array-element` Those container kinds are the raw building blocks for the author-facing buckets described in the target-language spec: - JSX expressions - top-level pattern-body value-expression sites - callback-local value-expression sites inside supported reactive collection callbacks `findLowerableExpressionSite` walks outward through enclosing pattern-context containers until it finds the nearest lowerable site admitted by `classifyExpressionSiteHandling`. `findPreferredNestedLowerableExpressionSite` is narrower: it only picks nested structural sites with container kinds `call-argument`, `object-property`, or `array-element`. These categories do not mean "rewrite every descendant expression." Explicit computation callbacks still create their own ownership boundary, and every site must separately satisfy the shared handling policy. In particular, explicit compute callbacks remain an ownership boundary for recursive lowering. That boundary does not disappear just because the callback returns JSX: ternaries and logical control flow inside the compute callback body stay authored JavaScript rather than recursively lowered helper control flow. ### 6.8 WriteAuthorizedBy validation (CFC, validation-only) `WriteAuthorizedByValidationTransformer` (pipeline stage 10) is the one piece of the CFC authoring contract that has landed on `main`, and it is **validation only** — there is no CFC schema lowering yet (no `ifc.integrity` / `ifc.opaque` / `ifc.collection` emission anywhere in `src/`; the draft lowering rules in `cfc_authoring_contract.md` remain unimplemented — see §14). It scans `toSchema()` (one type arg) and `pattern()` (the result type arg) for `WriteAuthorizedBy` references, resolving through local type aliases and type-parameter substitution (`findWriteAuthorizedByReferences`). For each reference it emits **`cfc-write-authorized-by`** when usage is malformed: - the second type argument is not a `typeof` binding (`TypeQueryNode`) - the `typeof` target is not a simple identifier - the bound name is not a supported origin — a local `handler()` / `module()` / `requireEventIntegrity()` initializer, or a local function declaration Well-formed `WriteAuthorizedBy` usage passes validation; the base schema still lowers as `T` (the `WriteAuthorizedBy` wrapper contributes no schema metadata on current `main`). This stage is exercised by `test/cfc-authoring.test.ts`, `test/cfc-transformer-coverage.test.ts`, and pipeline regressions. `ts-transformers` also re-exports the canonical CFC alias-name set (`CFC_CANONICAL_ALIAS_NAMES`, from `@commonfabric/api/cfc`) via `src/cfc-authoring.ts` — `Cfc`, `Confidential`, `Integrity`, `OpaqueInput`, `WriteAuthorizedBy`, the `TrustedAction*` family, the projection aliases, etc. These names are recognized as CFC vocabulary but, apart from `WriteAuthorizedBy` validation above, are not yet lowered. ## 7. JSX Expression Site Routing And Early Rewriting `JsxExpressionSiteRouterTransformer` runs only when helper import is present. ### 7.1 Top-level behavior For each `JsxExpression`: - skip empty JSX expressions and event-handler attributes - run data-flow analysis (`createDataFlowAnalyzer`) - if no rewrite required and no logical binary operators (`&&`, `||`), skip - in compute context: - only semantic logical rewrites (`&&`/`||`) are considered - computed wrapping is skipped - compute-context JSX does not lower `&&` / `||` - pattern-context JSX lowers `&&` / `||` deterministically - in `mode: "error"`: - report `opaque-ref:jsx-expression` for non-compute contexts requiring rewrite - no rewrite ### 7.2 Emitter behaviors The rewriter uses normalized data-flow dependencies and ordered emitters: 1. property access 2. binary expression 3. call expression 4. template expression 5. conditional expression 6. element access 7. prefix unary 8. container expression Key rewrite rules: - `a && b`: lowers to `when(condition, value)` only in pattern context - `a || b`: lowers to `unless(condition, fallback)` only in pattern context - ternary `cond ? x : y`: - becomes `ifElse(cond, x, y)` with branch/predicate processing - non-compute contexts: - complex reactive expressions are wrapped via `computed(() => expr)` (later lowered to the lift-applied form) - compute contexts: - no computed wrappers; only child rewrites and logical conversions Helper-owned compute branches introduced by ternary / conditional-helper rewriting are re-analyzed with synthetic compute ownership. This preserves plain-array semantics inside fully compute-wrapped branches while still letting later stages recover reactive collection rewrites for locally rewrapped aliases created inside compute code. Synthetic calls generated by this pass register result types in `typeRegistry` for later schema injection. ## 8. Lift-Applied Lowering `LiftLoweringTransformer` rewrites Common Fabric `computed(...)` calls into the canonical lift-applied form: - `computed(fn)` -> `__cfHelpers.lift(fn)({})` before schema injection - no-input computed-origin calls are schema-injected as `__cfHelpers.lift(false, fn)()` - **does not** forward `computed`'s type argument to `lift`: `computed` has a single result type param, while `lift` takes input `T` first, so forwarding `[R]` would place `R` in `lift`'s input slot. Type args are recomputed downstream (LiftAppliedStrategy / SchemaInjection) from the callback's parameter and return types. - does not additionally validate callback shape in this pass - preserves type information through `typeRegistry` (the original call's type is re-registered on the lowered lift-applied node) It runs only when source text contains `computed` or AST scan finds computed calls. ## 9. Closure Transformation `ClosureTransformer` runs only when helper import is present. It applies the first matching strategy (the strategies array in `closures/transformer.ts`), in order: 1. handler JSX attribute strategy 2. action strategy 3. array-method strategy 4. lift-applied strategy There is no longer a separate patternTool closure strategy (CT-1655, #3862): `patternTool` now requires an explicit `pattern(...)` first argument (see §6.5 `pattern-context:patterntool-requires-pattern`), so the captures live on that authored pattern and the call is hoisted by `BuilderCallHoisting` (§11) rather than capture-rewritten here. ### 9.1 Capture model Capture analysis: - captures identifiers/property chains declared outside callback scope - excludes imports, module-scoped declarations, function declarations, type parameters, JSX tag names, property keys - captures nested callback closures with filtering for outer locals/params - builds hierarchical capture trees by root path ### 9.2 Handler strategy Transforms inline JSX event handlers: - ` ...} />` -> `onClick={handler((event, params) => ...)(captures)}` - currently unwraps arrow functions only (not function expressions) - preserves body after recursive child transforms ### 9.3 Action strategy Transforms `action(...)` to handler factory invocation: - `action(cb)` -> `handler(rewrittenCb)(capturesObj)` - event schema: - no event param -> `never` - event param present -> inferred/explicit type - callback extraction currently supports arrow callbacks only ### 9.4 Array-method strategy Transforms eligible reactive collection operators to explicit `...WithPattern` forms with explicit capture params. Transform eligibility: - decision is context/receiver-policy driven: - pattern context + reactive receiver origin -> transform - compute context + `celllike_requires_rewrite` receiver kind -> transform - compute context + `opaque_autounwrapped` receiver kind -> do not transform - compute context + local alias in the same callback whose initializer re-wraps a reactive collection (`computed`, `lift`, `action`, `handler`, `wish`, already-rewritten collection calls, or other reactive cell-like receivers) -> transform - plain array `.map()` is not transformed - transformed callbacks are marked in `mapCallbackRegistry` and become pattern-callback contexts for downstream classification - synthetic compute-owned array-method nodes assert that stale pattern ownership is not retained after earlier rewrites Result shape: - `receiver.(fn[, thisArg])` -> `receiver.WithPattern(pattern(callbackSchema, resultSchema, newCallback), paramsObj[, thisArg])` - currently supported methods are `map`, `filter`, and `flatMap` - callback schema includes `{ element, index?, array? }` and adds `params` only when captures exist - computed destructuring keys are stabilized with generated key constants and lift-applied wrappers where needed ### 9.5 Lift-applied strategy Transforms lift-applied closures only when captures exist. Supported input forms: - `lift(callback)(input)` - `lift(inputSchema, resultSchema, callback)(input)` Behavior: - merge original input and captures into one input object - rewrite callback parameters to explicit destructuring - resolve name collisions (`name`, `name_1`, ...) - preserve/reinfer callback result type - skip explicit type args when result type is uninstantiated type parameter - register lift-applied call type for downstream inference If no captures are found, the lift-applied call is left unchanged. ### 9.6 patternTool (no closure strategy) There is no patternTool closure strategy in the current pipeline. The former strategy auto-wrapped a bare callback as `pattern(fn)` and auto-captured module-scoped reactive values into the call; both were removed in CT-1655 (#3862) in favor of an explicit, addressable pattern. Current behavior: - `patternTool(...)`'s first argument **must** be an explicit `pattern(...)`; a bare callback reports `pattern-context:patterntool-requires-pattern` (§6.5). - the captures live on the authored `pattern(...)` (module-scoped reads are absorbed by the pattern; per-instance values go in `extraParams`). - the bare `pattern(...)` inside `patternTool(...)` is hoisted to module scope by `BuilderCallHoisting` (§11, argument-position pattern case). ### 9.7 Pattern callback lowering `PatternCallbackLoweringTransformer` runs after closure transformation. Primary behaviors: - rewrites pattern-style callback parameter destructuring to explicit input bindings with `input.key(...)`-based prologues - lowers property/optional-navigation reads on opaque roots to `.key(...)` access in pattern contexts - preserves terminal path methods (`get`, `set`, `update`, etc.) and rewrites only the receiver path portion when needed - treats dynamic key access, spread, and optional-call forms as non-lowerable in pattern context with diagnostics - treats wildcard traversals (`Object.keys/values/entries`, `JSON.stringify`) as broad/full-shape access for capability analysis, but allows whole-call lowering when they appear in supported expression-root positions - classifies map captures as reactive vs non-reactive and avoids `.key(...)` rewrites for non-reactive captures - recursively rewrites lift-applied callback bodies so locally-declared opaque/reactive aliases created inside compute callbacks (including inside nested blocks) also receive `.key(...)` lowering - local opaque-root discovery is symbol-scoped and block-aware to avoid same-name false rewrites across scopes - extracts static destructuring defaults into capability summaries for schema default application - registers capability summaries for transformed callbacks/builders for downstream schema shrinking/wrapping #### 9.7.1 Current non-JSX expression-site split Current-main behavior distinguishes three buckets for non-JSX authored sites: 1. **top-level pattern-owned ordinary call roots** - when the authored site root is an ordinary call (for example `identity(state.done ? "Done" : "Pending")`), the shared expression-site path whole-wraps that call in a lift-applied computation - this applies across non-JSX container kinds such as `variable-initializer`, `object-property`, `array-element`, and `return-expression` 2. **explicit compute callbacks** - `computed` / `action` / `lift` / `handler` callbacks remain the explicit reactive boundary - inside those callbacks, authored conditionals stay authored JS inside the callback body rather than being rewritten to helper control flow 3. **supported collection-callback locals** - callback-local **ordinary call roots** now join the shared ordinary-call slice across `variable-initializer`, `object-property`, `array-element`, and direct `return-expression` sites, so the whole call lowers as a callback-local lift-applied computation - callback-local **plain structural control-flow sites** that are not under an owning ordinary call root still lower directly (for example bare conditional `object-property` / `array-element` / `variable-initializer` expressions lowering to `ifElse(...)`) In other words, the split is now explicitly **call-root vs nested structural site ownership** rather than a special-case callback return-expression rule. ## 10. Schema Injection `SchemaInjectionTransformer` runs only when helper import is present. It injects `toSchema<...>()` calls (later materialized to JSON schema literals). ### 10.1 General typing rules - uses explicit type annotations when present - otherwise infers from signatures/contextual types - `_param` convention implies `never` schema for that parameter - failed inference falls back to `unknown` - `typeRegistry` is consulted first for synthetic nodes/types ### 10.2 `pattern(...)` Builder schema injection supports function-first signatures and preserves that ordering in output. Cases: - 2+ type args: input and result from type args - 1 type arg: input from type arg, result inferred - no type args: - if 2 schema args already present: unchanged - if 1 schema arg present: treated as input schema, infer result schema - if none: infer both When inferring the result schema (0 or 1 type args), CTS requires a structurally representable top-level result: - structurally recoverable object-literal returns still emit concrete object schemas when CTS can recover their shape (see §6.6) - direct top-level `any` / `unknown` result inference emits `pattern:any-result-schema` - permissive/opaque result semantics are still allowed when made explicit via a result type parameter ### 10.3 `handler(...)` - with type args ``: - prepends event/state schemas - unresolved generic helper-definition-site type parameters degrade to `{ type: "unknown" }` - with single function arg: - infers event/state schemas from parameters - event absent -> `never`; untyped params -> `unknown` ### 10.4 `lift(...)` If schemas are not already present via type args: - infer input/result schema types from arguments and callbacks - literal-based input inference widens literals (`"x"` -> `string`, `1` -> `number`, etc.) - when inferred result type is missing or degrades to `any`/`unknown`, recovery first attempts object-literal return reconstruction and then direct projection recovery (`x => x.foo`, `x => x["foo"]`) - direct projection recovery can reuse result types recovered from local `lift(...)` initializer aliases registered in `typeRegistry` - unresolved generic helper-definition-site type parameters degrade to `{ type: "unknown" }` when schemas are injected from explicit builder type arguments ### 10.5 Cell factories and related APIs Injected behaviors: - `cell(...)`, `new Cell(...)`, `new OpaqueCell(...)`, `new Stream(...)`, etc.: - inject schema as second argument if missing - if no value arg, inject `undefined` then schema - value inference first uses registry/initializer recovery for transformed expressions before falling back to the direct value type - direct semantic `any` values emit `true` - direct semantic `unknown` values emit `{ type: "unknown" }` - if the value type at a generic helper definition site is an uninstantiated type parameter, CTS degrades the emitted schema to `{ type: "unknown" }` instead of leaking `{}` or omitting the schema - `Cell.for(...)`-style calls: - wrap with `.asSchema(schema)` unless already wrapped - `wish(...)`: - append schema as second argument if missing - explicit or contextual unresolved generic type parameters degrade to `{ type: "unknown" }` - `generateObject(...)`: - ensure options object has `schema` property (merge/spread as needed) - explicit or contextual unresolved generic result types degrade to `{ type: "unknown" }` - `sqliteQuery(...)`: - the **typed** form lowers the `Row` type argument to an injected `rowSchema` property on the options object (parallel to `generateObject`'s `schema`); the runtime builtin composes `result.items = rowSchema`. Two call shapes inject it: the free function `sqliteQuery({ db, sql, ... })` (options at arg 0) and a method form. Idempotent (skips when `rowSchema` already present). - untyped `sqliteQuery(...)` is not injected. Other SQLite builtins (`sqliteDatabase`, etc.) are recognized reactive-origin `runtime-call`s (§5) but receive no dedicated schema injection. ### 10.6 Conditional helpers Injects schemas for helper calls when absent: - `when(condition, value)` -> prepend 3 schemas: condition/value/result - `unless(condition, fallback)` -> prepend 3 schemas - `ifElse(condition, ifTrue, ifFalse)` -> prepend 4 schemas These use widened literal inference and register inferred types. ### 10.7 Capability summary application When capability summaries are available, schema injection applies wrapper/path adjustments: - wrapper selection based on observed capability: - `readonly` -> `ReadonlyCell` - `writeonly` -> `WriteonlyCell` - `writable` -> `Writable` - `opaque` -> `OpaqueCell` - compute-oriented boundaries apply full path shrink + wrapper selection - type aliases and interfaces (TypeReferenceNodes) are resolved to their declaration members and shrunk in-place, preserving source-level type annotations (Date formats, enum literals, `$ref`/`$defs`). When all members are retained the original TypeReference is kept for schema fidelity. - pattern boundaries apply defaults-only mode to preserve broad shape continuity while still applying extracted static defaults - wildcard roots disable path shrinking for affected parameters/arguments - capability analysis resolves member access through `.get()` when the member access itself is observed (`notes.get().length` records `["length"]` rather than a blanket root read) and suppresses the redundant blanket `.get()` read - array-like roots whose observed paths only touch non-item properties (`length`, `get`, `set`, `key`, `update`) keep array shape but shrink their item type to `unknown` - node-driven shrinking can still shrink the inner type of cell-like wrappers when `.get()` contributes an empty path but coexists with more specific non-empty paths - tuple types and numeric-indexed object types are not rewritten to array-with-unknown-items during this optimization - after shrinking, `validateShrinkCoverage` checks that all requested property paths were materialized (see §6.4); unresolvable paths produce hard errors ## 11. Builder Call Hoisting And `__cfReg` Registration `BuilderCallHoistingTransformer` (stage 13, **after** SchemaInjection) hoists every reactive *builder call* to module scope and emits a single trailing content-addressing registration. It is the sole module-scope hoisting phase; it absorbed the former `LiftHoistingTransformer` (lift-only) and replaced the deleted `BuilderCallbackHoistingTransformer` (which hoisted builder callbacks and caused TDZ double-hoist bugs — #3864). Tickets: CT-1644 (lift), CT-1655 (handler, pattern, patternTool), CT-1623 (`__cfReg` content addressing). ### 11.1 What gets hoisted After SchemaInjection, each reactive builder computation appears in a schema- injected applied or argument-position shape. This stage relocates the inner builder call to a named module-scope `const` and rewrites the original site to reference that name. Three builder shapes are registered in `HOISTABLE_BUILDERS`: - **Applied builders** (`lift`, `handler`): the site is `builder(...)(captures)` — the callee is itself the inner `builder(...)` call. Hoist the inner call, leave `__cfLift_N(captures)` / `__cfHandler_N(captures)` at the site (any trailing `.for(...)` member chain stays anchored on the outer call): ```ts // Shown inside a pattern body. // module scope: const __cfLift_1 = __cfHelpers.lift(argSchema, resSchema, callback); // original site: __cfLift_1(captures).for("result", true) ``` - **Argument-position builder** (`pattern`): the bare `pattern(...)` call sits in argument 0 of an enclosing `*WithPattern` call (`mapWithPattern`, etc.) or `patternTool(...)`. Hoist argument 0 to `__cfPattern_N` and rewrite only that argument, keeping the enclosing callee and remaining arguments intact. The top-level `export default pattern(...)` is a direct call, not an argument, so it is naturally excluded. Detection is provenance-driven via `detectCallKind` / `isHandlerAppliedCall` / `getWithPatternHoistablePatternCall` / `getPatternToolHoistablePatternCall`. ### 11.2 Why this runs after SchemaInjection SchemaInjection derives a lift's argument schema from the adjacent applied captures object. Hoisting the call to a bare `const = lift(callback)` *before* injection would separate the captures object and silently drop capture properties in nested/multi-capture callbacks. Running after injection means the schema is already baked into the inner call before relocation, so the hoist is schema-transparent (C-002; verified regression). ### 11.3 Hoist placement and TDZ ordering Hoisted consts are flushed immediately **before** their owning top-level statement, not pooled into a single after-imports block. This keeps each hoisted const after every module binding declared in an earlier statement. The ordering is behaviorally load-bearing for `pattern`: unlike `lift`/`handler` (callbacks stored and run lazily), `pattern(...)` invokes its callback **eagerly at construction**, so a hoisted `const __cfPattern_N = pattern(cb)` whose `cb` reads a later module-scoped binding would throw a module-load TDZ `ReferenceError` under after-imports placement. Hoisted identifiers use explicit per-prefix counters with literal numeric suffixes (`__cfLift_1`, `__cfPattern_1`, …), **not** `factory.createUniqueName` — whose `.text` carries only the bare prefix and defers suffixing to emit, which would make every hoisted identifier share the same `.text` and break the identity-by-text lookups later stages rely on. ### 11.4 `__cfReg` content-addressed registration After visiting the whole file, the stage appends **one** trailing call: ```ts // Shown at module scope. __cfReg({ __cfLift_1, __cfPattern_1, __cfHandler_1, /* … */ }); ``` using shorthand properties so each value is the module-level `const` binding itself. It is emitted only when there is something to register (hoist-free modules are unchanged). The registered set includes both: 1. the synthetic hoists produced above, and 2. **authored** non-exported top-level builder artifacts — `const foo = lift(...)` / `pattern(...)` / `handler(...)` etc. — detected on the original statement so an import/alias (`const x = imported`) is never mis-attributed to this module's identity. `__cfReg` is a free identifier supplied by the module wrapper (the 4th factory parameter under the runtime's ESM loader; a no-op global on the legacy/AMD path). The runtime registrar pairs each `{ symbol -> live value }` entry with the module's content identity, populating the content-addressed reverse index that backs builder-artifact identity resolution. A single trailing call (rather than per-artifact export/registration) keeps the runtime verifier's obligation to "exactly one top-level `__cfReg` call," with a run-once trap rejecting injected duplicates. Runtime side: `packages/runner/src/sandbox/module-record-compiler.ts` and `packages/runner/src/pattern-manager.ts`. This registration is current, shipped behavior: `__cfReg({...})` appears in the expected output of the large majority of builder-bearing fixtures. Note: the design comments frame this stage as Phase 2 of a "derive→lift→selfcontained" arc. Phase 3 (`selfcontained(...)` wrapping of the hoisted consts) is **not** implemented on `main` — see the design-deltas doc. ## 12. Schema Generation `SchemaGeneratorTransformer` replaces `toSchema(options?)` calls with JSON schema literals. Recognized call forms: - `toSchema()` - `__cfHelpers.toSchema()` Behavior: 1. resolve type from `typeRegistry` (preferred) or checker fallback 2. evaluate literal options object 3. extract `widenLiterals` generation option 4. generate schema via `createSchemaTransformerV2` 5. merge non-generation options into resulting schema object 6. emit literal as: - ` as const satisfies __cfHelpers.JSONSchema` Special path: - if resolved type is `any` and type arg node is synthetic (`pos=-1,end=-1`), generator uses synthetic-node generation path to recover schema fidelity. - synthetic union handling preserves `undefined` members (for example `string | undefined` retains an explicit `undefined` branch in generated schema). - `unknown` is emitted distinctly as `{ type: "unknown" }`; `any` remains `true` - arrays of `unknown` emit `items: { type: "unknown" }` - synthetic unions preserve explicit `{ type: "unknown" }` members in `anyOf` rather than collapsing them away - `OpaqueRef` does not emit `asOpaque`; only cell/stream wrappers add wrapper markers such as `asCell` / `asStream` - CFC-specific wrapper lowering such as `WriteAuthorizedBy`, projection aliases, and trusted-UI helper schema metadata is not part of current implemented behavior on `main`; those contracts are described separately in the draft CFC docs listed above ## 13. Diagnostics Message Transformation (Optional Consumer Layer) Diagnostic message transformers are exported separately from AST transform pipeline. Current built-in behavior: - `OpaqueRefErrorTransformer` rewrites TypeScript messages matching `"Property 'get' does not exist on type 'OpaqueCell<...>'"` into user-facing guidance about unnecessary `.get()`. - optional `verbose` mode appends original TypeScript message. - `CompositeDiagnosticTransformer` returns the first matching transformer result. ## 14. Current Known Limits (Observed) 1. Generic helper functions with uninstantiated type-parameter lift-applied result types can degrade schema precision (type arguments may be intentionally omitted). 2. Action and JSX inline handler callback extraction currently unwraps arrow functions only. 3. Optional-call forms on opaque pattern roots are non-lowerable and report `pattern-context:optional-chaining` diagnostics. Optional property/element access is supported only in explicit lowerable expression sites; statement- position optional access still errors. 4. Non-static destructuring defaults, rest destructuring, and unsupported computed destructuring keys in pattern callbacks remain non-lowerable and produce pattern-context diagnostics. 5. Interprocedural capability propagation applies only when a resolved callee declaration is analyzable in-proc (arrow/function expression/declaration/method); external/unresolved calls remain conservative. 6. The CFC authoring and trusted-UI helper contracts under `docs/specs/ts-transformer/cfc_*.md` are draft **lowering** contracts: the current pipeline on `main` does not lower those forms (no `ifc.*` schema metadata emission). The one landed exception is **`WriteAuthorizedBy` validation** (§6.8) — usage is validated (`cfc-write-authorized-by`) even though the wrapper is not yet lowered. The canonical CFC alias names are re-exported but otherwise inert. Note: the authoring draft says the schema generator validates `WriteAuthorizedBy`; on `main` that validation is a separate stage-10 `WriteAuthorizedByValidationTransformer`, not the schema generator. ## 15. Test Coverage Snapshot The fixture suites driven by `fixture-based.test.ts` live under `test/fixtures//` as `*.input.*` / `*.expected.*` pairs. The driver currently runs these suites: - `ast-transform` - `handler-schema` - `jsx-expressions` - `schema-transform` - `closures` (the largest suite by a wide margin) - `kitchensink` - `schema-injection` (The `bug-repro` directory exists but is not an input/expected fixture suite.) Exact counts are intentionally not pinned here — they churn with every fixture addition. The fixture corpus is in the high hundreds of input files overall, of which `closures` is the largest single suite. To get current numbers: ```bash # per suite for d in test/fixtures/*/; do printf '%s: %s\n' "$(basename "$d")" "$(ls "$d"*.input.* 2>/dev/null | wc -l)" done # total input fixtures find test/fixtures -name '*.input.*' | wc -l ``` Additional non-fixture unit suites cover: - cast/empty-array/pattern-context/opaque-get/schema-shrink validation - diagnostic message transformer behavior - event-handler detection heuristics - opaque-ref analysis/normalization/runtime-style APIs - pipeline regression and policy/capability-analysis behavior - lift-applied call helper and identifier utilities ## 16. Stability Statement This specification is a snapshot of current behavior. Any transformer code or fixture expectation changes should be treated as spec changes and reflected in this document. ### 16.1 Keeping This Spec Current (Sources Of Truth) Several facts in this document are enumerations the implementation already centralizes. When they change, update the spec from the canonical source rather than hand-maintaining a parallel list — and prefer pointing at the source over re-listing it. The enforced sources of truth: | Spec content | Canonical source | Guard / note | | --- | --- | --- | | Pipeline stage set + order (§3) | `CFC_TRANSFORMER_STAGE_SPECS` / `CFC_TRANSFORMER_STAGE_NAMES` (`src/cf-pipeline.ts`) | the array literal is the order | | Cross-stage registries (§2.2) | `CrossStageState` (`src/core/cross-stage-state.ts`) | NodeLinks-shaped families | | Recognized runtime exports + which are reactive origins (§5, §6.3) | `COMMONFABRIC_RUNTIME_EXPORT_REGISTRY` (`src/core/commonfabric-runtime-registry.ts`) | `test/core/commonfabric-runtime-registry.test.ts` asserts coverage of the runner builder factory | | SES self-contained callback boundaries (§6.5) | `SES_SELF_CONTAINED_CALLBACK_BOUNDARIES` (`src/transformers/pattern-context-validation.ts`) | excludes `sqlite-row-label-rule` by design | | Lowerable expression-site container kinds (§6.7) | `getExpressionContainerKind` (`expression-site-policy.ts`) | — | | Diagnostic `type:` strings | the emitting transformer's `reportDiagnostic` calls | grep `type: "…"` per validator | A drift-resistant habit: when a section enumerates a set, cite the constant / function that defines it so a reader can confirm the live set, and keep prose lists explicitly labeled "as of this writing."