Skip to content

Share the schema vocabulary between the hosts - #48

Merged
zoosky merged 2 commits into
mainfrom
feature/schema-config
Sep 12, 2026
Merged

zoosky merged 2 commits into
mainfrom
feature/schema-config

Conversation

@zoosky

@zoosky zoosky commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Step 4 of specs/features/cli-and-host-seams.md: crates/accent-proust-schema-config, the declarative half of a schema written once, and the WebAssembly host moved onto it in the same PR — extracting without moving the only existing consumer would have left two copies, which is the state this step exists to prevent. Conformance holds at 95 green, 10 annotated, 0 failing (of 105).

What the crate is

The key lists (TOP_LEVEL, SCHEMA_KEYS, ATTRIBUTE_KEYS, SLOT_KEYS), the refusal of an unknown key with the path to it, and the mapping from a declaration onto Schema, SchemaAttribute, SchemaSlot, ValidationType, RenderPolicy, ErrorLevel and SchemaMatches. declare(&root) -> Result<Declared, Error>; Declared::apply(&mut MapSchemaSource) -> Option<Variables> does the one thing both hosts do identically — merge over the built-ins, a redeclared key keeping its position and taking the new value, which is what the corpus's fence-hook case depends on.

The one design decision (decision 10 in the spec)

The crate walks a Declaration trait, not a Value. Converting a host's whole configuration into the library's Value first would fail on a JavaScript function written under validate at the conversion, with a message about functions — and the wasm tests pin a message about hooks, at the key. Seven methods (shape, as_bool, as_str, keys, get, items, to_value) let the walk refuse keys before it reads any value, and convert a value only where the vocabulary carries one through (default, variables). Each host keeps its own reading, which is what the spec's "each keeps only its own deserialisation" meant. Value implements the trait too, so the crate's own tests need no host.

Errors

Error { path, kind } with a #[non_exhaustive] ErrorKind. The crate's Display says what is true everywhere; a host matches the kind to add the reason that is its own. The wasm host does exactly that for transform/validate, functions and partials, and every path and message its tests assert survives unchanged — config.tags.callout.validate + "code does not cross", config.tags.callout.attributes.type.type + "Str", the node-name list, "looked up by its name", "regular expression", config.variables.when.

The wasm host

config.rs goes from 438 lines to 166: a newtype Js(JsValue) implementing the trait, build in six lines, and explain. path.rs is gone (the shared Path has the same scheme); value.rs returns the shared Error and drops its own variables(). Unions are one level deep now — a list inside a list is refused — where the old walker recursed; upstream never nests, and the walk is otherwise as deep as the document chose.

Tests

16 in crates/accent-proust-schema-config/tests/declare.rs, over Value: every key mapped, each level's unknown-key path and expected list, nodes.tag refused, children: ["tag"] allowed, unknown node type with the list, unknown attribute type with its path, one-level unions, matches as a pattern, render: true on a schema, wrong shapes, variables carried whole, NoCounterpart's message, apply keeping position and taking the value, and the key lists as the format. None panics. The wasm suite (11 + 17) passes unchanged.

Workspace

Member, not default member, with its own Schema config CI job, gate commands and rows in AGENT.md. Depends on the library by path alone, like the other members.

Review fixes

Eight findings from /code-review, all taken; three were regressions against the wasm host's previous behaviour and each is pinned by a new wasm test:

  • A throwing getter is refused, not dropped. Declaration::get now returns Result<Option<Self>, Error>, with an Unreadable kind; the host maps a failed Reflect::get to it. Absent and unreadable are different answers, and the second used to read as the first.
  • An undefined variable is null again, as the deleted variables() had it.
  • Any object is a schema when read key by key — a class instance, Object.create(base), a proxy. The plain-object strictness belongs to the value converter, which still applies it to default and variables.
  • Expected { what, got }: the message names the shape found ("expected an object, not a string"), and the host matches Shape::Other("function") to say code does not cross — which restores the two reasons the extraction had lost (a custom attribute type, a pattern in matches).
  • Error::explained(reason) is the seam; explain no longer retypes the vocabulary's sentence.
  • A non-string errorLevel is a wrong shape, not the unknown name "".
  • CHANGELOG names the one narrowed input (a nested type list); AGENT.md and the root manifest count the members rather than three.

Step 4 of specs/features/cli-and-host-seams.md:
`crates/accent-proust-schema-config`, the declarative half of a schema
written once. The key lists, the refusal of an unknown key with the path
to it, and the mapping onto `Schema` were the WebAssembly host's alone;
they are the crate's now, and that host reads through it -- its walker
went from 438 lines to 166: a newtype over `JsValue`, and the three
reasons a browser adds to a refusal.

The crate walks a `Declaration` trait rather than a `Value`. Converting a
host's whole configuration first would fail on a JavaScript function
written under `validate` at the conversion, with a message about
functions; the browser's tests pin a message about hooks, at the key. Seven
methods let the walk refuse keys before it reads values, and each host
keeps its own reading. `Value` implements the trait too.

Errors are a path and a kind. The kind is what lets a host add the reason
that is its own -- why a hook cannot cross into WebAssembly is the
browser's sentence -- while the crate's own `Display` says what is true
everywhere, and the wasm tests' paths and messages survive unchanged.
`nodes.tag` is refused and `children` may name `tag`, as both hosts now
agree.
Eight findings from the review of #48. Three were regressions against what
the WebAssembly host accepted before the extraction, and each is pinned by
a test now.

A property whose getter throws was read as absent, so a whole block could
be silently dropped -- the failure the crate exists to refuse. The trait's
`get` returns `Result<Option<Self>, Error>` with an `Unreadable` kind, and
the host maps a failed `Reflect::get` to it. An explicitly `undefined`
variable became an error where it used to be `null`; it is `null` again,
because a variable may be any value and `user: session?.user` means that.
And a schema object was accepted only if plain, because the value
converter's strictness was reused for key-by-key reading; any object but an
array or a function is a schema now, and the plain-object question stays
the converter's.

`Expected` carries the shape found as well as the shape wanted, so the
message says "expected an object, not a string" and a host can match on a
function wherever one was written. `Error::explained` appends a host's
reason to the vocabulary's own sentence, so the browser's three reasons no
longer retype it -- and it gained back the two the extraction had lost, for
a function in a value position and a pattern in `matches`. A non-string
`errorLevel` is a wrong shape, not the unknown name `""`.

The changelog names the one input that narrowed, a `type` nested inside a
list; the members are counted as members rather than as three.
@zoosky
zoosky merged commit 9bd23bf into main Sep 12, 2026
13 checks passed
@zoosky
zoosky deleted the feature/schema-config branch September 12, 2026 14:44
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.

1 participant