From 3e082d55993e5ee863b486dead4e2747c74f7bd8 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 10:52:53 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Stop=20authored=20work=20deliberate?= =?UTF-8?q?ly=20with=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` is a new ordinary `@executablemd/core` default that raises the author's own sentence where it is written. A repository `Fail.md` overrides it under the existing resolution order. It accepts only the self-closing form and a closed schema of one required non-empty `message`. Paired content, `as`, and every malformed prop are refused before the authored message can be raised, so a document that never reached its decision is never reported as having made one. A valid invocation is the ordinary failure of a function component: an `Error` carrying the exact message, positioned at the opening tag, rendering nothing and binding nothing. It carries no `printErrors()` declaration, which is what leaves recovery to an authored `` region; a value body's `throw` is not replaced there, so the authored failure settles the body ahead of missing-`` settlement. The specification and architecture now say that missing `` is the structural settlement error for a body that selected no value, and that a deliberate abort or a bounded exhaustion is expressed with ``. --- architecture.md | 8 +- packages/core/src/components/Fail.ts | 107 ++++ packages/core/src/components/registry.ts | 9 + .../core/tests/component-registration.test.ts | 2 + packages/core/tests/fail-component.test.ts | 457 ++++++++++++++++++ packages/core/tests/syntax-catalog.test.ts | 28 ++ specs/executable-mdx-spec.md | 90 +++- 7 files changed, 691 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/components/Fail.ts create mode 100644 packages/core/tests/fail-component.test.ts diff --git a/architecture.md b/architecture.md index 37e3e4e5..e81761b9 100644 --- a/architecture.md +++ b/architecture.md @@ -2390,8 +2390,11 @@ and never resolves a component named `Return`. The first executed `` claims the state before evaluating its expression, so two returns racing through one body cannot both believe they were first. The second fails the body, evaluates nothing, and the first value is not published — -there is no first-wins or last-wins fallback. A body that executes none reports -that instead, which is how a bounded search that found nothing reports itself. +there is no first-wins or last-wins fallback. A body that executes none settles +as a missing selection: the body ran and its flow reached no ``, which is +a structural outcome and carries nothing about what the author concluded. A +document that deliberately aborts, or that exhausted a bounded search, expresses +that as authored control flow with `` and the sentence its author wrote. **Selection is not a decision a document makes about itself.** A document's own eval block reaches further than it looks: a named context by name, because @@ -3539,6 +3542,7 @@ Status is measured against main. | `xmd syntax` | describes every structural construct and every selected component the production `run` profile would let a document write in the contextual working directory, as deterministic Markdown or as version-1 JSON, from one catalog. Inspection only: it registers the run profile's declarations in a bounded scope and reads the filesystem for which files exist and, for a selected Markdown component, that file's frontmatter. It runs no body, imports no repository TypeScript module, installs no provider, mints no authority and writes no journal. An include it cannot enumerate — a selection-relevant symbolic link to a directory beneath it included — fails the whole request rather than printing a healthy subset | built on the #632 stack | | document validation | validates one supplied root projection and the recursive Markdown source closure normal component selection discovers, returning deterministic version-1 document diagnostics and `valid`, `invalid` or `not-statically-checkable` invocation outcomes without evaluating document code or installing operational host behavior | built on the #654 stack | | `` / `printErrors(fn)` | prints failures | built on main | +| `` | stops authored work with the sentence its author wrote, raised where it is written. An ordinary overridable core default — never structural, never reserved, so a repository `Fail.md` is chosen ahead of it — with a closed schema of one required non-empty `message` and **self-closing only**: a paired spelling never enters its body, and `as` is refused by the body itself because there is nothing to bind. Every refusal reports the invocation and happens before the authored message, so a document that never reached its decision is never reported as having made one. A valid invocation is the ordinary failure of a function component: an `Error` carrying the exact authored message, positioned at the opening tag, rendering nothing and binding nothing. It carries no `printErrors()` declaration, which is what leaves recovery to an authored `` region under ordinary text-root modes; a value body's `throw` is not replaced there, so the authored failure settles the body ahead of missing-`` settlement. No authority, context, provider, resource, module state or durable operation of its own: replay of a completed root restores the recorded outcome without re-expanding the body | built on the #659 stack | | `` | binds one name in the current environment from exactly one source: the content it renders, or the exact value `value` names, bound by reference and never through the JSON boundary component props cross — the scanner resolves no JSON for that one prop, and expansion projects none. Which source it has is read from what the author wrote, before either one runs, so a construct naming both expands no child and evaluates no expression. It opens no scope, owns no resource, adds no middleware boundary and writes no journal record — replay reconstructs both sources through ordinary expansion | built on the #527 stack | | `` | shows any supplied string inside one fenced Markdown code block, choosing a fence one backtick longer than the value's longest backtick run and never shorter than three, so the value cannot close it. An ordinary overridable text default: `value` and the optional single-token `language` are ordinary props on the closed schema, so a repository `CodeBlock.md` receives them the way it receives any other prop. Self-closing only, declared to canonical dispatch rather than decided in the body, so a paired spelling is refused before the body runs and its content never expands; prop validation refuses a missing or non-string `value`, a refused `language` and any unknown prop ahead of both. The value is returned unchanged — nothing trimmed, normalized, escaped or removed — and the framing line feeds belong to the envelope, with no line feed after the closing fence. Exactness is a promise at the function-component return and the `as` capture boundary; the `DocumentOutput` middleware contract governs emitted output after it. It owns no scope, resource, authority or durable effect of its own, so partial and completed replay are the ordinary ones | built on the #658 stack | | `` | renders one supplied value as JSON text where the element was written, from one native two-space `JSON.stringify` call. An ordinary overridable core default whose operand is a capture: the exact evaluation result arrives by reference and is never mutated, cloned, replaced or frozen. It binds nothing, and `as`, content and a missing `value` are all refused before the operand evaluates. A value with no JSON text and a serialization that threw are distinct failures, each positioned at the invocation, emitting no partial output and preserving the original error as its cause. No scope, resource, authority or JSON-specific durable effect: replay reaches it through ordinary expansion, and a surrounding `` or `` keeps its own record of the text it consumed | built on the #452 stack | diff --git a/packages/core/src/components/Fail.ts b/packages/core/src/components/Fail.ts new file mode 100644 index 00000000..0ae0ae77 --- /dev/null +++ b/packages/core/src/components/Fail.ts @@ -0,0 +1,107 @@ +/** + * `` — stop authored work with a message the reader can act on + * (specs/executable-mdx-spec.md §6.8.2). + * + * ```md + * + * ``` + * + * A document that has decided it cannot continue has something to say about + * why, and until now the only way to end a value body was to reach its end + * without a `` — which reports that the body selected no value, not + * what the author concluded. This raises the author's own sentence instead, at + * the position the author wrote it. + * + * ## An ordinary failure, deliberately + * + * There is no new error mode here and no root special case. This is the + * ordinary failure of a function component (§6.8.1): the engine dismantles the + * invocation, names it, positions it at the opening tag, and propagates it. The + * message is the author's exact string — nothing is prefixed, classified or + * interpolated into it, because the author already wrote the sentence they + * wanted the reader to see. + * + * It carries no `printErrors()` declaration, and that omission is the whole of + * its recovery policy. A text root therefore stops here by default, and an + * author who wants the document to continue says so with `` around + * the region — the same way they would for any other component that fails. A + * value body installs `throw`, which a printing boundary does not replace, so + * an enclosing `` cannot turn a deliberate abort into a successful + * result; the authored failure settles the body before missing-`` + * settlement can report anything about it. + * + * ## What it refuses, and why it refuses it first + * + * The one form is self-closing, declared here and dispatched by canonical core, + * so a paired spelling never expands its children: an author who wrote a body + * meant something this component does not do. The closed schema takes one + * required non-empty `message`, so a missing, empty, non-string or unknown prop + * is refused by the ordinary props boundary before anything runs. + * + * `as` is the one refusal this body makes for itself, because `as` is valid for + * a text component by default. There is no value to capture: this renders + * nothing and returns nothing, it raises. Asking {@link hasBinding} before + * raising is what makes a mistaken capture a report about the capture rather + * than the authored message arriving under a name that would never be bound. + * + * ## What it owns + * + * Nothing. No authority, no context, no provider, no resource, and no durable + * operation of its own — the only journal activity around it is what ordinary + * execution already owns, importing the selected component and recording the + * root's outcome. Replay of a completed root restores that outcome without + * re-expanding the root. + */ + +import type { Operation } from "effection"; +import { hasBinding } from "../component-api.ts"; +import type { FormDeclaration, InvocationForm } from "../invocation-identity.ts"; +import type { Json } from "../types.ts"; + +export const props = { + type: "object", + properties: { + message: { type: "string", minLength: 1 }, + }, + required: ["message"], + additionalProperties: false, +}; + +/** An invocation `` will not raise from. */ +export class FailInvocationError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "FailInvocationError"; + } +} + +const BINDING = " raises its message and binds nothing, so `as` is not accepted."; + +const PAIRED = ' is self-closing and has no content: write instead.'; + +function* Fail(props: Record): Operation { + if (yield* hasBinding()) { + throw new FailInvocationError(BINDING); + } + throw new Error(String(props.message)); +} + +/** + * The one form this component runs, and what it says about the other. + * + * A refusal is a `FailInvocationError` about the invocation, never the authored + * message: an invocation this component would not run raised nothing, and + * reporting the author's sentence for it would say the document had decided + * something it never reached. + */ +export const form: FormDeclaration = { + forms: "self-closing", + fn: Fail, + refuse: (_props: Record, written: InvocationForm | undefined) => + new FailInvocationError( + written === "paired" + ? PAIRED + : " was called without the invocation the engine issued, so which form it was " + + "written as cannot be established.", + ), +}; diff --git a/packages/core/src/components/registry.ts b/packages/core/src/components/registry.ts index aa7f3cc9..ae8b0551 100644 --- a/packages/core/src/components/registry.ts +++ b/packages/core/src/components/registry.ts @@ -19,6 +19,7 @@ import type { ComponentRegistry, RegistryEntry } from "../types.ts"; import { form as codeBlockForm, props as codeBlockProps } from "./CodeBlock.ts"; import Elicit, { props as elicitProps, returns as elicitReturns } from "./Elicit.ts"; import TempDir, { props as tempDirProps } from "./TempDir.ts"; +import { form as failForm, props as failProps } from "./Fail.ts"; import Fetch, { props as fetchProps } from "./Fetch.ts"; import { form as fileForm, props as fileProps } from "./File.ts"; import { form as fileDeleteForm, props as fileDeleteProps } from "./FileDelete.ts"; @@ -131,6 +132,14 @@ export const CORE_REGISTRY: ComponentRegistry = new Map([ as: "Optional. Captures the rendered directory path instead of emitting it.", context: "Markdown expanded with the temporary directory as its working directory.", }), + core("Fail", failForm, parseJsonObject(failProps), { + description: + "Stop authored work with an actionable failure. " + + '`` raises the message where it ' + + "is written.", + as: null, + context: null, + }), core("Fetch", Fetch, parseJsonObject(fetchProps), { description: 'Fetch a URL. `` binds the ' + diff --git a/packages/core/tests/component-registration.test.ts b/packages/core/tests/component-registration.test.ts index cbc9a1c2..2c096e7d 100644 --- a/packages/core/tests/component-registration.test.ts +++ b/packages/core/tests/component-registration.test.ts @@ -370,6 +370,7 @@ describe("Tier CR — resolution order", () => { const dir = yield* useFixture(); for (const name of [ "CodeBlock", + "Fail", "Fetch", "File", "Glob", @@ -388,6 +389,7 @@ describe("Tier CR — resolution order", () => { const dir = yield* useFixture(); for (const name of [ "CodeBlock", + "Fail", "Fetch", "File", "Glob", diff --git a/packages/core/tests/fail-component.test.ts b/packages/core/tests/fail-component.test.ts new file mode 100644 index 00000000..fef30901 --- /dev/null +++ b/packages/core/tests/fail-component.test.ts @@ -0,0 +1,457 @@ +/** + * Tier FAIL — `` (spec §6.8.2). + * + * The component ships in core, so every case here drives the real definition + * the way a document reaches it: a file on disk, the real scanner, ordinary + * selection against core's registry, and `execute()` as the root. Nothing calls + * the implementation directly, because what this tier owns is what an authored + * invocation does — where the failure is positioned, what stops after it, and + * which enclosing region can recover it. + * + * Absence of output is never the evidence. A sibling that must not run is a + * registered `` recording its own mark, a projected child that must not + * expand is the same component written inside the element, and an invocation + * that must be refused before the authored message is raised is proved by the + * sentinel message being nowhere in the failure. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, resource, scoped, until } from "effection"; +import type { Operation, Result } from "effection"; +import { forEach } from "@effectionx/stream-helpers"; +import { rm, writeTextFile } from "@effectionx/fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStream } from "@executablemd/durable-streams"; +import type { DurableEvent } from "@executablemd/durable-streams"; +import { execute } from "../src/execute.ts"; +import { Component } from "../src/component-api.ts"; +import { registerComponents } from "../src/components/registration.ts"; +import type { ComponentFailure, ErrorSegment, Json } from "../src/types.ts"; + +/** + * The message a valid invocation would raise, in every case that must never + * reach one. + * + * Distinctive on purpose: a refusal that happened after the body decided to + * raise would carry this string, so its absence from the whole failure is what + * proves the order. + */ +const SENTINEL = "SENTINEL: this document decided to stop"; + +interface Run { + outcome: Result; + /** Text the consumer received, which is where a printed error appears. */ + output: string; + /** Every mark a `` sibling or projected child recorded, in order. */ + ran: string[]; + /** Every failure offered to `handleFailure`, observed outside the document. */ + offered: ComponentFailure[]; + /** Every error segment printed, so "reported once" is countable. */ + observed: ErrorSegment[]; + events: DurableEvent[]; +} + +function useDir(): Operation { + return resource(function* (provide) { + const dir = yield* until(mkdtemp(join(tmpdir(), "xmd-fail-"))); + yield* ensure(function* () { + yield* rm(dir, { recursive: true, force: true }); + }); + yield* provide(dir); + }); +} + +/** A component that records that it ran, so a skipped sibling is observable. */ +function useTripwire(ran: string[]): Operation { + return registerComponents([ + { + name: "Ran", + origin: "tier-fail", + props: { + type: "object", + properties: { mark: { type: "string" } }, + required: ["mark"], + additionalProperties: false, + }, + // deno-lint-ignore require-yield + *fn(props) { + ran.push(String(props.mark)); + return ""; + }, + }, + ]); +} + +/** + * Run one document, reporting the outcome rather than unwrapping it. + * + * The stream is a parameter so a case about replay can hand the same one to two + * runs; every other case gets a fresh one. + */ +function run( + dir: string, + source: string, + options: { stream?: InMemoryStream; files?: Record } = {}, +): Operation { + return scoped(function* () { + const path = join(dir, "doc.md"); + yield* writeTextFile(path, source); + for (const [name, content] of Object.entries(options.files ?? {})) { + yield* writeTextFile(join(dir, name), content); + } + const stream = options.stream ?? new InMemoryStream(); + const offered: ComponentFailure[] = []; + const observed: ErrorSegment[] = []; + const ran: string[] = []; + yield* Component.around({ + *handleFailure([failure], next) { + offered.push(failure); + return yield* next(failure); + }, + *raise([segment], next) { + observed.push(segment); + return yield* next(segment); + }, + }); + yield* useTripwire(ran); + const execution = yield* execute({ path, stream, includes: [dir] }); + const outcome = yield* execution; + const output = yield* forEach(function* (_chunk: string) {}, execution.output); + return { outcome, output, ran, offered, observed, events: yield* stream.readAll() }; + }); +} + +/** The failure a run ended with, failing the test if it completed instead. */ +function failureOf(result: Run): Error { + if (result.outcome.ok) { + throw new Error(`expected the document to fail, but it completed: ${result.outcome.value}`); + } + return result.outcome.error; +} + +/** Whether `target` is reachable from `error` by identity. */ +function reaches(error: unknown, target: unknown, seen = new Set()): boolean { + if (error === target) { + return true; + } + if (typeof error !== "object" || error === null || seen.has(error)) { + return false; + } + seen.add(error); + if (error instanceof AggregateError && error.errors.some((e) => reaches(e, target, seen))) { + return true; + } + return error instanceof Error && error.cause !== undefined + ? reaches(error.cause, target, seen) + : false; +} + +/** Every sentence a run produced, so a sentinel's absence covers all of them. */ +function everythingSaid(result: Run): string { + return [ + result.outcome.ok ? String(result.outcome.value) : result.outcome.error.message, + result.output, + ...result.observed.map((segment) => segment.message), + ...result.offered.map((failure) => failure.error.message), + ].join("\n"); +} + +/** The one failure `` offered, failing the test if it offered none. */ +function offeredByFail(result: Run): ComponentFailure { + const failures = result.offered.filter((failure) => failure.name === "Fail"); + if (failures.length !== 1) { + throw new Error(`expected exactly one failure, got ${failures.length}`); + } + return failures[0]; +} + +const MISSING_RETURN = "The root document declares `returns` but produced no value."; +const VALUE_ROOT = ["---", "returns:", " type: string", "---", ""].join("\n"); + +describe("Tier FAIL — stopping a document deliberately", () => { + it("FAIL1: ends a plain root with the authored message and runs no later sibling", function* () { + const dir = yield* useDir(); + const result = yield* run( + dir, + [ + "before", + "", + '', + "", + '', + "", + ].join("\n"), + ); + + // The authored sentence is the outcome, unprefixed and unclassified. + expect(failureOf(result).message).toBe("Review aborted; nothing was saved."); + // Text before it had already streamed; the component itself rendered none. + expect(result.output).toBe("before\n\n"); + expect(result.ran).toEqual([]); + // Nothing printed it: an unmarked component leaves recovery to the region. + expect(result.observed).toEqual([]); + }); + + it("FAIL2: fails a value root instead of reporting a missing ", function* () { + const dir = yield* useDir(); + const result = yield* run( + dir, + [ + VALUE_ROOT, + '', + "", + '', + "", + ].join("\n"), + ); + + const failure = failureOf(result); + // The authored error itself, by identity, rather than a message that reads + // like it. + expect(reaches(failure, offeredByFail(result).error)).toBe(true); + expect(failure.message).toBe("No candidate was approvable."); + expect(failure.message).not.toBe(MISSING_RETURN); + expect(everythingSaid(result)).not.toContain("produced no value"); + }); + + it("FAIL3: does nothing beneath an unselected , and the root returns", function* () { + const dir = yield* useDir(); + const result = yield* run( + dir, + [ + VALUE_ROOT, + ``, + "", + '', + "", + '', + "", + ].join("\n"), + ); + + expect(result.outcome).toEqual({ ok: true, value: "approved" }); + expect(result.ran).toEqual(["after"]); + expect(everythingSaid(result)).not.toContain(SENTINEL); + // An unselected branch imports nothing, so the component was never reached. + expect(importedNames(result.events)).not.toContain("Fail"); + }); + + it("FAIL4: names loop exhaustion after the loop reached its own max", function* () { + const dir = yield* useDir(); + const result = yield* run( + dir, + [ + VALUE_ROOT, + '', + "", + '', + "", + '', + "", + '', + "", + ].join("\n"), + ); + + // The loop finished normally; the failure is the sibling chosen after it. + expect(result.ran).toEqual(["attempt", "attempt"]); + expect(failureOf(result).message).toBe( + "No attempt produced an approvable candidate in 2 tries.", + ); + expect(result.ran).not.toContain("after"); + expect(everythingSaid(result)).not.toContain("produced no value"); + }); + + it("FAIL5: is recovered by an authored in a text root, once", function* () { + const dir = yield* useDir(); + const result = yield* run( + dir, + [ + "", + '', + "", + "", + '', + "tail", + "", + ].join("\n"), + ); + + expect(result.outcome.ok).toBe(true); + // Reported exactly once, as an ordinary function-component diagnostic. + expect(result.observed.map((segment) => segment.message)).toEqual([ + "Function component Fail error: This region was abandoned.", + ]); + // Later siblings run, and the component contributed nothing but that one + // printed error — the sentence appears exactly once in what the reader saw. + expect(result.ran).toEqual(["after"]); + expect(result.output).toContain("tail"); + expect(result.output.split("This region was abandoned.").length - 1).toBe(1); + }); + + it("FAIL6: is not recovered by in a value root", function* () { + const dir = yield* useDir(); + const result = yield* run( + dir, + [ + VALUE_ROOT, + "", + '', + "", + "", + '', + "", + ].join("\n"), + ); + + const failure = failureOf(result); + // `throw` is the mode a boundary does not replace, so the authored failure + // is still what the root settled on — and the later never ran. + expect(reaches(failure, offeredByFail(result).error)).toBe(true); + expect(failure.message).toContain("Nothing here is publishable."); + expect(everythingSaid(result)).not.toContain("produced no value"); + }); +}); + +describe("Tier FAIL — invocations that never raise the authored message", () => { + const refusals: readonly [string, string, string][] = [ + [ + "FAIL7a: paired content is refused without expanding it", + [``, '', "", ""].join( + "\n", + ), + ' is self-closing and has no content: write instead.', + ], + [ + "FAIL7b: an empty paired spelling is paired content too", + `\n`, + ' is self-closing and has no content: write instead.', + ], + [ + "FAIL7c: `as` is refused, because there is nothing to bind", + `\n`, + " raises its message and binds nothing, so `as` is not accepted.", + ], + [ + "FAIL7d: a missing message is refused by the schema", + "\n", + "must have required property 'message'", + ], + [ + "FAIL7e: an empty message is refused by the schema", + '\n', + "must NOT have fewer than 1 characters", + ], + [ + "FAIL7f: a non-string message is refused by the schema", + "\n", + "must be string", + ], + [ + "FAIL7g: an unknown prop is refused by the closed schema", + `\n`, + "must NOT have additional properties", + ], + ]; + + for (const [title, source, expected] of refusals) { + it(title, function* () { + const dir = yield* useDir(); + const result = yield* run(dir, source); + + expect(failureOf(result).message).toContain(expected); + // The refusal happened first: nothing anywhere carries the message a + // valid invocation would have raised. + expect(everythingSaid(result)).not.toContain(SENTINEL); + // A refused paired invocation never expands its children. + expect(result.ran).toEqual([]); + expect(result.output).toBe(""); + }); + } +}); + +describe("Tier FAIL — position, durability and precedence", () => { + it("FAIL8: positions the failure at the opening tag it was written on", function* () { + const dir = yield* useDir(); + const lines = [ + "# Review", + "", + "some prose", + "", + '', + "", + ]; + const source = lines.join("\n"); + const result = yield* run(dir, source); + + const failure = offeredByFail(result); + expect(failure.name).toBe("Fail"); + expect(failure.error.message).toBe("Stopped at line five."); + expect(failure.position).toEqual({ + path: join(dir, "doc.md"), + offset: source.indexOf("', + "", + '', + "", + ].join("\n"); + + const first = yield* run(dir, source, { stream }); + + expect(failureOf(first).message).toBe("Stop, and stay stopped."); + // Ordinary execution's own records, and nothing else: the root's import, + // each authored component's import, and the root outcome. + expect(first.events.map((event) => event.type)).toEqual(["yield", "yield", "yield", "close"]); + expect(importedNames(first.events)).toEqual(["__root__", "Ran", "Fail"]); + + const second = yield* run(dir, source, { stream }); + + // The retained outcome, restored without expanding the root again — the + // tripwire before the failure would have recorded a second run. + expect(failureOf(second).message).toBe("Stop, and stay stopped."); + expect(second.ran).toEqual([]); + expect(second.events.length).toBe(first.events.length); + }); + + it("FAIL10: a repository Fail.md is chosen ahead of core's", function* () { + const dir = yield* useDir(); + const result = yield* run(dir, `\n`, { + files: { + "Fail.md": [ + "---", + "props:", + " message:", + " type: string", + "---", + "noted: {props.message}", + "", + ].join("\n"), + }, + }); + + expect(result.outcome.ok).toBe(true); + expect(result.output).toContain(`noted: ${SENTINEL}`); + expect(result.offered).toEqual([]); + }); +}); + +/** The components a run imported, in the order it imported them. */ +function importedNames(events: DurableEvent[]): string[] { + return events.flatMap((event) => + event.type === "yield" && event.description.type === "import_component" + ? [String(event.description.name)] + : [], + ); +} diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 02f9877a..e2db7c26 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -909,6 +909,34 @@ describe("Tier SY: complete component contracts", () => { expect(entry.context).toBe(undefined); }); + it("SY24c: describes completely enough to copy the invocation", function* () { + const catalog = yield* catalogFor({}, []); + + // The whole row, so a drift in origin, form, schema, return mode or prose + // is a failure here rather than a surprise for an author reading it. + expect(find(builtIn(catalog), "Fail")).toEqual({ + kind: "component", + name: "Fail", + origin: { kind: "registered", origin: "@executablemd/core", reserved: false }, + sourceKind: "registered", + inspectability: "complete", + forms: ["self-closing"], + props: { + type: "object", + properties: { message: { type: "string", minLength: 1 } }, + required: ["message"], + additionalProperties: false, + }, + captures: [], + returnMode: "text", + returns: { type: "string" }, + description: + "Stop authored work with an actionable failure. " + + '`` raises the message where ' + + "it is written.", + }); + }); + it("SY25: preserves the declaration order of captures and forms", function* () { const catalog = yield* scoped(function* () { yield* useTree({}); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index b3fc20d6..76395550 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2723,12 +2723,12 @@ nothing. A non-string is not an error — it is a value with no destination. #### The components core supplies -Some components are core's own: `` (§6.11), ``, `` and -`` (§6.12), `` (§6.13), `` (§6.13.1), `` -(§6.14), `` (§6.18), and `` (§6.19). Each is already -in the module graph, so it ships in the compiled binary and every published -package without a search path or a bundling step, and a document invokes it with -no `--include`. +Some components are core's own: `` (§6.8.2), `` (§6.11), +``, `` and `` (§6.12), `` (§6.13), `` +(§6.13.1), `` (§6.14), `` (§6.18), and `` (§6.19). Each +is already in the module graph, so it ships in the compiled binary and every +published package without a search path or a bundling step, and a document +invokes it with no `--include`. They are ordinary **defaults**, not reserved names: a repository component called `Parse.md` is chosen ahead of core's ``, exactly as it would be @@ -3401,8 +3401,11 @@ or more `` elements anywhere in that body's own flow and executes exactly one of them, validates that value against its schema, and completes with the validated JSON. A conditional return is therefore how a root selects a value only when a condition holds; when none executes, the root completes `Err` with -`The root document declares \`returns\` but produced no value.`, which -is how a bounded search that found nothing reports itself. Selection does not +`The root document declares \`returns\` but produced no value.`. That is +the structural settlement error for a body whose executed flow selected no +value, not the way to express a decision: a document that deliberately aborts, +or that exhausted a bounded search, says so with `` and the sentence its +author wrote (§6.8.2). Selection does not end the root: documentation after it still runs. Its rendered body Markdown is not its result: the output stream stays an observability channel a consumer may watch independently, and a printed error can never pass for a result. A value @@ -5877,6 +5880,48 @@ that boundary's own author gated. Printing it at a component's own declaration, in a region that does not print, would resume what the *region's* author gated — so that one passes outward instead. +### 6.8.2 Stopping deliberately: `` + +A document that has decided it cannot go on has something to say about why: + +```md + +``` + +`` raises that sentence where it is written. It is an ordinary component +core supplies (§5.3), not a structural construct and not a reserved name: a +repository `Fail.md` is chosen ahead of it exactly as it would be ahead of any +other registration. + +**One form, one prop.** The only authored spelling is self-closing, declared by +the component and entered through canonical invocation-form dispatch (§5.6), so +a paired invocation is refused without its children expanding. The props schema +is closed and takes one required `message` of at least one character, so a +missing, empty, non-string or unknown prop is refused by ordinary prop +validation (§6.5). `as` is refused too, by the component itself: there is no +value to capture. Each of those refusals happens before the authored message is +raised and reports the invocation rather than the author's sentence, so a +document that never reached its decision is never reported as having made one. + +**A valid invocation is an ordinary function-component failure** (§6.8.1). It +throws an `Error` whose message is the exact authored string — nothing is +prefixed, rewritten, classified or interpolated into it — and the invocation +boundary supplies the component name and the source position of the opening tag. +It renders nothing, binds nothing, declares no `returns` and performs no durable +operation of its own; the only records around it are the ones ordinary execution +already writes, the component import and the root outcome, so a completed root +replays its failure without re-expanding the body. + +**Recovery is the region's, never the component's.** The implementation carries +no `printErrors()` declaration, so a text root stops at it by default and an +author asks for continuation explicitly by writing `` around the +region — which prints the ordinary function-component diagnostic once and lets +later siblings run. A value body installs `throw`, the one mode a printing +boundary does not replace, so an enclosing `` there cannot turn a +deliberate abort into a successful result: the authored failure settles the +body, a later `` is not selected, and the outcome is the authored +sentence rather than the missing-`` message (§6.10). + ### 6.9 Component-declared output: `` A component (or root document) declares which region of its body renders using @@ -6212,6 +6257,12 @@ The root document declares `returns` but produced no value. declares `returns` but produced no value. ``` +Both report a structural outcome: the body ran, and its flow reached no +``. Neither carries what the author concluded, because the author wrote +nothing for them to carry. A body that decided it cannot produce a value raises +that decision instead, with `` (§6.8.2), and the authored failure settles +the body before this message can be produced for it. + Selection appends no durable event. A partial replay replays completed effects and reconstructs the same ownership while re-executing the authored selection; a completed document replay reuses the retained terminal result without expanding @@ -9761,6 +9812,29 @@ platform's. | RF5 | `printErrors(fn)` | A component's own failure is still printed and the root continues | | RF6 | Replay | The determined failure and partial output replay from the record, with nothing appended and no command run again | +### Tier FAIL — `` (§6.8.2) + +Driven through `execute()` against real files, ordinary core selection and real +expansion. A sibling that must not run records its own mark, so a stopped +document is proved by what did not happen rather than by absent output. The +recovery boundaries themselves are Tier OM's and Tier RF's, the catalog row is +Tier SY's, the loop's own bound is Tier LOOP's, and missing-`` +settlement is Tier RV's; these rows cross-reference them rather than restating +them. + +| # | Test | Verify | +|---|------|--------| +| FAIL1 | A plain root | The run fails with the exact authored message, the prefix already streamed survives, the component renders nothing, the sibling after it never runs, and nothing printed it | +| FAIL2 | A value root | The authored `Error` reaches the completion by identity; the missing-`` message appears nowhere, though a `` is written later in the body | +| FAIL3 | An unselected `` | Nothing is imported, nothing is raised, the sibling after it runs, and the root selects its ordinary return | +| FAIL4 | Loop exhaustion | A `` reaches its own `max`, then the sibling `` names exhaustion; the failure is the one the author chose, and the work after it never begins | +| FAIL5 | Authored recovery | Inside `` in a text root, the failure is reported once as an ordinary function-component diagnostic and the later sibling runs | +| FAIL6 | Value-root non-recovery | The same element inside `` in a value root still fails the root with the authored error, and the later `` is not selected | +| FAIL7a–FAIL7g | Refused invocations | Paired content, an empty paired spelling, `as`, a missing, empty or non-string `message`, and an unknown prop are each refused; the message a valid invocation would have raised appears nowhere, and paired children never expand | +| FAIL8 | The position | The offered failure names `Fail`, the exact authored message, and the path, offset, line and column of the opening tag | +| FAIL9 | Durability | A failed root's journal holds the root import, each authored component import and the root outcome, and nothing else; the same stream replays the same failure with no event appended and no re-expansion | +| FAIL10 | Ordinary shadowing | A repository `Fail.md` is selected ahead of core's and renders its own content | + ### Tier IM — Expansion metadata | # | Test | Verify |