diff --git a/architecture.md b/architecture.md index b2e74676..156a3b00 100644 --- a/architecture.md +++ b/architecture.md @@ -49,8 +49,8 @@ Error handling has two layers: 1. **Lexical structure** — the document applies context, in two forms: context values (`` sets the `output` error mode) and middleware - (`` installs failure printing; `` installs - retry middleware). + (`` installs retry middleware). `` uses both: it sets + `print` for its region and installs printing middleware. 2. **Runtime execution** — execution runs under the context applied by the enclosing structure. @@ -211,7 +211,7 @@ Status is measured against main. | Construct | Does | Status | | --- | --- | --- | | `` / `printErrors(fn)` | prints failures | built on main | -| `` region `output` mode | an undecided error fails the run | defined, unbuilt — on main a region prints and continues | +| `` region `output` mode | an undecided error fails the run | built on main | | `` | retry a region until it completes | defined, unbuilt | | suspension effect | suspend durably | defined, unbuilt | | `` | binds `{ok: true, value}` or `{ok: false, error}`; a failure becomes a bound value, not a raise | defined, unbuilt | diff --git a/packages/core/src/answers.ts b/packages/core/src/answers.ts index 32db23aa..e0c20f28 100644 --- a/packages/core/src/answers.ts +++ b/packages/core/src/answers.ts @@ -84,7 +84,12 @@ import type { ComponentElement, ErrorSegment, Json, Segment } from "./types.ts"; * arm holds that state, so it binds the recursion and passes it down; nothing * here could reconstruct which expansion a region belongs to. */ -type ExpandSegments = (segments: Segment[]) => Operation; +/** + * `owner` is the region the segments render into, when they render at all: the + * body writes there as it goes, while a matcher's template produces a value and + * keeps its own buffer. + */ +type ExpandSegments = (segments: Segment[], owner?: Segment[]) => Operation; const ANSWERS = "Answers"; const ANSWER = "Answer"; @@ -130,6 +135,8 @@ export function strayAnswerError(element: ComponentElement): ErrorSegment { export function* expandAnswers( element: ComponentElement, expand: ExpandSegments, + /** The region the answered body renders into. */ + owner: Segment[], ): Operation { for (const name of Object.keys({ ...element.props, ...element.expressions })) { if (name !== "delegate") { @@ -200,7 +207,8 @@ export function* expandAnswers( { at: "min" }, ); - return yield* expand(body); + yield* expand(body, owner); + return []; }); } diff --git a/packages/core/src/component-api.ts b/packages/core/src/component-api.ts index 891d3c17..4b3cd85d 100644 --- a/packages/core/src/component-api.ts +++ b/packages/core/src/component-api.ts @@ -133,8 +133,8 @@ export interface ComponentApi { * does; a printing boundary answers with a printed error instead. * * Distinct from `raise`: this handles an operation failure, while `raise` - * observes an `ErrorSegment`. Failure printing uses both — it converts, then - * observes exactly once. + * observes an `ErrorSegment`. A printing boundary uses both — it converts, + * then observes exactly once. */ handleFailure(failure: ComponentFailure): Operation; /** diff --git a/packages/core/src/component-failures.ts b/packages/core/src/component-failures.ts index f48909f3..727d2379 100644 --- a/packages/core/src/component-failures.ts +++ b/packages/core/src/component-failures.ts @@ -4,16 +4,17 @@ * A component that fails fails the operation it is part of, like any other * Effection work. Carrying on instead is a decision somebody makes: either the * component says so about itself with `printErrors()`, or a document says so - * about a region with ``. Both install the same middleware, so - * "the nearest printing boundary handles it" is one rule rather than two. + * about a region with ``. Both install the boundary through + * `usePrintErrors()`, so "the nearest printing boundary handles it" is one rule + * rather than two. * - * Printing turns a failure into a printed error. It does not decide what happens - * to that printed error — the caller's ambient error mode still settles it, so under - * documentation a printed failure still stops the document. + * A boundary sets `print` for its region and turns a propagating failure into + * one printed error. Both halves are the same decision: the region prints, and + * a failure that reaches the boundary is what gets printed. */ import { Component, raise } from "./component-api.ts"; -import { attributeCause } from "./errors.ts"; +import { attributeCause, ErrorMode } from "./errors.ts"; import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.ts"; import type { Operation } from "effection"; @@ -51,16 +52,28 @@ export function printsErrors(component: FunctionComponent): boolean { } /** - * Report an invocation failure as one printed error instead of failing the - * operation. + * Print this region's errors, and report an invocation failure as one printed + * error instead of failing the operation. * - * Terminal: it answers rather than delegating, so the nearest boundary is the - * one that handles a failure and an enclosing one never sees it again. The - * original failure is attributed as the printed error's cause, so what the - * component actually did remains reachable from the outside. + * The mode is a context value, so it governs by lexical structure and nothing + * more: a region nested inside this one that chooses its own — an `` + * region in a component invoked here — shadows it, and what happens inside that + * region is the same whether or not this boundary is written around it. + * + * `throw` is the one mode this does not replace. Documentation and value roots + * render nothing, so a printed error there is a printed error nobody can read, + * and the failure stays a failure (§6.9). + * + * The middleware is terminal: it answers rather than delegating, so the nearest + * boundary is the one that handles a failure and an enclosing one never sees it + * again. The original failure is attributed as the printed error's cause, so + * what the component actually did remains reachable from the outside. */ -export function useFailurePrinting(): Operation { - return Component.around({ +export function* usePrintErrors(): Operation { + if ((yield* ErrorMode.get()) !== "throw") { + yield* ErrorMode.set("print"); + } + yield* Component.around({ *handleFailure([failure], _next): Operation { const segment: ErrorSegment = { type: "error", diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 7236d3c2..5a22b633 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -17,7 +17,7 @@ import type { ErrorSegment } from "./types.ts"; * error crossing from a component's own error mode to its caller's does not emit a * second observation. */ -export type ErrorMode = "print" | "throw"; +export type ErrorMode = "print" | "output" | "throw"; export const ErrorMode: Context = createContext( "component.errorMode", @@ -25,16 +25,27 @@ export const ErrorMode: Context = createContext( ); /** - * Settle a segment under the ambient error mode: the default `Component.raise` - * implementation calls this, and so does a consumer applying its own error mode to - * an error that already crossed a nested one. + * Settle a segment under the ambient error mode — the decision an undecided + * error is raised into, made exactly once, where it is raised. + * + * The three modes differ over an error no middleware converted: + * + * - `print` prints it into the document and the run continues. This is the + * root's mode, and what a printing boundary installs for its region. + * - `output` fails the run. Every `` region installs it: a region that + * shows an operator what a stage produced must not also let a failed stage + * reach the step after it. The failure that leaves the region propagates like + * any other, so the nearest printing boundary may print it instead. + * - `throw` fails the run whatever a printing boundary says. Documentation and + * value roots are hidden, so a printed error there gives an author nothing to + * read. */ export function* settle(segment: ErrorSegment): Operation { - const mode = yield* ErrorMode.get(); - if (mode === "throw") { - throw new DocumentationError(segment); + const mode = (yield* ErrorMode.get()) ?? "print"; + if (mode === "print") { + return segment; } - return segment; + throw new DocumentationError(segment, mode); } /** @@ -71,11 +82,19 @@ export function attributeCause(segment: ErrorSegment, from: unknown): void { */ export class DocumentationError extends Error { readonly segment: ErrorSegment; + /** + * The error mode that decided this failure. Recorded because the two failing + * modes end differently at a printing boundary (`decidedByOutput`), and + * because the decision was already made: nothing reads the ambient mode again + * to work out what this failure means. + */ + readonly mode: "output" | "throw"; - constructor(segment: ErrorSegment) { + constructor(segment: ErrorSegment, mode: "output" | "throw") { super(segment.message); this.name = "DocumentationError"; this.segment = segment; + this.mode = mode; // Membership, not value: a component can throw `undefined`, and that is // still the exact value this failure was translated from — the own `cause` // property records it. Only a segment with no attribution has none. @@ -137,8 +156,10 @@ export type FatalFailure = DocumentationError | DurabilityFailure; * is right for anything the document itself got wrong. Two kinds are not that, * and every generic catch in the engine rethrows them: * - * - `DocumentationError` — the ambient error mode has already decided this - * execution fails (§6.9); printing it would undo that decision. + * - `DocumentationError` — the error mode has already decided this execution + * fails (§6.9); printing it here would undo that decision and resume work + * the decision stopped. The one place that asks a narrower question is the + * invocation boundary — see `decidedByOutput`. * - a `DurabilityFailure` — the journal no longer describes this run (§6.11). * The document is not wrong and there is nothing useful to render: continuing * would run later siblings on top of work that never happened, and rendering @@ -170,7 +191,35 @@ export type FatalFailure = DocumentationError | DurabilityFailure; * `isRecoveredContent` for why the asymmetry is the point. */ export function fatalCause(error: unknown): FatalFailure | undefined { - return durabilityFailure(error) ?? firstCause(error, asDocumentationError, isRecoveredContent); + return durabilityFailure(error) ?? documentationFailure(error); +} + +/** + * The documentation failure this one carries, if any — the same search + * `fatalCause` runs, asked on its own by the execution boundary, which reports + * a document's failure as the document's own outcome and lets anything else + * escape as an infrastructure failure. + */ +export function documentationFailure(error: unknown): DocumentationError | undefined { + return firstCause(error, asDocumentationError, isRecoveredContent); +} + +/** + * Whether a printing boundary is allowed to print this failure. + * + * Every generic catch in the engine asks `fatalCause` a broader question — "may + * I turn this into a printed error and carry on?" — and the answer there is no + * for both failing modes, because carrying on resumes work the decision + * stopped. A printing boundary asks a narrower one: the region is already torn + * down and nothing after the failure ran, so the only thing left to decide is + * whether the document gets to read what happened. + * + * An `output` decision says yes — that is the whole difference between the mode + * a region installs and the mode documentation installs. A `throw` decision and + * a durability failure say no. + */ +export function decidedByOutput(failure: FatalFailure): boolean { + return failure instanceof DocumentationError && failure.mode === "output"; } /** diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 9a0d6241..bf5bd6b3 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -23,6 +23,8 @@ import { exec, readTextFile, cwd } from "@executablemd/runtime"; import { cwd as processCwd } from "@effectionx/fs"; import type { Workflow, Json } from "@executablemd/durable-streams"; import { createReplayStream } from "./replay-stream.ts"; +import { createContext } from "effection"; +import type { Context } from "effection"; import type { ComponentDefinition, ComponentRegistry, @@ -31,8 +33,9 @@ import type { JsonObject, PropsSchema, ReturnsSchema, + Segment, } from "./types.ts"; -import { parseJsonObject } from "./json.ts"; +import { parseJson, parseJsonObject } from "./json.ts"; import { compilePropsSchema, compileReturnsSchema, validateProps } from "./validate.ts"; import { isFunctionComponentPath, parseMarkdownDefinition } from "./definition.ts"; import { parseReturnsDeclaration } from "./frontmatter.ts"; @@ -46,7 +49,7 @@ import { createBlockCounter, } from "./expand.ts"; import type { BlockCounter } from "./expand.ts"; -import { DocumentationError } from "./errors.ts"; +import { DocumentationError, documentationFailure, durabilityFailure } from "./errors.ts"; import { Component, importComponent } from "./component-api.ts"; import { renderSegment } from "./render.ts"; import { DocumentOutput } from "./api.ts"; @@ -259,13 +262,216 @@ const silentFactory: ModifierFactory = (_params) => (_args, next) => /** * What a document run produces. `output` is rendered body text — the - * observability channel — and `value` is the document's return value: the same + * observability channel — and a completed run adds its return value: the same * rendered text for a text root, the validated JSON for a value root. The pair * is journaled together so replay restores both; only `value` is public. */ -interface DocumentResult extends JsonObject { +type DocumentResult = DocumentSuccess | DocumentFailureResult; + +type DocumentSuccess = { + status: "ok"; output: string; value: Json; +}; + +/** + * A document that decided it failed. This is an outcome, not an accident: the + * run is over, what it rendered first is part of the record, and the journal + * closes `ok` around it so a replay restores both without re-executing + * anything. A durability failure is the opposite case and never arrives here + * (§6.11) — it says the journal no longer describes this run, so recording it + * as the run's own result would write onto a journal already known to be wrong. + */ +type DocumentFailureResult = { + status: "err"; + output: string; + error: DocumentFailure; +}; + +/** + * What crosses the journal about a failure. Everything here is JSON: object + * identity, stacks, and the cause graph stay behind, which is why the live path + * resolves the original error instead of this description (`LiveFailure`). + * + * A field is absent when the failure had nothing to say there, and present when + * it did. The distinction is load-bearing for `cause`: an absent key says the + * failure had no own cause, while `"undefined"` says it had one whose value was + * `undefined` — a component may throw exactly that. + */ +type DocumentFailure = { + name: string; + message: string; + segment: { message: string; source?: string }; + cause?: string; + errors?: { name: string; message: string }[]; +}; + +/** + * Where one execution leaves the failure it caught, for its own completion. + * + * The handoff is short and entirely inside a run: the workflow's catch fills + * this, and the completion takes it. A live run therefore reports the failure it + * actually caught — same object, same type, same `cause`, same aggregate + * members — while a replayed run never enters the workflow, leaves the slot + * empty, and reports the account the journal kept. The empty slot is the signal; + * nothing asks whether it is replaying. + * + * The slot belongs to the run's scope, so it is gone when the run is. + */ +interface LiveFailureSlot { + failure?: unknown; +} + +const LiveFailure: Context = createContext< + LiveFailureSlot | undefined +>("execution.liveFailure", undefined); + +function* rememberLiveFailure(error: unknown): Operation { + const slot = yield* LiveFailure.get(); + if (slot) { + slot.failure = error; + } +} + +/** The live failure this run recorded, taken so it answers exactly once. */ +function* takeLiveFailure(slot: LiveFailureSlot): Operation { + const live = slot.failure; + slot.failure = undefined; + return live; +} + +function describeFailure(caught: unknown, documentation: DocumentationError): DocumentFailure { + const wrapper = caught instanceof Error ? caught : new Error(String(caught)); + return { + name: wrapper.name, + message: wrapper.message, + segment: { + message: documentation.segment.message, + ...(documentation.segment.source === undefined + ? {} + : { source: documentation.segment.source }), + }, + // An own property, not an inherited one: every Error inherits `cause` from + // nowhere useful, and what this records is what this failure was given. + ...(Object.hasOwn(wrapper, "cause") ? { cause: describeCause(wrapper.cause) } : {}), + ...(wrapper instanceof AggregateError + ? { + errors: wrapper.errors.map((member: unknown) => ({ + name: member instanceof Error ? member.name : "Error", + message: member instanceof Error ? member.message : String(member), + })), + } + : {}), + }; +} + +function describeCause(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** + * The error a completion reports for a failed document: the original one on a + * live run, and otherwise the documented reconstruction of it. + */ +function failureError(failure: DocumentFailure, live: unknown): unknown { + if (live !== undefined) { + return live; + } + const members = failure.errors; + const replayed = members + ? new AggregateError( + members.map((member) => withName(new Error(member.message), member.name)), + failure.message, + ) + : new Error(failure.message); + if (failure.cause !== undefined) { + replayed.cause = failure.cause; + } + return withName(replayed, failure.name); +} + +function withName(error: Error, name: string): Error { + error.name = name; + return error; +} + +/** + * Narrow what the journal or the workflow handed back, field by field. + * + * The result is parsed rather than trusted: a journal is data, and a replayed + * run must fail on a shape it cannot read instead of carrying it further. The + * live failure is looked up from the value `durableRun` returned, before this + * runs, so parsing is free to build its own object. + */ +function parseDocumentResult(value: unknown): DocumentResult { + const candidate = parseJsonObject(value); + const output = candidate["output"]; + if (typeof output !== "string") { + throw new Error("A document result must carry its rendered output as a string."); + } + const status = candidate["status"]; + if (status === "ok") { + return { status: "ok", output, value: parseJson(candidate["value"]) }; + } + if (status === "err") { + return { status: "err", output, error: parseFailure(candidate["error"]) }; + } + throw new Error( + `A document result records its outcome as "ok" or "err", and this one records ` + + `${JSON.stringify(status)}. A journal written before the outcome contract ` + + `(#318) has no status at all and cannot be replayed by this version.`, + ); +} + +function parseFailure(value: unknown): DocumentFailure { + const candidate = parseJsonObject(value); + const name = candidate["name"]; + const message = candidate["message"]; + if (typeof name !== "string" || typeof message !== "string") { + throw new Error("A failure description carries a name and a message."); + } + const segment = parseJsonObject(candidate["segment"]); + const segmentMessage = segment["message"]; + if (typeof segmentMessage !== "string") { + throw new Error("A failure description carries the message of the segment that failed."); + } + // An optional field is absent or well-formed. Anything else is a journal this + // run cannot read, and coercing it to "absent" would report a failure that + // quietly disagrees with the one recorded. + const source = optionalString(segment, "source", "The source of a failed segment"); + const cause = optionalString(candidate, "cause", "The cause of a failure"); + const errors = candidate["errors"]; + if (errors !== undefined && !Array.isArray(errors)) { + throw new Error("The aggregate members of a failure are a list."); + } + return { + name, + message, + segment: { message: segmentMessage, ...(source === undefined ? {} : { source }) }, + ...(cause === undefined ? {} : { cause }), + ...(errors === undefined ? {} : { errors: errors.map(parseFailureMember) }), + }; +} + +function optionalString(holder: JsonObject, key: string, subject: string): string | undefined { + const value = holder[key]; + if (value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(`${subject} is text when it is recorded at all.`); + } + return value; +} + +function parseFailureMember(value: Json): { name: string; message: string } { + const member = parseJsonObject(value); + const name = member["name"]; + const message = member["message"]; + if (typeof name !== "string" || typeof message !== "string") { + throw new Error("An aggregate member carries a name and a message."); + } + return { name, message }; } /** @@ -279,8 +485,9 @@ function* runValueRoot( returns: ReturnsSchema, validatedProps: Record, counter: BlockCounter, + /** What this root emitted, held by the caller so a failure still finds it. */ + chunks: string[], ): Operation { - const chunks: string[] = []; let produced: { value: Json } | undefined; yield* scoped(function* () { @@ -288,7 +495,7 @@ function* runValueRoot( { // deno-lint-ignore require-yield *raise([error], _next) { - throw new DocumentationError(error); + throw new DocumentationError(error, "throw"); }, }, { at: "min" }, @@ -319,7 +526,7 @@ function* runValueRoot( if (!produced) { throw new Error("The root document declares `returns` but produced no value."); } - return { output: chunks.join(""), value: produced.value }; + return { status: "ok", output: chunks.join(""), value: produced.value }; } function* documentWorkflow(props: Record): Workflow { @@ -342,6 +549,14 @@ function* documentWorkflow(props: Record): Workflow): Workflow 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. + // execute the whole body, then emit the selected regions once. The owner is + // allocated outside this expansion so that a failure partway still leaves + // this frame holding what the regions rendered before it. if (bodyHasOutput(root.bodySegments)) { - const expanded = yield* expandBody( + yield* expandBody( root.bodySegments, [], root.meta, @@ -379,45 +595,76 @@ function* documentWorkflow(props: Record): Workflow (spec §5.4). - const chunks: string[] = []; - + // The loop owns the segments, so a component whose own region fails partway + // has still handed over what it rendered — the root emits that before the + // failure is reported, exactly as a buffered root does. for (const segment of root.bodySegments) { - const expanded = yield* expandSegments( - [segment], - root.meta, - validatedProps, - new Set(), - counter, - ); + yield* expandSegments([segment], root.meta, validatedProps, new Set(), counter, produced); - for (const resolved of expanded) { - const text = renderSegment(resolved); + while (emittedThrough < produced.length) { + const resolved = produced[emittedThrough]; + emittedThrough += 1; + const text = resolved === undefined ? "" : renderSegment(resolved); if (text) { // Emit through the Document Output Api (spec §9). // ephemeral() bridges from Workflow (durable) to Operation // (non-durable) — output emission is a derived side effect, // not journaled. yield* ephemeral(DocumentOutput.operations.output(text)); - chunks.push(text); + streamed.push(text); } } } - const text = chunks.join(""); - return { output: text, value: text }; + const text = streamed.join(""); + return { status: "ok", output: text, value: text }; }); - return yield* ephemeral(scopedExpansion); + // The catch is outside the `yield*`, not inside the scope: the expansion's + // teardown — the invocation being dismantled, retained work, and whatever + // aggregate the platform builds from a body failure and a teardown failure + // together — finishes as this returns. Describing the failure any earlier + // would describe an error whose account of itself is not complete yet. + try { + return yield* ephemeral(scopedExpansion); + } catch (error) { + // What the failing segment handed over but never reached the emission step. + // Emitted here whatever the failure turns out to be: a consumer reading + // chunks is who the preservation is for, and the completion path only emits + // for a run that streamed nothing at all — which stops being true as soon + // as an earlier segment went out. + const tail = produced.slice(emittedThrough).map(renderSegment).join(""); + if (tail) { + yield* ephemeral(DocumentOutput.operations.output(tail)); + } + // A durability failure is not something the document did, so it never + // becomes the document's own outcome (§6.11). + if (durabilityFailure(error) !== undefined) { + throw error; + } + const documentation = documentationFailure(error); + if (documentation === undefined) { + throw error; + } + // Everything the document rendered: the buffered selection, or what the + // streaming loop emitted together with that tail. + const rendered = + selected.length > 0 ? selected.map(renderSegment).join("") : streamed.join("") + tail; + yield* ephemeral(rememberLiveFailure(error)); + return { status: "err", output: rendered, error: describeFailure(error, documentation) }; + } } /** @@ -526,6 +773,11 @@ function* executeDocument(options: ExecuteOptions): Operation *stderr() {}, }); + // The slot this run's completion reads its failure from. Created here and + // reclaimed with this task, so nothing a run decided outlives it. + const liveFailure: LiveFailureSlot = {}; + yield* LiveFailure.set(liveFailure); + // Create per-document eval scope (spec §3.1). // Created in the same scope as durableRun so that DurableCtx // (set by durableRun) is visible to eval code that calls @@ -557,20 +809,27 @@ function* executeDocument(options: ExecuteOptions): Operation { at: "min" }, ); - const { output, value } = yield* durableRun(() => Execution.operations.document(props), { - stream, - }); - - // Preserve output for any synchronous completion path that did not emit - // through the streaming API — a replayed run restores its body text from - // the journal instead of re-executing, and callback consumers only ever - // see chunks, never the close value. - if (!emitted && output) { - yield* DocumentOutput.operations.output(output); + const returned = yield* durableRun(() => Execution.operations.document(props), { stream }); + // Taken rather than read, so the handoff belongs to the run that made it. + const live = yield* takeLiveFailure(liveFailure); + const result = parseDocumentResult(returned); + + // Preserve output for any completion path that did not emit through the + // streaming API — a replayed run restores its body text from the journal + // instead of re-executing, and callback consumers only ever see chunks, + // never the close value. A failed document takes the same path, so what + // it rendered first reaches consumers before its failure does. + if (!emitted && result.output) { + yield* DocumentOutput.operations.output(result.output); } - yield* channel.close(output); - resolve(Ok(value)); + yield* channel.close(result.output); + if (result.status === "err") { + const failure = failureError(result.error, live); + resolve(Err(failure instanceof Error ? failure : new Error(String(failure)))); + return; + } + resolve(Ok(result.value)); } catch (error) { // Close with everything already emitted — printed errors produced before // an abort stay visible to consumers of the close value. diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 888e32c5..d96601c2 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -45,13 +45,13 @@ import { import { attributeCause, ContentError, + decidedByOutput, DocumentationError, durabilityFailure, ErrorMode, fatalCause, - settle, } from "./errors.ts"; -import { printsErrors, useFailurePrinting } from "./component-failures.ts"; +import { printsErrors, usePrintErrors } from "./component-failures.ts"; import { withInvocation } from "./invocation.ts"; import type { Invocation } from "./invocation.ts"; import { ActiveProjection } from "./projection.ts"; @@ -159,13 +159,15 @@ function expandChildrenScoped( props: Record, hideSet: Set, counter: BlockCounter, + /** Where this expansion accumulates — its caller's region, or a private buffer. */ + owner: Segment[], ): Operation { return scoped(function* () { yield* provideEnv({ values: { ...(callerEnv?.values ?? {}), ...(override ?? {}) } }); if (scope) { yield* provideEvalScope(scope); } - return yield* expandSegments(segments, meta, props, hideSet, counter); + return yield* expandSegments(segments, meta, props, hideSet, counter, owner); }); } @@ -246,6 +248,13 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { inner: ProjectionHandle | undefined; loop: LoopFrame | undefined; errors: Segment[]; + /** + * The caller's region, when this projection renders into one. Structural + * `` passes it, so a failure partway leaves the projected prefix + * with the document. A string projection passes none: it produces a value, + * and a value is not output until it is complete. + */ + owner?: Segment[]; }): Operation { return yield* scoped(function* () { const contentScope = yield* state.invocation.useContentScope(); @@ -255,8 +264,9 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { // replacing the documentation failure the caller is meant to see. const outcome = withResolvers<{ segments: Segment[]; failure?: unknown }>(); // Shared with the expansion below, so a failure still leaves behind what it - // rendered before stopping. - const rendered: Segment[] = []; + // rendered before stopping. When the caller owns a region, that array is + // the region itself and the prefix is already where the document needs it. + const rendered: Segment[] = options.owner ?? []; const task = contentScope.scope.run(function* () { try { yield* ErrorMode.set(options.mode); @@ -288,7 +298,9 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { if (result.failure !== undefined) { throw result.failure; } - return [...options.errors, ...result.segments]; + // A projection that wrote into the caller's region has nothing left to + // hand back; one that kept its own returns what it rendered. + return options.owner === undefined ? [...options.errors, ...result.segments] : options.errors; }); } @@ -384,6 +396,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { meta: Record, props: Record, hideSet: Set, + owner: Segment[], ): Operation { // Slots were resolved during substitution, so the environment, meta, // props and hide set are the body's own — only the resource scope moves. @@ -400,6 +413,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { inner: state.enclosing, loop: state.callerLoop, errors: [], + owner, }); }, project: runProjection, @@ -466,14 +480,19 @@ export function* expandSegments( hideSet: Set, counter: BlockCounter = createBlockCounter(), /** - * Where to accumulate, when the caller wants what was rendered even if this - * does not finish. Expansion appends as it goes, so a caller holding the same - * array still has everything produced before a failure — which is how a - * `` keeps its output when its body stops partway. + * The output owner: the accumulator belonging to the region whose text + * renders into the document. Expansion appends as it goes, so a caller + * holding the same array still has everything produced before a failure — + * which is how a failing `` region keeps what it rendered first, and + * how a `` keeps its output when its body stops partway. + * + * A call site that produces a binding, a value, or a string passes nothing. + * Its buffer is private and never merges into an owner, so a failure cannot + * promote content the document was not going to render (§6.9). */ - printedErrors?: Segment[], + owner?: Segment[], ): Operation { - const result: Segment[] = printedErrors ?? []; + const result: Segment[] = owner ?? []; // Read once: `` publishes its frame for the nested call that expands // its body, so the frame ambient here cannot change while this list runs. const loop = yield* ActiveLoop.get(); @@ -506,9 +525,7 @@ export function* expandSegments( // the ambient error mode, so they are appended as they are. const projection = yield* ActiveProjection.get(); if (projection && projection.claims(segment)) { - result.push( - ...(yield* projection.expandClaimed(segment, parentMeta, parentProps, hideSet)), - ); + yield* projection.expandClaimed(segment, parentMeta, parentProps, hideSet, result); break; } } @@ -543,14 +560,18 @@ export function* expandSegments( if (segment.name === "Each") { // Same as : expandEach reports its own errors and hands the // body's back untouched (§6.9). - result.push(...(yield* expandEach(segment, parentMeta, parentProps, hideSet, counter))); + result.push( + ...(yield* expandEach(segment, parentMeta, parentProps, hideSet, counter, result)), + ); break; } if (segment.name === "If") { // No raise() here, like the branches above: expandIf reports the // errors it creates, and the selected branch settled its own (§6.9). - result.push(...(yield* expandIf(segment, parentMeta, parentProps, hideSet, counter))); + // It renders into this expansion's output, so it writes into the owner + // rather than handing segments back to be appended. + yield* expandIf(segment, parentMeta, parentProps, hideSet, counter, result); break; } @@ -566,16 +587,14 @@ export function* expandSegments( if (segment.name === "Loop") { // No raise() here, for the same reason as : expandLoop reports // the errors it creates, and the body settled its own (§6.9). - result.push(...(yield* expandLoop(segment, parentMeta, parentProps, hideSet, counter))); + yield* expandLoop(segment, parentMeta, parentProps, hideSet, counter, result); break; } if (segment.name === "PrintErrors") { // No raise() here, like the branches above: expandPrintErrors // reports the errors it creates, and the body settled its own (§6.9). - result.push( - ...(yield* expandPrintErrors(segment, parentMeta, parentProps, hideSet, counter)), - ); + yield* expandPrintErrors(segment, parentMeta, parentProps, hideSet, counter, result); break; } @@ -586,8 +605,11 @@ export function* expandSegments( // matcher's template children — so it is handed this expansion's // recursion to render them with. result.push( - ...(yield* expandAnswers(segment, (inner) => - expandSegments(inner, parentMeta, parentProps, hideSet, counter), + ...(yield* expandAnswers( + segment, + (inner, into) => + expandSegments(inner, parentMeta, parentProps, hideSet, counter, into), + result, )), ); break; @@ -627,18 +649,14 @@ export function* expandSegments( segment.position, parentMeta, parentProps, + result, ); - // Consumer boundary: the callee reported these where they were created, - // under whatever error mode its body ran — an `` region prints, - // documentation throws. Settling them here applies this caller's error mode - // without reporting them a second time (spec §6.9). - for (const expandedSegment of expanded) { - if (expandedSegment.type === "error") { - result.push(yield* settle(expandedSegment)); - } else { - result.push(expandedSegment); - } - } + // A printed error the callee produced is data, and stays data here: it + // was decided once, where it was raised, under the error mode governing + // the region that raised it (§6.9). A rendering invocation wrote it + // straight into this owner; anything handed back — a binding the callee + // refused, an error about the invocation itself — is appended as it is. + result.push(...expanded); break; } @@ -861,6 +879,8 @@ function* expandEach( parentProps: Record, hideSet: Set, counter: BlockCounter, + /** The region a rendering iteration writes into; a captured one keeps its own. */ + owner: Segment[], ): Operation { const unknownProp = [...Object.keys(segment.props), ...Object.keys(segment.expressions)].find( (n) => !EACH_PROPS.has(n), @@ -929,9 +949,12 @@ function* expandEach( const parentEvalScope = yield* evalScope; const enclosingLoop = yield* ActiveLoop.get(); - const out: Segment[] = []; + // A rendering iteration writes into the caller's region as it goes, so a + // failure partway leaves the items it already produced behind. A captured one + // builds a value instead: its buffer is private and never becomes output. + const out: Segment[] = asBinding === undefined ? owner : []; for (const item of items) { - const expanded = yield* expandChildrenScoped( + yield* expandChildrenScoped( segment.children, callerEnv ?? undefined, { [name]: item }, @@ -940,8 +963,8 @@ function* expandEach( parentProps, hideSet, counter, + out, ); - out.push(...expanded); // A `` in the body exits the enclosing ``, so the remaining // items are part of the work that iteration no longer does. if (enclosingLoop?.broken) { @@ -950,7 +973,7 @@ function* expandEach( } if (asBinding === undefined) { - return out; + return []; } // A capture never swallows an error. The body reported these where they were @@ -1179,25 +1202,27 @@ function* expandIf( parentProps: Record, hideSet: Set, counter: BlockCounter, -): Operation { + /** The region this renders into: the selected branch writes there directly. */ + owner: Segment[], +): Operation { const unknownProp = [...Object.keys(segment.props), ...Object.keys(segment.expressions)].find( (name) => !IF_PROPS.has(name), ); if (unknownProp !== undefined) { - return [ + owner.push( yield* raise( ifError(segment, ` only accepts a "condition" prop. Got: "${unknownProp}".`), ), - ]; + ); + return; } const structure = ifStructure(segment); if (structure.violations.length > 0) { - const reported: Segment[] = []; for (const violation of structure.violations) { - reported.push(yield* raise(violation)); + owner.push(yield* raise(violation)); } - return reported; + return; } let condition: Json; @@ -1213,16 +1238,18 @@ function* expandIf( ); condition = resolved.condition; } catch (error) { - return [ + owner.push( yield* raise(ifError(segment, error instanceof Error ? error.message : String(error))), - ]; + ); + return; } } else { - return [yield* raise(ifError(segment, ' requires a "condition" prop (a boolean).'))]; + owner.push(yield* raise(ifError(segment, ' requires a "condition" prop (a boolean).'))); + return; } if (typeof condition !== "boolean") { - return [ + owner.push( yield* raise( ifError( segment, @@ -1230,15 +1257,17 @@ function* expandIf( " does not coerce truthy or falsy values.", ), ), - ]; + ); + return; } - return yield* expandSegments( + yield* expandSegments( condition ? structure.whenTrue : structure.whenFalse, parentMeta, parentProps, hideSet, counter, + owner, ); } @@ -1334,31 +1363,39 @@ function* expandLoop( parentProps: Record, hideSet: Set, counter: BlockCounter, -): Operation { + /** The region this renders into: each iteration writes there as it runs. */ + owner: Segment[], +): Operation { const unknownProp = [...Object.keys(segment.props), ...Object.keys(segment.expressions)].find( (name) => !LOOP_PROPS.has(name), ); if (unknownProp !== undefined) { - return [ + owner.push( yield* raise( loopError(segment, ` only accepts "max" and "name" props. Got: "${unknownProp}".`), ), - ]; + ); + return; } if ("name" in segment.expressions) { - return [yield* raise(loopError(segment, 'Prop "name" on must be a string literal.'))]; + owner.push( + yield* raise(loopError(segment, 'Prop "name" on must be a string literal.')), + ); + return; } const name = segment.props.name; if (name !== undefined && (typeof name !== "string" || name.length === 0)) { - return [ + owner.push( yield* raise(loopError(segment, 'Prop "name" on must be a non-empty string.')), - ]; + ); + return; } const bound = yield* loopBound(segment); if (!bound.ok) { - return [yield* raise(loopError(segment, bound.error.message))]; + owner.push(yield* raise(loopError(segment, bound.error.message))); + return; } // Taken from the shared block counter, so every `` an execution enters @@ -1367,7 +1404,6 @@ function* expandLoop( const identity: LoopIdentity = { id: counter.next(), ...(name === undefined ? {} : { name }) }; const frame: LoopFrame = { broken: false }; - const out: Segment[] = []; let started = 0; try { @@ -1376,9 +1412,7 @@ function* expandLoop( for (let iteration = 0; iteration < bound.value; iteration++) { yield* recordIteration(identity, iteration); started = iteration + 1; - out.push( - ...(yield* expandSegments(segment.children, parentMeta, parentProps, hideSet, counter)), - ); + yield* expandSegments(segment.children, parentMeta, parentProps, hideSet, counter, owner); if (frame.broken) { break; } @@ -1418,7 +1452,6 @@ function* expandLoop( const outcome: LoopOutcome = frame.broken ? "break" : "exhausted"; yield* recordOutcome(identity, { iterations: started, outcome }); - return out; } function breakElementViolations(segment: ComponentElement): string[] { @@ -1497,19 +1530,29 @@ function* expandPrintErrors( parentProps: Record, hideSet: Set, counter: BlockCounter, -): Operation { + /** The region this renders into: it writes there rather than returning. */ + owner: Segment[], +): Operation { const names = [...Object.keys(segment.props), ...Object.keys(segment.expressions)]; if (names.length > 0) { - return [ + owner.push( yield* raise( printErrorsPropError(segment, ` accepts no props. Got: "${names[0]}".`), ), - ]; + ); + return; } - return yield* scoped(function* () { - yield* useFailurePrinting(); - return yield* expandSegments(segment.children, parentMeta, parentProps, hideSet, counter); + yield* scoped(function* () { + yield* usePrintErrors(); + return yield* expandSegments( + segment.children, + parentMeta, + parentProps, + hideSet, + counter, + owner, + ); }); } @@ -1526,6 +1569,13 @@ function* expandComponent( /** The invoking frame's meta and props, for content this element projects. */ callerMeta: Record = {}, callerProps: Record = {}, + /** + * The caller's output owner, when this invocation renders into it. An + * invocation captured with `as` produces a binding rather than output and + * passes none, so what its body rendered before failing stays out of the + * document (§6.9). + */ + owner?: Segment[], ): Operation { // Cycle detection — Prosser's algorithm if (hideSet.has(name)) { @@ -1812,6 +1862,7 @@ function* expandComponent( return []; } + const bodyOwner = asBinding === undefined ? owner : undefined; const expanded = yield* withInvocation(function* (invocation) { yield* installInvocation(invocation); return yield* expandBody( @@ -1823,6 +1874,7 @@ function* expandComponent( counter, callerEvalEnv ?? undefined, claimProjection, + bodyOwner, ); }); @@ -1854,7 +1906,9 @@ function* expandComponent( return []; } - return expanded; + // A rendering body already wrote into the owner, so there is nothing left to + // hand back; one that kept its own returns what it rendered. + return bodyOwner === undefined ? expanded : []; } // Without `returns`, a function component's rendering is its return value, so @@ -2214,7 +2268,22 @@ function* expandFunctionComponent( // reporting it again here would double-observe it. if (error instanceof ContentExpansionFailure) { if (error.cause instanceof DocumentationError) { - throw error.cause; + // A `throw` decision is final: the region is hidden, so no printing + // boundary may undo it. An `output` decision leaves an ordinary + // propagating failure — the region already stopped, and printing what + // left it is what a boundary is for. The region's own failure travels + // on, not the segments it transported: reporting those again would + // print an error the region already decided about. + if (!decidedByOutput(error.cause)) { + throw error.cause; + } + return [ + yield* handleFailure({ + name, + ...(metadata.position === undefined ? {} : { position: metadata.position }), + error: error.cause, + }), + ]; } return [...error.errors]; } @@ -2244,7 +2313,7 @@ function* expandFunctionComponent( // decide the same for its siblings. if (printsErrors(definition.fn)) { return yield* scoped(function* () { - yield* useFailurePrinting(); + yield* usePrintErrors(); return yield* invoke(); }); } @@ -2642,6 +2711,13 @@ interface BodyChunk { /** true = a rendered `` region; false = documentation (executed, not rendered). */ output: boolean; segments: Segment[]; + /** + * An error about the region declaration itself rather than about work inside + * one. The region never opened, so the mode it would have installed has + * nothing to say about it: the enclosing mode decides, which is how a + * mistyped `` stays a printed error in a document that prints. + */ + declaration?: boolean; } function isTopLevelOutput(segment: Segment): boolean { @@ -2904,7 +2980,7 @@ function buildBody( if (segment.type === "component" && segment.name === "Output") { const propsError = validateOutputProps(segment); if (propsError) { - chunks.push({ output: true, segments: [propsError] }); + chunks.push({ output: true, segments: [propsError], declaration: true }); continue; } const outputSegments = substituteSegmentList( @@ -2930,12 +3006,16 @@ function buildBody( /** * 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 - * error mode (fail-fast) and its rendered result is discarded; output - * regions set a printing error mode of their own, so their errors render as - * comments; the caller settles them again on the way out. + * regions render; documentation executes for its side effects under `throw` and + * its rendered result is discarded; output regions install `output`, so an + * undecided error in a region fails the run and nothing after it — the rest of + * the region, later regions, later documentation — begins. * Regions and documentation run in document order, so output can depend on * bindings computed by preceding documentation. + * + * A failing region keeps what it rendered: every region writes into the owner + * as it goes, so the caller is already holding the prefix when the failure + * reaches it (§6.9 Partial output). */ export function* expandBody( bodySegments: Segment[], @@ -2946,22 +3026,30 @@ export function* expandBody( counter: BlockCounter, callerEnv: EvalEnv | undefined, claim: ClaimFn = passthroughClaim, + /** + * Where this body renders. A body that renders into the document shares its + * caller's owner, so a region that fails partway has already handed over what + * it produced; an invocation captured with `as` produces a binding rather + * than output and passes none. + */ + owner?: Segment[], ): Operation { if (!bodyHasOutput(bodySegments)) { const substituted = substituteContent(bodySegments, children, meta, props, callerEnv, claim); - return yield* expandSegments(substituted, meta, props, hideSet, counter); + return yield* expandSegments(substituted, meta, props, hideSet, counter, owner); } const chunks = buildBody(bodySegments, children, meta, props, callerEnv, claim); - const output: Segment[] = []; + const output: Segment[] = owner ?? []; for (const chunk of chunks) { - if (chunk.output) { - const expanded = yield* scoped(function* () { - yield* ErrorMode.set("print"); - return yield* expandSegments(chunk.segments, meta, props, hideSet, counter); + if (chunk.declaration) { + yield* expandSegments(chunk.segments, meta, props, hideSet, counter, output); + } else if (chunk.output) { + yield* scoped(function* () { + yield* ErrorMode.set("output"); + return yield* expandSegments(chunk.segments, meta, props, hideSet, counter, output); }); - output.push(...expanded); } else { // Documentation: execute for side effects, discard rendered output. yield* scoped(function* () { diff --git a/packages/core/src/projection.ts b/packages/core/src/projection.ts index 715762da..85e9dd37 100644 --- a/packages/core/src/projection.ts +++ b/packages/core/src/projection.ts @@ -49,12 +49,17 @@ export interface ProjectionHandle { */ claim(element: ComponentElement): ComponentElement; claims(element: ComponentElement): boolean; - /** Expand a claimed element's children inside the content scope. */ + /** + * Expand a claimed element's children inside the content scope, writing into + * the region the caller is rendering. Projected content is the caller's own + * text, so what it produced before a failure belongs to that region. + */ expandClaimed( element: ComponentElement, meta: Record, props: Record, hideSet: Set, + owner: Segment[], ): Operation; /** Structured result — ErrorSegments stay identifiable to the caller. */ project(request: ProjectionRequest): Operation; diff --git a/packages/core/tests/eval-error-mode.test.ts b/packages/core/tests/eval-error-mode.test.ts index 2c1e0cf1..c57dd6c4 100644 --- a/packages/core/tests/eval-error-mode.test.ts +++ b/packages/core/tests/eval-error-mode.test.ts @@ -73,8 +73,9 @@ describe("Tier O — Eval scope hierarchy", () => { expect(String(failure)).toContain("Missing"); }); - // O29: the same projection inside prints, and the region emits. - it("O29: Markdown inside prints a projected error", function* () { + // O29: the same projection inside carries the region's error mode, + // so the projected error fails the region and nothing after it renders. + it("O29: Markdown inside fails the region on a projected error", function* () { const stream = new InMemoryStream(); yield* useStubFs({ "components/Wrap.md": ["", "", "", "done", ""].join("\n"), @@ -82,12 +83,14 @@ describe("Tier O — Eval scope hierarchy", () => { }); yield* useEchoExec(); - const output = yield* collect(yield* execute({ path: "doc.md", stream })); + let failure: unknown; + try { + yield* collect(yield* execute({ path: "doc.md", stream })); + } catch (error) { + failure = error; + } - expect(output).toContain("ERROR"); - expect(output).toContain("done"); - // Reported once on the way out, not again when it crosses back. - expect(String(output).split("Cannot resolve component: Missing").length - 1).toBe(1); + expect(String(failure)).toContain("Missing"); }); // O30: value-component documentation carries the same error mode, so a claimed @@ -207,9 +210,9 @@ describe("Tier O — Eval scope hierarchy", () => { expect(output).not.toContain("ERROR"); }); - // O24: the same block inside an region prints instead, so the - // projected error renders as a comment and the region still emits. - it("O24: a persistent projection inside settles under the printing error mode", function* () { + // O24: the same block inside an region settles under `output`, so + // the projected error fails the run rather than rendering as a comment. + it("O24: a persistent projection inside settles under the output error mode", function* () { const stream = new InMemoryStream(); yield* useStubFs({ "components/Wrap.md": ["", ...PROJECTING_BLOCK, ""].join("\n"), @@ -217,9 +220,14 @@ describe("Tier O — Eval scope hierarchy", () => { }); yield* useEchoExec(); - const output = yield* collect(yield* execute({ path: "doc.md", stream })); + let failure: unknown; + try { + yield* collect(yield* execute({ path: "doc.md", stream })); + } catch (error) { + failure = error; + } - expect(output).toContain("ERROR"); + expect(String(failure)).toContain("Missing"); }); // O31: the same printing projection, captured — the string rendered the @@ -228,7 +236,13 @@ describe("Tier O — Eval scope hierarchy", () => { it("O31: a captured markdown projection refuses the binding on a projected error", function* () { const stream = new InMemoryStream(); yield* useStubFs({ - "components/Wrap.md": ["", ...PROJECTING_BLOCK, ""].join("\n"), + "components/Wrap.md": [ + "", + "", + ...PROJECTING_BLOCK, + "", + "", + ].join("\n"), "doc.md": '\n\n\n\nvalue:{cap}:end', }); yield* useEchoExec(); diff --git a/packages/core/tests/execute.test.ts b/packages/core/tests/execute.test.ts index 58d45a81..b392049e 100644 --- a/packages/core/tests/execute.test.ts +++ b/packages/core/tests/execute.test.ts @@ -1248,7 +1248,7 @@ describe("component-declared output — document workflow", () => { expect(chunks).toHaveLength(1); }); - it("emits no partial output when documentation fails in a buffered root", function* () { + it("emits what a buffered root selected before its documentation failed", function* () { const stream = new InMemoryStream(); yield* useStubFs({ "README.md": "\nSELECTED\n\n\n```bash exec\nfailing-command\n```\n", @@ -1263,7 +1263,10 @@ describe("component-declared output — document workflow", () => { const result = yield* execution; expect(result.ok).toBe(false); - expect(chunks.join("")).not.toContain("SELECTED"); + // The region completed before the documentation ran, so its text is part + // of what this run rendered — and a run that fails still hands over what it + // rendered (§6.9 Partial output). + expect(chunks.join("")).toContain("SELECTED"); }); it("keeps per-segment streaming for roots without ", function* () { diff --git a/packages/core/tests/expand.test.ts b/packages/core/tests/expand.test.ts index 2508a40d..36f0ac28 100644 --- a/packages/core/tests/expand.test.ts +++ b/packages/core/tests/expand.test.ts @@ -652,12 +652,29 @@ describe("component-declared output", () => { expect(output).toContain("ok"); }); - it("keeps errors inside an region as comments", function* () { - const comp = makeComponent("Err", "\n\n"); + it("fails on an error inside an region", function* () { + const comp = makeComponent("Err", "\nbefore\n\nafter\n"); + const ctx = { Err: comp }; + let threw = false; + try { + yield* expand(scanSegments(""), ctx); + } catch { + threw = true; + } + expect(threw).toBe(true); + }); + + it("keeps an error inside an region as a comment under ", function* () { + const comp = makeComponent( + "Err", + "\n\n\n\nafter\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. - -**Reporting and settling are separate.** `Component.raise` is where an error is +- An error produced while rendering an output region fails the run. Nothing + after it begins: not the rest of the region, not a later region, not the + documentation between them. A body that declares no `` is unaffected — + it runs under whatever mode encloses it, and at a root that is `print`. +- A root containing `` buffers its selection and emits it once. A run + that fails emits what its regions rendered before the failure, and an empty + selection emits nothing. + +Each construct installs one mode for its own region, and the nearest one governs +(§6.9 Error modes): + +| Region | Mode | +| --- | --- | +| the root, and any body with no `` | inherited; `print` at a root | +| an `` region | `output` | +| documentation, and a value root | `throw` | +| a `` region, or a `printErrors(fn)` invocation | `print`, except over `throw` | + +Because a mode is read from the enclosing structure, wrapping a printing +boundary around a component whose own body declares `` changes nothing +inside that component: its region installs `output` for itself, stops at its +failure, and the boundary prints the failure that left it rather than resuming +it. A region's author gates what follows a failure behind it, and no caller +undoes that gate. + +**Reporting and deciding are separate.** `Component.raise` is where an error is reported: its middleware chain observes each `ErrorSegment` once, where the segment is created, which is what lets instrumentation and `` count -failures. Its default implementation then *settles* the segment under the -ambient error mode — printed for rendering, or thrown as a documentation failure. -A documentation chunk and an `` region select the error mode by value rather -than by installing reporting middleware, so an error crossing from a component's -own error mode into its caller's is settled again without being reported twice. +failures. Its default implementation then *decides* the segment under the +ambient error mode — printed into the document, or thrown as a failure. +A documentation chunk and an `` region select the error mode by value +rather than by installing reporting middleware, so what an error becomes depends +only on where it was raised. **Whoever creates an `ErrorSegment` reports it.** `Component.raise` is called at the point the failure is decided, and a printed error that reaches the document without that call never passes the observation chain — middleware that counts, logs, or forwards failures never sees it. -**Every path reports once.** The rule is the same wherever segments cross a -construct: ``, ``, ``, ``, `` and `` report -the errors they create and hand a body's segments back untouched, because those -ran under the same error mode. A component invocation is the one boundary that -settles rather than appends — its body may have run under an error mode of its own — -and settling applies the caller's error mode without a second observation. So a -failing element reports exactly once wherever it is written: inline, in a -selected branch, in an iteration, inside a capture, in a component body, or -projected into a ``. - -**Appending and settling stay distinct.** The difference is not cosmetic. -`` and `` expand inside the caller's own error mode frame, so their -transported errors have already settled there and are appended as they are. A -component invocation settles instead, because its body may have run inside an -inner `` printing frame or a documentation throw frame; appending at -the caller would let a printed inner error slip past the caller's fail-fast -error mode. `content()` adds an *inner* boundary to a function component's own -control flow and does not replace that consumer boundary: the content provider -projects structured segments, presents `ContentError` at the `content()` call, -and — if the component does not recover — hands the original segments back as the -invocation's result under `print` or restores the original `DocumentationError` -under `throw`. The invocation's consumer then settles under its own ambient -error mode, without reporting anything a second time. - -Observation counts cannot catch a regression here, because appending and -settling can both observe exactly once. The guards are the consumer-boundary -tests in `packages/core/tests/expand.test.ts`: a child's `` error -consumed from parent documentation throws, the same error consumed inside a -parent `` renders as one printed comment, and a captured child -`` error throws before the `as` binding is stored. A function component -whose content prints an error in one error mode frame and is consumed by a -throwing parent frame belongs to that same set, and throws rather than appending. +**Every path reports once, and decides once.** The rule is the same wherever +segments cross a construct: ``, ``, ``, ``, ``, +`` and a component invocation report the errors they create and hand a +body's segments on untouched. So a failing element is reported exactly once +wherever it is written: inline, in a selected branch, in an iteration, inside a +capture, in a component body, or projected into a ``. + +**A printed error crosses an invocation as data.** A printed error was decided +where it was raised, under the mode governing that region, and nothing decides +it again — including the consumer that reads it. A child that printed an error +inside a `` region of its own hands its caller a document +containing that error; a parent whose documentation reads it neither stops the +rest of the child's rendering nor fails. A *failure* is the other half of the +same rule: uncaptured, it propagates out of the child and the parent does stop. +`content()` adds an inner boundary to a function component's own control flow — +the content provider projects structured segments and presents `ContentError` at +the `content()` call — and a component that does not recover is replaced by what +the projection already reported, under `print`, or by the failure a `throw` or +`output` decision already made. + +#### Partial output + +A failing region keeps what it rendered. Everything rendered before the failure +stays in the document and reaches the output stream, not only the journal — +including when an earlier segment already streamed, so a consumer reading chunks +sees the prefix before it sees the failure. + +Only work the document was going to render can reach the output. Expansion +writes into the accumulator its caller gave it, and a call site producing +something other than document text passes none: a binding (`as=`, ``, +``), a value component's return, a string projection +(`renderChildren`, `render`, `useContent`), and documentation each keep a +private buffer. A failure part-way through one of those adds nothing to the +document. #### 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. +`` is buffered — executed to completion, then emitted once — while a +root without `` keeps per-segment streaming. Buffering defers only when +output is emitted, not what executes, so replay is deterministic. + +#### Outcomes and the journal + +A run that fails is still a complete record. The document's workflow returns its +outcome — the rendered output together with a description of the failure — and +the root closes `ok` around it, so replaying restores both halves without +re-entering the workflow and without re-executing anything. + +What crosses the journal is data, not objects. The record holds the failure's +name and message, the message and source of the segment that failed, its own +`cause` as text when it had one, and its aggregate members when it was an +`AggregateError`. A field is absent when the failure had nothing to say there, +which is what keeps a failure with no cause distinct from one whose cause was +the value `undefined`. A live run therefore reports the error it actually +caught, by identity; a replayed run reports the reconstruction its record +describes. + +The record is parsed, never trusted. A shape this version cannot read — a name +that is not text, a segment with no message, aggregate members that are not a +list, a journal written before this contract — is refused with a message naming +the situation, rather than coerced into a failure that quietly disagrees with +the one recorded. + +Only a durability failure (§6.11) escapes this. It says the journal no longer +describes the run, so it is never recorded as the run's own outcome. ### 6.10 Component return values: `returns` and `` @@ -4349,7 +4397,7 @@ directory this run created and the reason, and that **ends the execution**: the `DocumentExecution` completes `Err`, nothing after the component runs, and the document must be re-run from the beginning. -The refusal does not settle under the ambient error mode (§6.9). An error mode +The refusal is not decided under the ambient error mode (§6.9). An error mode that prints would turn a durability failure into a comment and let later siblings run on top of work that never happened, so `StaleInputError` joins `DocumentationError` as an error the engine's generic catches rethrow rather @@ -4572,8 +4620,9 @@ be written into the file instead. It therefore fails the invocation rather than writing, and carries the underlying messages in its own printed error — which is the only place a reader would otherwise learn what went wrong. -Under fail-fast the reported failure is ``'s as well: the write is what the -document asked for, and that it did not happen is the fact a reader needs. The +When the failure propagates, the reported failure is ``'s as well: the +write is what the document asked for, and that it did not happen is the fact a +reader needs. The general rule then applies (§5.1.2) — the `DocumentationError` keeps ``'s own error as its cause, and the content failure that error was translated from stays reachable beneath it, carrying the same error segments the document reported — so @@ -5871,7 +5920,7 @@ visible warning blocks, gather into a separate error report). | 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 printed error and runs no body side effects | | C42 | Caller-projected `` inert | Projecting `` through `` neither activates nor alters the callee's error mode | -| 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 | +| C43 | Documentation and region failures | A failure in documentation (direct, inside ``, inside a nested component, or a transported error) throws; a modifier-handled failure continues; an error inside `` fails the run, and one under `` or in a body with no `` stays a comment | | C44 | **Array element-type mismatch** | `files` is `{ type: array, items: { type: string } }`; passing `["a", 3]` → PropValidationError | | C45 | **Object-shape rejected** | A nested object with `required: [symbol]` / `additionalProperties: false` rejects a missing `symbol` or an unknown key → PropValidationError | | C46 | **Nested default filled** | A row omitting `line` (declared `{ type: number, default: 0 }`) resolves with `line` set to `0` | @@ -5920,7 +5969,7 @@ visible warning blocks, gather into a separate error report). | 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 | +| E12 | Root `` buffering | A root with `` emits once; a later documentation failure still emits what the regions selected before it; an empty selection emits no event; replay reproduces the result | | E13 | `` inside `` (smoke) | `smoke-test/OutputDemo.md` renders the conditionally-selected region (its `condition` binding computed by preceding documentation eval) while its documentation prose does not appear | ### Tier F — Markdown healing (remend) @@ -6248,6 +6297,25 @@ visible warning blocks, gather into a separate error report). | FA18 | Precedence behind a content failure | A durability failure beneath a recovered content failure outranks a documentation failure, in either wrapper order | | FA19 | Cycles through a content failure | A self-caused content failure and one whose cause points back at the wrapper holding it both terminate, and the durability failure is still found | +### Tier OM — The `output` error mode + +| # | Test | Verify | +|---|------|--------| +| OM1–OM2 | A region fails the run | A root region and a component region each emit what they rendered first, fail, and start nothing after the failure | +| OM3 | A command that printed before it failed | The stdout stays visible and the document stops there (#307/#310) | +| OM4 | Later regions and documentation | Neither the documentation after a failing region nor the region after that begins | +| OM5a–OM5f | `` | Prints once and the region continues; fails without the boundary; the same for `printErrors(fn)`; `throw` is not overridden; a root without `` still prints | +| OM6–OM6c | The live failure | The original object, a settled printed error's `DocumentationError` with its mode, and a body-plus-teardown aggregate each reach the completion intact | +| OM7 | Replay | The same partial output and failure, with no command run again | +| OM8 | The close | The root closes `ok` around a recorded `err` outcome | +| OM9/OM10 a–e | Every visible producer | ``, ``, ``, projected `` and an answered `` body each keep their prefix on failure and render exactly once on success | +| OM11a–OM11e | Private buffers | A ``, an ``, a string projection, documentation, and a failing `as=` invocation each add nothing to the output | +| OM12a–OM12j | A malformed record | Seven corrupted fields are each refused, the refusal names the situation, a pre-contract journal is named as such, and an intact record replays | +| OM13a–OM13e | What crosses the journal | Absent fields stay absent, a `"undefined"` cause is a cause, and a replay reconstructs an `Error` or an `AggregateError` from the recorded fields | +| OM14–OM16 | Transitivity | ``, ``, and a printing component that does not recover each stop a callee's own region; each still prints what is raised under its own mode | +| OM17 | Chunks, not the close value | A streamed prefix arrives before the failing region's output, and both reach the stream | +| OM18–OM19 | A printed error is data | A child's printed error does not fail a parent's documentation; an uncaptured failure in the same position still propagates | + ### Tier IM — Invocation metadata | # | Test | Verify |