@llui/compiler
Current package version: 0.13.0
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 |
empty-props |
div({}, [...]) — an empty props object; the helpers take a children-only call, so write div([...]) |
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.
Mechanically fixable rules. empty-props, attr-name, event-handler-casing and
convention attach a LintFix (a title plus exact text edits) to their diagnostic, which
editors and applyLintFixes can apply verbatim. Only convention is
auto-applied by the Vite plugin — it is runtime-neutral, so the dev loop never blocks on a
casing nit. Every other rule, fix or no fix, still fails the build.
empty-props covers the HTML element helpers and the namespaced SVG helpers
(svg/path/g/…), which share the same call forms. It fires only on a literally empty
object literal in the props position: div({ ...attrs }, …), div(cond ? {} : props, …)
and a variable that happens to hold {} are all left alone, because the rule never reasons
about what an expression might evaluate to. el('div', {}, …) is out of scope too — el's
props parameter is positional with a = {} default, so omitting it allocates the same object.
Functions
allAnnotationArgs()
Arguments of every well-formed call of tag, in source order.
function allAnnotationArgs(text: string, tag: string): string[][]
analyzeAccessor()
Analyze a signal-accessor function. Each parameter is treated as a tainted
root; the returned deps[i] is the set of paths read from parameter i.
A parameter whose value ESCAPES (passed to a call, spread, returned whole)
yields the empty path '' — "the whole parameter" — which is what a caller
must read as "cannot narrow this one".
function analyzeAccessor(fn: AnalyzableFn): DepResult
analyzeSignalExpr()
The set of absolute dependency paths a signal-valued expression reads.
function analyzeSignalExpr(rawExpr: ts.Expression, roots: Roots = STATE_ROOTS): Set<string>
annotationsToObjectLiteral()
Build a TS object-literal expression for the annotation map. Used by
the transform for msg-annotation 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
msg-schema / effect-schema 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
collectSignalDeps()
Collect the dependency paths every signal component view in mod reads.
Paths are reported at full authored depth: state.at('user').at('profile') .at('address').at('city') is user.profile.address.city, not a two-segment
prefix of it. Truncating to a prefix stays SOUND for gating (a dep on a prefix
covers every descendant, because an immutable update replaces the prefix
reference) but it misreports what the code actually reads.
Takes a {@link ParsedModule} — which carries the real filename, and with it the
parse ScriptKind. That is not merely for reporting: a .ts file parsed as TSX
misparses the generic arrow form (const id = <T>(x: T): T => x), and here
that would not raise an error, it would silently return views: 0, paths: [].
function collectSignalDeps(mod: ParsedModule): SignalDepsResult
componentTypeNames()
The EFFECTIVE State/Msg/Effect type names for a component<…>() call: its own
type arguments where they are plain identifiers, else the
{@link CONVENTION_TYPE_NAMES} the file-local extractors fall back to.
The adapter (pre-resolution) and the transform (metadata emission + lookup) MUST both derive names through this function: they meet on the {@link crossFileKey} built from the result, and any divergence would make the lookup silently miss and degrade to file-local extraction.
function componentTypeNames(call: ts.CallExpression): {
state: string
msg: string
effect: 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
covers()
Does emitted dependency set emitted COVER a change at path? A dep on a
prefix covers any descendant (immutable update changes the prefix ref); a dep
deeper than the changed node is also covered (the change propagates up the
ref chain); the empty path covers everything.
function covers(emitted: Set<string>, path: string): boolean
createModuleCache()
function createModuleCache(): ModuleCache
crossFileKey()
The {@link CrossFileResolutions} key for a tuple of effective type names — both the transform's metadata cache key and the adapter's lookup key.
The key is the NAME tuple, not the resolved declaration, so two calls that name the same types share one entry. That is exact for the TOP-LEVEL declarations and module imports this resolver walks. Two known cases where a name is NOT a unique referent — both pre-existing limits shared with the file-local extractors, which key off names the same way:
- Shadowing. A
Msgdeclared inside a block/function scope collides with a top-levelMsg; the resolver only ever sees the top-level one, so a component under the shadow is keyed as if it used the outer type. - Non-identifier type arguments. An inline literal, a generic instantiation
or a qualified name (
A.Msg) is not a plain identifier, so {@link componentTypeNames} falls back to the convention name — and two calls with DIFFERENT qualified types collide on that one key.
Both produce the same wrongly-shared or file-local schema they produced before per-call keying; the fix is name resolution through a checker, not a wider key.
function crossFileKey(names: { state: string; msg: string; effect: string }): string
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(mod: ParsedModule, typeName: string, ctx: ResolveContext): Promise<MsgSchema | null>
extractEffectSchema()
The Effect union's schema. Same shape and same parse discipline as {@link extractMsgSchema}.
function extractEffectSchema(mod: ParsedModule, 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(mod: ParsedModule, 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 — silently. Nothing in the toolchain flags
the duplicate (see the note in this file's header about the lint rule
that no longer exists); a duplicate discriminant is a type error the
user's own tsc reports independently.
function extractMsgAnnotationsCrossFile(mod: ParsedModule, typeName: string, ctx: ResolveContext): Promise<Record<string, MessageAnnotations> | null>
extractMsgSchema()
The Msg union's schema, read from an already-parsed module ({@link ParsedModule} — one parse per pass, and the real filename's ScriptKind; see #93).
function extractMsgSchema(mod: ParsedModule, typeName: string = 'Msg'): MsgSchema | null
extractStateSchema()
Walk type State = { … } (or a type matching a user-provided name) and emit
a JSON-serializable shape descriptor. Supports primitives, string-literal
unions, arrays, nested objects, T | undefined optional fields and
T | null nullable ones (optionality and nullability are distinct — see
{@link StateType}).
Returns null if the named type isn't found or isn't a type literal.
Takes a {@link ParsedModule}, not a source string: the tree is shared with
lint, the transform and the cross-file resolver (one parse per pass, #93), and
the module carries the real filename — this used to parse every source as
input.ts, i.e. a .tsx component's State was read out of a TSX file parsed
as TS. TypeScript's error recovery masked that for most JSX; it did NOT where
recovery consumes the statement that follows. A .tsx module with
const list = <ul>{xs.map(x => <li key={x}>{x}</li>)}</ul> above
export type State returned null here, so an agent: true build shipped no
$ss — with no error anywhere.
function extractStateSchema(mod: ParsedModule, 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. export * barrels ARE followed (step 4, first hit
in textual order wins). Returns null if the chain leads to an
unresolved module, a namespace import, or a dead-end (alias not
declared anywhere we can see).
function findTypeSource(typeName: string, mod: ParsedModule, ctx: ResolveContext, visited: Set<string> = new Set()): Promise<ResolvedTypeSource | null>
firstAnnotationArgs()
Arguments of the FIRST well-formed call of tag, or null when the tag is
absent or every occurrence is malformed. Malformed never degrades to a
partial value — that is the whole point of this module.
function firstAnnotationArgs(text: string, tag: string): string[] | null
hasNonDefaultAnnotation()
Whether the annotation map carries any non-default values. Used to gate msg-annotation 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
isDefaultAnnotation()
Whether a single variant's annotations are ALL at their default value —
i.e. carry no authored information. Covers every field, including
examples/warning/emits (the previous hasNonDefaultAnnotation
missed these three, so a variant annotated only with @example/@warning/
@emits was wrongly treated as default). The runtime reconstructs a
fully-default variant from absence, so these are emission-redundant.
function isDefaultAnnotation(v: MessageAnnotations): boolean
isRichField()
True when f is a rich descriptor (object with type key).
function isRichField(f: MsgField): f is MsgFieldRich
isSignalExpr()
Is expr STRUCTURALLY a signal expression (a state/.at/.map/.peek
chain or derived(...))? Strict on shape — does NOT return true merely because
a signal appears somewhere inside (e.g. an event handler () => send(state.at( 'x').peek()) is NOT a signal expression). Used to distinguish reactive slots
from handlers/static values in the view transform.
function isSignalExpr(expr: ts.Expression, roots: Roots = STATE_ROOTS): boolean
lintAnnotationSyntaxSource()
Run ONLY agent-annotation-syntax over a module that is not a signal
component. A Msg union commonly lives in a plain msg.ts sibling that
carries no component( call, so lintSignalSource never sees it — yet that
is exactly where @routeGated/@validates are authored. The adapter calls
this for every other TS module it transforms.
The cheap string pre-check runs against mod.text BEFORE the module is parsed,
so a file with no agent annotation costs a regex and nothing else — which is
what keeps this affordable on every module in the project.
function lintAnnotationSyntaxSource(mod: ParsedModule): SignalLintMessage[]
lintSignalSource()
Run the signal lint rules over an already-parsed module, returning diagnostics with resolved line/column. The adapter (vite plugin) surfaces these as build errors. Call only on confirmed signal components.
Takes a {@link ParsedModule} so the tree it lints is the SAME one the transform
and the cross-file resolver use — one parse per dev transform (#93). The
module also fixes the ScriptKind from the real filename: a .ts file using the
generic-arrow form (const id = <T>(x: T): T => x) misparses as JSX under TSX
and fires a spurious operator-on-signal error.
function lintSignalSource(mod: ParsedModule): SignalLintMessage[]
lintTagSendSource()
Run ONLY tag-send-drift over a module that is not a signal component — the
companion to {@link lintAnnotationSyntaxSource}, and needed for the same
reason: tagSend is a LIBRARY-author helper, so the canonical call site is a
plain connect() module with no component( call in it, which
lintSignalSource never sees. Without this the rule would cover only the
rarest call sites.
Same pre-check discipline: the name is looked for in mod.text BEFORE the
module is parsed, so a module that never mentions tagSend costs one
substring search.
function lintTagSendSource(mod: ParsedModule): 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 msg-schema and effect-schema 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
parseModule()
Pair text with the fileName it came from. The parse happens on the first
{@link ParsedModule.sourceFile} call and is reused thereafter, so passing the
SAME instance to lint, cross-file resolution and the transform costs one parse.
Two calls with the same arguments produce two INDEPENDENT modules (and so two parses) — hold the instance, or go through a {@link ModuleCache}.
function parseModule(fileName: string, text: string): ParsedModule
rangeFromOffsets()
Convert a TS Compiler API (start, end) offset pair against a parsed source
file into the canonical Range shape. Used by emitters that have AST
node positions but not pre-computed line/column.
function rangeFromOffsets(sf: ts.SourceFile, 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
resolveFieldType()
function resolveFieldType(rawType: ts.TypeNode, typeIndex: TypeIndex = new Map(), depth = MAX_FIELD_DEPTH): MsgFieldType
scanAnnotationCalls()
Scan text (a comment, or any string) for @tag(…) calls of the given tags.
Defaults to every tag in {@link ANNOTATION_TAGS}. Arity is enforced here, so
a call in calls is always usable as-is.
function scanAnnotationCalls(text: string, tags: readonly string[] = Object.keys(ANNOTATION_TAGS)): AnnotationScan
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
signalPathOf()
The single absolute path an .at()-chain expression denotes, or null if it
is not a simple path (e.g. a .map/derived result, or rooted at something
other than a known signal root).
function signalPathOf(expr: ts.Expression, roots: Roots): string | null
sparseMsgAnnotations()
Build a JSON-ready annotation map that drops emission-redundant bytes:
- variants whose every field is default are OMITTED entirely, and
- within a retained variant, fields still at their default are OMITTED.
The runtime treats an absent variant / field as the default (see
list-actions.ts, which reads every field asann?.field ?? default), so this is a pure size optimization with no semantic change. Returns null when every variant is fully default — the caller then skips the annotations prop.
function sparseMsgAnnotations(a: Record<string, MessageAnnotations>): Record<string, Partial<MessageAnnotations>> | null
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/null/unknown become string
literals; the structural kinds become object literals with a kind
field plus the appropriate payload (of/fields/values).
Used by the transform for state-schema 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's collectLocalFns resolves identifiers to their original
arrow/function initializers; this pass replaces those initializers
with Object.assign(arrow, {…}) wrappers. Run Pass 2 BEFORE Pass 1
so the resolver still sees raw arrows.
Already-wrapped functions (CallExpressions, including user-applied
tagSend(...) 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.
Code-only convenience wrapper over {@link transformSignalComponentSourceWithMap} — kept for the many callers (mcp, dom codegen tests) that need no source map.
function transformSignalComponentSource(mod: ParsedModule, 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(mod: ParsedModule, opts: SignalTransformOptions = {}): SignalTransformResult
Types
AnalyzableFn
A function whose body this analyzer can walk: an inline accessor (arrow / function expression) or a declared helper.
export type AnalyzableFn = ts.ArrowFunction | ts.FunctionExpression | ts.FunctionDeclaration
CompilerDomInternalImport
export type CompilerDomInternalImport = (typeof COMPILER_DOM_INTERNAL_IMPORTS)[number]
CompilerMetaField
The descriptive field names of {@link COMPILER_META_KEYS}.
export type CompilerMetaField = keyof typeof COMPILER_META_KEYS
CompilerMetaKey
The literal property keys emitted into the bundle.
export type CompilerMetaKey = (typeof COMPILER_META_KEYS)[CompilerMetaField]
CrossFileResolutions
Per-file cross-file resolutions, keyed by {@link crossFileKey} of a
component() call's effective type-argument names.
Keyed PER CALL, not per file (issue #91): a file may declare component A with
an imported Msg and component B with a local one, and B must not be handed
A's schema/annotations. A wrong schema on the agent/devtools ABI is worse than
a missing one — a call with no entry here simply falls back to the transform's
file-local extractors.
export type CrossFileResolutions = ReadonlyMap<string, CrossFileResolution>
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's list_actions walks these to build
copy-paste-ready payload examples; the validator in send_message
walks 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
Descriptor for one state field's type, as consumed by agents/devtools.
'null' describes a field whose declared type includes null. It is a
VALUE, not an absence: null survives JSON (state must be
JSON-serializable) and TypeScript keeps field: T | null required, so a
nullable field is emitted as {kind: 'union', of: [T, 'null']} and NEVER
as {kind: 'optional'}. When T is itself a union its members are spliced
into that list rather than nested, so the member list stays flat:
string | number | null is {kind: 'union', of: ['string', 'number', 'null']}. T | undefined is the opposite case — it means
the field may be absent, and is emitted as {kind: 'optional', of: T}
exactly like field?: T. A field declared T | null | undefined is both:
{kind: 'optional', of: {kind: 'union', of: [T, 'null']}}.
export type StateType =
| 'string'
| 'number'
| 'boolean'
| 'null'
| '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
AnnotationCall
A well-formed @tag(…) call and its parsed arguments.
export interface AnnotationCall {
tag: string
args: string[]
/** Offset of the `@` within the scanned comment text. */
start: number
/** Length from the `@` through the closing `)`. */
length: number
}
AnnotationScan
export interface AnnotationScan {
calls: AnnotationCall[]
errors: AnnotationSyntaxError[]
}
AnnotationSyntaxError
A malformed @tag(…) call. Positions are relative to the scanned text.
export interface AnnotationSyntaxError {
tag: string
message: string
start: number
length: number
}
AnnotationTagSpec
Arity of one annotation tag. max: null means variadic.
export interface AnnotationTagSpec {
min: number
max: number | null
/**
* Whether an argument may be written as a bare JSON object/array literal
* instead of a quoted string (issue #98).
*
* `@example` ONLY, and deliberately so. An example IS a payload — a JSON
* object is what the tag's value already spells, just with every inner quote
* escaped, and thirteen files' worth of authors independently reached for
* the unescaped form before #89 made it an error. Every other tag takes
* something a JSON literal cannot be: `@routeGated`/`@validates` take a
* JavaScript predicate, `@intent`/`@warning`/`@should` take prose, `@emits`
* takes effect kinds. Accepting a brace there would invent a second spelling
* for a concept that has exactly one — so a brace after any other tag stays
* a build error.
*/
json?: true
}
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[]
}
CrossFileResolution
Everything the adapter resolved from sibling files for ONE
component<State, Msg, Effect> type-argument tuple.
export interface CrossFileResolution {
typeSources?: ExternalTypeSources
preExtracted?: PreExtractedSchemas
}
DepResult
Per-parameter dependency paths. deps[i] holds dotted relative paths read
from parameter i; the empty string '' means the whole parameter.
export interface DepResult {
deps: Set<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 declaring
MODULE (already parsed — the extractors reuse that tree rather than re-parsing
the sibling, #93) + 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?: { module: ParsedModule; typeName: string }
msg?: { module: ParsedModule; typeName: string }
effect?: { module: ParsedModule; 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
}
ModuleCache
Per-pass memo of {@link ParsedModule}s by path. The cross-file resolver looks
the same sibling up once per type argument, per composed union member and
again while enriching the type index — eight lookups of one msg.ts in a
single pre-resolution pass was typical, each its own parse.
Keyed by fileName and validated against the TEXT: a cached entry is reused
only while the text is identical, so a file edited between passes (or a
module the lint autofix rewrote mid-transform) re-parses instead of serving a
stale tree. Scope one to a pass — the Vite plugin creates one per transform
— rather than keeping a process-wide cache alive.
export interface ModuleCache {
get(fileName: string, text: string): ParsedModule
}
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
* @validates("v === \"admin\"") // embedded quote: escape it
*
* 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>>
}
ParsedModule
A module's text plus, on demand, its parsed tree — parsed at most once no matter how many analyses ask for it.
export interface ParsedModule {
/** The module's real path/name. Decides the parse ScriptKind. */
readonly fileName: string
/** The module's source text. Always available; never triggers a parse. */
readonly text: string
/** The parsed tree, with parent pointers. Memoized — parsed on first call. */
sourceFile(): ts.SourceFile
}
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 of findTypeSource itself (all of them SILENT — nothing
warns, and the affected metadata is simply absent):
- Composition (
type Msg = ImportedA | { type: 'b' }): it locates the alias but does not walk INTO the union. The composition-aware extractors below (extractMsgAnnotationsCrossFile,extractDiscriminatedUnionSchemaCrossFile) do recurse, and are what the adapter calls for Msg/Effect. - Namespace imports (
import * as ns from './msg'): not followed. (export *re-export barrels ARE followed — step 4.) - Generic types: not parameterized resolution; the type argument must resolve to a concrete type alias.
NOTE for future readers: this file used to attribute these gaps to a lint
rule named agent-msg-resolvable. That rule belonged to the DELETED
@llui/eslint-plugin and was never reimplemented as a compiler rule — there
is no guard. Do not re-add the claim without the rule (issue #91).
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>
/**
* Parse memo for this resolution pass. REQUIRED, and not a micro-optimization:
* one pass looks the same sibling up once per type argument, once per composed
* union member and again while enriching the type index — eight parses of one
* `msg.ts` was typical, plus ten of the focal module (issue #93). Every parse
* the resolver makes goes through it, so reuse does not depend on the caller
* remembering anything; the caller only decides the cache's LIFETIME (the Vite
* plugin: one per `transform`). Build one with `createModuleCache()`.
*/
modules: ModuleCache
}
ResolvedTypeSource
export interface ResolvedTypeSource {
/** The parsed module declaring the type alias (from `ctx.modules`, so every
* later consumer of the same file reuses this tree). */
module: ParsedModule
/** The local name of the alias *in that file* (after rename chains). */
localName: string
/** Absolute path of the file declaring the alias (debug aid). Always
* `module.fileName`. */
filePath: string
}
SignalDepsResult
export interface SignalDepsResult {
/** Absolute state paths, deduped and sorted. Excludes the whole-state read. */
paths: string[]
/** At least one binding reads the state wholesale (dep path `''`), so the
* runtime cannot gate it on any narrower path. */
wholeState: boolean
/** How many signal component views were analyzed. Zero means the file has no
* `component({ view: ({ state }) => … })` — `paths` being empty says nothing
* about the file's reactivity. */
views: number
}
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
}
SignalTransformOptions
Options controlling introspection metadata emission (mirrors the legacy
transform's devMode/emitAgentMetadata gating).
export interface SignalTransformOptions {
/** emit the msg/state/effect schemas + annotations for the agent surface
* (keyed by `COMPILER_META_KEYS` — see emit-names.ts for the ABI) */
emitAgentMetadata?: boolean
/** dev build — also emit the component meta `{ file, line }` (the file is the
* module's own `fileName`, which the {@link ParsedModule} always carries) */
devMode?: boolean
/** Cross-file resolutions from the adapter (pre-extracted composition-aware
* msg/effect schemas + annotations, and the declaring-file source for a `State`
* that lives elsewhere), keyed PER `component()` CALL by {@link crossFileKey} of
* the call's effective type-argument names. A call with an entry uses it in
* preference to file-local extraction; a call WITHOUT one falls back to the
* file-local extractors. It is deliberately not a file-wide value: that made a
* second component in the file inherit the first's schema (issue #91). */
crossFile?: CrossFileResolutions
/** Lowering-bail telemetry: called for every lowering ATTEMPT that gave up and
* fell back to a slower path (see {@link LowerBail}). Coverage tooling and the
* future `perf` diagnostics channel consume this; it does not affect output. */
onLowerBail?: (bail: LowerBail) => void
/** Perf diagnostics: called with one `llui/each-verbatim` Diagnostic
* (category `perf`, severity `warning`) per `each` site that ends FULLY
* verbatim — its rows render via the runtime authoring path instead of the
* compiled factory. Advisory only; never affects output. Verbatim `show`/
* `branch` are intentionally not surfaced (they only pay at toggle time). */
onPerfDiagnostic?: (diagnostic: Diagnostic) => void
}
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 resolver injects its `extractAccessorPaths`
* here; tests can stub with a simpler implementation.
*/
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 resolver; 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
ALL_ELEMENT_HELPERS
Every element-helper callee name — namespaced and not. Use this ONLY for
rules that inspect a call's arguments; never for lowering (see the note on
SVG_ELEMENT_HELPERS above: lowering a namespaced helper breaks it).
const ALL_ELEMENT_HELPERS: ReadonlySet<string>
ANNOTATION_TAGS
Every tag that takes a parenthesized argument list. Keyed by tag name
WITHOUT the @. Flag-style tags (@requiresConfirm, @humanOnly, …) take
no arguments and are not part of this grammar.
const ANNOTATION_TAGS: Readonly<Record<string, AnnotationTagSpec>>
COMPILER_DOM_INTERNAL_IMPORTS
const COMPILER_DOM_INTERNAL_IMPORTS
COMPILER_META_KEYS
Emitted property key per metadata field. The KEY of this record is the
field's descriptive name (the authoring/documentation vocabulary); the
VALUE is the literal identifier emitted into the bundle and read back by
the runtime. Only the value is load-bearing at runtime — changing one is a
breaking ABI change that must land in @llui/dom in the same release.
const COMPILER_META_KEYS
COMPILER_VERSION
The @llui/compiler version stamped on every emitted ComponentDef. Stamped so the runtime can check compiler/runtime compatibility.
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
CONVENTION_TYPE_NAMES
The State/Msg/Effect names the file-local extractors assume when a
component() call is untyped.
const CONVENTION_TYPE_NAMES
DOM_INTERNAL_MODULE_SPECIFIER
Module specifier the compiler emits for the internal-helper imports.
const DOM_INTERNAL_MODULE_SPECIFIER
ELEMENT_HELPERS
DOM element-helper callee names — tags that produce an element with props.
const ELEMENT_HELPERS: ReadonlySet<string>
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
SVG_ELEMENT_HELPERS
SVG element-helper callee names (the svgHelper(...) exports of @llui/dom).
These are EXPORT names, not tags — the SVG <text> helper is exported as
svgText so it doesn't collide with the text() node helper. Kept out of
{@link ELEMENT_HELPERS} because the view transform must NOT lower them
(createElementNS), but they accept the identical (children) /
(props?, children?) call forms, so argument-shape rules apply unchanged.
const SVG_ELEMENT_HELPERS: ReadonlySet<string>