diff --git a/.changeset/form-layout-view-container-ladder.md b/.changeset/form-layout-view-container-ladder.md new file mode 100644 index 0000000000..bd00cd98e0 --- /dev/null +++ b/.changeset/form-layout-view-container-ladder.md @@ -0,0 +1,55 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): `validateFormLayout` walks the view CONTAINER ladder, so both its rules stop reporting clean on every real app (#6251) + +`form-field-unknown` and `absolute-colspan-discouraged` read a `sections` array +off the **`views[]` entry itself** and skipped everything else. But a `views[]` +entry is a view CONTAINER, not a view: `ViewSchema` declares exactly `name` / +`label` / `object` / `list` / `form` / `listViews` / `formViews`, and form +sections live one level down, under `form` and each `formViews.`. So the +one shape the traversal read is the one shape strict `ViewSchema` **refuses** — +measured, `unrecognized_keys` naming `sections` — and the shapes every app +actually ships were never inspected at all. + +Measured on the three shipped example apps, before and after: `app-showcase`, +`app-crm` and `app-todo` carry **0** form sites at the entry root and **14** +under `form` / `formViews.`. The old traversal therefore had nothing to +read on any of them, and reported clean for that reason — the "ghost check" +shape (#4984 / #5009): a rule that is green because it never read anything is +worse than no rule, because it occupies the slot that would otherwise look +empty. + +One broken form, three placements, before → after: + +| placement | before | after | +| --- | --- | --- | +| `views[0].sections` (entry IS a bare form view) | reports | reports | +| `views[0].form.sections` (container default form) | silent | reports | +| `views[0].formViews.edit.sections` (named form view) | silent | reports | + +What changed, precisely: + +- The traversal is the one `validate-visibility-predicates.ts` landed in #6248 + for the identical hole on the sibling rule — copied, not re-derived, so two + rules on one surface cannot drift apart about which forms exist. `list` / + `listViews.` are `ObjectListViewSchema` and carry no `sections`, so they + are deliberately not walked; `objects[].views` stays out because + `object.zod.ts` tombstones that key by name. +- The legacy `groups` bucket (`FormSectionSchema[]`, the documented alias of + `sections`) is read too. Measured: it is **not** folded into `sections` at + parse, so a `groups`-authored form was a second silent shape. +- A finding names its sub-container — `view "contact_views" · formViews.create` + — because an artifact-emitted container carries neither `name` nor `object`, + and without it two forms under one view were indistinguishable. +- A sub-container inherits the container's object binding when it declares no + `data.object` of its own, resolved through the same `objectName` → `object` → + `data.object` ladder the other view-walking rules in this package use. +- A map-shaped `views` reports at the key it sits at (`views.contact_views.…`) + rather than a synthetic index, so a finding stays usable as an edit target. + +Both rules remain advisory `warning`s and their messages, hints and severities +are unchanged. No new finding appeared on any example app, so nothing that was +green goes red on existing metadata — what changes is that a form defect in the +places apps actually put forms is now reported instead of silently passed. diff --git a/packages/lint/src/validate-form-layout.test.ts b/packages/lint/src/validate-form-layout.test.ts index e0fcb1b5d1..d6f71d77b6 100644 --- a/packages/lint/src/validate-form-layout.test.ts +++ b/packages/lint/src/validate-form-layout.test.ts @@ -1,11 +1,14 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { defineStack, normalizeStackInput } from '@objectstack/spec'; import { validateFormLayout, FORM_FIELD_UNKNOWN, FORM_COLSPAN_ABSOLUTE, -} from './validate-form-layout'; +} from './validate-form-layout.js'; + +type AnyRec = Record; const objects = [ { name: 'contract', fields: { name: {}, amount: {}, status: {}, notes: {} } }, @@ -112,3 +115,222 @@ describe('validateFormLayout (#2578)', () => { expect(validateFormLayout({ views: [], objects: [] })).toEqual([]); }); }); + +// ─────────────────────────────────────────────────────────────────────────── +// #6251 — `views[]` is a view CONTAINER, and both rules above were unreachable +// on the shape real apps actually ship. +// +// The measurement that opened the issue: one broken form, three placements. +// Only the placement the strict schema REFUSES was being read, so an app whose +// forms all live under `form` / `formViews.` — i.e. every app — got a +// clean report from a rule that had read nothing at all. That is the ghost +// check #4984 / #5009 name: green because nothing was inspected. +// ─────────────────────────────────────────────────────────────────────────── + +/** The one broken form, reused verbatim in every placement below. */ +const brokenForm = { + data: { provider: 'object', object: 'contract' }, + sections: [{ columns: 2, fields: ['name', 'ghost_field'] }], +}; + +describe('#6251 — the view CONTAINER ladder', () => { + it('reports the SAME broken form in all three placements', () => { + const at = (view: AnyRec) => + validateFormLayout({ objects, views: [view] }).map((f) => `${f.rule}@${f.path}`); + + // (1) the entry IS a bare form view — the only shape read before #6251. + expect(at({ name: 'contract_form', ...brokenForm })).toEqual([ + `${FORM_FIELD_UNKNOWN}@views[0].sections[0].fields[1]`, + ]); + // (2) the container's DEFAULT form. + expect(at({ name: 'contract_views', object: 'contract', form: brokenForm })).toEqual([ + `${FORM_FIELD_UNKNOWN}@views[0].form.sections[0].fields[1]`, + ]); + // (3) a NAMED form view — where `os build` on app-showcase actually puts them. + expect(at({ name: 'contract_views', object: 'contract', formViews: { edit: brokenForm } })).toEqual([ + `${FORM_FIELD_UNKNOWN}@views[0].formViews.edit.sections[0].fields[1]`, + ]); + }); + + it('names the sub-container in `where`, so two forms under one view are distinguishable', () => { + const findings = validateFormLayout({ + objects, + views: [{ + name: 'contract_views', + object: 'contract', + form: brokenForm, + formViews: { edit: brokenForm, create: brokenForm }, + }], + }); + expect(findings.map((f) => f.where)).toEqual([ + 'view "contract_views" · form', + 'view "contract_views" · formViews.edit', + 'view "contract_views" · formViews.create', + ]); + }); + + it('a sub-container INHERITS the container binding when it declares none', () => { + // The canonical container carries `object`; a `formViews.` entry that + // omits its own `data.object` still renders against that object, so a + // dangling field reference there is as real as anywhere else. + const findings = validateFormLayout({ + objects, + views: [{ + name: 'contract_views', + object: 'contract', + formViews: { edit: { sections: [{ fields: ['ghost_inherited'] }] } }, + }], + }); + expect(findings.map((f) => f.rule)).toEqual([FORM_FIELD_UNKNOWN]); + expect(findings[0].message).toContain('ghost_inherited'); + expect(findings[0].message).toContain('"contract"'); + }); + + it('reads the legacy `groups` bucket too — measured NOT folded into `sections` at parse', () => { + const findings = validateFormLayout({ + objects, + views: [{ + name: 'contract_views', + object: 'contract', + form: { data: { object: 'contract' }, groups: [{ fields: ['name', { field: 'ghost_g', colSpan: 3 }] }] }, + }], + }); + expect(findings.map((f) => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_FIELD_UNKNOWN}@views[0].form.groups[0].fields[1]`, + `${FORM_COLSPAN_ABSOLUTE}@views[0].form.groups[0].fields[1].colSpan`, + ]); + }); + + it('reports a map-shaped `views` at the key it sits at, not a synthetic index', () => { + const findings = validateFormLayout({ + objects, + views: { contract_views: { object: 'contract', formViews: { edit: brokenForm } } }, + }); + expect(findings.map((f) => f.path)).toEqual(['views.contract_views.formViews.edit.sections[0].fields[1]']); + }); + + // NEGATIVE polarity — this one cannot go red when the traversal is reverted + // (a narrower walk trivially satisfies "does not walk list views"). It is + // here to pin the SCHEMA fact, not the fix: `list` / `listViews.` are + // `ObjectListViewSchema`, which declares no `sections`, so a `sections` key + // there is not a form and must not be judged as one. + it('does not walk `list` / `listViews.*` (they carry no sections by schema)', () => { + expect(validateFormLayout({ + objects, + views: [{ name: 'contract_views', object: 'contract', list: brokenForm, listViews: { all: brokenForm } }], + })).toEqual([]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// The anti-ghost pin: the rule must fire on a stack built through the REAL +// authoring door, not only on a hand-shaped object literal. +// +// `cliTierFor` is exactly what `os validate` / `os compile` hand the registry: +// `defineStack` (which Zod-PARSES) followed by `normalizeStackInput`. So a +// fixture that survives it is a shape an author can really ship — and a rule +// that reports on it is really reachable. Without this, "the fix works" could +// still mean "the fix works on shapes the schema refuses", which is how this +// rule was silently dead for four minor versions. +// ─────────────────────────────────────────────────────────────────────────── + +/** `defineStack` warns on the D2 conversion channel; keep test output clean. */ +function quietly(fn: () => T): { value?: T; error?: Error } { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + return { value: fn() }; + } catch (e) { + return { error: e as Error }; + } finally { + spy.mockRestore(); + } +} + +const cliTierFor = (stack: AnyRec): AnyRec => + normalizeStackInput(defineStack(stack as never) as unknown as AnyRec); + +describe('#6251 — reachable on a REAL parsed app stack', () => { + const manifest = { + id: 'com.example.formlayout', + namespace: 'fl', + version: '1.0.0', + type: 'app', + name: 'Form Layout Probe', + engines: { protocol: '^17' }, + }; + + const data = { provider: 'object' as const, object: 'fl_contact' }; + + /** + * The container ladder copied from `examples/app-showcase/src/ui/views/ + * contact.view.ts` — default `form` grouped into sections, plus a sparse + * `formViews.create` override — with one dangling field planted in each. + */ + const appShape: AnyRec = { + manifest, + objects: [{ + name: 'fl_contact', + label: 'Contact', + fields: { + name: { type: 'text', label: 'Name' }, + email: { type: 'email', label: 'Email' }, + phone: { type: 'phone', label: 'Phone' }, + }, + }], + views: [{ + name: 'fl_contact', + object: 'fl_contact', + list: { label: 'Contacts', type: 'grid', data, columns: [{ field: 'name' }] }, + form: { + type: 'simple', + data, + sections: [{ name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'ghost_default'] }], + }, + formViews: { + create: { + type: 'simple', + data, + title: 'New contact', + sections: [{ label: 'Who is this?', columns: 1, fields: ['name', { field: 'ghost_named', colSpan: 2 }] }], + }, + }, + }], + }; + + it('the fixture is a shape the strict schema ACCEPTS (so the pin is not testing a rejected stack)', () => { + const { error, value } = quietly(() => cliTierFor(structuredClone(appShape))); + expect(error).toBeUndefined(); + // And it really is the container shape: no `sections` at the entry root. + const view = (value!.views as AnyRec[])[0]; + expect(Object.keys(view).sort()).toEqual(['form', 'formViews', 'list', 'name', 'object']); + expect(view.sections).toBeUndefined(); + }); + + it('reports every planted defect on that stack — this is the assertion #6251 exists for', () => { + const { value } = quietly(() => cliTierFor(structuredClone(appShape))); + expect(validateFormLayout(value!).map((f) => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_FIELD_UNKNOWN}@views[0].form.sections[0].fields[2]`, + `${FORM_FIELD_UNKNOWN}@views[0].formViews.create.sections[0].fields[1]`, + `${FORM_COLSPAN_ABSOLUTE}@views[0].formViews.create.sections[0].fields[1].colSpan`, + ]); + }); + + // EMPTY-GREEN, declared. Revert the container ladder and this test still + // passes — because nothing was read, not because nothing is wrong. It is kept + // (a false-positive guard is worth having) but it is only meaningful PAIRED + // with the test above, which proves on the same fixture family that the + // traversal does read these sites. If that one is ever weakened, this one + // stops guarding anything; do not treat it as independent cover. + it('a CLEAN app stack of the same shape reports nothing — the fix adds no false positives', () => { + const clean = structuredClone(appShape); + const view = (clean.views as AnyRec[])[0]; + (view.form as AnyRec).sections = [{ name: 'contact', label: 'Contact', columns: 2, fields: ['name', 'email', 'phone'] }]; + (view.formViews as AnyRec).create = { + type: 'simple', data, title: 'New contact', + sections: [{ label: 'Who is this?', columns: 1, fields: ['name', { field: 'email', span: 'full' }] }], + }; + const { error, value } = quietly(() => cliTierFor(clean)); + expect(error).toBeUndefined(); + expect(validateFormLayout(value!)).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-form-layout.ts b/packages/lint/src/validate-form-layout.ts index f009b43597..c8ce57880a 100644 --- a/packages/lint/src/validate-form-layout.ts +++ b/packages/lint/src/validate-form-layout.ts @@ -20,9 +20,13 @@ * span only lines up at the one width the author imagined; the renderer * clamps it. The robust primitive is the relative `span: 'full'`. * - * Scope: top-level form `views` (a `sections` array). Forms embedded inside - * page component trees are a follow-up — the walker deliberately stays shallow - * so it never guesses at an arbitrary component's object binding. + * Scope: every form view reachable from a `views[]` entry — the entry itself + * when it IS a bare form view, plus the container's default `form` and each + * `formViews.` (see {@link formViewSites} for why reading only the first + * shape left both rules reporting clean on real app metadata, #6251). Forms + * embedded inside page component trees are a follow-up — the walker + * deliberately stays shallow so it never guesses at an arbitrary component's + * object binding. */ export const FORM_FIELD_UNKNOWN = 'form-field-unknown'; @@ -35,9 +39,9 @@ export interface FormLayoutFinding { severity: FormLayoutSeverity; /** Diagnostic rule id, e.g. `form-field-unknown`. */ rule: string; - /** Human-readable location, e.g. `view "contract_form"`. */ + /** Human-readable location, e.g. `view "contract_form" · formViews.create`. */ where: string; - /** Config path, e.g. `views[2].sections[0].fields[3]`. */ + /** Config path, e.g. `views[2].formViews.create.sections[0].fields[3]`. */ path: string; /** What is wrong. */ message: string; @@ -56,6 +60,110 @@ function asArray(v: unknown): AnyRec[] { return []; } +function isRec(v: unknown): v is AnyRec { + return !!v && typeof v === 'object' && !Array.isArray(v); +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** + * Every record in a collection authored either as an array or as a name-keyed + * map, each with its config PATH — `views[2]` for the array shape, + * `views.contact_views` for the map. Findings here are consumed as edit targets + * (`os lint --json`, Studio's finding renderer), so a map-shaped collection must + * not report a synthetic index nobody can look up. Same helper, same reasoning + * as `validate-visibility-predicates.ts` and `validate-translatable-sections.ts`. + */ +function collectionEntries(v: unknown, base: string): Array<{ rec: AnyRec; path: string }> { + if (Array.isArray(v)) { + const out: Array<{ rec: AnyRec; path: string }> = []; + for (let i = 0; i < v.length; i++) { + if (isRec(v[i])) out.push({ rec: v[i] as AnyRec, path: `${base}[${i}]` }); + } + return out; + } + if (isRec(v)) { + return Object.entries(v) + .filter(([, def]) => isRec(def)) + .map(([name, def]) => ({ rec: { name, ...(def as AnyRec) }, path: `${base}.${name}` })); + } + return []; +} + +/** + * Every FORM VIEW reachable from one `views[]` entry, with the path each sits at. + * + * **Copied from `validate-visibility-predicates.ts`'s `formViewSites` (#6248)** + * rather than re-derived: that file fixed this exact traversal hole on the + * sibling rule one PR earlier, and a second hand-rolled ladder is how two rules + * on one surface start disagreeing about which forms exist. The only thing added + * here is the object binding each site inherits (below) — this rule resolves a + * field reference, the visibility rules do not. + * + * Two shapes, and reading only the first is how BOTH rules in this file were + * dead on real app metadata until #6251 measured it. `os build` on + * `examples/app-showcase` emits its form sections at + * `views[0].formViews.edit.sections[…]`; the traversal read `views[0].sections`, + * found nothing, and reported clean on a stack that DOES carry form sections: + * + * - **View CONTAINER** (the runtime app shape). `ViewSchema` declares exactly + * `name` / `label` / `object` / `list` / `form` / `listViews` / `formViews` + * (`view.zod.ts:1890-1903` — the strict error map spells the container's own + * keys out in prose). Form sections therefore live one level down, under + * `form` and each `formViews.`. + * - **A bare FORM VIEW** (`FormViewSchema`, `view.zod.ts:1623-1624`), whose + * `sections` / `groups` sit at the top. + * + * `list` / `listViews.` are `ObjectListViewSchema` + * (`view.zod.ts:1838` — `ListViewSchema` minus `userFilters`) and carry no + * `sections` at all, so they are deliberately NOT walked. This is the one point + * where the other in-repo ladder, `validate-translatable-sections.ts`'s + * `collectViewSites`, is wider: it also visits `listViews.*.sections`. Measured + * against the schema, that rung can only ever read `undefined` — it costs + * nothing there and buys nothing here, so the narrower #6248 ladder is the one + * copied. Both agree on every rung that can hold a section. + * + * `objects[].views` is deliberately absent for the reason #6248 states: + * `object.zod.ts:1833` tombstones the key by name ("`views` is not an + * ObjectSchema field"), so a branch keyed on it could only fire for stacks the + * schema already rejects — the phantom check #4984 / #5017 removed elsewhere. + * + * The bare-form site (the entry itself) is NOT such a phantom, and the + * distinction is worth keeping straight: strict `ViewSchema` refuses a `views[]` + * entry carrying root `sections` — measured, `unrecognized_keys` naming + * `sections` — so on a parsed `defineStack` config only the container rungs can + * fire. But this rule is registered `input: 'parsed'`, and `os lint` never + * parses: `runAuthoringRules` hands `parsed` rules the NORMALIZED stack, where a + * raw (non-`defineStack`) config's root `sections` is still present and still + * the author's mistake to hear about. + */ +function formViewSites( + view: AnyRec, + basePath: string, +): Array<{ form: AnyRec; path: string; surface: string }> { + // `surface` names the sub-container in the human-readable `where`. It earns + // its place on exactly the shape this traversal was extended for: a runtime + // container carries neither `name` nor `object` in the emitted artifact, so + // without it every finding under one view reads `view "views[0]"` and the + // author cannot tell the `edit` form from the `create` one. + const sites = [{ form: view, path: basePath, surface: '' }]; + const dflt = view.form; + if (isRec(dflt)) { + sites.push({ form: dflt, path: `${basePath}.form`, surface: 'form' }); + } + const named = view.formViews; + if (isRec(named)) { + for (const [key, sub] of Object.entries(named)) { + if (isRec(sub)) { + sites.push({ form: sub, path: `${basePath}.formViews.${key}`, surface: `formViews.${key}` }); + } + } + } + return sites; +} + /** A section field entry is either a bare field name or `{ field, colSpan, … }`. */ function fieldNameOf(entry: unknown): string | null { if (typeof entry === 'string') return entry.length > 0 ? entry : null; @@ -66,13 +174,30 @@ function fieldNameOf(entry: unknown): string | null { return null; } -/** The object a form view binds to: `data.object` (canonical) or `objectName`. */ +/** + * The object a view — or one of its sub-containers — binds to, across the shapes + * it is authored in. + * + * The ladder is `objectName` → `object` → `data.object`, identical to + * `validate-translation-references.ts` and `validate-translatable-sections.ts`'s + * `viewObjectName` (and to the CLI i18n walker's), so all of them agree on which + * object a form belongs to. On the canonical container shape the binding lives + * INSIDE the sub-container (`form.data.object`) while the container itself + * carries `object`, which is why the caller resolves the site first and falls + * back to the container — a record-level lookup alone resolves to nothing on the + * shape real apps ship. + * + * `name` is deliberately NOT a rung. A stack-level container's `name` may be the + * object name (`view.zod.ts` says so for object-scoped containers), but a form + * view's `name` is its own — `contract_form`, not `contract` — and reading it + * here would bind the wrong object and report every field on the form as unknown. + */ function boundObject(view: AnyRec): string | undefined { - const data = view.data; - if (data && typeof data === 'object' && typeof (data as AnyRec).object === 'string') { - return (data as AnyRec).object as string; - } - return typeof view.objectName === 'string' ? (view.objectName as string) : undefined; + return ( + strName(view.objectName) ?? + strName(view.object) ?? + (isRec(view.data) ? strName(view.data.object) : undefined) + ); } /** @@ -93,64 +218,71 @@ export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { objectFields.set(name, new Set(fields)); } - const views = asArray(stack.views); - for (let i = 0; i < views.length; i++) { - const view = views[i]; - if (!view || typeof view !== 'object') continue; - const sections = Array.isArray(view.sections) ? view.sections : null; - if (!sections) continue; // only form views carry a sections array - - const viewName = typeof view.name === 'string' ? view.name : `(view ${i})`; - const objName = boundObject(view); - // Only reference-check when the bound object resolves; otherwise we can't. - const known = objName ? objectFields.get(objName) : undefined; - const where = `view "${viewName}"`; - const base = `views[${i}]`; - - for (let s = 0; s < sections.length; s++) { - const sec = sections[s]; - const secFields = sec && typeof sec === 'object' && Array.isArray((sec as AnyRec).fields) - ? ((sec as AnyRec).fields as unknown[]) - : []; - for (let f = 0; f < secFields.length; f++) { - const entry = secFields[f]; - const fname = fieldNameOf(entry); - const fpath = `${base}.sections[${s}].fields[${f}]`; - - // ── (a) section field references a real field on the bound object ── - if (fname && known && !known.has(fname)) { - findings.push({ - severity: 'warning', - rule: FORM_FIELD_UNKNOWN, - where, - path: fpath, - message: - `${viewName}: field "${fname}" is not a field on object "${objName}" — ` + - `it is silently skipped and never renders on the form`, - hint: - `Fix the field name, or add "${fname}" to ${objName}. Section field ` + - `references must match the object's field names exactly.`, - }); - } + for (const { rec: view, path: viewPath } of collectionEntries(stack.views, 'views')) { + // A container names itself with `name`, or binds with `object` — and an + // artifact-emitted one may carry neither, so the path is the last resort. + const viewName = strName(view.name) ?? strName(view.object) ?? viewPath; + const containerObject = boundObject(view); + + for (const site of formViewSites(view, viewPath)) { + // A sub-container declares its own binding (`form.data.object`) and + // otherwise inherits the container's — the resolution order every other + // view-walking rule in this package uses. + const objName = boundObject(site.form) ?? containerObject; + // Only reference-check when the bound object resolves; otherwise we can't. + const known = objName ? objectFields.get(objName) : undefined; + const where = site.surface ? `view "${viewName}" · ${site.surface}` : `view "${viewName}"`; + + // `sections` (canonical) and `groups` (legacy alias → sections, + // `view.zod.ts:1624`) both hold FormSection objects. Reading both is what + // #6248 does on this surface, for the same reason: a rule that judges only + // the canonical spelling is silent on the legacy one, which is exactly the + // half-coverage this issue is about. + for (const bucket of ['sections', 'groups'] as const) { + const sections = Array.isArray(site.form[bucket]) ? (site.form[bucket] as unknown[]) : []; + + for (let s = 0; s < sections.length; s++) { + const sec = sections[s]; + const secFields = isRec(sec) && Array.isArray(sec.fields) ? (sec.fields as unknown[]) : []; + for (let f = 0; f < secFields.length; f++) { + const entry = secFields[f]; + const fname = fieldNameOf(entry); + const fpath = `${site.path}.${bucket}[${s}].fields[${f}]`; + + // ── (a) section field references a real field on the bound object ── + if (fname && known && !known.has(fname)) { + findings.push({ + severity: 'warning', + rule: FORM_FIELD_UNKNOWN, + where, + path: fpath, + message: + `${viewName}: field "${fname}" is not a field on object "${objName}" — ` + + `it is silently skipped and never renders on the form`, + hint: + `Fix the field name, or add "${fname}" to ${objName}. Section field ` + + `references must match the object's field names exactly.`, + }); + } - // ── (b) absolute colSpan → steer to the surface-independent span ── - const colSpan = entry && typeof entry === 'object' && !Array.isArray(entry) - ? (entry as AnyRec).colSpan - : undefined; - if (colSpan != null) { - findings.push({ - severity: 'warning', - rule: FORM_COLSPAN_ABSOLUTE, - where, - path: `${fpath}.colSpan`, - message: - `${viewName}: field "${fname ?? '?'}" sets absolute colSpan ${String(colSpan)} — ` + - `the form's column count is derived per surface (mobile 1 / modal 2 / page 3-4), ` + - `so a fixed span only aligns at one width`, - hint: - `Prefer span: 'full' (whole row at any column count), or omit for auto ` + - `width. The renderer clamps colSpan to the current column count.`, - }); + // ── (b) absolute colSpan → steer to the surface-independent span ── + const colSpan = isRec(entry) ? entry.colSpan : undefined; + if (colSpan != null) { + findings.push({ + severity: 'warning', + rule: FORM_COLSPAN_ABSOLUTE, + where, + path: `${fpath}.colSpan`, + message: + `${viewName}: field "${fname ?? '?'}" sets absolute colSpan ${String(colSpan)} — ` + + `the form's column count is derived per surface (mobile 1 / modal 2 / page 3-4), ` + + `so a fixed span only aligns at one width`, + hint: + `Prefer span: 'full' (whole row at any column count), or omit for auto ` + + `width. The renderer clamps colSpan to the current column count.`, + }); + } + } } } }