Skip to content

Commit 58e8e31

Browse files
os-zhuangclaude
andauthored
feat(spec,lint): ADR-0079 gate — deprecate titleFormat + record-title validator (#2463)
- spec: titleFormat .describe() now '[DEPRECATED → nameField]' and corrects the stale "Overrides displayNameField" (Phase 2 makes nameField win). - @objectstack/lint: new validate-record-title — warns on a deprecated titleFormat (migrate to nameField / a formula field) and, via the shared objectTitleCompleteness predicate, warns when an object has no resolvable title. Wired into the validator registry + `os lint`, so os build/lint/MCP/ hand-authoring get the same coverage as cloud graph-lint (ADR-0078 not-cloud-only). - skills/objectstack-data: document nameField as canonical; displayNameField deprecated alias; titleFormat retired. Tests: validate-record-title 9/9; @objectstack/spec build + api-surface green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9ddf733 commit 58e8e31

7 files changed

Lines changed: 275 additions & 2 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/cli': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
feat(lint): ADR-0079 record-title gate — deprecate titleFormat + record-title validator
8+
9+
A record's human title is a structural invariant (ADR-0079): every object
10+
resolves a primary title from a real STORED field via `nameField` (the
11+
canonical pointer; `displayNameField` is the deprecated alias) or a
12+
deterministic derivation. This adds build-time diagnostics so `os build` /
13+
`os lint`, the MCP authoring surface, and hand-authoring all get the coverage
14+
cloud graph-lint already has (the ADR-0078 "not cloud-only" principle):
15+
16+
- `title-format-retired` — flags an object that declares a `titleFormat`. That
17+
key is a render-only template the server can neither return nor query;
18+
ADR-0079 retires it in favour of `nameField`. The schema still parses it
19+
(existing metadata keeps loading), so this is advisory, not an error.
20+
- `title-unresolvable` — flags an object whose title cannot be resolved from any
21+
stored field (`objectTitleCompleteness` reports `status: 'none'`).
22+
23+
`@objectstack/spec` carries the `titleFormat` `.describe()` deprecation note;
24+
the `@objectstack/cli` `lint` command wires the new validator into its run.

packages/cli/src/commands/lint.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +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';
1112
import { collectAndLintDocs } from '../utils/collect-docs.js';
1213
import { scoreMetadata } from '../lint/score.js';
1314
import { runMetadataEval } from '../lint/metadata-eval.js';
@@ -303,6 +304,24 @@ export function lintConfig(config: any): LintIssue[] {
303304
});
304305
}
305306

307+
// ── Record-title contract (ADR-0079) ──
308+
// titleFormat is retired (render-only template the server can't return or
309+
// query) in favour of nameField; and an object with no resolvable title
310+
// (no nameField/displayNameField and nothing derivable) ships records with
311+
// no meaningful name. Both are advisory warnings — the auto-provision
312+
// transform and the `Record #<id>` floor keep a green build from ever
313+
// shipping a fully title-less object (the ADR-0078 "not cloud-only" parity
314+
// with cloud graph-lint).
315+
for (const t of validateRecordTitle(config)) {
316+
issues.push({
317+
severity: t.severity,
318+
rule: t.rule,
319+
message: `${t.where}: ${t.message}`,
320+
path: t.path,
321+
fix: t.hint,
322+
});
323+
}
324+
306325
return issues;
307326
}
308327

