From dd831edce770dbc65e6d367a151261dd59ff6bdb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 15:54:59 +0000 Subject: [PATCH 1/5] wip(spec,lint): section group reference form + reference diagnostics --- packages/lint/src/index.ts | 13 ++ packages/lint/src/object-field-groups.ts | 167 ++++++++++++++++ packages/lint/src/validate-form-layout.ts | 30 +++ .../lint/src/validate-page-field-bindings.ts | 54 ++++++ packages/spec/src/data/field-group-layout.ts | 15 ++ packages/spec/src/data/object.zod.ts | 13 +- .../src/shared/section-group-reference.ts | 178 ++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 57 +++++- packages/spec/src/ui/view.zod.ts | 101 +++++++++- 9 files changed, 620 insertions(+), 8 deletions(-) create mode 100644 packages/lint/src/object-field-groups.ts create mode 100644 packages/spec/src/shared/section-group-reference.ts diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index d20fa78693..2c1d5e7ad4 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -195,6 +195,7 @@ export { validateFormLayout, FORM_FIELD_UNKNOWN, FORM_COLSPAN_ABSOLUTE, + FORM_SECTION_GROUP_UNKNOWN, } from './validate-form-layout.js'; export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js'; @@ -418,9 +419,21 @@ export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN, PAGE_FIELD_UNPROVISIONED, + PAGE_SECTION_GROUP_UNKNOWN, } from './validate-page-field-bindings.js'; export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js'; +// [#13855] The shared field-group reference half both layout surfaces resolve +// against — exported so an out-of-repo consumer (cloud graph-lint, the AI +// authoring path) can ask the same question of the same index rather than +// rebuilding it from `objects[].fieldGroups` by hand. +export { + indexObjectFieldGroups, + sectionGroupRefs, + checkSectionGroupRefs, +} from './object-field-groups.js'; +export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js'; + export { validateComponentProps, COMPONENT_PROPS_UNKNOWN_KEY, diff --git a/packages/lint/src/object-field-groups.ts b/packages/lint/src/object-field-groups.ts new file mode 100644 index 0000000000..6779ac6870 --- /dev/null +++ b/packages/lint/src/object-field-groups.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Field-group REFERENCE integrity — the cross-schema half of #13855. + * + * A layout section may name a declared field group instead of enumerating its + * members: `{ group: 'contact_info' }` on a `record:details` section or on a + * form view's `sections[]`. Membership and presentation are then derived from + * the object's `fieldGroups` entry with that key (`deriveFieldGroupLayout`, + * ADR-0085 §5). + * + * The key names something on a DIFFERENT schema, so the spec door deliberately + * cannot answer whether it resolves — it takes any well-formed snake_case key, + * exactly as `UserFilterFieldSchema.field` does (*"must exist — checked by + * reference diagnostics"*). This module is that check's shared half: both + * host rules ask one question of one index rather than growing two answers that + * happen to agree. + * + * ## Why a dangling key must be reported at all + * + * `deriveFieldGroupLayout` resolves a section's members by looking the key up + * among the object's declared groups. A key that matches nothing yields no + * members, and since the reference form and the enumerated form are mutually + * exclusive at parse, the section has no other member source — so the WHOLE + * section silently disappears from the rendered page. That is the same + * silent-skip consequence the two host rules' field-existence findings carry, + * one grain up. + * + * ## Severity: `warning`, matching the family + * + * Both host rules declare `warning` for a dangling reference, for the reason + * their module notes give: the consumer degrades (it skips what it cannot + * resolve) rather than failing, and the `error` limb in + * `validate-page-field-bindings` is reserved for a reference that reaches a + * QUERY, where the silent result is indistinguishable from "there is no data". + * A section that does not render is loud to look at and touches no query, so it + * sits with its siblings rather than gating. (The judgement is stated here + * rather than left implicit because it is a judgement — see the PR body for + * #13855, where it is offered for contract review.) + */ + +type AnyRec = Record; + +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; +} + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (isRec(v)) return Object.entries(v).map(([name, def]) => ({ name, ...(isRec(def) ? def : {}) })); + return []; +} + +/** + * object name → its DECLARED field-group keys. + * + * Declared, not derived: `deriveFieldGroupLayout` drops a group no visible field + * joins, and a reference to a declared-but-empty group is a different (and + * data-dependent) finding from a reference to a group that was never declared. + * This index answers only the second question — the one with a closed oracle. + * + * `fieldGroups` is an ARRAY on `ObjectSchema`, and `asArray` additionally + * resolves the name-keyed map shape that raw (non-`defineStack`) metadata can + * carry, the same tolerance every other index in this package extends. + */ +export function indexObjectFieldGroups(stack: unknown): Map> { + const index = new Map>(); + if (!isRec(stack)) return index; + for (const obj of asArray(stack.objects)) { + const name = strName(obj.name); + if (!name) continue; + const keys = new Set(); + for (const group of asArray(obj.fieldGroups)) { + // `asArray` supplies `name` for the map shape; the declared key spelling + // is `key`, so read it first and fall back to the synthesized map key. + const key = strName(group.key) ?? strName(group.name); + if (key) keys.add(key); + } + index.set(name, keys); + } + return index; +} + +/** One `section.group` reference, with the path that located it. */ +export interface SectionGroupRef { + /** The referenced field-group key, as authored. */ + key: string; + /** Config path of the `group` key itself. */ + path: string; +} + +/** + * Pull `group` references out of a `sections`-shaped value. + * + * `sep` joins the index onto `basePath` the way the calling surface addresses + * itself — `.` for metadata config paths, and the react surface's ` › ` if it + * ever grows a section-bearing block. + */ +export function sectionGroupRefs(sections: unknown, basePath: string, sep = '.'): SectionGroupRef[] { + if (!Array.isArray(sections)) return []; + const out: SectionGroupRef[] = []; + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (!isRec(section)) continue; + const key = strName(section.group); + if (key) out.push({ key, path: `${basePath}[${i}]${sep}group` }); + } + return out; +} + +/** The finding shape both host rules already declare, structurally. */ +export interface SectionGroupFinding { + severity: 'warning'; + rule: string; + where: string; + path: string; + message: string; + hint: string; +} + +/** + * Check one batch of group references against `objectName`'s declared groups. + * + * Bails out when the object is not defined in this stack — it may come from + * another installed package, and a group cannot be judged on a schema we cannot + * see. That is the same skip {@link checkFieldRefs} takes, and for the same + * reason: silence about an unknowable object beats a finding the author cannot + * act on. + */ +export function checkSectionGroupRefs( + refs: readonly SectionGroupRef[], + objectName: string | undefined, + objectFieldGroups: ReadonlyMap>, + where: string, + rule: string, +): SectionGroupFinding[] { + const findings: SectionGroupFinding[] = []; + if (!objectName) return findings; + const declared = objectFieldGroups.get(objectName); + if (!declared) return findings; // cross-package object — unknowable here + for (const ref of refs) { + if (declared.has(ref.key)) continue; + findings.push({ + severity: 'warning', + rule, + where, + path: ref.path, + message: + `section references field group "${ref.key}", which object "${objectName}" does not ` + + 'declare — the section inherits its members from that group, so it resolves to no ' + + 'fields and the whole section silently does not render', + hint: + `Fix the key, or declare it: \`fieldGroups: [{ key: '${ref.key}', label: '…' }]\` on ` + + `${objectName}, with each member field carrying \`group: '${ref.key}'\`. ` + + (declared.size > 0 + ? `Declared groups on ${objectName}: ${[...declared].sort().join(', ')}.` + : `${objectName} declares no field groups at all, so no section on it can reference one ` + + 'yet — enumerate the members with `fields` until it does.'), + }); + } + return findings; +} diff --git a/packages/lint/src/validate-form-layout.ts b/packages/lint/src/validate-form-layout.ts index 8b1388e55e..68da1f742c 100644 --- a/packages/lint/src/validate-form-layout.ts +++ b/packages/lint/src/validate-form-layout.ts @@ -30,10 +30,25 @@ */ import { collectionEntries } from './collection-entries.js'; +// [#13855] The field-group reference question, shared with +// `validate-page-field-bindings` so both layout escape hatches resolve a +// `section.group` against one index and report the same way. +import { + checkSectionGroupRefs, + indexObjectFieldGroups, + sectionGroupRefs, +} from './object-field-groups.js'; import { formViewSites, viewObjectName } from './view-walk.js'; export const FORM_FIELD_UNKNOWN = 'form-field-unknown'; export const FORM_COLSPAN_ABSOLUTE = 'absolute-colspan-discouraged'; +/** + * [#13855] A section's `group` names a field group the bound object does not + * declare. The reference form inherits the section's whole membership from that + * group, so a dangling key leaves the section with no members and it does not + * render at all. Advisory like both rules above — see `object-field-groups.ts`. + */ +export const FORM_SECTION_GROUP_UNKNOWN = 'form-section-group-unknown'; export type FormLayoutSeverity = 'error' | 'warning'; @@ -115,6 +130,8 @@ export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { : []; objectFields.set(name, new Set(fields)); } + // [#13855] object name → its declared field-group keys, for `section.group`. + const objectFieldGroups = indexObjectFieldGroups(stack); for (const { rec: view, path: viewPath } of collectionEntries(stack.views, 'views')) { // A container names itself with `name`, or binds with `object` — and an @@ -143,6 +160,19 @@ export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { for (const bucket of ['sections', 'groups'] as const) { const sections = Array.isArray(site.view[bucket]) ? (site.view[bucket] as unknown[]) : []; + // ── (c) [#13855] a section's `group` names a declared field group ── + // Both buckets, for the reason (a) reads both: a rule that judges only + // the canonical spelling is silent on the legacy one. + findings.push( + ...checkSectionGroupRefs( + sectionGroupRefs(sections, `${site.path}.${bucket}`), + objName, + objectFieldGroups, + where, + FORM_SECTION_GROUP_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[]) : []; diff --git a/packages/lint/src/validate-page-field-bindings.ts b/packages/lint/src/validate-page-field-bindings.ts index 55ed8bfd78..704c7d4c19 100644 --- a/packages/lint/src/validate-page-field-bindings.ts +++ b/packages/lint/src/validate-page-field-bindings.ts @@ -66,6 +66,14 @@ export const PAGE_FIELD_UNKNOWN = 'page-field-unknown'; * #8116's severity reasoning, unchanged. */ export const PAGE_FIELD_UNPROVISIONED = 'page-field-unprovisioned'; +/** + * [#13855] A section's `group` names a field group the bound object does not + * declare. The reference form inherits the section's whole membership from that + * group, so a dangling key leaves the section with no members and it does not + * render at all. Always `warning` — see `object-field-groups.ts` for why this + * sits with the family's advisory findings rather than gating. + */ +export const PAGE_SECTION_GROUP_UNKNOWN = 'page-section-group-unknown'; export type PageFieldSeverity = 'error' | 'warning'; @@ -99,6 +107,14 @@ import { unprovisionedAnchorCause, unprovisionedAnchorHint, } from './system-fields.js'; +// [#13855] The field-group reference question, shared with `validate-form-layout` +// so both layout escape hatches resolve a `section.group` against one index. +import { + checkSectionGroupRefs, + indexObjectFieldGroups, + sectionGroupRefs, + type SectionGroupRef, +} from './object-field-groups.js'; function asArray(v: unknown): AnyRec[] { if (Array.isArray(v)) return v as AnyRec[]; @@ -263,6 +279,30 @@ export function componentFieldRefs( return refs; } +/** + * [#13855] The `group` references a component's section props hold — the same + * `nestedSections` list {@link componentFieldRefs} walks, asked the other + * question. Read from that one descriptor table on purpose: a component that + * grows sections is covered by both checks in one edit, instead of gaining the + * field check and silently missing the group one. + * + * `null` for a type with no descriptor, matching {@link componentFieldRefs}. + */ +export function componentSectionGroupRefs( + type: string, + props: AnyRec, + basePath: string, + sep = '.', +): SectionGroupRef[] | null { + const spec = COMPONENT_FIELD_SPECS[type]; + if (!spec) return null; + const refs: SectionGroupRef[] = []; + for (const key of spec.nestedSections ?? []) { + refs.push(...sectionGroupRefs(props[key], `${basePath}${sep}${key}`, sep)); + } + return refs; +} + /** A `record:related_list` props bag, split by which object each batch resolves against. */ export interface RelatedListFieldRefs { /** The related (child) object this list renders — `properties.objectName`. */ @@ -443,6 +483,8 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { // [#8340] The provenance index alongside the existence one — same keying, // asked on the path where the existence check stays silent. const unprovisionedAnchors = indexUnprovisionedAnchors(stack); + // [#13855] object name → its declared field-group keys, for `section.group`. + const objectFieldGroups = indexObjectFieldGroups(stack); const pages = asArray(stack.pages); for (let pi = 0; pi < pages.length; pi++) { @@ -476,6 +518,18 @@ export function validatePageFieldBindings(stack: AnyRec): PageFieldFinding[] { const refs = componentFieldRefs(type, props, base); if (!refs) continue; // unregistered / non-field component — skip silently checkRefs(refs, objectName, where); + + // [#13855] The other question a section can raise: not "is this field + // real?" but "is this GROUP real?". Same walk, same object binding. + findings.push( + ...checkSectionGroupRefs( + componentSectionGroupRefs(type, props, base) ?? [], + objectName, + objectFieldGroups, + where, + PAGE_SECTION_GROUP_UNKNOWN, + ), + ); } // ── interfaceConfig (list pages) ── diff --git a/packages/spec/src/data/field-group-layout.ts b/packages/spec/src/data/field-group-layout.ts index 85ded1c036..54baee44ae 100644 --- a/packages/spec/src/data/field-group-layout.ts +++ b/packages/spec/src/data/field-group-layout.ts @@ -34,6 +34,21 @@ * flat/auto layout. */ +/** + * The field-group KEY grammar — lowercase snake_case, one declaration. + * + * Declared here rather than inline on `ObjectFieldGroupSchema.key` because the + * key is now written on TWO surfaces: the group that DECLARES it + * (`ObjectFieldGroupSchema.key`) and the layout sections that REFERENCE it + * (`FormSectionSchema.group`, `RecordDetailsProps.sections[].group` — #13855). + * Two spellings of one grammar is the failure `identifiers.zod.ts` records from + * the other side: the reference surface would accept a key the declaring + * surface refuses, so the reference could never resolve and the section would + * render nothing with nothing reported. This module is the ADR-0085 §5 single + * source for the grouping semantics, so it is where the key's shape belongs. + */ +export const FIELD_GROUP_KEY_PATTERN = /^[a-z_][a-z0-9_]*$/; + /** Collapse behaviour of a derived section (mirrors ObjectFieldGroupSchema.collapse). */ export type FieldGroupCollapse = 'none' | 'expanded' | 'collapsed'; diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index f0e6512b72..bce89b9a24 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -15,6 +15,7 @@ import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { strictObject } from '../shared/strict-object'; import { ProtectionSchema } from '../shared/protection.zod'; import { retiredKey } from '../shared/retired-key'; +import { FIELD_GROUP_KEY_PATTERN } from './field-group-layout'; export const ApiMethod = z.enum([ 'get', 'list', // Read 'create', 'update', 'delete', // Write @@ -1157,10 +1158,16 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ 'Write `visibleWhen: ` — FALSE hides the whole group, header included.', }, }, { - /** Group key — referenced by `Field.group` to assign a field to this group. Must be snake_case. */ - key: z.string().regex(/^[a-z_][a-z0-9_]*$/, { + /** + * Group key — referenced by `Field.group` to assign a field to this group, + * and (since #13855) by a layout section's `group` to inherit the whole + * group. Must be snake_case; the grammar is single-sourced as + * `FIELD_GROUP_KEY_PATTERN` beside the derivation it belongs to, so the + * declaring surface and the referencing surfaces cannot drift apart. + */ + key: z.string().regex(FIELD_GROUP_KEY_PATTERN, { message: 'Field group key must be lowercase snake_case (e.g., "contact_info", "billing", "system")', - }).describe('Group machine key (snake_case). Referenced by Field.group.'), + }).describe('Group machine key (snake_case). Referenced by Field.group, and by a layout section\'s `group`.'), /** Human-readable label displayed as the group header. */ label: z.string().describe('Group display label'), diff --git a/packages/spec/src/shared/section-group-reference.ts b/packages/spec/src/shared/section-group-reference.ts new file mode 100644 index 0000000000..16e68e091a --- /dev/null +++ b/packages/spec/src/shared/section-group-reference.ts @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Section → field-group REFERENCE — the shared mixing rule for both layout + * escape hatches (#13855, maintainer ruling 2026-08-31: 「直接处理b」). + * + * ## What this closes + * + * ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled + * in ONE place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape + * hatches — a custom record page's `record:details` `properties.sections`, and a + * view-level `form.sections` — were whole-takeover shapes: every section + * enumerated its members by hand, with zero mechanical link back to the + * declaration. An author who reached for either had to hand-copy the same + * membership fact a second and third time, and every field added to the object + * afterwards made those copies quietly staler. Measured on a real app: three + * disagreeing groupings of one object, with the detail page missing two fields + * the form showed. + * + * A section may now name a declared group instead: `{ group: 'contact_info' }`. + * Membership and the group's own presentation (label, icon, description, + * collapse, `visibleWhen`, and the empty-group drop) come from + * `deriveFieldGroupLayout` — the section re-declares none of it. + * + * ## The mixing rule, in one place because it is the same rule twice + * + * The two section vocabularies are not identical (a form section has + * `visibleWhen`/`pane`; a detail section has `hideEmpty`/`showBorder`/ + * `headerColor`), but the RULE over them is, so it is declared once and each + * surface passes its own key names in. Two copies of a mixing rule is how one + * gets fixed and the other does not. + * + * 1. **A section declares its members exactly one way.** `group` and `fields` + * are mutually exclusive, and a section that declares NEITHER is refused — + * it has no body at all, and before this shape existed that was + * unrepresentable because `fields` was required. + * 2. **A group-referencing section carries no key the group already declares.** + * Not a precedence rule — the ABSENCE of one. Letting a section restate + * `label` / `collapse` / `visibleWhen` beside `group` would put group + * presentation back in section land, which is the one thing ADR-0085 §5 + * single-sources; and two writable spellings of one fact is the hand-copy + * this whole reference form exists to remove. Refused at parse with the + * pointer to the `fieldGroups` entry that owns the key. + * + * ⚠️ This is the CONSERVATIVE direction on purpose. Refusing now and + * allowing section-level overrides later is additive; shipping overrides and + * withdrawing them later is a breaking change. The alternative reading — + * section keys override the group's — is named in the PR body for contract + * review rather than decided here. + * 3. **Across sections, both kinds coexist in declared array order.** A + * group-referencing section occupies exactly one slot and expands in place + * to that group's derived members. Nothing about ordering changes: `sections` + * is still read top to bottom. + * + * ## What this module deliberately does NOT do + * + * It never resolves the key. A section schema cannot see the object's + * `fieldGroups` — that is a cross-schema reference, and this repo has one + * channel for those: reference diagnostics (the `UserFilterFieldSchema.field` + * precedent, *"must exist — checked by reference diagnostics"*). Parse accepts + * any well-formed key; `page-section-group-unknown` / `form-section-group-unknown` + * in `@objectstack/lint` report one that resolves to nothing. + */ + +import { z } from 'zod'; + +import { FIELD_GROUP_KEY_PATTERN } from '../data/field-group-layout'; + +/** + * The `group` key as a layout section writes it — the same grammar the + * declaring `ObjectFieldGroupSchema.key` enforces, read from the one pattern + * both share so the reference cannot accept a key the declaration refuses. + * + * Existence is NOT checked here (see the module note): a well-formed key that + * names no declared group parses, and reference diagnostics report it. + */ +export const SectionGroupKeySchema = z.string().regex(FIELD_GROUP_KEY_PATTERN, { + message: 'Field group key must be lowercase snake_case (e.g., "contact_info", "billing", "system")', +}); + +/** The section keys this rule reads, as any surface's section may carry them. */ +interface SectionLike { + group?: unknown; + fields?: unknown; + [key: string]: unknown; +} + +export interface SectionGroupReferenceOptions { + /** Prose name of the section surface, e.g. ``'this `record:details` section'``. */ + surface: string; + /** + * Keys whose value `deriveFieldGroupLayout` supplies from the group — refused + * beside `group`, whatever they are set to. + */ + derivedKeys: readonly string[]; + /** + * Derived keys carrying a schema `.default(false)`, so by the time an + * object-level refinement runs an authored `false` is indistinguishable from + * the default. Only `true` is refused — the same asymmetry, for the same + * reason, as the wizard step-key refusals in `view.zod.ts`. + */ + trueOnlyDerivedKeys?: readonly string[]; +} + +/** + * The shared mixing rule as a `superRefine` body. Attach to a section shape + * whose `group` and `fields` are both optional: + * + * ```ts + * strictObject({ … }, { group: SectionGroupKeySchema.optional(), fields: […].optional(), … }) + * .superRefine(sectionGroupReferenceRefinement({ surface: '…', derivedKeys: […] })) + * ``` + */ +export function sectionGroupReferenceRefinement( + options: SectionGroupReferenceOptions, +): (section: SectionLike, ctx: z.RefinementCtx) => void { + const { surface, derivedKeys, trueOnlyDerivedKeys = [] } = options; + return (section, ctx) => { + if (!section || typeof section !== 'object') return; + const groupKey = typeof section.group === 'string' ? section.group : undefined; + const hasGroup = groupKey !== undefined && groupKey.length > 0; + // PRESENCE, not non-emptiness: `fields: []` is an authored (if empty) + // enumeration and parsed before this key existed, so it must keep parsing. + const hasFields = Array.isArray(section.fields); + + if (hasGroup && hasFields) { + ctx.addIssue({ + code: 'custom', + path: ['group'], + message: + '`group` and `fields` are mutually exclusive on ' + surface + '. `group` DERIVES the ' + + "member list from the object's declared `fieldGroups` entry (`deriveFieldGroupLayout`, " + + 'ADR-0085 §5); `fields` enumerates it by hand. Writing both makes two sources for one ' + + 'fact — the hand-copy this reference form exists to remove, and the copy is what goes ' + + "stale as the object gains fields. Keep `group: '" + groupKey + "'` to inherit the " + + 'group, or drop `group` and keep the enumerated `fields`.', + }); + return; + } + + if (!hasGroup && !hasFields) { + ctx.addIssue({ + code: 'custom', + path: ['fields'], + message: + 'A section must declare its members exactly one way, and ' + surface + ' declares ' + + "neither: write `fields: ['a', 'b']` to enumerate them, or `group: ''` " + + "to inherit the object's declared `fieldGroups` entry (membership and the group's own " + + 'presentation are derived by `deriveFieldGroupLayout`, ADR-0085 §5). A section with ' + + 'neither has no body and renders nothing.', + }); + return; + } + + if (!hasGroup) return; + + for (const key of derivedKeys) { + if (section[key] === undefined) continue; + ctx.addIssue({ code: 'custom', path: [key], message: derivedKeyMessage(key, groupKey, surface) }); + } + for (const key of trueOnlyDerivedKeys) { + if (section[key] !== true) continue; + ctx.addIssue({ code: 'custom', path: [key], message: derivedKeyMessage(key, groupKey, surface) }); + } + }; +} + +function derivedKeyMessage(key: string, groupKey: string | undefined, surface: string): string { + return ( + '`' + key + '` cannot be combined with `group` on ' + surface + '. A group-referencing ' + + "section takes its presentation from the object's `fieldGroups` entry for `" + + (groupKey ?? '') + '` — `deriveFieldGroupLayout` (ADR-0085 §5) is the single source of ' + + 'the label, icon, description, collapse state and `visibleWhen` a group renders with, so ' + + 'restating one here would be a second writable spelling of the same fact. Set `' + key + + '` on that `fieldGroups` entry instead (it then applies on every surface the group renders ' + + 'on), or drop `group` and enumerate `fields` to author this section standalone.' + ); +} diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 97678b04ea..bf92b93547 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -222,6 +222,9 @@ import { retiredKey } from '../shared/retired-key'; import { SortItemSchema } from '../shared/enums.zod'; import { strictObject } from '../shared/strict-object'; import type { KeySetGuidance } from '../shared/suggestions.zod'; +// [#13855] The section → field-group reference form, shared with +// `FormSectionSchema` (view.zod.ts) so one mixing rule serves both escape hatches. +import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../shared/section-group-reference'; /** * What silently happened to an undeclared prop before these shapes were closed @@ -843,6 +846,13 @@ export const RecordDetailsProps = strictObject({ sections: z.array(strictObject({ surface: 'this `record:details` section', history: PROPS_HISTORY, + // Both are the author reaching for the #13855 reference form with the word + // the neighbouring surface uses: the object declares `fieldGroups`, and the + // field points back with `group`. Neither is a typo edit distance reaches. + aliases: { + fieldGroup: 'group', + groupKey: 'group', + }, }, { /** * Stable section identifier, snake_case. This is the i18n anchor: the @@ -867,8 +877,37 @@ export const RecordDetailsProps = strictObject({ * #5611 is fixing, so the shape that documents itself truthfully wins. */ columns: z.number().int().min(1).max(4).optional().describe('Field-grid columns for this section (1-4). Omitted → the renderer derives the width.'), - /** Field names shown in this section, in order. */ - fields: z.array(z.string()).describe('Field names rendered in this section, in order'), + /** + * [#13855] Reference a declared field GROUP instead of enumerating members + * — the delta form ruled 2026-08-31 (maintainer: 「直接处理b」). + * + * `{ group: 'contact_info' }` inherits the object's `fieldGroups` entry with + * that key: its members (every visible field whose `Field.group` points at + * it, in field-declaration order) and its own presentation (label, icon, + * description, `collapse`, `visibleWhen`, and the drop when the group has no + * visible members) all come from `deriveFieldGroupLayout` (ADR-0085 §5). + * The section restates none of it — see + * {@link sectionGroupReferenceRefinement} for the mixing rule and why the + * keys the group owns are refused here rather than given a precedence. + * + * Existence is NOT a parse question: the key names something on a DIFFERENT + * schema, so it follows the `UserFilterFieldSchema.field` precedent — parse + * takes any well-formed key and `page-section-group-unknown` (`@objectstack/lint`) + * reports one that resolves to no declared group. + */ + group: SectionGroupKeySchema.optional().describe( + 'Field group key (snake_case) whose members and presentation this section inherits, from the object\'s `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `icon`, `description`, `collapsible`, `defaultCollapsed`). Must name a declared group — checked by reference diagnostics.', + ), + /** + * Field names shown in this section, in order. + * + * Optional since #13855 — and optional ONLY in the sense that `group` is the + * other way to declare the same fact. A section carrying neither is refused + * (see {@link sectionGroupReferenceRefinement}), so no section reaches a + * renderer without a member source, which is what the previously-required + * key guaranteed. + */ + fields: z.array(z.string()).optional().describe('Field names rendered in this section, in order. Omit only when `group` supplies the members instead.'), /** * The three presentation keys the renderer has honoured all along, * declared at last (#11289, maintainer ruling 2026-08-23 — direction 1: @@ -945,7 +984,19 @@ export const RecordDetailsProps = strictObject({ * renderer's own fallback. */ headerColor: z.enum(['muted', 'muted/50', 'accent', 'primary/10', 'secondary/10', 'destructive/10']).optional().describe('Section-header background tint, from the closed six-token vocabulary rendered by objectui\'s `record:details` header (`muted` | `muted/50` | `accent` | `primary/10` | `secondary/10` | `destructive/10`). A value outside the enum is refused at authoring time rather than silently not painting. Omit for an untinted header.'), - })).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`.'), + }).superRefine(sectionGroupReferenceRefinement({ + surface: 'this `record:details` section', + // Exactly the keys `deriveFieldGroupLayout` fills from the group. `name` is + // in the list because the derived section's `key` IS the group key, and the + // group key is already this surface's i18n anchor + // (`objects.._sections..label`) — a second name would give the + // same section two lookup identities. + // + // NOT in the list, deliberately: `columns`, `hideEmpty`, `showBorder`, + // `headerColor`. Those are how THIS page lays the section out and the group + // declares nothing about them, so there is no second source to create. + derivedKeys: ['name', 'label', 'icon', 'description', 'collapsible', 'defaultCollapsed'], + }))).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the #13855 reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object\'s `fieldGroups` entry.'), fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'), /** * Field names to omit from the body, applied to both `fields` and every diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 75fb25f71f..f32a0881d5 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -56,6 +56,10 @@ import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../share import { ExpressionInputSchema } from '../shared/expression.zod'; import { normalizeVisibleWhen, VISIBILITY_STRICT_OPTIONS } from '../shared/visibility'; import { SELECT_OPTION_EDITABILITY_GUIDANCE, VISIBILITY_ONLY_STRICT_OPTIONS } from '../shared/editability-boundary'; +// [#13855] The section → field-group reference form, shared with the +// `record:details` section shape (component.zod.ts) so one mixing rule serves +// both layout escape hatches. +import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../shared/section-group-reference'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; import { ChartTypeSchema } from './chart.zod'; import { SharingConfigSchema } from './sharing.zod'; @@ -2422,6 +2426,16 @@ export const FormSectionSchema = lazySchema(() => strictObject({ // to rename it in place. The reader can only tell which answer is theirs if // the rejection names the shape, so each of the three shapes names itself. surface: 'this form section', + // [#13855] Both are the author reaching for the field-group reference form + // with the word a neighbouring surface uses: the object declares + // `fieldGroups`, and a field points back with `group`. Neither is a typo edit + // distance reaches. Spread the inherited table first so nothing it carries is + // dropped by re-declaring the key. + aliases: { + ...VISIBILITY_ONLY_STRICT_OPTIONS.aliases, + fieldGroup: 'group', + groupKey: 'group', + }, }, { /** * Stable identifier for translation lookup. snake_case convention. @@ -2502,11 +2516,65 @@ export const FormSectionSchema = lazySchema(() => strictObject({ pane: z.enum(['primary', 'secondary']).optional().describe( "Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'.", ), + /** + * [#13855] Reference a declared field GROUP instead of enumerating members — + * the delta form ruled 2026-08-31 (maintainer: 「直接处理b」). + * + * `{ group: 'contact_info' }` inherits the bound object's `fieldGroups` entry + * with that key: its members (every visible field whose `Field.group` points + * at it, in field-declaration order) and its own presentation (label, + * description, `collapse`, `visibleWhen`, and the drop when the group has no + * visible members) all come from `deriveFieldGroupLayout` (ADR-0085 §5). The + * section restates none of it — see {@link sectionGroupReferenceRefinement} + * for the mixing rule, and for why the keys the group owns are refused beside + * `group` rather than given a precedence. + * + * Existence is NOT a parse question: the key names something on a DIFFERENT + * schema, so it follows the `UserFilterFieldSchema.field` precedent — parse + * takes any well-formed key, and `form-section-group-unknown` + * (`@objectstack/lint`) reports one that resolves to no declared group. + * + * ⛔ Not on a wizard step — refused by the {@link FormViewSchema} refinement, + * for the reason #13704 refused the step keys themselves: a group carries + * `visibleWhen` and `collapse`, and a wizard step has no slot for either. + */ + group: SectionGroupKeySchema.optional().describe( + 'Field group key (snake_case) whose members and presentation this section inherits, from the bound object\'s `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics.', + ), + /** + * The section's members, enumerated. + * + * Optional since #13855 — and optional ONLY in the sense that `group` is the + * other way to declare the same fact. A section carrying neither is refused + * (see {@link sectionGroupReferenceRefinement}), so no section reaches a + * renderer without a member source, which is what the previously-required key + * guaranteed. + */ fields: z.array(z.union([ z.string(), // Legacy: simple field name FormFieldSchema, // Enhanced: detailed field config - ])), -}).transform(normalizeVisibleWhen)); + ])).optional(), +}).superRefine(sectionGroupReferenceRefinement({ + surface: 'this form section', + // Exactly the keys `deriveFieldGroupLayout` fills from the group. `name` is + // in the list because the derived section's `key` IS the group key, which is + // already this surface's i18n anchor (`metadataForms..sections.`) + // — a second name would give one section two lookup identities. `visibleOn` + // rides along because this refinement runs BEFORE the `.transform` that folds + // it onto `visibleWhen`, so the deprecated spelling must be named here or it + // would be the one way to smuggle a section-level predicate past the rule. + // + // NOT in the list, deliberately: `columns` and `pane`. Those are how THIS + // form lays the section out and the group declares nothing about them, so + // there is no second source to create. + derivedKeys: ['name', 'label', 'description', 'visibleWhen', 'visibleOn'], + // `collapsible` / `collapsed` carry `.default(false)`, so an authored `false` + // is already indistinguishable from the default by the time this runs — the + // same asymmetry #13704 records for the wizard step keys, and it costs + // nothing: `false` declares exactly what a group with `collapse: 'none'` + // delivers. Only `true` declares presentation the group owns. + trueOnlyDerivedKeys: ['collapsible', 'collapsed'], +})).transform(normalizeVisibleWhen)); /** * A single form action button (submit / cancel / reset): visibility + label. @@ -3258,6 +3326,35 @@ export const FormViewSchema = lazySchema(() => strictObject({ }); } } + // [#13855] The field-group REFERENCE form is refused on a wizard step + // for the same reason the two refusals above exist, one layer back: a + // `fieldGroups` entry carries `visibleWhen` and `collapse`, and + // `deriveFieldGroupLayout` passes both through to the derived section. + // Accepting `group` here would hand a wizard step exactly the predicate + // and collapse state #13704 just finished refusing — reached through + // the object's declaration instead of the step's own keys, so the + // refusals above would report clean while the behaviour arrived anyway. + // The section schema cannot see the group (that is cross-schema), so + // the honest answer at parse is to refuse the reference on this form + // type, not to guess what the group declares. + // + // Deliberately the CONSERVATIVE direction: a wizard step whose group + // declares neither key is legal metadata this refuses today, and + // allowing it later (once the renderer half lands and a ruling covers a + // derived predicate on a step) is additive. + if (section.group != null) { + ctx.addIssue({ + code: 'custom', + path: [key, index, 'group'], + message: + '`group` on a wizard step is refused: a field group carries `visibleWhen` and ' + + '`collapse`, and `deriveFieldGroupLayout` passes both through to the section it ' + + 'derives — neither of which a wizard step has a slot for (steps are entered in ' + + 'array order behind the step gate, and the wizard shows exactly the current step). ' + + 'Enumerate the step with `fields: [...]`, or use a `simple`/`tabbed` form to ' + + 'inherit the group.', + }); + } } }); } From ba3779badf83b967a81aec8ae1a77b476c2d98fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:01:11 +0000 Subject: [PATCH 2/5] test,changeset: pin the section group-reference form and its diagnostics --- .changeset/section-field-group-reference.md | 73 +++++ .../lint/src/validate-form-layout.test.ts | 100 ++++++ .../src/validate-page-field-bindings.test.ts | 92 ++++++ packages/spec/authorable-surface/ui.json | 1 + .../src/ui/section-group-reference.test.ts | 297 ++++++++++++++++++ 5 files changed, 563 insertions(+) create mode 100644 .changeset/section-field-group-reference.md create mode 100644 packages/spec/src/ui/section-group-reference.test.ts diff --git a/.changeset/section-field-group-reference.md b/.changeset/section-field-group-reference.md new file mode 100644 index 0000000000..d913ea6c5c --- /dev/null +++ b/.changeset/section-field-group-reference.md @@ -0,0 +1,73 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +--- + +feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855) + +Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855): +「直接处理b」. + +ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in +one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches +were whole-takeover shapes with no way back to it: a custom record page's +`record:details` `properties.sections` and a view-level `form.sections` each +enumerated their members by hand, so an author who reached for either had to +hand-copy the same membership fact a second and third time. Nothing linked the +copies to the declaration, so every field added to the object afterwards made +them quietly staler — measured on a real app as three disagreeing groupings of +one object, with the detail page missing two fields the form showed. + +**A section may now name the group instead.** On both surfaces: + +```ts +sections: [ + { group: 'contact_info' }, // members + presentation derived + { label: 'Notes', fields: ['note'] }, // enumerated, unchanged +] +``` + +Members (every visible field whose `Field.group` points at the key, in +field-declaration order) and the group's own presentation (label, icon, +description, `collapse`, `visibleWhen`, and the drop when a group has no visible +members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in +section land. + +**The mixing rule**, declared once for both surfaces and pinned: + +- `group` and `fields` are mutually exclusive; a section declaring neither is + refused (before this change it was unrepresentable, because `fields` was + required). +- A group-referencing section carries no key the group already declares — + `name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and + its deprecated `visibleOn` spelling) are refused beside `group`, each with the + pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the + absence of one. The surface keys the group says nothing about — `columns`, + `pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual. +- Across sections, both kinds coexist in declared array order; a + group-referencing section occupies one slot and expands in place. +- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a + wizard step has no slot for either (the #13704 refusals, reached through the + object's declaration instead of the step's own keys). + +**Existence is checked by reference diagnostics, not at parse.** The key names +something on a different schema, so the spec door takes any well-formed +snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint` +reports a dangling one as `page-section-group-unknown` (`record:details`) or +`form-section-group-unknown` (form views, both the canonical `sections` and the +legacy `groups` bucket), advisory like every other dangling-reference finding in +that family, with the object's declared groups listed in the hint. + +The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the +derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two +referencing surfaces cannot drift into accepting different keys. + +**Type-surface note for consumers.** `fields` becomes optional on both section +shapes (that is what makes `group` the other way to declare the same fact), so +`z.infer` now types it `… | undefined`. A consumer that reads `section.fields` +unconditionally must handle the reference form; every in-repo reader already +guards it. No authored metadata changes shape, and nothing that parsed before +stops parsing — `fields: []` included. + +The renderer half (objectui) is tracked separately; until it lands, a +group-referencing section is declared and diagnosed but not yet rendered. diff --git a/packages/lint/src/validate-form-layout.test.ts b/packages/lint/src/validate-form-layout.test.ts index d6f71d77b6..ea6b60dc05 100644 --- a/packages/lint/src/validate-form-layout.test.ts +++ b/packages/lint/src/validate-form-layout.test.ts @@ -6,6 +6,7 @@ import { validateFormLayout, FORM_FIELD_UNKNOWN, FORM_COLSPAN_ABSOLUTE, + FORM_SECTION_GROUP_UNKNOWN, } from './validate-form-layout.js'; type AnyRec = Record; @@ -14,6 +15,28 @@ const objects = [ { name: 'contract', fields: { name: {}, amount: {}, status: {}, notes: {} } }, ]; +/** + * [#13855] The same object, plus the declared field groups a section may now + * reference. Kept separate from `objects` so every pre-existing test above + * still runs against an object with NO groups — which is also the state the + * `declares no field groups at all` hint is written for. + */ +const groupedObjects = [ + { + name: 'contract', + fieldGroups: [ + { key: 'basics', label: 'Basics' }, + { key: 'money', label: 'Money' }, + ], + fields: { + name: { group: 'basics' }, + amount: { group: 'money' }, + status: {}, + notes: {}, + }, + }, +]; + describe('validateFormLayout (#2578)', () => { it('is clean for a well-formed multi-column form (known fields, no colSpan)', () => { const stack = { @@ -116,6 +139,83 @@ describe('validateFormLayout (#2578)', () => { }); }); +// ─────────────────────────────────────────────────────────────────────────── +// #13855 — `section.group` names a field group on the bound object. +// +// The spec door takes any well-formed snake_case key (the cross-schema +// existence question is not one a section schema can answer — the +// `UserFilterFieldSchema.field` precedent), so THIS is where a dangling key is +// reported. A miss is total, not partial: the reference form and the enumerated +// form are mutually exclusive at parse, so a key that resolves to nothing +// leaves the section with no member source at all and it does not render. +// ─────────────────────────────────────────────────────────────────────────── + +describe('#13855 — dangling `section.group` references', () => { + const viewWith = (sections: unknown, bucket: 'sections' | 'groups' = 'sections') => ({ + objects: groupedObjects, + views: [{ + name: 'contract_form', + data: { provider: 'object', object: 'contract' }, + [bucket]: sections, + }], + }); + + it('is clean when the key names a declared group', () => { + expect(validateFormLayout(viewWith([{ group: 'basics' }, { group: 'money' }]))).toEqual([]); + }); + + it('flags a key that names no declared group, with the declared set in the hint', () => { + const findings = validateFormLayout(viewWith([{ group: 'contact_info' }])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FORM_SECTION_GROUP_UNKNOWN); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe('views[0].sections[0].group'); + expect(findings[0].message).toContain('field group "contact_info"'); + // The consequence, in the words the severity is argued from. + expect(findings[0].message).toContain('silently does not render'); + expect(findings[0].hint).toContain('Declared groups on contract: basics, money.'); + }); + + it('reads the LEGACY `groups` bucket too — a rule silent on the alias is half a rule', () => { + const findings = validateFormLayout(viewWith([{ group: 'nope' }], 'groups')); + expect(findings.map(f => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_SECTION_GROUP_UNKNOWN}@views[0].groups[0].group`, + ]); + }); + + it('tells an author with NO declared groups what to do instead', () => { + // `objects` (not `groupedObjects`) declares no `fieldGroups` at all. + const findings = validateFormLayout({ + objects, + views: [{ name: 'f', data: { provider: 'object', object: 'contract' }, sections: [{ group: 'basics' }] }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('declares no field groups at all'); + }); + + it('says nothing about an object this stack does not define', () => { + // Same skip the field-existence rule takes: the object may come from + // another installed package, and a group cannot be judged on a schema we + // cannot see. A finding here would be one the author cannot act on. + const findings = validateFormLayout({ + objects: groupedObjects, + views: [{ name: 'f', data: { provider: 'object', object: 'from_another_package' }, sections: [{ group: 'nope' }] }], + }); + expect(findings).toEqual([]); + }); + + it('reports the group miss alongside — not instead of — the field misses', () => { + const findings = validateFormLayout(viewWith([ + { group: 'ghost_group' }, + { fields: ['name', 'ghost_field'] }, + ])); + expect(findings.map(f => `${f.rule}@${f.path}`)).toEqual([ + `${FORM_SECTION_GROUP_UNKNOWN}@views[0].sections[0].group`, + `${FORM_FIELD_UNKNOWN}@views[0].sections[1].fields[1]`, + ]); + }); +}); + // ─────────────────────────────────────────────────────────────────────────── // #6251 — `views[]` is a view CONTAINER, and both rules above were unreachable // on the shape real apps actually ship. diff --git a/packages/lint/src/validate-page-field-bindings.test.ts b/packages/lint/src/validate-page-field-bindings.test.ts index 21d3f82f6c..405b5edfdb 100644 --- a/packages/lint/src/validate-page-field-bindings.test.ts +++ b/packages/lint/src/validate-page-field-bindings.test.ts @@ -4,9 +4,11 @@ import { describe, it, expect } from 'vitest'; import { validatePageFieldBindings, checkFieldRefs, + componentSectionGroupRefs, indexObjectFields, PAGE_FIELD_UNKNOWN, PAGE_FIELD_UNPROVISIONED, + PAGE_SECTION_GROUP_UNKNOWN, } from './validate-page-field-bindings.js'; import { indexUnprovisionedAnchors } from './system-fields.js'; @@ -127,6 +129,96 @@ describe('validatePageFieldBindings — record:details real authored shape', () }); }); +// ─────────────────────────────────────────────────────────────────────────── +// #13855 — a section may REFERENCE a declared field group instead of +// enumerating its members. The key names something on a different schema, so +// the spec door takes any well-formed one and the existence question lands +// here, with the field-existence family it belongs to. +// ─────────────────────────────────────────────────────────────────────────── + +describe('validatePageFieldBindings — dangling `section.group` (#13855)', () => { + /** `baseStack()` with declared field groups on `crm_lead`. */ + const groupedStack = () => { + const stack = baseStack(); + (stack.objects[0] as Record).fieldGroups = [ + { key: 'overview', label: 'Overview' }, + { key: 'money', label: 'Money' }, + ]; + return stack; + }; + + const detailsWith = (sections: unknown[]) => + [{ type: 'record:details', properties: { sections } }]; + + it('is clean when the key names a declared group', () => { + const findings = validatePageFieldBindings({ + ...groupedStack(), + pages: [pageWith(detailsWith([{ group: 'overview' }, { group: 'money' }]))], + }); + expect(findings).toEqual([]); + }); + + it('flags a key that names no declared group', () => { + const findings = validatePageFieldBindings({ + ...groupedStack(), + pages: [pageWith(detailsWith([{ group: 'overview' }, { group: 'contact_info' }]))], + }); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(PAGE_SECTION_GROUP_UNKNOWN); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].path).toBe('pages[0].regions[0].components[0].properties.sections[1].group'); + expect(findings[0].message).toContain('field group "contact_info"'); + expect(findings[0].message).toContain('silently does not render'); + expect(findings[0].hint).toContain('Declared groups on crm_lead: money, overview.'); + }); + + it('reports the group miss alongside — not instead of — a field miss in a sibling section', () => { + const findings = validatePageFieldBindings({ + ...groupedStack(), + pages: [pageWith(detailsWith([ + { group: 'ghost_group' }, + { label: 'Money', fields: ['amount', 'nonexistent_field'] }, + ]))], + }); + expect(findings.map(f => `${f.rule}@${f.path}`)).toEqual([ + `${PAGE_FIELD_UNKNOWN}@pages[0].regions[0].components[0].properties.sections[1].fields[1]`, + `${PAGE_SECTION_GROUP_UNKNOWN}@pages[0].regions[0].components[0].properties.sections[0].group`, + ]); + }); + + it('tells an author on an object with NO declared groups what to do instead', () => { + const findings = validatePageFieldBindings({ + ...baseStack(), + pages: [pageWith(detailsWith([{ group: 'overview' }]))], + }); + expect(findings).toHaveLength(1); + expect(findings[0].hint).toContain('declares no field groups at all'); + }); + + it('says nothing about an object this stack does not define', () => { + const findings = validatePageFieldBindings({ + ...groupedStack(), + pages: [{ + name: 'external_detail', + object: 'from_another_package', + regions: [{ name: 'main', components: detailsWith([{ group: 'nope' }]) }], + }], + }); + expect(findings).toEqual([]); + }); + + it('reads the group refs through the ONE descriptor table the field refs use', () => { + // `componentSectionGroupRefs` walks `COMPONENT_FIELD_SPECS[…].nestedSections` + // — the same list `componentFieldRefs` walks. A component that grows + // sections is covered by both checks in one edit, rather than gaining the + // field check and silently missing this one. + expect(componentSectionGroupRefs('record:details', { sections: [{ group: 'a' }, { fields: ['x'] }] }, 'P')) + .toEqual([{ key: 'a', path: 'P.sections[0].group' }]); + // Unregistered component types stay skipped, silently, exactly as for fields. + expect(componentSectionGroupRefs('record:line_items', { sections: [{ group: 'a' }] }, 'P')).toBeNull(); + }); +}); + describe('validatePageFieldBindings — related lists bind the related object', () => { it('checks columns against objectName, not the page object', () => { const findings = validatePageFieldBindings({ diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 25267ec513..4e2856ceb3 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -442,6 +442,7 @@ "ui/FormSection:columns", "ui/FormSection:description", "ui/FormSection:fields", + "ui/FormSection:group", "ui/FormSection:label", "ui/FormSection:name", "ui/FormSection:pane", diff --git a/packages/spec/src/ui/section-group-reference.test.ts b/packages/spec/src/ui/section-group-reference.test.ts new file mode 100644 index 0000000000..e1568c5b20 --- /dev/null +++ b/packages/spec/src/ui/section-group-reference.test.ts @@ -0,0 +1,297 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #13855 — a layout section may REFERENCE a declared field group instead of + * enumerating its members (maintainer ruling 2026-08-31: 「直接处理b」). + * + * ## What is being pinned + * + * The delta form, on both layout escape hatches, through their real doors: + * `ComponentPropsMap['record:details']` for a custom record page's sections, + * and `FormViewSchema` for a view-level `form.sections`. Both take + * `{ group: '' }` in place of `fields`, and both refuse the combinations + * the mixing rule makes meaningless. + * + * ## Why the refusals assert MESSAGES, not just failure + * + * Same rule as `view-form-features-root.test.ts`: a bare + * `expect(success).toBe(false)` carries one bit, and each of these refusals has + * two — *that* the shape is refused, and *what the author is told to write + * instead*. The prescription is the whole point of refusing at the authoring + * door rather than letting the section render empty, so it is pinned. + * + * ## Acceptance is pinned as hard as refusal + * + * Three accept pins carry the weight the refusals cannot: + * + * 1. the reference form parses, and the KEY SURVIVES the parse (a section that + * accepted `group` and dropped it on the floor would satisfy every refusal + * pin below while delivering nothing — and on the form surface the value has + * to survive a `.transform()` and an `.overwrite()` fold to get out); + * 2. the enumerated form is untouched — `fields` going optional is a widening + * for `group`'s sake and must not have loosened anything else; + * 3. the surface keys the group does NOT declare still ride beside `group` + * (`columns`, `pane`, `hideEmpty`, `showBorder`, `headerColor`). A refusal + * list written one key too wide is indistinguishable from a correct one + * until exactly this parses. + * + * Plus the control every accept widening owes: an unknown sibling key is still + * refused, so the shape did not go strict-less on the way through. + */ + +import { describe, it, expect } from 'vitest'; + +import { ComponentPropsMap } from './component.zod'; +import { FormViewSchema } from './view.zod'; + +type Issue = { code: string; path: Array; message: string }; + +const RecordDetails = ComponentPropsMap['record:details']; + +function detailsIssues(value: unknown): Issue[] { + const r = RecordDetails.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return (r.error?.issues ?? []) as Issue[]; +} + +function detailsAccept(value: unknown): Record { + const r = RecordDetails.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify(r.error?.issues ?? '')}`).toBe(true); + return r.data as Record; +} + +/** A minimal form view carrying `sections`, so only the section shape varies. */ +function formWith(sections: unknown[], extra: Record = {}): unknown { + return { type: 'simple', sections, ...extra }; +} + +function formIssues(value: unknown): Issue[] { + const r = FormViewSchema.safeParse(value); + expect(r.success, `expected REJECTION, got a successful parse of ${JSON.stringify(value)}`).toBe(false); + return (r.error?.issues ?? []) as Issue[]; +} + +function formAccept(value: unknown): Record { + const r = FormViewSchema.safeParse(value); + expect(r.success, `expected ACCEPTANCE, got ${JSON.stringify(r.error?.issues ?? '')}`).toBe(true); + return r.data as Record; +} + +function at(issues: Issue[], path: Array): Issue { + const issue = issues.find(i => JSON.stringify(i.path) === JSON.stringify(path)); + expect(issue, `expected an issue at ${JSON.stringify(path)}, got ${JSON.stringify(issues)}`).toBeDefined(); + return issue!; +} + +describe('record:details section — the field-group reference form (#13855)', () => { + it('accepts a group reference and CARRIES THE KEY THROUGH the parse', () => { + const parsed = detailsAccept({ sections: [{ group: 'contact_info' }] }); + const sections = parsed.sections as Array>; + expect(sections[0].group).toBe('contact_info'); + // The other half of "carried": nothing invented a member list for it. + expect(sections[0].fields).toBeUndefined(); + }); + + it('keeps the enumerated form working unchanged', () => { + const parsed = detailsAccept({ + sections: [{ name: 'billing', label: 'Billing', fields: ['amount', 'due_at'], columns: 2 }], + }); + const sections = parsed.sections as Array>; + expect(sections[0].fields).toEqual(['amount', 'due_at']); + // `fields: []` parsed before `group` existed and must keep parsing — the + // mixing rule tests PRESENCE, never non-emptiness. + expect(RecordDetails.safeParse({ sections: [{ fields: [] }] }).success).toBe(true); + }); + + it('lets both kinds of section coexist, in declared order', () => { + const parsed = detailsAccept({ + sections: [{ group: 'contact_info' }, { label: 'Notes', fields: ['note'] }, { group: 'billing' }], + }); + const sections = parsed.sections as Array>; + expect(sections.map(s => s.group ?? s.label)).toEqual(['contact_info', 'Notes', 'billing']); + }); + + it('accepts the surface keys the group does NOT declare beside `group`', () => { + const parsed = detailsAccept({ + sections: [{ + group: 'contact_info', + columns: 2, + hideEmpty: false, + showBorder: true, + headerColor: 'muted', + }], + }); + const section = (parsed.sections as Array>)[0]; + expect(section.columns).toBe(2); + expect(section.hideEmpty).toBe(false); + expect(section.showBorder).toBe(true); + expect(section.headerColor).toBe('muted'); + }); + + it('refuses `group` beside `fields` — two sources for one fact', () => { + const issue = at(detailsIssues({ sections: [{ group: 'contact_info', fields: ['email'] }] }), [ + 'sections', 0, 'group', + ]); + expect(issue.code).toBe('custom'); + expect(issue.message).toContain('`group` and `fields` are mutually exclusive'); + // The prescription: which one to keep, and why the pair is wrong. + expect(issue.message).toContain('deriveFieldGroupLayout'); + expect(issue.message).toContain("Keep `group: 'contact_info'`"); + }); + + it('refuses a section that declares NEITHER — it would render nothing', () => { + const issue = at(detailsIssues({ sections: [{ label: 'Orphan' }] }), ['sections', 0, 'fields']); + expect(issue.code).toBe('custom'); + expect(issue.message).toContain('must declare its members exactly one way'); + expect(issue.message).toContain('renders nothing'); + }); + + it.each([ + ['name', 'contact'], + ['label', 'Contact'], + ['icon', 'user'], + ['description', 'How to reach them'], + ['collapsible', true], + ['defaultCollapsed', true], + ])('refuses `%s` beside `group` — the group owns it', (key, value) => { + const issue = at(detailsIssues({ sections: [{ group: 'contact_info', [key]: value }] }), [ + 'sections', 0, key, + ]); + expect(issue.code).toBe('custom'); + expect(issue.message).toContain(`\`${key}\` cannot be combined with \`group\``); + // Points at the entry that owns the key, not merely at the refusal. + expect(issue.message).toContain('`fieldGroups` entry for `contact_info`'); + expect(issue.message).toContain('ADR-0085 §5'); + }); + + it('still refuses an unknown sibling key (the widening did not go strict-less)', () => { + const issues = detailsIssues({ sections: [{ group: 'contact_info', collapsedByDefault: true }] }); + const messages = issues.map(i => i.message).join('\n'); + expect(messages).toContain('collapsedByDefault'); + }); + + it('points a near-miss spelling of the new key at `group`', () => { + const messages = detailsIssues({ sections: [{ fieldGroup: 'contact_info' }] }) + .map(i => i.message).join('\n'); + expect(messages).toContain('fieldGroup'); + expect(messages).toContain('group'); + }); +}); + +describe('form.sections — the field-group reference form (#13855)', () => { + it('accepts a group reference and carries the key through transform AND fold', () => { + // The value has to survive `FormSectionSchema`'s `.transform`, the form + // view's `.superRefine`, and the `groups → sections` `.overwrite()` fold. + const parsed = formAccept(formWith([{ group: 'contact_info' }])); + const sections = parsed.sections as Array>; + expect(sections[0].group).toBe('contact_info'); + expect(sections[0].fields).toBeUndefined(); + }); + + it('carries the key through the LEGACY `groups` bucket too', () => { + const parsed = formAccept({ type: 'simple', groups: [{ group: 'contact_info' }] }); + const sections = parsed.sections as Array>; + expect(sections[0].group).toBe('contact_info'); + expect(parsed.groups).toBeUndefined(); + }); + + it('keeps the enumerated form working unchanged', () => { + const parsed = formAccept(formWith([{ label: 'Billing', fields: ['amount', { field: 'due_at' }] }])); + const sections = parsed.sections as Array>; + expect((sections[0].fields as unknown[]).length).toBe(2); + expect(FormViewSchema.safeParse(formWith([{ fields: [] }])).success).toBe(true); + }); + + it('accepts `columns` and `pane` beside `group` — the group declares neither', () => { + const parsed = formAccept(formWith([{ group: 'contact_info', columns: 2, pane: 'secondary' }], { + type: 'split', + })); + const section = (parsed.sections as Array>)[0]; + expect(section.columns).toBe(2); + expect(section.pane).toBe('secondary'); + }); + + it('refuses `group` beside `fields`', () => { + const issue = at(formIssues(formWith([{ group: 'contact_info', fields: ['email'] }])), [ + 'sections', 0, 'group', + ]); + expect(issue.message).toContain('`group` and `fields` are mutually exclusive'); + expect(issue.message).toContain('this form section'); + }); + + it('refuses a section that declares NEITHER', () => { + const issue = at(formIssues(formWith([{ label: 'Orphan' }])), ['sections', 0, 'fields']); + expect(issue.message).toContain('must declare its members exactly one way'); + }); + + it.each([ + ['name', 'contact'], + ['label', 'Contact'], + ['description', 'How to reach them'], + ['visibleWhen', "record.type == 'person'"], + ['visibleOn', "record.type == 'person'"], + ['collapsible', true], + ['collapsed', true], + ])('refuses `%s` beside `group` — the group owns it', (key, value) => { + const issue = at(formIssues(formWith([{ group: 'contact_info', [key]: value }])), [ + 'sections', 0, key, + ]); + expect(issue.message).toContain(`\`${key}\` cannot be combined with \`group\``); + expect(issue.message).toContain('`fieldGroups` entry for `contact_info`'); + }); + + it('refuses `visibleOn` beside `group` at ITS OWN path, before the fold renames it', () => { + // The deprecated spelling folds onto `visibleWhen` in a `.transform` that + // runs AFTER this refinement. Naming only the canonical key would leave + // `visibleOn` as the one way to smuggle a section predicate past the rule. + const paths = formIssues(formWith([{ group: 'contact_info', visibleOn: 'true' }])).map(i => i.path); + expect(paths).toContainEqual(['sections', 0, 'visibleOn']); + }); + + it('accepts an authored `collapsible: false` beside `group` — indistinguishable from the default', () => { + // Not leniency: `collapsible`/`collapsed` carry `.default(false)`, so by the + // time an object-level refinement runs, an authored `false` and an absent + // key are the same value — the same asymmetry #13704 records for the wizard + // step keys. `false` also declares exactly what `collapse: 'none'` delivers. + expect(FormViewSchema.safeParse( + formWith([{ group: 'contact_info', collapsible: false, collapsed: false }]), + ).success).toBe(true); + }); + + it('refuses `group` on a WIZARD step', () => { + const issue = at(formIssues(formWith([{ group: 'contact_info' }], { type: 'wizard' })), [ + 'sections', 0, 'group', + ]); + expect(issue.code).toBe('custom'); + expect(issue.message).toContain('`group` on a wizard step is refused'); + // The reason, and the two ways out. + expect(issue.message).toContain('deriveFieldGroupLayout'); + expect(issue.message).toContain('Enumerate the step with `fields: [...]`'); + }); + + it('still accepts an enumerated wizard step (the refusal is scoped to `group`)', () => { + expect(FormViewSchema.safeParse( + formWith([{ label: 'Step 1', fields: ['name'] }], { type: 'wizard' }), + ).success).toBe(true); + }); + + it('still refuses an unknown sibling key', () => { + const messages = formIssues(formWith([{ group: 'contact_info', collapsedByDefault: true }])) + .map(i => i.message).join('\n'); + expect(messages).toContain('collapsedByDefault'); + }); + + it('points a near-miss spelling of the new key at `group`', () => { + const messages = formIssues(formWith([{ groupKey: 'contact_info' }])).map(i => i.message).join('\n'); + expect(messages).toContain('groupKey'); + expect(messages).toContain('group'); + }); + + it('refuses a key the group grammar rejects, at the `group` path', () => { + // The reference surface reads the SAME pattern the declaring + // `ObjectFieldGroupSchema.key` enforces (`FIELD_GROUP_KEY_PATTERN`), so a + // key one surface refuses can never be written on the other. + const paths = formIssues(formWith([{ group: 'Contact Info' }])).map(i => i.path); + expect(paths).toContainEqual(['sections', 0, 'group']); + }); +}); From eb23b9b34a15b981c6b18cb492fa7a48840b57a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:04:09 +0000 Subject: [PATCH 3/5] chore(gen): regenerate reference docs and re-anchor the system-context census --- content/docs/permissions/system-context.mdx | 2 +- content/docs/references/data/object.mdx | 4 ++-- content/docs/references/ui/component.mdx | 5 +++-- content/docs/references/ui/view.mdx | 9 ++++++--- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 4d3290fefa..8821cbe16c 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -47,7 +47,7 @@ nothing to do with elevation. | Declaration | What it is | This page? | |:---|:---|:---:| | `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ | -| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | +| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | | `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ | | `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 8a5460426e..0203ae721e 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -302,7 +302,7 @@ const result = ApiMethod.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. | +| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. | | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | @@ -654,7 +654,7 @@ External datasource binding (ADR-0015) | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. | +| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. | | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 9149aa32b7..9dcd349c6f 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -753,7 +753,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) | | **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. | +| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the #13855 reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry. | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) | | **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). | @@ -767,7 +767,8 @@ Sort field and direction pair | **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.._sections..label`; a nameless section renders its authored label in every locale | | **label** | `string \| Record` | optional | Section heading (omit for an untitled, borderless section) | | **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. | -| **fields** | `string[]` | ✅ | Field names rendered in this section, in order | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `icon`, `description`, `collapsible`, `defaultCollapsed`). Must name a declared group — checked by reference diagnostics. | +| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. | | **hideEmpty** | `boolean` | optional | Hide this section's empty fields (renderer default: on — and a section whose fields are ALL empty then renders nothing at all: no heading, no skeleton). Set `false` to render empty rows, keeping the section's label skeleton on an all-empty record (e.g. a brand-new one). | | **collapsible** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). | | **showBorder** | `boolean` | optional | Draw this section's card chrome (renderer default: derived — on for a titled section, off for an untitled one). Set `false` for a borderless titled section, or `true` for a bordered untitled one. | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index d3847cfa86..074a21b371 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -336,7 +336,8 @@ View filter rule | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | -| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormSection.fields[number]` @@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | -| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormView.groups[number]` @@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option ` | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | | | **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | -| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | | +| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. | +| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | | ### Nested Shape: `FormView.subforms[number]` From 4cb9ddba6567c07e3f45421d15de89c050414468 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 16:20:34 +0000 Subject: [PATCH 4/5] chore(gen): record FIELD_GROUP_KEY_PATTERN in the export surface artifacts --- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + packages/spec/src/ui/component.zod.ts | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index ff491722a3..37a12f0bde 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -245,6 +245,7 @@ "ExternalTable (type)", "ExternalTableParsed (type)", "ExternalTableSchema (const)", + "FIELD_GROUP_KEY_PATTERN (const)", "FIELD_GROUP_SYSTEM_FIELDS (const)", "FIELD_KEY_GUIDANCE (const)", "FIELD_MASKING_PRESETS (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 07b1b97cf9..ccf45dd1a2 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -245,6 +245,7 @@ "ExternalTable": "src/data/external-catalog.zod.ts#ExternalTable (type)", "ExternalTableParsed": "src/data/external-catalog.zod.ts#ExternalTableParsed (type)", "ExternalTableSchema": "src/data/external-catalog.zod.ts#ExternalTableSchema (const)", + "FIELD_GROUP_KEY_PATTERN": "src/data/field-group-layout.ts#FIELD_GROUP_KEY_PATTERN (const)", "FIELD_GROUP_SYSTEM_FIELDS": "src/data/field-group-layout.ts#FIELD_GROUP_SYSTEM_FIELDS (const)", "FIELD_KEY_GUIDANCE": "src/data/authoring-key-lint.ts#FIELD_KEY_GUIDANCE (const)", "FIELD_MASKING_PRESETS": "src/data/field.zod.ts#FIELD_MASKING_PRESETS (const)", diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index bf92b93547..14ed30b8dc 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -996,7 +996,7 @@ export const RecordDetailsProps = strictObject({ // `headerColor`. Those are how THIS page lays the section out and the group // declares nothing about them, so there is no second source to create. derivedKeys: ['name', 'label', 'icon', 'description', 'collapsible', 'defaultCollapsed'], - }))).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the #13855 reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object\'s `fieldGroups` entry.'), + }))).optional().describe('Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object\'s `fieldGroups` entry (ADR-0085 §5).'), fields: z.array(z.string()).optional().describe('Explicit field list to display (optional, overrides highlightFields)'), /** * Field names to omit from the body, applied to both `fields` and every From a61844e50caa6b3431cc4194eab55bb97dcdfe67 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 17:20:43 +0000 Subject: [PATCH 5/5] chore(gen): regenerate the component reference and restore the census page's merged content --- content/docs/permissions/system-context.mdx | 20 +++++++++++++++++++- content/docs/references/ui/component.mdx | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 8821cbe16c..323002e68b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -306,7 +306,7 @@ still holds equal to the census on every pull request: | — in tests | 1013 | — | | — in non-test sources | 798 | — | | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | -| — parsed as a declaration | 21 | ✅ | +| — parsed as a declaration | 22 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | | — parsed as a property **read** | 115 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | @@ -336,6 +336,24 @@ test files certifies nothing. ⛔ Do not re-add them to `DECLARED_COUNTS` — a self-test case in the gate refuses that by name. Re-measure them with `node scripts/isystem-census.mjs` when you want them current, and move the date. +**What the enforced declarations row counts.** Not the four field declarations +above — those are four *distinct fields* that happen to share a name, and only +the first is elevation. This row counts every position where the parser puts the +identifier in a **declaring** slot: those four, plus the structural type literals +that restate `ExecutionContext.isSystem`'s shape inline rather than importing it +(`{ isSystem: true; tenantId?: string }`, `context?: { isSystem?: boolean }`, and +the `get isSystem()` accessor on the engine's context wrapper). A restatement is +a producer's declaration of the shape it will build, never a read, so a new one +moves this count and moves nothing else on this page — the census's read +population, the anchored rows above, and the packages and files totals all stay +where they are. The most recent arrival is the scoped +seed context threaded into the org-admin permission-set lookup in +`plugins/plugin-security/src/auto-org-admin-grant.ts`, so that read resolves +against the granting organization's own catalog row rather than an +organization-less one (#11670). ⛔ Cited without a line number deliberately: an +anchor here would be refused, and rightly — this page anchors elevation +**reads**, and a declaration is not one. + Counting by hand is what made the previous edition wrong in two independent ways, so both are worth naming. Its headline said "80 distinct sites across 18 packages" while its own tables anchored **77** — the number never matched the diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 9dcd349c6f..3bdc82eedc 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -753,7 +753,7 @@ Sort field and direction pair | :--- | :--- | :--- | :--- | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) | | **layout** | `never` | optional | [REMOVED] `record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — its declared `auto` \| `custom` semantics were never implemented: the renderer tests `layout` only against `inline` \| `compact`, two values the schema never permitted, so both legal values took the same branch and the key selected nothing. Delete the key — the body is already chosen by what you author: `sections` renders the explicit groups (the old `custom`), and omitting it falls back to the object's `highlightFields` (the old `auto`). Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand. | -| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the #13855 reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry. | +| **sections** | `{ name?: string; label?: string \| Record; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). | | **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) | | **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) | | **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |