diff --git a/AGENTS.md b/AGENTS.md index ee46e49..66e94e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,11 +31,34 @@ Do not commit if any check fails. Fix the issue first, then re-run all three. language in specifications or source code. 6. Parse to infer type; Do not type cast with `as`. 7. Do not use braceless `if` statements. +8. Keep the release spec current — changes to the release configuration + require changes to specs/release-process-spec.md to match. +9. Prefer stateless generators - use a function when calling a function that + returns an operation; Do not do this function*(arg) { return yield* generator(arg) } ## PR Process 1. Use .github/pull_request_template.md -2. After PR is open, monitor PR for +2. After PR is open, monitor PR for 1. CI failures 2. Comments with feedback - 3. Integrate changes feedback appears \ No newline at end of file + 3. Integrate changes feedback appears + +## Agent Roles + +If you're an Opus model, you're an Implementor agent. +If you're a GPT model, you're an Planner agent. +If you're a Fabel model, you're a Problem solver agent. + +### Implementor agent + +Writes code following Code Rules. + +### Planner agent + +##### When reviewing Implementor agent's plans** + +**User will ask you**: Review ; verdict; prompt on failure. +**Respond by:** +* Interviewing user to resolve ambiguity; do not ask the Implementor agent to make decisions. +* Writing a feedback prompt that user will handoff to the Implementor agent diff --git a/core/mod.ts b/core/mod.ts index 7ed1784..bbe4c5e 100644 --- a/core/mod.ts +++ b/core/mod.ts @@ -39,17 +39,32 @@ export type { ModifierMiddleware, CodeBlockWorkflow, } from "./src/modifiers.ts"; -export { useCodeBlock, CodeBlockCtx } from "./src/modifiers.ts"; +export { useCodeBlock } from "./src/modifiers.ts"; + +// --------------------------------------------------------------------------- +// Component Api — contextual operations for component expansion +// --------------------------------------------------------------------------- + +export type { ComponentApi } from "./src/component-api.ts"; +export { + Component, + importComponent, + applyModifiers, + raise, + env, + evalScope, + codeBlock, + persistent, + content, +} from "./src/component-api.ts"; // --------------------------------------------------------------------------- // Eval system (generator eval blocks) // --------------------------------------------------------------------------- -export type { EvalEnv } from "./src/eval-env.ts"; -export { EvalEnvCtx, EvalScopeCtx } from "./src/eval-env.ts"; +export type { EvalEnv } from "./src/types.ts"; -export type { EvalContext } from "./src/eval-context.ts"; -export { EvalCtxKey, createEvalContext, compileBlock } from "./src/eval-context.ts"; +export { compileBlock } from "./src/eval-context.ts"; // --------------------------------------------------------------------------- // executable.md Globals (for generated eval modules) diff --git a/core/src/component-api.ts b/core/src/component-api.ts new file mode 100644 index 0000000..321263c --- /dev/null +++ b/core/src/component-api.ts @@ -0,0 +1,93 @@ +/** + * Component Api — contextual operations for component expansion. + * + * One public Api replaces the former dependency container (ExpansionContext) + * and raw Effection context keys. Context-dependent behavior is installed as + * scope-local middleware via `Component.around(...)`: + * + * - Runtime implementations (document import, modifier execution, component + * state) install at `{ at: "min" }`. Middleware installed in a nested scope + * runs before inherited middleware, so a component that installs its own + * `env` shadows its ancestors without leaking into siblings — install + * inside `scoped()` for automatic removal. + * - Caller instrumentation and overrides wrap at the default `"max"`. + */ + +import { createApi } from "@effectionx/context-api"; +import type { Operation } from "effection"; +import type { EvalScope } from "@effectionx/scope-eval"; +import type { + CodeBlockContext, + CodeBlockResult, + ComponentDefinition, + ErrorSegment, + EvalEnv, + FunctionComponentDefinition, + Modifier, +} from "./types.ts"; + +export interface ComponentApi { + /** `"__root__"` imports the root document. */ + importComponent(name: string): Operation; + applyModifiers(modifiers: Modifier[], block: CodeBlockContext): Operation; + /** + * Report an ErrorSegment under the ambient error policy. The default + * returns the segment for rendering; suppressed-documentation scopes + * install middleware that throws instead (spec §6.9). + */ + raise(error: ErrorSegment): Operation; + env: EvalEnv | undefined; + evalScope: EvalScope | undefined; + codeBlock(): Operation; + /** Whether the current block runs with persistent resource lifetime. */ + persistent: boolean; + /** Render the invoking component's children (optionally a named slot). */ + content(slot?: string): Operation; +} + +export const Component = createApi("Component", { + // deno-lint-ignore require-yield + *importComponent(name: string): Operation { + throw new Error( + `Component.importComponent("${name}") has no provider. Install one with ` + + `Component.around({ importComponent }, { at: "min" }) before expansion.`, + ); + }, + // deno-lint-ignore require-yield + *applyModifiers(_modifiers: Modifier[], block: CodeBlockContext): Operation { + throw new Error( + `Component.applyModifiers() has no provider for block "${block.blockId}". Install one ` + + `with Component.around({ applyModifiers }, { at: "min" }) before expansion.`, + ); + }, + // deno-lint-ignore require-yield + *raise(error: ErrorSegment): Operation { + return error; + }, + env: undefined, + evalScope: undefined, + // deno-lint-ignore require-yield + *codeBlock(): Operation { + throw new Error( + "Component.codeBlock() has no provider: no code block is executing in this scope.", + ); + }, + persistent: false, + // deno-lint-ignore require-yield + *content(_slot?: string): Operation { + throw new Error( + "Component.content() has no provider: not inside a function component invocation.", + ); + }, +}); + +export const { + importComponent, + applyModifiers, + raise, + env, + evalScope, + codeBlock, + persistent, + content, +} = Component.operations; diff --git a/core/src/content-context.ts b/core/src/content-context.ts index 1480516..249c387 100644 --- a/core/src/content-context.ts +++ b/core/src/content-context.ts @@ -1,62 +1,26 @@ /** - * Content context for function components. - * - * Provides `useContent()` — the function component equivalent of - * `` in markdown components. The expansion engine sets - * the content handle on the Effection scope before calling the - * function component. Components that need rendered children call - * `yield* useContent()` to retrieve them. - * - * Named slots are supported: `yield* useContent("header")` returns - * the content for the "header" slot, matching `` - * in markdown components. + * useContent() — the function component equivalent of `` in + * markdown components. Ergonomic alias for the Component `content()` + * operation; the expansion engine installs the slot-rendering middleware + * around each function component invocation. */ -import { createContext } from "effection"; import type { Operation } from "effection"; -import type { Segment } from "./types.ts"; - -/** - * Handle providing access to a component's children content. - * Set on the Effection scope by the expansion engine. - */ -export interface ContentHandle { - /** Render the default slot (children without a slot prop). */ - renderDefault: () => Operation; - /** Render a named slot. Returns empty string if the slot has no content. */ - renderSlot: (name: string) => Operation; - /** Raw child segments (all slots combined, for inspection). */ - segments: Segment[]; -} +import { content } from "./component-api.ts"; /** - * Effection context key for the content handle. - */ -export const ContentCtx = createContext("content"); - -/** - * Render children content from the parent scope. - * - * The function component equivalent of `` in markdown components. + * Render children content from the invoking component. * * @param slotName - Optional slot name. If provided, renders only the - * content assigned to that slot. If omitted, renders the default slot - * (children without a `slot` prop). - * @returns The rendered content as a string. + * content assigned to that slot (matching ``). + * If omitted, renders the default slot. * * @example * ```ts - * // Render default content (equivalent to in .md) - * const content = yield* useContent(); - * - * // Render named slot (equivalent to in .md) + * const body = yield* useContent(); * const header = yield* useContent("header"); * ``` */ -export function* useContent(slotName?: string): Operation { - const handle = yield* ContentCtx.expect(); - if (slotName !== undefined) { - return yield* handle.renderSlot(slotName); - } - return yield* handle.renderDefault(); +export function useContent(slotName?: string): Operation { + return content(slotName); } diff --git a/core/src/errors.ts b/core/src/errors.ts new file mode 100644 index 0000000..92e51f7 --- /dev/null +++ b/core/src/errors.ts @@ -0,0 +1,16 @@ +import type { ErrorSegment } from "./types.ts"; + +/** + * Thrown by suppressed-documentation raise middleware (spec §6.9). Generic + * catches in the engine rethrow it instead of converting it into an + * ErrorSegment, so documentation fail-fast is never swallowed. + */ +export class DocumentationError extends Error { + readonly segment: ErrorSegment; + + constructor(segment: ErrorSegment) { + super(segment.message); + this.name = "DocumentationError"; + this.segment = segment; + } +} diff --git a/core/src/eval-context.ts b/core/src/eval-context.ts index 34034ea..0c77c83 100644 --- a/core/src/eval-context.ts +++ b/core/src/eval-context.ts @@ -1,52 +1,19 @@ /** - * Eval block compilation context (spec §5). + * Eval block compilation (spec §5). */ -import { createContext as createEffectionContext } from "effection"; import type { Operation } from "effection"; import { compile as runtimeCompile } from "@executablemd/runtime"; -// --------------------------------------------------------------------------- -// EvalContext — lightweight context for the eval system -// --------------------------------------------------------------------------- - -/** - * Eval context for document runs. - * - * In the Deno model, there is no VM context — eval blocks are compiled - * into data: URI modules that import their dependencies. The EvalContext - * exists as a marker that the eval system has been initialized. - */ -export interface EvalContext { - /** Placeholder for future per-document eval configuration. */ - initialized: true; -} - -/** - * Effection context key for the eval context. - */ -export const EvalCtxKey = createEffectionContext("evalContext"); - -/** - * Create an eval context for a document run. - * - * In the Deno model, this is lightweight — no VM sandbox creation. - * The eval context is set on the Effection scope so eval blocks - * can verify the eval system is initialized. - */ -export function createEvalContext(_globals: Record = {}): EvalContext { - return { initialized: true }; -} - /** * Compile transformed source code into a generator function. * * Delegates to `@executablemd/runtime` so platform-specific * compilation can be provided via API.Compiler middleware. */ -export function* compileBlock( +export function compileBlock( transformedBodyCode: string, userImports: string[], ): Operation<(env: Record) => Generator> { - return yield* runtimeCompile(transformedBodyCode, { imports: userImports }); + return runtimeCompile(transformedBodyCode, { imports: userImports }); } diff --git a/core/src/eval-env.ts b/core/src/eval-env.ts deleted file mode 100644 index 70b9660..0000000 --- a/core/src/eval-env.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Eval environment — shared binding environment and scope contexts - * for generator eval blocks (spec generator-eval-spec.md §3.1–3.2). - * - * - EvalEnv: mutable record of bindings shared across eval blocks within a component - * - EvalEnvCtx: Effection context for the current component's binding environment - * - EvalScopeCtx: Effection context for the current component's eval scope - */ - -import { createContext } from "effection"; -import type { EvalScope } from "@effectionx/scope-eval"; - -// --------------------------------------------------------------------------- -// Persist flag (spec §7.1) -// --------------------------------------------------------------------------- - -/** - * When set to true on the scope, the eval handler will run the compiled - * block inside the EvalScope's persistent child scope. Set by the - * persist modifier, read by evalFactory. - */ -export const PersistFlagCtx = createContext("persistFlag"); - -// --------------------------------------------------------------------------- -// Binding environment (spec §3.2) -// --------------------------------------------------------------------------- - -/** - * Shared binding environment for eval blocks within a single component. - * - * Created fresh at the start of component expansion. Each eval block - * reads bindings from `values` (via env preamble) and writes new - * bindings back (via env-write transforms). - */ -export interface EvalEnv { - values: Record; -} - -/** - * Effection context holding the current component's binding environment. - * - * Set via `EvalEnvCtx.with()` in the expansion engine when a component - * begins expansion. Handlers access it via `ephemeral(EvalEnvCtx.expect())`. - */ -export const EvalEnvCtx = createContext("evalEnv"); - -// --------------------------------------------------------------------------- -// Eval scope (spec §3.1) -// --------------------------------------------------------------------------- - -/** - * Effection context holding the current component's EvalScope. - * - * The EvalScope (from @effectionx/scope-eval) is created per component - * and allows `persist` blocks to retain resources beyond block lifetime. - * The scope is destroyed when component expansion completes. - */ -export const EvalScopeCtx = createContext("evalScope"); diff --git a/core/src/eval-handler.ts b/core/src/eval-handler.ts index bf0f8b3..c339ce6 100644 --- a/core/src/eval-handler.ts +++ b/core/src/eval-handler.ts @@ -4,7 +4,7 @@ import { unbox } from "@effectionx/scope-eval"; import type { Operation } from "effection"; import type { ModifierFactory } from "./modifiers.ts"; import { useCodeBlock } from "./modifiers.ts"; -import { EvalEnvCtx, EvalScopeCtx, PersistFlagCtx } from "./eval-env.ts"; +import { env, evalScope, persistent } from "./component-api.ts"; import { compileBlock } from "./eval-context.ts"; import { transformBlock, serializeExports } from "./eval-transform.ts"; @@ -15,8 +15,13 @@ import { transformBlock, serializeExports } from "./eval-transform.ts"; export const evalFactory: ModifierFactory = (_params) => (_args, _next) => (function* () { const ctx = yield* useCodeBlock(); - const env = yield* ephemeral(EvalEnvCtx.expect()); - const persist = yield* ephemeral(PersistFlagCtx.get()) ?? false; + const evalEnv = yield* ephemeral(env); + if (!evalEnv) { + throw new Error( + `eval block "${ctx.blockId}" requires a binding environment; none is in scope.`, + ); + } + const persist = yield* ephemeral(persistent); // Inject output() function into env so eval blocks can produce // rendered output. The function is a plain synchronous call: @@ -26,13 +31,13 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => // the journal. The output text itself is journaled alongside // exports as __output. const outputRef = { text: "" }; - env.values.output = (text: string) => { + evalEnv.values.output = (text: string) => { outputRef.text = String(text); }; - const transformed = transformBlock(ctx.content, ctx.blockId, Object.keys(env.values)); + const transformed = transformBlock(ctx.content, ctx.blockId, Object.keys(evalEnv.values)); - const bindings = serializeExports(env.values, transformed.imports); + const bindings = serializeExports(evalEnv.values, transformed.imports); const result = (yield createDurableOperation( { type: "eval", @@ -41,7 +46,7 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => }, function* (): Operation { // Merge incoming bindings snapshot into env before execution - Object.assign(env.values, bindings); + Object.assign(evalEnv.values, bindings); // Compile the eval block via data: URI module import. // compileBlock is async (returns Operation) — it generates a @@ -49,11 +54,16 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => const fn = yield* compileBlock(transformed.code, transformed.userImports ?? []); if (persist) { - // Persist mode: run the compiled block inside evalScope.eval() + // Persist mode: run the compiled block inside the eval scope // so spawned resources are retained in the persistent EvalScope. - const evalScope = yield* EvalScopeCtx.expect(); - const blockResult = yield* evalScope.eval( - () => fn(env.values) as unknown as Operation, + const scope = yield* evalScope; + if (!scope) { + throw new Error( + `persist eval block "${ctx.blockId}" requires a component eval scope; none is in scope.`, + ); + } + const blockResult = yield* scope.eval( + () => fn(evalEnv.values) as unknown as Operation, ); const returnValue = unbox(blockResult); if (!outputRef.text && returnValue != null) { @@ -62,13 +72,13 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => } else { // Normal mode: run the compiled block in the current scope. // Resources are torn down when this operation completes. - const returnValue = yield* fn(env.values) as unknown as Operation; + const returnValue = yield* fn(evalEnv.values) as unknown as Operation; if (!outputRef.text && returnValue != null) { outputRef.text = String(returnValue); } } - const exports = serializeExports(env.values, transformed.exports); + const exports = serializeExports(evalEnv.values, transformed.exports); if (outputRef.text) { (exports as Record).__output = outputRef.text; @@ -86,7 +96,7 @@ export const evalFactory: ModifierFactory = (_params) => (_args, _next) => } // Remove __output from exports before assigning to env const { __output: _, ...exports } = restored; - Object.assign(env.values, exports); + Object.assign(evalEnv.values, exports); } return { output: outputRef.text, exitCode: 0, stderr: "" }; diff --git a/core/src/expand.ts b/core/src/expand.ts index 96dfb5b..a98558a 100644 --- a/core/src/expand.ts +++ b/core/src/expand.ts @@ -13,24 +13,30 @@ * middleware installation) execute before children's code blocks. */ -import { useScope } from "effection"; +import { scoped } from "effection"; import type { Operation } from "effection"; -import { ContentCtx } from "./content-context.ts"; -import type { ContentHandle } from "./content-context.ts"; import type { Segment, + TextSegment, ErrorSegment, + ComponentInvocation, ComponentDefinition, + EvalEnv, FunctionComponentDefinition, Json, CodeBlockContext, - CodeBlockResult, - Modifier, } from "./types.ts"; import { interpolate } from "./interpolate.ts"; import { interpolateEvalBindings } from "./eval-interpolate.ts"; -import { EvalEnvCtx, EvalScopeCtx } from "./eval-env.ts"; -import type { EvalEnv } from "./eval-env.ts"; +import { + Component, + applyModifiers, + env, + evalScope, + importComponent, + raise, +} from "./component-api.ts"; +import { DocumentationError } from "./errors.ts"; import { useEvalScope, unbox } from "@effectionx/scope-eval"; import type { EvalScope } from "@effectionx/scope-eval"; import { validateProps } from "./validate.ts"; @@ -59,25 +65,14 @@ export function createBlockCounter(): BlockCounter { return { next: () => id++ }; } -// --------------------------------------------------------------------------- -// Types for the expansion context -// --------------------------------------------------------------------------- - -export type ComponentImporter = ( - name: string, -) => Operation; +// Providers install at "min" inside scoped() so nested components override +// ancestors (innermost min runs first) without leaking into siblings. +function provideEnv(value: EvalEnv): Operation { + return Component.around({ env: () => value }, { at: "min" }); +} -/** - * Function that executes a modifier chain for a code block. - */ -export type ModifierChainRunner = ( - modifiers: Modifier[], - context: CodeBlockContext, -) => Operation; - -export interface ExpansionContext { - importComponent: ComponentImporter; - runModifierChain: ModifierChainRunner; +function provideEvalScope(value: EvalScope): Operation { + return Component.around({ evalScope: () => value }, { at: "min" }); } // --------------------------------------------------------------------------- @@ -90,6 +85,10 @@ const IDENTIFIER_RE = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/; /** * Expand an array of segments, resolving components and executing code blocks. * + * Component import, modifier execution, bindings, and error policy are all + * delivered contextually through the Component Api — install providers with + * `Component.around(..., { at: "min" })` before expanding. + * * @param counter - Optional block ID counter. If omitted, a local counter * is created. For per-segment emission (§9), pass a shared counter so * IDs are stable across calls. @@ -99,7 +98,6 @@ export function* expandSegments( parentMeta: Record, parentProps: Record, hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter = createBlockCounter(), ): Operation { const result: Segment[] = []; @@ -114,8 +112,8 @@ export function* expandSegments( const interpolated = interpolate(healed, parentMeta, parentProps); // Interpolate bare {name} refs from eval bindings (spec §6.4/§6.6). // Runs after meta/props interpolation so component contract takes - // precedence. Only runs when an EvalEnv is present on the scope. - const textEvalEnv = yield* EvalEnvCtx.get(); + // precedence. Only runs when a binding environment is in scope. + const textEvalEnv = yield* env; const final = textEvalEnv ? interpolateEvalBindings(interpolated, textEvalEnv.values) : interpolated; @@ -124,17 +122,25 @@ export function* expandSegments( } case "component": { + if (segment.name === "Output") { + // Definition-owned is consumed by buildBody before it + // reaches here. Reaching this branch means a misplaced or + // dynamically scanned (e.g. render(markdown) content) — + // diagnose it defensively per the ambient policy. + result.push(yield* raise(misplacedOutputError())); + break; + } + if (segment.name === "Capture") { const captureResult = yield* expandCapture( segment, parentMeta, parentProps, hideSet, - ctx, counter, ); if (captureResult) { - result.push(captureResult); + result.push(yield* raise(captureResult)); } break; } @@ -145,23 +151,30 @@ export function* expandSegments( segment.expressions, segment.children, hideSet, - ctx, counter, segment.projectedEnv, ); - result.push(...expanded); + // Consumer boundary: re-raise transported error segments under the + // ambient policy before appending them (spec §6.9). + for (const expandedSegment of expanded) { + if (expandedSegment.type === "error") { + result.push(yield* raise(expandedSegment)); + } else { + result.push(expandedSegment); + } + } break; } case "codeBlock": { // Interpolate eval bindings into content before the modifier chain. - // EvalEnvCtx may not be set (e.g., blocks outside component expansion), - // so we use .get() and fall back to the original content. + // A binding environment may not be in scope (e.g., blocks outside + // component expansion) — fall back to the original content. // // Skip interpolation for eval blocks — they access bindings directly // via the env preamble (const { name } = env;). Interpolating would // mangle JS template literals like `${name}` into `$`. - const evalEnv = yield* EvalEnvCtx.get(); + const evalEnv = yield* env; const lastModifier = segment.modifiers[segment.modifiers.length - 1]; const isEvalTerminal = lastModifier !== undefined && lastModifier.name === "eval"; const interpolatedContent = @@ -180,14 +193,16 @@ export function* expandSegments( }; try { - const codeResult = yield* ctx.runModifierChain(segment.modifiers, context); + const codeResult = yield* applyModifiers(segment.modifiers, context); if (codeResult.exitCode !== 0 && codeResult.output === "") { - result.push({ - type: "error", - message: `Command failed (exit ${codeResult.exitCode}): ${codeResult.stderr}`, - source: segment.content, - }); + result.push( + yield* raise({ + type: "error", + message: `Command failed (exit ${codeResult.exitCode}): ${codeResult.stderr}`, + source: segment.content, + }), + ); } else if (codeResult.output !== "") { result.push({ type: "execOutput", @@ -201,17 +216,31 @@ export function* expandSegments( } // If output is empty and exit code is 0, nothing added (e.g., silent) } catch (error) { - result.push({ - type: "error", - message: error instanceof Error ? error.message : String(error), - source: segment.content, - }); + // A DocumentationError from nested expansion (e.g. renderChildren + // inside an eval block) is our own fail-fast — never swallow it. + if (error instanceof DocumentationError) { + throw error; + } + result.push( + yield* raise({ + type: "error", + message: error instanceof Error ? error.message : String(error), + source: segment.content, + }), + ); } break; } - default: - result.push(segment); + default: { + if (segment.type === "error") { + // Pre-existing error segments (e.g. slot/substitution errors) follow + // the ambient policy. + result.push(yield* raise(segment)); + } else { + result.push(segment); + } + } } } @@ -223,7 +252,6 @@ function* expandCapture( parentMeta: Record, parentProps: Record, hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter, ): Operation { if (segment.selfClosing || segment.children.length === 0) { @@ -291,7 +319,6 @@ function* expandCapture( parentMeta, parentProps, hideSet, - ctx, counter, ); const rendered = renderSegments(expandedChildren).replace(/\s+$/, ""); @@ -308,15 +335,15 @@ function* expandCapture( } } - const env = yield* EvalEnvCtx.get(); - if (!env) { + const bindingEnv = yield* env; + if (!bindingEnv) { return { type: "error", message: " requires an evaluation environment.", source: "Capture", }; } - env.values[bindingName] = captured; + bindingEnv.values[bindingName] = captured; return undefined; } @@ -330,44 +357,43 @@ function* expandComponent( expressions: Record, children: Segment[], hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter, projectedEnv?: EvalEnv, ): Operation { // Cycle detection — Prosser's algorithm if (hideSet.has(name)) { return [ - { + yield* raise({ type: "error", message: `Cycle detected: ${name} is already being expanded (hide set: ${[...hideSet].join(" → ")})`, source: name, - }, + }), ]; } if (hideSet.size >= MAX_EXPANSION_DEPTH) { return [ - { + yield* raise({ type: "error", message: `Maximum expansion depth (${MAX_EXPANSION_DEPTH}) exceeded`, source: name, - }, + }), ]; } let imported: ComponentDefinition | FunctionComponentDefinition; try { - imported = yield* ctx.importComponent(name); + imported = yield* importComponent(name); } catch (error) { return [ - { + yield* raise({ type: "error", message: error instanceof Error ? `Failed to import component ${name}: ${error.message}` : `Failed to import component ${name}: ${String(error)}`, source: name, - }, + }), ]; } @@ -380,7 +406,6 @@ function* expandComponent( children, imported, hideSet, - ctx, counter, projectedEnv, ); @@ -388,6 +413,15 @@ function* expandComponent( const definition = imported; + // Structural preflight (spec §6.9): validate placement against the + // component's own source AST before any part of its body executes. A + // structurally invalid component runs no eval, exec, Capture, nested + // components, or other side effects. + const placementError = validateOutputPlacement(definition.bodySegments); + if (placementError) { + return [yield* raise(placementError)]; + } + // Resolve eval expression props against env.values using the shared // VM context. This must happen before validation so that resolved // values can be type-checked. See spec §5.1 (expression prop evaluation). @@ -396,21 +430,21 @@ function* expandComponent( resolvedProps = yield* resolveExpressionProps(props, expressions, name, projectedEnv); } catch (error) { return [ - { + yield* raise({ type: "error", message: error instanceof Error ? error.message : String(error), source: name, - }, + }), ]; } if ("as" in expressions) { return [ - { + yield* raise({ type: "error", message: `Prop "as" on <${name} /> must be a string literal.`, source: name, - }, + }), ]; } @@ -430,11 +464,11 @@ function* expandComponent( validatedProps = validateProps(name, propsForValidation, definition.inputs); } catch (error) { return [ - { + yield* raise({ type: "error", message: error instanceof Error ? error.message : String(error), source: name, - }, + }), ]; } @@ -447,24 +481,11 @@ function* expandComponent( // the projectedEnv from the outer caller must be merged with the current // context env so that ancestor bindings propagate through all levels. // The current context env's bindings take precedence (innermost-wins). - const contextEnv = yield* EvalEnvCtx.get(); + const contextEnv = yield* env; const callerEvalEnv = projectedEnv ? { values: { ...projectedEnv.values, ...(contextEnv?.values ?? {}) } } : contextEnv; - // Substitute raw children into positions. Children are NOT - // pre-expanded — they expand in document order when the component body - // is expanded. This ensures eval blocks before (e.g., - // provider middleware installation) run before children's code blocks. - // Children segments are tagged with callerEvalEnv so expression props - const substituted = substituteContent( - definition.bodySegments, - children, - definition.meta, - validatedProps, - callerEvalEnv ?? undefined, - ); - // Recurse with augmented hide set. // Each component gets its own fresh binding environment so that // eval blocks within a component share bindings but don't leak @@ -484,7 +505,7 @@ function* expandComponent( // By creating it via parentEvalScope.eval(), the child's spawned task // lives inside the parent's scope — Effection's scope prototype chain // ensures scope.reduce() walks child → parent when resolving middleware. - const parentEvalScope = yield* EvalScopeCtx.get(); + const parentEvalScope = yield* evalScope; let childEvalScope: EvalScope | undefined = undefined; if (parentEvalScope) { const result = yield* parentEvalScope.eval(() => useEvalScope()); @@ -511,10 +532,9 @@ function* expandComponent( // their own child scopes off parentEvalScope, and ancestor // middleware is visible through Effection's scope prototype chain. // - // Both wrap their expandSegments call in EvalEnvCtx.with() and - // EvalScopeCtx.with() so the full expansion context is available - // regardless of which task the closure runs in (e.g., inside - // evalScope.eval()). + // Both install env/evalScope middleware inside a fresh scope so the full + // expansion context is available regardless of which task the closure + // runs in (e.g., inside evalScope.eval()). // // These are non-serializable (functions) so serializeExports silently // omits them from the journal. @@ -526,102 +546,66 @@ function* expandComponent( // without triggering false cycle detection. True cycles in a component's // body are still caught because body expansion uses newHideSet. const capturedChildrenHideSet = hideSet; - const capturedCtx = ctx; const capturedParentEvalScope = parentEvalScope; // Children are caller-provided content. Use the caller's eval env so // expression props (e.g., {pr}) resolve against the scope where the // JSX was written, not the wrapping component's env. Falls back to const capturedCallerEnv = callerEvalEnv ?? componentEnv; - componentEnv.values.renderChildren = function* () { - return yield* EvalEnvCtx.with(capturedCallerEnv, function* () { + const renderInCallerScope = (segments: Segment[]) => + scoped(function* () { + yield* provideEnv(capturedCallerEnv); if (capturedParentEvalScope) { - return yield* EvalScopeCtx.with(capturedParentEvalScope, function* () { - const expanded = yield* expandSegments( - children, - capturedMeta, - capturedProps, - capturedChildrenHideSet, - capturedCtx, - counter, - ); - return renderSegments(expanded); - }); - } - const expanded = yield* expandSegments( - children, - capturedMeta, - capturedProps, - capturedChildrenHideSet, - capturedCtx, - counter, - ); - return renderSegments(expanded); - }); - }; - - componentEnv.values.render = function* (markdown: string) { - const segments = scanSegments(markdown); - return yield* EvalEnvCtx.with(capturedCallerEnv, function* () { - if (capturedParentEvalScope) { - return yield* EvalScopeCtx.with(capturedParentEvalScope, function* () { - const expanded = yield* expandSegments( - segments, - capturedMeta, - capturedProps, - capturedChildrenHideSet, - capturedCtx, - counter, - ); - return renderSegments(expanded); - }); + yield* provideEvalScope(capturedParentEvalScope); } const expanded = yield* expandSegments( segments, capturedMeta, capturedProps, capturedChildrenHideSet, - capturedCtx, counter, ); return renderSegments(expanded); }); - }; - const expanded = yield* EvalEnvCtx.with(componentEnv, function* () { + componentEnv.values.renderChildren = () => renderInCallerScope(children); + + componentEnv.values.render = (markdown: string) => renderInCallerScope(scanSegments(markdown)); + + const expanded = yield* scoped(function* () { + yield* provideEnv(componentEnv); if (childEvalScope) { - return yield* EvalScopeCtx.with(childEvalScope, function* () { - return yield* expandSegments( - substituted, - definition.meta, - validatedProps, - newHideSet, - ctx, - counter, - ); - }); + yield* provideEvalScope(childEvalScope); } - return yield* expandSegments( - substituted, + return yield* expandBody( + definition.bodySegments, + children, definition.meta, validatedProps, newHideSet, - ctx, counter, + callerEvalEnv ?? undefined, ); }); if (asBinding) { - const parentEnv = yield* EvalEnvCtx.get(); + const parentEnv = yield* env; if (!parentEnv) { return [ - { + yield* raise({ type: "error", message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, source: name, - }, + }), ]; } + // Consumer boundary (spec §6.9): raise transported errors unconditionally + // so documentation never captures and hides an error comment. + for (const capturedSegment of expanded) { + if (capturedSegment.type === "error") { + yield* raise(capturedSegment); + } + } parentEnv.values[asBinding] = renderSegments(expanded); return []; } @@ -647,17 +631,16 @@ function* expandFunctionComponent( children: Segment[], definition: FunctionComponentDefinition, hideSet: Set, - ctx: ExpansionContext, counter: BlockCounter, projectedEnv?: EvalEnv, ): Operation { if ("as" in expressions) { return [ - { + yield* raise({ type: "error", message: `Prop "as" on <${name} /> must be a string literal.`, source: name, - }, + }), ]; } @@ -667,11 +650,11 @@ function* expandFunctionComponent( resolvedProps = yield* resolveExpressionProps(props, expressions, name, projectedEnv); } catch (error) { return [ - { + yield* raise({ type: "error", message: error instanceof Error ? error.message : String(error), source: name, - }, + }), ]; } @@ -679,11 +662,11 @@ function* expandFunctionComponent( const asBindingResult = validateBindingName(resolvedProps.as); if (!asBindingResult.ok) { return [ - { + yield* raise({ type: "error", message: `Prop "as" on <${name} /> ${asBindingResult.error}`, source: name, - }, + }), ]; } const asBinding = asBindingResult.value; @@ -695,47 +678,48 @@ function* expandFunctionComponent( validatedProps = validateProps(name, propsForValidation, definition.inputs); } catch (error) { return [ - { + yield* raise({ type: "error", message: error instanceof Error ? error.message : String(error), source: name, - }, + }), ]; } - // Set ContentCtx on the scope so the function component can - // access children via `yield* useContent()`. Supports named - // slots: `yield* useContent("header")`. const slots = partitionBySlot(children); - const scope = yield* useScope(); - - const contentHandle: ContentHandle = { - segments: children, - *renderDefault() { - const expanded = yield* expandSegments(slots.default, {}, {}, hideSet, ctx, counter); - return renderSegments(expanded); - }, - *renderSlot(slotName: string) { - const slotChildren = (slots.named.get(slotName) ?? []).map(stripSlotProp); - if (slotChildren.length === 0) return ""; - const expanded = yield* expandSegments(slotChildren, {}, {}, hideSet, ctx, counter); - return renderSegments(expanded); - }, - }; - scope.set(ContentCtx, contentHandle); - // Call the function component + // Call the function component with content middleware in scope so it can + // render children via `yield* useContent()` / `useContent("slot")`. try { - const output = yield* definition.fn(validatedProps); + const output = yield* scoped(function* () { + yield* Component.around( + { + *content([slotName], _next) { + if (slotName !== undefined) { + const slotChildren = (slots.named.get(slotName) ?? []).map(stripSlotProp); + if (slotChildren.length === 0) { + return ""; + } + const expanded = yield* expandSegments(slotChildren, {}, {}, hideSet, counter); + return renderSegments(expanded); + } + const expanded = yield* expandSegments(slots.default, {}, {}, hideSet, counter); + return renderSegments(expanded); + }, + }, + { at: "min" }, + ); + return yield* definition.fn(validatedProps); + }); if (asBinding) { - const parentEnv = yield* EvalEnvCtx.get(); + const parentEnv = yield* env; if (!parentEnv) { return [ - { + yield* raise({ type: "error", message: `Prop "as" on <${name} /> requires a parent evaluation environment.`, source: name, - }, + }), ]; } parentEnv.values[asBinding] = output; @@ -743,15 +727,20 @@ function* expandFunctionComponent( } return [{ type: "text", content: output }]; } catch (error) { + // A DocumentationError from a content-rendering path (useContent) is + // fail-fast — propagate it unchanged. + if (error instanceof DocumentationError) { + throw error; + } return [ - { + yield* raise({ type: "error", message: error instanceof Error ? `Function component ${name} error: ${error.message}` : `Function component ${name} error: ${String(error)}`, source: name, - }, + }), ]; } } @@ -810,7 +799,7 @@ function* resolveExpressionProps( return resolved; } - const contextEnv = yield* EvalEnvCtx.get(); + const contextEnv = yield* env; // For projected children (substituted via ), merge the // caller's env (explicitEnv) with the wrapping component's env @@ -982,6 +971,71 @@ export function stripSlotProp(segment: Segment): Segment { // Content slot substitution (spec §6.3) // --------------------------------------------------------------------------- +type ProjectFn = (segments: Segment[]) => Segment[]; + +/** Mutable flag so slot validation errors are emitted only once. */ +interface SubstitutionState { + errorsEmitted: boolean; +} + +/** + * Build the projection function that tags substituted children with the + * caller's eval env so their expression props resolve in the caller's scope. + */ +function makeProjectFn(callerEnv: EvalEnv | undefined): ProjectFn { + const project: ProjectFn = (segments) => { + if (!callerEnv) { + return segments; + } + return segments.map((seg) => { + if (seg.type === "component") { + return { + ...seg, + projectedEnv: callerEnv, + children: project(seg.children), + }; + } + return seg; + }); + }; + return project; +} + +/** + * Replace `` / `` in a segment list with the + * caller's children (partitioned by slot) and interpolate {meta}/{props} in + * text. Slot validation errors are emitted once, at the first projection + * point, tracked via the shared `state`. + */ +function substituteSegmentList( + segments: Segment[], + slots: SlotMap, + meta: Record, + props: Record, + project: ProjectFn, + state: SubstitutionState, +): Segment[] { + return segments.flatMap((segment) => { + if (segment.type === "component" && segment.name === "Content") { + const targetSlot = segment.props.slot; + const pendingErrors = !state.errorsEmitted ? slots.errors : []; + if (pendingErrors.length > 0) { + state.errorsEmitted = true; + } + + if (targetSlot !== undefined) { + const slotKey = String(targetSlot); + return [...pendingErrors, ...project((slots.named.get(slotKey) ?? []).map(stripSlotProp))]; + } + return [...pendingErrors, ...project(slots.default)]; + } + if (segment.type === "text") { + return [{ ...segment, content: interpolate(segment.content, meta, props) }]; + } + return [segment]; + }); +} + /** * Replace `` and `` invocations with the * caller's children, partitioned by slot assignment. @@ -998,49 +1052,202 @@ function substituteContent( callerEnv?: EvalEnv, ): Segment[] { const slots = partitionBySlot(children); - // Track whether errors have been emitted (only emit once) - let errorsEmitted = false; + const state: SubstitutionState = { errorsEmitted: false }; + const project = makeProjectFn(callerEnv); + return substituteSegmentList(bodySegments, slots, meta, props, project, state); +} - // Tag projected children with the caller's eval env so expression - const tagProjected = (segments: Segment[]): Segment[] => { - if (!callerEnv) return segments; - return segments.map((seg) => { - if (seg.type === "component") { - return { - ...seg, - projectedEnv: callerEnv, - children: tagProjected(seg.children), - }; +interface BodyChunk { + /** true = a rendered `` region; false = documentation (executed, not rendered). */ + output: boolean; + segments: Segment[]; +} + +function isTopLevelOutput(segment: Segment): boolean { + return segment.type === "component" && segment.name === "Output"; +} + +export function bodyHasOutput(bodySegments: Segment[]): boolean { + return bodySegments.some(isTopLevelOutput); +} + +function misplacedOutputError(): ErrorSegment { + return { + type: "error", + message: + " must be a direct top-level child of the component or document " + + "that declares it. For conditional rendering, use inside .", + source: "Output", + }; +} + +function previewOutput(segment: ComponentInvocation): string { + const text = segment.children + .filter((child): child is TextSegment => child.type === "text") + .map((child) => child.content) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (text.length === 0) { + return " (empty)"; + } + const clipped = text.slice(0, 40); + return ` containing "${clipped}${text.length > 40 ? "…" : ""}"`; +} + +/** + * Structural preflight (spec §6.9). Validates `` placement against the + * body's own source AST. Only a direct top-level `` is a valid + * declaration; any `` at depth > 0 — including inside unreachable or + * discarded children — is a placement violation. All violations are combined + * into a single aggregate ErrorSegment. Returns undefined when placement is + * valid. + */ +export function validateOutputPlacement(bodySegments: Segment[]): ErrorSegment | undefined { + const violations: string[] = []; + + const walk = (segments: Segment[], depth: number): void => { + for (const segment of segments) { + if (segment.type !== "component") { + continue; } - return seg; - }); + if (segment.name === "Output" && depth > 0) { + violations.push(previewOutput(segment)); + } + walk(segment.children, depth + 1); + } }; - return bodySegments.flatMap((segment) => { - if (segment.type === "component" && segment.name === "Content") { - const targetSlot = segment.props.slot as string | undefined; - // Emit slot validation errors at the first Content projection point - const pendingErrors = !errorsEmitted ? slots.errors : []; - if (pendingErrors.length > 0) errorsEmitted = true; + walk(bodySegments, 0); - if (targetSlot !== undefined) { - // Named slot projection — strip slot prop from each child - return [ - ...pendingErrors, - ...tagProjected((slots.named.get(targetSlot) ?? []).map(stripSlotProp)), - ]; + if (violations.length === 0) { + return undefined; + } + + const list = violations.map((entry) => ` - ${entry}`).join("\n"); + return { + type: "error", + message: + " must be a direct top-level child of the component or document " + + "that declares it. For conditional rendering, use inside " + + `. Misplaced found:\n${list}`, + source: "Output", + }; +} + +function validateOutputProps(segment: ComponentInvocation): ErrorSegment | undefined { + const hasProps = Object.keys(segment.props).length > 0; + const hasExpressions = Object.keys(segment.expressions).length > 0; + if (hasProps || hasExpressions) { + return { type: "error", message: " accepts no props.", source: "Output" }; + } + return undefined; +} + +/** + * Partition a definition body into ordered chunks (spec §6.9). Output policy + * is determined by definition provenance — top-level `` segments in + * the source, before `` substitution — so caller-projected + * `` can neither activate nor alter it. `` inside a + * top-level `` is substituted one level in; slot errors are emitted + * once across the whole body via the shared substitution state. + */ +function buildBody( + bodySegments: Segment[], + children: Segment[], + meta: Record, + props: Record, + callerEnv: EvalEnv | undefined, +): BodyChunk[] { + const slots = partitionBySlot(children); + const state: SubstitutionState = { errorsEmitted: false }; + const project = makeProjectFn(callerEnv); + const chunks: BodyChunk[] = []; + + for (const segment of bodySegments) { + if (segment.type === "component" && segment.name === "Output") { + const propsError = validateOutputProps(segment); + if (propsError) { + chunks.push({ output: true, segments: [propsError] }); + continue; } - // Default slot projection - return [...pendingErrors, ...tagProjected(slots.default)]; + const outputSegments = substituteSegmentList( + segment.children, + slots, + meta, + props, + project, + state, + ); + chunks.push({ output: true, segments: outputSegments }); + continue; } - if (segment.type === "text") { - return [ - { - ...segment, - content: interpolate(segment.content, meta, props), - }, - ]; + + const docSegments = substituteSegmentList([segment], slots, meta, props, project, state); + chunks.push({ output: false, segments: docSegments }); + } + + return chunks; +} + +/** + * Expand a definition body (spec §6.9). Without a top-level ``, the + * whole body renders (backward compatible). With ``, only the declared + * regions render; documentation executes for its side effects under a throwing + * raise policy (fail-fast) and its rendered result is discarded; output + * regions install a collecting raise that shadows any inherited throwing + * middleware (innermost min runs first), so their errors render as comments. + * Regions and documentation run in document order, so output can depend on + * bindings computed by preceding documentation. + */ +export function* expandBody( + bodySegments: Segment[], + children: Segment[], + meta: Record, + props: Record, + hideSet: Set, + counter: BlockCounter, + callerEnv: EvalEnv | undefined, +): Operation { + if (!bodyHasOutput(bodySegments)) { + const substituted = substituteContent(bodySegments, children, meta, props, callerEnv); + return yield* expandSegments(substituted, meta, props, hideSet, counter); + } + + const chunks = buildBody(bodySegments, children, meta, props, callerEnv); + const output: Segment[] = []; + + for (const chunk of chunks) { + if (chunk.output) { + const expanded = yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *raise([error], _next) { + return error; + }, + }, + { at: "min" }, + ); + return yield* expandSegments(chunk.segments, meta, props, hideSet, counter); + }); + output.push(...expanded); + } else { + // Documentation: execute for side effects, discard rendered output. + yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *raise([error], _next) { + throw new DocumentationError(error); + }, + }, + { at: "min" }, + ); + return yield* expandSegments(chunk.segments, meta, props, hideSet, counter); + }); } - return [segment]; - }); + } + + return output; } diff --git a/core/src/modifiers.ts b/core/src/modifiers.ts index ebce5c8..82912f6 100644 --- a/core/src/modifiers.ts +++ b/core/src/modifiers.ts @@ -10,47 +10,24 @@ * - The middleware signature is Middleware<[], CodeBlockWorkflow> */ -import { createContext } from "effection"; +import { scoped } from "effection"; import type { Operation } from "effection"; import { ephemeral } from "@executablemd/durable-streams"; import type { Workflow } from "@executablemd/durable-streams"; import type { Middleware } from "@effectionx/middleware"; import { combine } from "@effectionx/middleware"; +import { Component, codeBlock } from "./component-api.ts"; import type { CodeBlockContext, CodeBlockResult, Modifier } from "./types.ts"; -// --------------------------------------------------------------------------- -// CodeBlock context — Effection scope-inherited value -// --------------------------------------------------------------------------- - -/** - * Effection Context holding the current code block's metadata. - * - * Set on the scope via `CodeBlockCtx.with()` before the modifier chain - * runs. Handlers that need the code block info (language, content, - * componentName) read it via `useCodeBlock()` instead of receiving it - * as a parameter. - */ -export const CodeBlockCtx = createContext("codeBlock"); - /** - * Read the current code block context from the Effection scope. + * Read the current code block context. * - * Returns a Workflow-compatible generator (bridged via `ephemeral`) - * so it can be `yield*`'d inside modifier handlers that run within - * durable workflows. - * - * @example - * ```typescript - * const myFactory: ModifierFactory = (_params) => - * (_args, next) => function* () { - * const ctx = yield* useCodeBlock(); - * console.log(ctx.language); - * return yield* next(); - * }(); - * ``` + * Ergonomic alias for the Component `codeBlock()` operation, bridged via + * `ephemeral` so it can be `yield*`'d inside modifier handlers that run + * within durable workflows. */ export function useCodeBlock(): Workflow { - return ephemeral(CodeBlockCtx.expect()); + return ephemeral(codeBlock()); } // --------------------------------------------------------------------------- @@ -125,9 +102,9 @@ export function createModifierRegistry(parent?: ModifierRegistry): ModifierRegis * leftmost modifier is the outermost wrapper. `combine` handles the * right-to-left reduction internally. * - * The `context` parameter is made available to handlers via Effection's - * `CodeBlockCtx.with()`, which sets the context on the scope for the - * duration of the chain execution and restores it afterward. + * The `context` parameter is made available to handlers via scope-local + * `Component.operations.codeBlock()` middleware, installed for the + * duration of the chain execution and removed when its scope exits. */ export function composeModifierChain( modifiers: Modifier[], @@ -156,13 +133,22 @@ export function composeModifierChain( // Combine all middlewares into a single middleware const composed = combine(middlewares); - // Return a thunk that sets CodeBlockCtx for the duration of the - // chain via Context.with(), then runs the composed middleware. + // Return a thunk that provides the code block contextually for the + // duration of the chain, then runs the composed middleware. // The cast is safe because CodeBlockWorkflow yields DurableEffect // values which extend Effect — structurally compatible with Operation. return function* () { return yield* ephemeral( - CodeBlockCtx.with(context, function* () { + scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *codeBlock(_args, _next) { + return context; + }, + }, + { at: "min" }, + ); return yield* composed([], terminal) as unknown as Operation; }), ); diff --git a/core/src/modifiers/daemon.ts b/core/src/modifiers/daemon.ts index 1da0c31..cebba24 100644 --- a/core/src/modifiers/daemon.ts +++ b/core/src/modifiers/daemon.ts @@ -15,7 +15,7 @@ import { ephemeral } from "@executablemd/durable-streams"; import { daemon } from "@effectionx/process"; import type { ModifierFactory } from "../modifiers.ts"; import { useCodeBlock } from "../modifiers.ts"; -import { EvalScopeCtx } from "../eval-env.ts"; +import { evalScope } from "../component-api.ts"; // --------------------------------------------------------------------------- // daemonFactory — terminal modifier (spec §3.3) @@ -26,7 +26,7 @@ import { EvalScopeCtx } from "../eval-env.ts"; * * Ignores `next` — this is the terminal handler (like `exec` and `eval`). * Reads code block metadata via useCodeBlock() and the eval scope via - * EvalScopeCtx. + * the contextual `evalScope` value. * * The block's content (already interpolated with eval bindings by the * expansion engine) is used to build the subprocess command. The command @@ -46,14 +46,17 @@ export const daemonFactory: ModifierFactory = (_params) => (_args, _next) => // daemon produces no journal entry, so all its effects are ephemeral. const launchDaemon = { *[Symbol.iterator]() { - const evalScope = yield* EvalScopeCtx.expect(); + const scope = yield* evalScope; + if (!scope) { + throw new Error("daemon requires a component eval scope; none is in scope."); + } // The block content is a raw shell command (e.g. the body of a // ```bash daemon exec``` block). Pass it directly to daemon() // with shell:true so @effectionx/process invokes the system // shell instead of splitting with shellwords — which would // mangle commands containing nested quotes. - yield* evalScope.eval(function* () { + yield* scope.eval(function* () { yield* daemon(ctx.content, { shell: true }); }); }, diff --git a/core/src/modifiers/persist.ts b/core/src/modifiers/persist.ts index 1369058..23d62d9 100644 --- a/core/src/modifiers/persist.ts +++ b/core/src/modifiers/persist.ts @@ -8,16 +8,17 @@ * evalScope.eval(), which retains spawned resources in the persistent * EvalScope until the component finishes expanding. * - * Implementation: persist sets a context flag (PersistFlagCtx) that the - * eval handler reads. The eval handler then runs fn(env) inside + * Implementation: persist installs the contextual `persistent` value middleware that + * the eval handler reads. The eval handler then runs fn(env) inside * evalScope.eval() instead of directly. This avoids wrapping the entire * modifier chain (which includes durable effects that can't cross the * evalScope channel boundary). */ +import { scoped } from "effection"; import { ephemeral } from "@executablemd/durable-streams"; import type { ModifierFactory } from "../modifiers.ts"; -import { PersistFlagCtx } from "../eval-env.ts"; +import { Component } from "../component-api.ts"; // --------------------------------------------------------------------------- // persistFactory (spec §7.1) @@ -26,17 +27,15 @@ import { PersistFlagCtx } from "../eval-env.ts"; /** * Wrapping modifier that marks a block for persistent resource lifetime. * - * Sets PersistFlagCtx=true on the scope for the duration of the inner - * chain. The eval handler reads this flag and routes the compiled block + * Makes the contextual `persistent` value answer true for the duration of the inner + * chain. The eval handler reads this and routes the compiled block * execution through evalScope.eval() for resource retention. */ export const persistFactory: ModifierFactory = (_params) => (_args, next) => (function* () { - // Set the persist flag on the scope, then delegate to the inner chain. - // evalFactory will read this flag and route execution through - // evalScope.eval() for resource retention. return yield* ephemeral( - PersistFlagCtx.with(true, function* () { + scoped(function* () { + yield* Component.around({ persistent: () => true }, { at: "min" }); return yield* next() as unknown as import("effection").Operation< import("../types.ts").CodeBlockResult >; diff --git a/core/src/run-document.ts b/core/src/run-document.ts index e8480a9..ba2feb2 100644 --- a/core/src/run-document.ts +++ b/core/src/run-document.ts @@ -8,7 +8,7 @@ * See DEC-005 in specs/decisions.md. */ -import { useScope, spawn, createChannel, withResolvers, until } from "effection"; +import { scoped, spawn, createChannel, withResolvers, until } from "effection"; import type { Operation, Stream } from "effection"; import { durableRun, @@ -26,13 +26,17 @@ import type { FunctionComponentDefinition, InputDefinition, ImportResult, - Modifier, - CodeBlockContext, } from "./types.ts"; import { scanSegments } from "./scanner.ts"; import { parseFrontmatter } from "./frontmatter.ts"; -import { expandSegments, createBlockCounter } from "./expand.ts"; -import type { ExpansionContext } from "./expand.ts"; +import { + expandSegments, + expandBody, + bodyHasOutput, + validateOutputPlacement, + createBlockCounter, +} from "./expand.ts"; +import { Component, importComponent } from "./component-api.ts"; import { renderSegment } from "./render.ts"; import { DocumentOutput } from "./api.ts"; import { @@ -41,13 +45,12 @@ import { createModifierRegistry, useCodeBlock, } from "./modifiers.ts"; -import type { ModifierFactory, ModifierRegistry } from "./modifiers.ts"; +import type { ModifierFactory } from "./modifiers.ts"; import { evalFactory } from "./eval-handler.ts"; import { persistFactory } from "./modifiers/persist.ts"; import { timeoutFactory } from "./modifiers/timeout.ts"; import { daemonFactory } from "./modifiers/daemon.ts"; -import { EvalEnvCtx, EvalScopeCtx } from "./eval-env.ts"; -import type { EvalEnv } from "./eval-env.ts"; +import type { EvalEnv } from "./types.ts"; import { useEvalScope } from "@effectionx/scope-eval"; import { Stdio } from "@effectionx/process"; @@ -246,49 +249,67 @@ const silentFactory: ModifierFactory = (_params) => (_args, next) => // calls inside the expansion engine. See DEC-002. // --------------------------------------------------------------------------- -function* documentWorkflow( - docPath: string, - searchPaths: string[], - modifierRegistry: ModifierRegistry, -): Workflow { - // Import root — same pipeline as any component - const root = yield* durableImportComponent("__root__", docPath, searchPaths); +function* documentWorkflow(): Workflow { + // Import root — same pipeline as any component. The provider middleware + // installed by runDocument maps "__root__" to the document path. The + // ephemeral() wrapper bridges typing only — the import inside remains a + // durable, journaled operation. + const root = yield* ephemeral(importComponent("__root__")); if (root.kind === "function") { throw new Error("Root document must be a markdown file, not a function component"); } - const env: EvalEnv = { values: {} }; - - // Build the expansion context - const ctx: ExpansionContext = { - importComponent: function* (name: string) { - return yield* durableImportComponent(name, undefined, searchPaths); - }, - runModifierChain: function* (modifiers: Modifier[], context: CodeBlockContext) { - const chain = composeModifierChain(modifiers, context, modifierRegistry); - return yield* chain(); - }, - }; + const rootEnv: EvalEnv = { values: {} }; // Per-root-segment emission loop (spec §9). // Mutable counter preserves deterministic blockIds across // per-segment expansion calls (see spec §6.1). const counter = createBlockCounter(); - // EvalScopeCtx is already set on the scope by runDocument. - // EvalEnvCtx is scoped to the document expansion lifetime via - // Context.with() (spec §3.1). Resources spawned by `persist` blocks - // are retained in the eval scope until expansion completes, then - // torn down. - // - // The EvalEnvCtx.with() wraps the entire loop so all segments share - // the same binding environment. - const scopedExpansion: Operation = EvalEnvCtx.with(env, function* () { + // The root binding environment is installed as scope-local middleware + // around the entire loop so all segments share it. Resources spawned by + // `persist` blocks are retained in the eval scope until expansion + // completes, then torn down. + const scopedExpansion: Operation = scoped(function* () { + yield* Component.around({ env: () => rootEnv }, { at: "min" }); + // Structural preflight (spec §6.9): a root with misplaced + // executes no body side effects; the aggregate diagnostic renders as a + // comment (root policy is "collect"). + const placementError = validateOutputPlacement(root.bodySegments); + if (placementError) { + const text = renderSegment(placementError); + yield* ephemeral(DocumentOutput.operations.output(text)); + return text; + } + + // A root declaring top-level buffers completely (spec §5.4): + // execute the whole body, then emit the selected regions only after + // successful completion. A documentation failure throws before any emit, + // so no partial output is produced. + if (bodyHasOutput(root.bodySegments)) { + const expanded = yield* expandBody( + root.bodySegments, + [], + root.meta, + {}, + new Set(), + counter, + undefined, + ); + const text = expanded.map(renderSegment).join(""); + // An empty buffered root emits no output event. + if (text) { + yield* ephemeral(DocumentOutput.operations.output(text)); + } + return text; + } + + // Per-root-segment emission loop for roots without (spec §5.4). const chunks: string[] = []; for (const segment of root.bodySegments) { - const expanded = yield* expandSegments([segment], root.meta, {}, new Set(), ctx, counter); + const expanded = yield* expandSegments([segment], root.meta, {}, new Set(), counter); for (const resolved of expanded) { const text = renderSegment(resolved); @@ -397,7 +418,6 @@ export function* runDocument(options: RunDocumentOptions): Operation(); yield* spawn(function* () { - const scope = yield* useScope(); let emitted = false; // Install platform-appropriate compiler middleware @@ -426,11 +446,32 @@ export function* runDocument(options: RunDocumentOptions): Operation rootEvalScope, + }, + { at: "min" }, + ); // Run the durable workflow. try { - const output = yield* durableRun(() => documentWorkflow(docPath, componentDirs, registry), { + const output = yield* durableRun(() => documentWorkflow(), { stream, }); diff --git a/core/src/types.ts b/core/src/types.ts index 9dfc3aa..534ae60 100644 --- a/core/src/types.ts +++ b/core/src/types.ts @@ -42,10 +42,10 @@ export interface ComponentInvocation { selfClosing: boolean; /** * When set, expression props resolve against this env instead of the - * current EvalEnvCtx. Used for projected children (substituted via - * ``) — they carry the caller's eval env so that expression - * props like `{pr}` resolve in the lexical scope where the JSX was - * written, not the wrapping component's scope. + * contextual `env()` binding environment. Used for projected children + * (substituted via ``) — they carry the caller's eval env so + * that expression props like `{pr}` resolve in the lexical scope where + * the JSX was written, not the wrapping component's scope. * * This field is NOT part of the parsed IR — it's set at expansion time * by substituteContent when projecting children into slots. @@ -79,6 +79,15 @@ export interface ExecResult { stderr: string; } +/** + * Shared binding environment for eval blocks within a single component + * (spec §4.3). Created fresh at the start of component expansion; read + * contextually via the Component `env` operation. + */ +export interface EvalEnv { + values: Record; +} + // --------------------------------------------------------------------------- // Modifier system (spec §3.2–3.5) // --------------------------------------------------------------------------- diff --git a/core/tests/component-api.test.ts b/core/tests/component-api.test.ts new file mode 100644 index 0000000..70fbdb5 --- /dev/null +++ b/core/tests/component-api.test.ts @@ -0,0 +1,219 @@ +/** + * Component Api tests — default operations, missing-provider diagnostics, + * scoped middleware overrides, and nested provider precedence. + */ +import { describe, it } from "@effectionx/bdd/node"; +import { expect } from "@effectionx/bdd/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; +import { + Component, + importComponent, + applyModifiers, + raise, + env, + evalScope, + codeBlock, + persistent, + content, +} from "../src/component-api.ts"; +import { ephemeral } from "@executablemd/durable-streams"; +import { useEvalScope } from "@effectionx/scope-eval"; +import { persistFactory } from "../src/modifiers/persist.ts"; +import type { ComponentDefinition, ErrorSegment } from "../src/types.ts"; + +function stubComponent(name: string): ComponentDefinition { + return { kind: "markdown", name, path: `${name}.md`, meta: {}, inputs: {}, bodySegments: [] }; +} + +function* messageOf(operation: Operation): Operation { + try { + yield* operation; + return ""; + } catch (error) { + return error instanceof Error ? error.message : String(error); + } +} + +describe("Component Api", () => { + // ----- Defaults ----- + + it("importComponent without a provider throws a missing-provider error", function* () { + const message = yield* messageOf(importComponent("Missing")); + expect(message).toContain('importComponent("Missing")'); + expect(message).toContain("has no provider"); + }); + + it("applyModifiers without a provider throws a missing-provider error", function* () { + const block = { language: "bash", content: "x", blockId: "test:0" }; + const message = yield* messageOf(applyModifiers([{ name: "exec" }], block)); + expect(message).toContain("applyModifiers"); + expect(message).toContain("has no provider"); + }); + + it("codeBlock without a provider throws a missing-provider error", function* () { + const message = yield* messageOf(codeBlock()); + expect(message).toContain("codeBlock"); + expect(message).toContain("has no provider"); + }); + + it("content without a provider throws a missing-provider error", function* () { + const message = yield* messageOf(content()); + expect(message).toContain("content"); + expect(message).toContain("has no provider"); + }); + + it("raise returns the supplied ErrorSegment by default", function* () { + const segment: ErrorSegment = { type: "error", message: "boom", source: "test" }; + const returned = yield* raise(segment); + expect(returned).toBe(segment); + }); + + it("env and evalScope default to undefined; persistent defaults to false", function* () { + expect(yield* env).toBe(undefined); + expect(yield* evalScope).toBe(undefined); + expect(yield* persistent).toBe(false); + }); + + // ----- Scoped overrides ----- + + it("a scoped max middleware wraps importComponent and is removed on scope exit", function* () { + const real = stubComponent("Real"); + const aliased = stubComponent("Aliased"); + yield* Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + if (name === "Real") { + return real; + } + throw new Error(`Component not found: ${name}`); + }, + }, + { at: "min" }, + ); + + const inside = yield* scoped(function* () { + yield* Component.around({ + *importComponent([name], next) { + if (name === "Alias") { + return aliased; + } + return yield* next(name); + }, + }); + return { + alias: yield* importComponent("Alias"), + delegated: yield* importComponent("Real"), + }; + }); + expect(inside.alias).toBe(aliased); + expect(inside.delegated).toBe(real); + + // Outside the scope the alias middleware is gone. + const message = yield* messageOf(importComponent("Alias")); + expect(message).toContain("Component not found: Alias"); + }); + + it("a scoped applyModifiers provider overrides an inherited one and is removed on exit", function* () { + const block = { language: "bash", content: "x", blockId: "test:0" }; + yield* Component.around( + { + // deno-lint-ignore require-yield + *applyModifiers(_args, _next) { + return { output: "outer\n", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + + const inner = yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *applyModifiers(_args, _next) { + return { output: "inner\n", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + return yield* applyModifiers([], block); + }); + expect(inner.output).toBe("inner\n"); + + const outer = yield* applyModifiers([], block); + expect(outer.output).toBe("outer\n"); + }); + + // ----- Nested precedence without sibling leakage ----- + + it("nested env providers override ancestors without leaking into siblings", function* () { + const outerEnv = { values: { tag: "outer" } }; + const innerEnv = { values: { tag: "inner" } }; + yield* Component.around({ env: () => outerEnv }, { at: "min" }); + + const first = yield* scoped(function* () { + yield* Component.around({ env: () => innerEnv }, { at: "min" }); + return yield* env; + }); + expect(first).toBe(innerEnv); + + // A sibling scope sees the ancestor provider, not the first sibling's. + const second = yield* scoped(function* () { + return yield* env; + }); + expect(second).toBe(outerEnv); + }); + + it("nested evalScope providers override ancestors without leaking into siblings", function* () { + const outerScope = yield* useEvalScope(); + const innerScope = yield* useEvalScope(); + yield* Component.around({ evalScope: () => outerScope }, { at: "min" }); + + const first = yield* scoped(function* () { + yield* Component.around({ evalScope: () => innerScope }, { at: "min" }); + return yield* evalScope; + }); + expect(first).toBe(innerScope); + + const second = yield* scoped(function* () { + return yield* evalScope; + }); + expect(second).toBe(outerScope); + }); + + // ----- Persistent evaluation flag ----- + + it("persistent is true inside the persist modifier chain and false outside", function* () { + let observed: boolean | undefined = undefined; + const middleware = persistFactory(undefined); + // The terminal is Workflow-typed, so the contextual read bridges + // through ephemeral(). + const terminal = function* () { + observed = yield* ephemeral(persistent); + return { output: "", exitCode: 0, stderr: "" }; + }; + yield* middleware([], terminal); + expect(observed).toBe(true); + expect(yield* persistent).toBe(false); + }); + + // ----- Content provider dispatch ----- + + it("content(slot) dispatches through the installed provider", function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *content([slot], _next) { + if (slot !== undefined) { + return `slot:${slot}`; + } + return "default"; + }, + }, + { at: "min" }, + ); + expect(yield* content()).toBe("default"); + expect(yield* content("header")).toBe("slot:header"); + }); +}); diff --git a/core/tests/daemon.test.ts b/core/tests/daemon.test.ts index 208afef..f955021 100644 --- a/core/tests/daemon.test.ts +++ b/core/tests/daemon.test.ts @@ -6,9 +6,10 @@ */ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; +import { scoped } from "effection"; import { daemonFactory } from "../src/modifiers/daemon.ts"; import { combine } from "@effectionx/middleware"; -import { CodeBlockCtx } from "../src/modifiers.ts"; +import { Component } from "../src/component-api.ts"; import type { ModifierFactory, ModifierMiddleware } from "../src/modifiers.ts"; import type { Operation } from "effection"; @@ -63,16 +64,14 @@ describe("Tier Q — Daemon modifier", () => { expect(factory.length).toBe(2); }); - // Q10b: daemon without EvalScopeCtx throws a clear error - // When the daemon middleware runs in a scope where EvalScopeCtx - // is not set, EvalScopeCtx.expect() should throw. - it("Q10b: daemon without EvalScopeCtx throws clear error", function* () { + // Q10b: daemon without an eval scope in scope throws a clear error + it("Q10b: daemon without an eval scope throws clear error", function* () { const middleware = daemonFactory(undefined); const terminal = function* () { return { output: "", exitCode: 0, stderr: "" }; }; - // Provide CodeBlockCtx (required by useCodeBlock) but NOT EvalScopeCtx + // Provide the code block (required by useCodeBlock) but no eval scope const fakeContext = { language: "bash", content: "echo hello", @@ -83,11 +82,17 @@ describe("Tier Q — Daemon modifier", () => { let errorMessage = ""; try { - yield* CodeBlockCtx.with(fakeContext, function* () { - // The daemon middleware generator yields ephemeral operations. - // When it reaches EvalScopeCtx.expect() without the context set, - // it should throw. - yield* middleware([], terminal) as unknown as Operation; + yield* scoped(function* () { + yield* Component.around( + { + // deno-lint-ignore require-yield + *codeBlock(_args, _next) { + return fakeContext; + }, + }, + { at: "min" }, + ); + yield* middleware([], terminal); }); } catch (e) { threw = true; @@ -95,7 +100,6 @@ describe("Tier Q — Daemon modifier", () => { } expect(threw).toBe(true); - // The error should mention the missing context - expect(errorMessage.toLowerCase()).toMatch(/evalscope|context/); + expect(errorMessage).toContain("daemon requires a component eval scope"); }); }); diff --git a/core/tests/eval-middleware.test.ts b/core/tests/eval-middleware.test.ts index abc1a67..2d3b01a 100644 --- a/core/tests/eval-middleware.test.ts +++ b/core/tests/eval-middleware.test.ts @@ -53,7 +53,7 @@ describe("Tier T3 — Middleware factory conformance", () => { expect(typeof middleware).toBe("function"); }); - // T28: EvalEnvCtx accessible pattern + // T28: binding environment accessible pattern it("T28: evalFactory creates a generator when called", function* () { const middleware = evalFactory(undefined); // Call the middleware with dummy args and next diff --git a/core/tests/expand.test.ts b/core/tests/expand.test.ts index d41a8fe..fd9b3cc 100644 --- a/core/tests/expand.test.ts +++ b/core/tests/expand.test.ts @@ -1,20 +1,22 @@ import { describe, it } from "@effectionx/bdd/node"; import { expect } from "@effectionx/bdd/expect"; +import { scoped } from "effection"; import { expandSegments } from "../src/expand.ts"; -import type { ExpansionContext } from "../src/expand.ts"; +import { Component } from "../src/component-api.ts"; import { scanSegments } from "../src/scanner.ts"; import { interpolate } from "../src/interpolate.ts"; import { validateProps, PropValidationError } from "../src/validate.ts"; import { renderSegments } from "../src/render.ts"; import type { Operation } from "effection"; -import { EvalEnvCtx } from "../src/eval-env.ts"; +import { ephemeral } from "@executablemd/durable-streams"; +import { useContent } from "../src/content-context.ts"; import type { Segment, ComponentDefinition, + EvalEnv, + FunctionComponentDefinition, Json, CodeBlockResult, - Modifier, - CodeBlockContext, } from "../src/types.ts"; // --------------------------------------------------------------------------- @@ -39,56 +41,70 @@ function makeComponent( }; } -function makeCtx( - components: Record, +/** Install test component + modifier providers on the current scope. */ +function useTestComponents( + components: Record, codeResult?: CodeBlockResult, -): ExpansionContext { - return { - importComponent: function* (name: string) { - const comp = components[name]; - if (!comp) throw new Error(`Component not found: ${name}`); - return comp; - }, - runModifierChain: function* (_modifiers: Modifier[], _context: CodeBlockContext) { - return ( - codeResult ?? { - output: "mock output\n", - exitCode: 0, - stderr: "", +): Operation { + return Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + const comp = components[name]; + if (!comp) { + throw new Error(`Component not found: ${name}`); } - ); + return comp; + }, + // deno-lint-ignore require-yield + *applyModifiers(_args, _next) { + return ( + codeResult ?? { + output: "mock output\n", + exitCode: 0, + stderr: "", + } + ); + }, }, - }; + { at: "min" }, + ); +} + +/** Install a binding environment on the current scope. */ +function useTestEnv(testEnv: EvalEnv): Operation { + return Component.around({ env: () => testEnv }, { at: "min" }); } function expand( segments: Segment[], - ctx: ExpansionContext, - meta: Record = {}, - props: Record = {}, + components: Record, + opts: { + meta?: Record; + props?: Record; + codeResult?: CodeBlockResult; + } = {}, ): Operation { - function* op() { - return yield* EvalEnvCtx.with({ values: {} }, function* () { - const expanded = yield* expandSegments(segments, meta, props, new Set(), ctx); - return renderSegments(expanded); - }); - } - return op() as unknown as Operation; + return scoped(function* () { + yield* useTestComponents(components, opts.codeResult); + yield* useTestEnv({ values: {} }); + const expanded = yield* expandSegments(segments, opts.meta ?? {}, opts.props ?? {}, new Set()); + return renderSegments(expanded); + }); } function expandWithEnv( segments: Segment[], - ctx: ExpansionContext, + components: Record, + codeResult?: CodeBlockResult, ): Operation<{ output: string; env: Record }> { - function* op() { - const env = { values: {} as Record }; - const output = yield* EvalEnvCtx.with(env, function* () { - const expanded = yield* expandSegments(segments, {}, {}, new Set(), ctx); - return renderSegments(expanded); - }); - return { output, env: env.values }; - } - return op() as unknown as Operation<{ output: string; env: Record }>; + return scoped(function* () { + const testEnv: EvalEnv = { values: {} }; + yield* useTestComponents(components, codeResult); + yield* useTestEnv(testEnv); + const expanded = yield* expandSegments(segments, {}, {}, new Set()); + return { output: renderSegments(expanded), env: testEnv.values }; + }); } // --------------------------------------------------------------------------- @@ -99,7 +115,7 @@ describe("expansion", () => { // C1: Basic expansion it("C1: basic expansion — component body in output", function* () { const comp = makeComponent("Greeting", "Hello world!"); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("Hello world!"); @@ -108,7 +124,7 @@ describe("expansion", () => { // C2: Content slot it("C2: content slot — children at position", function* () { const comp = makeComponent("Wrap", "Before After"); - const ctx = makeCtx({ Wrap: comp }); + const ctx = { Wrap: comp }; const segments = scanSegments("middle"); const output = yield* expand(segments, ctx); expect(output).toBe("Before middle After"); @@ -118,7 +134,7 @@ describe("expansion", () => { it("C3: nested expansion — A contains B", function* () { const compB = makeComponent("B", "inner"); const compA = makeComponent("A", "outer end"); - const ctx = makeCtx({ A: compA, B: compB }); + const ctx = { A: compA, B: compB }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("outer inner end"); @@ -129,7 +145,7 @@ describe("expansion", () => { const compC = makeComponent("C", "leaf"); const compB = makeComponent("B", "mid()"); const compA = makeComponent("A", "top()"); - const ctx = makeCtx({ A: compA, B: compB, C: compC }); + const ctx = { A: compA, B: compB, C: compC }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("top(mid(leaf))"); @@ -138,7 +154,7 @@ describe("expansion", () => { // C5: Direct cycle it("C5: direct cycle — A contains A → ErrorSegment", function* () { const compA = makeComponent("A", "start end"); - const ctx = makeCtx({ A: compA }); + const ctx = { A: compA }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -149,7 +165,7 @@ describe("expansion", () => { it("C6: mutual cycle — A→B→A → ErrorSegment", function* () { const compA = makeComponent("A", "a()"); const compB = makeComponent("B", "b()"); - const ctx = makeCtx({ A: compA, B: compB }); + const ctx = { A: compA, B: compB }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -161,7 +177,7 @@ describe("expansion", () => { const comp = makeComponent("Page", "Title: {meta.title}", { meta: { title: "My Page" }, }); - const ctx = makeCtx({ Page: comp }); + const ctx = { Page: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("Title: My Page"); @@ -172,7 +188,7 @@ describe("expansion", () => { const comp = makeComponent("Greeting", "Hello, {props.name}!", { inputs: { name: { type: "string", required: true } }, }); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(''); const output = yield* expand(segments, ctx); expect(output).toBe("Hello, world!"); @@ -181,7 +197,7 @@ describe("expansion", () => { // C10: Missing interpolation key → empty string it("C10: missing interpolation key → empty string", function* () { const comp = makeComponent("Comp", "value: {meta.nonexistent}"); - const ctx = makeCtx({ Comp: comp }); + const ctx = { Comp: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("value: "); @@ -192,7 +208,7 @@ describe("expansion", () => { const comp = makeComponent("Comp", "host: {meta.config.db.host}", { meta: { config: { db: { host: "localhost" } } }, }); - const ctx = makeCtx({ Comp: comp }); + const ctx = { Comp: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("host: localhost"); @@ -201,7 +217,7 @@ describe("expansion", () => { // C12: No Content slot — children silently discarded it("C12: no Content slot — children silently discarded", function* () { const comp = makeComponent("NoSlot", "fixed content"); - const ctx = makeCtx({ NoSlot: comp }); + const ctx = { NoSlot: comp }; const segments = scanSegments("ignored"); const output = yield* expand(segments, ctx); expect(output).toBe("fixed content"); @@ -210,7 +226,7 @@ describe("expansion", () => { // C13: Multiple Content slots it("C13: multiple Content slots — each replaced with same children", function* () { const comp = makeComponent("Multi", "first: second: "); - const ctx = makeCtx({ Multi: comp }); + const ctx = { Multi: comp }; const segments = scanSegments("stuff"); const output = yield* expand(segments, ctx); expect(output).toBe("first: stuff second: stuff"); @@ -221,7 +237,7 @@ describe("expansion", () => { const comp = makeComponent("Greeting", "{props.greeting}, world!", { inputs: { greeting: { type: "string", default: "Hello" } }, }); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("Hello, world!"); @@ -230,7 +246,7 @@ describe("expansion", () => { // C20: No inputs, no props — valid it("C20: no inputs, no props — valid", function* () { const comp = makeComponent("Badge", "badge"); - const ctx = makeCtx({ Badge: comp }); + const ctx = { Badge: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("badge"); @@ -241,7 +257,7 @@ describe("expansion", () => { const comp = makeComponent("Comp", "val:{props.opt}", { inputs: { opt: { type: "string", required: false } }, }); - const ctx = makeCtx({ Comp: comp }); + const ctx = { Comp: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toBe("val:"); @@ -249,32 +265,47 @@ describe("expansion", () => { // Code block expansion it("code block expansion via modifier chain", function* () { - const ctx = makeCtx({}, { output: "hello\n", exitCode: 0, stderr: "" }); const segments = scanSegments("```bash exec\necho hello\n```\n"); - const output = yield* expand(segments, ctx); + const output = yield* expand( + segments, + {}, + { + codeResult: { output: "hello\n", exitCode: 0, stderr: "" }, + }, + ); expect(output).toBe("hello\n"); }); // Code block with non-zero exit it("code block with non-zero exit → error", function* () { - const ctx = makeCtx({}, { output: "", exitCode: 1, stderr: "not found" }); const segments = scanSegments("```bash exec\nfoo\n```\n"); - const output = yield* expand(segments, ctx); + const output = yield* expand( + segments, + {}, + { + codeResult: { output: "", exitCode: 1, stderr: "not found" }, + }, + ); expect(output).toContain("ERROR"); expect(output).toContain("not found"); }); // Silent code block → no output it("silent code block produces no output", function* () { - const ctx = makeCtx({}, { output: "", exitCode: 0, stderr: "" }); const segments = scanSegments("```bash silent exec\necho hello\n```\n"); - const output = yield* expand(segments, ctx); + const output = yield* expand( + segments, + {}, + { + codeResult: { output: "", exitCode: 0, stderr: "" }, + }, + ); expect(output).toBe(""); }); it("captures component output with as", function* () { const comp = makeComponent("Greeting", "Hello world!"); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(''); const { output, env } = yield* expandWithEnv(segments, ctx); expect(output).toBe(""); @@ -282,7 +313,7 @@ describe("expansion", () => { }); it("Capture stores children output into env and stays silent", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments('hello\n'); const { output, env } = yield* expandWithEnv(segments, ctx); expect(output).toBe(""); @@ -290,7 +321,7 @@ describe("expansion", () => { }); it("Capture rejects expression as prop", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments("text"); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -298,7 +329,7 @@ describe("expansion", () => { }); it("Capture rejects self-closing usage", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments(''); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -306,7 +337,7 @@ describe("expansion", () => { }); it("Capture rejects extra props", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments('text'); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -314,7 +345,7 @@ describe("expansion", () => { }); it("Capture with select extracts code block by CSS selector", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments( 'prose text\n\n```json\n{"key":"val"}\n```\n\nmore prose\n', ); @@ -324,7 +355,7 @@ describe("expansion", () => { }); it("Capture with select falls back to full content when no match", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments( 'no code here\n', ); @@ -334,7 +365,7 @@ describe("expansion", () => { }); it("Capture with select extracts paragraph text", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments('Hello world\n'); const { output, env } = yield* expandWithEnv(segments, ctx); expect(output).toBe(""); @@ -342,7 +373,7 @@ describe("expansion", () => { }); it("Capture accepts select alongside as without error", function* () { - const ctx = makeCtx({}); + const ctx = {}; const segments = scanSegments('text\n'); const output = yield* expand(segments, ctx); expect(output).not.toContain("ERROR"); @@ -350,7 +381,7 @@ describe("expansion", () => { it("component as rejects expression prop", function* () { const comp = makeComponent("Greeting", "Hello world!"); - const ctx = makeCtx({ Greeting: comp }); + const ctx = { Greeting: comp }; const segments = scanSegments(""); const output = yield* expand(segments, ctx); expect(output).toContain("ERROR"); @@ -358,6 +389,300 @@ describe("expansion", () => { }); }); +// --------------------------------------------------------------------------- +// Component-declared output — (spec §6.9) +// --------------------------------------------------------------------------- + +/** + * Install a recording applyModifiers provider and return the captured + * code-block contents. Installed after useTestComponents in the same scope, + * so it wins (later-installed min middleware runs first). + */ +function* useRecordingModifiers(codeResult?: CodeBlockResult): Operation { + const execCalls: string[] = []; + yield* Component.around( + { + // deno-lint-ignore require-yield + *applyModifiers([_modifiers, block], _next) { + execCalls.push(block.content); + return codeResult ?? { output: "ran\n", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + return execCalls; +} + +function recordingExpand( + segments: Segment[], + components: Record, + codeResult?: CodeBlockResult, +): Operation<{ output: string; execCalls: string[] }> { + return scoped(function* () { + yield* useTestComponents(components); + const execCalls = yield* useRecordingModifiers(codeResult); + yield* useTestEnv({ values: {} }); + const expanded = yield* expandSegments(segments, {}, {}, new Set()); + return { output: renderSegments(expanded), execCalls }; + }); +} + +describe("component-declared output", () => { + it("renders only the region, suppressing documentation", function* () { + const comp = makeComponent( + "Warn", + "Docs heading.\n\n\nSHOWN\n\n\nMore docs.\n", + ); + const ctx = { Warn: comp }; + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("SHOWN"); + expect(output).not.toContain("Docs heading"); + expect(output).not.toContain("More docs"); + }); + + it("without renders the complete body", function* () { + const comp = makeComponent("Doc", "Alpha then Beta."); + const ctx = { Doc: comp }; + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("Alpha then Beta."); + }); + + it("concatenates multiple regions in document order", function* () { + const comp = makeComponent( + "Multi", + "ONE\n\nmiddle docs\n\nTWO\n", + ); + const ctx = { Multi: comp }; + const output = yield* expand(scanSegments(""), ctx); + expect(output).not.toContain("middle docs"); + expect(output.indexOf("ONE")).toBeGreaterThanOrEqual(0); + expect(output.indexOf("ONE")).toBeLessThan(output.indexOf("TWO")); + }); + + it("preserves markdown source inside , including a GitHub admonition", function* () { + const comp = makeComponent( + "Adm", + "docs\n\n\n> [!WARNING]\n> Careful now.\n\n", + ); + const ctx = { Adm: comp }; + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("> [!WARNING]"); + expect(output).toContain("> Careful now."); + expect(output).not.toContain("docs"); + }); + + it("treats and as equivalent empty output", function* () { + const selfClosing = makeComponent("A", "before\n\n\n\nafter"); + const paired = makeComponent("B", "before\n\n\n\nafter"); + const ctx = { A: selfClosing, B: paired }; + const a = yield* expand(scanSegments(""), ctx); + const b = yield* expand(scanSegments(""), ctx); + expect(a.trim()).toBe(""); + expect(b.trim()).toBe(""); + }); + + it("rejects props on ", function* () { + const comp = makeComponent("Bad", 'x'); + const ctx = { Bad: comp }; + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("ERROR"); + expect(output).toContain("accepts no props"); + }); + + it("rejects expression props on ", function* () { + const comp = makeComponent("Bad", "y"); + const ctx = { Bad: comp }; + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("ERROR"); + expect(output).toContain("accepts no props"); + }); + + it("projects caller content through inside ", function* () { + const comp = makeComponent("Wrap", "docs\n\n\n\n\n"); + const ctx = { Wrap: comp }; + const output = yield* expand(scanSegments("PROJECTED"), ctx); + expect(output).toContain("PROJECTED"); + expect(output).not.toContain("docs"); + }); + + it("lets an region read a binding recorded by preceding documentation", function* () { + const comp = makeComponent( + "Dep", + 'HELLO\n\nmsg={msg}', + ); + const ctx = { Dep: comp }; + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("msg=HELLO"); + }); + + it("executes exec blocks outside but suppresses their output", function* () { + const comp = makeComponent("Ex", "```bash exec\nDOCRUN\n```\n\nok\n"); + const { output, execCalls } = yield* recordingExpand(scanSegments(""), { Ex: comp }); + expect(execCalls.some((c) => c.includes("DOCRUN"))).toBe(true); + expect(output).toContain("ok"); + expect(output).not.toContain("ran"); + }); + + it("executes documentation after an region", function* () { + const comp = makeComponent("Post", "ok\n\n```bash exec\nAFTER\n```\n"); + const { output, execCalls } = yield* recordingExpand(scanSegments(""), { Post: comp }); + expect(execCalls.some((c) => c.includes("AFTER"))).toBe(true); + expect(output).toContain("ok"); + }); + + it("keeps errors inside an region as comments", function* () { + const comp = makeComponent("Err", "\n\n"); + const ctx = { Err: comp }; + const output = yield* expand(scanSegments(""), ctx); + expect(output).toContain("` comment). +- A root containing `` emits its selected output only after the whole + body completes successfully; a documentation failure yields no partial + output, and an empty selection emits nothing. + +An error a nested component renders inside its own output region is a normal +comment when that component renders normally; but when that component is +executed as a parent's documentation, the parent's documentation fail-fast +applies and the error propagates rather than being hidden. + +#### Root and component consistency + +A root document obeys exactly the same rules as an imported component (§5.4). +Because selecting output requires the whole body, a root that declares +`` is buffered — executed to completion, then emitted once on success — +while a root without `` keeps per-segment streaming. Buffering defers +only when output is emitted, not what executes, so replay is deterministic. - // Install built-in modifier handlers (exec, silent, sample, eval, persist, timeout) - yield* useBuiltinModifiers(); +--- - // Install custom modifier handlers - for (const [name, factory] of Object.entries(customModifiers)) { - registry.set(name, factory); - } +## 7. Entry point - // Channel delivery is the innermost DocumentOutput handler (installed first). - scope.around(DocumentOutput, { - *output([text]) { - yield* channel.send(text); - }, - }); - - // Run the durable workflow. On completion, resolve with the - // stored output and close the channel; on failure, reject and close. - try { - const storedOutput = yield* durableRun( - () => documentWorkflow(docPath), - { stream }, - ); - channel.close(storedOutput); - resolve(storedOutput); - } catch (error) { - channel.close(); - reject(error); - } - }); +### 8.1 `runDocument` - // Return the execution handle — caller can yield* it for the full - // output, or consume execution.output for streaming chunks. - return { ...operation, output: channel } as DocumentExecution; -} -``` +`runDocument(options)` executes a markdown document as a durable +workflow and returns a `DocumentExecution` handle. Options: + +- `docPath` — path to the root markdown document +- `stream` — the durable stream that journals the run +- `componentDirs?` — component search directories (default: + `["components", "."]`) +- `modifiers?` — custom modifier factories registered alongside the + built-ins (`exec`, `silent`, `eval`, `persist`, `timeout`, `daemon`) + +`DocumentExecution` is an `Operation`: `yield* execution` +completes with the document's full output. Its `output` property is a +`Stream` of the chunks emitted during execution +(per-segment for streaming roots, one chunk for buffered `` +roots — §5.4); the stream closes with the full output as its close +value. + +Execution runs in its own scope. Before the durable workflow starts, +`runDocument` installs the document's scope-local runtime providers — +the platform compiler, the Component providers for import, modifier +execution, and the root eval scope (§5.5), and the output→stream +bridge — so nothing leaks onto the caller's scope and the whole run +inherits them contextually. + +On successful completion, `yield* execution` returns the full output +and the `output` stream closes with it. On failure, the stream closes +and `yield* execution` throws the workflow error. ### 8.2 Usage from standalone code @@ -3799,6 +3488,18 @@ visible warning blocks, collect into a separate error report). | C29 | `` CSS extraction | `` with code fence child stores code block value only | | C30 | `` fallback | `select="code[lang=json]"` with no matching node stores full rendered content | | C31 | `` paragraph | `select="paragraph"` extracts paragraph text content | +| C32 | `` selects region | Only the `` region renders; documentation outside is suppressed | +| C33 | No `` | Whole body renders (backward compatible) | +| C34 | Documentation executes | eval/exec/`` outside `` run; a later `` reads their bindings; documentation after a region still runs | +| C35 | Multiple `` regions | Concatenate in document order | +| C36 | Markdown preserved in `` | A `> [!WARNING]` admonition survives intact | +| C37 | Empty-tag parity | `` and `` both contribute no content | +| C38 | `` props rejected | Props/expression props on `` produce an ErrorSegment | +| C39 | `` in `` | Caller content projects into a top-level `` region | +| C40 | `as=` captures selected output | A component invoked with `as=` captures only its `` regions; documentation is neither rendered nor captured | +| C41 | Structural placement | Nested/misplaced `` (including inside `` or a content-discarding component) produces one aggregate diagnostic and runs no body side effects | +| C42 | Caller-projected `` inert | Projecting `` through `` neither activates nor alters the callee's policy | +| C43 | Documentation fail-fast | A failure in documentation (direct, inside ``, inside a nested component, or a transported error) throws; a modifier-handled failure continues; errors inside `` or with no `` remain comments | ### Tier D — Code execution and modifier middleware @@ -3839,6 +3540,9 @@ visible warning blocks, collect into a separate error report). | E8 | `silent exec` in full document | Command runs, result journaled, output omitted | | E9 | `sample exec` in full document | Command + LLM both journaled, LLM response in output | | E10 | Unclosed bold across component boundary | `**text\n\nmore` → healed bold in first segment, component expanded, `more` unaffected | +| E11 | `` component vs. root consistency | An imported component and a root document apply `` identically; documentation is suppressed in both | +| E12 | Root `` buffering | A root with `` emits once after success; a later documentation failure yields no partial output; an empty selection emits no event; replay reproduces the result | +| E13 | `` inside `` (smoke) | `smoke-test/OutputDemo.md` renders the conditionally-selected region (its `when` binding computed by preceding documentation eval) while its documentation prose does not appear | ### Tier F — Markdown healing (remend) @@ -3940,7 +3644,7 @@ visible warning blocks, collect into a separate error report). | # | Test | Verify | |---|------|--------| -| H1 | Eval context creation | `createEvalContext()` returns `EvalContext` with `initialized: true` | +| H1 | Missing-provider diagnostics | `importComponent`, `applyModifiers`, `codeBlock`, and `content` report clear missing-provider errors when no provider is installed | | H2 | Effection globals available | `sleep`, `spawn`, `createChannel` accessible in compiled block via standard imports | | H3 | executable.md globals available | `findFreePort`, `Sample`, `when` accessible in compiled block via `@executablemd/core` | | H5 | `compileBlock` returns generator function | `yield* compileBlock(code, [])` returns a callable generator function | @@ -3954,7 +3658,7 @@ visible warning blocks, collect into a separate error report). |---|------|--------| | I1 | `eval` is terminal | `evalFactory` ignores `next` — never calls it | | I2 | `eval` returns empty output | `result.output === ""`, `exitCode === 0` | -| I3 | `persist eval` composes | `persist` sets `PersistFlagCtx`, `eval` reads it | +| I3 | `persist eval` composes | `persist` makes `persistent` answer true, `eval` reads it | | I4 | `timeout=5s eval` composes | Timeout cancels after 5s if block hangs | | I5 | `timeout eval` default | Default timeout is 30s | | I6 | `persist timeout=10s eval` | Three modifiers compose: persist → timeout → eval | @@ -3991,7 +3695,7 @@ visible warning blocks, collect into a separate error report). | L1 | `persist eval` retains spawned resource | Resource spawned in block survives block completion | | L2 | Non-persist eval tears down resource | Resource spawned in block torn down at block end | | L3 | Persist resource lifetime matches component | Resource torn down when component expansion completes | -| L4 | PersistFlagCtx scoped to chain | Flag is `true` only during the persist-wrapped chain | +| L4 | Persistent flag scoped to chain | `persistent` is `true` only during the persist-wrapped chain | | L5 | Multiple persist blocks in one component | Each retains its own resources independently | | L6 | Persist on repeated run | Resource is created and retained again for the current component lifetime | | L7 | Persist flag does not leak to sibling blocks | Non-persist block after persist block → flag is false | @@ -4040,7 +3744,7 @@ visible warning blocks, collect into a separate error report). | Q7 | Process terminated on parent cancellation | If parent scope cancelled, process terminated | | Q8 | Premature exit propagates as error | Process exits during expansion → `daemon()` throws → `ErrorSegment` in output | | Q9 | `{port}` interpolation in daemon content | Binding from preceding `eval` block substituted into command | -| Q10 | `daemon` without eval scope | Missing `EvalScopeCtx` → clear error | +| Q10 | `daemon` without eval scope | No eval scope in scope → clear error | | Q11 | Modifier chain: `bash daemon exec` | `daemon` is outermost terminal; `exec` present but never called | | Q12 | Repeated run: daemon starts and stops | Process is spawned and terminated again | | Q13 | Repeated run: current port used | Eval allocates a current port; daemon binds it | @@ -4202,7 +3906,7 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 4 | `durableImportComponent` is a single journaled operation | Resolve + read in one `createDurableOperation`; one diagnostic journal entry per component | | 5 | Parsing is runtime | Deterministic from file content, no journal needed | | 6 | Info string modifiers are a middleware chain | `bash silent exec` — left-to-right wrapping, composable, extensible, compatible with all renderers | -| 7 | Each modifier is a factory that returns `Middleware<[], CodeBlockWorkflow>` | Factory captures params in closure; context on Effection scope via `CodeBlockCtx.with()` + `useCodeBlock()`; aligns with Effection v4.1's `Middleware` | +| 7 | Each modifier is a factory that returns `Middleware<[], CodeBlockWorkflow>` | Factory captures params in closure; the block context is delivered contextually via `codeBlock()`/`useCodeBlock()` (§5.5); aligns with Effection v4.1's `Middleware` | | 8 | `useModifier` registers handlers on the scope | Scope-inherited — child scopes can override parent handlers for their subtree | | 9 | `exec`/`eval` are terminal handlers, others are wrapping | Terminal handlers ignore `next`; wrapping handlers call `next()` and transform the result | | 10 | `sample` handler delegates to Sample Api via `durableSample` | Two layers: handler (part of modifier chain) and Api (LLM middleware) — each composable independently | @@ -4218,13 +3922,13 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 22 | Components are semantic boundaries for markdown constructs | Bold, italic, links, code spans cannot span across a component or exec block — each text segment is healed independently | | 23 | Remend runs after scanning, before interpolation | Heals incomplete markdown in text segments; `htmlTags: false` required — boundary scanner owns JSX completeness, remend owns markdown completeness | | 24 | Healing is runtime, not journaled | Pure function of current text content; no journal entry | -| 25 | `CodeBlockContext` delivered via Effection Context, not handler parameter | `CodeBlockCtx.with()` scopes the context to the chain execution; handlers read via `useCodeBlock()`; keeps middleware signature clean `Middleware<[], ...>` | +| 25 | `CodeBlockContext` delivered contextually, not as a handler parameter | A scope-local `codeBlock()` provider covers exactly the chain execution; handlers read via `useCodeBlock()`; keeps middleware signature clean `Middleware<[], ...>` | | 26 | Reusable `Middleware` primitive in `@effectionx/middleware` | Same type as Effection v4.1's Api middleware; `combine()` composes arrays; decoupled from modifier-specific types; originally `src/middleware.ts`, extracted to shared package | | 27 | `blockId` format: `eval:${componentName ?? "root"}:${index}` | Unique within a document run and stable enough to compare diagnostic traces | | 28 | Acorn + magic-string for source transform | Acorn provides reliable ES2024 parsing; magic-string preserves source positions for accurate source maps without rebuilding AST | | 29 | Execution mode auto-detected from AST | No modifier needed — `yield` in body → generator, `await` → async, neither → sync; mixed yield+await is a transform error | | 30 | `data:` URI module compilation for eval blocks | Eval blocks are compiled into `data:application/typescript,...` URI modules and dynamically imported via `yield* call(() => import(dataUri))`. APIs are standard `import` statements in the generated module, resolved through Deno's import map. `new Function()` is used for expression props (simpler than `data:` URI for single expressions, no module imports needed) | -| 31 | `persist` uses a context flag, not direct wrapping | Wrapping the full modifier chain in `evalScope.eval()` hangs because durable effects can't interact with the journal from inside the eval scope's channel processor; instead `persist` sets `PersistFlagCtx`, and `evalFactory` routes only the compiled VM block through `evalScope.eval()` | +| 31 | `persist` uses a contextual flag, not direct wrapping | Wrapping the full modifier chain in `evalScope.eval()` hangs because durable effects can't interact with the journal from inside the eval scope's channel processor; instead `persist` makes `persistent` answer true, and `evalFactory` routes only the compiled VM block through `evalScope.eval()` | | 32 | `evalScope` created before the journaled workflow | The channel processor and eval sender share an ancestor scope | | 33 | Non-serializable bindings silently omitted from journal | Functions, class instances, and live objects remain in `env.values` during the current run but are absent from the diagnostic trace | | 34 | Eval blocks produce no rendered output by default | Eval blocks primarily exist for bindings and side effects. The `output()` function (§4.7) optionally produces rendered output; without it, result is `{ output: "", exitCode: 0, stderr: "" }` | @@ -4239,17 +3943,17 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 43 | `when` (from `@effectionx/converge`) is the polling VM global | `when` is the exported name from the package; the sandbox already contains it; no rename or addition needed | | 44 | Provider lifecycle expressed as a component, not a `RunDocumentOptions` field | Scope boundary is visible in the document tree; composable — multiple providers nest naturally via structured concurrency; no framework-level lifecycle hooks required | | 45 | Readiness check is a separate `eval` block, not internal to `daemon` | Auditable — strategy visible in the document; replaceable — different daemons have different readiness signals; composable with `when`'s configurable backoff | -| 46 | Sample middleware reads `baseUrl` from `env.values` | Avoids a dedicated inference server context key; `EvalEnvCtx` is already the shared state carrier for within-component coordination; scope-correct because `EvalEnvCtx` is set per component expansion | -| 47 | Each component gets a fresh `EvalEnv` | `EvalEnvCtx.with()` wraps component expansion so eval blocks within a component share bindings but don't leak into parent or sibling components; critical for provider isolation | +| 46 | Sample middleware reads `baseUrl` from `env.values` | Avoids a dedicated inference server context key; the binding environment is already the shared state carrier for within-component coordination; scope-correct because a fresh environment is provided per component expansion | +| 47 | Each component gets a fresh `EvalEnv` | The component's environment is installed as a scope-local `env` provider around body expansion, so eval blocks within a component share bindings but don't leak into parent or sibling components; critical for provider isolation | | 48 | `output()` is a plain function, not `yield*` | Output is a synchronous side effect (mutating a ref), not an Effection operation; making it a function keeps the API simple and avoids requiring generator context just to set output text | | 49 | `__output` stored alongside exports in journal | Avoids a separate journal entry; `__output` is extracted before merging into `env.values` to prevent namespace pollution | | 50 | `renderChildren`/`render` are closures in `env.values`, not an Api | A Render Api would require middleware installation per component; closures are simpler and capture the expansion context at the injection point; they are non-serializable and silently omitted from the journal | -| 51 | `renderChildren`/`render` use `parentEvalScope` wrapped in `EvalEnvCtx.with()` + `EvalScopeCtx.with()` | Children are caller-provided content and expand in the caller's scope context; the component's `childEvalScope` sequential channel is for its own `persist eval` blocks, not for expanding caller content; children may create resources (nested components, daemons) but their lifecycle is bound by their place in the expansion tree; wrapping ensures the correct scope is visible regardless of which task the closure runs in | +| 51 | `renderChildren`/`render` install the caller's environment and `parentEvalScope` as scope-local providers | Children are caller-provided content and expand in the caller's scope context; the component's `childEvalScope` sequential channel is for its own `persist eval` blocks, not for expanding caller content; children may create resources (nested components, daemons) but their lifecycle is bound by their place in the expansion tree; installing providers inside the closure ensures the correct context is visible regardless of which task it runs in | | 52 | `durableSample` routes through `EvalScope` | Sample Api middleware installed by `persist eval` blocks (e.g., `LlamafileProvider`'s `Sample.around()`) lives in the eval scope's task hierarchy; routing through `evalScope.eval()` ensures the middleware chain is found | | 53 | Sample component calls `Sample.operations.sample()` directly | The enclosing eval operation journals the complete block result | | 54 | Sample component props default to empty string, not undefined | `validateProps` omits optional props with no default from `env.values`, causing `ReferenceError` in eval blocks; empty-string defaults ensure the variables exist; `model \|\| undefined` converts empty to undefined for routing semantics | | 55 | `daemon()` uses `shell: true` | Matches `bash exec` block semantics — the same command string passed to `bash -c` is passed to the shell; handles shell expansions and PATH lookups correctly | -| 56 | Provider installs its own middleware, not a global `useLlamafileSample()` | A single global handler installed before `runDocument()` would execute in the outer scope at call time, where `EvalEnvCtx` has no `baseUrl`; middleware must close over `baseUrl` and `model` at the moment the provider becomes active | +| 56 | Provider installs its own middleware, not a global `useLlamafileSample()` | A single global handler installed before `runDocument()` would execute in the outer scope at call time, where the binding environment has no `baseUrl`; middleware must close over `baseUrl` and `model` at the moment the provider becomes active | | 57 | Routing key is `model`, not a separate `name` prop | Model identity is the natural key — it unifies "which server to route to" with "which model to request"; a separate `name` prop would require keeping two values in sync with no added expressiveness | | 58 | `context.model === undefined` routes to innermost provider | Omitting a model is the common case for single-provider documents; innermost-wins matches how middleware chains work — handlers installed later sit higher in the chain and are traversed first | | 59 | `callLlamafile()` is a standard import in generated eval modules | Provider components are markdown files — eval blocks are compiled into `data:` URI modules that import executable.md globals from `@executablemd/core`; functions like `callLlamafile`, `callOllama`, `callAnthropic`, `Sample`, `findFreePort`, and `useContent` are available via this import | @@ -4261,14 +3965,14 @@ must preserve the trace for diagnosis or remove it before starting a new run. | 65 | Whitespace normalization is middleware, not post-processing | Stateful across calls; composes with other middleware; can be disabled via `--raw`; mutable closure state scoped per `useNormalizedOutput()` call | | 66 | Terminal formatting is middleware, not a separate renderer | Composes with normalization; conditional on TTY; disabled for piped output; uses `marked-terminal` with `async: false` | | 67 | Channel-based delivery, not direct `process.stdout.write` | Decouples production from consumption; enables buffered collection for piped output; consumer task lifetime tied to document run scope; `channel.close()` in `finally` block guarantees consumer exits cleanly | -| 68 | Per-root-segment emission, not full-document batch | Enables streaming UX; root segments are sequential and independent; component-internal expansion remains recursive and buffered; `documentWorkflow` returns `""` — output already emitted via Api | +| 68 | Per-root-segment emission for roots without ``; full buffering for roots that declare it | Streaming UX for the common case — root segments are sequential and independent, and component-internal expansion is recursive and buffered. A root declaring top-level `` (§6.9) buffers completely and emits the selected regions only after successful expansion, so a later documentation failure yields no partial output; an empty selection emits nothing | | 69 | `blockId` counter threaded through expansion context | Per-segment expansion resets `result.length`; mutable counter preserves unique diagnostic IDs; counter guarded by expansion scope cancellation | | 70 | `output()` wrapped in `ephemeral()` | Output emission is a non-durable side effect; journal records durable effects only; output text is derived from journaled expansion results; all middleware/side effects execute on the ephemeral side | | 71 | Middleware installation order: normalize outer, terminal inner, channel innermost | `scope.around` later-installed handlers wrap earlier ones; execution flows outer → inner: normalize → terminal → channel; install order is reverse of execution order; must be documented to prevent reordering | | 72 | `channel.send()` must be `yield*`'d | Ensures backpressure and cancellation safety — no text "in flight" when scope tears down; without `yield*`, buffering issues or silent cancellation may occur | | 73 | `DocumentExecution` with `withResolvers` | Execution is both an `Operation` (`yield*` for result) and has `.output` stream for chunks; errors surface through `yield* execution` via `withResolvers` `reject()`; eliminates `Result` / `unbox` in consumer code | | 74 | Function components receive props directly, not wrapped | `function*(props)` not `function*({ props })` — eliminates unnecessary destructuring; props are already validated by the expansion engine before the function is called | -| 75 | `useContent()` on Effection scope, not in function args | Decouples function components from the expansion engine's API surface; leaf components don't need to ignore an `expandChildren` parameter; Effection-idiomatic — same pattern as `EvalEnvCtx`, `EvalScopeCtx`; supports named slots via `useContent("header")` | +| 75 | `useContent()` is contextual, not a function argument | Decouples function components from the expansion engine's API surface; leaf components don't need to ignore an `expandChildren` parameter; Effection-idiomatic — same contextual pattern as `env`/`evalScope`; supports named slots via `useContent("header")` | | 76 | `.md` wins over `.ts` in resolution | Backward compatibility — existing markdown components are not shadowed by TypeScript files added later; explicit — if both exist, the human-readable markdown is preferred | | 77 | Function component imported on every run | The current module must execute because functions are not serialized into a trace | | 78 | Internal durable-streams package | Provides journaling for the core runtime |