packages/lint/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,10 @@ export {
3737
export type { StyleFinding, StyleSeverity } from './validate-responsive-styles.js';
3838
export { validateJsxPages } from './validate-jsx-pages.js';
3939
export type { JsxPageFinding, JsxPageSeverity } from './validate-jsx-pages.js';
40+
41+
export {
42+
validateRecordTitle,
43+
TITLE_FORMAT_RETIRED,
44+
TITLE_UNRESOLVABLE,
45+
} from './validate-record-title.js';
46+
export type { RecordTitleFinding, RecordTitleSeverity } from './validate-record-title.js';
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, it, expect } from 'vitest';
2+
import {
3+
validateRecordTitle,
4+
TITLE_FORMAT_RETIRED,
5+
TITLE_UNRESOLVABLE,
6+
} from './validate-record-title.js';
7+
8+
/** One-object stack; `fields` is a name-keyed map (the parsed-config shape). */
9+
function objStack(obj: Record<string, unknown>) {
10+
return { objects: [{ name: 'invoice', ...obj }] };
11+
}
12+
13+
describe('validateRecordTitle (record-title contract, ADR-0079)', () => {
14+
it('an object with an explicit nameField is clean', () => {
15+
const findings = validateRecordTitle(
16+
objStack({ nameField: 'title', fields: { title: { type: 'text' } } }),
17+
);
18+
expect(findings).toHaveLength(0);
19+
});
20+
21+
it('a deprecated displayNameField alias still resolves (no warning)', () => {
22+
const findings = validateRecordTitle(
23+
objStack({ displayNameField: 'subject', fields: { subject: { type: 'text' } } }),
24+
);
25+
expect(findings).toHaveLength(0);
26+
});
27+
28+
it('a derivable title-eligible field (no explicit pointer) is clean', () => {
29+
// `name` is derivable → completeness status 'derived', not 'none'.
30+
const findings = validateRecordTitle(
31+
objStack({ fields: { name: { type: 'text' } } }),
32+
);
33+
expect(findings).toHaveLength(0);
34+
});
35+
36+
it('warns (not errors) when titleFormat is declared', () => {
37+
const findings = validateRecordTitle(
38+
objStack({
39+
titleFormat: '{{record.first}} {{record.last}}',
40+
fields: { name: { type: 'text' } },
41+
}),
42+
);
43+
expect(findings).toHaveLength(1);
44+
expect(findings[0].severity).toBe('warning');
45+
expect(findings[0].rule).toBe(TITLE_FORMAT_RETIRED);
46+
expect(findings[0].where).toBe('object "invoice"');
47+
expect(findings[0].path).toBe('objects[0]');
48+
expect(findings[0].message).toContain('titleFormat is retired (ADR-0079)');
49+
expect(findings[0].message).toContain('migrate to nameField');
50+
expect(findings[0].hint).toContain('nameField');
51+
});
52+
53+
it('warns (not errors) when no title is resolvable (status: none)', () => {
54+
const findings = validateRecordTitle(
55+
objStack({ fields: { amount: { type: 'currency' }, when: { type: 'date' } } }),
56+
);
57+
expect(findings).toHaveLength(1);
58+
expect(findings[0].severity).toBe('warning');
59+
expect(findings[0].rule).toBe(TITLE_UNRESOLVABLE);
60+
expect(findings[0].message).toContain('no resolvable record title');
61+
expect(findings[0].hint).toContain('nameField');
62+
});
63+
64+
it('a nameField that points at a not-yet-present field (synthesized) is not flagged', () => {
65+
// status 'synthesized' (pointer set, field absent) is not 'none' → silent;
66+
// the runtime materializes the primary.
67+
const findings = validateRecordTitle(
68+
objStack({ nameField: 'name', fields: { amount: { type: 'currency' } } }),
69+
);
70+
expect(findings).toHaveLength(0);
71+
});
72+
73+
it('reports BOTH rules when an object has titleFormat and no resolvable title', () => {
74+
const findings = validateRecordTitle(
75+
objStack({
76+
titleFormat: '{{record.amount}}',
77+
fields: { amount: { type: 'currency' } },
78+
}),
79+
);
80+
expect(findings).toHaveLength(2);
81+
expect(findings.map((f) => f.rule).sort()).toEqual(
82+
[TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE].sort(),
83+
);
84+
expect(findings.every((f) => f.severity === 'warning')).toBe(true);
85+
});
86+
87+
it('an empty-string titleFormat is not flagged', () => {
88+
const findings = validateRecordTitle(
89+
objStack({ titleFormat: '', fields: { name: { type: 'text' } } }),
90+
);
91+
expect(findings).toHaveLength(0);
92+
});
93+
94+
it('a stack with no objects is clean', () => {
95+
expect(validateRecordTitle({})).toHaveLength(0);
96+
expect(validateRecordTitle({ objects: [] })).toHaveLength(0);
97+
});
98+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { objectTitleCompleteness } from '@objectstack/spec/data';
4+
import type { DisplayNameObjectMeta } from '@objectstack/spec/data';
5+
6+
/**
7+
* Build-time record-title diagnostics (ADR-0079).
8+
*
9+
* A record's human title is a structural invariant: every object resolves a
10+
* primary title from a real STORED field via `nameField` (the canonical
11+
* pointer; `displayNameField` is the deprecated alias) or a deterministic
12+
* derivation. Two authoring smells are flagged here so `os build`/`os lint`,
13+
* the MCP authoring surface, and hand-authoring all get the coverage cloud
14+
* graph-lint already has (the ADR-0078 "not cloud-only" principle):
15+
*
16+
* - `title-format-retired` — the object declares a `titleFormat`. That field
17+
* is a RENDER-ONLY template the server can neither return nor query; ADR-0079
18+
* retires it in favour of `nameField`. The schema still parses it (existing
19+
* metadata keeps loading), so this is advisory, not an error.
20+
* - `title-unresolvable` — `objectTitleCompleteness` reports `status: 'none'`:
21+
* no `nameField`/`displayNameField` pointer and no title-eligible field to
22+
* derive one from. Records will have no meaningful title (the runtime falls
23+
* back to the auto-provisioned primary / `Record #<id>` floor), so this is a
24+
* warning, not an error — nothing is fully broken.
25+
*
26+
* Both are warnings: the auto-provision transform and the id floor mean a
27+
* green build never ships a fully title-less object. Reuses the shared spec
28+
* predicate (`@objectstack/spec/data` → display-name) so cloud and framework
29+
* classify titles identically.
30+
*/
31+
32+
export const TITLE_FORMAT_RETIRED = 'title-format-retired';
33+
export const TITLE_UNRESOLVABLE = 'title-unresolvable';
34+
35+
export type RecordTitleSeverity = 'error' | 'warning';
36+
37+
export interface RecordTitleFinding {
38+
/** Always `warning` today — both rules are advisory (see module note). */
39+
severity: RecordTitleSeverity;
40+
/** Diagnostic rule id (registry entry), e.g. `title-format-retired`. */
41+
rule: string;
42+
/** Human-readable location, e.g. `object "invoice"`. */
43+
where: string;
44+
/** Config path, e.g. `objects[3]`. */
45+
path: string;
46+
/** What is wrong. */
47+
message: string;
48+
/** How to fix it. */
49+
hint: string;
50+
}
51+
52+
type AnyRec = Record<string, unknown>;
53+
54+
/** Coerce a collection (array or name-keyed map) to an array of records. */
55+
function asArray(v: unknown): AnyRec[] {
56+
if (Array.isArray(v)) return v as AnyRec[];
57+
if (v && typeof v === 'object') {
58+
return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) }));
59+
}
60+
return [];
61+
}
62+
63+
/**
64+
* Validate every object's record-title declaration. Returns the list of
65+
* findings (empty = clean). Both rules are advisory (`warning`): the caller
66+
* must never fail the build on them alone — auto-provision + the `Record #<id>`
67+
* floor guarantee a resolvable title at runtime.
68+
*/
69+
export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] {
70+
const findings: RecordTitleFinding[] = [];
71+
72+
const objects = asArray(stack.objects);
73+
for (let i = 0; i < objects.length; i++) {
74+
const obj = objects[i];
75+
const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`;
76+
const where = `object "${objName}"`;
77+
const path = `objects[${i}]`;
78+
79+
// ── (a) titleFormat is retired (ADR-0079) ──
80+
// Render-only template the server cannot return or query. Still parsed by
81+
// the schema for back-compat, so advisory.
82+
if (obj.titleFormat !== undefined && obj.titleFormat !== null && obj.titleFormat !== '') {
83+
findings.push({
84+
severity: 'warning',
85+
rule: TITLE_FORMAT_RETIRED,
86+
where,
87+
path,
88+
message:
89+
`${objName}: titleFormat is retired (ADR-0079) — migrate to nameField ` +
90+
`(single field) or a formula field designated nameField`,
91+
hint:
92+
`titleFormat is a render-only template the server cannot return or ` +
93+
`query, and an explicit nameField now takes precedence. For a ` +
94+
`single-field title set nameField: '<field>'. For a composite title, ` +
95+
`add a formula field (returnType: 'text') and designate it via ` +
96+
`nameField.`,
97+
});
98+
}
99+
100+
// ── (b) no resolvable title (status: 'none') ──
101+
// Reuse the shared spec predicate so cloud graph-lint and framework lint
102+
// classify titles identically. `none` = no pointer AND nothing derivable.
103+
const completeness = objectTitleCompleteness(obj as DisplayNameObjectMeta);
104+
if (completeness.status === 'none') {
105+
findings.push({
106+
severity: 'warning',
107+
rule: TITLE_UNRESOLVABLE,
108+
where,
109+
path,
110+
message:
111+
`${objName}: no resolvable record title — records will have no ` +
112+
`meaningful name (no nameField and no title-eligible field to derive one)`,
113+
hint:
114+
`Set nameField to a text/email field (or a formula field with ` +
115+
`returnType: 'text'), or add a text field named "name"/"title". The ` +
116+
`runtime auto-provisions a primary and falls back to "Record #<id>", ` +
117+
`but an explicit title is far more useful.`,
118+
});
119+
}
120+
}
121+
122+
return findings;
123+
}

packages/spec/src/data/object.zod.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,7 @@ const ObjectSchemaBase = z.object({
569569
displayFormat: z.string().optional().describe('Auto-number format pattern (e.g., "CASE-{0000}", "INV-{YYYY}-{0000}")'),
570570
startNumber: z.number().int().min(0).optional().describe('Starting number for autonumber (default: 1)'),
571571
}).optional().describe('Record name generation configuration (Salesforce pattern)'),
572-
titleFormat: TemplateExpressionInputSchema.optional().describe('Title template — supports {{record.field}} interpolation. Overrides displayNameField.'),
572+
titleFormat: TemplateExpressionInputSchema.optional().describe('[DEPRECATED → nameField (ADR-0079)] Render-only title template; the server cannot return or query it, and an explicit nameField now takes precedence. Migrate a single-field title to nameField, a composite to a formula field designated as nameField.'),
573573
compactLayout: z.array(z.string()).optional().describe('Primary fields for hover/cards/lookups'),
574574

575575
/**

skills/objectstack-data/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ database table and exposes automatic CRUD APIs.
7676
| `pluralLabel` || Plural form (e.g., "Accounts") |
7777
| `namespace` || **Deprecated** — ignored by the runtime. Embed prefix directly in `name` instead (e.g. `name: 'crm_account'`) |
7878
| `datasource` | `'default'` | Target datasource ID for virtualized data |
79-
| `displayNameField` | `'name'` | Field used as record display name |
79+
| `nameField` | derived (e.g. `'name'`/`'title'`) | **Canonical** record-title field — the stored field used as the record's display name. Use a single text/email field, or a formula field (`returnType: 'text'`) for a composite title |
80+
| `displayNameField` || **Deprecated** alias for `nameField` (still honored as a fallback) |
81+
| `titleFormat` || **Retired (ADR-0079)** — a render-only template the server can't return or query. Use `nameField`; for a composite title, designate a `returnType: 'text'` formula field as `nameField` |
8082
| `enable` || Capability flags (trackHistory, searchable, apiEnabled, etc.) |
8183
| `fieldGroups` || Ordered list of logical field groups for forms/detail pages (see [Field Groups](#field-groups-mvp)) |
8284

0 commit comments

Comments
 (0)