Skip to content

Commit c94dd94

Browse files
os-zhuangclaude
andcommitted
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>
1 parent 965949d commit c94dd94

48 files changed

Lines changed: 847 additions & 85 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).

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/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+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Build-time semantic-role diagnostics (ADR-0085).
5+
*
6+
* The object-level semantic roles (`stageField`, `highlightFields` /
7+
* deprecated `compactLayout`, `fieldGroups` + `Field.group`) are pointers
8+
* into the object's own field map. A dangling pointer is Zod-valid but
9+
* silently inert at render time — the exact "parsed, unmarked, silently
10+
* inert" shape ADR-0078 prohibits — so the completeness lint flags it here,
11+
* uniformly for `os build`/`os validate`, MCP authoring and hand authors.
12+
*
13+
* All three rules are warnings, not errors: every consumer degrades
14+
* gracefully (an unknown `Field.group` renders in the ungrouped bucket, an
15+
* unknown highlight name is skipped, an unknown `stageField` falls back to
16+
* heuristics), so nothing is fully broken — but the author almost certainly
17+
* typo'd a name and should be told at author time, not discover it by
18+
* staring at an unchanged page.
19+
*/
20+
21+
export const FIELD_GROUP_UNDECLARED = 'field-group-undeclared';
22+
export const FIELD_GROUP_EMPTY = 'field-group-empty';
23+
export const SEMANTIC_ROLE_FIELD_UNKNOWN = 'semantic-role-field-unknown';
24+
25+
export type SemanticRoleSeverity = 'error' | 'warning';
26+
27+
export interface SemanticRoleFinding {
28+
/** Always `warning` today — all three rules are advisory (see module note). */
29+
severity: SemanticRoleSeverity;
30+
/** Diagnostic rule id, e.g. `field-group-undeclared`. */
31+
rule: string;
32+
/** Human-readable location, e.g. `object "invoice"`. */
33+
where: string;
34+
/** Config path, e.g. `objects[3]`. */
35+
path: string;
36+
/** What is wrong. */
37+
message: string;
38+
/** How to fix it. */
39+
hint: string;
40+
}
41+
42+
type AnyRec = Record<string, unknown>;
43+
44+
/** Coerce a collection (array or name-keyed map) to an array of records. */
45+
function asArray(v: unknown): AnyRec[] {
46+
if (Array.isArray(v)) return v as AnyRec[];
47+
if (v && typeof v === 'object') {
48+
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
49+
}
50+
return [];
51+
}
52+
53+
/**
54+
* Validate every object's semantic-role pointers. Returns the list of
55+
* findings (empty = clean). Advisory only — the caller must never fail the
56+
* build on these alone.
57+
*/
58+
export function validateSemanticRoles(stack: AnyRec): SemanticRoleFinding[] {
59+
const findings: SemanticRoleFinding[] = [];
60+
61+
const objects = asArray(stack.objects);
62+
for (let i = 0; i < objects.length; i++) {
63+
const obj = objects[i];
64+
if (!obj || typeof obj !== 'object') continue; // tolerate junk entries
65+
const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`;
66+
const where = `object "${objName}"`;
67+
const path = `objects[${i}]`;
68+
69+
const fields = (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields))
70+
? (obj.fields as Record<string, AnyRec | undefined>)
71+
: {};
72+
const fieldNames = new Set(Object.keys(fields));
73+
74+
// ── (a) Field.group → declared fieldGroups[].key ──
75+
const declaredGroups = new Set(
76+
(Array.isArray(obj.fieldGroups) ? obj.fieldGroups : [])
77+
.filter((g): g is AnyRec => !!g && typeof g === 'object')
78+
.map((g) => g.key)
79+
.filter((k): k is string => typeof k === 'string' && k.length > 0),
80+
);
81+
const referencedGroups = new Set<string>();
82+
for (const [fname, f] of Object.entries(fields)) {
83+
const g = f?.group;
84+
if (typeof g !== 'string' || g.length === 0) continue;
85+
referencedGroups.add(g);
86+
if (!declaredGroups.has(g)) {
87+
findings.push({
88+
severity: 'warning',
89+
rule: FIELD_GROUP_UNDECLARED,
90+
where,
91+
path: `${path}.fields.${fname}.group`,
92+
message:
93+
`${objName}.${fname}: group "${g}" is not declared in fieldGroups — ` +
94+
`the field renders in the ungrouped bucket, not under "${g}"`,
95+
hint:
96+
`Declare { key: '${g}', label: '…' } in ${objName}.fieldGroups, or fix ` +
97+
`the field's group reference. Group keys are snake_case and must match exactly.`,
98+
});
99+
}
100+
}
101+
102+
// ── (b) declared group no field references ──
103+
for (const key of declaredGroups) {
104+
if (!referencedGroups.has(key)) {
105+
findings.push({
106+
severity: 'warning',
107+
rule: FIELD_GROUP_EMPTY,
108+
where,
109+
path: `${path}.fieldGroups`,
110+
message:
111+
`${objName}: fieldGroups declares "${key}" but no field references it — ` +
112+
`the group never renders`,
113+
hint:
114+
`Assign at least one field via group: '${key}', or remove the unused ` +
115+
`group declaration.`,
116+
});
117+
}
118+
}
119+
120+
// ── (c) semantic-role pointers name real fields ──
121+
const stage = obj.stageField;
122+
if (typeof stage === 'string' && stage.length > 0 && !fieldNames.has(stage)) {
123+
findings.push({
124+
severity: 'warning',
125+
rule: SEMANTIC_ROLE_FIELD_UNKNOWN,
126+
where,
127+
path: `${path}.stageField`,
128+
message:
129+
`${objName}: stageField "${stage}" is not a field on this object — ` +
130+
`consumers fall back to heuristic stage detection`,
131+
hint:
132+
`Point stageField at an existing select/status field, or set ` +
133+
`stageField: false to declare the object has no linear lifecycle.`,
134+
});
135+
}
136+
137+
const highlights = Array.isArray(obj.highlightFields)
138+
? obj.highlightFields
139+
: Array.isArray(obj.compactLayout) // deprecated alias (pre-normalization input)
140+
? obj.compactLayout
141+
: [];
142+
for (const entry of highlights) {
143+
if (typeof entry !== 'string' || entry.length === 0 || fieldNames.has(entry)) continue;
144+
findings.push({
145+
severity: 'warning',
146+
rule: SEMANTIC_ROLE_FIELD_UNKNOWN,
147+
where,
148+
path: `${path}.highlightFields`,
149+
message:
150+
`${objName}: highlightFields entry "${entry}" is not a field on this ` +
151+
`object — it is silently skipped by every consumer`,
152+
hint:
153+
`Fix the field name (highlightFields drives default columns, cards, ` +
154+
`previews and the detail highlight strip, in order).`,
155+
});
156+
}
157+
}
158+
159+
return findings;
160+
}

