From e6225d2f104cf6da19b2251eef4ee55a55b64652 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 14:07:48 +0000 Subject: [PATCH] =?UTF-8?q?docs(lint):=20=E6=8C=89=E5=AE=9E=E6=B5=8B?= =?UTF-8?q?=E6=94=B9=E6=AD=A3=20`normalized`=20=E8=BE=93=E5=85=A5=E5=B1=82?= =?UTF-8?q?=E7=9A=84=E4=B8=89=E6=9D=A1=E4=BE=9D=E6=8D=AE,=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E5=9B=9E=E5=BD=92=20pin=20(#6073)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6073 要求「先测量、后行动」。测量做完了,结论是假设不成立——但不成立的方式 和单子预期的两个分支都不同,所以按实测如实记录,不套模板。 `AuthoringRuleInputTier` 的 `normalized` 层此前用三个例子自证:「规则要读 parse 会剥掉的键(views: [] 里的扁平 list view、对象 list view 上的 userFilters、visibleOn 别名)」。三条**全部实测为假**,各有各的原因: 1. defineStack 在定义期就 parse,所以 TS 配置交给三条命令的值已经是 result.data(实测:flow.runAs === 'user'、status === 'draft' 已填充); 2. 但什么都没丢——#4001 之后 ViewSchema / ObjectListViewSchema 是 strict, 不是 strip:defineStack 直接抛错并点名 type/data/columns 与 quickFilters, 还附 defineView 包裹修法。example app 上 os lint 与 os validate 均在 LOAD 阶段拒绝配置,规则根本没机会跑,而这比规则报得更早、更准; 3. visibleOn 别名在任何输入形状下都到不了这一层:ADR-0087 D2 的两条转换在 normalizeStackInput **之内**折叠它,而该函数的输出就是这一层。单独立单 #6318 记录。 保留该层的真实理由(实测确认):无关的 schema 错误会中止 parse,而 normalized 层的 findings 仍能到达作者——raw 路径上 os validate 停在 schema 步零 finding,os lint 仍点名两条规则。 改动仅为注释 + 一个新的回归 pin 测试(12 例),非注释代码行零改动; examples/ 终态零 diff。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- .../src/authoring-rule-input-tier.test.ts | 313 ++++++++++++++++++ packages/lint/src/authoring-rules.ts | 80 ++++- packages/lint/src/validate-list-view-mode.ts | 28 +- packages/lint/src/validate-view-containers.ts | 33 +- .../src/validate-visibility-predicates.ts | 24 +- 5 files changed, 447 insertions(+), 31 deletions(-) create mode 100644 packages/lint/src/authoring-rule-input-tier.test.ts diff --git a/packages/lint/src/authoring-rule-input-tier.test.ts b/packages/lint/src/authoring-rule-input-tier.test.ts new file mode 100644 index 0000000000..36d6b3b885 --- /dev/null +++ b/packages/lint/src/authoring-rule-input-tier.test.ts @@ -0,0 +1,313 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #6073 — what `AuthoringRuleInputTier`'s `normalized` value actually buys. +// +// ## Why this file exists +// +// The tier's doc comment used to justify itself with three examples — "the rules +// that need it check keys the parse strips (a flat list view in `views: []`, +// `userFilters` on an object list view, a `visibleOn` alias): by the time +// `result.data` exists the evidence is gone". All three were measured FALSE +// under #6073, each for a different reason, and a comment is not something CI +// can keep honest. Every claim the corrected comment makes is pinned here, so +// the next reader inherits the measurement instead of re-deriving it — and so a +// change that silently restores one of the old premises goes red. +// +// The mechanism half was never in doubt (#5693 measured it, this file re-pins +// it): `defineStack` PARSES at definition time, so for a TS config — the +// documented and universal way to declare a stack — the value the CLI hands the +// registry is already `result.data`, and re-normalizing it cannot resurrect +// anything the parse resolved. What was in doubt was the CONSEQUENCE, and the +// consequence is not the blind spot it looked like: the two view schemas the +// comment cited went strict at #4001, so they REFUSE where the comment says they +// STRIP, and refuse earlier and better than any lint rule could. +// +// ## What this file does NOT claim +// +// It does not claim the tier is useless. The reason +// `validate-functional-completeness.ts` gives for it is real and pinned in the +// last describe block: on the raw (non-`defineStack`) door, `os lint` — which +// never parses — still reports rule findings on a stack whose schema step would +// have failed. `normalized` means "needs no PARSED stack", never "is guaranteed +// to see pre-parse evidence". + +import { describe, expect, it, vi } from 'vitest'; +import { defineStack, normalizeStackInput } from '@objectstack/spec'; + +import { validateListViewMode } from './validate-list-view-mode.js'; +import { validateViewContainers } from './validate-view-containers.js'; +import { validateVisibilityPredicates } from './validate-visibility-predicates.js'; +import { runAuthoringRules } from './authoring-rules.js'; + +type AnyRec = Record; + +const manifest = { + id: 'com.example.tier', + namespace: 'tier', + version: '1.0.0', + type: 'app', + name: 'Tier Probe', + engines: { protocol: '^17' }, +}; + +/** `defineStack` warns on the D2 conversion channel; keep test output clean. */ +function quietly(fn: () => T): { value?: T; error?: Error; warnings: string[] } { + const warnings: string[] = []; + const spy = vi.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + warnings.push(args.join(' ')); + }); + try { + return { value: fn(), warnings }; + } catch (e) { + return { error: e as Error, warnings }; + } finally { + spy.mockRestore(); + } +} + +/** + * The value the three commands hand `runAuthoringRules` for a `defineStack` + * config: `loadConfig()` returns the module's default export (= `result.data`), + * and `lint.ts` / `validate.ts` / `compile.ts` then call `normalizeStackInput` + * on THAT. Modelled here rather than imported so this package does not depend + * on the CLI; the shape is asserted against reality in the first block. + */ +const cliTierFor = (stack: AnyRec): AnyRec => + normalizeStackInput(defineStack(stack as never) as unknown as AnyRec); + +// ─────────────────────────────────────────────────────────────────────────── +describe('the mechanism: for a defineStack config the `normalized` tier is POST-parse', () => { + const flowStack = { + manifest, + flows: [ + { + name: 'tier_flow', + label: 'Tier Flow', + type: 'schedule', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [{ id: 'e1', source: 'start', target: 'end' }], + }, + ], + }; + + it('carries parse-time DEFAULTS that a truly pre-parse stack does not have', () => { + // The tell #5693 tripped over: `os lint` printed a message arm reachable only + // when `flow.runAs` IS a string, which only `FlowSchema`'s `.default('user')` + // can produce. If this ever goes back to `undefined`, the whole premise below + // changes and the comment on `AuthoringRuleInputTier` must be re-measured. + const trulyPreParse = normalizeStackInput(structuredClone(flowStack)) as AnyRec; + const cliTier = quietly(() => cliTierFor(structuredClone(flowStack))); + expect(cliTier.error).toBeUndefined(); + + const pre = (trulyPreParse.flows as AnyRec[])[0]; + const post = (cliTier.value!.flows as AnyRec[])[0]; + + expect(pre.runAs).toBeUndefined(); + expect(pre.status).toBeUndefined(); + expect(post.runAs).toBe('user'); + expect(post.status).toBe('draft'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('premise 1 (FALSE): "the parse strips a flat list view in `views: []`"', () => { + const flatView = { + name: 'tier_flat', + label: 'Tier Flat', + type: 'grid', + data: { provider: 'object', object: 'tier_task' }, + columns: [{ field: 'name' }], + }; + + it('defineStack REFUSES it by name instead — ViewSchema is strict since #4001', () => { + const { error } = quietly(() => defineStack({ manifest, views: [flatView] } as never)); + expect(error).toBeDefined(); + // The schema names every offending key AND prints the wrap-it fix, which is + // strictly more than `view-container-shape` would have said. + expect(error!.message).toContain('views.0'); + expect(error!.message).toContain('Unrecognized key(s) on this view container'); + for (const key of ['type', 'data', 'columns']) expect(error!.message).toContain(key); + expect(error!.message).toContain('defineView({ list:'); + }); + + it('still fires on the doors the strict parse never sees (raw input, no defineStack)', () => { + // `os lint` on a raw object-literal config, `defineStack(x, { strict: false })`, + // and direct API callers all reach the rule with the flat shape intact. This + // is the arm that keeps the `looksFlat` branch alive — deleting it would take + // the only diagnostic those three doors get. + const findings = validateViewContainers( + normalizeStackInput({ manifest, views: [flatView] }) as AnyRec, + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe('view-container-shape'); + expect(findings[0].message).toContain('Flat list-view object'); + }); + + it('the arm that DOES survive the parse is the all-slots-empty container', () => { + // Every key here is declared, so nothing is refused and nothing is stripped: + // this is the shape `validateViewContainers` is genuinely the only reporter of. + const emptyContainer = { manifest, views: [{ name: 'tier_empty' }] }; + const cli = quietly(() => cliTierFor(structuredClone(emptyContainer))); + expect(cli.error).toBeUndefined(); + + const findings = validateViewContainers(cli.value!); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe('view-container-shape'); + expect(findings[0].message).toContain('defines no views'); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('premise 2 (FALSE): "the parse strips `userFilters`/`quickFilters` on an object list view"', () => { + const objectWithBadFilters = { + manifest, + objects: [ + { + name: 'tier_task', + label: 'Task', + fields: { + name: { type: 'text', label: 'Name' }, + status: { type: 'text', label: 'Status' }, + }, + listViews: { + my_pending: { + label: 'My Pending', + type: 'grid', + columns: [{ field: 'name' }], + quickFilters: [{ field: 'status', label: 'Status' }], + userFilters: { element: 'tabs', fields: ['status'] }, + }, + }, + }, + ], + }; + + it('defineStack REFUSES both — strict key rejection AND an enum refusal', () => { + const { error } = quietly(() => defineStack(structuredClone(objectWithBadFilters) as never)); + expect(error).toBeDefined(); + // `quickFilters` — refused as an unrecognized KEY, with the rename suggestion. + expect(error!.message).toContain('Unrecognized key(s) on this list view'); + expect(error!.message).toContain('quickFilters'); + // `element: 'tabs'` — refused as an invalid VALUE. Two different rejection + // mechanisms; both louder than the rule, both at definition time. + expect(error!.message).toContain("Invalid value 'tabs'"); + expect(error!.message).toContain('dropdown'); + }); + + it('still fires on raw input, which is the door that keeps the rule honest', () => { + const findings = validateListViewMode( + normalizeStackInput(structuredClone(objectWithBadFilters)) as AnyRec, + ); + expect(findings.map((f) => f.rule)).toEqual([ + 'list-view-filters-in-views-mode', + 'list-view-filters-in-views-mode', + ]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('premise 3 (FALSE): "the `visibleOn` alias survives until the parse"', () => { + // The fold is an ADR-0087 D2 conversion inside `normalizeStackInput` — one + // layer BEFORE this tier — not a parse-time `.transform()`. So the alias is + // gone from the tier's own input on every door, `os lint` included. #6318. + const aliasSites: Array<[string, AnyRec]> = [ + ['views[].form.sections[]', { + manifest, + views: [{ + name: 'tier_form', + form: { type: 'simple', sections: [{ label: 'S', visibleOn: 'record.a == 1', fields: [{ field: 'name' }] }] }, + }], + }], + ['views[].formViews.edit.sections[]', { + manifest, + views: [{ + name: 'tier_form2', + formViews: { edit: { type: 'simple', sections: [{ label: 'S', visibleOn: 'record.a == 1', fields: [{ field: 'name' }] }] } }, + }], + }], + ['pages[].regions[].components[]', { + manifest, + pages: [{ + name: 'tier_page', + label: 'P', + type: 'home', + object: 'tier_task', + regions: [{ name: 'main', components: [{ type: 'element:text', visibility: "page.selectedId != ''" }] }], + }], + }], + ]; + + it.each(aliasSites)('%s: the alias is folded BEFORE the tier, so the rule reports 0', (_site, stack) => { + // Fed the raw authored object (what the rule's own unit tests do) it reports. + expect(validateVisibilityPredicates(structuredClone(stack))).toHaveLength(1); + // Fed the `normalized` tier (what all three commands do) it does not. + expect(validateVisibilityPredicates(normalizeStackInput(structuredClone(stack)) as AnyRec)).toEqual([]); + }); + + it('the author is NOT left silent — the D2 conversion notice names the site and its retirement', () => { + const { error, warnings } = quietly(() => defineStack(structuredClone(aliasSites[0][1]) as never)); + expect(error).toBeUndefined(); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("'visibleOn' → 'visibleWhen'"); + expect(warnings[0]).toContain('views[0].form.sections[0].visibleWhen'); + expect(warnings[0]).toContain('retires in protocol 16'); + }); + + it('the predicate-VALUE rules in the same file are unaffected — do not connect them', () => { + // The value moves into `visibleWhen` intact, so these two still report on the + // tier. #6318 is about the alias-KEY rule only. + const bare = { + manifest, + views: [{ + name: 'tier_form3', + form: { type: 'simple', sections: [{ label: 'S', visibleWhen: "status == 'active'", fields: [{ field: 'name' }] }] }, + }], + }; + const cli = quietly(() => cliTierFor(structuredClone(bare))); + expect(cli.error).toBeUndefined(); + expect(validateVisibilityPredicates(cli.value!).map((f) => f.rule)).toEqual([ + 'visibility-bare-identifier', + ]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +describe('what `normalized` DOES buy: findings survive a schema error that stops the parse', () => { + it('the registry reports on a stack whose parse would have failed outright', () => { + // This is the surviving justification, and it is not theoretical: on the raw + // door `os validate` stops at the schema step and prints zero rule findings, + // while `os lint` — which never parses — still names both rules. A `parsed` + // tier could not have run here at all. + const unparseable = { + manifest, + objects: [{ + name: 'tier_task', + label: 'Task', + fields: { name: { type: 'text', label: 'Name' } }, + listViews: { + my_pending: { + label: 'My Pending', + type: 'grid', + columns: [{ field: 'name' }], + quickFilters: [{ field: 'status', label: 'Status' }], + }, + }, + }], + views: [{ name: 'tier_flat', label: 'F', type: 'grid', columns: [{ field: 'name' }] }], + }; + + // The parse refuses it — that is the premise of this test, not an aside. + expect(quietly(() => defineStack(structuredClone(unparseable) as never)).error).toBeDefined(); + + // …and the `normalized`-tier rules still deliver their verdicts. + const findings = runAuthoringRules('lint', { + normalized: normalizeStackInput(structuredClone(unparseable)) as AnyRec, + }); + const rules = new Set(findings.map((f) => f.rule)); + expect(rules.has('list-view-filters-in-views-mode')).toBe(true); + expect(rules.has('view-container-shape')).toBe(true); + }); +}); diff --git a/packages/lint/src/authoring-rules.ts b/packages/lint/src/authoring-rules.ts index bb4316bce2..9c3fc70e72 100644 --- a/packages/lint/src/authoring-rules.ts +++ b/packages/lint/src/authoring-rules.ts @@ -201,10 +201,8 @@ export type AuthoringRuleTier = 'gating' | 'advisory'; /** * Which tier of the stack a rule reads. * - * - `normalized` — the `normalizeStackInput` output, BEFORE the Zod parse. The - * rules that need it check keys the parse strips (a flat list view in - * `views: []`, `userFilters` on an object list view, a `visibleOn` alias): by - * the time `result.data` exists the evidence is gone. + * - `normalized` — the `normalizeStackInput` output, run before this package's + * caller had a chance to Zod-parse. * - `parsed` — the post-parse stack, where defaults are filled and shapes are * settled. * @@ -212,6 +210,49 @@ export type AuthoringRuleTier = 'gating' | 'advisory'; * `os validate`'s verdict to give), so it runs BOTH tiers on the normalized * stack. Every rule here is written to tolerate that — it is what `os lint` * already did for the reference-integrity suite and the security linter. + * + * ## What `normalized` does NOT buy, measured (#6073) + * + * This tier used to be justified as "the rules that need it check keys the + * parse strips — a flat list view in `views: []`, `userFilters` on an object + * list view, a `visibleOn` alias — by the time `result.data` exists the + * evidence is gone". **All three of those examples were measured false**, each + * for its own reason, and the pins live in `authoring-rule-input-tier.test.ts`: + * + * 1. `defineStack` — the documented and universal way a TS config declares a + * stack — PARSES at definition time, so for such a config the value every + * command hands this registry is already `result.data`: parse-time defaults + * filled (`flow.runAs === 'user'`), unknown keys resolved. Re-normalizing it + * cannot resurrect anything. That half was measured in #5693 and re-measured + * here. + * 2. But nothing is lost, because since #4001 the two cited view schemas do not + * STRIP — they REFUSE. `ViewSchema` and `ObjectListViewSchema` are strict, so + * `defineStack` throws on the flat list view and on `quickFilters` / + * `userFilters: { element: 'tabs' }`, naming the same sites the rules name, + * one layer earlier and with the schema's own fix hint. Measured end to end: + * `os lint` and `os validate` on an example app carrying the flat view both + * refuse the config at LOAD, before any rule runs. + * 3. The `visibleOn` alias never reaches this tier on ANY input shape: the + * ADR-0087 D2 conversion (`view-visibleOn-to-visibleWhen`, + * `page-component-visibility-to-visibleWhen`) folds it into `visibleWhen` + * INSIDE `normalizeStackInput` — one layer before the tier, not during the + * parse. See #6318. + * + * ## What it does buy, and why the tier stays + * + * The surviving reason is the one `validate-functional-completeness.ts` states + * and the measurement confirms: a `normalized`-tier finding reaches the author + * even when an unrelated schema error elsewhere would stop the parse. On the + * raw (non-`defineStack`) path that is not theoretical — `os validate` stops at + * the schema step and reports zero rule findings, while `os lint`, which never + * parses, still names `view-container-shape` and + * `list-view-filters-in-views-mode`. Plus the plain fact that `os lint` has no + * parsed stack to give. + * + * So: read `normalized` as "does not REQUIRE a parsed stack", never as "is + * guaranteed to see pre-parse evidence". A new rule whose only evidence is a key + * a strict schema rejects is a rule that will never fire — put the diagnostic in + * the schema instead, where #4001 already puts it. */ export type AuthoringRuleInputTier = 'normalized' | 'parsed'; @@ -349,8 +390,12 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ })), }, // ADR-0053 — `userFilters`/`quickFilters` on an object list view ("views" - // mode) are silently dropped: `ObjectListViewSchema` omits them, so this must - // read the pre-parse tier or the evidence is already gone. + // mode). NOT "silently dropped" any more: since #4001 `ObjectListViewSchema` + // is strict and refuses `quickFilters` by name, and `ObjectUserFiltersSchema` + // refuses `element: 'tabs'` by enum — measured under #6073, `defineStack` + // THROWS on both. `normalized` here therefore means "needs no parsed stack" + // (so `os lint`, which never parses, can run it), not "sees evidence the + // parse would have eaten". { name: 'validateListViewMode', tier: 'gating', @@ -381,9 +426,13 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ surfaceReason: RUNTIME_OBJECT_WRITES_P2, run: (stack) => validateFunctionalCompleteness(stack), }, - // A flat list-view object in `views: []` parses to an EMPTY container - // (ViewSchema strips unknown keys): the schema step passes, zero views - // register, and the Console renders nothing. Pre-parse for the same reason. + // A view container in `views: []` that registers zero views: nothing appears + // in the Console, and the schema step cannot tell it from an intentionally + // empty one. The FLAT-list-view arm no longer needs this tier — `ViewSchema` + // went strict at #4001, so `defineStack` now REFUSES `{ name, type, columns, + // data }` by name with the wrap-it hint (measured under #6073); the arm that + // still needs a rule is the all-slots-empty container, whose keys are all + // declared and which survives the parse untouched. { name: 'validateViewContainers', tier: 'gating', @@ -694,9 +743,16 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [ run: (stack) => validateSeedStateMachine(stack), }, // ADR-0089 D3b — deprecated visibility aliases and a mis-layered binding root, - // plus (#6128) the bare-identifier gate. Pre-parse: the schema folds - // `visibleOn`/`visibility` into `visibleWhen` during parse, so the alias the - // author wrote is gone from `result.data`. + // plus (#6128) the bare-identifier gate. This entry used to read "pre-parse: + // the schema folds `visibleOn`/`visibility` into `visibleWhen` during parse, + // so the alias the author wrote is gone from `result.data`". Measured false + // at #6073: the ADR-0087 D2 conversions do that fold INSIDE + // `normalizeStackInput`, one layer BEFORE this tier, so on every spec-valid + // alias site `visibility-alias-deprecated` reports zero here too — see #6318, + // which carries the per-site table and the retire-or-rewire question. The two + // predicate-VALUE rules (`visibility-bare-identifier`, + // `visibility-root-mislayered`) are unaffected: the value moves into + // `visibleWhen` intact and both still report on this tier. // // `gating` since #6128: `visibility-bare-identifier` emits `error`. The two // ADR-0089 rules stay advisory findings within it — the tier is a property of diff --git a/packages/lint/src/validate-list-view-mode.ts b/packages/lint/src/validate-list-view-mode.ts index ceb2da31dc..425f796b77 100644 --- a/packages/lint/src/validate-list-view-mode.ts +++ b/packages/lint/src/validate-list-view-mode.ts @@ -12,13 +12,27 @@ // A `dropdown` (value-chip) `userFilters` IS allowed on object views since the // ADR-0047 amendment (framework #2679 / objectui #2338) and is NOT flagged. // -// Runs PRE-parse (on the normalizeStackInput output, before the -// ObjectStackDefinition parse): the object-list schema (ObjectListViewSchema) -// narrows `userFilters` to ObjectUserFiltersSchema (dropdown/toggle only), so a -// post-parse stack has already had a `tabs` user-filter stripped and this rule -// would never see it. The layering is deliberate — tsc rejects it at author -// time, the schema strips it at runtime (no throw, back-compat), and this rule -// reports it at `os validate` with a fix hint. See objectui #2338 and ADR-0047. +// Registered `input: 'normalized'` — which means "needs no PARSED stack", so +// `os lint` (which never parses) can run it and its findings survive an +// unrelated schema error that would stop the parse. +// +// ## It is NOT the last line of defence any more (#6073) +// +// This header used to say the schema "strips a `tabs` user-filter at runtime +// (no throw, back-compat)" and that a post-parse stack would therefore have +// lost the evidence. Measured false at #6073: since #4001 `ObjectListViewSchema` +// is strict and refuses `quickFilters` BY NAME (with the +// `quickFilters` → `userFilters` suggestion), and `ObjectUserFiltersSchema` +// refuses `element: 'tabs'` by enum ("Expected one of: dropdown, toggle"). +// `defineStack` throws on both, so a TS config carrying either never loads — +// `os lint` and `os validate` report the schema's message, not this rule's. +// +// The rule still earns its place on the doors the strict parse never sees: +// `os lint` on a raw object-literal config (measured: it names +// `list-view-filters-in-views-mode` twice where `os validate` stops at the +// schema step), `defineStack(x, { strict: false })`, and direct API callers. +// tsc rejects it at author time on top of all of that. See objectui #2338 and +// ADR-0047. export type ListViewModeSeverity = 'error' | 'warning'; diff --git a/packages/lint/src/validate-view-containers.ts b/packages/lint/src/validate-view-containers.ts index de93796070..a60ffd18db 100644 --- a/packages/lint/src/validate-view-containers.ts +++ b/packages/lint/src/validate-view-containers.ts @@ -3,17 +3,34 @@ // Build-time guardrail for the `defineView` container shape. // // A pure `(stack) => Finding[]` rule (ADR-0019), run from `os validate`. It -// catches the "flat view object" authoring mistake the schema alone cannot -// surface: `ViewSchema` is a container (`{ list, form, listViews, formViews }`) -// whose slots are all optional, and Zod strips unknown keys — so a flat list -// view (`{ name: 'all_tasks', label, type: 'grid', columns: [...] }`) parses -// to an EMPTY container. The stack validates, the loader finds nothing to +// catches the container that registers ZERO views: `ViewSchema` is a container +// (`{ list, form, listViews, formViews }`) whose slots are all optional, so a +// container with none of them set is schema-valid, the loader finds nothing to // expand, and the Console silently renders no view (no switcher entry). The // third-party 15.1 evaluation hit exactly this via the old docs. // -// Runs PRE-parse (on the normalizeStackInput output, before the -// ObjectStackDefinition parse): post-parse the flat keys are already stripped -// and the mistake is indistinguishable from an intentionally empty container. +// ## The flat-list-view arm is now the SCHEMA's verdict, not this rule's (#6073) +// +// This header used to say `ViewSchema` "strips unknown keys — so a flat list +// view (`{ name: 'all_tasks', label, type: 'grid', columns: [...] }`) parses to +// an EMPTY container", and that the rule therefore had to run pre-parse. +// Measured false at #6073: `ViewSchema` went `.strict()` at #4001. `defineStack` +// now THROWS on that shape, naming `type` / `data` / `columns` and printing the +// wrap-it-in-defineView fix; `os lint` and `os validate` on an example app +// carrying it both refuse the config at LOAD, before any rule runs. `defineView` +// refuses it a step earlier still (`view.zod.ts:1951`, viewCount === 0). +// +// So `input: 'normalized'` in the registry means "this rule needs no PARSED +// stack" — which is what lets `os lint` (which never parses) run it, and what +// keeps its findings alive when an unrelated schema error stops the parse. It +// does NOT mean the rule is the only thing standing between the author and the +// flat-view mistake; the schema is, and it says it better. +// +// The `looksFlat` branch below is kept deliberately: it still fires on the +// non-`defineStack` doors the strict parse never sees — `os lint` on a raw +// object-literal config (measured: reports `view-container-shape` where +// `os validate` stops at the schema step), `defineStack(x, { strict: false })`, +// and direct API callers. // // Independent ViewItems (`viewKind` + `config`) are legal `views: []` entries // (the loader registers them as-is) and are not flagged. diff --git a/packages/lint/src/validate-visibility-predicates.ts b/packages/lint/src/validate-visibility-predicates.ts index a10dbf24b8..56a09f06ab 100644 --- a/packages/lint/src/validate-visibility-predicates.ts +++ b/packages/lint/src/validate-visibility-predicates.ts @@ -7,10 +7,26 @@ * canonical key **`visibleWhen`** across data fields, view form sections/fields, * and page components. The deprecated spellings — `visibleOn` (view form) and * `visibility` (page component) — stay accepted and are folded into `visibleWhen` - * at the schema boundary (a zod `.transform()`). Because that fold happens during - * `parse()`, the aliases are gone from the *parsed* stack — so this rule runs on - * the **pre-parse** (normalized) stack, exactly like `validate-list-view-mode`, - * to see what the author actually wrote. + * at the schema boundary (a zod `.transform()`). + * + * **The fold does NOT happen during `parse()` (measured, #6073).** This header + * used to say it did, and that running on the pre-parse (normalized) stack was + * therefore enough to see what the author wrote. It is not: the ADR-0087 D2 + * conversions `view-visibleOn-to-visibleWhen` and + * `page-component-visibility-to-visibleWhen` rename the key INSIDE + * `normalizeStackInput` — whose output *is* the normalized tier. On all three + * spec-valid alias sites (`views[].form.*`, `views[].formViews.*`, + * `pages[].regions[].components[]`) `visibility-alias-deprecated` therefore + * reports ZERO through every CLI door, `os lint` on a raw config included. The + * only shape on which it still fires is `views[].sections[]` — the shape the + * unit tests below use, and the one strict `ViewSchema` refuses. The author is + * not left silent (the D2 conversion emits its own, better-worded notice naming + * the protocol-16 retirement), so nothing is broken for a user today; #6318 + * carries the per-site table and the retire-or-rewire question. + * + * The other two rules in this file judge the predicate's **value**, which the + * fold carries into `visibleWhen` intact — both still report normally on the + * normalized tier, and neither is affected by the above. * * Two advisory rules (both `warning` — nothing is broken, the alias still works * and a mis-rooted predicate just never matches) plus one **gating** rule