|
| 1 | +#!/usr/bin/env node |
| 2 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 3 | + |
| 4 | +/** |
| 5 | + * Error-code casing guard (ADR-0112, #4003). |
| 6 | + * |
| 7 | + * ## What it guards |
| 8 | + * |
| 9 | + * `error.code` is a closed set: `StandardErrorCode` ∪ `ERROR_CODE_LEDGER`, all |
| 10 | + * SCREAMING_SNAKE, and `ApiErrorSchema.code` validates against it. The ledger's |
| 11 | + * own test enforces the casing of every code someone **registers**. |
| 12 | + * |
| 13 | + * That leaves the hole this guard closes. An unregistered lowercase string in a |
| 14 | + * code position is invisible to the ledger — there is nothing to check the |
| 15 | + * casing of — and invisible to the schema on any route that doesn't parse its |
| 16 | + * own response. That is precisely how 208 lowercase literals accumulated across |
| 17 | + * 10 packages before batch 2 swept them (#4003). Without a guard, number 209 |
| 18 | + * lands the same way: quietly, in a package nobody is looking at. |
| 19 | + * |
| 20 | + * ## Why textual, not AST |
| 21 | + * |
| 22 | + * The failure mode here is a string literal in one of a handful of syntactic |
| 23 | + * positions, and batch 2 learned the hard way that the positions are more |
| 24 | + * varied than they look: emission (`code: 'x'`), property assignment |
| 25 | + * (`err.code = 'x'`), comparison (`code === 'x'`), computed ternaries, literal |
| 26 | + * union *types*, and test assertions. An AST pass buys nothing over a regex for |
| 27 | + * "is this literal lowercase_snake" while costing a parse of every file — and |
| 28 | + * the union-type case is a type annotation, which the AST shapes for the value |
| 29 | + * cases would miss anyway. |
| 30 | + * |
| 31 | + * ## The vocabularies this does NOT govern |
| 32 | + * |
| 33 | + * ADR-0112 D6/D6b/D6c draw the line: the catalog governs the code a failing |
| 34 | + * REQUEST answers with. Three neighbours legitimately stay lowercase, and each |
| 35 | + * is skipped by a rule below rather than by a blanket ignore, so a new file in |
| 36 | + * one of those families still has to say which family it joins: |
| 37 | + * |
| 38 | + * - **D6 — field/param-addressed** (`{ field, code }`, `{ param, code }`): |
| 39 | + * validator vocabularies, #3977's scope. |
| 40 | + * - **D6b — persisted**: `sys_metadata_audit.code` is audit history; old rows |
| 41 | + * keep their spelling forever and the column also holds `ok`. |
| 42 | + * - **D6c — diagnostics**: probe/diff records that ship as payload of a 200. |
| 43 | + * |
| 44 | + * Zod's own issue codes (`invalid_type`, `too_small`, `custom`, …) are a fourth |
| 45 | + * — they are Zod's API, not ours. |
| 46 | + * |
| 47 | + * Run `--self-test` to check the matcher against known-good and known-bad |
| 48 | + * samples before trusting a green run. |
| 49 | + */ |
| 50 | + |
| 51 | +import { readFileSync, readdirSync, statSync } from 'node:fs'; |
| 52 | +import { join, relative, sep } from 'node:path'; |
| 53 | + |
| 54 | +const ROOT = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); |
| 55 | +const SCAN_ROOTS = ['packages']; |
| 56 | + |
| 57 | +/** |
| 58 | + * Files exempt as a whole, each because it OWNS one of the non-catalog |
| 59 | + * vocabularies above. A path earns a line here only with the reason; a blanket |
| 60 | + * directory ignore is what lets a real emitter hide. |
| 61 | + */ |
| 62 | +const EXEMPT_FILES = new Map([ |
| 63 | + // D6 — field-addressed validator vocabularies (#3977) |
| 64 | + ['packages/objectql/src/validation/record-validator.ts', 'D6 field-level validator codes'], |
| 65 | + ['packages/objectql/src/validation/rule-validator.ts', 'D6 field-level validator codes'], |
| 66 | + ['packages/rest/src/import-coerce.ts', 'D6 field-level import coercion codes'], |
| 67 | + ['packages/rest/src/import-runner.ts', 'D6 field-level import row codes'], |
| 68 | + ['packages/plugins/plugin-sharing/src/rule-criteria.ts', 'D6 field-level; top-level code is VALIDATION_FAILED'], |
| 69 | + ['packages/spec/src/ui/action-params.zod.ts', 'D6 param-addressed action-param issues'], |
| 70 | + // D6b — persisted audit column |
| 71 | + ['packages/metadata-core/src/objects/sys-metadata-audit.object.ts', 'D6b persisted audit vocabulary'], |
| 72 | + ['packages/spec/src/api/errors.test.ts', 'D6 FieldError tests spell field-level codes'], |
| 73 | + ['packages/objectql/src/validation/skip-provenance.test.ts', 'D6 field-level assertions'], |
| 74 | + ['packages/rest/src/import-runner-selfref.test.ts', 'D6 field-level import codes'], |
| 75 | + // D6c — diagnostics payloads of a 200 |
| 76 | + ['packages/metadata-protocol/src/build-probes.ts', 'D6c runtime build-probe diagnostics'], |
| 77 | + ['packages/metadata-protocol/src/metadata-diagnostics.ts', 'D6c spec-validation diagnostics'], |
| 78 | + ['packages/objectql/src/build-probes.test.ts', 'D6c build-probe diagnostics tests'], |
| 79 | + ['packages/objectql/src/metadata-diagnostics.test.ts', 'D6c diagnostics tests'], |
| 80 | + ['packages/objectql/scripts/dry-run-hash-compat.ts', 'D6c findings report of a dev script'], |
| 81 | + ['packages/objectql/src/dry-run-hash-compat.test.ts', 'D6c findings report tests'], |
| 82 | + // Zod's own vocabulary, and this file's own samples |
| 83 | + ['packages/spec/src/shared/error-map.zod.ts', "Zod issue codes, not ours"], |
| 84 | + ['packages/spec/src/api/odata.zod.ts', "OData's own error vocabulary, a foreign protocol"], |
| 85 | + ['packages/spec/src/api/odata.test.ts', "OData's own error vocabulary, a foreign protocol"], |
| 86 | + ['packages/spec/src/system/license.test.ts', 'plan/feature codes are domain data; the nearby toThrow() is the tripwire'], |
| 87 | + // The ledger and its test spell codes for a living |
| 88 | + ['packages/spec/src/api/error-code-ledger.zod.ts', 'the ledger itself'], |
| 89 | + ['packages/spec/src/api/error-code-ledger.test.ts', 'the ledger admission test'], |
| 90 | +]); |
| 91 | + |
| 92 | +/** Literals that are never an error code, however they look. */ |
| 93 | +const NOT_CODES = new Set([ |
| 94 | + // Zod issue codes and API constants |
| 95 | + 'custom', 'invalid_type', 'invalid_value', 'invalid_format', 'invalid_union', |
| 96 | + 'too_small', 'too_big', 'unrecognized_keys', 'invalid_key', 'invalid_element', |
| 97 | + 'invalid_arguments', 'invalid_return_type', 'not_multiple_of', |
| 98 | + // service/AI/queue status vocabularies that share the word "code" |
| 99 | + 'unavailable', 'default', 'string', 'ok', |
| 100 | +]); |
| 101 | + |
| 102 | +const CODE_POSITION_PATTERNS = [ |
| 103 | + // emission and object literals: code: 'x' |
| 104 | + { name: 'emission', re: /\bcode\s*:\s*'([a-z][a-z0-9_]*)'/g }, |
| 105 | + // property assignment: err.code = 'x' |
| 106 | + { name: 'assignment', re: /\.code\s*=\s*'([a-z][a-z0-9_]*)'/g }, |
| 107 | + // comparison — the silent one: code === 'x' |
| 108 | + { name: 'comparison', re: /\bcode\s*(?:===|!==)\s*'([a-z][a-z0-9_]*)'/g }, |
| 109 | + // literal-union type: code?: 'x' | 'y' (the one that breaks a consumer's dts) |
| 110 | + { name: 'union-type', re: /\bcode\??\s*:\s*'([a-z][a-z0-9_]*)'\s*\|/g }, |
| 111 | +]; |
| 112 | + |
| 113 | +function walk(dir, out = []) { |
| 114 | + for (const entry of readdirSync(dir)) { |
| 115 | + if (entry === 'node_modules' || entry === 'dist' || entry === '.turbo' || entry === 'coverage') continue; |
| 116 | + const full = join(dir, entry); |
| 117 | + const st = statSync(full); |
| 118 | + if (st.isDirectory()) walk(full, out); |
| 119 | + else if (/\.(ts|tsx)$/.test(entry) && !/\.d\.ts$/.test(entry)) out.push(full); |
| 120 | + } |
| 121 | + return out; |
| 122 | +} |
| 123 | + |
| 124 | +/** Strip line and block comments so a docblock naming an old code is not a hit. */ |
| 125 | +function stripComments(src) { |
| 126 | + return src |
| 127 | + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) |
| 128 | + .replace(/(^|[^:])\/\/[^\n]*/g, (m, p) => p + m.slice(p.length).replace(/./g, ' ')); |
| 129 | +} |
| 130 | + |
| 131 | +export function findViolations(src, file) { |
| 132 | + const text = stripComments(src); |
| 133 | + const hits = []; |
| 134 | + for (const { name, re } of CODE_POSITION_PATTERNS) { |
| 135 | + re.lastIndex = 0; |
| 136 | + let m; |
| 137 | + while ((m = re.exec(text)) !== null) { |
| 138 | + const literal = m[1]; |
| 139 | + if (NOT_CODES.has(literal)) continue; |
| 140 | + const lineStart = text.lastIndexOf('\n', m.index) + 1; |
| 141 | + const lineEnd = text.indexOf('\n', m.index); |
| 142 | + const line = text.slice(lineStart, lineEnd === -1 ? undefined : lineEnd); |
| 143 | + const lineNo = text.slice(0, m.index).split('\n').length; |
| 144 | + // `typeof x.code === 'number'` and friends: a type guard, not a code |
| 145 | + if (/typeof\s+[\w.?]*code\s*(?:===|!==)/.test(line)) continue; |
| 146 | + // D6: the literal sits in a field/param/path-addressed record. Check a |
| 147 | + // small window, not just the hit line — `{ code, field, message }` is |
| 148 | + // routinely spread over three lines, and the discriminating key is as |
| 149 | + // likely to be below the code as beside it. |
| 150 | + const allLines = text.split('\n'); |
| 151 | + const window = allLines.slice(Math.max(0, lineNo - 3), lineNo + 2).join('\n'); |
| 152 | + if (/\b(field|param|path|target)\s*[:,=]/.test(window)) continue; |
| 153 | + // `code` is also a plain domain field name — a license plan code, an |
| 154 | + // industry code, a locale. Require an error-shaped neighbour before |
| 155 | + // calling a lowercase literal a violation, or the guard starts policing |
| 156 | + // seed data and gets switched off. |
| 157 | + if (!/\b(error|message|throw|reject|httpStatus|statusCode|status|issues|failed|denied|refus)/i.test(window)) continue; |
| 158 | + // not an error code at all: locale/language/currency/country code fields |
| 159 | + if (/\b(locale|language|currency|country|label)\b/.test(line)) continue; |
| 160 | + // site-level opt-out for a file that legitimately holds BOTH vocabularies |
| 161 | + // (e.g. protocol.ts throws a catalog code and writes an audit row beside |
| 162 | + // it). Must name a reason, on the hit line or the line above it. |
| 163 | + const rawLines = src.split('\n'); |
| 164 | + const own = rawLines[lineNo - 1] ?? ''; |
| 165 | + const prev = rawLines[lineNo - 2] ?? ''; |
| 166 | + if (/adr0112-ok:\s*\S/.test(own) || /adr0112-ok:\s*\S/.test(prev)) continue; |
| 167 | + hits.push({ file, line: lineNo, literal, form: name }); |
| 168 | + } |
| 169 | + } |
| 170 | + return hits; |
| 171 | +} |
| 172 | + |
| 173 | +function selfTest() { |
| 174 | + const cases = [ |
| 175 | + // [source, expectedHitCount, label] |
| 176 | + [`return c.json({ error: { code: 'not_found', message: 'x' } }, 404);`, 1, 'emission'], |
| 177 | + [`const err = new Error('locked'); (err as any).code = 'item_locked';`, 1, 'assignment'], |
| 178 | + [`if (err?.code === 'destructive_change') throw err;`, 1, 'comparison'], |
| 179 | + [`interface R { error?: string; code?: 'forbidden' | 'invalid_signal'; }`, 1, 'union type'], |
| 180 | + [`error: { code: 'RESOURCE_NOT_FOUND', message: 'x' }`, 0, 'SCREAMING passes'], |
| 181 | + [`ctx.addIssue({ code: 'custom', message: 'x' });`, 0, "Zod's own code"], |
| 182 | + [`issues.push({ field: 'email', code: 'invalid_email', message: 'x' });`, 0, 'D6 field-addressed'], |
| 183 | + [`// legacy servers sent code: 'not_found' here`, 0, 'comment is not code'], |
| 184 | + [`if (info.services.i18n.status === 'unavailable') {}`, 0, 'status vocabulary'], |
| 185 | + [`locales.push({ code: 'en', label: 'en', isDefault: true });`, 0, 'locale code is not an error code'], |
| 186 | + [`expect(f.field === 'organization_id' && f.code === 'required').toBe(true);`, 0, 'D6 via field ==='], |
| 187 | + [`code: 'item_locked', // adr0112-ok: D6b persisted audit column`, 0, 'inline opt-out, same line'], |
| 188 | + [`// adr0112-ok: D6b persisted audit column\n code: 'item_locked',`, 0, 'inline opt-out, line above'], |
| 189 | + [`throw Object.assign(new Error('x'), { code: 'item_locked' }); // adr0112-ok:`, 1, 'opt-out without a reason does not count'], |
| 190 | + [`const plan = PlanSchema.parse({ code: 'pro_v1', features: [] });`, 0, 'license plan code is domain data'], |
| 191 | + [`records: [{ code: 'tech', name: 'Technology' }],`, 0, 'seed industry code is domain data'], |
| 192 | + [`{ code: 'required', message: 'x', target: 'email' }`, 0, "D6 via OData's target"], |
| 193 | + ]; |
| 194 | + let failed = 0; |
| 195 | + for (const [src, want, label] of cases) { |
| 196 | + const got = findViolations(src, 'self-test.ts').length; |
| 197 | + if (got !== want) { |
| 198 | + console.error(` ✗ self-test "${label}": expected ${want} hit(s), got ${got}`); |
| 199 | + failed++; |
| 200 | + } |
| 201 | + } |
| 202 | + if (failed) { |
| 203 | + console.error(`\n✗ check-error-code-casing self-test failed (${failed} case(s)).`); |
| 204 | + process.exit(1); |
| 205 | + } |
| 206 | + console.log(`✓ check-error-code-casing self-test: ${cases.length} cases pass.`); |
| 207 | +} |
| 208 | + |
| 209 | +function main() { |
| 210 | + if (process.argv.includes('--self-test')) return selfTest(); |
| 211 | + |
| 212 | + const files = SCAN_ROOTS.flatMap((r) => walk(join(ROOT, r))); |
| 213 | + const violations = []; |
| 214 | + for (const full of files) { |
| 215 | + const rel = relative(ROOT, full).split(sep).join('/'); |
| 216 | + if (EXEMPT_FILES.has(rel)) continue; |
| 217 | + violations.push(...findViolations(readFileSync(full, 'utf8'), rel)); |
| 218 | + } |
| 219 | + |
| 220 | + if (violations.length === 0) { |
| 221 | + console.log(`✓ no lowercase error codes in ${files.length} scanned file(s) (ADR-0112).`); |
| 222 | + return; |
| 223 | + } |
| 224 | + |
| 225 | + console.error(`\n✗ lowercase error-code literal(s) in a code position (ADR-0112 D1):\n`); |
| 226 | + for (const v of violations) { |
| 227 | + console.error(` ${v.file}:${v.line} '${v.literal}' (${v.form})`); |
| 228 | + } |
| 229 | + console.error(` |
| 230 | +error.code is a closed set of SCREAMING_SNAKE values — StandardErrorCode |
| 231 | +(packages/spec/src/api/errors.zod.ts) for generic conditions, ERROR_CODE_LEDGER |
| 232 | +(packages/spec/src/api/error-code-ledger.zod.ts) for service-specific ones. |
| 233 | +
|
| 234 | + - a generic condition (not found / permission / validation / rate limit) should |
| 235 | + use the standard catalog rather than register a synonym; |
| 236 | + - anything else gets a SCREAMING code registered under its owning package. |
| 237 | +
|
| 238 | +If this literal is NOT an error.code — a field/param-addressed validator code |
| 239 | +(D6), a persisted column (D6b), or a diagnostics record shipped inside a 200 |
| 240 | +(D6c) — add the file to EXEMPT_FILES in this script WITH its reason. |
| 241 | +`); |
| 242 | + process.exit(1); |
| 243 | +} |
| 244 | + |
| 245 | +main(); |
0 commit comments