From b1e626ee1639af928432bae69c3904bc84b8dc2b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 15:45:30 +0000 Subject: [PATCH 1/3] =?UTF-8?q?fix(lint):=20validateFormLayout=20=E8=B5=B0?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E5=AE=B9=E5=99=A8=E9=98=B6=E6=A2=AF,?= =?UTF-8?q?=E4=B8=A4=E6=9D=A1=E8=A7=84=E5=88=99=E4=B8=8D=E5=86=8D=E5=AF=B9?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20app=20=E5=85=A8=E7=9B=98=E6=8A=A5=E7=BB=BF?= =?UTF-8?q?=20(#6251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `form-field-unknown` / `absolute-colspan-discouraged` 只读 `views[]` 条目 自身的 `sections`,其余一律 `continue`。但 `views[]` 是视图**容器** —— `ViewSchema` 自有键只有 name/label/object/list/form/listViews/formViews, 表单 `sections` 在下一层的 `form` 与 `formViews.` 下。于是唯一被读 的那种形状,恰恰是严格 `ViewSchema` 会拒绝的形状(实测报 `unrecognized_keys` 并点名 `sections`),真实 app 出货的形状一个都没被检查。 三个 example app 实测:app-showcase / app-crm / app-todo 在条目根部有 0 个 表单站点,在 form / formViews. 下有 14 个 —— 旧遍历在它们身上无物可读, 报绿正是因为什么都没读到(#4984 / #5009 的幽灵检查族)。 遍历直接照抄 #6248 落在同包 `validate-visibility-predicates.ts` 里的 `formViewSites`,不另造第三套;list / listViews. 是 `ObjectListViewSchema`,按 schema 不带 sections,故不走;`objects[].views` 已被 `object.zod.ts` 具名墓碑化,同样不走。另补:legacy `groups` 桶(实测 parse 阶段并未折叠进 sections)、finding 的 `where` 标注子容器、子容器缺 `data.object` 时继承容器绑定、map 形态的 `views` 报在真实键上。 两条规则的严重级别、消息与提示一字未改;三个 example app 上新增 finding 数 为 0,即不引入误报。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- .../form-layout-view-container-ladder.md | 55 ++++ .../lint/src/validate-form-layout.test.ts | 218 +++++++++++++- packages/lint/src/validate-form-layout.ts | 268 +++++++++++++----- 3 files changed, 472 insertions(+), 69 deletions(-) create mode 100644 .changeset/form-layout-view-container-ladder.md 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..9393925f6e 100644 --- a/packages/lint/src/validate-form-layout.test.ts +++ b/packages/lint/src/validate-form-layout.test.ts @@ -1,12 +1,15 @@ // 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'; +type AnyRec = Record; + const objects = [ { name: 'contract', fields: { name: {}, amount: {}, status: {}, notes: {} } }, ]; @@ -112,3 +115,216 @@ 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`, + ]); + }); + + 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.`, + }); + } + } } } } From decb363f235e8326d1965598fc94754db441bbcc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 15:48:15 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(lint):=20=E6=A0=87=E6=B3=A8=20clean-st?= =?UTF-8?q?ack=20=E6=96=AD=E8=A8=80=E7=9A=84=E7=A9=BA=E7=BB=BF=E6=80=A7?= =?UTF-8?q?=E8=B4=A8=E4=B8=8E=E9=85=8D=E5=AF=B9=E4=BE=9D=E8=B5=96=20(#6251?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 反向验证声明 B 的空绿自查查出:「干净 app 栈报 0」在遍历被退回后照样通过 —— 因为什么都没读到,不是因为没有缺陷。断言保留(误报守卫仍有价值),但把它 只在与「真实 app 形状上确实报了」配对时才成立这件事写进文件,免得日后 后者被削弱而前者被当成独立保障。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- packages/lint/src/validate-form-layout.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/lint/src/validate-form-layout.test.ts b/packages/lint/src/validate-form-layout.test.ts index 9393925f6e..3bf8f2064c 100644 --- a/packages/lint/src/validate-form-layout.test.ts +++ b/packages/lint/src/validate-form-layout.test.ts @@ -315,6 +315,12 @@ describe('#6251 — reachable on a REAL parsed app stack', () => { ]); }); + // 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]; From 7d0f68b00c9c346884896e864934aa147ec15e8e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 16:10:36 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(lint):=20=E6=B5=8B=E8=AF=95=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E7=9A=84=E7=9B=B8=E5=AF=B9=E5=AF=BC=E5=85=A5=E8=A1=A5?= =?UTF-8?q?=20`.js`=20=E6=89=A9=E5=B1=95=E5=90=8D,=E4=BF=AE=E5=A4=8D=20Typ?= =?UTF-8?q?eScript=20Type=20Check=20=E9=97=A8=20(#6251)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 `check:type-check-debt` 把 `packages/lint` 的**测试层**重新纳入 tsc 后 测得 48 个 raw error,超过 TEST_DEBT 冻结的 42(+6)。包自己的 `pnpm typecheck` 看不到这个 —— 它的 tsconfig 把 `**/*.test.ts` 排除在外。 根因是一处 TS2835:`./validate-form-layout` 缺 `.js` 扩展名,在 `moduleResolution: node16` 下解析失败,导入即退化为 `any`,下游每个 `.map(f => …)` 都变成 TS7006。补上扩展名(本包其余测试文件本就都是这个写法, 这一处是异类)后该文件 9 个 error 全清,包的测试层从 48 降到 39 —— 低于 冻结值,ratchet 只降不升,按门的规则属 ℹ 而非 error。 TEST_DEBT 条目保持 42 不动:门明确「改进不必为记账付费」,且并发改动下 下调数字容易与他人的测量赛跑。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- packages/lint/src/validate-form-layout.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lint/src/validate-form-layout.test.ts b/packages/lint/src/validate-form-layout.test.ts index 3bf8f2064c..d6f71d77b6 100644 --- a/packages/lint/src/validate-form-layout.test.ts +++ b/packages/lint/src/validate-form-layout.test.ts @@ -6,7 +6,7 @@ import { validateFormLayout, FORM_FIELD_UNKNOWN, FORM_COLSPAN_ABSOLUTE, -} from './validate-form-layout'; +} from './validate-form-layout.js'; type AnyRec = Record;