Skip to content

Commit 5178906

Browse files
os-zhuangclaude
andauthored
feat(spec): ADR-0085 semantic roles — stageField, highlightFields rename, detail block removal, collapse enum, shared fieldGroups derivation (#2521)
* feat(spec): ADR-0085 semantic roles — stageField, highlightFields rename, detail block removal, collapse enum, shared fieldGroups derivation Executes ADR-0085 (#2520) in the spec layer: - ObjectSchema gains stageField: string | false (lifecycle role; false suppresses stage heuristics) — legitimizes the key renderers already read. - compactLayout → highlightFields (ordered most-important fields; drives default columns, cards, previews, detail highlight strip). Old key stays a parse-time alias, preserved on output (ADR-0079 pattern). 35 platform objects + app-todo migrated. - fieldGroups[].collapse enum replaces defaultExpanded and the UI-dialect collapsible/collapsed pair (both aliased at parse for one minor); visibleOn removed (zero consumers, ADR-0049 enforce-or-remove). - detail passthrough UI-hints block removed (zero authors; the typed hideReferenceRail was a proven no-op; renderViaSchema retires with the legacy monolith render path in objectui). - New @objectstack/spec/data deriveFieldGroupLayout — single source of the grouping semantics (declared order, empty groups dropped, ungrouped trailing bucket minus system fields, collapse passthrough incl. aliases). - New @objectstack/lint validateSemanticRoles (wired into os lint): warns on dangling Field.group / empty declared groups / unknown stageField & highlightFields names (ADR-0078 completeness gate). - Liveness ledger updated (detail dropped; highlightFields + stageField registered live; compactLayout marked deprecated alias). spec 6666 tests, lint 112, cli 440, platform-objects 63 all green; api-surface unchanged; check:liveness green; app-todo typecheck green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: teach highlightFields/stageField in place of compactLayout (ADR-0085) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(spec): mirror highlightFields back onto compactLayout during transition Browser dogfood of the rename found a live gap: metadata authored with the canonical highlightFields key parsed to output WITHOUT compactLayout, so current renderers that still read the old key (ObjectGrid default columns, card compact views, vendored console) silently lost their curated field list. The alias now mirrors both directions (canonical wins when both are present); the back-fill is removed together with the deprecated key. Verified end-to-end in a running app (app-todo, HMR console against the worktree server): default list columns, mobile card fields, detail highlight strip, stageField stepper, and field-group form sections all render from the renamed keys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(dogfood): drop stale visibleOn row from expression ledger ADR-0085 §3 removed fieldGroups[].visibleOn from ObjectSchema (declared, zero consumers — enforce-or-remove per ADR-0049). The ADR-0060 ratchet correctly flagged the ledger row as a stale cover; drop it until the key returns with a real enforcement path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ab2362a commit 5178906

53 files changed

Lines changed: 869 additions & 92 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
'@objectstack/cli': patch
5+
'@objectstack/platform-objects': patch
6+
---
7+
8+
ADR-0085: object presentation intent is declared as cross-surface semantic
9+
roles, never as per-surface hint blocks.
10+
11+
**@objectstack/spec**
12+
13+
- New top-level `stageField: string | false` — names the object's linear
14+
lifecycle field (`false` declares the status-like field non-linear and
15+
suppresses every consumer's stage heuristics). Legitimizes the key the UI
16+
runtime already read but the schema rejected.
17+
- `compactLayout`**`highlightFields`** (the value is an ordered field
18+
list, not a layout; "highlight" is already the renderer-side term of art).
19+
`compactLayout` stays accepted as a parse-time alias and is preserved on
20+
output — the ADR-0079 `displayNameField → nameField` pattern.
21+
- `fieldGroups[].collapse: 'none' | 'expanded' | 'collapsed'` replaces
22+
`defaultExpanded` AND the UI-dialect `collapsible`/`collapsed` boolean pair
23+
(which had drifted two ways: spec declared a key no renderer read, renderers
24+
read keys the spec rejected). Old keys map onto the enum at parse and remain
25+
accepted for one minor.
26+
- `fieldGroups[].visibleOn` removed (no consumer anywhere — ADR-0049
27+
enforce-or-remove; re-add together with its enforcement when a surface
28+
evaluates it).
29+
- The `detail: { … }.passthrough()` UI-hints block is **removed**. Every key
30+
in it was either unauthorable, a proven no-op for spec authors
31+
(`hideReferenceRail` — the rail is default-off and its enabling key was
32+
never typed), or a per-page toggle that belongs to an assigned Page. Zero
33+
authors existed across framework and objectui (evidence in ADR-0085); the
34+
removal ships as a minor under the documented dead-surface exception
35+
(PR #2272 precedent).
36+
- New `deriveFieldGroupLayout(def)` in `@objectstack/spec/data` — the single
37+
source of the fieldGroups rendering semantics (declared order, empty groups
38+
dropped, ungrouped trailing bucket minus audit/system fields, collapse
39+
passthrough incl. deprecated aliases). UI renderers consume this instead of
40+
their two pre-existing near-identical local copies.
41+
42+
**@objectstack/lint / @objectstack/cli**
43+
44+
- New `validateSemanticRoles` (wired into `os lint`): warns on
45+
`Field.group` → undeclared group, declared-but-unreferenced groups, and
46+
`stageField`/`highlightFields` entries naming non-existent fields — the
47+
dangling-pointer shapes that are Zod-valid but silently inert at render
48+
time (ADR-0078 completeness gate).
49+
50+
**@objectstack/platform-objects**
51+
52+
- All 35 system objects renamed `compactLayout:``highlightFields:`
53+
(behaviour unchanged via the alias).

content/docs/guides/data-modeling.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export const MyObject = ObjectSchema.create({
3939

4040
// Display configuration
4141
titleFormat: '{{record.field1}} - {{record.field2}}',
42-
compactLayout: ['field1', 'field2', 'field3'],
42+
highlightFields: ['field1', 'field2', 'field3'],
4343

4444
// Fields definition
4545
fields: {
@@ -67,7 +67,7 @@ export const MyObject = ObjectSchema.create({
6767
| `icon` | string | Icon identifier | `'building'` |
6868
| `description` | string | Help text | `'Companies...'` |
6969
| `titleFormat` | string | Record title template (`{{record.field}}` interpolation) | `'{{record.name}} - {{record.id}}'` |
70-
| `compactLayout` | string[] | Quick view fields | `['name', 'status']` |
70+
| `highlightFields` | string[] | Most-important fields, in priority order (default columns, cards, previews, detail highlight strip; ADR-0085 — formerly `compactLayout`, still accepted as an alias) | `['name', 'status']` |
7171

7272
### Enable Features
7373

content/docs/guides/metadata/object.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ export const Account = ObjectSchema.create({
6868
| :--- | :--- | :--- | :--- |
6969
| `displayNameField` | `string` | optional | Field used as record display name (defaults to `'name'`) |
7070
| `titleFormat` | `string` | optional | Title expression (e.g. `'{name} - {code}'`) |
71-
| `compactLayout` | `string[]` | optional | Primary fields for hover cards and lookups |
71+
| `highlightFields` | `string[]` | optional | Most-important fields in priority order — default list columns, cards, previews, detail highlight strip (ADR-0085; formerly `compactLayout`, accepted as an alias) |
72+
| `stageField` | `string \| false` | optional | Linear lifecycle field; `false` declares the status field non-linear and suppresses stage heuristics (ADR-0085) |
7273
| `recordName` | `object` | optional | Record name auto-generation config |
7374

7475
### Capabilities (`enable`)

content/docs/guides/standards.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export const MyObject = ObjectSchema.create({
9595

9696
// ✅ Display Configuration
9797
titleFormat: '{field1} - {field2}',
98-
compactLayout: ['field1', 'field2', 'field3'],
98+
highlightFields: ['field1', 'field2', 'field3'],
9999

100100
// ✅ Fields Definition
101101
fields: {

content/docs/references/ui/component.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,9 @@ const result = AIChatWindowProps.parse(data);
274274
| Property | Type | Required | Description |
275275
| :--- | :--- | :--- | :--- |
276276
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` || Number of columns for field layout (1-4) |
277-
| **layout** | `Enum<'auto' \| 'custom'>` || Layout mode: auto uses object compactLayout, custom uses explicit sections |
277+
| **layout** | `Enum<'auto' \| 'custom'>` || Layout mode: auto uses object highlightFields (formerly compactLayout), custom uses explicit sections |
278278
| **sections** | `string[]` | optional | Section IDs to show (required when layout is "custom") |
279-
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides compactLayout) |
279+
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
280280
| **aria** | `Object` | optional | ARIA accessibility attributes |
281281

282282

examples/app-todo/src/objects/task.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export const Task = ObjectSchema.create({
181181
],
182182

183183
nameField: 'subject',
184-
compactLayout: ['subject', 'status', 'priority', 'due_date', 'owner'],
184+
highlightFields: ['subject', 'status', 'priority', 'due_date', 'owner'],
185185

186186
validations: [
187187
{

packages/cli/src/commands/lint.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
88
import { computeI18nCoverage } from '../utils/i18n-coverage.js';
99
import { lintDataModel } from '../lint/data-model-rules.js';
1010
import { validateWidgetBindings } from '@objectstack/lint';
11-
import { validateRecordTitle } from '@objectstack/lint';
11+
import { validateRecordTitle, validateSemanticRoles } from '@objectstack/lint';
1212
import { collectAndLintDocs } from '../utils/collect-docs.js';
1313
import { scoreMetadata } from '../lint/score.js';
1414
import { runMetadataEval } from '../lint/metadata-eval.js';
@@ -322,6 +322,21 @@ export function lintConfig(config: any): LintIssue[] {
322322
});
323323
}
324324

325+
// ── Semantic-role pointers (ADR-0085) ──
326+
// stageField / highlightFields / Field.group are pointers into the object's
327+
// field map; a dangling pointer is Zod-valid but silently inert at render
328+
// time (the ADR-0078 completeness gate). All advisory — every consumer
329+
// degrades gracefully.
330+
for (const t of validateSemanticRoles(config)) {
331+
issues.push({
332+
severity: t.severity,
333+
rule: t.rule,
334+
message: `${t.where}: ${t.message}`,
335+
path: t.path,
336+
fix: t.hint,
337+
});
338+
}
339+
325340
return issues;
326341
}
327342

packages/dogfood/test/expression-conformance.ledger.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
108108
dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log',
109109
enforcement: 'console (objectui) SchemaRenderer + server celEngine (interpret)',
110110
covers: [
111-
'data/object.zod.ts:visibleOn',
111+
// data/object.zod.ts:visibleOn (fieldGroups group-level CEL visibility)
112+
// was removed by ADR-0085 §3 — declared but never consumed anywhere
113+
// (enforce-or-remove, ADR-0049). Re-add the row when the key returns
114+
// WITH an enforcement path.
112115
'ui/action.zod.ts:visible',
113116
'ui/app.zod.ts:visible',
114117
'ui/page.zod.ts:visibility',

packages/lint/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,11 @@ export {
5050
TITLE_UNRESOLVABLE,
5151
} from './validate-record-title.js';
5252
export type { RecordTitleFinding, RecordTitleSeverity } from './validate-record-title.js';
53+
54+
export {
55+
validateSemanticRoles,
56+
FIELD_GROUP_UNDECLARED,
57+
FIELD_GROUP_EMPTY,
58+
SEMANTIC_ROLE_FIELD_UNKNOWN,
59+
} from './validate-semantic-roles.js';
60+
export type { SemanticRoleFinding, SemanticRoleSeverity } from './validate-semantic-roles.js';
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
validateSemanticRoles,
6+
FIELD_GROUP_UNDECLARED,
7+
FIELD_GROUP_EMPTY,
8+
SEMANTIC_ROLE_FIELD_UNKNOWN,
9+
} from './validate-semantic-roles';
10+
11+
const stack = (objects: unknown) => ({ objects });
12+
13+
describe('validateSemanticRoles (ADR-0085)', () => {
14+
it('passes a clean object', () => {
15+
const findings = validateSemanticRoles(stack([{
16+
name: 'account',
17+
stageField: 'status',
18+
highlightFields: ['name', 'status'],
19+
fieldGroups: [{ key: 'basic', label: 'Basic' }],
20+
fields: {
21+
name: { type: 'text', group: 'basic' },
22+
status: { type: 'select' },
23+
},
24+
}]));
25+
expect(findings).toEqual([]);
26+
});
27+
28+
it('flags a Field.group referencing an undeclared group', () => {
29+
const findings = validateSemanticRoles(stack([{
30+
name: 'account',
31+
fieldGroups: [{ key: 'basic', label: 'Basic' }],
32+
fields: {
33+
name: { type: 'text', group: 'basic' },
34+
vat: { type: 'text', group: 'billling' }, // typo
35+
},
36+
}]));
37+
expect(findings).toHaveLength(1);
38+
expect(findings[0]).toMatchObject({
39+
severity: 'warning',
40+
rule: FIELD_GROUP_UNDECLARED,
41+
path: 'objects[0].fields.vat.group',
42+
});
43+
expect(findings[0].message).toContain('billling');
44+
});
45+
46+
it('flags a declared group no field references', () => {
47+
const findings = validateSemanticRoles(stack([{
48+
name: 'account',
49+
fieldGroups: [
50+
{ key: 'basic', label: 'Basic' },
51+
{ key: 'unused', label: 'Unused' },
52+
],
53+
fields: { name: { type: 'text', group: 'basic' } },
54+
}]));
55+
expect(findings).toHaveLength(1);
56+
expect(findings[0]).toMatchObject({ rule: FIELD_GROUP_EMPTY });
57+
expect(findings[0].message).toContain('unused');
58+
});
59+
60+
it('flags stageField pointing at a missing field; false is fine', () => {
61+
const bad = validateSemanticRoles(stack([{
62+
name: 'lead', stageField: 'pipeline', fields: { status: {} },
63+
}]));
64+
expect(bad).toHaveLength(1);
65+
expect(bad[0]).toMatchObject({ rule: SEMANTIC_ROLE_FIELD_UNKNOWN, path: 'objects[0].stageField' });
66+
67+
const optedOut = validateSemanticRoles(stack([{
68+
name: 'lead', stageField: false, fields: { status: {} },
69+
}]));
70+
expect(optedOut).toEqual([]);
71+
});
72+
73+
it('flags unknown highlightFields entries, including via the compactLayout alias', () => {
74+
const findings = validateSemanticRoles(stack([{
75+
name: 'account',
76+
highlightFields: ['name', 'industy'], // typo
77+
fields: { name: {}, industry: {} },
78+
}]));
79+
expect(findings).toHaveLength(1);
80+
expect(findings[0].message).toContain('industy');
81+
82+
const aliased = validateSemanticRoles(stack([{
83+
name: 'account',
84+
compactLayout: ['ghost'],
85+
fields: { name: {} },
86+
}]));
87+
expect(aliased).toHaveLength(1);
88+
expect(aliased[0]).toMatchObject({ rule: SEMANTIC_ROLE_FIELD_UNKNOWN });
89+
});
90+
91+
it('accepts objects as a name-keyed map and tolerates junk shapes', () => {
92+
const findings = validateSemanticRoles(stack({
93+
account: { stageField: 'nope', fields: {} },
94+
}));
95+
expect(findings).toHaveLength(1);
96+
expect(validateSemanticRoles({})).toEqual([]);
97+
expect(validateSemanticRoles(stack(null))).toEqual([]);
98+
expect(validateSemanticRoles(stack([null, 'junk', 42]))).toEqual([]);
99+
});
100+
});

0 commit comments

Comments
 (0)