From c508a614a042c57e36eece9144f82f5ce24a7e04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 05:24:54 +0000 Subject: [PATCH] =?UTF-8?q?fix(lint):=20=E6=94=B6=E6=95=9B=20validate-rule?= =?UTF-8?q?-compilability=20=E7=9A=84=20spec=20=E4=B8=8D=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E9=94=AE=20`=3F=3F`=20=E5=88=AB=E5=90=8D=E8=AF=BB=E6=B3=95=20(?= =?UTF-8?q?#5096)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4984 → #5009 → #5017/PR #5046 同族第八处,落在第三个文件。 `validate-rule-compilability.ts:239` 读 `obj.validations ?? obj.validationRules`, 而 `ObjectSchema.shape` 只声明 `validations` 且 strict —— `validationRules` 被按名 拒绝("Did you mean `validationRules` → `validations`?",#4001)。该规则以 `input: 'parsed'` 注册,canonical 排首位,别名 limb 对任何能解析的 stack 不可达。 三个 example(crm / showcase / todo,28 个对象、17 条验证规则)上改动前后 findings 逐字相同,两侧均 0 条。代价从来不是漏报而是误导:consumer 里的别名 fallback 等于 向后来的读者和照着写的 AI 宣称 `objects[].validationRules` 是真实 authoring 面。 补两层结构性 meta-guard:declared-key(含 `rule[branch]` 计算属性读法的专项断言) + reachability(判据是 `safeParse` 全绿,比 #5046 严一档 —— 编不过的 regex/schema 在 spec 眼里依然完全合法)。原测试 `reads `validationRules` too` 断言的正是被删掉 的 limb(实测产出 1 条 finding,非空转),故替换而非改拼写。变异测试:别名 limb 加回 → 2 条红;改为纯别名读 → 11 条红。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ --- .../lint-rule-compilability-alias-read.md | 39 ++ .../src/validate-rule-compilability.test.ts | 432 +++++++++++++++++- .../lint/src/validate-rule-compilability.ts | 38 +- 3 files changed, 491 insertions(+), 18 deletions(-) create mode 100644 .changeset/lint-rule-compilability-alias-read.md diff --git a/.changeset/lint-rule-compilability-alias-read.md b/.changeset/lint-rule-compilability-alias-read.md new file mode 100644 index 0000000000..f6571e2b6a --- /dev/null +++ b/.changeset/lint-rule-compilability-alias-read.md @@ -0,0 +1,39 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): 收敛 `validateRuleCompilability` 里读 spec 不声明键的 `??` 别名链 (#5096) + +#4984 → #5009 → #5017/PR #5046 同族第八处,落在第三个文件 +(`validate-rule-compilability.ts:239`): + +| 原读法 | spec 事实(对 live `.shape` + `safeParse` 实测) | 处置 | +|:--|:--|:--| +| `obj.validations ?? obj.validationRules` | `ObjectSchema.shape` 只声明 `validations`,且 strict —— `validationRules` 被**按名拒绝**:`Unrecognized key(s) on this object: \`validationRules\`. … Did you mean \`validationRules\` → \`validations\`?`(#4001) | 收敛为 `obj.validations` | + +该规则以 `input: 'parsed'` 注册,canonical 排首位,所以别名 limb 对任何能解析的 +stack 都不可达 —— 三个 example(crm / showcase / todo,共 28 个对象、17 条验证规则) +上改动前后 findings **逐字相同**(两侧均 0 条)。 + +**代价从来不是漏报,而是误导。** 一个写在 consumer 里的别名 fallback,等于向后来的 +读者、以及照着这份源码写元数据的 AI 宣称 `objects[].validationRules` 是一个真实的 +authoring 面;它把 schema 一句指名道姓的拒绝,降级成一条静默失效的分支。别名容忍属于 +producer 的拒绝面,不属于 consumer(Prime Directive #12)。 + +同时给本文件补上两层结构性 meta-guard(#4992 模式,#5017 形状),让下一条死读法在 +review 前就红: + +- **declared-key guard** —— 规则源码里从 `stack` / `obj` / `rule` 上读的每个键,必须 + 出现在对应 surface 自己的 Zod `.shape` 里,且 `expected` 精确匹配;另加一条 "covers + every receiver" 元测试,以及一条针对 `flattenRules` 里 `rule[branch]` **计算属性** + 读法的专项断言(点号扫描看不见它,而 `then` / `otherwise` 恰是本规则最有意思的读法)。 +- **reachability guard** —— 两个 `findings.push` 落点都必须被一条 `ObjectStackSchema` + **完整 parse 通过**的 fixture 触达。这里用的是 #5018 的 `safeParse` 全绿判据,比 + `validate-security-posture` 只能要求"不报 `unrecognized_keys`"更严一档 —— 因为本规则 + 判的是"编译不过",而在 spec 眼里 `regex` 是任意字符串、`schema` 是任意 record,编不过 + 的产物依然完全 spec 合法:这条 gate 存在的理由正是 zod 看不见该缺陷,所以它永远不需要 + 一条 zod 会拒绝的 fixture。 + +原测试 `reads \`validationRules\` too` 断言的正是被删掉的那条 limb,实测确实产出 1 条 +finding(不是空转),因此它被**替换**而不是改拼写:把 key 换成 canonical 只会留下一条 +主题已不存在的绿测试。变异测试:别名 limb 加回去 → 2 条红;改成纯别名读 → 11 条红。 diff --git a/packages/lint/src/validate-rule-compilability.test.ts b/packages/lint/src/validate-rule-compilability.test.ts index 7e52735267..723a021ffe 100644 --- a/packages/lint/src/validate-rule-compilability.test.ts +++ b/packages/lint/src/validate-rule-compilability.test.ts @@ -19,6 +19,8 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, it, expect } from 'vitest'; +import { ObjectStackSchema } from '@objectstack/spec'; +import { ObjectSchema } from '@objectstack/spec/data'; import { validateRuleCompilability, @@ -32,6 +34,17 @@ import { AUTHORING_COMMANDS, AUTHORING_RULES, authoringRulesFor, runAuthoringRul const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..'); const RUNTIME_VALIDATOR = 'packages/objectql/src/validation/rule-validator.ts'; +const MANIFEST = { id: 'rule_compilability_probe', name: 'rule_compilability_probe', version: '1.0.0', type: 'app' } as const; + +/** Does the schema refuse any KEY in this stack (as opposed to any VALUE)? */ +function unrecognizedKeysIn(stack: unknown): string[] { + const result = ObjectStackSchema.safeParse(stack); + if (result.success) return []; + return result.error.issues + .filter((i) => i.code === 'unrecognized_keys') + .map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`); +} + /** One object carrying the given validation rules. */ const objectWith = (...validations: unknown[]) => ({ objects: [ @@ -286,19 +299,6 @@ describe('validateRuleCompilability — walks what authors actually write', () = expect(findings.map((f) => f.where)).toEqual(["object 'account' · validation 'ein'"]); }); - it('reads `validationRules` too — the same list `validate-expressions.ts` reads', () => { - expect( - ids({ - objects: [ - { - name: 'account', - validationRules: [{ type: 'format', name: 'ein', field: 'tax_id', regex: '([', message: 'm' }], - }, - ], - }), - ).toEqual([VALIDATION_RULE_REGEX_UNCOMPILABLE]); - }); - it('terminates on a self-referential `conditional` — `os lint` never parses', () => { // The pre-parse stack is whatever the author's own module built, so a // self-referential rule is a two-line accident rather than a hypothetical. @@ -424,3 +424,409 @@ describe('validateRuleCompilability — registry wiring', () => { } }); }); + +// ── The alias this rule stopped reading, and why (#5096) ───────────── + +/** + * `objects[].validationRules` was read here as `obj.validations ?? + * obj.validationRules` — the eighth of the family #4984 → #5009 → #5017/PR + * #5046 has been closing out, and the third file it lands in. + * + * The canonical key came FIRST, so on any stack `ObjectStackSchema` can parse + * the alias limb was unreachable (the schema refuses the key outright, so it is + * never present in a parsed object). What the limb still did was *claim*, in + * the source every later reader and every AI authoring metadata against this + * repo consults, that `objects[].validationRules` is a real authoring surface. + * That claim is the defect; the behaviour change is nil, and the tests below + * pin BOTH halves rather than asserting one and assuming the other. + * + * Note the direction of the reverse verification, which is #5018's lesson: the + * canonical key was first, so removing the limb does not *lose* a finding on + * any valid stack. It loses one only on a stack the schema already refuses by + * name — where the refusal is the better diagnostic. The old test here + * (`reads \`validationRules\` too`) asserted exactly the limb being removed and + * really did fire (1 finding, measured before the change), so it is replaced + * rather than re-spelled: keeping it green by swapping the key to `validations` + * would have kept a test whose stated subject no longer exists. + */ +describe('validateRuleCompilability — undeclared keys are the schema’s job, not this rule’s (#5096)', () => { + const aliasSpelling = { + objects: [ + { + name: 'account', + validationRules: [{ type: 'format', name: 'ein', field: 'tax_id', regex: '([', message: 'm' }], + }, + ], + }; + const canonicalSpelling = { + objects: [ + { + name: 'account', + validations: [{ type: 'format', name: 'ein', field: 'tax_id', regex: '([', message: 'm' }], + }, + ], + }; + + it('`ObjectSchema` declares `validations` and refuses `validationRules` BY NAME', () => { + const objectKeys = Object.keys(ObjectSchema.shape); + expect(objectKeys).toContain('validations'); + expect(objectKeys).not.toContain('validationRules'); + + // Strict, so this is not a silent strip: the whole object is refused, and + // the refusal names the replacement key. + const refusals = unrecognizedKeysIn({ + manifest: MANIFEST, + objects: [ + { + name: 'account', + label: 'Account', + fields: { tax_id: { type: 'text', label: 'Tax ID' } }, + validationRules: [{ type: 'format', name: 'ein', field: 'tax_id', regex: '([', message: 'm' }], + }, + ], + }).join(' '); + expect(refusals).toMatch(/Unrecognized key\(s\) on this object: `validationRules`/); + expect(refusals).toMatch(/Did you mean `validationRules` → `validations`\?/); + + // …while the canonical spelling of the same stack parses clean. + expect( + ObjectStackSchema.safeParse({ + manifest: MANIFEST, + objects: [ + { + name: 'account', + label: 'Account', + fields: { tax_id: { type: 'text', label: 'Tax ID' } }, + validations: [{ type: 'format', name: 'ein', field: 'tax_id', regex: '([', message: 'm' }], + }, + ], + }).success, + ).toBe(true); + }); + + it('reads the canonical key only — the alias spelling yields nothing here', () => { + // The whole finding, on the canonical spelling… + expect(ids(canonicalSpelling)).toEqual([VALIDATION_RULE_REGEX_UNCOMPILABLE]); + // …and silence on the spelling the schema refuses. Silence is correct: the + // author is told by NAME which key to fix, instead of getting a report about + // a list this gate had to invent a second reading of the spec to find. + expect(ids(aliasSpelling)).toEqual([]); + }); + + it('is exactly what the alias limb used to answer — the removed behaviour, restated', () => { + // The limb, rebuilt here so the claim "no valid stack changes verdict" is + // demonstrated rather than described (#5046's `OLD_CHAIN` pattern). + const OLD_CHAIN = (stack: Record) => { + const objects = (stack.objects as Array>) ?? []; + return objects.map((o) => o.validations ?? o.validationRules); + }; + const NEW_READ = (stack: Record) => + ((stack.objects as Array>) ?? []).map((o) => o.validations); + + // Canonical spelling: identical, which is the zero-behaviour-change claim. + expect(OLD_CHAIN(canonicalSpelling)).toEqual(NEW_READ(canonicalSpelling)); + // Alias spelling: the ONLY input where they differ — and it is a stack the + // schema rejects, so it never reaches this `input: 'parsed'` rule at all. + expect(OLD_CHAIN(aliasSpelling)).not.toEqual(NEW_READ(aliasSpelling)); + expect(unrecognizedKeysIn({ manifest: MANIFEST, ...aliasSpelling })).not.toEqual([]); + }); +}); + +/** + * ── The structural meta-guard (#4992 pattern, #5009/#5018/#5017 shape) ─────── + * + * Two guards, both scanning the SOURCE rather than the behaviour — deliberately, + * because an unreachable branch has no behaviour to assert on, which is the + * whole problem this family keeps rediscovering: + * + * 1. **Declared-key guard** — every key this rule reads off a stack, an object + * or a validation rule must appear in that surface's own Zod `.shape`, with + * `expected` matched EXACTLY so that adding a read (or renaming a loop + * variable, which would silently disarm the scan) forces a deliberate visit + * to the table. Plus a "covers every receiver" meta-test, so a NEW receiver + * cannot carry undeclared reads by simply not being listed. + * + * 2. **Reachability guard** — every `findings.push` site is reached by a fixture + * that `ObjectStackSchema` parses CLEAN. + * + * That bar is #5018's flat `safeParse`, one notch stricter than the + * `unrecognized_keys`-only bar `validate-security-posture.test.ts` had to + * settle for, and it is available here because of what this rule judges: a + * `regex` is any string and a `schema` any record as far as the spec is + * concerned, so an artifact that will not COMPILE is still perfectly + * spec-VALID. This gate exists precisely because zod cannot see the defect — + * it therefore never needs a fixture zod rejects. + */ +const RULE_SOURCE = readFileSync(new URL('./validate-rule-compilability.ts', import.meta.url), 'utf8'); + +/** + * Strip literal TEXT, keeping code. This rule's findings carry long `message` / + * `hint` prose and a `path` template, and that prose is thick with dotted names + * that are not property reads at all — `objects..validations..regex` + * is a config PATH shown to an author, `rule-validator.ts` is a filename. A scan + * that counted them would need `objects`, `validations` and `validator` excused + * as "plumbing", which is exactly the kind of padding that makes an excuse list + * stop meaning anything. `${…}` interpolations ARE reads (`${rule.regex}`) and + * are kept. + */ +function stripLiteralText(src: string): string { + let out = ''; + let i = 0; + while (i < src.length) { + const c = src[i]; + if (c === "'" || c === '"') { + const quote = c; + i++; + while (i < src.length && src[i] !== quote) i += src[i] === '\\' ? 2 : 1; + i++; + out += ' '; + continue; + } + if (c === '`') { + i++; + let depth = 0; + while (i < src.length) { + if (src[i] === '\\') { + i += 2; + continue; + } + if (depth === 0) { + if (src[i] === '`') { + i++; + break; + } + if (src[i] === '$' && src[i + 1] === '{') { + depth = 1; + i += 2; + out += ' '; + continue; + } + i++; + continue; + } + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) { + i++; + out += ' '; + continue; + } + out += src[i]; + i++; + } + out += ' '; + continue; + } + out += c; + i++; + } + return out; +} + +/** Comments stripped, string literals intact — for reading declared LITERALS. */ +const RULE_CODE_WITH_LITERALS = RULE_SOURCE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); + +/** The rule's CODE — comments and literal prose stripped: the guards scan reads. */ +const RULE_CODE = stripLiteralText(RULE_CODE_WITH_LITERALS); + +/** Distinct property names read off `receiver` in the rule's code. */ +function keysReadOff(receiver: string): string[] { + const re = new RegExp(`\\b${receiver}\\??\\.([A-Za-z_$][\\w$]*)`, 'g'); + return [...new Set([...RULE_CODE.matchAll(re)].map((m) => m[1]))].sort(); +} + +/** + * The declared keys of a schema, unwrapping the optional / array / record / + * lazy / union layers between a collection and its element. A union answers the + * UNION of its members' keys: a validation rule is exactly one variant, and a + * key any variant declares is one an author may legitimately write. + * + * `lazySchema` wraps schemas in a Proxy whose target is a FUNCTION, so the + * `typeof` guard admits both — miss that and every lazily-built schema silently + * answers "declares nothing", which would make this guard vacuous. + */ +function shapeKeysOf(schema: unknown, depth = 0): string[] { + const s = schema as { shape?: Record; _def?: Record; unwrap?: () => unknown }; + if (!s || (typeof s !== 'object' && typeof s !== 'function') || depth > 12) return []; + if (s.shape) return Object.keys(s.shape); + const d = (s._def ?? {}) as Record; + if (d.type === 'union' && Array.isArray(d.options)) { + return [...new Set((d.options as unknown[]).flatMap((o) => shapeKeysOf(o, depth + 1)))]; + } + const getter = d.getter as (() => unknown) | undefined; + for (const next of [d.innerType, d.element, d.valueType, getter?.(), d.in, d.out]) { + const r = shapeKeysOf(next, depth + 1); + if (r.length) return r; + } + if (typeof s.unwrap === 'function') return shapeKeysOf(s.unwrap(), depth + 1); + return []; +} + +/** + * Receivers whose keys are NOT a spec `.shape`, and why. An explicit, reasoned + * list rather than "whatever the table forgot": a receiver that drops out of the + * table silently is how an undeclared read gets back in. + */ +const NOT_SCHEMA_RECEIVERS: Record = { + err: 'a caught `Error` — `.message` is the JS builtin.', + import: '`import.meta.url`, the ESM builtin `loadAjv` anchors `createRequire` on.', + process: '`process.cwd()`, a Node global — the CJS-build fallback anchor.', +}; + +const READ_SURFACES: Array<{ receiver: string; expected: string[]; declaredBy: string; keys: () => string[] }> = [ + { + receiver: 'stack', + expected: ['objects'], + declaredBy: 'ObjectStackSchema', + keys: () => Object.keys(ObjectStackSchema.shape), + }, + { + // `validationRules` is absent, and that is the #5096 fix. + receiver: 'obj', + expected: ['name', 'validations'], + declaredBy: 'ObjectSchema', + keys: () => Object.keys(ObjectSchema.shape), + }, + { + receiver: 'rule', + expected: ['name', 'regex', 'schema', 'type'], + declaredBy: 'ObjectSchema.validations[] (the ValidationRuleSchema union)', + keys: () => shapeKeysOf(ObjectSchema.shape.validations), + }, +]; + +describe('validateRuleCompilability — reads only keys the spec declares (meta-test, #5096)', () => { + it.each(READ_SURFACES)('every key read off `$receiver` is declared by $declaredBy', (surface) => { + const read = keysReadOff(surface.receiver); + expect(read).toEqual(surface.expected); + const declared = surface.keys(); + expect(declared.length, `${surface.declaredBy} resolved to no keys — the guard would be vacuous`).toBeGreaterThan(0); + expect(read.filter((k) => !declared.includes(k))).toEqual([]); + }); + + it('the COMPUTED `rule[branch]` reads are declared too — the dotted scan cannot see them', () => { + // `flattenRules` descends via `rule[branch]` over a literal list, so the + // regex scan above is blind to `then` / `otherwise`. Read the literal out of + // the source and check it the same way, or this rule's most interesting + // reads would sit outside every guard in this file. + const literal = /for \(const branch of \[([^\]]*)\] as const\)/.exec(RULE_CODE_WITH_LITERALS); + expect(literal, 'flattenRules no longer iterates a literal branch list — re-check this guard').not.toBeNull(); + const branches = [...literal![1].matchAll(/'([^']+)'/g)].map((m) => m[1]).sort(); + expect(branches).toEqual(['otherwise', 'then']); + const declared = shapeKeysOf(ObjectSchema.shape.validations); + expect(branches.filter((b) => !declared.includes(b))).toEqual([]); + }); + + it('covers every receiver in the source that is not explicitly excused', () => { + // Without this, a NEW receiver (a new loop variable over a new collection) + // would carry undeclared reads with nothing to notice — the table only + // 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']); + 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 + // is a hole a future receiver can walk through under a familiar alias, and + // is also how an excuse list quietly becomes decorative. + const present = new Set(receivers); + expect( + [...Object.keys(NOT_SCHEMA_RECEIVERS), ...PLUMBING].filter((r) => !present.has(r)), + 'these names are excused but no longer read anything — drop them', + ).toEqual([]); + }); +}); + +/** + * ── Reachability: every branch is reachable from a stack the spec ACCEPTS ──── + */ +function pushedRuleIds(): string[] { + const sites = RULE_CODE.split('findings.push({').slice(1); + return sites.map((block, i) => { + const ruleConst = /rule:\s*([A-Z_][A-Z0-9_]*)/.exec(block)?.[1]; + if (!ruleConst) throw new Error(`findings.push site #${i} has no literal \`rule:\` — the guard cannot map it`); + const id = RULE_IDS[ruleConst]; + if (!id) throw new Error(`findings.push site #${i} emits unknown rule id \`${ruleConst}\``); + return id; + }); +} + +const RULE_IDS: Record = { + VALIDATION_RULE_REGEX_UNCOMPILABLE, + VALIDATION_RULE_SCHEMA_UNCOMPILABLE, +}; + +/** A full, spec-VALID stack carrying the given validation rules. */ +const parseableStackWith = (...validations: unknown[]) => ({ + manifest: MANIFEST, + objects: [ + { + name: 'account', + label: 'Account', + fields: { + tax_id: { type: 'text', label: 'Tax ID' }, + support_config: { type: 'json', label: 'Support Config' }, + }, + validations, + }, + ], +}); + +const REACHABILITY_CORPUS: Array<{ label: string; stack: Record }> = [ + { + label: 'regex-uncompilable (top level)', + stack: parseableStackWith({ type: 'format', name: 'tax_id_format', field: 'tax_id', regex: '([', message: 'm' }), + }, + { + label: 'json-schema-uncompilable (top level)', + stack: parseableStackWith({ + type: 'json_schema', + name: 'support_config_shape', + field: 'support_config', + schema: { type: 'not-a-type' }, + message: 'm', + }), + }, + { + label: 'both, nested in a `conditional` branch', + stack: parseableStackWith({ + type: 'conditional', + name: 'churn_reason_consistency', + when: "record.status == 'churned'", + message: 'm', + then: { type: 'format', name: 'churn_code_shape', field: 'tax_id', regex: 'a{2,1}', message: 'm' }, + otherwise: { + type: 'json_schema', + name: 'config_shape', + field: 'support_config', + schema: { required: 'tier' }, + message: 'm', + }, + }), + }, +]; + +describe('validateRuleCompilability — every branch is reachable from a PARSING stack (meta-test, #5096)', () => { + it.each(REACHABILITY_CORPUS)('$label: the fixture is spec-valid', ({ stack }) => { + const result = ObjectStackSchema.safeParse(stack); + expect( + result.success ? [] : result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), + ).toEqual([]); + }); + + it('maps every `findings.push` site in the source', () => { + expect(pushedRuleIds()).toHaveLength(2); + }); + + it('reaches every `findings.push` site from that corpus — through the PARSED stack', () => { + // Parsed, not raw: the corpus has to reach these sites as the rule actually + // receives them on the compile path (`input: 'parsed'`). A fixture that only + // works pre-parse would prove nothing about the gate authors meet. + const emitted = new Set( + REACHABILITY_CORPUS.flatMap(({ stack }) => + validateRuleCompilability(ObjectStackSchema.parse(stack)).map((f) => f.rule), + ), + ); + expect([...emitted].sort()).toEqual([...new Set(pushedRuleIds())].sort()); + }); +}); diff --git a/packages/lint/src/validate-rule-compilability.ts b/packages/lint/src/validate-rule-compilability.ts index 13fe5f0c00..d8abdc64d3 100644 --- a/packages/lint/src/validate-rule-compilability.ts +++ b/packages/lint/src/validate-rule-compilability.ts @@ -73,6 +73,34 @@ * recurses into those and reaches the very same `checkFormat` / * `checkJsonSchema`. Nothing else in the stack is judged here. * + * ### The keys read, and the one deliberately NOT read (#5096) + * + * This rule is registered `input: 'parsed'` (`authoring-rules.ts`), so on the + * compile path it sees what `ObjectStackSchema` returned. Every key it reads is + * one `@objectstack/spec` DECLARES — a contract, not a style preference: the + * strict sub-schemas reject an undeclared key by NAME, so a branch keyed on one + * is inert for every stack an author can ship (#4984, #5009, #5017, #5096). + * + * | Read | Declared by | + * |---------------------------------------------|--------------------------------------| + * | `objects[].validations[]` | `ObjectSchema` | + * | `validations[].type` / `.name` | every `*ValidationSchema` variant | + * | `validations[].regex` | `FormatValidationSchema` | + * | `validations[].schema` | `JSONValidationSchema` | + * | `validations[].then` / `.otherwise` | `ConditionalValidationSchema` | + * + * NOT read: **`objects[].validationRules`**. `ObjectSchema` declares + * `validations` and is strict, so the alias is refused by name — "Unrecognized + * key(s) on this object: `validationRules`. … Did you mean `validationRules` → + * `validations`?" (#4001). It was read here as a `??` fallback with the + * canonical key FIRST, so the limb could not run on any parsing stack; what it + * did do was assert, to every later reader and to every AI writing metadata + * against this source, that `objects[].validationRules` is a real authoring + * surface. Alias tolerance belongs at the schema's refusal, never in a consumer + * (Prime Directive #12) — `validate-expressions.ts` shed the identical read in + * #5017/PR #5046, and this was the eighth of that family (#5096). The live + * `.shape` and the refusal text are both pinned in this rule's test. + * * ## Why `error` * * The severity bar `lint-flow-patterns.ts` states — gate when NO reading of the @@ -232,11 +260,11 @@ export function validateRuleCompilability(stack: unknown): RuleCompilabilityFind for (const obj of asArray(stack.objects)) { const objectName = typeof obj.name === 'string' ? obj.name : '(unnamed object)'; - // `validations` is the spec key; `validationRules` is read for the same - // reason `validate-expressions.ts` reads it — so the two rules that judge - // one object's validation list can never see different lists. Not a new - // dialect: no key is invented here, and neither rule rewrites anything. - const validations = obj.validations ?? obj.validationRules; + // `validations` is the key `ObjectSchema` declares; `validationRules` is a + // rejected alias of it (#5096) — see the `### The keys read` note above. + // The two rules that judge one object's validation list still see the same + // list, because `validate-expressions.ts` reads the same single key (#5017). + const validations = obj.validations; for (const authored of asArray(validations)) { for (const { rule, label, path } of flattenRules(authored, '', '')) {