From 374a55d05309fb3a3e709f247b995b3e023b9c12 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:04:52 +0000 Subject: [PATCH] feat(lint): refuse a MISSPELLED `format` in a `json_schema` validation rule at publish time (#5178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5029 registered `ajv-formats` so a `json_schema` rule's `format` is really enforced. Under `strict: false` an UNRECOGNISED format name stays a non-event: ajv logs one line at compile time and DROPS the keyword, so `format: 'emial'` leaves the rule declared, listed, running on every write, enforcing `type` and `required` — and enforcing nothing for the keyword its author wrote. The record is ACCEPTED, which is the silent direction. New gating rule `validateRuleSchemaFormats` (`validation-rule-json-schema-unknown-format`), one entry in AUTHORING_RULES so `os validate` / `os build` / `os lint` all run it. Each finding names the rule, the object, the RFC 6901 JSON Pointer to the keyword and the nearest registered name. The vocabulary is enumerated off a live instance of the same ajv the publish gate builds to mirror the runtime (`registeredFormatNames()`), never a hardcoded list that would start refusing formats the write path enforces the day the plugin adds one. The walk visits only real subschema positions (`properties`, both `items` forms, `anyOf`/`allOf`/`oneOf`/`prefixItems`, `$defs`/`definitions`, `additionalProperties`, `patternProperties`, `if`/`then`/`else`, `not`, `contains`, `propertyNames`, `dependentSchemas`, draft-07 `dependencies`) at any depth, plus a `conditional`'s `then`/`otherwise` rules. A `format` inside `default`/`const`/`enum`/`examples` is data ajv never reads as schema, so it is deliberately not reported; a non-string `format` is left to `validation-rule-json-schema-uncompilable`. The runtime is untouched and the #4762/#5029 compile parity is untouched: this judgement is laid BESIDE the compile, never folded into it. `validateRuleCompilability` still compiles in the runtime's exact environment and still publishes a typo'd format, and its #5029 pin passes verbatim. Both rules share one traversal (`walkObjectValidationRules`) and one ajv environment, so they cannot disagree about which rules exist or what "registered" means. `ajv`/`ajv-formats` stay lazy — and this rule is lazier: a `json_schema` rule naming no `format` loads neither. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE --- .../json-schema-rule-unknown-format-gate.md | 98 +++++ packages/lint/src/authoring-rules.ts | 23 + packages/lint/src/index.ts | 18 + packages/lint/src/lazy-deps.test.ts | 48 +++ .../src/validate-rule-compilability.test.ts | 16 +- .../lint/src/validate-rule-compilability.ts | 210 ++++++--- .../src/validate-rule-schema-formats.test.ts | 399 ++++++++++++++++++ .../lint/src/validate-rule-schema-formats.ts | 333 +++++++++++++++ 8 files changed, 1082 insertions(+), 63 deletions(-) create mode 100644 .changeset/json-schema-rule-unknown-format-gate.md create mode 100644 packages/lint/src/validate-rule-schema-formats.test.ts create mode 100644 packages/lint/src/validate-rule-schema-formats.ts diff --git a/.changeset/json-schema-rule-unknown-format-gate.md b/.changeset/json-schema-rule-unknown-format-gate.md new file mode 100644 index 0000000000..8d71273f02 --- /dev/null +++ b/.changeset/json-schema-rule-unknown-format-gate.md @@ -0,0 +1,98 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): a MISSPELLED `format` in a `json_schema` validation rule is refused at publish time (#5178) + +#5029 registered `ajv-formats` so a `json_schema` validation rule's `format` +keyword is really enforced. It did **not** close the other half, and said so: +under `strict: false` — which is load-bearing, because author-written schemas +legitimately carry vendor keywords — an **unrecognised** format name is a +non-event. ajv logs one line at compile time and **drops the keyword**: + +``` +$ node -e "const Ajv=require('ajv'); const addFormats=require('ajv-formats'); + const ajv=new Ajv({allErrors:true,strict:false}); addFormats(ajv); + const v=ajv.compile({type:'object',properties:{e:{type:'string',format:'emial'}}}); + console.log('validates {e: zzz} =', v({e:'zzz'}));" +unknown format "emial" ignored in schema at path "#/properties/e" +validates {e: zzz} = true +``` + +So an author who types `emial`, `e-mail`, `datetime` for `date-time`, +`urireference` for `uri-reference`, `ipv_4` for `ipv4` or `Email` for `email` +gets a rule that is declared, appears in the metadata, appears in every "what +protects this object" listing, runs on every write, enforces `type` and +`required` — and enforces nothing for the keyword they actually wrote. The +record is **accepted**, which is the silent direction: nothing in the metadata, +the UI or a test run says the constraint is inert. A typo is also the single +most likely mistake in a hand-written or AI-generated JSON Schema. + +**New gate — `validateRuleSchemaFormats`**, a `gating` entry in +`AUTHORING_RULES`, so it runs on all three authoring commands (`os validate`, +`os build`, `os lint`) with no per-command wiring. One rule id: + +| id | fires when | +|:---|:---| +| `validation-rule-json-schema-unknown-format` | a `json_schema` rule's schema names a `format` the runtime's ajv has not registered | + +Each finding names the rule, the object, the **RFC 6901 JSON Pointer** to the +offending keyword, and the **nearest registered name**: + +``` +objects.account.validations.support_shape.schema#/properties/email/format + `json_schema` validation 'support_shape' on object 'account' names + `format: 'emial'` at `#/properties/email/format`, which is not a registered + format. … the schema compiles, the rule ships and runs on every write, its + `type`/`required` keywords are enforced, and this constraint is enforced on + no record, ever. The record is ACCEPTED, so nothing downstream reports the + gap either. + hint: Did you mean `format: 'email'`? The registered names are: binary, byte, + date, date-time, … — the default `ajv-formats` set, the one + `rule-validator.ts` registers (#5029). +``` + +**The vocabulary is enumerated, never written down.** The registered set is read +off a live instance of the very ajv the publish gate builds to mirror the +runtime (`registeredFormatNames()`), not from a hardcoded list. A list would be +a third opinion that nobody updates: the day `ajv-formats` adds a name, it +starts refusing a format the write path enforces — a gate that turns working +metadata red gets switched off, and then protects nothing. Enumerating means the +gate follows the plugin across an upgrade with no edit at all. + +**The walk is JSON-Schema-aware, because `format` is not a magic word.** Every +subschema position is visited — `properties`, `items` (both the 2020-12 schema +form and draft-07's tuple array), `anyOf`/`allOf`/`oneOf`/`prefixItems`, +`$defs`/`definitions`, `additionalProperties`, `patternProperties`, +`if`/`then`/`else`, `not`, `contains`, `propertyNames`, `dependentSchemas`, +draft-07 `dependencies` — at any depth, plus the rules nested in a +`conditional`'s `then`/`otherwise`. Positions that hold arbitrary **data** are +deliberately never read: a `format` inside `default`, `const`, `enum` or +`examples` enforces nothing and was never meant to, so reporting it would invent +a defect out of a legal document. A non-string `format` (`format: 42`) is left +to `validation-rule-json-schema-uncompilable`, which already refuses it in +ajv's own words. + +**The runtime is untouched, and so is the #4762/#5029 compile parity.** Option 2 +on #5178 (make an unknown format a runtime compile error) was rejected: it fires +at runtime and fail-**open**, since `checkJsonSchema` catches, logs and skips — +trading one silent gap for another. And this is a separate judgement laid +*beside* the existing compile, never folded into it: `validateRuleCompilability` +still compiles each schema in the runtime's exact environment and still +**publishes** a typo'd format, because a typo'd format compiles there too. Its +`#5029` pin ("a MISSPELLED format name is published, not refused") passes +verbatim. Two questions, two rules — "does ajv accept this schema?" and "will +this `format` keyword do anything?" — sharing one traversal and one ajv +environment so they can never disagree about which rules exist or what +"registered" means. + +`ajv` / `ajv-formats` stay **lazy**, and this rule is lazier than its neighbour: +the registered vocabulary is only fetched once a schema actually names a +`format`, so a `json_schema` rule that names none loads neither package. Pinned +by the package's `lazy-deps.test.ts` in all three of its layers. + +**Upgrading:** if `os validate` / `os build` / `os lint` newly rejects a +`json_schema` validation rule, the format name in it was never being enforced — +fix the spelling to the name the finding suggests, or express the constraint +with `pattern`, which is enforced. No metadata that was working changes +behaviour. diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index 9c69a30f9d..bf986fb49e 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -123,6 +123,7 @@ import { validateOrgAxisRedLines } from './validate-org-axis-red-lines.js'; import { validateSharingRuleEnforceability } from './validate-sharing-rule-enforceability.js'; import { validateRlsPredicateEnforceability } from './validate-rls-predicate-enforceability.js'; import { validateRuleCompilability } from './validate-rule-compilability.js'; +import { validateRuleSchemaFormats } from './validate-rule-schema-formats.js'; import { validateActionLocations } from './validate-action-locations.js'; import { lintFlowPatterns } from './lint-flow-patterns.js'; import { lintLivenessProperties } from './lint-liveness-properties.js'; @@ -924,6 +925,28 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_OBJECT_WRITES_P2, run: (stack) => validateRuleCompilability(stack), }, + // #5178 — the residual half of #5029, which registering `ajv-formats` does + // NOT close: under `strict: false` a MISSPELLED format name (`emial`) is + // logged once and DROPPED, so the rule compiles, ships, runs on every write + // and enforces nothing for the keyword its author wrote — and the record is + // accepted, which is the silent direction. Deliberately its own entry rather + // than a third finding inside the rule above: that one's whole contract is + // compiling in the runtime's exact environment, and a typo'd format compiles + // there. This judges the format NAME against the registered set (enumerated + // from the same ajv instance, never a hardcoded list) and compiles nothing, + // so the #4762/#5029 compile parity is untouched — a judgement beside the + // compile, not a divergent compile. Gating for the `lint-flow-patterns.ts` + // bar: no reading of the metadata behaves as written. + { + name: 'validateRuleSchemaFormats', + tier: 'gating', + input: 'parsed', + commands: ALL, + source: 'packages/lint/src/validate-rule-schema-formats.ts', + surfaces: CLI_ONLY, + surfaceReason: RUNTIME_OBJECT_WRITES_P2, + run: (stack) => validateRuleSchemaFormats(stack), + }, ]; // ─── Runner ───────────────────────────────────────────────────────── diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index d01a0be739..bba3234e39 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -296,8 +296,26 @@ export { export type { RuleCompilabilityFinding, RuleCompilabilitySeverity, + WalkedValidationRule, } from './validate-rule-compilability.js'; +// #5178 — the residual half #5029's `ajv-formats` registration does not close. +// Under `strict: false` an UNRECOGNISED format name is logged once and the +// keyword is DROPPED, so `format: 'emial'` leaves the rule declared, running on +// every write, and enforcing nothing — with the record ACCEPTED. Judged here +// against the names that ajv instance really has registered, beside the compile +// above rather than inside it, so the runtime/gate compile parity is untouched. +export { + validateRuleSchemaFormats, + nearestRegisteredFormat, + MAX_SCHEMA_WALK_DEPTH, + VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT, +} from './validate-rule-schema-formats.js'; +export type { + RuleSchemaFormatFinding, + RuleSchemaFormatSeverity, +} from './validate-rule-schema-formats.js'; + export { validateNavAccess, NAV_OBJECT_UNGRANTED } from './validate-nav-access.js'; export type { NavAccessFinding, NavAccessSeverity } from './validate-nav-access.js'; diff --git a/packages/lint/src/lazy-deps.test.ts b/packages/lint/src/lazy-deps.test.ts index c10c9699a4..6f16b9cac0 100644 --- a/packages/lint/src/lazy-deps.test.ts +++ b/packages/lint/src/lazy-deps.test.ts @@ -20,6 +20,12 @@ // i.e. it must be listed here even though its own weight would not justify // it. // +// #5178 added a second consumer of both — `validate-rule-schema-formats.ts`, +// which asks that same ajv environment which `format` names it registered. It +// is LAZIER than the compile gate on purpose: the registered set is only needed +// once a schema actually names a `format`, so a `json_schema` rule that names +// none pays nothing, which is the case pinned below. +// // "A react page" was the whole story when this file was written; it is not any // more, and the cases below say which trigger they are pinning. Keep them // named that way — a test called "until a react page is validated" that also @@ -117,6 +123,12 @@ describe('lazy dependency loading (kernel boot-path contract)', () => { mod.validateRuleCompilability(ruleStack({ type: 'format', name: 'f', field: 'payload', regex: '([', message: 'm' })); if (loaded('ajv')) fail('the rule-compilability gate must not load ajv to judge a format rule'); if (loaded('ajv-formats')) fail('the rule-compilability gate must not load ajv-formats to judge a format rule'); + // #5178 — and the format-NAME gate asks for the registered set only when + // a schema actually names a format, so this one costs nothing either. + mod.validateRuleSchemaFormats( + ruleStack({ type: 'json_schema', name: 'j0', field: 'payload', schema: { type: 'object' }, message: 'm' }), + ); + if (loaded('ajv')) fail('the format-name gate must not load ajv for a schema that names no format (#5178)'); const schemaFindings = mod.validateRuleCompilability( ruleStack({ type: 'json_schema', name: 'j', field: 'payload', schema: { type: 'not-a-type' }, message: 'm' }), ); @@ -125,6 +137,18 @@ describe('lazy dependency loading (kernel boot-path contract)', () => { if (!schemaFindings.some((f) => f.rule === 'validation-rule-json-schema-uncompilable')) { fail('rule-compilability gate produced no finding'); } + const formatNameFindings = mod.validateRuleSchemaFormats( + ruleStack({ + type: 'json_schema', + name: 'jf', + field: 'payload', + schema: { type: 'object', properties: { e: { type: 'string', format: 'emial' } } }, + message: 'm', + }), + ); + if (!formatNameFindings.some((f) => f.rule === 'validation-rule-json-schema-unknown-format')) { + fail('format-name gate produced no finding (#5178)'); + } console.log('OK'); }; `; @@ -162,6 +186,7 @@ describe('lazy dependency loading (kernel boot-path contract)', () => { validateHookBodyWrites, validateActionBodyWrites, validateRuleCompilability, + validateRuleSchemaFormats, } = await import('./index.js'); // Stacks without a react-source page never touch either dep. @@ -198,6 +223,15 @@ describe('lazy dependency loading (kernel boot-path contract)', () => { ).map((f) => f.rule), ).toEqual(['validation-rule-regex-uncompilable']); + // #5178 — nor does the format-NAME gate on a `json_schema` rule that names + // no `format`: it needs the registered vocabulary only when there is a name + // to judge, so the vocabulary is fetched last, not first. + expect( + validateRuleSchemaFormats( + ruleStack({ type: 'json_schema', name: 'j0', field: 'payload', schema: { type: 'object' }, message: 'm' }), + ), + ).toEqual([]); + for (const dep of LAZY_DEPS) { expect(depLoaded(req.cache, dep), `${dep} loaded before any react-source or L2-body validation`).toBe(false); } @@ -212,6 +246,20 @@ describe('lazy dependency loading (kernel boot-path contract)', () => { expect(depLoaded(req.cache, 'ajv-formats')).toBe(true); expect(schemaFindings.map((f) => f.rule)).toEqual(['validation-rule-json-schema-uncompilable']); + // …and the format-name gate, once a schema does name one, reaches the same + // already-loaded environment and still produces its finding (#5178). + expect( + validateRuleSchemaFormats( + ruleStack({ + type: 'json_schema', + name: 'jf', + field: 'payload', + schema: { type: 'object', properties: { e: { type: 'string', format: 'emial' } } }, + message: 'm', + }), + ).map((f) => f.rule), + ).toEqual(['validation-rule-json-schema-unknown-format']); + // The first react page with source pays the cost of exactly its own gate's // dep — and the gates still work. const syntax = validateReactPages({ diff --git a/packages/lint/src/validate-rule-compilability.test.ts b/packages/lint/src/validate-rule-compilability.test.ts index 3172f68c47..1b819be9a4 100644 --- a/packages/lint/src/validate-rule-compilability.test.ts +++ b/packages/lint/src/validate-rule-compilability.test.ts @@ -410,7 +410,13 @@ describe('validateRuleCompilability — parity with the write path (#4762)', () ).toEqual([]); }); - it('a MISSPELLED format name is published, not refused — pinned as-is, not changed (#5029)', () => { + it('a MISSPELLED format name is published by THIS gate — the compile parity, unchanged (#5029/#5178)', () => { + // #5178 closed the gap and this assertion still reads `[]`, deliberately. + // The refusal lives in `validate-rule-schema-formats.ts`, a rule that + // compiles nothing and judges the format NAME against the registered set. + // Merging it into this gate would have made the publish gate disagree with + // the write path about what COMPILES, which is the one thing this file + // exists to prevent. If this ever goes red, that merge has happened. // Under `strict: false` ajv logs `unknown format "emial" ignored` and drops // the keyword — in the runtime AND here. So the gate must publish it: a // finding would be this gate inventing a verdict the write path does not @@ -826,8 +832,12 @@ describe('validateRuleCompilability — reads only keys the spec declares (meta- // guards what the table lists. const receivers = [...new Set([...RULE_CODE.matchAll(/\b([a-z][\w$]*)\??\.[A-Za-z_$]/g)].map((m) => m[1]))]; const tabled = new Set([...READ_SURFACES.map((r) => r.receiver), ...Object.keys(NOT_SCHEMA_RECEIVERS)]); - // Locals whose "keys" are JS methods / this file's own plumbing, not metadata. - const PLUMBING = new Set(['findings', 'v', 'out']); + // Locals whose "keys" are JS methods / this file's own plumbing, not + // metadata. `walked` and `names` are the two accumulators #5178 added when + // the rule walk and the registered-format vocabulary became shared handles + // (`walkObjectValidationRules`, `registeredFormatNames`) — array `.push` / + // `.length` / `.sort`, never a key off authored metadata. + const PLUMBING = new Set(['findings', 'v', 'out', 'walked', 'names']); expect(receivers.filter((r) => !tabled.has(r) && !PLUMBING.has(r))).toEqual([]); // …and no excuse outlives the read it excuses. A stale name in either list diff --git a/packages/lint/src/validate-rule-compilability.ts b/packages/lint/src/validate-rule-compilability.ts index bfe0e5c595..64435fd40b 100644 --- a/packages/lint/src/validate-rule-compilability.ts +++ b/packages/lint/src/validate-rule-compilability.ts @@ -62,10 +62,21 @@ * * What this gate deliberately does NOT judge is a MISSPELLED format name. * `format: 'emial'` compiles in both environments — under `strict: false` ajv - * logs one line and drops the keyword — so refusing it here would be the gate - * inventing a verdict the runtime does not share, the mirror image of the - * `strict: true` mistake above. #5029 pins that behaviour rather than changing - * it; closing it is an authoring-time decision of its own. + * logs one line and drops the keyword — so refusing it *on the compile's + * verdict* would be this gate inventing an outcome the runtime does not share, + * the mirror image of the `strict: true` mistake above. #5029 pinned that, and + * the pin still stands: `validateRuleCompilability` publishes a typo'd format. + * + * #5178 closed the residual gap the other way — with a SEPARATE judgement laid + * beside this compile rather than folded into it. `validate-rule-schema-formats.ts` + * walks the same schemas for `format` NAMES and refuses any name the + * `ajv-formats` registry does not carry. It never compiles anything, so the + * parity above is untouched: the two rules answer two different questions about + * one artifact ("does ajv accept this?" and "will this keyword do anything?"), + * and it reaches the registry through {@link registeredFormatNames} — this + * file's own instance — so "registered" cannot come to mean two things in one + * publish. Both rules walk one traversal, {@link walkObjectValidationRules}, + * for the same reason. * * ### One ajv instance per schema, on purpose * @@ -181,7 +192,16 @@ function asArray(v: unknown): AnyRec[] { * rather than imported as a value so no import statement can accidentally become * eager — see the lazy-load note at the top. */ -type AjvLike = { compile: (schema: unknown) => unknown }; +type AjvLike = { + compile: (schema: unknown) => unknown; + /** + * ajv's own format registry — `readonly formats` on the `Ajv` class, the map + * `addFormat` writes into and the `format` keyword reads back out. Declared + * here because #5178's gate enumerates the registered names from it rather + * than hardcoding a list that would silently stop tracking the plugin. + */ + formats: Record; +}; type AjvCtor = new (options?: AjvOptions) => AjvLike; /** `ajv-formats`' plugin entry — mutates the instance it is handed (#5029). */ type AddFormats = (ajv: AjvLike) => unknown; @@ -269,6 +289,39 @@ function createRuntimeAjv(): AjvLike { return instance; } +/** + * Every `format` name the runtime's environment actually has registered, sorted + * (#5178). + * + * Read out of a live {@link createRuntimeAjv} instance rather than written down + * as a list. A hardcoded vocabulary would be a THIRD opinion — after the + * runtime's registration and this gate's mirror of it — and the only one nobody + * updates: the day `ajv-formats` adds a name, a list here starts refusing a + * format the write path enforces, which is the "gate turns working metadata + * red" failure this whole file is written against. Enumerating means the gate + * follows the plugin across an upgrade with no edit at all, and the `fast` / + * default mode question answers itself (both modes register the same NAMES; + * only the implementations differ — and if that ever stops being true, the + * instance still knows and a list still would not). + * + * Throws rather than degrading if the registry comes back empty: an empty set + * would make this gate refuse EVERY format in the stack, so failing loudly with + * the cause named beats a run that reports the whole codebase as misspelled. + */ +export function registeredFormatNames(): readonly string[] { + const names = Object.keys(createRuntimeAjv().formats).sort(); + if (names.length === 0) { + throw new Error( + `@objectstack/lint: the runtime-parity ajv instance has no \`format\` registered. "ajv-formats" ` + + `loaded but added nothing, so the set of legitimate format names is unknown — refusing to judge ` + + `format names against an empty vocabulary, which would report every \`format\` in the stack as ` + + `misspelled. Check that the installed "ajv-formats" is the real package and matches the version ` + + `@objectstack/lint declares.`, + ); + } + return names; +} + /** The message a thrown compile error contributes, verbatim. */ function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err); @@ -315,14 +368,33 @@ function flattenRules( return out; } +/** One authored validation rule, located for a finding. */ +export interface WalkedValidationRule { + /** The rule record itself — a top-level entry, or a `conditional` branch. */ + rule: AnyRec; + /** The declaring object's `name`, or `(unnamed object)`. */ + objectName: string; + /** The nesting-aware rule name as prose: `'outer' → 'inner'`. */ + label: string; + /** Human-readable location: `object 'account' · validation 'outer' → 'inner'`. */ + where: string; + /** Config path of the rule: `objects.account.validations.outer.then.inner`. */ + basePath: string; +} + /** - * Reject every object validation rule whose static artifact — a `format` rule's - * `regex`, a `json_schema` rule's `schema` — the runtime's own compiler cannot - * compile. Pure `(stack) => Finding[]`; never throws. + * Every object validation rule an author declared, `conditional` branches + * flattened in, each carrying the location strings a finding needs. + * + * Exported because #5178's format-NAME gate judges exactly this set. A second + * walker there would be a second opinion about which rules EXIST — the same + * drift this file refuses for the compile verdict itself, one question over — + * and it would drift silently, because a rule the two walkers disagree about is + * simply one that no finding ever mentions. */ -export function validateRuleCompilability(stack: unknown): RuleCompilabilityFinding[] { - const findings: RuleCompilabilityFinding[] = []; - if (!isRec(stack)) return findings; +export function walkObjectValidationRules(stack: unknown): WalkedValidationRule[] { + const walked: WalkedValidationRule[] = []; + if (!isRec(stack)) return walked; for (const obj of asArray(stack.objects)) { const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)'; @@ -334,57 +406,75 @@ export function validateRuleCompilability(stack: unknown): RuleCompilabilityFind for (const authored of asArray(validations)) { for (const { rule, label, path } of flattenRules(authored, '', '')) { - const where = `object '${objectName}' · validation ${label}`; - const basePath = `objects.${objectName}.validations.${path}`; + walked.push({ + rule, + objectName, + label, + where: `object '${objectName}' · validation ${label}`, + basePath: `objects.${objectName}.validations.${path}`, + }); + } + } + } - if (rule.type === 'format' && typeof rule.regex === 'string' && rule.regex !== '') { - try { - // The exact call `checkFormat` makes — no flags, same constructor. - new RegExp(rule.regex); - } catch (err) { - findings.push({ - severity: 'error', - rule: VALIDATION_RULE_REGEX_UNCOMPILABLE, - where, - path: `${basePath}.regex`, - message: - `\`format\` validation ${label} on object '${objectName}' declares a \`regex\` that does not ` + - `compile: ${errorText(err)}. The write path builds it with \`new RegExp(rule.regex)\` and ` + - `SKIPS the rule when that throws (rule-validator.ts \`checkFormat\`), so the rule is declared, ` + - `listed in the metadata, and enforces nothing on any record.`, - hint: - `Fix the pattern so \`new RegExp('${rule.regex}')\` compiles — a literal \`(\`, \`[\` or \`\\\` ` + - `must be escaped (\`\\\\(\`, \`\\\\[\`, \`\\\\\\\\\`), and the source is a STRING, so a backslash ` + - `is written twice in TypeScript ('^\\\\d{2}-\\\\d{7}$'). Or drop \`regex\` and use a named ` + - `\`format\` ('email' | 'url' | 'phone' | 'json').`, - }); - } - } + return walked; +} + +/** + * Reject every object validation rule whose static artifact — a `format` rule's + * `regex`, a `json_schema` rule's `schema` — the runtime's own compiler cannot + * compile. Pure `(stack) => Finding[]`; never throws. + */ +export function validateRuleCompilability(stack: unknown): RuleCompilabilityFinding[] { + const findings: RuleCompilabilityFinding[] = []; + + for (const { rule, objectName, label, where, basePath } of walkObjectValidationRules(stack)) { + if (rule.type === 'format' && typeof rule.regex === 'string' && rule.regex !== '') { + try { + // The exact call `checkFormat` makes — no flags, same constructor. + new RegExp(rule.regex); + } catch (err) { + findings.push({ + severity: 'error', + rule: VALIDATION_RULE_REGEX_UNCOMPILABLE, + where, + path: `${basePath}.regex`, + message: + `\`format\` validation ${label} on object '${objectName}' declares a \`regex\` that does not ` + + `compile: ${errorText(err)}. The write path builds it with \`new RegExp(rule.regex)\` and ` + + `SKIPS the rule when that throws (rule-validator.ts \`checkFormat\`), so the rule is declared, ` + + `listed in the metadata, and enforces nothing on any record.`, + hint: + `Fix the pattern so \`new RegExp('${rule.regex}')\` compiles — a literal \`(\`, \`[\` or \`\\\` ` + + `must be escaped (\`\\\\(\`, \`\\\\[\`, \`\\\\\\\\\`), and the source is a STRING, so a backslash ` + + `is written twice in TypeScript ('^\\\\d{2}-\\\\d{7}$'). Or drop \`regex\` and use a named ` + + `\`format\` ('email' | 'url' | 'phone' | 'json').`, + }); + } + } - if (rule.type === 'json_schema' && isRec(rule.schema)) { - try { - // A fresh instance per schema — see "One ajv instance per schema". - createRuntimeAjv().compile(rule.schema); - } catch (err) { - findings.push({ - severity: 'error', - rule: VALIDATION_RULE_SCHEMA_UNCOMPILABLE, - where, - path: `${basePath}.schema`, - message: - `\`json_schema\` validation ${label} on object '${objectName}' declares a \`schema\` ajv cannot ` + - `compile: ${errorText(err)}. The write path compiles it with the same ajv ` + - `(\`new Ajv({ allErrors: true, strict: false })\` + \`ajv-formats\`) and SKIPS the rule when that throws ` + - `(rule-validator.ts \`checkJsonSchema\`), so the rule is declared and enforces nothing on any ` + - `record.`, - hint: - `Correct the schema so ajv compiles it — the message above names the offending keyword. ` + - `\`type\` must be one of null|boolean|object|array|number|string|integer (or an array of ` + - `those), \`required\` an array of strings, and every \`$ref\` must resolve. Vendor keywords ` + - `are fine (the runtime runs \`strict: false\`); a MALFORMED standard keyword is not.`, - }); - } - } + if (rule.type === 'json_schema' && isRec(rule.schema)) { + try { + // A fresh instance per schema — see "One ajv instance per schema". + createRuntimeAjv().compile(rule.schema); + } catch (err) { + findings.push({ + severity: 'error', + rule: VALIDATION_RULE_SCHEMA_UNCOMPILABLE, + where, + path: `${basePath}.schema`, + message: + `\`json_schema\` validation ${label} on object '${objectName}' declares a \`schema\` ajv cannot ` + + `compile: ${errorText(err)}. The write path compiles it with the same ajv ` + + `(\`new Ajv({ allErrors: true, strict: false })\` + \`ajv-formats\`) and SKIPS the rule when that throws ` + + `(rule-validator.ts \`checkJsonSchema\`), so the rule is declared and enforces nothing on any ` + + `record.`, + hint: + `Correct the schema so ajv compiles it — the message above names the offending keyword. ` + + `\`type\` must be one of null|boolean|object|array|number|string|integer (or an array of ` + + `those), \`required\` an array of strings, and every \`$ref\` must resolve. Vendor keywords ` + + `are fine (the runtime runs \`strict: false\`); a MALFORMED standard keyword is not.`, + }); } } } diff --git a/packages/lint/src/validate-rule-schema-formats.test.ts b/packages/lint/src/validate-rule-schema-formats.test.ts new file mode 100644 index 0000000000..27097c5fba --- /dev/null +++ b/packages/lint/src/validate-rule-schema-formats.test.ts @@ -0,0 +1,399 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #5178 — a MISSPELLED `format` in a `json_schema` validation rule is logged +// once by ajv and DROPPED, so the rule ships, runs on every write, and enforces +// nothing for the keyword its author wrote. The record is ACCEPTED, so no +// downstream signal exists either. +// +// The three halves this file has to prove, because any one alone is worthless: +// +// 1. the misspelling goes RED, naming the rule, the object, the JSON POINTER +// and the nearest legitimate name (an author cannot act on "unknown +// format"); +// 2. every LEGITIMATE registered format stays GREEN — table-driven over the +// enumerated set, not a sample. A gate that turns working metadata red gets +// switched off, and then protects nothing; +// 3. the compile gate next door is UNCHANGED by any of it. #4762/#5029's +// parity — the publish gate compiles in the runtime's exact environment — +// is the thing this rule must lay beside, never fold into. + +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { ObjectStackSchema } from '@objectstack/spec'; + +import { + validateRuleSchemaFormats, + nearestRegisteredFormat, + MAX_SCHEMA_WALK_DEPTH, + VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT, +} from './validate-rule-schema-formats.js'; +import { registeredFormatNames, validateRuleCompilability } from './validate-rule-compilability.js'; +import { AUTHORING_COMMANDS, AUTHORING_RULES, authoringRulesFor, runAuthoringRules } from './authoring-rules.js'; + +const srcDir = dirname(fileURLToPath(import.meta.url)); +const MANIFEST = { id: 'schema_format_probe', name: 'schema_format_probe', version: '1.0.0', type: 'app' } as const; + +/** One object carrying the given validation rules. */ +const objectWith = (...validations: unknown[]) => ({ + objects: [ + { + name: 'account', + label: 'Account', + fields: { tax_id: { type: 'text' }, support_config: { type: 'json' } }, + validations, + }, + ], +}); + +/** A `json_schema` rule named `support_shape` carrying `schema`. */ +const schemaRule = (schema: unknown, name = 'support_shape') => ({ + type: 'json_schema', + name, + field: 'support_config', + message: 'Support config does not match its schema.', + schema, +}); + +const ids = (stack: unknown) => validateRuleSchemaFormats(stack).map((f) => f.rule); +const paths = (stack: unknown) => validateRuleSchemaFormats(stack).map((f) => f.path); + +// ── Red: declared, schema-valid, compiling, and enforcing nothing ──── + +describe('validateRuleSchemaFormats — the dropped keyword goes RED (#5178)', () => { + it('rejects `format: \'emial\'`, naming rule, object, pointer and the nearest real name', () => { + const findings = validateRuleSchemaFormats( + objectWith(schemaRule({ type: 'object', properties: { email: { type: 'string', format: 'emial' } } })), + ); + + expect(findings).toHaveLength(1); + const [f] = findings; + expect(f.severity).toBe('error'); + expect(f.rule).toBe(VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT); + // Named: which rule, on which object, and exactly where in the schema. + expect(f.where).toBe("object 'account' · validation 'support_shape'"); + expect(f.path).toBe('objects.account.validations.support_shape.schema#/properties/email/format'); + expect(f.message).toContain("format: 'emial'"); + expect(f.message).toContain('#/properties/email/format'); + // The consequence, in the direction that matters: the record is accepted. + expect(f.message).toContain('ACCEPTED'); + // The fix, concretely — an author cannot act on "unknown format". + expect(f.hint).toContain("Did you mean `format: 'email'`?"); + // …and the full vocabulary, so a name that is merely unfamiliar is + // resolvable without reading ajv-formats' source. + expect(f.hint).toContain('date-time'); + expect(f.hint).toContain('uri-reference'); + }); + + it('is reachable on a stack the spec fully accepts — a VALUE verdict needs a green safeParse', () => { + // The rule judges a VALUE (the format name) inside `schema`, a key + // `JSONValidationSchema` declares as `z.record(z.string(), z.unknown())`. + // So the bar is a full parse, not merely "no unrecognized_keys": if the + // stack could not parse, the finding would be unreachable on the compile + // path this rule is registered `input: 'parsed'` for (#5018/#5046). + const stack = { + ...MANIFEST, + ...objectWith(schemaRule({ type: 'object', properties: { email: { type: 'string', format: 'emial' } } })), + }; + const parsed = ObjectStackSchema.safeParse(stack); + expect(parsed.success, JSON.stringify(parsed.error?.issues ?? [], null, 2)).toBe(true); + expect(ids(parsed.success ? parsed.data : stack)).toEqual([VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT]); + }); + + it('finds a misspelling at ANY schema depth — one finding per pointer', () => { + // The walker must reach every subschema position, not just `properties`: + // `$defs`, `items` (both forms), `anyOf`/`allOf`/`oneOf`, `if`/`then`/`else`, + // `additionalProperties`, `patternProperties`, `contains`, `not`, + // `propertyNames`, `dependentSchemas`, draft-07 `dependencies`. + const deep = { + $defs: { stamp: { type: 'string', format: 'datetime' } }, + type: 'object', + properties: { + rows: { + type: 'array', + items: { + anyOf: [ + { type: 'object', properties: { at: { type: 'string', format: 'date_time' } } }, + { type: 'object', additionalProperties: { type: 'string', format: 'urireference' } }, + ], + }, + }, + pair: { type: 'array', items: [{ format: 'ipv_4' }, { format: 'ipv6' }] }, + bag: { type: 'object', patternProperties: { '^x-': { format: 'uuid4' } } }, + }, + if: { properties: { kind: { const: 'web' } } }, + then: { properties: { site: { format: 'ur1' } } }, + else: { properties: { host: { format: 'hostname' } } }, + dependentSchemas: { site: { properties: { canonical: { format: 'uri-ref' } } } }, + dependencies: { legacy: { properties: { old: { format: 'e-mail' } } } }, + allOf: [{ not: { properties: { banned: { format: 'passwrd' } } } }], + oneOf: [{ contains: { format: 'json-pointr' } }, { propertyNames: { format: 'regexp' } }], + }; + + const findings = validateRuleSchemaFormats(objectWith(schemaRule(deep))); + // Every planted misspelling, and none of the two legitimate names + // (`ipv6`, `hostname`) that sit right beside them. + expect(findings.map((f) => f.path.split('#')[1]).sort()).toEqual( + [ + '/$defs/stamp/format', + '/allOf/0/not/properties/banned/format', + '/dependencies/legacy/properties/old/format', + '/dependentSchemas/site/properties/canonical/format', + '/oneOf/0/contains/format', + '/oneOf/1/propertyNames/format', + '/properties/bag/patternProperties/^x-/format', + '/properties/pair/items/0/format', + '/properties/rows/items/anyOf/0/properties/at/format', + '/properties/rows/items/anyOf/1/additionalProperties/format', + '/then/properties/site/format', + ].sort(), + ); + expect(new Set(findings.map((f) => f.rule))).toEqual(new Set([VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT])); + }); + + it('judges the rules nested in a `conditional`, and names the branch', () => { + // `evaluateRule` recurses into `then` / `otherwise` and reaches the very + // same `checkJsonSchema`, so a misspelling in a branch is just as inert. + const findings = validateRuleSchemaFormats( + objectWith({ + type: 'conditional', + name: 'when_enterprise', + message: 'Enterprise accounts need a full support config.', + when: 'record.tier == "enterprise"', + then: schemaRule({ properties: { email: { format: 'emial' } } }, 'support_shape_strict'), + }), + ); + + expect(findings).toHaveLength(1); + expect(findings[0].where).toBe("object 'account' · validation 'when_enterprise' → 'support_shape_strict'"); + expect(findings[0].path).toBe( + 'objects.account.validations.when_enterprise.then.support_shape_strict.schema#/properties/email/format', + ); + }); + + it('escapes `~` and `/` in pointer segments (RFC 6901)', () => { + expect( + paths(objectWith(schemaRule({ properties: { 'a/b~c': { format: 'emial' } } }))), + ).toEqual(['objects.account.validations.support_shape.schema#/properties/a~1b~0c/format']); + }); +}); + +// ── Green: the red line — legitimate metadata must not turn red ────── + +describe('validateRuleSchemaFormats — legitimate metadata stays GREEN', () => { + const registered = registeredFormatNames(); + + it('the registered set is enumerated from the plugin, not written down here', () => { + // The whole cost #5178 weighed was "the gate must enumerate the registered + // format set, which couples it to the plugin version". This is how that + // cost is paid: the set comes off a live ajv built exactly the way + // `rule-validator.ts` builds its own, so an `ajv-formats` upgrade moves the + // gate with it and no edit here is possible to forget. + const require_ = createRequire(import.meta.url); + const AjvMod = require_('ajv'); + const formatsMod = require_('ajv-formats'); + const Ajv = AjvMod.default ?? AjvMod; + const addFormats = formatsMod.default ?? formatsMod; + const probe = new Ajv({ allErrors: true, strict: false }); + addFormats(probe); + + expect([...registered]).toEqual(Object.keys(probe.formats).sort()); + expect(registered.length).toBeGreaterThan(0); + // A sample, so a plugin that silently registered nothing is visible here + // rather than only as "every format in your stack is misspelled". + expect(registered).toContain('email'); + expect(registered).toContain('date-time'); + expect(registered).toContain('uri-reference'); + + // …and no hardcoded vocabulary crept back into the rule's source. A list + // there would start refusing formats the write path enforces the day the + // plugin adds one — the failure mode this rule exists to avoid, aimed at + // the gate itself. + const source = readFileSync(join(srcDir, 'validate-rule-schema-formats.ts'), 'utf8'); + for (const name of ['date-time', 'uri-reference', 'json-pointer', 'ipv4']) { + expect(source, `the rule hardcodes the format name "${name}" — enumerate instead`).not.toContain(`'${name}'`); + } + }); + + it.each([...registered])('publishes `format: %s` — every registered name', (name) => { + const stack = objectWith(schemaRule({ type: 'object', properties: { value: { format: name } } })); + expect(ids(stack)).toEqual([]); + // …and the schema really is publishable end to end: the #4762 compile gate + // is green on it too, so this table is not quietly green because the + // metadata was broken in some other way. + expect(validateRuleCompilability(stack)).toEqual([]); + }); + + it('says nothing about a schema that names no format at all', () => { + expect( + ids( + objectWith( + schemaRule({ + type: 'object', + properties: { plan: { type: 'string', enum: ['free', 'pro'] }, seats: { type: 'integer', minimum: 1 } }, + required: ['plan'], + 'x-vendor-hint': { anything: true }, + }), + ), + ), + ).toEqual([]); + }); + + it('never reads `format` out of DATA — `default`, `const`, `enum`, `examples`', () => { + // The false-positive trap a naive "collect every `format` key" walker falls + // into. These hold arbitrary values ajv never reads as schema, so a + // `format` inside one enforces nothing and was never meant to; reporting it + // would be inventing a defect out of a legal document. + expect( + ids( + objectWith( + schemaRule({ + type: 'object', + properties: { + tpl: { type: 'object', default: { format: 'emial' }, examples: [{ format: 'nonsense' }] }, + mode: { const: { format: 'also-not-a-format' } }, + pick: { enum: [{ format: 'still-not' }, 'plain'] }, + }, + }), + ), + ), + ).toEqual([]); + }); + + it('leaves a NON-STRING `format` to the compile gate, which refuses it by name', () => { + // `format: 42` fails ajv's own meta-schema check, so #4762's rule already + // rejects the schema with ajv's words. A second finding here would be noise + // — and a `$data` reference is resolved at validation time, not statically. + const bad = objectWith(schemaRule({ type: 'object', properties: { e: { format: 42 } } })); + expect(ids(bad)).toEqual([]); + expect(validateRuleCompilability(bad).map((f) => f.rule)).toEqual(['validation-rule-json-schema-uncompilable']); + + expect(ids(objectWith(schemaRule({ properties: { e: { format: { $data: '1/fmt' } } } })))).toEqual([]); + }); + + it('says nothing about the other five validation-rule types, or a schema-less rule', () => { + expect( + ids( + objectWith( + { type: 'format', name: 'tax', field: 'tax_id', regex: '^\\d{2}$', format: 'email', message: 'm' }, + { type: 'script', name: 's', condition: 'record.tax_id != null', message: 'm' }, + { type: 'json_schema', name: 'no_schema', field: 'support_config', message: 'm' }, + ), + ), + ).toEqual([]); + }); + + it('tolerates junk without throwing — it is a pure (stack) => Finding[]', () => { + for (const junk of [undefined, null, 42, 'stack', [], {}, { objects: 'nope' }, { objects: [null] }]) { + expect(() => validateRuleSchemaFormats(junk)).not.toThrow(); + expect(validateRuleSchemaFormats(junk)).toEqual([]); + } + }); + + it('stops at MAX_SCHEMA_WALK_DEPTH instead of hanging on a self-referential schema', () => { + // `os lint` never parses, so it hands this walker whatever object the + // author's module actually built — and a self-reference is a two-line + // accident. Same cheap promise `MAX_RULE_NESTING_DEPTH` makes next door. + const cyclic: Record = { type: 'object', properties: { bad: { format: 'emial' } } }; + (cyclic.properties as Record).self = cyclic; + expect(MAX_SCHEMA_WALK_DEPTH).toBeGreaterThan(8); + const findings = validateRuleSchemaFormats(objectWith(schemaRule(cyclic))); + expect(findings.length).toBeGreaterThan(0); + expect(findings.length).toBeLessThanOrEqual(MAX_SCHEMA_WALK_DEPTH + 1); + }); +}); + +// ── The suggestion has to be worth printing ────────────────────────── + +describe('nearestRegisteredFormat — the suggestion half of the message', () => { + const registered = registeredFormatNames(); + + it.each([ + ['emial', 'email'], + ['e-mail', 'email'], + ['Email', 'email'], + ['datetime', 'date-time'], + ['date_time', 'date-time'], + ['urireference', 'uri-reference'], + ['ipv_4', 'ipv4'], + ['uuid4', 'uuid'], + ['hostnmae', 'hostname'], + ])('suggests %s → %s', (typo, expected) => { + expect(nearestRegisteredFormat(typo, registered)).toBe(expected); + }); + + it('suggests NOTHING for a name that is not a typo of anything', () => { + // A confident wrong suggestion is worse than none: it sends the author to + // rename a constraint they never meant to write. + for (const invented of ['shoe_size', 'company-tax-identifier', 'zz']) { + expect(nearestRegisteredFormat(invented, registered)).toBeNull(); + } + }); + + it('is deterministic — ties break alphabetically, so a message never flickers', () => { + expect(nearestRegisteredFormat('emial', [...registered].reverse())).toBe( + nearestRegisteredFormat('emial', registered), + ); + }); +}); + +// ── The compile gate next door is untouched ────────────────────────── + +describe('#4762/#5029 parity is laid BESIDE, never folded in', () => { + it('the compile gate still PUBLISHES a misspelled format — its #5029 pin stands', () => { + // The direction matters and was decided before it was run: this is NOT a + // "revert the fix and watch it go red" case. `validateRuleCompilability` + // compiles in the runtime's exact environment, a typo'd format compiles + // there, and so it must keep returning [] — its pin + // ('a MISSPELLED format name is published, not refused') passes unchanged. + // What changed is that a SECOND rule, which compiles nothing, now refuses + // the same stack. If this assertion ever goes red, the two judgements have + // been merged and the parity contract broken. + const stack = objectWith(schemaRule({ type: 'object', properties: { email: { format: 'emial' } } })); + expect(validateRuleCompilability(stack)).toEqual([]); + expect(ids(stack)).toEqual([VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT]); + }); + + it('the rule never compiles a schema — the parity instance is only asked what it registered', () => { + const source = readFileSync(join(srcDir, 'validate-rule-schema-formats.ts'), 'utf8'); + expect(source, 'this rule must not compile — that is the neighbouring gate\'s job').not.toMatch( + /\.compile\s*\(/, + ); + }); + + it('an uncompilable schema still yields BOTH verdicts, each from its own rule', () => { + const stack = objectWith(schemaRule({ type: 'not-a-type', properties: { e: { format: 'emial' } } })); + expect(validateRuleCompilability(stack).map((f) => f.rule)).toEqual(['validation-rule-json-schema-uncompilable']); + expect(ids(stack)).toEqual([VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT]); + }); +}); + +// ── Wired, or it protects nothing (#4409/#4449) ────────────────────── + +describe('validateRuleSchemaFormats is wired into every authoring command', () => { + it('is a gating registry entry on all three commands', () => { + const entry = AUTHORING_RULES.find((r) => r.name === 'validateRuleSchemaFormats'); + expect(entry, 'the rule must be registered in AUTHORING_RULES').toBeDefined(); + expect(entry!.tier).toBe('gating'); + expect(entry!.source).toBe('packages/lint/src/validate-rule-schema-formats.ts'); + for (const command of AUTHORING_COMMANDS) { + expect( + authoringRulesFor(command).map((r) => r.name), + `os ${command} must run validateRuleSchemaFormats`, + ).toContain('validateRuleSchemaFormats'); + } + }); + + it.each([...AUTHORING_COMMANDS])('os %s reports the misspelling as an error', (command) => { + const stack = { + ...MANIFEST, + ...objectWith(schemaRule({ type: 'object', properties: { email: { type: 'string', format: 'emial' } } })), + }; + const findings = runAuthoringRules(command, { normalized: stack, parsed: stack }); + const mine = findings.filter((f) => f.rule === VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT); + expect(mine).toHaveLength(1); + expect(mine[0].severity).toBe('error'); + }); +}); diff --git a/packages/lint/src/validate-rule-schema-formats.ts b/packages/lint/src/validate-rule-schema-formats.ts new file mode 100644 index 0000000000..42fe3a358f --- /dev/null +++ b/packages/lint/src/validate-rule-schema-formats.ts @@ -0,0 +1,333 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5178 — a MISSPELLED `format` name in a `json_schema` validation rule + * enforces nothing, and every existing gate is structurally blind to it. + * + * After #5029 the runtime's shared ajv is `new Ajv({ allErrors: true, strict: + * false })` + `addFormats(ajv)`, so `format: 'email'` really does reject + * `not-an-email`. But `strict: false` is also what makes an UNRECOGNISED format + * name a non-event: ajv logs `unknown format "emial" ignored in schema at path + * "#/properties/e"` once at compile time and **drops the keyword**. The rule + * then compiles, ships, appears in the metadata, appears in every "what + * protects this object" listing, runs on every write, enforces `type` and + * `required` — and enforces nothing at all for the keyword its author actually + * wrote. The record is ACCEPTED, which is the silent direction. + * + * That is the #4649 family shape again — declared ≠ enforced, with a stderr line + * naming no rule and no object as the only signal — with the trigger moved from + * "we forgot the plugin" (#5029) to "the author made a typo". And a typo is the + * single most likely mistake in a hand-written or AI-generated JSON Schema: + * `emial`, `e-mail`, `datetime` for `date-time`, `urireference` for + * `uri-reference`, `ipv_4` for `ipv4`, `Email` for `email`. + * + * ## Why a rule BESIDE the compile, not inside it + * + * `validate-rule-compilability.ts` (#4762) compiles each `json_schema` rule with + * the SAME ajv environment the runtime uses — deliberately, and #5029 extended + * that parity to the `ajv-formats` registration. A schema whose format name the + * runtime silently drops is therefore a schema that gate also compiles happily, + * and it must stay that way: a gate inventing a COMPILE verdict the runtime does + * not share is the `strict: true` mistake in a different hat. Its `#5029` pin — + * "a MISSPELLED format name is published, not refused" — still passes verbatim, + * because it is a statement about the compile. + * + * So this is a second, independent judgement over the same artifact, and it + * never compiles anything. Two questions, two rules: + * + * | question | rule | + * |-----------------------------------------|--------------------------------------------| + * | does ajv ACCEPT this schema? | `validation-rule-json-schema-uncompilable` | + * | will this `format` keyword DO anything? | `validation-rule-json-schema-unknown-format` | + * + * Both are decidable from the metadata alone, with no record in hand — the very + * property #4762 used to argue for an authoring-time gate — so both are refused + * before deployment rather than at write time. The runtime is untouched + * (option 2 on #5178, `strict: 'log'` → throw, was rejected: it fires at + * runtime and fail-OPEN, since `checkJsonSchema` catches, logs and skips, so it + * would trade one silent gap for another; and `strict: false` is load-bearing + * for the vendor keywords author-written schemas legitimately carry). + * + * ## The vocabulary is enumerated, never written down + * + * {@link registeredFormatNames} reads the names off a live instance of the very + * ajv this repo's gate builds to mirror the runtime. A hardcoded list would be a + * third opinion that nobody updates: the day `ajv-formats` adds a name, the list + * starts refusing a format the write path enforces — a gate that turns working + * metadata red, which gets switched off and then protects nothing. Enumerating + * makes the gate follow the plugin across an upgrade with no edit here at all. + * + * ## The walk is JSON-Schema-aware, because `format` is not a magic word + * + * A naive "collect every `format` key at any depth" walker reports false + * positives on data, and false positives are this rule's red line. `default: { + * format: 'emial' }`, `const`, `enum` and `examples` all hold arbitrary VALUES + * that ajv never reads as schema — a `format` inside one enforces nothing and + * was never meant to. So the walk descends only into positions JSON Schema + * defines as subschemas ({@link SUBSCHEMA_KEYS} and friends), which is exactly + * the set of places ajv would apply the keyword. Positions ajv does not treat as + * a schema cannot carry an enforced `format`, so skipping them loses nothing. + * + * Non-string `format` values are skipped too, for the complementary reason: + * `format: 42` fails ajv's own meta-schema check (`data/properties/e/format must + * be string`), so the compile gate already refuses it by name, and a second + * finding here would only be noise. Same for `{ format: { $data: '1/f' } }`. + * + * ## Scope, and the keys read + * + * Object validation rules only, via {@link walkObjectValidationRules} — the same + * traversal `validate-rule-compilability.ts` uses, shared rather than + * re-implemented so the two rules can never disagree about which rules exist. + * That covers `object.validations[]` including the rules nested in a + * `conditional`'s `then` / `otherwise`, since `evaluateRule` recurses into those + * and reaches the very same `checkJsonSchema`. + * + * | Read | Declared by | + * |----------------------------------|------------------------| + * | `objects[].validations[]` | `ObjectSchema` | + * | `validations[].type` / `.name` | every `*ValidationSchema` variant | + * | `validations[].schema` | `JSONValidationSchema` | + * | `validations[].then`/`.otherwise`| `ConditionalValidationSchema` | + * + * Every one is a key `@objectstack/spec` DECLARES; no alias is read, on either + * side (#4984, #5009, #5017, #5096) — the strict sub-schemas reject an + * undeclared key by NAME, so a branch keyed on one would be inert for every + * stack an author can ship. + * + * ## Why `error` + * + * The severity bar `lint-flow-patterns.ts` states — gate when NO reading of the + * metadata behaves as written. There is no deployment in which a dropped + * keyword enforces the author's declared guarantee; it is inert for every + * record, forever. + */ + +import { + registeredFormatNames, + walkObjectValidationRules, +} from './validate-rule-compilability.js'; + +export type RuleSchemaFormatSeverity = 'error'; + +export interface RuleSchemaFormatFinding { + severity: RuleSchemaFormatSeverity; + /** Stable diagnostic rule id (`--json` consumers and allowlists key on it). */ + rule: string; + /** Human-readable location, e.g. `object 'account' · validation 'support_shape'`. */ + where: string; + /** + * Config path of the offending keyword — the rule's `schema` key followed by + * the RFC 6901 JSON Pointer into it, e.g. + * `objects.account.validations.support_shape.schema#/properties/email/format`. + */ + path: string; + message: string; + hint: string; +} + +/** A `json_schema` rule naming a `format` the runtime's ajv has not registered. */ +export const VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT = 'validation-rule-json-schema-unknown-format'; + +type AnyRec = Record; + +const isRec = (v: unknown): v is AnyRec => !!v && typeof v === 'object' && !Array.isArray(v); + +/** Keywords whose value IS a subschema. */ +const SUBSCHEMA_KEYS = [ + 'additionalItems', + 'additionalProperties', + 'contains', + 'propertyNames', + 'if', + 'then', + 'else', + 'not', + 'unevaluatedItems', + 'unevaluatedProperties', +] as const; + +/** Keywords whose value is an ARRAY of subschemas. */ +const SUBSCHEMA_LIST_KEYS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'] as const; + +/** Keywords whose value is a MAP of name → subschema. */ +const SUBSCHEMA_MAP_KEYS = [ + 'properties', + 'patternProperties', + '$defs', + 'definitions', + 'dependentSchemas', +] as const; + +/** + * Depth cap on the schema walk. Not a cycle guard for parsed metadata — that is + * a tree — but the same cheap promise `flow-walk.ts`'s `MAX_REGION_DEPTH` and + * `validate-rule-compilability.ts`'s `MAX_RULE_NESTING_DEPTH` make: `os lint` + * never parses, so it hands this walker whatever object the author's module + * actually built, and `const s = { type: 'object' }; s.properties = { self: s };` + * is a two-line accident. Well past any reviewable schema. + */ +export const MAX_SCHEMA_WALK_DEPTH = 32; + +/** RFC 6901 — `~` and `/` are the two characters a pointer segment must escape. */ +const escapePointerSegment = (segment: string) => segment.replace(/~/g, '~0').replace(/\//g, '~1'); + +/** One `format: ''` found at a real schema position. */ +interface FormatUse { + /** JSON Pointer to the `format` KEYWORD, without the leading `#` (e.g. `/properties/email/format`). */ + pointer: string; + name: string; +} + +/** + * Collect every `format` string reachable from `schema` through positions JSON + * Schema defines as subschemas. Order is deterministic (declaration order within + * each keyword group) so findings are stable across runs. + */ +function collectFormatUses(schema: unknown, pointer: string, out: FormatUse[], depth: number): void { + if (!isRec(schema)) return; + + if (typeof schema.format === 'string') { + out.push({ pointer: `${pointer}/format`, name: schema.format }); + } + + if (depth >= MAX_SCHEMA_WALK_DEPTH) return; + + for (const key of SUBSCHEMA_KEYS) { + if (key in schema) { + collectFormatUses(schema[key], `${pointer}/${escapePointerSegment(key)}`, out, depth + 1); + } + } + + for (const key of SUBSCHEMA_LIST_KEYS) { + const value = schema[key]; + if (!Array.isArray(value)) continue; + value.forEach((entry, index) => { + collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${index}`, out, depth + 1); + }); + } + + for (const key of SUBSCHEMA_MAP_KEYS) { + const value = schema[key]; + if (!isRec(value)) continue; + for (const [name, entry] of Object.entries(value)) { + collectFormatUses(entry, `${pointer}/${escapePointerSegment(key)}/${escapePointerSegment(name)}`, out, depth + 1); + } + } + + // `items` is a schema in 2020-12 and an ARRAY of schemas in draft-07's tuple + // form. ajv accepts both (the runtime pins no `$schema`), so both are walked. + const items = schema.items; + if (Array.isArray(items)) { + items.forEach((entry, index) => collectFormatUses(entry, `${pointer}/items/${index}`, out, depth + 1)); + } else if (isRec(items)) { + collectFormatUses(items, `${pointer}/items`, out, depth + 1); + } + + // draft-07 `dependencies`: name → schema OR name → string[]. Only the schema + // form is a schema position; the string list is data. + const dependencies = schema.dependencies; + if (isRec(dependencies)) { + for (const [name, entry] of Object.entries(dependencies)) { + if (!isRec(entry)) continue; + collectFormatUses(entry, `${pointer}/dependencies/${escapePointerSegment(name)}`, out, depth + 1); + } + } +} + +/** Plain Levenshtein distance. Small inputs (format names are ≤ 26 chars). */ +function editDistance(a: string, b: string): number { + let previous = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const current = [i]; + for (let j = 1; j <= b.length; j++) { + current[j] = Math.min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + previous = current; + } + return previous[b.length]; +} + +/** + * The registered name closest to `name`, or `null` when nothing is close enough + * to be worth suggesting. + * + * The budget scales with the typo's own length — three edits on a twelve-letter + * name is a plausible slip, three edits on a four-letter one is a different word + * — so a genuinely invented name (`shoe_size`) gets no suggestion instead of a + * confident wrong one. Comparison is case-insensitive on the authored side so + * `Email` is diagnosed as the case typo it is; ajv's own lookup is + * case-SENSITIVE, which is exactly why `Email` is unregistered in the first + * place. Ties break alphabetically, so the suggestion is stable. + */ +export function nearestRegisteredFormat(name: string, registered: readonly string[]): string | null { + const budget = Math.min(3, Math.floor(name.length / 2)); + if (budget < 1) return null; + + const authored = name.toLowerCase(); + let best: string | null = null; + let bestDistance = Number.POSITIVE_INFINITY; + for (const candidate of [...registered].sort()) { + const distance = editDistance(authored, candidate); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + } + return bestDistance <= budget ? best : null; +} + +/** + * Reject every `json_schema` validation rule that names a `format` the runtime's + * ajv has not registered — the keyword ajv logs once and drops, leaving the rule + * declared and inert. Pure `(stack) => Finding[]`. + * + * Lazier than the compile gate on purpose: the registered set is only asked for + * once a schema actually NAMES a format, so a stack whose `json_schema` rules + * use none never loads ajv at all (`lazy-deps.test.ts` pins this). + */ +export function validateRuleSchemaFormats(stack: unknown): RuleSchemaFormatFinding[] { + const findings: RuleSchemaFormatFinding[] = []; + + const pending: Array<{ use: FormatUse; where: string; label: string; objectName: string; basePath: string }> = []; + for (const { rule, objectName, label, where, basePath } of walkObjectValidationRules(stack)) { + if (rule.type !== 'json_schema' || !isRec(rule.schema)) continue; + const uses: FormatUse[] = []; + collectFormatUses(rule.schema, '', uses, 0); + for (const use of uses) pending.push({ use, where, label, objectName, basePath }); + } + if (pending.length === 0) return findings; + + const registered = registeredFormatNames(); + const known = new Set(registered); + + for (const { use, where, label, objectName, basePath } of pending) { + if (known.has(use.name)) continue; + const suggestion = nearestRegisteredFormat(use.name, registered); + const pointer = `#${use.pointer}`; + findings.push({ + severity: 'error', + rule: VALIDATION_RULE_SCHEMA_UNKNOWN_FORMAT, + where, + path: `${basePath}.schema${pointer}`, + message: + `\`json_schema\` validation ${label} on object '${objectName}' names \`format: '${use.name}'\` at ` + + `\`${pointer}\`, which is not a registered format. ajv logs \`unknown format "${use.name}" ignored\` ` + + `once at compile time and DROPS the keyword — in the write path (rule-validator.ts, ` + + `\`strict: false\`) and in the publish gate alike — so the schema compiles, the rule ships and runs ` + + `on every write, its \`type\`/\`required\` keywords are enforced, and this constraint is enforced ` + + `on no record, ever. The record is ACCEPTED, so nothing downstream reports the gap either.`, + hint: + (suggestion ? `Did you mean \`format: '${suggestion}'\`? ` : '') + + `The registered names are: ${registered.join(', ')} — the default \`ajv-formats\` set, the one ` + + `\`rule-validator.ts\` registers (#5029). Names are case-sensitive and hyphenated ` + + `(\`date-time\`, not \`datetime\`). If you meant a constraint ajv has no format for, express it ` + + `with \`pattern\` instead — a regex is enforced, an unknown format name is not.`, + }); + } + + return findings; +}