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 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'