diff --git a/.github/workflows/example-regen.yml b/.github/workflows/example-regen.yml index f98e720..2e9da85 100644 --- a/.github/workflows/example-regen.yml +++ b/.github/workflows/example-regen.yml @@ -1228,6 +1228,72 @@ jobs: [[ $fail -eq 0 ]] || exit 1 echo "==> 428 rules fire on declarations and prose, and stay silent on clean writes and byte sizes." + # A rule name written as `error.code` (authoring#163). `Error.code` is the + # HTTP status since compatibility.md §54, and prose survives a schema + # migration. Asserted in both directions, plus the gate: the same prose + # under a STRING `code` is the pre-§54 shape and must stay silent. + - name: Rule names belong in error.reason, not error.code (regression) + run: | + # See the note on the earlier fixture steps: `bash -e` would abort on + # the fixtures' expected error-severity findings. + set +e + set -uo pipefail + REPORT="${RUNNER_TEMP}/error-reason-prose.txt" + GATE="${RUNNER_TEMP}/error-reason-prose-string-code.txt" + + spectral lint \ + --ruleset schemas/spectral/specfuse-openapi.yaml \ + schemas/spectral/fixtures/error-reason-prose.yaml \ + >"$REPORT" 2>&1 + STATUS=$? + cat "$REPORT" + if [[ $STATUS -ge 2 ]]; then + echo "::error::Spectral crashed on the error-reason-prose fixture (exit ${STATUS})." >&2 + exit 1 + fi + + spectral lint \ + --ruleset schemas/spectral/specfuse-openapi.yaml \ + schemas/spectral/fixtures/error-reason-prose-string-code.yaml \ + >"$GATE" 2>&1 + STATUS=$? + if [[ $STATUS -ge 2 ]]; then + cat "$GATE" + echo "::error::Spectral crashed on the error-reason-prose-string-code fixture (exit ${STATUS})." >&2 + exit 1 + fi + + fail=0 + RULE=specfuse-error-rule-name-in-code + fired() { grep "$RULE" "$REPORT" | grep -q "$1"; } + + for at in \ + "paths./assign.patch.description" \ + "paths./compare.get.summary" \ + "paths./inResponse.delete.responses\[409\].description" \ + "paths./jsonish.post.x-notes.description"; do + if ! fired "$at"; then + echo "::error::${RULE} did not fire at ${at}." >&2 + fail=1 + fi + done + + for at in "paths./clean" "paths./status" "paths./otherCodes"; do + if fired "$at"; then + echo "::error::${RULE} fired under ${at}, which names no rule in code." >&2 + fail=1 + fi + done + + if grep -q "$RULE" "$GATE"; then + grep "$RULE" "$GATE" + echo "::error::${RULE} fired on a STRING Error.code. The integer gate is gone." >&2 + fail=1 + fi + + [[ $fail -eq 0 ]] || exit 1 + echo "==> Rule names in error.code warn under an integer code; reason, status codes and string-code projects stay silent." + # Conditional GET. Each violation mirrors a generator 0.12.0 ERROR # (ConditionalReadDeclarationValidationRule), except /stale412, which is # kit-only. The silent cases matter as much as the firings: the jar reads diff --git a/compatibility.md b/compatibility.md index 9bf7ad7..0ed4df6 100644 --- a/compatibility.md +++ b/compatibility.md @@ -1388,7 +1388,7 @@ The C# server prefixes every route with the path part of the server URL; the cli - The §1.1 table gains `DELETE_SOFT_CLASSIFIED_NO_RETENTION`. **Generator follow-up, not filed:** a structured `x-compliance.retention` (`period`, `from` as a date property, `action: delete | clear`, allowed per property) that the generator could turn into a sweep. Then `DELETE_SOFT_CLASSIFIED_NO_RETENTION` could accept either retention. The consumer has not asked for it yet. File when a second project needs it. -2. **`Error.code` vs the HTTP status.** A consumer's `Error.code` was `type: integer` (the status), while its copy of `API_Handbook.md` §2.1 called `code` a snake_case rule name. As a result, 17 operation descriptions named codes the wire cannot carry. The consumer added a string `reason` field. The kit's own `Error` types `code` as a snake_case string and carries the status in a separate integer `status`, and §2.1 matches. **Corrected at the `0.16.0` pin bump (§52):** the conclusion drawn here, that the contradiction was the consumer's alone, is wrong. The generator's C# `domainExceptions` set `Error.Code` to an integer, so the consumer's integer `code` was the generator's shape, and the kit's `Error` does not compile with that artifact. The proposed generator rule (warn on `error.code = ` prose when `code` is an integer) is still not forwarded. **Resolved in §54:** the kit adopts the consumer's shape. +2. **`Error.code` vs the HTTP status.** A consumer's `Error.code` was `type: integer` (the status), while its copy of `API_Handbook.md` §2.1 called `code` a snake_case rule name. As a result, 17 operation descriptions named codes the wire cannot carry. The consumer added a string `reason` field. The kit's own `Error` types `code` as a snake_case string and carries the status in a separate integer `status`, and §2.1 matches. **Corrected at the `0.16.0` pin bump (§52):** the conclusion drawn here, that the contradiction was the consumer's alone, is wrong. The generator's C# `domainExceptions` set `Error.Code` to an integer, so the consumer's integer `code` was the generator's shape, and the kit's `Error` does not compile with that artifact. The proposed rule (warn on `error.code = ` prose when `code` is an integer) was never forwarded to the generator; it shipped as the kit Spectral rule `specfuse-error-rule-name-in-code` (§54, authoring#163). **Resolved in §54:** the kit adopts the consumer's shape. ### 52. Generator `0.16.0` pin bump (kit `0.25.0`) @@ -1497,7 +1497,7 @@ The full Domain set does not build from `hello-orders` alone for unrelated reaso **Migration for a project on the old shape** (any project scaffolded by kit `≤ 0.26.0`). Schema and docs only: the generated C# exceptions never compiled against the old shape, so no generated code depends on it. -1. Move each rule name from `code` to `reason`: in `Error` examples, in response `description`s that list conflict codes, and in prose (`error.code = x` → `error.reason = x`). +1. Move each rule name from `code` to `reason`: in `Error` examples, in response `description`s that list conflict codes, and in prose (`error.code = x` → `error.reason = x`). Once step 2 types `code` as an integer, `specfuse-error-rule-name-in-code` lists the prose still left behind. 2. Retype `code` as `type: integer`, the HTTP status, and delete `status`. 3. Drop `format: uuid` from `id`. 4. Add `type` and `ErrorType` if the project registers, or will register, `domainExceptions`. @@ -1505,6 +1505,13 @@ The full Domain set does not build from `hello-orders` alone for unrelated reaso **Generator follow-up (`#2671`):** `(string reason, string message)` constructors on the six exceptions a rule can refuse with, and `Id` following the schema's `format`. Until then, `reason` is set by building the `Error` and passing it to the `(Error error)` constructor. +**Prose lint (authoring#163):** `specfuse-error-rule-name-in-code` (`warn`, `functions/openapiErrorReasonProse.js`) flags a `description` or `summary` anywhere in the document that assigns or compares a snake_case rule name to `code` (`error.code = template_in_use`, `errors[0].code == 'x_y'`, `"code": "x_y"`). Two choices keep it quiet on correct prose: + +- **It runs only when `components.schemas.Error.properties.code` is `type: integer`.** A project still on the old shape is described correctly by that prose, so it gets the migration above, not a list of warnings. +- **The value must contain an underscore.** That separates a rule name from `code: integer` or `code = 409`. A single-word rule name is missed. `\b` keeps `status_code` out. + +Response examples need no new rule: `spectral:oas` example validation already rejects a string `code`. Fixtures `error-reason-prose.yaml` (4 firings, 3 silences) and `error-reason-prose-string-code.yaml` (the gate) are asserted in `example-regen.yml`. `hello-orders` and the scaffold lint with no new findings. + ## Outstanding kit-side work These are gaps in the kit itself, separate from the generator-side follow-ups above. Each represents an enforcement promise the handbooks make that has no implementation today. diff --git a/handbooks/API_Handbook.md b/handbooks/API_Handbook.md index 83b84ff..f974d6b 100644 --- a/handbooks/API_Handbook.md +++ b/handbooks/API_Handbook.md @@ -1133,7 +1133,7 @@ Content-Type: application/json **Required fields**: - `code`: the HTTP status, an integer (`409`). -- `reason`: the snake_case name of the rule that failed (`template_in_use`). Clients branch on it. A rule name never goes in `code`: prose, examples and `409` descriptions say `error.reason = template_in_use`, never `error.code = template_in_use`. +- `reason`: the snake_case name of the rule that failed (`template_in_use`). Clients branch on it. A rule name never goes in `code`: prose, examples and `409` descriptions say `error.reason = template_in_use`, never `error.code = template_in_use`. Spectral's `specfuse-error-rule-name-in-code` warns on the second form. - `type`: the broad category, from the `ErrorType` enum. The generated C# `ConflictException` sets `resourceConflict`. - `message`: Human-readable explanation - `retryable`: Usually `false` for business rule violations diff --git a/schemas/spectral/fixtures/error-reason-prose-string-code.yaml b/schemas/spectral/fixtures/error-reason-prose-string-code.yaml new file mode 100644 index 0000000..5ea7e8b --- /dev/null +++ b/schemas/spectral/fixtures/error-reason-prose-string-code.yaml @@ -0,0 +1,86 @@ +# ============================================================================= +# Regression fixture (gate): the same prose under a STRING `code` +# ============================================================================= +# Companion to `error-reason-prose.yaml`. Same paths, but `Error.code` is the +# pre-§54 string rule name, so `error.code = template_in_use` is correct here +# and `specfuse-error-rule-name-in-code` must not fire anywhere in this file. +# A project that has not migrated yet gets §54's migration note, not a wall of +# warnings. Asserted by .github/workflows/example-regen.yml. +# +# Not a Specfuse-conformant example — do not copy. +# ============================================================================= +openapi: 3.0.3 +info: + title: error-reason-prose (string code) fixture + version: "1.0.0" +components: + schemas: + Error: + type: object + required: [code, message] + properties: + code: { type: string } + reason: { type: string } + message: { type: string } +paths: + + # SILENT here (fires in error-reason-prose.yaml): assignment in an operation description. + /assign: + patch: + summary: Update the template + description: Returns 409 with error.code = template_in_use while a menu uses it. + responses: + '200': { description: OK } + + # SILENT here (fires in error-reason-prose.yaml): comparison in an operation summary. + /compare: + get: + summary: "List items; clients check errors[0].code == 'page_size_over_limit'" + responses: + '200': { description: OK } + + # SILENT here (fires in error-reason-prose.yaml): backticked, in a nested response description. + /inResponse: + delete: + summary: Delete the booking + responses: + '204': { description: No Content } + '409': + description: 'The slot is taken (`code`: `slot_already_booked`).' + + # SILENT here (fires in error-reason-prose.yaml): JSON-ish, in an `x-*` block's description. + /jsonish: + post: + summary: Archive the thing + x-notes: + description: 'Fails with {"code": "delete_not_allowed"} once archived.' + responses: + '200': { description: OK } + + # SILENT: the rule name is in `reason`. + /clean: + patch: + summary: Update the template + description: Returns 409 with error.reason = template_in_use while a menu uses it. + responses: + '200': { description: OK } + + # SILENT: `code` named with the status, and described as a field. + /status: + patch: + summary: Update with a precondition + description: >- + A conflict returns code = 409 and a stale ETag code: 412. The field is + declared as `code: integer`. + responses: + '200': { description: OK } + + # SILENT: other words ending in "code". + /otherCodes: + get: + summary: Look up an address + description: >- + Filters on status_code = not_found and reason_code: out_of_area; the + postal code is free text. + responses: + '200': { description: OK } diff --git a/schemas/spectral/fixtures/error-reason-prose.yaml b/schemas/spectral/fixtures/error-reason-prose.yaml new file mode 100644 index 0000000..d3a6857 --- /dev/null +++ b/schemas/spectral/fixtures/error-reason-prose.yaml @@ -0,0 +1,102 @@ +# ============================================================================= +# Regression fixture: a rule name written as `error.code` (authoring#163) +# ============================================================================= +# `Error.code` is the integer HTTP status and the rule name is `reason` +# (API Handbook §11, compatibility.md §54). Pins +# `specfuse-error-rule-name-in-code` in BOTH directions. Its companion, +# `error-reason-prose-string-code.yaml`, carries the same prose under a +# string `code` and must stay silent: the gate. +# +# Expected findings (asserted by .github/workflows/example-regen.yml): +# +# /assign patch operation description error.code = template_in_use +# /compare get operation summary errors[0].code == 'page_size_over_limit' +# /inResponse delete response description `code`: `slot_already_booked` +# /jsonish post x-* block description "code": "delete_not_allowed" +# +# /clean patch SILENT <- error.reason = template_in_use +# /status patch SILENT <- code = 409, `code` is an integer +# /otherCodes get SILENT <- status_code / reason_code / postal code +# +# The silent cases are the load-bearing ones. A value without an underscore +# (`code: integer`, `code = 409`) is how prose describes the field itself, and +# `\b` keeps `status_code = foo_bar` out. A rule that fires on those is a rule +# a consumer switches off. +# +# Not a Specfuse-conformant example — do not copy. +# ============================================================================= +openapi: 3.0.3 +info: + title: error-reason-prose fixture + version: "1.0.0" +components: + schemas: + Error: + type: object + required: [code, message] + properties: + code: { type: integer } + reason: { type: string } + message: { type: string } +paths: + + # VIOLATION: assignment in an operation description. + /assign: + patch: + summary: Update the template + description: Returns 409 with error.code = template_in_use while a menu uses it. + responses: + '200': { description: OK } + + # VIOLATION: comparison in an operation summary. + /compare: + get: + summary: "List items; clients check errors[0].code == 'page_size_over_limit'" + responses: + '200': { description: OK } + + # VIOLATION: backticked, in a nested response description. + /inResponse: + delete: + summary: Delete the booking + responses: + '204': { description: No Content } + '409': + description: 'The slot is taken (`code`: `slot_already_booked`).' + + # VIOLATION: JSON-ish, in an `x-*` block's description. + /jsonish: + post: + summary: Archive the thing + x-notes: + description: 'Fails with {"code": "delete_not_allowed"} once archived.' + responses: + '200': { description: OK } + + # SILENT: the rule name is in `reason`. + /clean: + patch: + summary: Update the template + description: Returns 409 with error.reason = template_in_use while a menu uses it. + responses: + '200': { description: OK } + + # SILENT: `code` named with the status, and described as a field. + /status: + patch: + summary: Update with a precondition + description: >- + A conflict returns code = 409 and a stale ETag code: 412. The field is + declared as `code: integer`. + responses: + '200': { description: OK } + + # SILENT: other words ending in "code". + /otherCodes: + get: + summary: Look up an address + description: >- + Filters on status_code = not_found and reason_code: out_of_area; the + postal code is free text. + responses: + '200': { description: OK } diff --git a/schemas/spectral/functions/openapiErrorReasonProse.js b/schemas/spectral/functions/openapiErrorReasonProse.js new file mode 100644 index 0000000..5888fd9 --- /dev/null +++ b/schemas/spectral/functions/openapiErrorReasonProse.js @@ -0,0 +1,81 @@ +"use strict"; + +// specfuse-error-rule-name-in-code — prose names a rule in `code`, which +// carries the HTTP status. +// +// Since authoring#159 (compatibility.md §54) the kit's `Error` carries the +// HTTP status in an integer `code` and the snake_case rule name in `reason`. +// A description that says `error.code = template_in_use` passes `validate` and +// `generate` and still sends every client that reads it to branch on a field +// that never holds that value. Response examples are covered elsewhere: +// `spectral:oas` example validation rejects a string `code`. Nothing checks prose. +// +// Gate: the rule speaks only when `components.schemas.Error.properties.code` +// is typed `integer`. A project still on the old shape (string `code` = rule +// name) is described correctly by that prose, and flagging it would +// push the migration through a lint warning instead of §54's note. +// +// Targets: every `description` and `summary` string in the document, at any +// depth. Operations, responses, parameters, schemas and `x-*` blocks all carry +// prose a client reads. +// +// Matches (the key may be written `code`, `error.code`, `errors[0].code`, +// quoted or in backticks, so the match starts at `code` itself): +// code = foo_bar code == 'foo_bar' code: foo_bar "code": "foo_bar" +// The value must contain an underscore. That is what separates a rule name +// (`template_in_use`, `slot_already_booked`) from prose such as `code: integer` +// or `code = status`, and a rule that fires on those is a rule a consumer +// switches off. A single-word rule name is not caught; the trade is deliberate. +// `\b` before `code` keeps `status_code` and `reason_code` out. +// +// Given: $ (resolved, so a multi-file bundle's `Error` and descriptions are +// visible; Spectral maps each finding back to its source file and drops the +// duplicates a shared `$ref` produces). + +const RULE_IN_CODE = + /\bcode[`"']?\s*(?:===?|:|=)\s*[`"']?([a-z][a-z0-9]*_[a-z0-9_]*)\b/g; + +function isObj(v) { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function codeIsInteger(doc) { + const error = + isObj(doc.components) && isObj(doc.components.schemas) ? doc.components.schemas.Error : undefined; + if (!isObj(error) || !isObj(error.properties)) return false; + const code = error.properties.code; + if (!isObj(code)) return false; + const t = code.type; + return t === "integer" || (Array.isArray(t) && t.includes("integer")); +} + +function walk(node, path, out, seen) { + if (node === null || typeof node !== "object" || seen.has(node)) return; + seen.add(node); + const entries = Array.isArray(node) ? node.map((v, i) => [i, v]) : Object.entries(node); + for (const [key, value] of entries) { + if ((key === "description" || key === "summary") && typeof value === "string") { + const names = []; + for (const m of value.matchAll(RULE_IN_CODE)) names.push(m[1]); + if (names.length > 0) out.push({ path: path.concat([key]), names }); + } else { + walk(value, path.concat([key]), out, seen); + } + } + seen.delete(node); +} + +module.exports = function openapiErrorReasonProse(targetVal) { + if (!isObj(targetVal) || !codeIsInteger(targetVal)) return; + const found = []; + walk(targetVal, [], found, new Set()); + return found.map(({ path, names }) => { + const first = names[0]; + return { + message: + `Prose names the rule ${names.map((n) => `'${n}'`).join(", ")} in \`code\`, but Error.code is the HTTP status. ` + + `Name the rule in \`reason\` (error.reason = ${first}). API Handbook §11.`, + path, + }; + }); +}; diff --git a/schemas/spectral/specfuse-openapi.yaml b/schemas/spectral/specfuse-openapi.yaml index c02ee96..06fc605 100644 --- a/schemas/spectral/specfuse-openapi.yaml +++ b/schemas/spectral/specfuse-openapi.yaml @@ -34,6 +34,7 @@ extends: "spectral:oas" formats: [oas3] functions: + - openapiErrorReasonProse - openapiProjectionCoherence - openapiChildCollectionReconcile - openapiPillarRegistry @@ -326,6 +327,22 @@ rules: functionOptions: notMatch: "\\b428\\b" + # `Error.code` is the HTTP status and the rule name is `reason` (API + # Handbook §11, compatibility.md §54). A schema migration does not touch + # description text, so prose saying `error.code = template_in_use` survives + # it and points clients at a field that never holds that value. The function + # header documents the gate (integer `Error.code` only) and why a match + # needs an underscore in the value. + specfuse-error-rule-name-in-code: + description: >- + Prose must not name a rule in `code` when Error.code is the HTTP status. + Write error.reason = . See API Handbook section 11. + message: "{{error}}" + severity: warn + given: $ + then: + function: openapiErrorReasonProse + # Conditional GET: `If-None-Match` + `304 Not Modified`. Generator 0.12.0's # ConditionalReadDeclarationValidationRule reports all five of the jar's # checks at ERROR; see the function header for the id each one mirrors and