From c0fc47466acb80a1b0fb37696e5efd53f6cbb676 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:44:45 -0400 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=92=A5=20refactor:=20rename=20Collect?= =?UTF-8?q?Failures=20to=20CaptureErrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The construct names an explicit boundary where a document asks to carry on past a failure. "Collect" described the mechanism; "capture errors" says what an author is asking for, and the paired vocabulary now matches: , captureErrors(fn), capturesErrors(), useFailures(). The old tag and export are gone rather than aliased — now resolves like any other unknown component. Mechanical: no behavior changes. --- packages/core/mod.ts | 2 +- packages/core/src/component-failures.ts | 27 +++---- packages/core/src/components/File.ts | 4 +- packages/core/src/components/Glob.ts | 4 +- packages/core/src/components/Parse.ts | 4 +- packages/core/src/components/SafeParse.ts | 4 +- packages/core/src/components/TempDir.ts | 4 +- packages/core/src/expand.ts | 24 +++--- packages/core/src/structural.ts | 2 +- packages/core/tests/capture-errors.test.ts | 8 +- .../tests/construct-error-observation.test.ts | 6 +- ...llection.test.ts => error-capture.test.ts} | 78 +++++++++---------- packages/core/tests/expand.test.ts | 4 +- packages/core/tests/file-component.test.ts | 4 +- .../core/tests/function-components.test.ts | 8 +- packages/core/tests/if.test.ts | 4 +- packages/core/tests/invocation-harness.ts | 4 +- packages/core/tests/loop.test.ts | 8 +- specs/executable-mdx-spec.md | 12 +-- 19 files changed, 103 insertions(+), 108 deletions(-) rename packages/core/tests/{failure-collection.test.ts => error-capture.test.ts} (93%) diff --git a/packages/core/mod.ts b/packages/core/mod.ts index 7a18796a..dafcc89d 100644 --- a/packages/core/mod.ts +++ b/packages/core/mod.ts @@ -141,7 +141,7 @@ export type { ComponentRegistration } from "./src/components/registration.ts"; export { DEFAULT_COMPONENT_DIRS, selectComponent } from "./src/components/select.ts"; export type { SelectOptions } from "./src/components/select.ts"; export { RESERVED_STRUCTURAL } from "./src/structural.ts"; -export { collectFailures } from "./src/component-failures.ts"; +export { captureErrors } from "./src/component-failures.ts"; export { parseMarkdownDefinition } from "./src/definition.ts"; export { compileDataUri, useDataUriCompiler } from "./src/data-uri-compiler.ts"; export { compileTempFile, useTempFileCompiler } from "./src/temp-file-compiler.ts"; diff --git a/packages/core/src/component-failures.ts b/packages/core/src/component-failures.ts index 8aab1549..7af86a65 100644 --- a/packages/core/src/component-failures.ts +++ b/packages/core/src/component-failures.ts @@ -3,13 +3,14 @@ * * 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 `collectFailures()`, or a document says so - * about a region with ``. Both install the same middleware, so - * "the nearest collection boundary handles it" is one rule rather than two. + * component says so about itself with `captureErrors()`, or a document says so + * about a region with ``. Both install the same middleware, so + * "the nearest capture boundary handles it" is one rule rather than two. * - * Collection turns a failure into a diagnostic. It does not decide what happens - * to that diagnostic — the caller's ambient policy still settles it, so under - * documentation a collected failure still stops the document. + * Capture turns a failure into a diagnostic and marks that diagnostic as one a + * document asked for. It does not decide what happens to it — the caller's + * ambient policy still settles it, so a captured failure renders inside an + * `` region and still stops the document under documentation. */ import { Component, raise } from "./component-api.ts"; @@ -23,7 +24,7 @@ import type { Operation } from "effection"; * Identity rather than name: a repository component that happens to share a * registered component's name is a different function and inherits nothing. */ -const collecting = new WeakSet(); +const capturing = new WeakSet(); /** * Continue after this component fails, reporting the failure as a diagnostic. @@ -32,7 +33,7 @@ const collecting = new WeakSet(); * so its identity and type survive: * * ```ts - * export default collectFailures(function* (props) { + * export default captureErrors(function* (props) { * // body, requested content, retained work and teardown are all inside * }); * ``` @@ -41,13 +42,13 @@ const collecting = new WeakSet(); * invocation is being dismantled is collected too, and content the component * projects is inside it. */ -export function collectFailures(component: T): T { - collecting.add(component); +export function captureErrors(component: T): T { + capturing.add(component); return component; } -export function collectsFailures(component: FunctionComponent): boolean { - return collecting.has(component); +export function capturesErrors(component: FunctionComponent): boolean { + return capturing.has(component); } /** @@ -59,7 +60,7 @@ export function collectsFailures(component: FunctionComponent): boolean { * original failure is attributed as the diagnostic's cause, so what the * component actually did remains reachable from the outside. */ -export function useFailureCollection(): Operation { +export function useFailures(): Operation { return Component.around({ *handleFailure([failure], _next): Operation { const segment: ErrorSegment = { diff --git a/packages/core/src/components/File.ts b/packages/core/src/components/File.ts index 2307ea28..66c5e07e 100644 --- a/packages/core/src/components/File.ts +++ b/packages/core/src/components/File.ts @@ -61,7 +61,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod import { randomUUID } from "node:crypto"; import { ensure, scoped } from "effection"; import type { Operation } from "effection"; -import { collectFailures } from "../component-failures.ts"; +import { captureErrors } from "../component-failures.ts"; import { cwd, ensureDir, @@ -121,7 +121,7 @@ function* guard(requested: string, verb: string, operation: Operation): Op } } -export default collectFailures(function* (props: Record): Operation { +export default captureErrors(function* (props: Record): Operation { const requested = String(props.path); const admitted = yield* admissible(requested); diff --git a/packages/core/src/components/Glob.ts b/packages/core/src/components/Glob.ts index 0d93aebf..0226378d 100644 --- a/packages/core/src/components/Glob.ts +++ b/packages/core/src/components/Glob.ts @@ -42,7 +42,7 @@ import { isAbsolute } from "node:path"; import type { Operation } from "effection"; -import { collectFailures } from "../component-failures.ts"; +import { captureErrors } from "../component-failures.ts"; import { cwd, glob, stat } from "@executablemd/runtime"; import type { Json } from "../types.ts"; import { reason } from "./fs-diagnostics.ts"; @@ -74,7 +74,7 @@ export class GlobError extends Error { } } -export default collectFailures(function* (props: Record): Operation { +export default captureErrors(function* (props: Record): Operation { const include = patterns("include", props.include); const exclude = patterns("exclude", props.exclude); diff --git a/packages/core/src/components/Parse.ts b/packages/core/src/components/Parse.ts index 23a52def..3de64e5e 100644 --- a/packages/core/src/components/Parse.ts +++ b/packages/core/src/components/Parse.ts @@ -9,7 +9,7 @@ */ import type { Operation } from "effection"; -import { collectFailures } from "../component-failures.ts"; +import { captureErrors } from "../component-failures.ts"; import { content } from "../component-api.ts"; import type { Json } from "../types.ts"; import { @@ -43,7 +43,7 @@ export const props = { */ export const returns = { $schema: "http://json-schema.org/draft-07/schema#" }; -export default collectFailures(function* (props: Record): Operation { +export default captureErrors(function* (props: Record): Operation { const validate = compileParseSchema("Parse", props.schema); const text = yield* content(); diff --git a/packages/core/src/components/SafeParse.ts b/packages/core/src/components/SafeParse.ts index 7dbc9b01..e4c8d517 100644 --- a/packages/core/src/components/SafeParse.ts +++ b/packages/core/src/components/SafeParse.ts @@ -10,7 +10,7 @@ */ import type { Operation } from "effection"; -import { collectFailures } from "../component-failures.ts"; +import { captureErrors } from "../component-failures.ts"; import { content } from "../component-api.ts"; import type { Json } from "../types.ts"; import { @@ -70,7 +70,7 @@ export const returns = { ], }; -export default collectFailures(function* (props: Record): Operation { +export default captureErrors(function* (props: Record): Operation { const validate = compileParseSchema("SafeParse", props.schema); const text = yield* content(); diff --git a/packages/core/src/components/TempDir.ts b/packages/core/src/components/TempDir.ts index 4d58a1f4..74a52487 100644 --- a/packages/core/src/components/TempDir.ts +++ b/packages/core/src/components/TempDir.ts @@ -10,7 +10,7 @@ import { ensure, resource } from "effection"; import type { Operation } from "effection"; -import { collectFailures } from "../component-failures.ts"; +import { captureErrors } from "../component-failures.ts"; import { rm } from "@effectionx/fs"; import { API } from "@executablemd/runtime"; import { ReplayGuard, StaleInputError } from "@executablemd/durable-streams"; @@ -89,7 +89,7 @@ function refuseReplayInside(directory: string): Operation { }); } -export default collectFailures(function* (): Operation { +export default captureErrors(function* (): Operation { if (yield* hasContent()) { const directory = yield* useTemporaryDirectory(); yield* API.Env.around( diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 698e9041..db1a3bba 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -51,7 +51,7 @@ import { fatalCause, settle, } from "./errors.ts"; -import { collectsFailures, useFailureCollection } from "./component-failures.ts"; +import { capturesErrors, useFailures } from "./component-failures.ts"; import type { ErrorPolicy } from "./errors.ts"; import { withInvocation } from "./invocation.ts"; import type { Invocation } from "./invocation.ts"; @@ -571,11 +571,11 @@ export function* expandSegments( break; } - if (segment.name === "CollectFailures") { - // No raise() here, like the branches above: expandCollectFailures + if (segment.name === "CaptureErrors") { + // No raise() here, like the branches above: expandCaptureErrors // reports the errors it creates, and the body settled its own (§6.9). result.push( - ...(yield* expandCollectFailures(segment, parentMeta, parentProps, hideSet, counter)), + ...(yield* expandCaptureErrors(segment, parentMeta, parentProps, hideSet, counter)), ); break; } @@ -1474,13 +1474,13 @@ function* expandBreak( return reported; } -function collectFailuresError(segment: ComponentElement, message: string): ErrorSegment { - return { type: "error", message: positioned(message, segment), source: "CollectFailures" }; +function captureErrorsError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "CaptureErrors" }; } /** * Continue past ordinary component failures in this region (spec §6.8.1 - * ``). + * ``). * * The body expands as structured segments rather than a rendered string: this * is a region of the caller's document, expanded in the caller's own frame, @@ -1492,7 +1492,7 @@ function collectFailuresError(segment: ComponentElement, message: string): Error * expanded and a prop expression is never evaluated, because the mistake is the * prop being written at all rather than anything its value turns out to be. */ -function* expandCollectFailures( +function* expandCaptureErrors( segment: ComponentElement, parentMeta: Record, parentProps: Record, @@ -1503,13 +1503,13 @@ function* expandCollectFailures( if (names.length > 0) { return [ yield* raise( - collectFailuresError(segment, ` accepts no props. Got: "${names[0]}".`), + captureErrorsError(segment, ` accepts no props. Got: "${names[0]}".`), ), ]; } return yield* scoped(function* () { - yield* useFailureCollection(); + yield* useFailures(); return yield* expandSegments(segment.children, parentMeta, parentProps, hideSet, counter); }); } @@ -2243,9 +2243,9 @@ function* expandFunctionComponent( // projected content inside it, since their scopes descend from this one. // Scoped, so a component that collects its own failures does not quietly // decide the same for its siblings. - if (collectsFailures(definition.fn)) { + if (capturesErrors(definition.fn)) { return yield* scoped(function* () { - yield* useFailureCollection(); + yield* useFailures(); return yield* invoke(); }); } diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts index 603baca1..a555f9e2 100644 --- a/packages/core/src/structural.ts +++ b/packages/core/src/structural.ts @@ -18,7 +18,7 @@ export const RESERVED_STRUCTURAL: ReadonlySet = new Set([ "Else", "Loop", "Break", - "CollectFailures", + "CaptureErrors", "Answers", "Answer", ]); diff --git a/packages/core/tests/capture-errors.test.ts b/packages/core/tests/capture-errors.test.ts index 69b39b96..8d99ba4a 100644 --- a/packages/core/tests/capture-errors.test.ts +++ b/packages/core/tests/capture-errors.test.ts @@ -5,7 +5,7 @@ import type { Operation } from "effection"; import { StaleInputError } from "@executablemd/durable-streams"; import { expandSegments } from "../src/expand.ts"; import { Component, content } from "../src/component-api.ts"; -import { collectFailures } from "../src/component-failures.ts"; +import { captureErrors } from "../src/component-failures.ts"; import { useContent } from "../src/content-context.ts"; import { scanSegments } from "../src/scanner.ts"; import { renderSegments } from "../src/render.ts"; @@ -131,7 +131,7 @@ function recovering( props: OPEN_SCHEMA, // Collects: these assert what a recovered — or unrecovered — failure looks // like once it is a diagnostic the capture can see. - fn: collectFailures(function* () { + fn: captureErrors(function* () { try { const rendered = yield* content(); if (options.boom) { @@ -169,7 +169,7 @@ function forging(name: string, log: Trace, fabricated: ErrorSegment): FunctionCo props: OPEN_SCHEMA, // Collects, because what this asserts is how a fabricated content failure // is treated once it becomes a diagnostic. - fn: collectFailures(function* () { + fn: captureErrors(function* () { yield* recordEntry(log.effects, name); throw new ContentError([fabricated]); }), @@ -203,7 +203,7 @@ function brokenComponent(): FunctionComponentDefinition { name: "Broken", props: OPEN_SCHEMA, // deno-lint-ignore require-yield - fn: collectFailures(function* () { + fn: captureErrors(function* () { seq += 1; throw new Error(`broken ${seq}`); }), diff --git a/packages/core/tests/construct-error-observation.test.ts b/packages/core/tests/construct-error-observation.test.ts index 58dac39f..fb47d592 100644 --- a/packages/core/tests/construct-error-observation.test.ts +++ b/packages/core/tests/construct-error-observation.test.ts @@ -4,7 +4,7 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { expandSegments } from "../src/expand.ts"; import { Component, content } from "../src/component-api.ts"; -import { collectFailures } from "../src/component-failures.ts"; +import { captureErrors } from "../src/component-failures.ts"; import { AmbientErrorPolicy, ContentError, DocumentationError } from "../src/errors.ts"; import { scanSegments } from "../src/scanner.ts"; import { renderSegments } from "../src/render.ts"; @@ -82,7 +82,7 @@ function throwingComponent(name: string, failure: unknown): FunctionComponentDef name, props: OPEN_SCHEMA, // deno-lint-ignore require-yield - fn: collectFailures(function* () { + fn: captureErrors(function* () { throw failure; }), }; @@ -127,7 +127,7 @@ const BROKEN: FunctionComponentDefinition = { name: "Broken", props: OPEN_SCHEMA, // deno-lint-ignore require-yield - fn: collectFailures(function* (props: Record) { + fn: captureErrors(function* (props: Record) { const prop = props.message; throw new Error(typeof prop === "string" ? prop : "broken thing"); }), diff --git a/packages/core/tests/failure-collection.test.ts b/packages/core/tests/error-capture.test.ts similarity index 93% rename from packages/core/tests/failure-collection.test.ts rename to packages/core/tests/error-capture.test.ts index 0bb62160..cd651233 100644 --- a/packages/core/tests/failure-collection.test.ts +++ b/packages/core/tests/error-capture.test.ts @@ -2,8 +2,8 @@ * Tier CF — what a function component's failure means (spec §6.8.1). * * A component that fails fails the operation it is part of. Carrying on is a - * decision: `collectFailures()` for a component that says so about itself, - * `` for a document that says so about a region. These + * decision: `captureErrors()` for a component that says so about itself, + * `` for a document that says so about a region. These * distinguish a *failed* operation from a completed one that happens to contain * a diagnostic — reading the output alone cannot tell those apart — so each case * asserts the outcome, what was observed, and the identity of the failure that @@ -20,7 +20,7 @@ import { mkdtemp, realpath } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Component, content } from "../src/component-api.ts"; -import { collectFailures } from "../src/component-failures.ts"; +import { captureErrors } from "../src/component-failures.ts"; import { registerComponents } from "../src/components/registration.ts"; import type { ComponentRegistration } from "../src/components/registration.ts"; import { AmbientErrorPolicy, ContentError, DocumentationError } from "../src/errors.ts"; @@ -58,7 +58,7 @@ function throwing(name: string, failure: unknown): FunctionComponentDefinition { function collecting(name: string, failure: unknown): FunctionComponentDefinition { return component( name, - collectFailures( + captureErrors( // deno-lint-ignore require-yield function* (): Operation { throw failure; @@ -319,7 +319,7 @@ describe("Tier CF — failing is the default", () => { }); }); -describe("Tier CF — collectFailures(fn)", () => { +describe("Tier CF — captureErrors(fn)", () => { it("CF5: a marked component reports once and lets later work run", function* () { const boom = new Error("boom"); const result = yield* run("\n\nAFTER\n", { Boom: collecting("Boom", boom) }); @@ -346,7 +346,7 @@ describe("Tier CF — collectFailures(fn)", () => { const timeline: string[] = []; const marked = component( "T", - collectFailures(function* (): Operation { + captureErrors(function* (): Operation { yield* ensure(function* () { timeline.push("cleanup"); }); @@ -380,7 +380,7 @@ describe("Tier CF — collectFailures(fn)", () => { const nested = new Error("nested"); const outer = component( "Outer", - collectFailures(function* (): Operation { + captureErrors(function* (): Operation { return yield* content(); }), ); @@ -433,7 +433,7 @@ describe("Tier CF — collectFailures(fn)", () => { const cleanup = new Error("cleanup"); const marked = component( "T", - collectFailures(function* (): Operation { + captureErrors(function* (): Operation { yield* ensure(function* () { throw cleanup; }); @@ -462,7 +462,7 @@ describe("Tier CF — collectFailures(fn)", () => { const marked = () => component( "T", - collectFailures(function* (): Operation { + captureErrors(function* (): Operation { yield* ensure(function* () { throw teardown; }); @@ -509,7 +509,7 @@ describe("Tier CF — collectFailures(fn)", () => { let seen: ComponentFailure | undefined; const marked = component( "T", - collectFailures(function* (): Operation { + captureErrors(function* (): Operation { yield* ensure(function* () { throw cleanup; }); @@ -548,7 +548,7 @@ describe("Tier CF — collectFailures(fn)", () => { const acquired = withResolvers(); const marked = component( "Hang", - collectFailures(function* (): Operation { + captureErrors(function* (): Operation { yield* ensure(function* () { timeline.push("released"); }); @@ -585,10 +585,10 @@ describe("Tier CF — collectFailures(fn)", () => { }); }); -describe("Tier CF — ", () => { +describe("Tier CF — ", () => { it("CF8: it handles a child's failure and continues to the next child", function* () { const result = yield* run( - "\n\n\nSTILL RUNS\n\n\nAFTER\n", + "\n\n\nSTILL RUNS\n\n\nAFTER\n", { Boom: throwing("Boom", new Error("boom")) }, ); @@ -600,7 +600,7 @@ describe("Tier CF — ", () => { it("CF9: it reaches a failure nested inside another component", function* () { const nested = new Error("nested"); - const source = "\n\n\n\n\n\nAFTER\n"; + const source = "\n\n\n\n\n\nAFTER\n"; const definitions = { Outer: projecting("Outer"), Boom: throwing("Boom", nested) }; const result = yield* run(source, definitions); @@ -616,8 +616,8 @@ describe("Tier CF — ", () => { it("CF9b: the nearest of two nested boundaries handles it, and only it", function* () { const boom = new Error("boom"); const result = yield* run( - "\n\n\n\n\nINNER DONE\n" + - "\n\nAFTER\n", + "\n\n\n\n\nINNER DONE\n" + + "\n\nAFTER\n", { Boom: throwing("Boom", boom) }, ); @@ -635,7 +635,7 @@ describe("Tier CF — ", () => { it("CF9c: a marked component inside the element is handled once, by itself", function* () { const boom = new Error("boom"); const result = yield* run( - "\n\n\nSTILL RUNS\n\n\nAFTER\n", + "\n\n\nSTILL RUNS\n\n\nAFTER\n", { Boom: collecting("Boom", boom) }, ); @@ -650,7 +650,7 @@ describe("Tier CF — ", () => { it("CF10: it does not collect a durability failure", function* () { const stale = new StaleInputError("the journal no longer describes this run"); - const result = yield* run("\n\n\n", { + const result = yield* run("\n\n\n", { Boom: throwing("Boom", stale), }); @@ -660,7 +660,7 @@ describe("Tier CF — ", () => { it("CF11: under a throwing policy it reports once and still stops", function* () { const boom = new Error("boom"); const result = yield* run( - "\n\n\n\nAFTER\n", + "\n\n\n\nAFTER\n", { Boom: throwing("Boom", boom) }, { policy: "throw" }, ); @@ -673,7 +673,7 @@ describe("Tier CF — ", () => { }); }); -describe("Tier CF — accepts no props", () => { +describe("Tier CF — accepts no props", () => { /** A component that records having run, so a skipped body is observable. */ function sentinel(ran: string[]): FunctionComponentDefinition { // deno-lint-ignore require-yield @@ -686,13 +686,13 @@ describe("Tier CF — accepts no props", () => { it("CF12: a literal prop is a syntax error and the body does not run", function* () { const ran: string[] = []; const result = yield* run( - '\n\n\n', + '\n\n\n', { Sentinel: sentinel(ran) }, ); expect(result.outcome.ok).toBe(true); expect(result.observed).toHaveLength(1); - expect(result.observed[0].source).toBe("CollectFailures"); + expect(result.observed[0].source).toBe("CaptureErrors"); expect(result.observed[0].message).toContain("accepts no props"); expect(result.observed[0].message).toContain("unexpected"); // No body effect runs after invalid syntax, and nothing of it is rendered. @@ -703,14 +703,14 @@ describe("Tier CF — accepts no props", () => { it("CF13: an expression prop is rejected without ever being evaluated", function* () { const ran: string[] = []; const result = yield* run( - "\n\n\n", + "\n\n\n", { Sentinel: sentinel(ran) }, ); // The mistake is the prop being written at all, so the diagnostic names // that rather than whatever evaluating it would have gone wrong with. expect(result.observed).toHaveLength(1); - expect(result.observed[0].source).toBe("CollectFailures"); + expect(result.observed[0].source).toBe("CaptureErrors"); expect(result.observed[0].message).toContain("accepts no props"); expect(result.observed[0].message).toContain("when"); expect(result.observed[0].message).not.toContain("missing"); @@ -719,21 +719,15 @@ describe("Tier CF — accepts no props", () => { it("CF14: `as` and `slot` are props here, not fields of their own", function* () { const ran: string[] = []; - const bound = yield* run( - '\n\n\n', - { - Sentinel: sentinel(ran), - }, - ); + const bound = yield* run('\n\n\n', { + Sentinel: sentinel(ran), + }); expect(bound.observed).toHaveLength(1); expect(bound.observed[0].message).toContain('Got: "as"'); - const slotted = yield* run( - '\n\n\n', - { - Sentinel: sentinel(ran), - }, - ); + const slotted = yield* run('\n\n\n', { + Sentinel: sentinel(ran), + }); expect(slotted.observed).toHaveLength(1); expect(slotted.observed[0].message).toContain('Got: "slot"'); @@ -743,7 +737,7 @@ describe("Tier CF — accepts no props", () => { it("CF15: the diagnostic is positioned, and reported exactly once", function* () { const ran: string[] = []; const result = yield* run( - 'intro\n\n\n\n\n', + 'intro\n\n\n\n\n', { Sentinel: sentinel(ran) }, { path: "doc.md" }, ); @@ -758,7 +752,7 @@ describe("Tier CF — accepts no props", () => { it("CF16: a valid no-props element still collects and expands its body", function* () { const ran: string[] = []; const result = yield* run( - "\n\n\n\n\nSTILL RUNS\n\n\nAFTER\n", + "\n\n\n\n\nSTILL RUNS\n\n\nAFTER\n", { Sentinel: sentinel(ran), Boom: throwing("Boom", new Error("boom")) }, ); @@ -826,10 +820,10 @@ describe("Tier CF — what a collection boundary is never offered", () => { it("CF19: an uncaught content failure restores its segments without a second report", function* () { const boom = new Error("boom"); - const result = yield* run( - "\n\n\n\n\n", - { Outer: projecting("Outer"), Boom: throwing("Boom", boom) }, - ); + const result = yield* run("\n\n\n\n\n", { + Outer: projecting("Outer"), + Boom: throwing("Boom", boom), + }); // One observation, for the child's failure — the boundary converted it, so // the content the outer component asked for came back holding a diagnostic. diff --git a/packages/core/tests/expand.test.ts b/packages/core/tests/expand.test.ts index 3d22ee11..5fa2489f 100644 --- a/packages/core/tests/expand.test.ts +++ b/packages/core/tests/expand.test.ts @@ -3,7 +3,7 @@ import { expect } from "@executablemd/test-support/expect"; import { scoped } from "effection"; import { expandSegments } from "../src/expand.ts"; import { Component, content } from "../src/component-api.ts"; -import { collectFailures } from "../src/component-failures.ts"; +import { captureErrors } from "../src/component-failures.ts"; import { scanSegments } from "../src/scanner.ts"; import { interpolate } from "../src/interpolate.ts"; import { validateProps, PropValidationError } from "../src/validate.ts"; @@ -89,7 +89,7 @@ const BROKEN: FunctionComponentDefinition = { name: "Broken", props: { type: "object", properties: {}, additionalProperties: false }, // deno-lint-ignore require-yield - fn: collectFailures(function* () { + fn: captureErrors(function* () { throw new Error("broken thing"); }), }; diff --git a/packages/core/tests/file-component.test.ts b/packages/core/tests/file-component.test.ts index d62cf0f1..083b9a28 100644 --- a/packages/core/tests/file-component.test.ts +++ b/packages/core/tests/file-component.test.ts @@ -26,7 +26,7 @@ import { useTempFileCompiler } from "../src/temp-file-compiler.ts"; import { FileAccessError } from "../src/components/File.ts"; import { CORE_REGISTRY } from "../src/components/registry.ts"; import { Component } from "../src/component-api.ts"; -import { collectFailures } from "../src/component-failures.ts"; +import { captureErrors } from "../src/component-failures.ts"; import { expandSegments } from "../src/expand.ts"; import { scanSegments } from "../src/scanner.ts"; import { AmbientErrorPolicy, ContentError, DocumentationError } from "../src/errors.ts"; @@ -148,7 +148,7 @@ const BROKEN: FunctionComponentDefinition = { name: "Broken", props: { type: "object", properties: {}, additionalProperties: false }, // deno-lint-ignore require-yield - fn: collectFailures(function* () { + fn: captureErrors(function* () { throw new Error("broken"); }), }; diff --git a/packages/core/tests/function-components.test.ts b/packages/core/tests/function-components.test.ts index 7d50918f..8ea029f5 100644 --- a/packages/core/tests/function-components.test.ts +++ b/packages/core/tests/function-components.test.ts @@ -404,15 +404,15 @@ describe("Tier FC — Function components", () => { } }); - // The explicit choice: `collectFailures` says this component reports rather + // The explicit choice: `captureErrors` says this component reports rather // than stops, so the failure becomes one diagnostic and the document goes on. - it("FC5b: a component marked with collectFailures reports and continues", function* () { + it("FC5b: a component marked with captureErrors reports and continues", function* () { const tmpDir = makeTempDir(); try { writeFiles(tmpDir, { "components/Broken.ts": [ - 'import { collectFailures } from "@executablemd/core";', - "export default collectFailures(function*() {", + 'import { captureErrors } from "@executablemd/core";', + "export default captureErrors(function*() {", ' throw new Error("component error");', "});", ].join("\n"), diff --git a/packages/core/tests/if.test.ts b/packages/core/tests/if.test.ts index b534f68e..332a227e 100644 --- a/packages/core/tests/if.test.ts +++ b/packages/core/tests/if.test.ts @@ -5,7 +5,7 @@ import { scoped } from "effection"; import type { Operation } from "effection"; import { expandSegments } from "../src/expand.ts"; import { Component } from "../src/component-api.ts"; -import { collectFailures } from "../src/component-failures.ts"; +import { captureErrors } from "../src/component-failures.ts"; import { AmbientErrorPolicy, DocumentationError } from "../src/errors.ts"; import { scanSegments } from "../src/scanner.ts"; import type { SourceOrigin } from "../src/scanner.ts"; @@ -716,7 +716,7 @@ describe("Tier IF — error observation", () => { name: "Broken", props: { type: "object", properties: {}, additionalProperties: false }, // deno-lint-ignore require-yield - fn: collectFailures(function* () { + fn: captureErrors(function* () { throw new Error("broken thing"); }), }; diff --git a/packages/core/tests/invocation-harness.ts b/packages/core/tests/invocation-harness.ts index b5f117f1..f1f46992 100644 --- a/packages/core/tests/invocation-harness.ts +++ b/packages/core/tests/invocation-harness.ts @@ -12,7 +12,7 @@ import { ensure, resource, scoped, suspend } from "effection"; import type { Operation } from "effection"; import { useEvalScope } from "@effectionx/scope-eval"; import { Component } from "../src/component-api.ts"; -import { collectFailures } from "../src/component-failures.ts"; +import { captureErrors } from "../src/component-failures.ts"; import { expandSegments } from "../src/expand.ts"; import { scanSegments } from "../src/scanner.ts"; import type { ComponentDefinition, FunctionComponentDefinition, Segment } from "../src/types.ts"; @@ -60,7 +60,7 @@ export function component( ): FunctionComponentDefinition { // These fixtures exist to be observed failing, so they collect rather than // stopping the expansion the assertion is about. - return { kind: "function", name, props: NO_PROPS, fn: collectFailures(body) }; + return { kind: "function", name, props: NO_PROPS, fn: captureErrors(body) }; } /** diff --git a/packages/core/tests/loop.test.ts b/packages/core/tests/loop.test.ts index fd96dda5..6cd73f1a 100644 --- a/packages/core/tests/loop.test.ts +++ b/packages/core/tests/loop.test.ts @@ -15,7 +15,7 @@ import type { DurableEvent, Json, Result } from "@executablemd/durable-streams"; import { useEchoExec, useStubFs } from "@executablemd/runtime/test"; import { execute } from "../src/execute.ts"; import { collect } from "../src/collect.ts"; -import { collectFailures } from "../src/component-failures.ts"; +import { captureErrors } from "../src/component-failures.ts"; import type { ComponentElement, FunctionComponent, Segment } from "../src/types.ts"; import { asText } from "./helpers.ts"; @@ -34,7 +34,7 @@ const OBJECT_SCHEMA = { type: "object", properties: {} }; /** Function components the harness serves instead of the filesystem. */ // Ordinary components: indexing the definition union would widen these to the -// live shape too, and then collectFailures could not infer a concrete arm. +// live shape too, and then captureErrors could not infer a concrete arm. type Stubs = Record; function runLoop( @@ -64,7 +64,7 @@ function runLoop( props: OBJECT_SCHEMA, // What these assert is how a rendered diagnostic interacts with a // loop and its policy, which needs the failure to become one. - fn: collectFailures(fn), + fn: captureErrors(fn), }; }, // deno-lint-ignore require-yield @@ -1357,7 +1357,7 @@ describe("Tier LOOP — replay validates the terminal record", () => { path: "Mixed.ts", props: OBJECT_SCHEMA, // deno-lint-ignore require-yield - fn: collectFailures(function* () { + fn: captureErrors(function* () { throw new AggregateError( [ new DocumentationError({ diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 13db54a3..dade5902 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2144,7 +2144,7 @@ deterministic from the content, so it needs no separate journal entry. A component name is resolved in tiers, and the first tier that answers wins: 1. **structural syntax** — ``, ``, ``, ``, - ``, ``/``, ``/``, ``, + ``, ``/``, ``/``, ``, ``/``. These are the language's own constructs. They are reserved: a registration cannot claim one, and a repository file named after one never stands in for it. A structural name @@ -3988,19 +3988,19 @@ that leaves the boundary accounts for the body and its teardown together. An becomes an `Error` carrying the original value as its `cause`. Later siblings do not run. -Continuing instead is an explicit, scope-local choice. `collectFailures(fn)` +Continuing instead is an explicit, scope-local choice. `captureErrors(fn)` says it about one component, keyed by the exact function object — a repository component that shares a registered component's name is a different function and -inherits nothing. `` says it about a region of a document: +inherits nothing. `` says it about a region of a document: ```md - + - + ``` -`` accepts no props: it names a region and nothing else, so any +`` accepts no props: it names a region and nothing else, so any prop — `as` and `slot` included, written as a literal or as an expression — is a syntax error reported against the element. An element written that way performs no action at all: its body does not expand, and a prop expression is never From 9e811b8d52fa38db0f830b71e93d773a981113ca Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:29:37 -0400 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=92=A5=20feat:=20make=20=20fa?= =?UTF-8?q?il=20fast,=20and=20record=20the=20failed=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` collected failures and carried on. That is the wrong default for an operational document: a failed preview could still reach a later or a destructive publish step, and the document rendered as though the stage had worked. A region is now fail-fast. What it produced before the failure is kept and emitted — usually the explanation an operator needs — and then the execution fails. No later sibling, region, documentation block, or effect begins. Continuing is asked for explicitly, and stays scope-local: ... work whose errors should render without aborting ... Three settlement policies replace two. `collect` renders and continues, and is still what a root without does. `output` throws an ordinary diagnostic and returns one an explicit capture boundary handled. `throw` ends the execution whatever the diagnostic is, because documentation is hidden and a captured diagnostic there has nothing to render into. `useFailures()` marks the segments raised beneath a boundary — by identity, never a copy — which is how a captured diagnostic crosses an invocation boundary into a fail-fast caller and still renders. Preserving partial output required saying who owns rendered segments. Expansion writes into the accumulator its caller gave it; a call site that produces a binding, a value, or a string owns a private one instead, so a failing `` or `as=` invocation never promotes content the document was not going to render. A failed document is now a determined durable outcome rather than an escape: the root closes `ok` around `{status: "err", output, error}`, so a replay restores the same partial output and the same failure without re-entering the workflow. Durability failures still escape, and a failure before entering or after returning from durableRun still produces no close. A live run resolves the error it actually caught — same object, type, cause and aggregate members — while a replayed run reports the reconstruction the recorded fields describe. Closes #309 --- packages/core/src/component-failures.ts | 11 +- packages/core/src/errors.ts | 52 ++- packages/core/src/execute.ts | 285 +++++++++++-- packages/core/src/expand.ts | 113 +++-- packages/core/tests/eval-policy.test.ts | 40 +- packages/core/tests/execute.test.ts | 7 +- packages/core/tests/expand.test.ts | 58 ++- packages/core/tests/loop.test.ts | 11 +- packages/core/tests/output-fail-fast.test.ts | 422 +++++++++++++++++++ specs/executable-mdx-spec.md | 136 +++++- 10 files changed, 1015 insertions(+), 120 deletions(-) create mode 100644 packages/core/tests/output-fail-fast.test.ts diff --git a/packages/core/src/component-failures.ts b/packages/core/src/component-failures.ts index 7af86a65..182ae6cd 100644 --- a/packages/core/src/component-failures.ts +++ b/packages/core/src/component-failures.ts @@ -14,7 +14,7 @@ */ import { Component, raise } from "./component-api.ts"; -import { attributeCause } from "./errors.ts"; +import { attributeCause, markCaptured } from "./errors.ts"; import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.ts"; import type { Operation } from "effection"; @@ -71,5 +71,14 @@ export function useFailures(): Operation { attributeCause(segment, failure.error); return yield* raise(segment); }, + // Every diagnostic raised beneath the boundary is one the document asked to + // carry on past, not only the ones translated from a component failure: a + // region that captures errors captures the ones its own syntax reports too. + // Marking here rather than in `handleFailure` keeps that a property of the + // region, and delegating leaves the observation chain a single pass. + *raise([segment], next): Operation { + markCaptured(segment); + return yield* next(segment); + }, }); } diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index ceff0d36..6c520443 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -17,21 +17,57 @@ import type { ErrorSegment } from "./types.ts"; * error crossing from a component's own policy to its caller's does not emit a * second observation. */ -export type ErrorPolicy = "collect" | "throw"; +export type ErrorPolicy = "collect" | "output" | "throw"; export const AmbientErrorPolicy: Context = createContext( "component.errorPolicy", "collect", ); +/** + * Diagnostics a document asked to carry on past, remembered by identity. + * + * Membership rather than a field on the segment: the object a capture boundary + * handled is the same object the document renders and an observer already saw, + * and copying it to record the decision would break both. + */ +const capturedSegments = new WeakSet(); + +/** + * Record that an explicit capture boundary handled this diagnostic. + * `useFailures()` calls it for every segment raised beneath the boundary — the + * ones it builds from a component failure, and the structural ones the region + * raises on its own. + */ +export function markCaptured(segment: ErrorSegment): void { + capturedSegments.add(segment); +} + +export function isCaptured(segment: ErrorSegment): boolean { + return capturedSegments.has(segment); +} + /** * Settle a segment under the ambient policy: the default `Component.raise` * implementation calls this, and so does a consumer applying its own policy to * an error that already crossed a nested one. + * + * The three policies differ over a failure the document did not ask to continue + * past: + * + * - `collect` renders every diagnostic and carries on — a root or a body with + * no ``. + * - `output` is fail-fast: an ordinary diagnostic ends the execution, while one + * an explicit `` boundary handled renders and later content + * still runs. A region that shows an operator what a stage produced must not + * also let a failed stage reach the step after it. + * - `throw` ends the execution whatever the diagnostic is. Documentation is + * hidden, so a captured diagnostic has nothing to render into and an author + * has nothing to read instead. */ export function* settle(segment: ErrorSegment): Operation { const policy = yield* AmbientErrorPolicy.get(); - if (policy === "throw") { + if (policy === "throw" || (policy === "output" && !isCaptured(segment))) { throw new DocumentationError(segment); } return segment; @@ -170,7 +206,17 @@ 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); } /** diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 0bd0831e..8188e0b3 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -31,8 +31,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 +47,7 @@ import { createBlockCounter, } from "./expand.ts"; import type { BlockCounter } from "./expand.ts"; -import { DocumentationError } from "./errors.ts"; +import { documentationFailure, durabilityFailure, DocumentationError } from "./errors.ts"; import { Component, importComponent } from "./component-api.ts"; import { renderSegment } from "./render.ts"; import { DocumentOutput } from "./api.ts"; @@ -263,9 +264,164 @@ const silentFactory: ModifierFactory = (_params) => (_args, next) => * 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 (`liveFailures`). + * + * Absence is `null` rather than a missing key, because absence is information: + * `cause: null` says the failure had none, while `cause: "undefined"` says it + * had one whose value was `undefined` — a component may throw exactly that, and + * a replayed run should still be able to tell the two apart. + */ +type DocumentFailure = { + name: string; + message: string; + segment: { message: string; source: string | null }; + cause: string | null; + errors: { name: string; message: string }[] | null; +}; + +/** + * The original error a failed outcome was derived from, by identity. + * + * A live run resolves the failure it actually caught — same object, same type, + * same `cause`, same aggregate members — because `durableRun` hands back the + * very object the workflow returned. A replayed run is given the journal's + * reconstruction of that object instead, so the lookup misses and the described + * fields are all there is. The miss is the signal; nothing asks whether it is + * replaying. + */ +const liveFailures = new WeakMap(); + +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, + source: documentation.segment.source ?? null, + }, + cause: "cause" in wrapper ? describeCause(wrapper.cause) : null, + errors: + wrapper instanceof AggregateError + ? wrapper.errors.map((member: unknown) => ({ + name: member instanceof Error ? member.name : "Error", + message: member instanceof Error ? member.message : String(member), + })) + : null, + }; +} + +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 !== null) { + 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 is "ok" or "err", not ${JSON.stringify(status)}.`); +} + +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."); + } + const source = segment["source"]; + const cause = candidate["cause"]; + const errors = candidate["errors"]; + return { + name, + message, + segment: { message: segmentMessage, source: typeof source === "string" ? source : null }, + cause: typeof cause === "string" ? cause : null, + errors: Array.isArray(errors) ? errors.map(parseFailureMember) : null, + }; +} + +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 +435,8 @@ function* runValueRoot( returns: ReturnsSchema, validatedProps: Record, counter: BlockCounter, + chunks: string[], ): Operation { - const chunks: string[] = []; let produced: { value: Json } | undefined; yield* scoped(function* () { @@ -319,7 +475,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 +498,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 here rather than inside the 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 +544,75 @@ function* documentWorkflow(props: Record): Workflow (spec §5.4). - const chunks: string[] = []; - + // The loop owns the segments so that a component whose own region fails + // partway has still handed over what it rendered — the root emits that + // before the failure is reported, the same way 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) { + // 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 plus whatever the failing segment had already + // handed over but not reached the emission step yet. + const rendered = + selected.length > 0 + ? selected.map(renderSegment).join("") + : streamed.join("") + produced.slice(emittedThrough).map(renderSegment).join(""); + const outcome: DocumentResult = { + status: "err", + output: rendered, + error: describeFailure(error, documentation), + }; + liveFailures.set(outcome, error); + return outcome; + } } /** @@ -557,20 +752,30 @@ 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 }); + // Looked up from what the workflow returned, before parsing: on a live + // run this is the same object the workflow built, and it is the only + // place the original error still exists. + const live = + typeof returned === "object" && returned !== null ? liveFailures.get(returned) : undefined; + 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 — diagnostics 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 db1a3bba..f6fb9a77 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -467,14 +467,18 @@ 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 of 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. + * + * A call site that produces a binding, a value, or a string passes nothing: + * its buffer is private and is never merged into an owner, so a failure + * cannot promote content the document was not going to render. */ - collect?: Segment[], + owner?: Segment[], ): Operation { - const result: Segment[] = collect ?? []; + 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(); @@ -574,9 +578,9 @@ export function* expandSegments( if (segment.name === "CaptureErrors") { // No raise() here, like the branches above: expandCaptureErrors // reports the errors it creates, and the body settled its own (§6.9). - result.push( - ...(yield* expandCaptureErrors(segment, parentMeta, parentProps, hideSet, counter)), - ); + // It renders into this expansion's output, so it is handed the owner + // and writes there rather than handing segments back to be appended. + yield* expandCaptureErrors(segment, parentMeta, parentProps, hideSet, counter, result); break; } @@ -616,6 +620,7 @@ export function* expandSegments( break; } + const invocationStart = result.length; const expanded = yield* expandComponent( segment.name, segment.props, @@ -628,11 +633,25 @@ export function* expandSegments( segment.position, parentMeta, parentProps, + result, ); // Consumer boundary: the callee reported these where they were created, - // under whatever policy its body ran — an `` region collects, - // documentation throws. Settling them here applies this caller's policy - // without reporting them a second time (spec §6.9). + // under whatever policy its body ran — an `` region is fail-fast + // unless an explicit capture handled the failure, documentation throws. + // Settling here applies this caller's policy without reporting anything + // a second time (spec §6.9). + // + // A rendering invocation wrote its body straight into this owner, so + // settling happens in place over what it added: the diagnostic is taken + // out first, so a policy that ends the execution does not leave it in + // the output the document rendered before failing. + for (let index = invocationStart; index < result.length; index++) { + const written = result[index]; + if (written?.type === "error") { + result.splice(index, 1); + result.splice(index, 0, yield* settle(written)); + } + } for (const expandedSegment of expanded) { if (expandedSegment.type === "error") { result.push(yield* settle(expandedSegment)); @@ -1498,19 +1517,29 @@ function* expandCaptureErrors( 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( captureErrorsError(segment, ` accepts no props. Got: "${names[0]}".`), ), - ]; + ); + return; } - return yield* scoped(function* () { + yield* scoped(function* () { yield* useFailures(); - return yield* expandSegments(segment.children, parentMeta, parentProps, hideSet, counter); + return yield* expandSegments( + segment.children, + parentMeta, + parentProps, + hideSet, + counter, + owner, + ); }); } @@ -1527,6 +1556,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)) { @@ -1813,6 +1849,7 @@ function* expandComponent( return []; } + const bodyOwner = asBinding === undefined ? owner : undefined; const expanded = yield* withInvocation(function* (invocation) { yield* installInvocation(invocation); return yield* expandBody( @@ -1824,6 +1861,7 @@ function* expandComponent( counter, callerEvalEnv ?? undefined, claimProjection, + bodyOwner, ); }); @@ -1855,7 +1893,9 @@ function* expandComponent( return []; } - return expanded; + // A rendering invocation already wrote into the caller's owner, so there is + // nothing left to hand back — its consumer settles what is now in place. + return bodyOwner === undefined ? expanded : []; } // Without `returns`, a function component's rendering is its return value, so @@ -2643,6 +2683,13 @@ interface BodyChunk { /** true = a rendered `` region; false = documentation (executed, not rendered). */ output: boolean; segments: Segment[]; + /** + * A diagnostic about the region declaration itself rather than about work + * inside one. The region never opened, so its fail-fast policy has nothing to + * say about this: the enclosing frame settles it, which is how a mistyped + * `` stays a comment in a document that collects. + */ + declaration?: boolean; } function isTopLevelOutput(segment: Segment): boolean { @@ -2905,7 +2952,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( @@ -2932,9 +2979,10 @@ 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 - * raise policy (fail-fast) and its rendered result is discarded; output - * regions set a collecting policy of their own, so their errors render as - * comments; the caller settles them again on the way out. + * raise policy (fail-fast) and its rendered result is discarded; output regions + * set a fail-fast policy of their own, under which an ordinary diagnostic ends + * the execution and one an explicit `` boundary handled renders + * as a comment; the caller settles what survives on the way out. * Regions and documentation run in document order, so output can depend on * bindings computed by preceding documentation. */ @@ -2947,22 +2995,29 @@ export function* expandBody( counter: BlockCounter, callerEnv: EvalEnv | undefined, claim: ClaimFn = passthroughClaim, + /** + * The owner this body renders into. A body that renders shares its caller's, + * so a region that fails partway has already handed over what it produced; + * an invocation captured with `as` passes none and keeps its own. + */ + 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* AmbientErrorPolicy.set("collect"); - 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* AmbientErrorPolicy.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/tests/eval-policy.test.ts b/packages/core/tests/eval-policy.test.ts index 043e442a..46aaf6aa 100644 --- a/packages/core/tests/eval-policy.test.ts +++ b/packages/core/tests/eval-policy.test.ts @@ -73,8 +73,11 @@ describe("Tier O — Eval scope hierarchy", () => { expect(String(failure)).toContain("Missing"); }); - // O29: the same projection inside collects, and the region emits. - it("O29: Markdown inside collects a projected error", function* () { + // O29: the same projection inside , where the region is fail-fast + // (#309). The policy travels with the projection, so the content task ends the + // execution rather than rendering a comment, and the region's later text never + // reaches the document. + it("O29: a Markdown inside fails on a projected error", function* () { const stream = new InMemoryStream(); yield* useStubFs({ "components/Wrap.md": ["", "", "", "done", ""].join("\n"), @@ -82,12 +85,15 @@ 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(failure).toBeInstanceOf(DocumentationError); + expect(String(failure)).toContain("Missing"); }); // O30: value-component documentation carries the same policy, so a claimed @@ -207,12 +213,18 @@ describe("Tier O — Eval scope hierarchy", () => { expect(output).not.toContain("ERROR"); }); - // O24: the same block inside an region collects instead, so the + // O24: the same block inside a captured region of an , where the // projected error renders as a comment and the region still emits. - it("O24: a persistent projection inside settles under the collecting policy", function* () { + it("O24: a captured persistent projection inside renders the diagnostic", 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", }); yield* useEchoExec(); @@ -228,7 +240,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 4e1e4b86..792e42ef 100644 --- a/packages/core/tests/execute.test.ts +++ b/packages/core/tests/execute.test.ts @@ -1248,7 +1248,10 @@ describe("component-declared output — document workflow", () => { expect(chunks).toHaveLength(1); }); - it("emits no partial output when documentation fails in a buffered root", function* () { + // What a region produced before the failure is what an operator needs to see + // to understand it, so a failing buffered root emits its selection and then + // reports the failure (#309). + it("emits the partial selection when documentation fails in a buffered root", function* () { const stream = new InMemoryStream(); yield* useStubFs({ "README.md": "\nSELECTED\n\n\n```bash exec\nfailing-command\n```\n", @@ -1263,7 +1266,7 @@ describe("component-declared output — document workflow", () => { const result = yield* execution; expect(result.ok).toBe(false); - expect(chunks.join("")).not.toContain("SELECTED"); + 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 5fa2489f..383e4e10 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* () { + it("fails an region on an ordinary error", function* () { const comp = makeComponent("Err", "\n\n"); - const ctx = { Err: comp }; - const output = yield* expand(scanSegments(""), ctx); + let caught: unknown; + try { + yield* expand(scanSegments(""), { Err: comp }); + } catch (error) { + caught = error; + } + if (!(caught instanceof DocumentationError)) { + throw new Error(`expected DocumentationError, received ${String(caught)}`); + } + expect(caught.message).toContain("Failed to import component Bogus"); + }); + + it("keeps a captured error inside an region as a comment", function* () { + const comp = makeComponent( + "Err", + "\n\n\n\n\nAFTER\n", + ); + const output = yield* expand(scanSegments(""), { Err: comp, Broken: BROKEN }); 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. +- An error produced while rendering an output region stops the execution too. + The output the region produced before it is preserved and emitted; no later + sibling, region, documentation block, elicitation, or other effect begins. +- An error an explicit `` boundary handled renders as an + `` comment inside an output region, and the region continues. +- An error anywhere in a body that declares no `` retains normal + `ErrorSegment` rendering (an `` comment) and execution + continues. +- A root containing `` emits its selected output once: after the whole + body completes successfully, or — when the body fails — the part the regions + produced before the failure, emitted before the execution reports it. An empty + selection emits nothing. + +Four situations are therefore distinct, and §6.9's settlement policies name them +directly: + +| Where | Ordinary diagnostic | Captured diagnostic | +|---|---|---| +| A root or body with no `` | renders, execution continues | renders | +| An `` region | ends the execution, keeping what was rendered first | renders, region continues | +| Documentation | ends the execution, hidden | ends the execution, hidden | + +An error a nested component renders inside its own output region behaves the +same way at its consumer: a captured one is a comment when that component +renders normally, an uncaptured one fails the caller, and when the component is +executed as a parent's documentation the parent's fail-fast applies to both. **Reporting and settling 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 policy — collected for rendering, or thrown as a documentation failure. +ambient policy — one of three: + +| Policy | Where it is installed | Ordinary diagnostic | Captured diagnostic | +|---|---|---|---| +| `collect` | the default; roots and bodies with no `` | returned for rendering | returned | +| `output` | every `` region, in a root and in a component alike | thrown as a documentation failure | returned for rendering | +| `throw` | documentation, and value roots | thrown | thrown | + A documentation chunk and an `` region select the policy by value rather than by installing reporting middleware, so an error crossing from a component's own policy into its caller's is settled again without being reported twice. +**A capture is remembered on the segment, by identity.** `` and +`captureErrors(fn)` mark every `ErrorSegment` raised beneath them as one the +document asked to carry on past — the same object an observer already saw, never +a copy — and settlement under `output` consults that mark. This is what lets a +captured diagnostic cross an invocation boundary into a fail-fast caller and +still render there, while an uncaptured one from the same region fails the +caller. Documentation ignores the mark: it is hidden, so there is nothing for a +captured diagnostic to render into. + **Whoever creates an `ErrorSegment` reports it.** `Component.raise` is called at the point the failure is decided, and a diagnostic that reaches the document without that call never passes the observation chain — middleware that counts, @@ -4161,9 +4196,11 @@ throwing parent frame belongs to that same set, and throws rather than appending 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. A failing body +emits what its regions produced before the failure, and emits it before the +execution reports that failure. ### 6.10 Component return values: `returns` and `` @@ -5708,9 +5745,45 @@ With the default directory resolver: [4] yield root { type: "eval", name: "eval:root:0", language: "js" } result: { status: "ok", value: { value: { port: 4321 } } } -[5] close root result: { status: "ok", value: "...rendered output..." } +[5] close root result: { status: "ok", value: { status: "ok", output: "...", value: "..." } } ``` +### 10.2.1 The root close records a document outcome + +The root coroutine's `Close` carries what the document decided, not merely +whether the process reached the end: + +| Outcome | Close | Value | +|---|---|---| +| The document succeeded | `ok` | `{ status: "ok", output, value }` | +| The document failed on its own terms | `ok` | `{ status: "err", output, error }` | +| A durability failure (§6.11) | `err` | the serialized failure | +| A failure escaping the workflow while `durableRun` is active | `err` | the serialized failure | +| A failure before entering or after returning from `durableRun` | no close is written | — | + +An ordinary document failure is a *determined* outcome: the run is over, what it +rendered first is part of the record, and closing `ok` around it is what lets a +replay restore both without re-executing anything. A durability failure is the +opposite — 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. +`err` closes are therefore not reserved for infrastructure failures in general: +a failure outside `durableRun` produces no close at all. + +`error` holds only what JSON can carry, and that is the complete replay contract: + +| Field | Type | Meaning | +|---|---|---| +| `name`, `message` | `string` | of the original caught failure, wrapper included | +| `segment` | `{ message, source }` | the `ErrorSegment` the failure was decided on | +| `cause` | `string \| null` | the rendered cause; `null` when there was none, and `"undefined"` when there was one whose value was `undefined` | +| `errors` | `{ name, message }[] \| null` | shallow `AggregateError` members, in order | + +Object identity, stacks, and the cause graph do not cross. A live run therefore +reports the failure it actually caught — the same object, with its type, `cause` +and aggregate members intact — while a replayed run reports the reconstruction +these fields describe: an `AggregateError` when `errors` is present, and an +`Error` otherwise. + ### 10.3 Sequential coroutine IDs In the basic sequential model, all effects run under the `root` @@ -5871,7 +5944,7 @@ visible warning blocks, collect 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 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 | +| 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 with no `` remain comments, and inside `` only a ``-handled one does | | 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 +5993,7 @@ visible warning blocks, collect 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 after success; a later documentation failure emits the part produced before it and then fails; 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) @@ -6498,6 +6571,27 @@ Identifiers match `packages/core/tests/if.test.ts` one to one. | IF53 | Throwing policy | An ambient `throw` policy still aborts on a selected-branch error | | IF54 | Provider boundary | An unselected branch makes zero Sample Api calls; the same probe records one when selected | +### Tier OFF — `` fail-fast + +Identifiers match `packages/core/tests/output-fail-fast.test.ts`. + +| # | Test | Verify | +|---|------|--------| +| OFF1 | Root region failure | The output produced before the failure is emitted, completion is `Err`, and the block after the failure never runs | +| OFF2 | Component region failure | The same three, through a real invocation, with the caller's later block never starting | +| OFF3 | Non-zero exec with stdout | The stdout stays visible, the execution fails, and the next block never runs — the integration pin with #307 | +| OFF4 | Multiple regions | An earlier region's output survives; the documentation between the regions and the later region never run | +| OFF5a | `` in a region | One diagnostic renders and the marker after it renders too | +| OFF5b | Same fixture, no boundary | The region fails and the marker does not render | +| OFF5c | Captured region through a document | A component whose region captured its failure completes, and the marker renders | +| OFF5d | `captureErrors(fn)` in a region | The component's own diagnostic renders once and a following sibling runs | +| OFF5e | Captured failure in documentation | Still fail-fast: the marking is ignored where nothing renders | +| OFF5f | Root with no `` | Collecting behavior is unchanged: the diagnostic renders and later blocks run | +| OFF6 | Live completion | `Err` carries the failure the engine caught | +| OFF7 | Replay | The same partial output and failure, with no command running a second time | +| OFF8 | Journal shape | The root closes `ok` around a value whose `status` is `err` | +| OFF9 | `as` capture is not output | A failing `as=` invocation's rendered prefix does not reach the document | + ### Tier OBS — error observation The one-observation contract of §6.9, measured with counting `Component.raise` @@ -6810,7 +6904,7 @@ 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 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 | +| 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 once — after successful expansion, or the part produced before a failure, ahead of the failure being reported; 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 | From 49e7f305a7dff0908d757c8945c7e2f82a0c16d9 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 3 Aug 2026 05:50:05 -0400 Subject: [PATCH 3/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20mark=20the?= =?UTF-8?q?=20object,=20complete=20the=20owner=20conversion,=20parse=20the?= =?UTF-8?q?=20journal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three module-lifetime habits and two unfinished conversions, from review. **No module-scoped registries.** `capturesErrors` and the captured-diagnostic mark were WeakSets beside the objects they described: one table per process, shared by every run, invisible to any scope. Both are now brands on the object itself, under `Symbol.for` and non-enumerable — the component function carries its own answer, and a captured diagnostic carries the decision that was made about it, so the state lives and dies with the value and stays out of rendering and serialization. `local/no-module-scoped-weakset` makes that a rule rather than a habit. It reports module-lifetime `new WeakSet()` — declared, exported, assigned later, or held inside another module-scoped value — and accepts one created inside a function or an operation. Confirmed reddening both declarations before they were removed. **Every visible producer writes into the region.** The selected `` branch, `` iterations, a rendering ``, an answered `` body, and projected `` each built a private array and lost it when nested expansion threw. They now write into the owner they are given and return only what the caller must still append. The atomic paths are unchanged and stay unchanged on purpose: ``, `as`, string projection, documentation, and value production keep private buffers that are never merged, so a failure cannot promote content the document was not going to render. **The journal is parsed, not coerced.** A recorded failure with a non-string `source` or `cause`, a non-list `errors`, or a member missing its message is refused rather than silently read as absent — reporting a failure that disagrees with the one recorded is worse than refusing to report. The presence contract is back to what it describes: an absent own cause stays absent, an own `cause` of `undefined` records `"undefined"`, and an inherited one is not an own one (`Object.hasOwn`). The live sidecar is now taken rather than read, so a caller replaying the very same outcome object gets the journal's account like any other replay. Coverage: one prefix-survives and one exact-count case per visible producer, one isolation case per atomic path, seven malformed-journal cases, the persisted-field and reconstruction contract, and live identity through the cause chain. Five mutations confirmed discriminating — clone before the sidecar lookup, omit the capture mark, settle `output` like `throw`, share an owner at an `as` boundary, and append a shared write twice. --- .oxlintrc.json | 1 + packages/core/src/answers.ts | 12 +- packages/core/src/component-failures.ts | 18 +- packages/core/src/errors.ts | 18 +- packages/core/src/execute.ts | 83 +++- packages/core/src/expand.ts | 118 +++-- packages/core/src/projection.ts | 7 +- packages/core/tests/output-fail-fast.test.ts | 445 +++++++++++++++++- scripts/oxlint-plugin.js | 2 + .../oxlint-rules/no-module-scoped-weakset.js | 80 ++++ scripts/tests/fixtures/module-weakset.ts | 22 + scripts/tests/fixtures/scoped-weakset.ts | 34 ++ .../tests/no-module-scoped-weakset.test.ts | 25 + 13 files changed, 769 insertions(+), 96 deletions(-) create mode 100644 scripts/oxlint-rules/no-module-scoped-weakset.js create mode 100644 scripts/tests/fixtures/module-weakset.ts create mode 100644 scripts/tests/fixtures/scoped-weakset.ts create mode 100644 scripts/tests/no-module-scoped-weakset.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 3ee247f3..493d0752 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -42,6 +42,7 @@ "typescript/no-redundant-type-constituents": "warn", "typescript/no-unnecessary-boolean-literal-compare": "warn", + "local/no-module-scoped-weakset": "error", "local/no-section-divider-comments": "error", "local/no-yield-in-finally": "error", "local/prefer-effection-result": "error" diff --git a/packages/core/src/answers.ts b/packages/core/src/answers.ts index 45c16752..55867ccd 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; +/** + * The caller's own recursion. `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-failures.ts b/packages/core/src/component-failures.ts index 182ae6cd..11a807fc 100644 --- a/packages/core/src/component-failures.ts +++ b/packages/core/src/component-failures.ts @@ -19,12 +19,18 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types. import type { Operation } from "effection"; /** - * Components that continue after failing, remembered by function identity. + * The brand a component wears to say it continues after failing. * - * Identity rather than name: a repository component that happens to share a - * registered component's name is a different function and inherits nothing. + * It sits on the function object itself, so the answer is the component's own + * property rather than an entry in a table that outlives every run. Identity is + * what carries it: a repository component that happens to share a registered + * component's name is a different function object, wears no brand, and inherits + * nothing. + * + * Not enumerable, so a component that is copied, wrapped, or inspected does not + * carry the decision along by accident. */ -const capturing = new WeakSet(); +const CAPTURES = Symbol.for("executablemd.core.capturesErrors"); /** * Continue after this component fails, reporting the failure as a diagnostic. @@ -43,12 +49,12 @@ const capturing = new WeakSet(); * projects is inside it. */ export function captureErrors(component: T): T { - capturing.add(component); + Object.defineProperty(component, CAPTURES, { value: true, enumerable: false }); return component; } export function capturesErrors(component: FunctionComponent): boolean { - return capturing.has(component); + return CAPTURES in component; } /** diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts index 6c520443..e17371dc 100644 --- a/packages/core/src/errors.ts +++ b/packages/core/src/errors.ts @@ -25,13 +25,19 @@ export const AmbientErrorPolicy: Context = createContext(); +const CAPTURED = Symbol.for("executablemd.core.capturedDiagnostic"); /** * Record that an explicit capture boundary handled this diagnostic. @@ -40,11 +46,11 @@ const capturedSegments = new WeakSet(); * raises on its own. */ export function markCaptured(segment: ErrorSegment): void { - capturedSegments.add(segment); + Object.defineProperty(segment, CAPTURED, { value: true, enumerable: false }); } export function isCaptured(segment: ErrorSegment): boolean { - return capturedSegments.has(segment); + return CAPTURED in segment; } /** diff --git a/packages/core/src/execute.ts b/packages/core/src/execute.ts index 8188e0b3..5f4b3ec6 100644 --- a/packages/core/src/execute.ts +++ b/packages/core/src/execute.ts @@ -291,17 +291,17 @@ type DocumentFailureResult = { * identity, stacks, and the cause graph stay behind, which is why the live path * resolves the original error instead of this description (`liveFailures`). * - * Absence is `null` rather than a missing key, because absence is information: - * `cause: null` says the failure had none, while `cause: "undefined"` says it - * had one whose value was `undefined` — a component may throw exactly that, and - * a replayed run should still be able to tell the two apart. + * 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 | null }; - cause: string | null; - errors: { name: string; message: string }[] | null; + segment: { message: string; source?: string }; + cause?: string; + errors?: { name: string; message: string }[]; }; /** @@ -323,16 +323,21 @@ function describeFailure(caught: unknown, documentation: DocumentationError): Do message: wrapper.message, segment: { message: documentation.segment.message, - source: documentation.segment.source ?? null, + ...(documentation.segment.source === undefined + ? {} + : { source: documentation.segment.source }), }, - cause: "cause" in wrapper ? describeCause(wrapper.cause) : null, - errors: - wrapper instanceof AggregateError - ? wrapper.errors.map((member: unknown) => ({ + // 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), - })) - : null, + })), + } + : {}), }; } @@ -355,12 +360,22 @@ function failureError(failure: DocumentFailure, live: unknown): unknown { failure.message, ) : new Error(failure.message); - if (failure.cause !== null) { + if (failure.cause !== undefined) { replayed.cause = failure.cause; } return withName(replayed, failure.name); } +/** The live failure a returned outcome carries, consumed on the way out. */ +function takeLiveFailure(returned: unknown): unknown { + if (typeof returned !== "object" || returned === null) { + return undefined; + } + const live = liveFailures.get(returned); + liveFailures.delete(returned); + return live; +} + function withName(error: Error, name: string): Error { error.name = name; return error; @@ -402,18 +417,35 @@ function parseFailure(value: unknown): DocumentFailure { if (typeof segmentMessage !== "string") { throw new Error("A failure description carries the message of the segment that failed."); } - const source = segment["source"]; - const cause = candidate["cause"]; + // 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: typeof source === "string" ? source : null }, - cause: typeof cause === "string" ? cause : null, - errors: Array.isArray(errors) ? errors.map(parseFailureMember) : null, + 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"]; @@ -753,11 +785,12 @@ function* executeDocument(options: ExecuteOptions): Operation ); const returned = yield* durableRun(() => Execution.operations.document(props), { stream }); - // Looked up from what the workflow returned, before parsing: on a live - // run this is the same object the workflow built, and it is the only - // place the original error still exists. - const live = - typeof returned === "object" && returned !== null ? liveFailures.get(returned) : undefined; + // Taken from what the workflow returned, before parsing: on a live run + // this is the same object the workflow built, and it is the only place + // the original error still exists. Taken rather than read, so the handoff + // belongs to the run that made it — a caller replaying the very same + // object gets the journal's account, like any other replay. + const live = takeLiveFailure(returned); const result = parseDocumentResult(returned); // Preserve output for any completion path that did not emit through the diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index f6fb9a77..34c934b7 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -160,13 +160,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); }); } @@ -247,6 +249,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(); @@ -256,8 +265,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* AmbientErrorPolicy.set(options.policy); @@ -289,7 +299,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; }); } @@ -385,6 +397,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. @@ -401,6 +414,7 @@ function createProjectionHandle(state: ProjectionState): ProjectionHandle { inner: state.enclosing, loop: state.callerLoop, errors: [], + owner, }); }, project: runProjection, @@ -512,7 +526,13 @@ export function* expandSegments( 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; } @@ -548,14 +568,16 @@ 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))); + yield* expandIf(segment, parentMeta, parentProps, hideSet, counter, result); break; } @@ -571,7 +593,7 @@ 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; } @@ -591,8 +613,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; @@ -881,6 +906,8 @@ function* expandEach( parentProps: Record, hideSet: Set, counter: BlockCounter, + /** The region a rendering loop 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), @@ -949,9 +976,12 @@ function* expandEach( const parentEvalScope = yield* evalScope; const enclosingLoop = yield* ActiveLoop.get(); - const out: Segment[] = []; + // A rendering loop 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 document output. + const out: Segment[] = asBinding === undefined ? owner : []; for (const item of items) { - const expanded = yield* expandChildrenScoped( + yield* expandChildrenScoped( segment.children, callerEnv ?? undefined, { [name]: item }, @@ -960,8 +990,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) { @@ -970,7 +1000,7 @@ function* expandEach( } if (asBinding === undefined) { - return out; + return []; } // A capture never swallows an error. The body reported these where they were @@ -1199,25 +1229,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; @@ -1233,16 +1265,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, @@ -1250,15 +1284,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, ); } @@ -1354,31 +1390,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 @@ -1387,7 +1431,6 @@ function* expandLoop( const identity: LoopIdentity = { id: counter.next(), ...(name === undefined ? {} : { name }) }; const frame: LoopFrame = { broken: false }; - const out: Segment[] = []; let started = 0; try { @@ -1396,9 +1439,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; } @@ -1438,7 +1479,6 @@ function* expandLoop( const outcome: LoopOutcome = frame.broken ? "break" : "exhausted"; yield* recordOutcome(identity, { iterations: started, outcome }); - return out; } function breakElementViolations(segment: ComponentElement): string[] { @@ -1893,8 +1933,6 @@ function* expandComponent( return []; } - // A rendering invocation already wrote into the caller's owner, so there is - // nothing left to hand back — its consumer settles what is now in place. return bodyOwner === undefined ? expanded : []; } diff --git a/packages/core/src/projection.ts b/packages/core/src/projection.ts index 5a4b1a74..209f39a9 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/output-fail-fast.test.ts b/packages/core/tests/output-fail-fast.test.ts index 66b2272c..d65c84ea 100644 --- a/packages/core/tests/output-fail-fast.test.ts +++ b/packages/core/tests/output-fail-fast.test.ts @@ -14,7 +14,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; -import { scoped } from "effection"; +import { ensure, scoped } from "effection"; import type { Operation } from "effection"; import { InMemoryStream } from "@executablemd/durable-streams"; import type { DurableEvent } from "@executablemd/durable-streams"; @@ -25,10 +25,11 @@ import { execute } from "../src/execute.ts"; import { expandSegments } from "../src/expand.ts"; import { Component } from "../src/component-api.ts"; import { captureErrors } from "../src/component-failures.ts"; -import { AmbientErrorPolicy } from "../src/errors.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import { AmbientErrorPolicy, DocumentationError } from "../src/errors.ts"; import { scanSegments } from "../src/scanner.ts"; import { renderSegments } from "../src/render.ts"; -import type { FunctionComponentDefinition, Segment } from "../src/types.ts"; +import type { FunctionComponent, FunctionComponentDefinition, Segment } from "../src/types.ts"; interface Run { /** Chunks in the order consumers received them. */ @@ -86,6 +87,46 @@ function run(files: Record, stream = new InMemoryStream()): Oper }); } +/** + * The same run, with function components registered rather than read from disk. + * `execute()` installs its own terminal `importComponent`, so a registration is + * how a test puts its own component in a document's reach. + */ +function runRegistered( + files: Record, + components: Record, + stream = new InMemoryStream(), +): Operation { + return scoped(function* () { + yield* registerComponents( + Object.entries(components).map(([name, definition]) => ({ + name, + origin: "output-fail-fast.test", + props: definition.props, + fn: definition.fn, + })), + ); + return yield* run(files, stream); + }); +} + +/** Every error an aggregate carries, at any depth, plus the aggregate itself. */ +function aggregateMembers(error: unknown): unknown[] { + const found: unknown[] = []; + const pending: unknown[] = [error]; + while (pending.length > 0) { + const next = pending.pop(); + found.push(next); + if (next instanceof AggregateError) { + pending.push(...next.errors); + } + if (next instanceof Error && next.cause !== undefined) { + pending.push(next.cause); + } + } + return found; +} + /** Expansion under a chosen policy, for the boundaries a document cannot reach. */ function expandUnder( policy: "collect" | "output" | "throw", @@ -340,13 +381,72 @@ describe("Tier OFF — is how a region continues", () => { describe("Tier OFF — the failed document is a determined outcome", () => { // OFF6 — the live completion carries the error the engine actually caught. it("reports the original failure object on a live run", function* () { + const thrown = new Error("the component's own failure"); + const failing: FunctionComponentDefinition = { + kind: "function", + name: "Failing", + props: { type: "object", properties: {}, additionalProperties: false }, + // deno-lint-ignore require-yield + fn: function* () { + throw thrown; + }, + }; + const result = yield* runRegistered( + { "doc.md": "\n\n\n\n" }, + { Failing: failing }, + ); + + expect(result.ok).toBe(false); + // The object itself, not a description of it. Identity is the whole claim: + // a reconstruction built from the journal cannot contain the very error the + // component threw. + const carried = aggregateMembers(result.error); + expect(carried).toContain(thrown); + expect(String(result.error)).toContain("the component's own failure"); + }); + + // OFF6c — the determined-outcome path, where the failure is a diagnostic the + // region settled: its type survives to the completion as well as its message. + it("reports a settled diagnostic as the documentation failure it is", function* () { const result = yield* run({ "doc.md": "\n\n```bash exec\nFAIL\n```\n\n", }); expect(result.ok).toBe(false); - expect(result.error).toBeInstanceOf(Error); - expect(String(result.error)).toContain("Command failed"); + const carried = aggregateMembers(result.error); + const documentation = carried.find((member) => member instanceof DocumentationError); + if (!(documentation instanceof DocumentationError)) { + throw new Error(`expected a DocumentationError, received ${String(result.error)}`); + } + expect(documentation.segment.message).toContain("Command failed"); + expect(documentation.segment.source).toContain("FAIL"); + }); + + // OFF6b — a body failure and a teardown failure arrive together, and both + // members survive to the completion. + it("reports the aggregate a body and its teardown produced together", function* () { + const fromBody = new Error("body failed"); + const fromTeardown = new Error("teardown failed"); + const failing: FunctionComponentDefinition = { + kind: "function", + name: "Failing", + props: { type: "object", properties: {}, additionalProperties: false }, + fn: function* () { + yield* ensure(function* () { + throw fromTeardown; + }); + throw fromBody; + }, + }; + const result = yield* runRegistered( + { "doc.md": "\n\n\n\n" }, + { Failing: failing }, + ); + + expect(result.ok).toBe(false); + const members = aggregateMembers(result.error); + expect(members).toContain(fromBody); + expect(members).toContain(fromTeardown); }); // OFF7 — the journal records the outcome, so a replay reproduces both halves @@ -395,28 +495,341 @@ describe("Tier OFF — the failed document is a determined outcome", () => { }); }); -describe("Tier OFF — a capture is not output", () => { - // OFF9 — an `as` invocation produces a binding, so what its body rendered - // before failing is not promoted into the document. - it("keeps a failing as= invocation's prefix out of the document", function* () { +/** + * One case per visible producer. Each fixture renders a prefix, fails inside + * the construct, and has a marker after it: the prefix survives, the diagnostic + * that ended the run does not appear in what was rendered, and the marker never + * runs. The success half asserts an exact count, so a producer that both writes + * into the region and hands its segments back reddens here. + */ +describe("Tier OFF — every visible producer keeps its prefix", () => { + const CASES: { name: string; failing: string; succeeding: string }[] = [ + { + name: "the selected branch", + failing: "\n\nPREFIX\n\n```bash exec\nFAIL\n```\n\n\n\nMARKER", + succeeding: "\n\nPREFIX\n\n", + }, + { + name: " iterations", + failing: "\n\nPREFIX\n\n```bash exec\nFAIL\n```\n\n\n\nMARKER", + succeeding: "\n\nPREFIX\n\n", + }, + { + name: " without as", + failing: '\n\nPREFIX\n\n```bash exec\nFAIL\n```\n\n\n\nMARKER', + succeeding: '\n\nPREFIX\n\n', + }, + { + name: "projected ", + failing: "\n\nPREFIX\n\n```bash exec\nFAIL\n```\n\n\n\nMARKER", + succeeding: "\n\nPREFIX\n\n", + }, + { + name: "an answered body", + failing: + "\n\n\nPREFIX\n\n```bash exec\nFAIL\n```\n\n\n\nMARKER", + succeeding: "\n\n\nPREFIX\n\n", + }, + ]; + + const WRAPPER = ""; + + for (const subject of CASES) { + it(`keeps what ${subject.name} rendered before failing`, function* () { + const result = yield* run({ + "components/Wrapper.md": WRAPPER, + "doc.md": `\n\n${subject.failing}\n\n`, + }); + + expect(result.output).toContain("PREFIX"); + expect(result.ok).toBe(false); + expect(result.output).not.toContain("MARKER"); + // The diagnostic that ended the run is the failure, not the output. + expect(result.output).not.toContain("