feat: execute the semantic phase, and close the conformance debt behind issue #9 - #25
Merged
Merged
Conversation
Eight of the twenty-four diagnostic codes the three spec.md files declare are about a document's surroundings rather than its contents — a directory with a name, a sibling document, a file on disk. A case was one `case.yaml`, so none of them had a fixture, and they reached `main` as prose with CI green. That is the failure mode CONTRIBUTING.md ground rule 2 exists to prevent. GOVERNANCE.md lists "Changing the conformance fixture contract" among the changes needing an ADR. This is that change. A case directory MAY now carry a `tree/` instead of a `case.yaml`, with `metadata.document` naming the file under test inside it. `tree/` is the parent of the item root rather than the item root itself: ERR_SLUG_MISMATCH tests the item directory's *name*, so a fixture for it needs a directory that has one. `case.yaml` is not thereby the legacy form, and the ADR says so. It asserts that the document has no item root — a real state, per blueprint §3.1, and the one an API payload arrives in. Making `tree/` the only form would have traded one blind spot for another. Symlinks are declared in metadata rather than committed. ERR_PATH_ESCAPE is reachable only by symlink now that listing §5's grammar makes `..` unspellable, and a committed link does not survive a checkout without `core.symlinks`, is invisible in a diff, and would ship inside a release tarball pointing outside the archive. The ADR also corrects tools/src/validator.ts, which asserted that the semantic phase was out of scope "per ADR 0001". ADR 0001 §6 forbids *publishing* a reference validator and is untouched; §7 describes tools/ as one non-normative adapter. Implementing more phases inside it publishes nothing. The alternative was a corpus in which every semantic clause was checked by nothing at all. Refs: #9 Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Component §7 has said since bootstrap that the parser phase MUST reject anchors and aliases. Nothing did. `parseDocument` set `uniqueKeys`, `merge: false` and `strict`, none of which touches an alias — a document spelling `&b` / `*b` parsed cleanly and the alias expanded. The parser now walks the tree and rejects both. `maxAliasCount` would have covered the alias and not the lone anchor, which is inert and rejected anyway: an author who writes one is reaching for a feature this contract withholds, and §2 already settled that a field which does nothing is an error rather than a silence. Two codes rather than one. `ERR_ANCHOR_OR_ALIAS` is new. `ERR_INVALID_YAML` is not — `validator.ts` has emitted it since bootstrap and no spec.md declared it, so a fixture citing it would have been rejected by the runner's own registry check as an unknown code. Both are now in component §8, and the two say different things to an author: one means the document is malformed, the other means it is well-formed and uses something this contract does not have. Also switches the parser from `parse` to `parseDocument`, which reports every problem instead of throwing on the first and carries machine-readable error codes. The previous implementation matched on message text — the one thing this specification explicitly declares non-normative. The parser lives in a new document.ts so that semantic.ts can read an item's other documents without closing an import cycle. Fail-before evidence: all six anchor/alias cases validate cleanly against tools/ at be77e19 and fail after. The three malformed-YAML cases are green beforehand and are the regression pin for the code registration. This tightens the parser phase — a document with an alias validates today and will not after. It is free while no tag exists and release PRs #1/#2/#3 are open; after they merge it is a v2 directory plus a migration note. Refs: #9 Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Every semantic clause PR #13 wrote — acyclicity, the floating-tag blocklist, ERR_CONFLICTING_INPUT_SCHEMA, the media rules — was prose that nothing in CI ever executed. Five fixtures existed and all five were skipped. New semantic.ts, split the way the prose splits the rules rather than the way this runner would find convenient. An **in-document** rule is decided by reading the document: a floating image tag, a probe naming an endpoint that is not declared, a `fromRole` naming no node, a cycle, two screenshots sharing a basename. An **item-scoped** rule is measured against the item root, and is reported only when the caller supplies one. That is blueprint §3.1's rule, not a concession: "an implementation handed a document rather than a directory MUST NOT report any of them … a diagnostic it cannot substantiate is worse than a silence." It is also why `check:examples` stays green — `examples/` is not an item root, and its documents reference `./components/postgres.yaml` and `media/icon.png`, neither of which exists beside them. PR #13's body flagged that as latent breakage; this is the shape that makes it not one. Cycle reporting follows §4.2's canonical form exactly: strongly connected components, each reported as a closed walk from the lexicographically smallest node, choosing the smallest successor at each step. Verified stable across the three key orderings of one three-node cycle — the walk is a property of the graph, not of the traversal, which is the whole point of pinning it. conformance.ts gains the ADR 0002 case-tree form: `tree/` materialised to a scratch directory with its declared symlinks created there, validated with the item root set to the document's own directory. A case must declare exactly one of `case.yaml` or `tree/`; declaring both leaves it ambiguous which one the diagnostics describe. `IMPLEMENTED_PHASES` gains `semantic`. `capability` stays out — it needs an account, a region and a quota, which is a server. Refs: #9 Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
`$defs.ListingSpec.description` still told readers that `icon` and `screenshots[].file` are "paths relative to the listing document" — the exact wording §5 now singles out as wrong: "`media/` is a stronger rule than 'relative to the listing document'. That was this section's earlier wording, and it is not what anything enforces." PR #13 updated the two per-property descriptions and missed the containing object's, so the schema contradicted itself and the prose in one file. Refs: #9 Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
Thirteen new cases covering every diagnostic code that needed a directory to
exist. All are trees; none was expressible before.
blueprint 003–009 take the identity, reference and merge rules one at a time —
slug against the directory name, version against the sibling listing, a
component document nothing references, a reference resolving to nothing, a
reference escaping the item root, a `fromOutput` naming no output of the
component it resolves to, and two nodes declaring one input key with differing
schemas.
Three deserve their reasons stated:
007 the reference is `../shared/postgres.yaml` and the document it lands on
exists. A leading `../` is legal spelling — it is how a blueprint reaches
a sibling directory — so only a containment check on the resolved path
catches this one.
009 `api` and `db` both declare `adminPassword`, STRING against INTEGER.
`api` sorts first, so `api`'s declaration stands and `db`'s is reported.
The case exists because silent first-wins is defensible until you write
down which schema the second component's value was validated against.
011 a three-node cycle written as queue, db, cache. semantic-002 cannot make
this case: with two nodes, "smallest first" and "first declared" can
coincide by luck. Here they do not, so an implementation reporting from
wherever its traversal started produces the wrong walk and fails.
listing 002–005 mirror the identity rules and add the two media rules that need
the filesystem. 005 is the symlink case, declared in metadata rather than
committed per ADR 0002 §3.
blueprint 010 and listing 006 are the positive cases, and they are not padding.
Each satisfies every item-scoped rule of its family at once, which makes them
the regression pin for the class: a careless containment check or a merge rule
that forgets to absorb an identical redeclaration rejects a well-formed item,
and nothing else in the corpus would notice.
`ERR_UNKNOWN_COMPONENT` remains uncovered. It is `capability` — resolving a
published reference needs the catalog, and no phase a client runs may reach the
network.
Refs: #9
Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
The runner enforced fixture → spec: every code a case declares must exist in a reachable diagnostics table, at the right phase. Nothing enforced spec → fixture, which is how ten codes reached `main` untested with CI green. Two checks close it. Every `ERR_*` row in a family's own diagnostics table must be exercised by an indexed case or appear in `UNCOVERED` with a reason — after this branch that list holds one entry. And every case directory on disk must be named by `cases.json`, since the runner reads the index rather than walking the tree: an unindexed directory is not a failing fixture, it is an invisible one. Both were verified by injecting a violation and watching the check fail. The README's "Coverage status" described a limitation ADR 0002 removes, so it is rewritten around what is now true: three phases covered, one code uncovered, and a check that derives the list instead of a paragraph someone has to remember to update. It also documents the `tree/` form, `document`, and `symlinks`. CONTRIBUTING gains the obligation this creates: adding a diagnostic code to a spec.md means adding a case for it. Refs: #9 Signed-off-by: Justin Merrell <merrelljustin@gmail.com>
This was referenced Aug 9, 2026
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.
Second pass on #9. #13 did the prose and the schema work; what it left behind is the machinery that makes the prose enforceable, plus three defects.
The state this starts from
task checkwas green atbe77e19, and green was not telling the truth:ERR_*codesEvery
semanticclause #13 wrote — acyclicity, the floating-tag blocklist,ERR_CONFLICTING_INPUT_SCHEMA, the media rules — was prose nothing in CI ever ran. CONTRIBUTING ground rule 2 was satisfied in letter and not in substance.After this branch: 75 cases, 0 skipped, 1 code uncovered —
ERR_UNKNOWN_COMPONENT, which iscapabilityand needs the catalog.Three defects
1. YAML anchors and aliases were not rejected. Component §7 has said they MUST be since bootstrap.
parseDocumentsetuniqueKeys,merge: falseandstrict— none of which touches an alias. A document spelling&b/*bparsed cleanly and the alias expanded. No fixture covered it.2.
ERR_INVALID_YAMLwas defined nowhere.validator.tsemitted it; nospec.mddeclared it. A fixture citing it would have been rejected by the runner's own registry check as an unknown code.3.
ListingSpec.descriptionstill said media paths are "relative to the listing document" — the exact wording listing §5 singles out as wrong. #13 updated the two per-property descriptions and missed the containing object's.What lands
ADR 0002 — case trees. Eight codes were about a document's surroundings, and a case was one
case.yaml. A case may now carry atree/instead, withmetadata.documentnaming the file under test inside it. GOVERNANCE.md gates this on an ADR; it is here.Two decisions in it worth review:
case.yamlis not the legacy form. It asserts the document has no item root — a real state per blueprint §3.1, and the one an API payload arrives in. Makingtree/the only shape would have traded one blind spot for another.ERR_PATH_ESCAPEis reachable only by symlink now that §5's grammar makes..unspellable. A committed link does not survive a checkout withoutcore.symlinks, is invisible in a diff, and would ship inside a release tarball pointing outside the archive.The
semanticphase, implemented.validator.tsclaimed semantic was out of scope "per ADR 0001". ADR 0001 §6 forbids publishing a reference validator and is untouched; §7 describestools/as one non-normative adapter. ADR 0002 §5 records the correction.The rules split the way the prose splits them, not the way this runner would find convenient:
That split is why
check:examplesstays green.examples/is not an item root, and its documents reference./components/postgres.yamlandmedia/icon.png, neither of which exists beside them. #13's body flagged that as latent breakage; this is the shape that makes it not one — and it is blueprint §3.1's rule, not a workaround for it.Cycle reporting follows §4.2's canonical form: SCCs, each reported as a closed walk from the lexicographically smallest node. Verified stable across all three key orderings of one three-node cycle.
The reverse coverage gate. The runner enforced fixture → spec. Nothing enforced spec → fixture, which is how ten codes reached
mainuntested. Now: everyERR_*row in a family's own diagnostics table needs an indexed case or anUNCOVEREDentry carrying a reason. Plus an orphan check — a case directorycases.jsondoes not name is not a failing fixture, it is an invisible one.Both verified by injecting a violation and watching the check fail.
Fail-before evidence
The six anchor/alias cases were rewritten so the only thing wrong with each document is the anchor. Against
tools/atbe77e19:The three malformed-YAML cases are green beforehand and are the regression pin for the code registration. The thirteen tree cases could not exist before ADR 0002.
blueprint/010-well-formed-itemandlisting/006-well-formed-itemare the positive pins: each satisfies every item-scoped rule of its family at once, so a careless containment check or a merge rule that forgets to absorb an identical redeclaration rejects a well-formed item.Compatibility
Rejecting anchors and aliases tightens the
parserphase. A document with an alias validates today and will not after. That is free while no tag exists and #1/#2/#3 are open; after they merge it is av2directory plus a migration note. This should land before them.Two new
parsercodes,ERR_INVALID_YAMLandERR_ANCHOR_OR_ALIAS. Two rather than one because they say different things to an author: one means the document is malformed, the other means it is well-formed and uses something this contract withholds.fix(listing):is the only family-scoped change and is a patch — a description, no validation behaviour.Issue #9
Not closed by this PR, deliberately. Ten tracking issues are open for the remaining
TODOmarkers — #14 #15 #16 #17 #18 #19 #20 #21 #22 #23 — none of which #9 supplied an answer for. #22 (the Markdown subset) is the one with a real deadline: constraining it is breaking after the first tag.#24 collects the three places #13 decided against the platform — cycle detection, parameter merge, and whether a
SERVICEmust expose an endpoint. Each is a live divergence rather than a filled gap, and #9 should not read as closing them.Verification
🤖 Generated with Claude Code