packages/platform-objects/src/audit/sys-attachment.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const SysAttachment = ObjectSchema.create({
3131
managedBy: 'platform',
3232
description: 'Polymorphic link between a sys_file and any other record',
3333
titleFormat: '{file_name} → {parent_object}/{parent_id}',
34-
compactLayout: ['created_at', 'parent_object', 'file_name', 'mime_type', 'size'],
34+
highlightFields: ['created_at', 'parent_object', 'file_name', 'mime_type', 'size'],
3535

3636
fields: {
3737
id: Field.text({

packages/platform-objects/src/audit/sys-email-template.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export const SysEmailTemplate = ObjectSchema.create({
2727
displayNameField: 'label',
2828
nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
2929
titleFormat: '{label}',
30-
compactLayout: ['name', 'label', 'category', 'locale', 'active'],
30+
highlightFields: ['name', 'label', 'category', 'locale', 'active'],
3131

3232
fields: {
3333
id: Field.text({

packages/platform-objects/src/audit/sys-email.object.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export const SysEmail = ObjectSchema.create({
2929
displayNameField: 'subject',
3030
nameField: 'subject', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
3131
titleFormat: '{subject}',
32-
compactLayout: ['subject', 'to', 'status', 'sent_at'],
32+
highlightFields: ['subject', 'to', 'status', 'sent_at'],
3333

3434
fields: {
3535
id: Field.text({

0 commit comments

Comments
 (0)