💥 feat: make <Output> fail fast and rename CollectFailures to CaptureErrors - #312
💥 feat: make <Output> fail fast and rename CollectFailures to CaptureErrors#312taras wants to merge 6 commits into
Conversation
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>, captureErrors(fn), capturesErrors(), useFailures(). The old tag and export are gone rather than aliased — <CollectFailures> now resolves like any other unknown component. Mechanical: no behavior changes.
`<Output>` collected failures and carried on. That is the wrong default for
an operational document: a failed preview could still reach a later
<Elicit> 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:
<Output>
<CaptureErrors>
... work whose errors should render without aborting ...
</CaptureErrors>
</Output>
Three settlement policies replace two. `collect` renders and continues, and
is still what a root without <Output> 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 `<Capture as>` 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
|
|
||
| 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // nothing left to hand back — its consumer settles what is now in place. |
PR #312: 💥 feat: make fail fast and rename CollectFailures to CaptureErrors37 files, +2307 / -333 Scope🔴 PR has 2640 lines changed. Split into focused PRs. 🟡 2640 lines changed. PRs under 400 receive more thorough review. 🟡 37 files changed. Are all changes related? 🟡 PR mixes config and source changes. Structural🟡 Type declarations with no consumers: ExpandSegments.
Slop
Static Analysis✅ Oxlint found no issues. CorrectnessNo extraneous code patterns detected. |
…e journal 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 `<If>` branch, `<Loop>` iterations, a rendering `<Each>`, an answered `<Answers>` body, and projected `<Content />` 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: `<Capture>`, `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.
| : { source: documentation.segment.source }), | ||
| }, | ||
| // An own property, not an inherited one: every Error inherits `cause` from | ||
| // nowhere useful, and what this records is what this failure was given. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // nowhere useful, and what this records is what this failure was given. |
| } | ||
| 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hand back; one that kept its own returns what it rendered. |
| ...(yield* expandCollectFailures(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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // and writes there rather than handing segments back to be appended. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // the output the document rendered before failing. |
… stream Two demonstrated contract violations, and the spec drift behind them. **A capture boundary was deciding for its callees.** `useFailures()` marked every diagnostic raised anywhere beneath it, so an enclosing boundary flipped a nested component's `<Output>` region from fail-fast to render-and-continue — running the very commands that region's author gated behind the failure. Every built-in that captures its own failures (`<File>`, `<Parse>`, `<SafeParse>`, `<Glob>`, `<TempDir>`) did it just by wrapping the invocation, in a plain collecting root with no `<Output>` in sight. A policy value cannot tell those apart: two frames can both be `output` and still be different decisions, made by different authors about different regions. So a decision now has identity — `AmbientPolicyFrame`, a fresh object per `usePolicy()` — and a boundary marks only what is raised under its own. A projection carries the caller's frame rather than opening one, because it expands the caller's own text. What reaches the boundary from a nested region is the invocation's failure, which it handles on its own terms; a failure that region already settled stays outside that recovery, like every already-settled documentation failure. **The preserved tail never reached the stream.** It was left to the completion path, which only emits for a run that streamed nothing at all — so a document with any earlier output silently dropped exactly what the preservation exists for, and only the close value carried it. It is emitted in the workflow's catch now, before the failure is classified. **§10.2.1 still described the null sentinels** the schema had already dropped. It now describes absent keys, `Object.hasOwn` ownership, and the refusal of malformed records; the Tier OFF table lists every test again, so "identifiers match the file" is true. Minor: `capturesErrors` asks `Object.hasOwn` rather than `in`, so a derived object cannot inherit the brand; the live failure rides on the outcome under a non-enumerable symbol instead of a module-scoped WeakMap; the lint rule reports a static class field, which is module lifetime; and an old journal with no recorded status now says so instead of reporting a generic shape error.
| : { source: documentation.segment.source }), | ||
| }, | ||
| // An own property, not an inherited one: every Error inherits `cause` from | ||
| // nowhere useful, and what this records is what this failure was given. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // nowhere useful, and what this records is what this failure was given. |
| } | ||
| 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hand back; one that kept its own returns what it rendered. |
| 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // a value instead: its buffer is private and never becomes document output. |
| * module-scoped ones, including exported and lazily-assigned declarations. | ||
| */ | ||
| // A class body is not a lifetime: a class declared at module scope holds its | ||
| // fields for the life of the module, which is exactly the shape this reports. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // fields for the life of the module, which is exactly the shape this reports. |
State belongs to a scope that reclaims it. Three registries in this package
belonged to the process instead, and the previous commit made that worse by
moving one onto the objects it described — the same lifetime, spread across
more places, under a `Symbol.for` key anyone can forge.
Each is now created where a run begins and reclaimed with it:
- the diagnostics a run captured, and what expansion translated each segment
from, are one `Diagnostics` record opened by `useDiagnostics()` and read
through context. `markCaptured`, `isCaptured` and `attributeCause` became
operations, and a `DocumentationError` is built by `documentationError()`,
which reads the cause before constructing — a constructor cannot reach a
scope. Expansion driven directly, with no execution around it, opens the
registries for exactly its own lifetime.
- the failure a completion reports is left in a slot the run provides, filled
by the workflow's catch and taken by the completion. A replayed run never
enters the workflow, so the empty slot is the replay signal — no brand, no
table, nothing to clear.
- the schema caches are gone. Ajv already memoizes by schema object, and the
contract checks now run on every call, which is what the local caches were
skipping.
`AmbientPolicyFrame`'s default was a module-scoped object shared by every run
— inert, but the banned shape, and the `?? {}` fallbacks around it would have
silently disabled marking if they ever fired. The default is now `undefined`,
matched by identity like any other frame, so no fallback is needed.
`captureErrors(fn)` keeps its brand on the function: it runs while a component
module is evaluated, outside any operation, and records what an author
declared about a definition rather than anything a run decided. The key is a
module-private `Symbol()` now, and the rule's docs draw that line.
The rule is `local/no-module-scoped-registry`: it covers `Map` and `Set`
alongside the weak kinds, and reports the tables a module creates empty and
fills later. A table built from its contents is a constant, an instance field
belongs to its object, and one handed straight to a call is not something the
module can accumulate into. Confirmed reporting all four converted
declarations before the conversions.
Also: the crossed OFF6b/OFF6c comments, and the one test left without an
identifier — the `"undefined"` cause — now has both a label and a spec row.
| const causes = (yield* RunDiagnostics.get())?.causes; | ||
| // Membership, not value: a component can throw `undefined`, and that is still | ||
| // the exact value this failure was translated from. Only a segment with no | ||
| // attribution has no own cause at all. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // attribution has no own cause at all. |
| : { source: documentation.segment.source }), | ||
| }, | ||
| // An own property, not an inherited one: every Error inherits `cause` from | ||
| // nowhere useful, and what this records is what this failure was given. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // nowhere useful, and what this records is what this failure was given. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // the region itself and the prefix is already where the document needs it. |
| } | ||
| 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hand back; one that kept its own returns what it rendered. |
| 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // a value instead: its buffer is private and never becomes document output. |
| * module-private `Symbol()` is how it is written. | ||
| */ | ||
| // A class body is not a lifetime: a class declared at module scope holds its | ||
| // fields for the life of the module, which is exactly the shape this reports. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // fields for the life of the module, which is exactly the shape this reports. |
| } | ||
|
|
||
| // An instance field is initialized per instance, so its table belongs to | ||
| // that object's lifetime. A static one belongs to the module. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // that object's lifetime. A static one belongs to the module. |
|
|
||
| // Handed straight to a call — a context's empty default, a provider's | ||
| // initial registry — and never bound here. The module holds no reference, | ||
| // so nothing in it can accumulate into the table. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // so nothing in it can accumulate into the table. |
Two things from review. `handleFailure` was marking the diagnostic it built as well as the `raise` middleware, on the theory that a provider's own call does not re-enter the middleware it is part of. It does — the suite passes with the call removed, and it passed before the call existed. Removed rather than left standing without a test that goes red without it. The Ajv instance in `parse-schema.ts` was the shape the deleted schema caches were fronting: Ajv memoizes every compile in a table keyed by the schema object, and a run brings fresh schema objects, so one compiler per process accumulates one entry per schema per run. It belongs to the run now, created where the run opens its other registries and reclaimed with them. `compileParseSchema` and `prepareElicitation` became operations to read it; expansion driven with no run around it compiles into a instance that lives exactly as long as the call. The Ajv in `validate.ts` is *not* converted. Reaching it means making `validateProps` and `parseMarkdownDefinition` operations — both synchronous, both public — which is a public-API change that belongs in its own PR. That, and whether `captureErrors(fn)` should keep its brand on the function or move capture-ness onto the per-run `ComponentDefinition`, are written into the rule's docs as open rulings, so both are decisions somebody makes rather than defaults the linter quietly settled.
| ): Operation<ValidateFunction> { | ||
| const declaration = readSchema(componentName, schema); | ||
| // Without a run there is nothing to reclaim: the compiler lives exactly as | ||
| // long as this call. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // long as this call. |
| : { source: documentation.segment.source }), | ||
| }, | ||
| // An own property, not an inherited one: every Error inherits `cause` from | ||
| // nowhere useful, and what this records is what this failure was given. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // nowhere useful, and what this records is what this failure was given. |
|
|
||
| // What the document rendered before it stopped, held outside the expansion | ||
| // scope so a failure still leaves it here (§6.9). The buffered root fills | ||
| // `selected`; every streaming root has already emitted `streamed`. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // `selected`; every streaming root has already emitted `streamed`. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // still leaves this frame holding what the regions rendered before it. |
| // Emitted here, whatever the failure turns out to be: a consumer reading | ||
| // chunks is who the preservation is for, and the completion path only emits | ||
| // for a run that streamed nothing at all — which stops being true as soon as | ||
| // an earlier segment went out. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // an earlier segment went out. |
| throw error; | ||
| } | ||
| // Everything the document rendered: the buffered selection, or what the | ||
| // streaming loop emitted together with that tail. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // streaming loop emitted together with that tail. |
| // 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // the region itself and the prefix is already where the document needs it. |
| } | ||
| 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. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hand back; one that kept its own returns what it rendered. |
| // same schema costs one compile however often it is asked for. Nothing here | ||
| // keeps a table of its own: a cache beside Ajv's would also have to decide | ||
| // whose contract a cached validator was compiled under, and the contract checks | ||
| // below run on every call precisely so that question cannot arise. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // below run on every call precisely so that question cannot arise. |
|
Closing without merge — superseded by the architecture settled in The model this PR converged on was simplified after it was written: control at raise now belongs to the error mode value alone, so the frame/marking apparatus this branch carries ( The branch stays as a parts bin. Commit |
Closes #309. Builds on #310.
What changed
<Output>is fail-fast. A region renders what it produced before a failure, then the execution fails; no later sibling, region, documentation block, or effect begins. Continuing is asked for explicitly and stays scope-local:Three settlement policies replace two (
packages/core/src/errors.ts):collect<Output>output<Output>region, root and component alikethrowsettle()is the only place that branches on the policy, so this is one function's decision.Capture is remembered by identity.
useFailures()installs araisemiddleware that marks every segment raised beneath the boundary — including the structural ones the region reports itself, not just failures translated throughhandleFailure— on the original object, with no clone and no second observation. That mark is what lets a captured diagnostic cross an invocation boundary into a fail-fast caller and still render.Output ownership. Preserving partial output meant saying who owns rendered segments: expansion writes into the accumulator its caller gave it (
expandSegments' existingcollectparameter, nowowner), and a call site producing a binding, a value, or a string owns a private one. A failing<Capture as>oras=invocation therefore never promotes content the document was not going to render — asserted by OFF9, not assumed.A failed document is a determined durable outcome.
documentWorkflowcatches an ordinaryDocumentationErroroutsideyield* scopedExpansion, so teardown and any aggregate wrapper are complete first, and returns{status: "err", output, error}. The root closesokaround it, so replay restores both halves without re-entering the workflow.durabilityFailure()is consulted first and still escapes; a failure before entering or after returning fromdurableRunstill produces no close. A live run resolves the error it actually caught (sidecar keyed on the returned outcome); a replayed run reports the reconstruction the recorded fields describe —AggregateErrorwhenerrorsis present,Errorotherwise. The replayed value is parsed field by field, never cast.Rename (breaking)
<CollectFailures>→<CaptureErrors>,collectFailures()→captureErrors(),collectsFailures()→capturesErrors(),useFailureCollection()→useFailures(), plus the diagnostic and structural helper names and the reservation entry. No aliases —<CollectFailures>now resolves like any unknown component. The sweep for both old names returns nothing outside git history. Commit 1 is the rename alone, with a green gate, so it reviews separately from the semantics.Tests
New
packages/core/tests/output-fail-fast.test.ts(Tier OFF, 14 tests). Verified against193da3c4in a throwaway worktree, and the split matched the plan exactly:ascapture is not output.Mutations run and confirmed: omitting the captured mark reddens OFF5d; treating
outputasthrowreddens the capture tests. A third mutation — a policy downgrade inside the capture boundary — turned out to redden nothing, so that code was removed rather than kept unguarded.Existing tests changed, and why
Each is #309's settled contract, not a test bent to fit:
expand.test.ts"keeps errors inside an<Output>region as comments"expand.test.tschild/content errors inside parent<Output><CaptureErrors>, plus a new uncaptured-fails counterpartexecute.test.ts"emits no partial output…"eval-policy.test.tsO24, O31<CaptureErrors>; O29 restated to fail-fastloop.test.tsLOOP44, LOOP46okaround the failed outcomeO29 also surfaced a pre-existing limit worth knowing:
<Content />is only claimed at a region's top level, so it cannot be nested inside<CaptureErrors>— the fixture stays unwrapped and asserts fail-fast.Spec
§6.9 now distinguishes four situations (collecting root, fail-fast region, fail-fast documentation, captured-inside-region) with a table, and states the capture mark; §6.8.1 covers the marking; §10 gains 10.2.1, the root-close outcome contract with the exact persisted
errorfields; C43, E12 and decision 68 rewritten; Tier OFF rows added.Verification
Runtimes asserted first (Deno 2.9.1, Node v22.23.2, Bun 1.3.14), one
deno task setup, nothing reinstalled after:deno task fmt/lintdeno task checkdeno task testdeno task check:jsrtsc --project tsconfig.node.jsonpnpm test:nodebun run test:bundeno task build+./dist/xmd test packages/core/src --raw./dist/xmd test smoke-test/README.md(CI's component dirs)git diff --summary/--check