@llui/compiler
Build-tool-agnostic compiler engine for LLui. It runs the signal transform — lowering signal expressions in a component's direct view to runtime helpers (signalText/el/signalEach/…) and emitting introspection metadata — and enforces the signal lint set as non-bypassable compile-time errors.
This package is the engine. End users normally consume it through an adapter:
@llui/vite-plugin— the Vite adapter@llui/compiler-ssr— opt-in'use client'directive handling
Why compile-time errors, not lint warnings
Every rule reports at error severity through the compiler. LLM-generated code routinely ignores lint warnings; non-bypassable compiler errors are the only effective channel for catching idiomatic-LLui mistakes before they reach the runtime.
The @llui/eslint-plugin package was removed when the rules migrated into this engine — they are compiler errors now, never ESLint rules.
Rule catalogue
Signal lint rules — checked against signal expressions in a component's view:
| Rule ID | Description |
|---|---|
operator-on-signal |
A JS operator used on a signal handle (sig + 1, `${sig}`, sig ? a : b) — derive with .map |
peek-in-slot |
.peek() in a reactive slot — binds once and never updates; .peek() is for handlers/effects only |
pure-derive-body |
A .map/derive body that isn't pure over plain values (side effects, .at/.map/.peek, node helpers) |
no-node-construction-in-body |
Building element/text nodes inside a derive body — use a structural primitive (show/branch/each) |
prefer-at-over-map |
state.map((s) => s.x) where state.at('x') is the more precise, narrower read |
Cross-file / composition diagnostics — view-helper resolution, dependency flow, and module emission:
| Rule ID | Description |
|---|---|
llui/opaque-view-call |
A view-position call the cross-file walker can't analyze — annotate Renderable/Mountable, accept a view bag, or /** @llui-helper */ |
llui/async-view-helper |
A view-helper returns a Promise — the view layer is synchronous; use onMount() / clientOnly() |
llui/opaque-state-flow |
State flows opaquely through an accessor — coarsens the binding to the whole-state sentinel |
llui/opaque-options-bag |
A helper's options-bag argument isn't an object literal, so its dependency paths can't be narrowed |
llui/missing-context-provider |
A precompiled helper reads a context with no matching provide(...) at the consumer's call site |
llui/helper-cycle |
A cycle in the cross-file view-helper graph |
llui/substitution-cycle |
A cycle while substituting a precompiled library helper's dependency paths |
llui/substitution-depth-exceeded |
A precompiled-helper substitution chain exceeded the depth limit (coarsens that call site) |
The signal transform also enforces the agent annotation rules
(@intent / @emits / @should / tagSend translators) when agent metadata is emitted.
Functions
annotationsToObjectLiteral()
Build a TS object-literal expression for the annotation map. Used by
msgAnnotationsModule for __msgAnnotations emission. Variant
names are emitted as string literals (not identifiers) so
discriminants containing /, -, reserved words, etc. produce
valid JS.
function annotationsToObjectLiteral(a: Record<string, MessageAnnotations>): ts.ObjectLiteralExpression
applyLintFixes()
Apply the fixes carried by messages to source, returning the rewritten
code and how many fixes applied vs. were skipped (overlapping with one already
applied). Messages without a fix are ignored, so a caller can pass a filtered
subset (e.g. only convention diagnostics) to apply just those. Pure — does
not re-lint; the caller decides whether a second pass is warranted.
function applyLintFixes(source: string, messages: ReadonlyArray<{ fix?: LintFix }>): { code: string; applied: number; skipped: number }
buildFieldDescriptor()
Build a single field descriptor from a property signature: type,
optionality, and any @should("…") JSDoc hint. Emits the compact
bare form when there's nothing extra to communicate; otherwise the
rich {type, optional?, priority?, hint?} shape.
Exported so the cross-file resolver (which walks the same property
signatures when the Msg type lives in a different file from the
component() call) can produce identical descriptors. Without
sharing this helper, JSDoc hints would silently disappear whenever
a Msg union got resolved across module boundaries.
function buildFieldDescriptor(member: ts.PropertySignature, source: string, typeIndex: TypeIndex = new Map()): MsgField
buildFieldDescriptorExpr()
Build a TS expression for a single field descriptor in a MsgSchema's
variant map. Used by msgSchemaToLiteral (this file) for the
__msgSchema / __effectSchema emissions. Migrated from inline
buildFieldDescriptorExpr in transform.ts (v2c/decomp-5).
function buildFieldDescriptorExpr(descriptor: MsgField, f: ts.NodeFactory): ts.Expression
buildManifest()
Build a manifest from a package's source program. Only emits entries that
carry useful narrowing info (at least one state-value param with reads);
helpers that would contribute nothing are omitted (a missing entry coarsens
identically, so this just keeps the manifest lean).
function buildManifest(program: ts.Program, opts: BuildManifestOptions): Manifest
clearManifestCache()
function clearManifestCache(): void
collectDeps()
String-input convenience over {@link collectStatePathsFromSource}: parse a
source file and return the sorted set of unique state dependency paths read
by its reactive accessors, plus the opaque-flow flag. These are the paths
the runtime's chunked-mask reconciler gates each binding on.
Files that don't import from @llui/dom return an empty set. extraPaths
(paths discovered through in-repo view-helpers in other files, via the
cross-file walker) are unioned in so cross-file helpers contribute to the
consumer's dependency set.
function collectDeps(source: string, extraPaths?: ReadonlySet<string>): {
paths: string[]
opaque: boolean
/** AST node that first triggered the opaque-flow flag (if any). */
opaqueNode?: ts.Node
/** Short human label for the opaque shape. */
opaqueShape?: string
}
collectStatePathsFromSource()
Walk the AST and collect every unique state access path referenced by a reactive accessor. A reactive accessor is one of:
- An inline arrow / function expression at a reactive position
(
text(s => s.count),div({ title: s => s.title }),show({ when: s => s.gated }), etc.). - An Identifier at a reactive position that resolves to a callable
in this file — a const-bound arrow / function expression,
a hoisted function declaration, or
const x = memo(arrow). The second case lets authors refactor a literal arrow into a named helper without the analyzer losing its dependency paths. When a reactive accessor can't be resolved, the file is marked opaque and the runtime coarsens those bindings to the whole-state sentinel (correct, but every such binding fires on every state change). The emitted dependency paths feed the runtime's chunked-mask reconciler (each binding'sdepsarray).collectDeps(below) is the string-input convenience wrapper.
function collectStatePathsFromSource(sourceFile: ts.SourceFile): {
paths: Set<string>
opaque: boolean
opaqueNode?: ts.Node
opaqueShape?: string
}
computeSchemaHash()
Stable hex SHA-256 (first 32 chars) over a normalized JSON serialization
of msgSchema + stateSchema + msgAnnotations. Object key order is
normalized so equivalent inputs always produce equal hashes.
Used by the runtime to detect when the browser-to-server hello frame
needs to re-send its schema payload (dev hot-reload).
function computeSchemaHash(input: SchemaHashInput): string
detectOpaqueStateFlow()
function detectOpaqueStateFlow(body: ts.Node, stateParam: string, out: OpaqueOut): void
extractDiscriminatedUnionSchemaCrossFile()
Cross-file companion to extractMsgSchema / extractEffectSchema.
Discriminated-union schema extractor that follows composed
TypeReferences through the resolver. Same recursion shape as
extractMsgAnnotationsCrossFile, just collecting field shapes
instead of JSDoc annotations.
function extractDiscriminatedUnionSchemaCrossFile(source: string, typeName: string, filePath: string, ctx: ResolveContext): Promise<MsgSchema | null>
extractEffectSchema()
function extractEffectSchema(source: string, typeName: string = 'Effect'): MsgSchema | null
extractMsgAnnotations()
Walk a Msg-like discriminated-union type alias and extract JSDoc
annotations attached to each union member. Returns null if no
recognizable union is found so callers can skip emission cleanly.
Expected JSDoc grammar (order-independent):
@intent("human readable")
@alwaysAffordable
@requiresConfirm
@humanOnly — sugar for dispatchMode: 'human-only'
@agentOnly — sugar for dispatchMode: 'agent-only'
Unknown tags are ignored; malformed @intent (no quoted string) is
treated as "no intent". @humanOnly and @agentOnly are mutually
exclusive — if both are present (which the ESLint rule
agent-exclusive-annotations reports as an error), the parser
falls back to 'shared' so a misconfigured Msg variant doesn't
silently lock out one audience.
function extractMsgAnnotations(source: string, typeName: string = 'Msg'): Record<string, MessageAnnotations> | null
extractMsgAnnotationsCrossFile()
Annotation extractor that walks composed Msg unions across files.
Given a Msg type that may be a union of inline { type: 'literal' }
objects AND TypeReferences (e.g.
type Msg = ImportedFoo | { type: 'extra' }), recursively follow
each TypeReference via findTypeSource and merge its variants into
the returned map.
Composition + cross-file is the union of two failure modes the
file-local sync extractor silently mishandles. This function
produces the same map the runtime expects regardless of how the
developer organized the type declarations.
Conflict policy: if two composed branches contribute the same
discriminant string (e.g. both halves declare { type: 'inc' }),
the first one walked wins. The lint rule agent-msg-resolvable
fires before this point on most pathological cases; ESLint's
type-checker would flag the duplicate independently.
function extractMsgAnnotationsCrossFile(source: string, typeName: string, filePath: string, ctx: ResolveContext): Promise<Record<string, MessageAnnotations> | null>
extractMsgSchema()
function extractMsgSchema(source: string, typeName: string = 'Msg'): MsgSchema | null
extractPaths()
Extract state access paths from an expression body. Handles:
- Direct property access: param.field, param.field.subfield
- Bracket notation with string literal: param['field']
function extractPaths(node: ts.Node, paramName: string, _prefix: string, paths: Set<string>): void
extractStateSchema()
function extractStateSchema(source: string, typeName = 'State'): StateSchema | null
fieldType()
Extracts the bare type from either descriptor form.
function fieldType(f: MsgField): MsgFieldType
findTypeSource()
Walk imports + re-exports to find where a type alias is actually
declared. Returns the source string and local name of the alias in
its declaring file. Returns null if the chain leads to an unresolved
module, a re-export through export *, a namespace import, or a
dead-end (alias not declared anywhere we can see).
function findTypeSource(typeName: string, source: string, filePath: string, ctx: ResolveContext, visited: Set<string> = new Set()): Promise<ResolvedTypeSource | null>
hasNonDefaultAnnotation()
Whether the annotation map carries any non-default values. Used to
gate __msgAnnotations emission — annotations whose every field is
default are emission-redundant (the runtime treats absence as the
same defaults). Saves ~50 bytes per component for un-annotated Msg
unions, which dominates the corpus.
function hasNonDefaultAnnotation(a: Record<string, MessageAnnotations>): boolean
injectScopeVariantRegistrations()
function injectScopeVariantRegistrations(node: ts.SourceFile, f: ts.NodeFactory): InjectResult
isMemoCallWithArrowArg()
Recognize memo(arrow) / memo(fn) calls so the inner accessor can
be analyzed for state-path masking. The runtime memo() returns a
cached accessor — its body's reads determine when it re-evaluates,
not the call site.
function isMemoCallWithArrowArg(expr: ts.Expression): expr is ts.CallExpression & {
arguments: readonly [ts.ArrowFunction | ts.FunctionExpression, ...ts.Expression[]]
}
isReactiveAccessor()
Determines if a node is at a reactive-accessor position — either an
inline arrow / function expression OR an identifier that's about to
be resolved to one. The check is identity-based on parent.arguments[0]
etc., so the same logic works for both shapes.
Exported so the cross-file walker can use the same gate. Without this
gate the walker descends into every 1-param arrow in the file —
including onEffect: (bag) => bag.send(...) — and pollutes
__prefixes with non-state property names (issue #5, bug 3).
function isReactiveAccessor(node: ts.Node): boolean
isRichField()
True when f is a rich descriptor (object with type key).
function isRichField(f: MsgField): f is MsgFieldRich
lintSignalSource()
Parse source and run the signal lint rules, returning diagnostics with
resolved line/column. The adapter (vite plugin) surfaces these as build
errors. Call only on confirmed signal components.
function lintSignalSource(source: string, fileName = 'm.tsx'): SignalLintMessage[]
lookupHelperFromSymbol()
Resolve the manifest helper entry for a call-site callee symbol. @param sym the (possibly aliased) symbol of the call target @param checker the program's type checker
function lookupHelperFromSymbol(sym: ts.Symbol, checker: ts.TypeChecker): ManifestLookupResult
msgSchemaToLiteral()
Build the full { discriminant, variants } object literal for a
MsgSchema. Symmetric for __msgSchema and __effectSchema emission
(both use the discriminated-union shape).
function msgSchemaToLiteral(schema: MsgSchema, f: ts.NodeFactory): ts.ObjectLiteralExpression
parseAnnotations()
Parse a JSDoc comment string into MessageAnnotations. The single
source of truth for the annotation grammar — used both for same-file
Msg unions (here) and for cross-file resolution
(cross-file-resolver.ts imports this rather than re-implementing it,
so the two paths can't drift).
function parseAnnotations(comment: string): MessageAnnotations
parseManifest()
Parse + validate a manifest JSON string. Validation is intentionally shallow
but covers everything the substitution engine iterates (helpers[*].kind,
.viaParams[*].shape, .index) so a malformed third-party manifest can't
crash the consumer's compile. Schema version must equal the current
MANIFEST_SCHEMA_VERSION and the emitting compilerVersion's major must
match this compiler.
function parseManifest(json: string): ParseManifestResult
rangeFromOffsets()
Convert a TS Compiler API (start, end) offset pair against a source
file into the canonical Range shape. Used by emitters that have AST
node positions but not pre-computed line/column.
function rangeFromOffsets(sourceText: string, start: number, end: number): Range
readComponentTypeArgNames()
Inspect the type arguments of a component<...>() call and return
the textual identifier for each known position. Returns null for
positions whose type argument isn't a plain identifier (e.g.
inline literal types, generic instantiations, namespace-qualified
names). Identifiers are what the resolver can chase; everything else
we leave to the local extractor's existing behavior.
Order: [State, Msg, Effect] matching component<State, Msg, Effect>.
function readComponentTypeArgNames(call: ts.CallExpression): {
state: string | null
msg: string | null
effect: string | null
}
relativizeFile()
Project-relative path helper. Adapters pass the project root resolved
from llui.config.ts / Vite's config.root; emitters that have an
absolute path use this to canonicalize before placing into a
Diagnostic. Falls back to the absolute path if root is empty or
the file isn't a descendant.
function relativizeFile(absoluteFile: string, root: string): string
resolveAccessorBody()
Resolve a value at a reactive-accessor position down to the callable
AST node we can mask-analyze. Returns null when the value isn't a
recognized accessor shape — caller leaves the call unchanged (runtime
falls back to FULL_MASK, which is correct just slower).
Recognized shapes:
(s) => …(ArrowFunction)function (s) { … }(FunctionExpression)memo((s) => …)— returns the inner arrowsomeIdentifierresolving to any of the above (or to a hoistedfunction X(s) { … }declaration) Whencheckeris supplied, identifier resolution follows alias chains across files:import { matrixOrEmpty } from '../state'becomes resolvable. Without a checker the resolver falls back to file-localconst/functionlookup. The cross-file path requires the identifier's AST node to be bound to the checker's Program — pass nodes obtained viaprogram.getSourceFile(...), not from a freshlyts.createSourceFile'd copy. (See AnalysisContext.program.)
function resolveAccessorBody(value: ts.Expression, checker?: ts.TypeChecker): ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration | null
resolveFieldType()
function resolveFieldType(type: ts.TypeNode, typeIndex: TypeIndex = new Map(), depth = MAX_FIELD_DEPTH): MsgFieldType
resolveLocalConstInitializer()
Walk parent chains to find a const X = ... declaration matching
use.text, or a hoisted function X(...) declaration. Returns the
resolved declaration or null for unresolvable references (imports,
parameters, this-bindings, etc.).
Limitations:
- Only
const.letresolution is unsafe — we can't track later reassignments without a type checker. - Only single-binding declarations (
const a = …, notconst a = …, b = …). - The declaration must dominate the use (lexical scope).
function resolveLocalConstInitializer(use: ts.Identifier): ts.Expression | ts.FunctionDeclaration | null
serializeManifest()
Serialize a manifest to stable, diff-friendly JSON: object keys sorted
(so re-emits are byte-identical regardless of insertion order), arrays left
in their meaningful order (e.g. viaParams is index-ordered). 2-space indent
- trailing newline to match the repo's prettier output.
function serializeManifest(manifest: Manifest): string
shadowsStateParam()
Returns true when one of the function's parameter bindings would
shadow an outer identifier named stateParam. Covers identifier
params ((s) => …), simple destructured patterns (({s}) => …),
and array patterns (([s]) => …). Tracking shadowing avoids a
common false positive where a nested arrow's s is mis-attributed
to the outer accessor's state parameter — most notably bites the
track({ deps: (s) => [...] }) escape hatch when the outer
accessor is also (s) => ….
function shadowsStateParam(parameters: ts.NodeArray<ts.ParameterDeclaration>, stateParam: string): boolean
stateTypeToLiteral()
Build a TypeScript expression representing the given StateType as a
runtime-readable literal. The emission shape mirrors the StateType
tagged union — string/number/boolean/unknown become string
literals; the structural kinds become object literals with a kind
field plus the appropriate payload (of/fields/values).
Used by stateSchemaModule for __stateSchema emission. The shape
is the runtime/agent contract; downstream tools (MCP introspection,
agent's "what type is this field?") consume it.
function stateTypeToLiteral(t: StateType, f: ts.NodeFactory): ts.Expression
substituteHelperCall()
Substitute a manifest helper call against its call-site arguments. Given a helper's manifest entry and the argument expressions at one call site, returns the set of host-state paths the call contributes to the consumer's __prefixes table. §4.4 substitution rules:
- For each ViaParams entry, resolve the call-site argument.
shape: 'accessor'parameters are walked viaextractPaths.shape: 'options-bag'parameters are unpacked field-by-field against the call site's object-literal argument.innerReadsare composed against the resolved accessors:- rooted: helper-local, contributed verbatim
- param-result: paths from param N's body
- param-result-path: lift + sub-path composition
readsThroughResultOf: N— param's body operates on param N's result; substitution composes through N's accessor.contextReads— resolved againstproviders; provider.accessor + subPaths compose to host-state paths.- Depth bounded at 8; cycles caught by
(helper-symbol, param-index)visited set.
function substituteHelperCall(entry: HelperEntry, callArgs: ReadonlyArray<ts.Expression>, ctx: SubstitutionContext, helperKey = 'anonymous', visited = new Set<string>(), depth = 0): SubstitutionResult
tagDispatchHandlers()
Walks every ArrowFunction and FunctionExpression in the source
and wraps any whose body contains literal <id>({type:'X', …})
dispatches with Object.assign(fn, {__lluiVariants: ['X', …]}).
The runtime (in @llui/dom elements.ts / el-split.ts) reads
__lluiVariants from event-handler bindings only — so tags placed
on functions in non-handler positions (a const declared but never
bound, an arrow passed to Array.filter, a view function whose
body has nested handlers with dispatches) are runtime-inert. The
compiler tags generously; the runtime registers selectively.
Universal scope means three concrete patterns all surface their
variants without the app author having to think about it:
- Inline event-handler arrows —
onClick: () => send({type:'X'})(the original Pass 1 case). - Const-bound translator functions —
const sendMenu = (m) => dispatch({type:'Y'})paired with*.connect(get, sendMenu, …)(the original Pass 3 case). The tag travels with the function reference; library connect impls usetagSend(send, libVariants, fn)to propagate it onto returned handlers. - Positional-arg handlers —
helper(label, () => send({type:'Z'}))wherehelperis an app-defined wrapper likenavButton(label, onClick)that eventually binds the function as an event listener. The arrow is still tagged at its declaration site, and the runtime reads the tag when the wrapper binds it. False positives are deliberate. The alternative — proving that a tagged arrow actually reaches an event-handler binding — would require cross-function, cross-file flow analysis the compiler doesn't do. In practice the cost of an over-tagged arrow is bytes, not behavior: the runtime never reads the tag from non-handler bindings. Pass 2'scollectLocalFnsresolves identifiers to their original arrow/function initializers; this pass replaces those initializers withObject.assign(arrow, {…})wrappers. Run Pass 2 BEFORE Pass 1 so the resolver still sees raw arrows. Already-wrapped functions (CallExpressions, including user-appliedtagSend(...)or this pass's own prior output) are skipped — the pass only fires on bare arrow/function expressions.
function tagDispatchHandlers(node: ts.SourceFile, f: ts.NodeFactory): ts.SourceFile
transformSignalComponentSource()
Rewrite signal views in a source file and inject the runtime import.
Returns the source unchanged if it contains no signal components.
String-only convenience wrapper over {@link transformSignalComponentSourceWithMap}
— kept for the many callers (mcp, dom codegen tests) that only need the code.
function transformSignalComponentSource(source: string, opts: SignalTransformOptions = {}): string
transformSignalComponentSourceWithMap()
The map-returning form. Every splice (view rewrites, metadata, batch, bag
injection) plus the injected runtime import compose through ONE MagicString
instance, so the returned {@link SourceMap} is coherent. The vite-plugin threads
this map (and can compose the lint-autofix pass, which shares the same
{@link applyEditsWithMap} splicer) in a later stage.
function transformSignalComponentSourceWithMap(source: string, opts: SignalTransformOptions = {}): SignalTransformResult
Types
CompilerDomInternalImport
export type CompilerDomInternalImport = (typeof COMPILER_DOM_INTERNAL_IMPORTS)[number]
CompilerRenameableKey
export type CompilerRenameableKey = (typeof COMPILER_RENAMEABLE_KEYS)[number]
DiagnosticCategory
export type DiagnosticCategory =
/** Reactive-path correctness — overflow, opaque accessors, mask gating. */
| 'reactivity'
/** View composition — async helpers, missing context providers, helper cycles. */
| 'composition'
/** Agent integration — Msg-schema resolvability, dispatch-translator drift. */
| 'agent'
/** Style / authoring conventions — naming, redundancy, lint-only signals. */
| 'style'
/** Performance — whole-state (FULL_MASK) coarsening, expensive accessors. */
| 'perf'
/** Module / build configuration — manifest skew, version mismatch, integrity. */
| 'config'
/** Internal — module exceptions, walker termination paths, debug diagnostics. */
| 'internal'
DiagnosticSeverity
export type DiagnosticSeverity = 'error' | 'warning' | 'info'
DispatchMode
export type DispatchMode = 'shared' | 'human-only' | 'agent-only'
FieldSpec
export type FieldSpec =
| { shape: 'accessor'; innerReads: InnerRead[] }
| {
shape: 'accessor'
readsThroughResultOf: number
innerReads: InnerRead[]
}
| { shape: 'state-value'; reads: string[] }
| { shape: 'send' }
| { shape: 'thunk-returning-nodes' }
| { shape: 'opaque' }
InnerRead
export type InnerRead =
/** Helper-local read — rare; the helper sees state directly. */
| { kind: 'rooted'; path: string }
/** The entire result of parameter N. */
| { kind: 'param-result'; from: number }
/** A sub-path within parameter N's accessor result. The dominant kind across @llui/components. */
| { kind: 'param-result-path'; from: number; path: string }
ManifestLookupResult
export type ManifestLookupResult =
| { kind: 'found'; lookup: ManifestHelperLookup }
/** No package / no manifest file — coarsen silently (the common case). */
| { kind: 'absent' }
/** Manifest present but version-incompatible — coarsen + emit a diagnostic. */
| { kind: 'incompatible'; detail: string }
/** Manifest present but unparseable/structurally wrong — coarsen + emit a diagnostic. */
| { kind: 'malformed'; detail: string }
MessageAnnotations
export type MessageAnnotations = {
intent: string | null
alwaysAffordable: boolean
requiresConfirm: boolean
dispatchMode: DispatchMode
/**
* Concrete example dispatches the LLM can copy from. Populated by
* `@example("text")` JSDoc tags. Each tag becomes one entry, in
* source order, so authors can mix scenarios ("typical case",
* "edge case with auth", etc.) without nesting them in a single
* string.
*/
examples: string[]
/**
* Non-blocking caution. Surfaced verbatim to the agent at affordance
* time so the LLM can weigh the consequence ("this overwrites the
* cloud version", "fires analytics that can't be retracted") before
* dispatching. Distinct from `requiresConfirm`, which is a runtime
* gate the user must acknowledge.
*/
warning: string | null
/**
* Effect kinds this variant emits when dispatched, declared by the
* author via `@emits("kind1", "kind2")`. Lets the agent reason
* about side effects ("this dispatch hits the cloud, so I should
* batch") without the compiler having to walk update.ts. Authored
* rather than auto-extracted because real apps emit effects
* through helpers (`track('foo')`, `saveDelta(d)`) — auto-detecting
* those would require helper-return-shape analysis with
* ergonomically-painful failure modes; the declarative form trades
* automatic discovery for accuracy and simplicity.
*
* Empty when no `@emits` tag is present.
*/
emits: string[]
/**
* Boolean predicate gating whether the variant surfaces in
* `list_actions`. Authored as `@routeGated("expr")`; the compiler
* captures the predicate string verbatim and the runtime evaluates
* it with `state` bound to the current state. The variant only
* appears in the agent's affordance list when the predicate
* returns true.
*
* Compile-time alternative to `agentAffordances(state) => Msg[]`
* for the common case of "this Msg is reachable when state.X
* looks like Y." Co-located with the Msg definition rather than
* threaded through a separate hook.
*
* Examples:
* @routeGated("state.matrixState.kind === 'loaded'")
* @routeGated("state.route.kind === 'page' && state.route.slug === 'ranking'")
* @routeGated("state.auth.status === 'authenticated'")
*
* Null when no `@routeGated` tag is present (variant defaults to
* its dispatchMode-driven affordance behavior).
*/
routeGate: string | null
/**
* Human-readable reason surfaced when the `@routeGated` predicate is
* FALSE. Authored as the optional second argument of `@routeGated`:
* `@routeGated("step === 'review'", "available during the review step")`.
* `list_actions` includes the gated variant as `available: false` with
* this string as `unavailableReason`, so the agent learns the action
* exists and what unblocks it instead of seeing it silently vanish.
*
* Null when `@routeGated` has no second argument (the runtime falls back
* to a generic "not available in the current state").
*/
routeGateReason: string | null
}
MsgField
export type MsgField = MsgFieldType | MsgFieldRich
MsgFieldType
The "bare type" of a field. Covers five cases:
- primitive keyword as a string:
'string','number','boolean','unknown' - literal union:
{enum: ['a', 'b']}for strings,{enum: [1, 2, 3]}for numbers,{enum: [true]}for booleans. Mixed-type literal unions stay'unknown'. - nested object shape:
{kind: 'object', shape: {...}}— emitted when a field's type is a local interface/type alias the extractor could follow (depth-limited; cross-file references stay'unknown'). - array of element type:
{kind: 'array', element: <bare type>}. - discriminated union of objects:
{kind: 'discriminated-union', discriminant: 'kind', variants: {a: {...}, b: {...}}}. Emitted when every member of a union is an object literal sharing one literal-string property name with distinct values. Symmetric with how the top-level Msg union itself is encoded — same shape, recursed. The synthesizer in@llui/agent'slist_actionswalks these to build copy-paste-ready payload examples; the validator insend_messagewalks them too (treating object/array as "any" since deep validation is the reducer's job).
export type MsgFieldType =
| string
| { enum: ReadonlyArray<string | number | boolean> }
| { kind: 'object'; shape: Record<string, MsgField> }
| { kind: 'array'; element: MsgFieldType }
| {
kind: 'discriminated-union'
discriminant: string
variants: Record<string, Record<string, MsgField>>
}
ParamSpec
export type ParamSpec =
| { index: number; shape: 'accessor'; innerReads: InnerRead[] }
| {
index: number
shape: 'accessor'
/** This parameter's body operates on the result of parameter N. */
readsThroughResultOf: number
innerReads: InnerRead[]
}
/**
* The parameter is the STATE VALUE itself, passed directly (not via an
* accessor function): `helper(s)` inside `state.map(s => helper(s))`. `reads`
* are the dotted sub-paths the helper reads from that value; substitution
* composes them onto the call-site argument's path prefix
* (`s` → '', `s.foo` → 'foo'). Added in schema v2.
*/
| { index: number; shape: 'state-value'; reads: string[] }
| { index: number; shape: 'options-bag'; fields: Record<string, FieldSpec> }
| { index: number; shape: 'send' }
| { index: number; shape: 'thunk-returning-nodes' }
| { index: number; shape: 'opaque' }
ParseManifestResult
export type ParseManifestResult =
| { ok: true; manifest: Manifest }
/** `incompatible` = readable but the schema/compiler version doesn't match;
* `malformed` = unparseable or structurally wrong. Both → caller coarsens. */
| { ok: false; reason: 'incompatible' | 'malformed'; detail: string }
SchemaHashInput
export type SchemaHashInput = {
msgSchema: unknown
stateSchema: unknown
// structurally serialized into the hash — accepts the typed annotations map
// (Record<string, MessageAnnotations>) or a cross-file-resolved equivalent.
msgAnnotations: Record<string, unknown> | null | undefined
}
StateType
export type StateType =
| 'string'
| 'number'
| 'boolean'
| 'unknown'
| { kind: 'enum'; values: string[] }
| { kind: 'array'; of: StateType }
| { kind: 'object'; fields: Record<string, StateType> }
| { kind: 'optional'; of: StateType }
| { kind: 'union'; of: StateType[] }
TypeIndex
Index of type aliases and interfaces visible from a source file,
keyed by name. Lets the field-type resolver follow Criterion[] →
interface Criterion { … } and emit a nested object shape rather
than 'unknown'.
The cross-file resolver pipeline (cross-file-resolver.ts) builds
an enriched index that includes types imported from sibling files —
follow GridSorting → 'rank' | 'crit-X' | 'crit-Y' → {enum: […]}
even when the alias lives in ./state.ts not the Msg-defining file.
export type TypeIndex = Map<string, ts.TypeNode | ts.InterfaceDeclaration>
Interfaces
BuildManifestOptions
export interface BuildManifestOptions {
/** Absolute path to the package's source root (e.g. `<pkg>/src`); module ids are relative to it. */
srcRoot: string
}
CodeAction
export interface CodeAction {
/** Human-readable label for the autofix. */
title: string
/** Source edits that apply the fix. Adapters translate to their host edit format. */
edits: Array<{
file: string
range: Range
/** New text replacing `range`. Empty string deletes the range. */
newText: string
}>
}
ComponentEntry
export interface ComponentEntry {
/** Reserved for v2b's read-everything-the-component-reads escape hatch. Unused at v2b ship. */
name: string
}
ContextProvider
export interface ContextProvider {
context: string
/** Source AST for the consumer's `provide(LocaleContext, (s) => s.i18n, ...)` accessor. */
accessor: ts.ArrowFunction | ts.FunctionExpression | undefined
}
ContextRead
export interface ContextRead {
/** Canonical id: `<package-name>#<export-name>`. */
context: string
/** Sub-paths within the context value the helper reads. */
subPaths: string[]
}
Diagnostic
export interface Diagnostic {
/** Stable id — `<namespace>/<slug>`. Examples: `llui/opaque-view-call`. */
id: string
severity: DiagnosticSeverity
category: DiagnosticCategory
/** Human-readable, present-tense, actionable. */
message: string
location: DiagnosticLocation
/** Cross-references (e.g. the other end of a cycle, the missing provider's expected site). */
relatedInformation?: DiagnosticRelatedInformation[]
/** Structured edits the adapter can offer as autofixes. */
fixes?: CodeAction[]
/** Optional URL to user-facing documentation for this diagnostic id. */
documentation?: string
}
DiagnosticLocation
export interface DiagnosticLocation {
/** Project-relative path on emission (never absolute, never hostname-tainted). */
file: string
range: Range
}
DiagnosticRelatedInformation
export interface DiagnosticRelatedInformation {
location: DiagnosticLocation
message: string
}
ExternalTypeSources
Resolved external type sources for the file under analysis: the source
string + local alias name for each of the State / Msg / Effect
type arguments that the host adapter (vite-plugin) chased to their
declaring file via findTypeSource. The schema/annotation extractors
run against these instead of the focal file when the alias lives
elsewhere. All fields optional — absent ones fall back to file-local
extraction.
export interface ExternalTypeSources {
state?: { source: string; typeName: string }
msg?: { source: string; typeName: string }
effect?: { source: string; typeName: string }
}
HelperEntry
export interface HelperEntry {
/**
* `'view-helper'` — the call returns Node[]-like and is resolved once per
* call site.
* `'parts-helper'` — the call returns a *parts bag* (a record of accessor
* thunks). The bag is later spread into element calls by the consumer;
* every spread contributes the same read set.
*/
kind: 'view-helper' | 'parts-helper'
/** Paths the helper reads from its OWN state shape (rare; usually empty). */
helperLocalPaths: string[]
/** Per-parameter substitution metadata. Index N corresponds to the helper's Nth declared parameter. */
viaParams: ParamSpec[]
/** Context-provider keys this helper consumes. Resolved against the consumer's provide() call sites. */
contextReads?: ContextRead[]
}
InjectResult
export interface InjectResult {
sf: ts.SourceFile
/** True when at least one `__registerScopeVariants(...)` call was inserted. */
injected: boolean
}
LintEdit
A single text replacement, as absolute char offsets into the linted source.
export interface LintEdit {
start: number
end: number
newText: string
}
LintFix
A deterministic, mechanically-applicable fix for a diagnostic — the same
shape an editor quick-fix or applyLintFixes consumes. A diagnostic carries
at most one (the single obvious correction); multi-option fixes aren't needed
for the rename-style rules that produce them.
export interface LintFix {
/** Short label, e.g. "Rename to `tabindex`". */
title: string
edits: LintEdit[]
}
LowerBail
A lowering attempt that gave up and fell back to a slower path. Events are
facts about ATTEMPTS, not final outcomes: an each whose row factory bails
(each-direct) may still lower on the render-callback path (signalEach),
and a pass-1 shape bail may be picked up by the pass-2 helper lowering —
correlate with the transformed output to classify final tiers. Reason tokens
are short, stable kebab-case strings meant to feed coverage telemetry and,
later, user-facing perf diagnostics.
export interface LowerBail {
/** which lowering gave up: the each row factory (`each-direct`), the each
* render-callback arm (`each-render`), a `show`/`branch` arm, the view-helper
* pass-2 `each` (`helper-each`), or same-file helper-row inlining
* (`inline-helper`, reported only once a same-file delegation target was
* actually identified). */
kind: 'each-direct' | 'each-render' | 'show' | 'branch' | 'helper-each' | 'inline-helper'
/** short stable reason token, e.g. 'row-local-signal-alias' */
reason: string
/** start offset of the bailing call / row render in the original source file */
pos: number
}
Manifest
export interface Manifest {
/** Schema version. Currently 2 (see `MANIFEST_SCHEMA_VERSION`). */
version: 2
/** Compiler version that emitted this manifest. */
compilerVersion: string
/** Exported helpers keyed by name. */
helpers: Record<string, HelperEntry>
/** Exported components keyed by name (for completeness; not used in v2b's substitution). */
components: Record<string, ComponentEntry>
}
ManifestHelperLookup
export interface ManifestHelperLookup {
manifest: Manifest
packageName: string
/** `<moduleId>#<exportName>`, the canonical helper key (also used as the substitution label). */
helperKey: string
/** The matched entry, or undefined when the package ships a manifest but not this helper. */
entry: HelperEntry | undefined
}
MsgFieldRich
Rich per-field descriptor. Emitted only when there's something
beyond the bare type to communicate — optionality, an explicit
priority hint, a freeform agent hint, or a runtime validation
predicate. When everything but type is unset, the producer emits
the bare MsgFieldType instead so variants without annotations
stay byte-cheap in the bundle.
export interface MsgFieldRich {
type: MsgFieldType
/** Mirrors TypeScript's `?:` optional marker. Required fields omit this. */
optional?: boolean
/**
* Strength signal for optional fields. Borrows RFC 2119's `SHOULD`:
* the LLM ought to fill it in unless it has a specific reason not
* to. Required fields don't carry a priority — TS already conveys
* "must" via the type system. Currently the only level; future
* extensions could add `'recommended'` or similar.
*/
priority?: 'should'
/** Freeform consequence-shaped explanation. Surfaced verbatim to
* the LLM at affordance time. */
hint?: string
/**
* Boolean JS expression that must hold for the field's value to be
* accepted. The expression has `v` bound to the field's runtime
* value; everything else is global (Math, JSON, RegExp, etc.).
* Authored as `@validates("expr")` JSDoc — the compiler captures
* the source string verbatim and the validator compiles it lazily
* with `new Function`, caching across calls.
*
* Examples:
* @validates("v >= 0 && v <= 100") // weight 0–100
* @validates("v.length > 0") // non-empty string
* @validates("/^[a-z0-9-]+$/.test(v)") // slug format
*
* The predicate runs ONLY at the agent boundary. Human-driven
* dispatches bypass it because TypeScript already validated the
* call site. Use for invariants the type system can't express
* (numeric ranges, format predicates, length bounds).
*/
validates?: string
}
MsgSchema
export interface MsgSchema {
discriminant: string
variants: Record<string, Record<string, MsgField>>
}
OpaqueOut
Mutable collector for "the file's __prefixes must degrade to the
whole-state sentinel" — written by every accessor walker that sees
an opaque shape (unresolvable delegation, dynamic s[expr], state
in a spread, etc.). The first triggering site is captured so a
downstream diagnostic can point the author at the line that
silently degraded mask precision for every binding in the file.
Subsequent leaks DON'T overwrite — the surface diagnostic is "fix
this one and rerun"; flooding the user with every leak found is
lower value than a precise first cause.
export interface OpaqueOut {
value: boolean
/** First opaque shape encountered. Stable across calls — only set when value flips false→true. */
node?: ts.Node
/** Short human label for the shape (e.g. "dynamic element access `s[expr]`"). */
shape?: string
}
Position
export interface Position {
/** 0-based line index. */
line: number
/** 0-based UTF-16 code-unit column. */
column: number
}
PreExtractedSchemas
Schemas already extracted by the adapter's async cross-file / composition-aware hook before invoking the signal transform. Used when the file-local sync extractors can't see the whole picture — the Msg/Effect/State alias lives in another file, or the union composes inline literals with imported TypeReferences. When provided, the transform uses these instead of running its own file-local extractors.
export interface PreExtractedSchemas {
msgSchema?: ReturnType<typeof extractMsgSchema>
msgAnnotations?: ReturnType<typeof extractMsgAnnotations>
stateSchema?: ReturnType<typeof extractStateSchema>
effectSchema?: ReturnType<typeof extractEffectSchema>
}
Range
export interface Range {
start: Position
end: Position
}
ResolveContext
Cross-file type resolver.
The schema/annotation extractors (extractMsgAnnotations,
extractMsgSchema, extractStateSchema, extractEffectSchema) only
see the source string for the file currently being transformed. When
a developer keeps the Msg (or State / Effect) union in a
separate file and imports it where component() is called, those
extractors silently return null — the plugin emits no annotations,
runtime LAP validation is disabled, and Claude can dispatch arbitrary
type strings that fall through to assertNever.
This module follows imports and re-exports to find the source file
that declares the requested type alias, returning that file's source
string + the local name of the alias there. Extractors then run
against that source and produce the same output they would have for
a co-located declaration.
Limitations:
- Composition (
type Msg = ImportedA | { type: 'b' }): only the locally-declared variants are extracted; the imported half isn't walked recursively into. The lint ruleagent-msg-resolvablecatches this case at lint time. - Namespace imports (
import * as ns from './msg') andexport *: not followed. Same lint coverage. - Generic types: not parameterized resolution; the type argument must resolve to a concrete type alias.
export interface ResolveContext {
/**
* Resolve a module specifier (e.g. `'./msg'`, `'@scope/pkg'`) against
* the importing file's path. Returns the absolute filesystem path of
* the resolved module, or `null` if it cannot be resolved (the type
* stays unresolved and the extractor falls back to local-only mode).
*/
resolveModule: (spec: string, importerPath: string) => Promise<string | null>
/**
* Read the source contents of an absolute module path. The contents
* are parsed by TypeScript so they should be valid TS/TSX. The plugin
*'s vite hook plumbs `fs/promises.readFile` here; tests provide an
* in-memory map.
*/
readSource: (absolutePath: string) => Promise<string>
}
ResolvedTypeSource
export interface ResolvedTypeSource {
/** The full source string of the file declaring the type alias. */
source: string
/** The local name of the alias *in that file* (after rename chains). */
localName: string
/** Absolute path of the file declaring the alias (debug aid). */
filePath: string
}
SignalDiagnostic
export interface SignalDiagnostic {
rule: string
message: string
start: number
length: number
/** Present iff the diagnostic is mechanically fixable (rename-style rules). */
fix?: LintFix
}
SignalLintMessage
A lint diagnostic with source position resolved (1-based line, 0-based col).
export interface SignalLintMessage {
rule: string
message: string
start: number
line: number
column: number
/** Present iff the diagnostic is mechanically fixable (see {@link LintFix}). */
fix?: LintFix
}
SignalTransformResult
Result of {@link transformSignalComponentSourceWithMap}: the rewritten code and a source map (null when the file had no signal component and was returned as-is).
export interface SignalTransformResult {
code: string
map: SourceMap | null
}
StateSchema
export interface StateSchema {
fields: Record<string, StateType>
}
SubstitutionContext
export interface SubstitutionContext {
/** Maps canonical context ids to the consumer's matching provide(...) accessor. */
providers: Map<string, ContextProvider>
/**
* Path-extraction hook. Walks an arrow body and returns the dotted paths
* it reads. The cross-file walker injects its `extractAccessorPaths`
* here; tests can stub with a simpler walker.
*/
extractPaths: (
accessor: ts.ArrowFunction | ts.FunctionExpression,
rootParamName: string,
) => string[]
/**
* The enclosing reactive accessor's root parameter name — the `s` in
* `state.map(s => helper(s))` — used to resolve bare `state-value` args.
* Absent for accessor-function-only call contexts; then `state-value` params
* coarsen to FULL_MASK.
*/
rootParamName?: string
/**
* Extract the dotted path a VALUE expression denotes relative to
* `rootParamName` (`s` → '', `s.foo.bar` → 'foo.bar'); returns null when the
* expression is not rooted at the param (so the call coarsens). Injected by
* the cross-file walker; tests may stub it.
*/
extractValuePath?: (expr: ts.Expression, rootParamName: string) => string | null
}
SubstitutionDiagnostic
export interface SubstitutionDiagnostic {
id:
| 'llui/opaque-options-bag'
| 'llui/missing-context-provider'
| 'llui/substitution-depth-exceeded'
| 'llui/substitution-cycle'
message: string
}
SubstitutionResult
export interface SubstitutionResult {
/** Host-state paths contributed by this call site, e.g. `['carousel.paused', 'carousel.current']`. */
paths: string[]
/** Diagnostics emitted by the substitution. */
diagnostics: SubstitutionDiagnostic[]
/** Whether the call site fell back to FULL_MASK (e.g. unrecognized options-bag shape). */
fullMask: boolean
}
Constants
COMPILER_DOM_INTERNAL_IMPORTS
const COMPILER_DOM_INTERNAL_IMPORTS
COMPILER_RENAMEABLE_KEYS
Single source of truth for the compiler's emission name registry. Two disjoint sets:
COMPILER_RENAMEABLE_KEYS— property keys the compiler synthesizes ontocomponent({...})literals. The runtime reads these via property access (def.__view,def.__prefixes, etc.) inside the same bundle that the compiler emitted them into. Their producer and consumer are colocated in the bundle, so the vite-plugin's post- bundle property-rename pass can shorten them to$a/$b/… without breaking the contract.COMPILER_DOM_INTERNAL_IMPORTS— runtime helpers the compiler references by NAME (not by property key) via animport { __cloneStaticTemplate } from '@llui/dom/internal'declaration. These cross a module boundary at consumer build time. Anything the rename pass touches that ends up in an import specifier would be rewritten to$X, which the source package never exports, and rolldown fails the build withMISSING_EXPORT. These names must NEVER be renamed. The two sets are disjoint by construction — the type-levelExtract<...>assertion below fails compilation if any name appears in both lists. New compiler-emitted names land in whichever list matches their lifetime; if you accidentally add one to both,tsctells you before the bug ships. Subpath choice matters: the helpers live at@llui/dom/internal, not at the root@llui/dom, because the rename regex matches any__-prefixed identifier in the bundle. By hosting the helpers on a subpath whose import specifier never gets touched by the rename, we keep both the regex and the runtime export surface internally consistent without needing an AST-aware rename pass.
const COMPILER_RENAMEABLE_KEYS
COMPILER_VERSION
The @llui/compiler version stamped on every emitted ComponentDef.
Read at runtime by assertCompilerCompatibility() in @llui/dom's
update-loop. v2b §5.
Keep this in sync with package.json — the publish script (Phase 7
scripts/publish.sh) reads from package.json so a drift is caught at
release time.
const COMPILER_VERSION
DOM_INTERNAL_MODULE_SPECIFIER
Module specifier the compiler emits for the internal-helper imports.
const DOM_INTERNAL_MODULE_SPECIFIER
HELPER_KEY_SEP
Canonical module-id separator in helper keys: <moduleId>#<exportName>.
const HELPER_KEY_SEP
MANIFEST_RELATIVE_PATH
The well-known on-disk location, relative to a published package root.
const MANIFEST_RELATIVE_PATH
MANIFEST_SCHEMA_VERSION
Current manifest schema version. Bumped 1→2 to add the state-value
param/field shape (helpers called as helper(s) with the state value passed
directly, e.g. state.map(s => itemFill(s, i))), which v1's accessor-function
shapes could not express. Consumers reject other majors via compilerVersion.
const MANIFEST_SCHEMA_VERSION