From 6c618b6b05a45df0e240581fd8238f1cc2822479 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 18 Aug 2026 23:53:35 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(blueprint):=20stop=20=C2=A710=20rejecti?= =?UTF-8?q?ng=20a=20cycle=20=C2=A74.2=20permits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §10 said "A cycle is rejected with ERR_CONNECTION_CYCLE" while §4.2 of the same document says the graph MAY contain a cycle and an implementation MUST NOT reject a composition for that reason alone. The corpus sides with §4.2 — semantic/002-connection-cycle and semantic/011-three-node-cycle both declare expected: pass — so §10 was the outlier, and a disagreement between two normative artifacts blocks a release here rather than leaving an implementation to choose. It is not residue from the acyclicity reversal, which matters for what the fix should say. §4.2's permission landed in af2dec0, the ADR 0005 commit that withdrew ERR_DEPENDENCY_CYCLE, and §10 was written afterwards in e840e6d, whose own message states the intent: cycles MUST be detected rather than bounded by a recursion limit, because a stack overflow is a crash and not a diagnostic. The rejection clause reinstated a withdrawn rule under a new code name one commit later. ERR_CONNECTION_CYCLE was in no registry and occurred nowhere else in the repository. So the traversal requirement is kept, and now cites §4.2 rather than contradicting it. It also says what detection does not mean: a walk that meets a cycle terminates, it does not report. Deleting the clause outright would have left a reader who arrives at §10 first able to re-infer rejection from silence. conformance/README.md's phases table still named "dependency cycles" as something the semantic phase enforces — the same withdrawn rule surviving in a second document. That phase enforces cross-document agreement. No fixture: nothing about validation changes, and the surviving rule is already pinned by the two cycle cases. No ADR: this corrects a slip inside ADR 0005 §2 rather than deciding policy, and that section reads true afterwards. Refs #46 Signed-off-by: Justin Merrell --- conformance/README.md | 2 +- specifications/blueprint/v1/spec.md | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/conformance/README.md b/conformance/README.md index e0787bd..a554238 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -190,7 +190,7 @@ identical corpus. |---|---|---| | `parser` | The Musher YAML profile — [component §7.1](../specifications/component/v1/spec.md#yaml-profile) | Never | | `structural` | The family's JSON Schema 2020-12 bundle | Never | -| `semantic` | Reference resolution, path containment, dependency cycles | Never | +| `semantic` | Reference resolution, path containment, cross-document agreement | Never | | `capability` | Account, region, and quota checks | Server only | An implementation MUST apply the phases in order and MUST NOT report a diff --git a/specifications/blueprint/v1/spec.md b/specifications/blueprint/v1/spec.md index 314ffa6..996be6b 100644 --- a/specifications/blueprint/v1/spec.md +++ b/specifications/blueprint/v1/spec.md @@ -748,11 +748,14 @@ reviewer of the item can see: a graph that reaches outside the directory being reviewed deploys something the review did not cover. **Graph traversal.** [§4.2](#connections) makes the component graph a directed -graph an implementation walks. A cycle is rejected with `ERR_CONNECTION_CYCLE`, -and an implementation MUST detect cycles rather than relying on a recursion limit -to stop it — a stack overflow is a crash, not a diagnostic. The parser's nesting -bound does not help here: the cycle is in the graph the document describes, not -in the document's own structure. +graph an implementation walks, and permits that graph to contain a cycle. An +implementation MUST therefore detect cycles rather than relying on a recursion +limit to stop it — a stack overflow is a crash, not a diagnostic. Detecting one +means terminating the walk, not rejecting the document: that clause forbids +rejecting a composition for containing a cycle, so a traversal that meets one +MUST finish rather than report. The parser's nesting bound does not help here: +the cycle is in the graph the document describes, not in the document's own +structure. **Published references.** Resolving a published reference is `capability` ([§6](#validation-layers)) precisely because it needs the catalog. An From e11dad8fbc28eb8fae11dac828110b1a7d69e7b3 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Tue, 18 Aug 2026 23:54:05 +0000 Subject: [PATCH 2/2] feat(tools): check every diagnostic code the prose names against the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two directions were checked and a third was not. checkCaseShape asks whether every code a fixture declares is defined by the prose; checkCoverage asks whether every code the prose defines is exercised by a fixture. Neither looks at a code named in a sentence, because DIAGNOSTIC_ROW is anchored to ^| and so sees only the registry tables. That gap is how blueprint §10 shipped naming ERR_CONNECTION_CYCLE — a code no table has ever defined, contradicting §4.2 in the same document — with every check green. A code named in normative prose is the same promise to an implementer as a row in the registry, and an implementer who looks this one up finds nothing. checkProseCodes closes it. Every backticked ERR_ in the three spec.md files must be declared by some registry, or recorded in HYPOTHETICAL with a written reason, in the shape UNCOVERED and UNPINNED already use. The set compared against is the union of all three registries rather than registryFor's reachable set. Component §3 and §10 legitimately name blueprint's codes and listing §4 names ERR_UNREFERENCED_COMPONENT; those are citations, not declarations, and the question being asked is whether the code exists at all. One entry in HYPOTHETICAL. Component §3 names ERR_SCHEMA_TOO_OLD as a code that deliberately does not exist, to explain why a field from a newer release is reported as ERR_UNKNOWN_FIELD — a validator holding neither definition cannot tell that case from a misspelling. Scoped to spec.md. An ADR is immutable and records withdrawn codes as history, so ERR_DEPENDENCY_CYCLE in ADR 0005 is correctly a code that no longer exists; a named code is a promise only in a normative document. Confirmed to fail closed three ways before being committed: the reinstated §10 wording is reported at blueprint spec.md:751, an emptied HYPOTHETICAL is reported at component spec.md:100, and an invented code anywhere in a spec.md is reported. Refs #46 Signed-off-by: Justin Merrell --- tools/src/conformance.ts | 72 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tools/src/conformance.ts b/tools/src/conformance.ts index f662948..aa5aa34 100644 --- a/tools/src/conformance.ts +++ b/tools/src/conformance.ts @@ -121,6 +121,14 @@ const BASE_FAMILY = 'component' const DIAGNOSTIC_ROW = /^\|\s*`(ERR_[A-Z0-9_]+)`\s*\|\s*`([a-z]+)`\s*\|/ /** A stable heading anchor, `## 2. Document envelope`. */ const SPEC_ANCHOR = /<\/a>/g +/** + * A diagnostic code named anywhere in the prose, inside backticks. + * + * `DIAGNOSTIC_ROW` is anchored to `^|`, so it sees only the registry tables. A + * code named in a sentence is the same promise to an implementer and was + * matched by nothing — see `checkProseCodes`. + */ +const PROSE_CODE = /`(ERR_[A-Z0-9_]+)`/g interface SpecIndex { /** Diagnostic code to the phase the prose assigns it. */ @@ -197,6 +205,23 @@ function registryFor(family: Family): ReadonlyMap { return new Map([...(base ?? EMPTY_INDEX).codes, ...own.codes]) } +/** + * Every code any of the three registries declares. + * + * Deliberately global rather than `registryFor`'s reachable set. A family's + * prose legitimately names another family's code — component §3 and §10 name + * blueprint's `ERR_UNKNOWN_COMPONENT`, listing §4 names + * `ERR_UNREFERENCED_COMPONENT` — and those are citations, not declarations. + * What `checkProseCodes` asks is whether the code exists at all. + */ +function declaredCodes(): ReadonlySet { + const codes = new Set() + for (const family of discoverFamilies()) { + for (const code of specIndex(family.specPath)?.codes.keys() ?? []) codes.add(code) + } + return codes +} + function loadIndex(family: Family, failures: Failures): CaseIndexEntry[] { const indexPath = join(family.conformanceDir, 'cases.json') if (!existsSync(indexPath)) return [] @@ -605,6 +630,52 @@ function checkRequirementCoverage(cited: ReadonlySet, failures: Failures } } +/** + * Codes the prose names on purpose without declaring them, and why. + * + * The same shape as `UNCOVERED` and `UNPINNED`, for the same reason. An entry + * here is a claim a reviewer can check, and the list should stay near empty: + * naming a code that does not exist is how a withdrawn rule survives. + */ +const HYPOTHETICAL: ReadonlyMap = new Map([ + [ + 'ERR_SCHEMA_TOO_OLD', + 'component §3 names it as a code that deliberately does not exist, to explain why a field from a newer release is reported as ERR_UNKNOWN_FIELD — a validator holding neither definition cannot tell that case from a misspelling', + ], +]) + +/** + * Every diagnostic code the prose names is declared by a registry. + * + * The third direction, and the one nothing checked. `checkCaseShape` asks + * whether a code a *fixture* declares exists; `checkCoverage` asks whether a + * code a *registry* declares is fixtured. Neither looks at a code named in a + * sentence — which is how blueprint §10 came to reject a cycle §4.2 permits, + * with `ERR_CONNECTION_CYCLE`, a code no table has ever defined, and CI green. + * + * Scoped to the three spec.md files. An ADR is immutable and records withdrawn + * codes as history, so a code that no longer exists is correct there; a named + * code is a promise only in a normative document. + */ +function checkProseCodes(failures: Failures): void { + const declared = declaredCodes() + for (const family of discoverFamilies()) { + if (!existsSync(family.specPath)) continue + const lines = readFileSync(family.specPath, 'utf8').split('\n') + for (const [offset, line] of lines.entries()) { + for (const match of line.matchAll(PROSE_CODE)) { + const code = match[1] + if (code === undefined || declared.has(code) || HYPOTHETICAL.has(code)) continue + failures.add( + `${relativeToRepo(family.specPath)}:${offset + 1}: ${code} is named in the prose but ` + + 'is declared by no diagnostics table. Add it to a registry, or record it in ' + + 'HYPOTHETICAL with a reason.', + ) + } + } + } +} + /** * Case directories that no `cases.json` entry names. The index is the contract * and the runner never walks the filesystem, so an unindexed directory is not a @@ -654,6 +725,7 @@ function main(): void { } checkRequirementCoverage(cited, failures) + checkProseCodes(failures) const suffix = skipped > 0 ? ` (${skipped} skipped)` : '' const profile = profileFor(IMPLEMENTED_PHASES) ?? 'none'