From bb0bc33973f5c2194dfad1bcba94fd7dc510298d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 06:37:15 +0000 Subject: [PATCH] gate: reconcile the documented HTTP status of an error code against the status the runtime emits (#8967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/api/error-catalog.mdx` and `content/docs/protocol/kernel/error-handling.mdx` publish an HTTP status per error code; the doors decide it. Nothing reconciled the two, so `MISSING_REQUIRED_FIELD` was documented 400 in both pages while five `controlled_by_parent` refusal paths answered 422, with CI green throughout. `scripts/check-error-status-conformance.mjs` closes that. The runtime side is DERIVED, never listed: error classes' own `readonly status` / `readonly statusCode`, both `sendError` doors' literal arguments, the REST mapper's `{ status, body }` terminals, and `HttpStatusErrorCodeMap`'s explicit entries (never the bucket fallback). Identifiers resolve through a repo-wide constant index including computed-key status tables; an unresolvable declaration is reported, not dropped; comments are stripped first, because this repo narrates envelopes in prose and two of the gate's first findings were docblocks describing a bug that had been fixed. The assertion is a set comparison in BOTH directions, so a code may carry more than one documented status: every status the runtime can emit is documented, and every status the docs claim is reachable. Doc statements are graded — per-code lines and quick-reference rows CLAIM (both directions), `## … (NNN)` section headings only COVER (direction A), because a category heading is not a per-code assertion. Verified red on PR #8963's merge parent (90197e15e), where the real pre-fix pages filed the code under 400 with no exception: x MISSING_REQUIRED_FIELD: the runtime can emit HTTP 422, and no doc publishes that status for it (documented: 400). Emitted at packages/plugins/plugin-security/src/errors.ts: class MasterReferenceMissingError. Swapping in ONLY #8963's two doc files on that same tree clears it. The real pre-fix text is embedded as a `--self-test` red case so the property is pinned, not demonstrated once. Two more of the same defect class, found by the new instrument on `main` and corrected here in `error-catalog.mdx`'s quick reference (docs only, no runtime status moved): `NOT_IMPLEMENTED` is served 501 by four storage routes while the page filed it under 500, and `TIMEOUT` is the code a 504 carries while the 500 row claimed it and nothing emits it at 500. Refs #8967 --- .github/workflows/lint.yml | 15 + content/docs/api/error-catalog.mdx | 14 +- package.json | 1 + scripts/check-error-status-conformance.mjs | 896 ++++++++++++++++++++ scripts/error-status-unpinned-baseline.json | 42 + 5 files changed, 966 insertions(+), 2 deletions(-) create mode 100644 scripts/check-error-status-conformance.mjs create mode 100644 scripts/error-status-unpinned-baseline.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e138c24dd6..8791883d9f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -382,6 +382,21 @@ jobs: - name: Error-code casing guard run: pnpm check:error-code-casing + # #8967: the docs publish an HTTP status per error code and the doors decide + # it; nothing reconciled the two. `MISSING_REQUIRED_FIELD` was documented 400 + # in both pages while five controlled_by_parent refusals answered 422 — on a + # public error contract, with CI green the whole time, found only by a human + # reading a PR. The runtime side is DERIVED (error classes' `readonly status`, + # both `sendError` doors, `HttpStatusErrorCodeMap`), never a second hand-written + # table, and the assertion is a set comparison in BOTH directions so a code may + # legitimately carry more than one documented status. Runs its own --self-test + # first, which pins the real pre-fix doc text as a red case. Placed beside the + # other ADR-0112 error-vocabulary guards, and deliberately in a job with no + # `paths:` filter — either side of this reconciliation can move it, so filtering + # on `packages/**` or on `content/**` would blind it to half its own subject. + - name: Documented HTTP status matches the status the runtime emits + run: pnpm check:error-status-conformance + # Namespace-wildcard fall-through guard (#4116). A handler mounted on # `/*` claims the whole namespace, and Hono's first-registered # handler that answers wins — so a TERMINAL wildcard makes every other diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index e5230c6c48..fc8a4de9b9 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -404,7 +404,15 @@ an environment scope (no `X-Environment-Id` header and no hostname mapping). --- -## Server Errors (500) +## Server Errors (5xx) + +The heading is `5xx`, not `500`, because these five codes are not all served with +the same status: `TIMEOUT` is the code a `504` carries and `NOT_IMPLEMENTED` the +code a `501` carries (`HttpStatusErrorCodeMap`, `packages/spec/src/api/errors.zod.ts`), +and `SERVICE_UNAVAILABLE` is a `503`. The per-code status is in the +[quick reference](#http-status-quick-reference) at the end of this page, and +`pnpm check:error-status-conformance` reconciles every row there against what the +runtime can actually emit. ### `INTERNAL_ERROR` **Cause:** An unexpected server-side error occurred. @@ -673,6 +681,8 @@ async function handleApiCall() { | 409 | `conflict` | `CONCURRENT_MODIFICATION`, `DUPLICATE_RECORD`, `DELETE_RESTRICTED` | | 422 | `validation` | `MISSING_REQUIRED_FIELD` on an absent `controlled_by_parent` master reference (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code | | 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` | -| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR`, `TIMEOUT` | +| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR` | +| 501 | `server` | `NOT_IMPLEMENTED` — the storage routes answer it when the adapter cannot issue presigned URLs | | 502 | `external` | `EXTERNAL_SERVICE_ERROR`, `INTEGRATION_ERROR` | | 503 | `maintenance` | `SERVICE_UNAVAILABLE` | +| 504 | `server` | `TIMEOUT` — the status this code names; no route answers `TIMEOUT` with `500` | diff --git a/package.json b/package.json index aaa441d4af..b1edee46a7 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "check:service-providers": "node scripts/check-service-providers.mjs", "check:route-envelope": "node scripts/check-route-envelope.mjs --self-test && node scripts/check-route-envelope.mjs", "check:error-code-casing": "node scripts/check-error-code-casing.mjs --self-test && node scripts/check-error-code-casing.mjs", + "check:error-status-conformance": "node scripts/check-error-status-conformance.mjs --self-test && node scripts/check-error-status-conformance.mjs", "check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs", "check:meta-type-normalized": "node scripts/check-meta-type-normalized.mjs --self-test && node scripts/check-meta-type-normalized.mjs", "check:filter-alias-parity": "node scripts/check-filter-alias-parity.mjs --self-test && node scripts/check-filter-alias-parity.mjs", diff --git a/scripts/check-error-status-conformance.mjs b/scripts/check-error-status-conformance.mjs new file mode 100644 index 0000000000..5aed245dc4 --- /dev/null +++ b/scripts/check-error-status-conformance.mjs @@ -0,0 +1,896 @@ +#!/usr/bin/env node +// check-error-status-conformance — reconcile the HTTP status the DOCS publish for +// an error code against the status the RUNTIME can actually emit for it. +// +// node scripts/check-error-status-conformance.mjs +// node scripts/check-error-status-conformance.mjs --self-test +// node scripts/check-error-status-conformance.mjs --report # print the whole derivation +// node scripts/check-error-status-conformance.mjs --update # ⛔ MAINTAINER-ONLY +// +// ## Why this exists +// +// `content/docs/api/error-catalog.mdx` and +// `content/docs/protocol/kernel/error-handling.mdx` both publish an HTTP status +// per error code. The statuses are DECIDED at the doors (`packages/rest`, +// `packages/types`, the error classes each package throws). Nothing reconciled +// the two, so they were free to disagree indefinitely — and did: +// `MISSING_REQUIRED_FIELD` was documented 400 in both pages while five +// `controlled_by_parent` refusal paths answered 422, on a public error contract, +// with CI fully green. It was found by a human reading a PR. +// +// ## The two shapes this had to get right +// +// **(a) The runtime side is DERIVED, never listed.** A hand-written code→status +// table here would be a second copy of the very thing that drifted, needing its +// own drift tripwire; the repo already carries one such list, reconciled against +// nothing, as the standing example of what not to build. Every runtime status +// below is read out of source: the error classes' own `readonly status` / +// `readonly statusCode`, the two `sendError` doors' literal arguments, the REST +// mapper's terminal envelopes, and the door's own `HttpStatusErrorCodeMap`. +// Where an identifier cannot be resolved the declaration is REPORTED as +// unresolved, never silently dropped — a deriver that goes quietly blind is the +// same failure one layer down. +// +// **(b) One code may legitimately have MORE THAN ONE status.** +// `MISSING_REQUIRED_FIELD` is documented as 400 with one documented 422 +// exception. A gate asserting a single status per code would demand the docs +// lie. So the assertion is a SET comparison in both directions: +// +// A. every status the runtime can emit for a code is documented for it, and +// B. every status the docs CLAIM for a code is one the runtime can emit +// (asserted only for codes the deriver actually found a producer for — +// see the unpinned census below). +// +// ## The doc side: two grades of statement, deliberately +// +// claimed A per-code assertion, used for BOTH directions: +// • `error-handling.mdx` `#### \`CODE\`` + `**HTTP Status:** …` +// (every 4xx/5xx integer on that line — this is what carries a +// documented exception: "400 — with one documented exception, +// which answers **422**" claims {400, 422}) +// • `error-catalog.mdx` the HTTP Status Quick Reference rows +// covered A weaker CATEGORY statement, used for direction A only: +// • `error-catalog.mdx` `## … Errors (NNN)` section headings +// +// The section headings are graded down on purpose. `## Validation Errors (400)` +// mirrors `ErrorCategory.validation → 400` (`ErrorHttpStatusMap`): it states the +// CATEGORY's status, not each member's. Reading it as a per-code assertion makes +// the gate manufacture findings out of a heading that never claimed them — +// `## Server Errors (500)` groups codes served at 500, 501, 503 and 504. It can +// still ABSOLVE an emitted status (a 405 under `## Request Errors (405/428)` is +// documented), which is all direction A needs from it. +// +// ## Bounds — stated here and printed on every run, so a partial gate can never +// ## read as a complete one +// +// • Reconciled vocabulary: `StandardErrorCode` members ONLY. Those are the +// codes both pages publish a status for. Registered ledger codes +// (`ERROR_CODE_LEDGER`) are derived and counted, but neither page publishes +// their status, so there is nothing to reconcile them against. +// • `HttpStatusErrorCodeMap`'s EXPLICIT entries only, never +// `standardErrorCodeForHttpStatus`'s bucket fallback — the same bound +// `standardSynonymOf` draws in `error-code-ledger.zod.ts`, and for the same +// reason: the fallback would make `VALIDATION_ERROR` "emittable" at every +// unnamed 4xx and `INTERNAL_ERROR` at every unnamed 5xx. +// • Direction B is asserted only for codes with ≥1 derived producer. A code +// with none is not silently passed: it lands in the unpinned census below. +// +// ## The unpinned census — "no in-package declaration" as a finding, not a pass +// +// #8880 recorded the honest complication: `ValidationError` declares no status +// at all, so nothing pinned the doc's claim on either side of that branch. This +// gate states that class of finding rather than passing it. Every documented +// standard code with ZERO derived producer is listed in +// `scripts/error-status-unpinned-baseline.json`; a NEW one fails the gate, and a +// row that becomes pinned fails it too (ratchet down with `--update`). +import { readdirSync, readFileSync, writeFileSync, statSync, existsSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +const SCAN_ROOT = 'packages'; +const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', 'coverage', 'build', 'fixtures']); +const ERRORS_ZOD = 'packages/spec/src/api/errors.zod.ts'; +const DOC_HANDLING = 'content/docs/protocol/kernel/error-handling.mdx'; +const DOC_CATALOG = 'content/docs/api/error-catalog.mdx'; +const BASELINE_PATH = 'scripts/error-status-unpinned-baseline.json'; + +/** + * Kept identical to the token `check-role-word` / `check-engine-double-contract` + * / `check-type-check-coverage` print, so the #8435 convention stays greppable: + * a remedy that WEAKENS a ratchet must say whose path it is. + */ +const RATCHET_AUTHORITY_MARKER = '⛔ MAINTAINER-ONLY'; + +/** + * The baseline-EXPANDING offer, as a detector rather than a string compare, so + * the self-test proves it still reaches its subject — a reworded offer that + * stopped matching would make the convention check pass vacuously. + */ +const RATCHET_EXPANSION_OFFER = /admit it into\s+scripts\/error-status-unpinned-baseline\.json/; + +// ─────────────────────────────────────────────────────────────────────────── +// Constant resolution +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Index every `const NAME = ` and `const OBJ = { key: }` in + * the scanned sources, so a declaration written as `readonly code = SOME_CODE` + * still resolves. + * + * A name bound to two different literals in two files is recorded as AMBIGUOUS + * and refused at resolution time. Guessing there would let the gate assert a + * status nobody declared, which is worse than reporting the declaration + * unresolved. + * + * @param {Map} sources path → source text + */ +export function buildConstantIndex(sources) { + const values = new Map(); + const ambiguous = new Set(); + const put = (name, value) => { + if (values.has(name)) { + if (values.get(name) !== value) ambiguous.add(name); + return; + } + values.set(name, value); + }; + const TAIL = '\\s*(?:as const\\s*)?(?:satisfies\\s+[^;]+)?;'; + const SCALAR = new RegExp( + `^\\s*(?:export\\s+)?const\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=\\n]+)?=\\s*(?:'([^'\\n]*)'|"([^"\\n]*)"|(\\d{3}))${TAIL}`, + 'gm', + ); + const OBJECT = new RegExp( + `^\\s*(?:export\\s+)?const\\s+([A-Za-z_$][\\w$]*)\\s*(?::[^=\\n]+)?=\\s*\\{([^{}]*)\\}${TAIL}`, + 'gm', + ); + const LITERAL_ENTRY = /(?:^|[\s,])(?:'([^'\n]+)'|([A-Za-z_$][\w$]*)|(\d+))\s*:\s*(?:'([^'\n]*)'|"([^"\n]*)"|(\d{3}))/g; + // `[EXTERNAL_ERROR_CODES.schemaMismatch]: 503` — a COMPUTED key, resolved in a + // second pass once the scalar/object entries it names are indexed. The pattern + // is how this repo writes a status table beside the code table it keys on + // (`external-errors.ts`), so not reading it left the deriver blind to a whole + // error family and reporting three declarations it could have resolved. + const COMPUTED_ENTRY = /\[\s*([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\s*\]\s*:\s*(?:'([^'\n]*)'|"([^"\n]*)"|(\d{3}))/g; + + const clean = [...sources.values()].map(stripComments); + const objectBodies = []; + for (const src of clean) { + for (const m of src.matchAll(SCALAR)) put(m[1], m[2] ?? m[3] ?? Number(m[4])); + for (const m of src.matchAll(OBJECT)) { + objectBodies.push([m[1], m[2]]); + for (const kv of m[2].matchAll(LITERAL_ENTRY)) { + put(`${m[1]}.${kv[1] ?? kv[2] ?? kv[3]}`, kv[4] ?? kv[5] ?? Number(kv[6])); + } + } + } + const index = { values, ambiguous }; + for (const [name, body] of objectBodies) { + for (const kv of body.matchAll(COMPUTED_ENTRY)) { + const key = lookup(kv[1], index); + if (key === undefined) continue; + put(`${name}.${key}`, kv[2] ?? kv[3] ?? Number(kv[4])); + } + } + return index; +} + +/** @returns {string|undefined} */ +export function resolveString(expr, index) { + const e = String(expr).trim().replace(/\s+as const$/, ''); + const lit = /^'([^']*)'$/.exec(e) ?? /^"([^"]*)"$/.exec(e); + if (lit) return lit[1]; + const v = lookup(e, index); + return typeof v === 'string' ? v : undefined; +} + +/** @returns {number|undefined} an HTTP status in the 400–599 band */ +export function resolveStatus(expr, index) { + const e = String(expr).trim().replace(/\s+as const$/, ''); + const v = /^\d{3}$/.test(e) ? Number(e) : lookup(e, index); + return typeof v === 'number' && v >= 400 && v < 600 ? v : undefined; +} + +function lookup(e, index) { + if (/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/.test(e)) { + return index.ambiguous.has(e) ? undefined : index.values.get(e); + } + // MAP[OBJ.key] / MAP[NAME] — resolve the subscript, then the member. + const m = /^([A-Za-z_$][\w$]*)\[([A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?)\]$/.exec(e); + if (!m) return undefined; + const key = lookup(m[2], index); + if (key === undefined || index.ambiguous.has(m[1])) return undefined; + return index.values.get(`${m[1]}.${key}`); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Runtime side — derived +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Blank out comments (keeping byte offsets, so reported line numbers stay + * true), because this repo DOCUMENTS envelopes in prose. + * + * Not a tidiness measure — it is load-bearing. Two of this gate's first findings + * on `main` were docblocks: `quickjs-runner.ts` narrates the bug it fixed + * ("the action surface answered `{ code: 'RECORD_NOT_FOUND', httpStatus: 400 }`") + * and `protocol.ts` names a shape it exists to PREVENT ("would mint incoherent + * rows — `{ code: 'INTERNAL_ERROR', httpStatus: 409 }`"). Read as producers, + * both manufacture a status disagreement out of a sentence saying the opposite. + * A deriver that mines prose is not a deriver. + * + * Strings and template literals are tracked only so a `//` or `/*` inside one + * cannot open a phantom comment; their contents are left intact. + */ +export function stripComments(src) { + const out = src.split(''); + let i = 0; + const n = src.length; + while (i < n) { + const c = src[i]; + const d = src[i + 1]; + if (c === '/' && d === '/') { + while (i < n && src[i] !== '\n') { out[i] = ' '; i++; } + continue; + } + if (c === '/' && d === '*') { + while (i < n && !(src[i] === '*' && src[i + 1] === '/')) { if (src[i] !== '\n') out[i] = ' '; i++; } + if (i < n) { out[i] = ' '; out[i + 1] = ' '; i += 2; } + continue; + } + if (c === "'" || c === '"' || c === '`') { + const quote = c; + // A `'` or `"` never spans a line in JS, so a quote character mis-read + // out of a regex character class (`/["'`]/`) recovers at end of line + // instead of swallowing the rest of the file. + const bounded = quote !== '`'; + i++; + while (i < n && src[i] !== quote && !(bounded && src[i] === '\n')) { + if (src[i] === '\\') i++; + i++; + } + i++; + continue; + } + i++; + } + return out.join(''); +} + +/** Brace-matched class bodies, so a property read never escapes its class. */ +function classBodies(src) { + const out = []; + const re = /(?:^|\n)(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)[^{;]*\{/g; + let m; + while ((m = re.exec(src))) { + const open = m.index + m[0].length - 1; + let depth = 0; + let i = open; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) { i++; break; } + } + out.push({ name: m[1], body: src.slice(open, i) }); + re.lastIndex = i; + } + return out; +} + +const lineOf = (src, idx) => src.slice(0, idx).split('\n').length; + +/** + * Every (code, status) pair the scanned sources prove reachable, with the + * evidence that proves it. + * + * @param {Map} sources path → source text + * @returns {{ emitted: Map>, unresolved: string[], sites: number }} + */ +export function deriveRuntimeStatuses(sources, index) { + const emitted = new Map(); + const unresolved = []; + let sites = 0; + const record = (code, status, where) => { + sites++; + if (!emitted.has(code)) emitted.set(code, new Map()); + const perStatus = emitted.get(code); + if (!perStatus.has(status)) perStatus.set(status, []); + const list = perStatus.get(status); + if (list.length < 4 && !list.includes(where)) list.push(where); + }; + const refuse = (where, code, status) => + unresolved.push(`${where}: code=${String(code).trim()} status=${String(status).trim()}`); + + for (const [path, raw] of sources) { + const src = stripComments(raw); + // R1 — error classes declaring their own code + status/statusCode. + for (const { name, body } of classBodies(src)) { + const codeM = /^[ \t]*(?:public\s+|protected\s+|private\s+)?readonly\s+code\s*(?::[^=\n]+)?=\s*([^;\n]+);/m.exec(body); + if (!codeM) continue; + const statusM = + /^[ \t]*(?:public\s+|protected\s+|private\s+)?readonly\s+status\s*(?::[^=\n]+)?=\s*([^;\n]+);/m.exec(body) + ?? /^[ \t]*(?:public\s+|protected\s+|private\s+)?readonly\s+statusCode\s*(?::[^=\n]+)?=\s*([^;\n]+);/m.exec(body); + if (!statusM) continue; + const code = resolveString(codeM[1], index); + const status = resolveStatus(statusM[1], index); + if (code === undefined || status === undefined) { refuse(`${path} class ${name}`, codeM[1], statusM[1]); continue; } + record(code, status, `${path}: class ${name}`); + } + // R2 — the `@objectstack/types` envelope door: sendError(res, status, code, message). + for (const m of src.matchAll(/\bsendError\(\s*[\w$.]+\s*,\s*('[^']*'|"[^"]*"|[\w$.]+)\s*,\s*('[^']*'|"[^"]*"|[\w$.]+)\s*[,)]/g)) { + const status = resolveStatus(m[1], index); + const code = resolveString(m[2], index); + if (status === undefined || code === undefined) { + // Only a shape that LOOKS like the 4-arg door is a refusal; the 2-arg + // `packages/rest` door (sendError(res, error)) is a different function. + if (/^\d{3}$/.test(m[1].trim()) || /^[A-Z_]+$/.test(m[1].trim().replace(/^.*\./, ''))) refuse(`${path}:${lineOf(src, m.index)} sendError`, m[2], m[1]); + continue; + } + record(code, status, `${path}:${lineOf(src, m.index)}: sendError`); + } + // R3/R4/R5 — object literals that carry both a code and a status: the + // `packages/rest` sendError door, `Object.assign(new Error(…), …)` throws, + // and the REST mapper's `{ status, body: { code } }` terminals. + for (const m of src.matchAll(/\{[^{}]*\bcode\s*:[^{}]*\}|\{[^{}]*\bstatus\s*:[^{}]*\}/g)) { + const inner = m[0]; + const c = /\bcode\s*:\s*('[^']*'|"[^"]*"|[\w$.]+)/.exec(inner); + // `status` / `statusCode` ONLY, the two spellings `declaredHttpStatus` + // (`packages/rest/src/error-response.ts`) actually reads. `httpStatus` is + // a RESPONSE BODY field, not a thrown error's declaration of its status, + // and mining it read narrated envelopes as producers. + const s = /\b(?:status|statusCode)\s*:\s*('[^']*'|"[^"]*"|[\w$.]+)/.exec(inner); + if (!c || !s) continue; + const code = resolveString(c[1], index); + const status = resolveStatus(s[1], index); + if (code === undefined || status === undefined) continue; // an unrelated `{ code, status }` pair + if (!/^[A-Z][A-Z0-9_]*$/.test(code)) continue; + record(code, status, `${path}:${lineOf(src, m.index)}: { code, status }`); + } + // R5b — `{ status: N, body: { …, code: 'X' } }`, the mapper's two sanitised + // 5xx terminals, where the pair straddles a nested brace. + for (const m of src.matchAll(/\bstatus\s*:\s*(\d{3})\s*,\s*body\s*:\s*\{([^{}]*)\}/g)) { + const c = /\bcode\s*:\s*'([A-Z][A-Z0-9_]*)'/.exec(m[2]); + const status = resolveStatus(m[1], index); + if (!c || status === undefined) continue; + record(c[1], status, `${path}:${lineOf(src, m.index)}: { status, body }`); + } + } + return { emitted, unresolved, sites }; +} + +/** + * The door's own status→code map. EXPLICIT entries only — see the bounds note + * in this file's header for why the bucket fallback is excluded. + */ +export function deriveDoorMap(errorsZodSource) { + const out = []; + const block = /export const HttpStatusErrorCodeMap[^=]*=\s*\{([\s\S]*?)\n\};/.exec(errorsZodSource); + if (!block) return out; + for (const kv of block[1].matchAll(/(\d{3})\s*:\s*'([A-Z][A-Z0-9_]*)'/g)) { + out.push({ code: kv[2], status: Number(kv[1]) }); + } + return out; +} + +/** The reconciled vocabulary: `StandardErrorCode`'s members. */ +export function parseStandardErrorCodes(errorsZodSource) { + const block = /export const StandardErrorCode = z\.enum\(\[([\s\S]*?)\]\);/.exec(errorsZodSource); + if (!block) throw new Error(`${ERRORS_ZOD}: StandardErrorCode enum not found — the deriver's anchor moved.`); + return [...block[1].matchAll(/'([A-Z][A-Z0-9_]*)'/g)].map((m) => m[1]); +} + +// ─────────────────────────────────────────────────────────────────────────── +// Doc side — parsed +// ─────────────────────────────────────────────────────────────────────────── + +const STATUS_IN_PROSE = /\b([45]\d\d)\b/g; + +/** + * @param {{ handling: string, catalog: string }} docs + * @returns {{ claimed: Map>, covered: Map>, documented: Set }} + */ +export function parseDocumentedStatuses(docs) { + const claimed = new Map(); + const covered = new Map(); + const documented = new Set(); + const add = (map, code, status, where) => { + documented.add(code); + if (!map.has(code)) map.set(code, new Map()); + const perStatus = map.get(code); + if (!perStatus.has(status)) perStatus.set(status, []); + if (!perStatus.get(status).includes(where)) perStatus.get(status).push(where); + }; + + // error-handling.mdx — `#### \`CODE\`` then `**HTTP Status:** …` + const h = docs.handling.split('\n'); + for (let i = 0; i < h.length; i++) { + const head = /^#### `([A-Z][A-Z0-9_]*)`\s*$/.exec(h[i]); + if (!head) continue; + documented.add(head[1]); + for (let j = i + 1; j < Math.min(i + 6, h.length); j++) { + const line = /^\*\*HTTP Status:\*\*(.*)$/.exec(h[j]); + if (!line) continue; + for (const s of line[1].matchAll(STATUS_IN_PROSE)) { + add(claimed, head[1], Number(s[1]), `${DOC_HANDLING}:${j + 1}`); + } + break; + } + } + + // error-catalog.mdx — section headings (covered) and the quick-reference rows (claimed) + const c = docs.catalog.split('\n'); + let section = null; + for (let i = 0; i < c.length; i++) { + const sec = /^## (.+?)\s*$/.exec(c[i]); + if (sec) { + section = { title: sec[1], statuses: [...sec[1].matchAll(STATUS_IN_PROSE)].map((m) => Number(m[1])) }; + continue; + } + const entry = /^### `([A-Z][A-Z0-9_]*)`\s*$/.exec(c[i]); + if (entry) { + documented.add(entry[1]); + for (const st of section?.statuses ?? []) { + add(covered, entry[1], st, `${DOC_CATALOG}:${i + 1} (§ ${section.title})`); + } + continue; + } + const row = /^\|\s*(\d{3})\s*\|[^|]*\|(.*)\|\s*$/.exec(c[i]); + if (row) { + for (const cm of row[2].matchAll(/`([A-Z][A-Z0-9_]*)`/g)) { + add(claimed, cm[1], Number(row[1]), `${DOC_CATALOG}:${i + 1} (quick reference)`); + } + } + } + return { claimed, covered, documented }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Reconciliation +// ─────────────────────────────────────────────────────────────────────────── + +const statuses = (map, code) => new Set(map.get(code)?.keys() ?? []); +const evidence = (map, code, status) => (map.get(code)?.get(status) ?? []).join(', '); + +/** + * The set comparison, in both directions, over the reconciled vocabulary. + * + * @returns {{ emittedNotDocumented: object[], documentedNotReachable: object[], + * unpinned: string[], reconciledCodes: number, reconciledPairs: number }} + */ +export function reconcile({ members, emitted, claimed, covered, documented }) { + const emittedNotDocumented = []; + const documentedNotReachable = []; + const unpinned = []; + let reconciledCodes = 0; + let reconciledPairs = 0; + + for (const code of members) { + const runtime = statuses(emitted, code); + const docClaimed = statuses(claimed, code); + const docCovered = statuses(covered, code); + const docAll = new Set([...docClaimed, ...docCovered]); + + // Direction A — every status the runtime can emit is documented. + for (const status of [...runtime].sort()) { + if (docAll.has(status)) { reconciledPairs++; continue; } + emittedNotDocumented.push({ + code, + status, + documented: [...docAll].sort(), + where: evidence(emitted, code, status), + }); + } + // Direction B — every status the docs CLAIM is one the runtime can emit. + // Bounded to codes with a derived producer: see the unpinned census. + if (runtime.size > 0) { + reconciledCodes++; + for (const status of [...docClaimed].sort()) { + if (runtime.has(status)) continue; + documentedNotReachable.push({ + code, + status, + emits: [...runtime].sort(), + where: evidence(claimed, code, status), + }); + } + } else if (documented.has(code)) { + unpinned.push(code); + } + } + return { emittedNotDocumented, documentedNotReachable, unpinned: unpinned.sort(), reconciledCodes, reconciledPairs }; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Messages — named and pure, so the self-test can assert the exact text +// ─────────────────────────────────────────────────────────────────────────── + +export function emittedNotDocumentedMessage(f) { + return ( + `${f.code}: the runtime can emit HTTP ${f.status}, and no doc publishes that status for it ` + + `(documented: ${f.documented.length ? f.documented.join(', ') : 'nothing'}). ` + + `Emitted at ${f.where}. Document the status where it belongs — a code may carry more than ` + + `one, so an exception is documented, not flattened away.` + ); +} + +export function documentedNotReachableMessage(f) { + return ( + `${f.code}: the docs publish HTTP ${f.status} (${f.where}), and no producer this gate can read ` + + `emits that code at that status (derived: ${f.emits.join(', ')}). Either the doc claim is stale, ` + + `or a producer exists in a shape the deriver cannot read — teach the deriver rather than ` + + `deleting the claim, and add a --self-test case for the shape.` + ); +} + +export function newUnpinnedMessage(code) { + return ( + `${code}: documented with an HTTP status, but no producer declares one — nothing pins the doc's ` + + `claim on either side, so it can drift with no test to update. Wire a producer that declares ` + + `\`status\`/\`statusCode\` (or a \`sendError\` door with a literal status). Only if the code is ` + + `genuinely unemitted today, admit it into scripts/error-status-unpinned-baseline.json — that ` + + `path WEAKENS this ratchet and is ${RATCHET_AUTHORITY_MARKER}, not a co-equal remedy.` + ); +} + +export function nowPinnedMessage(code) { + return `${code}: baselined as unpinned, but a producer now declares its status — ratchet the baseline down with --update.`; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Self-test +// ─────────────────────────────────────────────────────────────────────────── + +/** + * The pre-#8963 doc text, byte-copied out of `90197e15e` (PR #8963's merge + * parent) — the real state of the pages when `MISSING_REQUIRED_FIELD` was + * documented 400 while five paths answered 422. Kept verbatim rather than + * hand-authored: a synthetic "wrong" fixture proves the gate can fail, not that + * it catches THIS defect. + */ +const PRE_8963_HANDLING = [ + '#### `MISSING_REQUIRED_FIELD`', + '**HTTP Status:** 400 ', + '**Meaning:** Required field is missing', +].join('\n'); + +const PRE_8963_CATALOG = [ + '## Validation Errors (400)', + '', + '### `MISSING_REQUIRED_FIELD`', + '**Cause:** A required field was not provided in the request body. ', + '', + '## HTTP Status Quick Reference', + '', + '| Status | Category | Common Codes |', + '|:---:|:---|:---|', + '| 400 | `validation` | `VALIDATION_ERROR`, `INVALID_FIELD`, `MISSING_REQUIRED_FIELD`, `INVALID_QUERY` |', +].join('\n'); + +/** The post-#8963 lines, byte-copied out of the landed pages. */ +const POST_8963_HANDLING = [ + '#### `MISSING_REQUIRED_FIELD`', + '**HTTP Status:** 400 — with one documented exception, which answers **422** (see below) ', + '**Meaning:** Required field is missing', +].join('\n'); + +const POST_8963_CATALOG = [ + PRE_8963_CATALOG, + '| 422 | `validation` | `MISSING_REQUIRED_FIELD` on an absent `controlled_by_parent` master reference (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |', +].join('\n'); + +/** The real refusal class, reduced to the two declarations the deriver reads. */ +const CBP_ERROR_CLASS = ` +export class MasterReferenceMissingError extends Error { + readonly code = 'MISSING_REQUIRED_FIELD'; + readonly status = 422; + readonly statusCode = 422; +} +`; + +const CBP_400_DOOR = ` +export function registerPackageRoutes() { + sendError(res, 400, 'MISSING_REQUIRED_FIELD', 'Missing required fields: manifest, metadata'); +} +`; + +function runFixture({ files, handling, catalog, members }) { + const sources = new Map(Object.entries(files)); + const index = buildConstantIndex(sources); + const derived = deriveRuntimeStatuses(sources, index); + const doc = parseDocumentedStatuses({ handling, catalog }); + const result = reconcile({ members, emitted: derived.emitted, ...doc }); + return { ...result, unresolved: derived.unresolved, emitted: derived.emitted }; +} + +function selfTest() { + const failures = []; + const check = (name, ok, detail) => { if (!ok) failures.push(`${name}${detail ? ` — ${detail}` : ''}`); }; + const members = ['MISSING_REQUIRED_FIELD', 'VALIDATION_ERROR', 'TIMEOUT']; + + // 1 — THE regression pin: the real pre-#8963 pages go RED, naming 422. + const pre = runFixture({ + files: { 'a/errors.ts': CBP_ERROR_CLASS, 'a/routes.ts': CBP_400_DOOR }, + handling: PRE_8963_HANDLING, catalog: PRE_8963_CATALOG, members, + }); + check('1 pre-#8963 fixture is red', pre.emittedNotDocumented.length === 1, + `got ${pre.emittedNotDocumented.length}`); + check('1b pre-#8963 finding names MISSING_REQUIRED_FIELD @422', + pre.emittedNotDocumented[0]?.code === 'MISSING_REQUIRED_FIELD' && pre.emittedNotDocumented[0]?.status === 422, + JSON.stringify(pre.emittedNotDocumented[0])); + + // 2 — POSITIVE CONTROL for the zero-hit case: the instrument must be SEEING a + // status it accepts, not merely finding nothing. Same fixture, post-#8963 + // text: zero findings AND a non-zero count of reconciled (code, status) + // pairs, so "0 findings" can never be reported by a blind run. + const post = runFixture({ + files: { 'a/errors.ts': CBP_ERROR_CLASS, 'a/routes.ts': CBP_400_DOOR }, + handling: POST_8963_HANDLING, catalog: POST_8963_CATALOG, members, + }); + check('2 post-#8963 fixture is green', post.emittedNotDocumented.length === 0 && post.documentedNotReachable.length === 0, + JSON.stringify([post.emittedNotDocumented, post.documentedNotReachable])); + check('2b positive control: the accepted statuses were actually seen', post.reconciledPairs === 2, + `reconciledPairs=${post.reconciledPairs}`); + check('2c the multi-status code carries BOTH statuses', + [...(post.emitted.get('MISSING_REQUIRED_FIELD')?.keys() ?? [])].sort().join(',') === '400,422'); + + // 3 — a green run over a fixture with NO producer must NOT report reconciled + // pairs (the blind-run inverse of case 2). + const blind = runFixture({ files: { 'a/x.ts': 'export const nothing = 1;' }, handling: '', catalog: PRE_8963_CATALOG, members }); + check('3 no producers ⇒ no reconciled pairs', blind.reconciledPairs === 0 && blind.reconciledCodes === 0); + check('3b documented-but-unproduced lands in the census, not in a failure', + blind.unpinned.includes('MISSING_REQUIRED_FIELD') && blind.emittedNotDocumented.length === 0); + + // 4 — direction B: a doc claim no producer can reach. + const dirB = runFixture({ + files: { 'a/e.ts': 'export class E extends Error {\n readonly code = \'TIMEOUT\';\n readonly status = 504;\n}' }, + handling: '#### `TIMEOUT`\n**HTTP Status:** 500 \n', catalog: '', members, + }); + check('4 direction B fires on an unreachable documented status', + dirB.documentedNotReachable.length === 1 && dirB.documentedNotReachable[0].status === 500, + JSON.stringify(dirB.documentedNotReachable)); + check('4b direction A fires on the same pair from the other side', + dirB.emittedNotDocumented.length === 1 && dirB.emittedNotDocumented[0].status === 504); + + // 5 — section headings are `covered`: they absolve direction A, and never + // drive direction B. + const sec = runFixture({ + files: { 'a/e.ts': 'export class E extends Error {\n readonly code = \'VALIDATION_ERROR\';\n readonly statusCode = 428;\n}' }, + handling: '', catalog: '## Request Errors (405/428)\n\n### `VALIDATION_ERROR`\n', members, + }); + check('5 a multi-status section heading absolves an emitted status', sec.emittedNotDocumented.length === 0); + check('5b a section heading never demands reachability', sec.documentedNotReachable.length === 0); + + // 6 — constant resolution: a class that names its code and status. + const consts = runFixture({ + files: { + 'a/c.ts': `export const REFUSAL_CODE = 'VALIDATION_ERROR';\nexport const REFUSAL_STATUS = 400;`, + 'a/e.ts': 'export class E extends Error {\n readonly code = REFUSAL_CODE;\n readonly status = REFUSAL_STATUS;\n}', + }, + handling: '', catalog: '| 400 | `validation` | `VALIDATION_ERROR` |', members, + }); + check('6 identifiers resolve through the constant index', + consts.reconciledPairs === 1 && consts.emittedNotDocumented.length === 0, JSON.stringify(consts.emittedNotDocumented)); + + // 6b — object-member and MAP[OBJ.key] resolution. + const mapped = runFixture({ + files: { + 'a/c.ts': `export const CODES = { timeout: 'TIMEOUT' };\nexport const STATUS = { TIMEOUT: 504 };`, + 'a/e.ts': 'export class E extends Error {\n readonly code = CODES.timeout;\n readonly status = STATUS[CODES.timeout];\n}', + }, + handling: '#### `TIMEOUT`\n**HTTP Status:** 504 \n', catalog: '', members, + }); + check('6b MAP[OBJ.key] resolves', mapped.reconciledPairs === 1 && mapped.unresolved.length === 0, JSON.stringify(mapped.unresolved)); + + // 7 — an UNRESOLVABLE declaration is reported, never silently dropped. + const opaque = runFixture({ + files: { 'a/e.ts': 'export class E extends Error {\n readonly code = lookupCode(x);\n readonly status = lookupStatus(x);\n}' }, + handling: '', catalog: '', members, + }); + check('7 an unresolved declaration is reported', opaque.unresolved.length === 1, JSON.stringify(opaque.unresolved)); + + // 8 — an ambiguous identifier is refused, not guessed. + const amb = buildConstantIndex(new Map([['a.ts', `const S = 400;`], ['b.ts', `const S = 500;`]])); + check('8 an ambiguous constant refuses to resolve', resolveStatus('S', amb) === undefined); + + // 9 — the two `sendError` doors, and the mapper's `{ status, body }` terminal. + const doors = runFixture({ + files: { + 'a/a.ts': `sendError(res, 503, 'SERVICE_UNAVAILABLE', 'down');`, + 'a/b.ts': `sendError(res, { code: 'VALIDATION_ERROR', message: 'x', status: 400 });`, + 'a/c.ts': `const T = () => ({ status: 500, body: { error: 'Internal data error', code: 'TIMEOUT' } });`, + }, + handling: '', catalog: '', members: ['SERVICE_UNAVAILABLE', 'VALIDATION_ERROR', 'TIMEOUT'], + }); + check('9 all three door shapes are derived', + doors.emitted.get('SERVICE_UNAVAILABLE')?.has(503) + && doors.emitted.get('VALIDATION_ERROR')?.has(400) + && doors.emitted.get('TIMEOUT')?.has(500), + [...doors.emitted.keys()].join(',')); + + // 10 — the door map contributes explicit entries only, never the bucket fallback. + const door = deriveDoorMap(`export const HttpStatusErrorCodeMap: Record = {\n 400: 'VALIDATION_ERROR',\n 504: 'TIMEOUT',\n};\n`); + check('10 the door map is parsed', door.length === 2 && door.some((d) => d.code === 'TIMEOUT' && d.status === 504)); + check('10b the bucket fallback contributes nothing', !door.some((d) => d.status === 415 || d.status === 507)); + + // 11 — the ratchet-authority convention holds on the weakening remedy only. + check('11 the baseline-expanding remedy is marked maintainer-only', + RATCHET_EXPANSION_OFFER.test(newUnpinnedMessage('X')) && newUnpinnedMessage('X').includes(RATCHET_AUTHORITY_MARKER)); + check('11b the ratchet-DOWN remedy stays the author\'s own', + !nowPinnedMessage('X').includes(RATCHET_AUTHORITY_MARKER)); + + // 12 — the vocabulary bound: a ledger code is derived but not reconciled. + const ledger = runFixture({ + files: { 'a/e.ts': 'export class E extends Error {\n readonly code = \'SETTINGS_LOCKED\';\n readonly statusCode = 409;\n}' }, + handling: '', catalog: '', members: ['VALIDATION_ERROR'], + }); + check('12 a non-standard code is derived but not reconciled', + ledger.emitted.has('SETTINGS_LOCKED') && ledger.emittedNotDocumented.length === 0 && ledger.reconciledCodes === 0); + + // 13 — comments are NOT producers. Both halves matter: a docblock narrating a + // fixed bug must not mint a finding, and the real declaration two lines + // down must still be read. Both sentences below are the real ones this + // gate first tripped over on `main`. + const prose = runFixture({ + files: { + 'a/n.ts': + '/**\n' + + " * the action surface answered `{ code: 'RECORD_NOT_FOUND', httpStatus: 400 }`\n" + + " * would mint incoherent rows — `{ code: 'INTERNAL_ERROR', status: 409 }`\n" + + ' */\n' + + "sendError(res, 404, 'RECORD_NOT_FOUND', 'gone'); // sendError(res, 400, 'RECORD_NOT_FOUND', 'x')\n", + }, + handling: '#### `RECORD_NOT_FOUND`\n**HTTP Status:** 404 \n', catalog: '', + members: ['RECORD_NOT_FOUND', 'INTERNAL_ERROR'], + }); + check('13 narrated envelopes in comments are not producers', + prose.emittedNotDocumented.length === 0 && !prose.emitted.has('INTERNAL_ERROR'), + JSON.stringify(prose.emittedNotDocumented)); + check('13b the real declaration beside them is still read', + prose.emitted.get('RECORD_NOT_FOUND')?.has(404) && prose.emitted.get('RECORD_NOT_FOUND')?.size === 1); + + // 14 — a computed-key status table beside the code table it keys on, the + // `external-errors.ts` shape. + const computed = runFixture({ + files: { + 'a/c.ts': + "export const CODES = {\n writeForbidden: 'PERMISSION_DENIED',\n} as const;\n" + + 'export const STATUS = {\n [CODES.writeForbidden]: 403,\n} as const satisfies Record;\n', + 'a/e.ts': 'export class E extends Error {\n readonly code = CODES.writeForbidden;\n readonly status = STATUS[CODES.writeForbidden];\n}', + }, + handling: '#### `PERMISSION_DENIED`\n**HTTP Status:** 403 \n', catalog: '', members: ['PERMISSION_DENIED'], + }); + check('14 a computed-key status table resolves', computed.reconciledPairs === 1 && computed.unresolved.length === 0, + JSON.stringify(computed.unresolved)); + + const CASES = 24; + if (failures.length) { + for (const f of failures) console.error(` x self-test: ${f}`); + console.error(`\n✗ check-error-status-conformance --self-test: ${failures.length}/${CASES} case(s) failed.\n`); + process.exit(1); + } + console.log( + `✓ check-error-status-conformance --self-test: ${CASES} cases pass — the real pre-#8963 doc text goes RED naming ` + + 'MISSING_REQUIRED_FIELD @422, the landed text goes green WITH the accepted statuses actually seen ' + + '(positive control), both directions fire independently, section headings absolve but never demand, ' + + 'narrated envelopes in comments are not producers, unresolvable declarations are reported, and the ' + + 'baseline-expanding remedy stays maintainer-only.', + ); + process.exit(0); +} + +if (process.argv.includes('--self-test')) selfTest(); + +// ─────────────────────────────────────────────────────────────────────────── +// The real check +// ─────────────────────────────────────────────────────────────────────────── + +function walk(dir, out) { + for (const e of readdirSync(dir)) { + if (SKIP_DIRS.has(e)) continue; + const p = join(dir, e); + const s = statSync(p); + if (s.isDirectory()) walk(p, out); + else if (/\.tsx?$/.test(e) && !/\.(test|spec|d)\.tsx?$/.test(e)) out.push(p); + } +} + +const update = process.argv.includes('--update'); + +const files = []; +walk(SCAN_ROOT, files); +const sources = new Map(); +for (const f of files.sort()) sources.set(relative('.', f).replace(/\\/g, '/'), readFileSync(f, 'utf8')); + +const errorsZod = readFileSync(ERRORS_ZOD, 'utf8'); +const members = parseStandardErrorCodes(errorsZod); +const index = buildConstantIndex(sources); +const derived = deriveRuntimeStatuses(sources, index); +for (const { code, status } of deriveDoorMap(errorsZod)) { + if (!derived.emitted.has(code)) derived.emitted.set(code, new Map()); + const perStatus = derived.emitted.get(code); + if (!perStatus.has(status)) perStatus.set(status, []); + if (!perStatus.get(status).includes(`${ERRORS_ZOD}: HttpStatusErrorCodeMap`)) { + perStatus.get(status).push(`${ERRORS_ZOD}: HttpStatusErrorCodeMap`); + } +} + +const doc = parseDocumentedStatuses({ + handling: readFileSync(DOC_HANDLING, 'utf8'), + catalog: readFileSync(DOC_CATALOG, 'utf8'), +}); +const result = reconcile({ members, emitted: derived.emitted, ...doc }); + +const baseline = existsSync(BASELINE_PATH) + ? JSON.parse(readFileSync(BASELINE_PATH, 'utf8')) + : { unpinned: [] }; +const baselined = new Set(baseline.unpinned ?? []); +const newlyUnpinned = result.unpinned.filter((c) => !baselined.has(c)); +const nowPinned = [...baselined].filter((c) => !result.unpinned.includes(c) && members.includes(c)).sort(); + +if (update) { + writeFileSync( + BASELINE_PATH, + `${JSON.stringify({ + note: + 'StandardErrorCode members documented with an HTTP status that NO producer this gate can read ' + + 'declares a status for — nothing pins the doc claim on either side. Shrink-only: a new entry is a ' + + 'gate failure, and a row that becomes pinned must be removed. Regenerate with ' + + '`node scripts/check-error-status-conformance.mjs --update`.', + unpinned: result.unpinned, + }, null, 2)}\n`, + ); + console.log(`Baseline rewritten: ${result.unpinned.length} unpinned code(s).`); + process.exit(0); +} + +const ledgerCodes = [...derived.emitted.keys()].filter((c) => !members.includes(c)).length; + +// `--report` prints the whole derivation rather than only the disagreements. +// A finding is only as trustworthy as the evidence behind it, and "which +// producers did you actually see for this code?" is the first question anyone +// reading a failure asks. +if (process.argv.includes('--report')) { + for (const code of members) { + const runtime = derived.emitted.get(code); + if (!runtime) continue; + console.log(`${code}`); + for (const [status, where] of [...runtime].sort((a, b) => a[0] - b[0])) { + console.log(` ${status} ${where.join('\n ')}`); + } + } +} + +console.log('check:error-status-conformance — documented HTTP status ⇄ runtime-emitted status'); +console.log( + ` scope: ${members.length} StandardErrorCode members reconciled; ${sources.size} source files scanned; ` + + `${derived.sites} producer site(s) derived; ${ledgerCodes} registered ledger code(s) derived but NOT reconciled ` + + '(neither doc page publishes their status).', +); +console.log( + ` reconciled: ${result.reconciledCodes} code(s) with a derived producer, ` + + `${result.reconciledPairs} (code, status) pair(s) matched against the docs.`, +); +console.log(` unpinned: ${result.unpinned.length} documented code(s) with no derivable producer (baselined: ${baselined.size}).`); +if (derived.unresolved.length) { + console.log(` unresolved: ${derived.unresolved.length} declaration(s) the deriver could not read —`); + for (const u of derived.unresolved) console.log(` ${u}`); +} + +if (result.reconciledPairs === 0) { + console.error( + '\n✗ the deriver matched ZERO (code, status) pairs. A green run with nothing reconciled is a blind run, ' + + 'not a clean one — the source anchors this gate reads have moved.\n', + ); + process.exit(1); +} + +const failures = []; +for (const f of result.emittedNotDocumented) failures.push(emittedNotDocumentedMessage(f)); +for (const f of result.documentedNotReachable) failures.push(documentedNotReachableMessage(f)); +for (const c of newlyUnpinned) failures.push(newUnpinnedMessage(c)); +for (const c of nowPinned) failures.push(nowPinnedMessage(c)); + +if (failures.length) { + console.error(''); + for (const f of failures) console.error(` ✗ ${f}`); + console.error(`\n✗ check:error-status-conformance — ${failures.length} finding(s).\n`); + process.exit(1); +} + +console.log('\n✓ every derivable runtime status is documented, and every documented status is reachable.'); diff --git a/scripts/error-status-unpinned-baseline.json b/scripts/error-status-unpinned-baseline.json new file mode 100644 index 0000000000..e8f325266a --- /dev/null +++ b/scripts/error-status-unpinned-baseline.json @@ -0,0 +1,42 @@ +{ + "note": "StandardErrorCode members documented with an HTTP status that NO producer this gate can read declares a status for — nothing pins the doc claim on either side. Shrink-only: a new entry is a gate failure, and a row that becomes pinned must be removed. Regenerate with `node scripts/check-error-status-conformance.mjs --update`.", + "unpinned": [ + "BATCH_COMPLETE_FAILURE", + "BATCH_PARTIAL_FAILURE", + "CONCURRENT_LIMIT_EXCEEDED", + "CONCURRENT_MODIFICATION", + "DELETE_RESTRICTED", + "DUPLICATE_RECORD", + "DUPLICATE_VALUE", + "EMAIL_NOT_VERIFIED", + "ENDPOINT_NOT_FOUND", + "EXPIRED_TOKEN", + "FIELD_NOT_ACCESSIBLE", + "FIELD_NOT_FOUND", + "INSUFFICIENT_PRIVILEGES", + "INTEGRATION_ERROR", + "INVALID_CREDENTIALS", + "INVALID_FIELD", + "INVALID_FILTER", + "INVALID_FORMAT", + "INVALID_QUERY", + "INVALID_REFERENCE", + "INVALID_SORT", + "INVALID_TOKEN", + "IP_RESTRICTED", + "LICENSE_REQUIRED", + "LOCK_CONFLICT", + "MAX_RECORDS_EXCEEDED", + "MFA_REQUIRED", + "OBJECT_NOT_FOUND", + "QUOTA_EXCEEDED", + "RECORD_NOT_ACCESSIBLE", + "SESSION_EXPIRED", + "TIME_RESTRICTED", + "TRANSACTION_FAILED", + "VALUE_OUT_OF_RANGE", + "VALUE_TOO_LONG", + "VALUE_TOO_SHORT", + "WEBHOOK_DELIVERY_FAILED" + ] +}