Skip to content

💥 feat(core): return schema-validated values from components and roots - #200

Merged
taras merged 6 commits into
mainfrom
feat/component-returns
Jul 28, 2026
Merged

💥 feat(core): return schema-validated values from components and roots#200
taras merged 6 commits into
mainfrom
feat/component-returns

Conversation

@taras

@taras taras commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Why

Components produce rendered Markdown, and as captures 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 with
rendered text, so a document that computed a structured result had nowhere to
put it.

Closes #176.

What changes

Before:

  • A component returned its rendering. <Review as="review" /> bound a string.
  • DocumentExecution completed with Result<string>; xmd run printed
    rendered Markdown.

After:

  • A returns declaration 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.
  • A root uses the same modes minus as. A value root completes with its
    validated JSON; xmd run writes only that JSON to stdout.
  • inspectDocument() reports returns (the effective schema) and returnMode
    without executing the document.
---
returns:
  passed: { type: boolean }
  summary: { type: string }
---

```js eval
const verdict = { passed: true, summary: "no findings" };
```

<Return value={verdict} />
<Review as="review" />

<Show when={review.passed}>Review passed: {review.summary}</Show>

How it works

frontmatter/export `returns` → definition.returns → structural preflight →
body runs as documentation, <Return> evaluated in place → JSON boundary +
schema → bound under `as` in the caller's env (or the root's completion value)

parseReturnsDeclaration is the single declaration parser — object-only, full
schema vs object-return shorthand, draft-07 dialect check — used by Markdown
frontmatter and by export const returns. Every shorthand property is
required; optional properties need the full schema form.

validateBodyStructure replaces validateOutputPlacement at both call sites.
It runs against the body's own source, before <Content /> substitution, and
aggregates every violation into one diagnostic.

expandValueBody runs the complete body: everything except the
definition-owned <Return> is documentation under fail-fast, its rendering
discarded. It returns the validated value rather than binding it, so
expandComponent binds into the caller's environment after the component's
scope unwinds — the value never enters the component's own env.

validateReturnValue passes the produced value through parseJson before 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.ts

Then review:

  1. specs/executable-mdx-spec.md §6.10, §5.4, §8.1, §9.6 — the contract
  2. packages/core/src/frontmatter.ts + validate.ts — declaration parsing and
    the JSON/schema boundary
  3. packages/core/src/expand.ts — preflight, <Return> reservation,
    expandValueBody, the binding boundary
  4. packages/core/src/execute.ts — value roots, the internal { output, value }
    result, and DocumentExecution
  5. packages/cli/src/cli.ts — stdout/stderr separation

Look carefully at:

  • Value-root failure is total: structural, schema, value, body, and
    post-<Return> failures all complete Err (packages/cli/tests/value-root.test.ts).
  • Replay still delivers output: executeDocument sends the journaled body text
    through DocumentOutput before closing, because callback consumers never see
    the close value.

What must stay true

  • definition.returns === undefined is text mode — absence is never
    normalized, so an explicit returns: { type: string } is still value mode.
    Checked by "treats an explicit string schema as value mode".
  • Structure fails before body effects — enforced by the preflight in
    expandComponent/documentWorkflow and checked by the "structure, before
    body effects" tests, which assert no exec ran.
  • Props and returns never share a compiled validator — separate WeakMap
    caches, checked by "keeps props and return contracts independent for the same
    schema object".
  • A value never reaches stdout as text, and rendered text never passes for a
    result — checked by Tier VR.

How to verify it

  • Tier RV (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.
  • Tier VR (packages/cli/tests/value-root.test.ts) proves the CLI channel
    contract end to end and fails if body text or a diagnostic reaches stdout.
  • smoke-test/Guide/ReturnValues.md runs under xmd test in CI, and the new
    CI step asserts xmd run smoke-test/value-root.md prints exactly the JSON the
    guide 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

  • returns for Markdown and function components, <Return>, value roots, the
    xmd run JSON result contract, DocumentInfo.returns / returnMode, spec
    and author documentation.

Intentionally unchanged

  • <Output> behavior for text components and text roots.
  • xmd test output: its report stays on stdout in both modes.
  • Persistence: returning a value does not choose where it is stored.

New abstractions

  • ReturnsSchema names the declared return schema, distinct from PropsSchema,
    whose root must be an object.
  • ReturnSchemaError / ReturnValidationError exist so a return failure is not
    reported as a props failure; they share an internal SchemaValidationError
    base that the segment conversion reads, and the base is not exported.
  • packages/core/tests/helpers.ts holds asText for suites that assert on
    rendered text now that collect() returns Json.

Risks and limitations

  • 💥 DocumentExecution completes with Result<Json> and collect() returns
    Json. A text root still completes with its rendered Markdown, so the runtime
    value is unchanged; call sites that need a string narrow it.
  • A text function component returning a non-string is now an error rather than
    flowing into a text segment unchecked.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

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
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown

PR #200: 💥 feat(core): return schema-validated values from components and roots

49 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.

Correctness

No extraneous code patterns detected.

taras added 5 commits July 28, 2026 13:40
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.
@taras
taras merged commit 9979743 into main Jul 28, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support schema-validated component returns

1 participant