💥 feat(core): return schema-validated values from components and roots - #200
Merged
Conversation
Components produced one thing — rendered Markdown — and `as` captured it as
a string. Agent workflows need validated values for control flow, so a
verdict, a finding list, or a question had to be rendered as text and
re-parsed.
A `returns` declaration now switches a component into value mode. It renders
nothing, must be invoked with `as`, and binds the JSON value its single direct
top-level `<Return value={…} />` produces, validated at the component
boundary. Components without `returns` are unchanged, and their effective
return schema is `{ type: "string" }`.
`returns` is an object: a draft-07 schema marked by `type`/`$schema`, or the
concise object-return shorthand, where every declared property is required.
Markdown frontmatter and a function component's `export const returns` share
one parser, so the two declaration sites cannot drift.
`<Return>` selects the value; it does not end the body. Documentation before
and after it runs in document order under fail-fast, its rendering discarded,
and the expression is evaluated in place. Structure is validated against the
component's own source before any body effect: a missing, duplicate, nested,
or misplaced `<Return>`, `<Output>` alongside `returns`, bad `<Return>` props,
and a value component invoked without `as` all fail before eval, exec, or
capture runs. `<Return>` is reserved throughout expansion, so a projected or
dynamically produced one is diagnosed rather than resolving `Return.md`.
Values cross a strict JSON boundary before their schema: `undefined`,
non-finite numbers, class instances, and cyclic objects are rejected, and
schema defaults fill the returned clone rather than the producer's object.
A root uses the same modes minus `as`. A value root executes its complete body,
completes with its validated JSON, and keeps rendered body text on `.output` as
observability — never as a result, so every failure completes `Err`. `xmd run`
reserves stdout for that value as JSON; `--verbose` moves body output and
journal diagnostics to stderr, and a failure exits non-zero with empty stdout.
💥 `DocumentExecution` completes with `Result<Json>` rather than
`Result<string>`, and `collect()` returns `Json`. A text root still completes
with its rendered Markdown. `inspectDocument()` gains `returns` and
`returnMode`, reporting the effective schema without executing the document.
Closes #176
PR #200: 💥 feat(core): return schema-validated values from components and roots49 files, +2906 / -851 Scope🔴 PR has 3757 lines changed. Split into focused PRs. 🟡 3757 lines changed. PRs under 400 receive more thorough review. 🟡 49 files changed. Are all changes related? 🟡 PR mixes config and source changes. 🟡 New abstraction files: packages/core/tests/helpers.ts. Verify 3+ consumers. 🟡 package.json changed without dependency justification. Structural✅ No structural bloat detected. Slop✅ Slop indicators look low. Static Analysis✅ Oxlint found no issues. CorrectnessNo extraneous code patterns detected. |
The site checks formatting with `deno fmt`, not the repository's oxfmt, so the new section failed the site job's fmt gate.
Every suite that shells out to xmd rebuilt the same subprocess plumbing: spawned stdout and stderr readers, chunk arrays, a TextDecoder, and a hand-rolled timeout around Process.join(). `@effectionx/process` already captures both streams — `Exec.expect()` for a run that must succeed, `Exec.join()` when the test inspects a failure — so none of it was needed. `@executablemd/test-support/launch` now owns launching as well as naming the command: `runCli(args, options)` returns the exit status with captured output, `expectCli(args, options)` raises on a nonzero exit, and both configure cwd, environment, and timeout through one path beside `cliCommand`. A run inherits only what a subprocess needs — PATH, HOME, and the runtime cache variables — with `env` overriding and `inheritEnv` keeping the whole environment for the suites that ran that way. Migrated the value-root, agent-cli, props-cli, cli-help, command, and testing CLI suites onto it. Assertions are unchanged; the agent suite keeps its isolated HOME by passing it as an override, and the testing suite keeps its 30s bound and full environment. Also brings the function-component fixture in the Tier RV suite onto `@effectionx/fs`, leaving `node:fs` only for the symlink it does not provide. Removing the directory removes that symlink with it, so the explicit unlink is gone — it could throw before the fixture was cleaned up when setup failed before the symlink existed.
Review feedback on the shared launcher, and the rest of the CLI half of #201. `runCli(args, options)` now returns a bounded run with `.expect()` and `.join()`, matching how `@effectionx/process` is synchronized everywhere else, so choosing between "must succeed" and "inspect the failure" reads the same in a suite as it does with a plain exec. `expectCli()` is gone. The minimal environment no longer includes `HOME`. A run that exercises user configuration supplies an isolated one — the agent suite already did — and a run that genuinely needs the whole environment opts in with `inheritEnv`. Migrated the two remaining finite CLI runners, in `cli-journal.test.ts` and the test-agent smoke suite; both had their own capture plumbing and timeout. `worker-lifecycle.test.ts` keeps using `@effectionx/process` directly, since it consumes the worker's streams live. `@effectionx/process` and `@effectionx/timebox` are now declared where they are used, in `packages/test-support/package.json`. The temporary-project helpers stay in #201 for their own cleanup.
AGENTS.md rule 4 keeps comments for surprising behavior. Several added with the return contract only named the expression, helper, constant, or field below them: what `evaluateIn` evaluates with, what `TEXT_RETURN_SCHEMA` holds, that a `returns?: ReturnsSchema` field is a return schema, what a test's `CAPTURING_ROOT` captures, and that a file of helpers holds helpers. The reasons stay: replay output restoration, the text and value channels, value-root fail-fast, binding after the component's scope unwinds, why props and returns keep separate validator caches, the JSON clone and its defaults, how a function-component fixture resolves its modules, why removing the directory is enough to remove the symlink, and what a run inherits. Comments only — no behavior or structure changed.
The remaining restatements from the return contract: a preflight comment repeating what `validateBodyStructure()` documents, two capture-requirement comments repeating the check and message beneath them, a ternary narrated by its own branches, a segment helper described by its name, and a test comment naming the assertion under it. The root's preflight comment stays — it records why a value root fails where a text root renders the diagnostic, which the code cannot show. Comments only — no behavior changed.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Components produce rendered Markdown, and
ascaptures that text as a string.Agent workflows need validated values for control flow — a verdict, a list of
findings, a question — and the only way to get one was to render text and
re-parse it. Root documents had the same gap:
execute()completed withrendered text, so a document that computed a structured result had nowhere to
put it.
Closes #176.
What changes
Before:
<Review as="review" />bound a string.DocumentExecutioncompleted withResult<string>;xmd runprintedrendered Markdown.
After:
returnsdeclaration switches a component into value mode: it rendersnothing, must be invoked with
as, and binds the JSON value its singledirect top-level
<Return value={…} />produces, validated at the componentboundary. Components without
returnsare unchanged.as. A value root completes with itsvalidated JSON;
xmd runwrites only that JSON to stdout.inspectDocument()reportsreturns(the effective schema) andreturnModewithout executing the document.
How it works
parseReturnsDeclarationis the single declaration parser — object-only, fullschema vs object-return shorthand, draft-07 dialect check — used by Markdown
frontmatter and by
export const returns. Every shorthand property isrequired; optional properties need the full schema form.
validateBodyStructurereplacesvalidateOutputPlacementat both call sites.It runs against the body's own source, before
<Content />substitution, andaggregates every violation into one diagnostic.
expandValueBodyruns the complete body: everything except thedefinition-owned
<Return>is documentation under fail-fast, its renderingdiscarded. It returns the validated value rather than binding it, so
expandComponentbinds into the caller's environment after the component'sscope unwinds — the value never enters the component's own env.
validateReturnValuepasses the produced value throughparseJsonbefore Ajv,so non-JSON is rejected and defaults fill a clone rather than the producer's
object.
Review guide
Start with:
packages/core/tests/component-returns.test.tsThen review:
specs/executable-mdx-spec.md§6.10, §5.4, §8.1, §9.6 — the contractpackages/core/src/frontmatter.ts+validate.ts— declaration parsing andthe JSON/schema boundary
packages/core/src/expand.ts— preflight,<Return>reservation,expandValueBody, the binding boundarypackages/core/src/execute.ts— value roots, the internal{ output, value }result, and
DocumentExecutionpackages/cli/src/cli.ts— stdout/stderr separationLook carefully at:
post-
<Return>failures all completeErr(packages/cli/tests/value-root.test.ts).executeDocumentsends the journaled body textthrough
DocumentOutputbefore closing, because callback consumers never seethe close value.
What must stay true
definition.returns === undefinedis text mode — absence is nevernormalized, so an explicit
returns: { type: string }is still value mode.Checked by "treats an explicit string schema as value mode".
expandComponent/documentWorkflowand checked by the "structure, beforebody effects" tests, which assert no
execran.WeakMapcaches, checked by "keeps props and return contracts independent for the same
schema object".
result — checked by Tier VR.
How to verify it
packages/core/tests/component-returns.test.ts) proves value kinds,declaration forms, the JSON boundary, structure, execution order, function
components, composition, value roots, and replay; it fails if a value is
bound before validation or if a structural failure lets the body run.
packages/cli/tests/value-root.test.ts) proves the CLI channelcontract end to end and fails if body text or a diagnostic reaches stdout.
smoke-test/Guide/ReturnValues.mdruns underxmd testin CI, and the newCI step asserts
xmd run smoke-test/value-root.mdprints exactly the JSON theguide documents.
All seven checks pass locally:
deno task lint,check,test,check:jsr,pnpm exec tsc --project tsconfig.node.json --noEmit,pnpm test:node,bun run test:bun.Scope
Included
returnsfor Markdown and function components,<Return>, value roots, thexmd runJSON result contract,DocumentInfo.returns/returnMode, specand author documentation.
Intentionally unchanged
<Output>behavior for text components and text roots.xmd testoutput: its report stays on stdout in both modes.New abstractions
ReturnsSchemanames the declared return schema, distinct fromPropsSchema,whose root must be an object.
ReturnSchemaError/ReturnValidationErrorexist so a return failure is notreported as a props failure; they share an internal
SchemaValidationErrorbase that the segment conversion reads, and the base is not exported.
packages/core/tests/helpers.tsholdsasTextfor suites that assert onrendered text now that
collect()returnsJson.Risks and limitations
DocumentExecutioncompletes withResult<Json>andcollect()returnsJson. A text root still completes with its rendered Markdown, so the runtimevalue is unchanged; call sites that need a string narrow it.
flowing into a text segment unchecked.
Scope confirmation