From c87c33bf7c77f2148bce3a5ec9106fac87c4c713 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 29 Jun 2026 22:40:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(spec,lint):=20ADR-0079=20gate=20=E2=80=94?= =?UTF-8?q?=20deprecate=20titleFormat=20+=20record-title=20validator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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: Claude Opus 4.8 --- .changeset/adr-0079-record-title-gate.md | 24 ++++ packages/cli/src/commands/lint.ts | 19 +++ packages/lint/src/index.ts | 7 + .../lint/src/validate-record-title.test.ts | 98 ++++++++++++++ packages/lint/src/validate-record-title.ts | 123 ++++++++++++++++++ packages/spec/src/data/object.zod.ts | 2 +- pnpm-lock.yaml | 2 +- skills/objectstack-data/SKILL.md | 4 +- 8 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 .changeset/adr-0079-record-title-gate.md create mode 100644 packages/lint/src/validate-record-title.test.ts create mode 100644 packages/lint/src/validate-record-title.ts diff --git a/.changeset/adr-0079-record-title-gate.md b/.changeset/adr-0079-record-title-gate.md new file mode 100644 index 0000000000..e1f0b65b04 --- /dev/null +++ b/.changeset/adr-0079-record-title-gate.md @@ -0,0 +1,24 @@ +--- +'@objectstack/lint': minor +'@objectstack/cli': patch +'@objectstack/spec': patch +--- + +feat(lint): ADR-0079 record-title gate — deprecate titleFormat + record-title validator + +A record's human title is a structural invariant (ADR-0079): every object +resolves a primary title from a real STORED field via `nameField` (the +canonical pointer; `displayNameField` is the deprecated alias) or a +deterministic derivation. This adds build-time diagnostics so `os build` / +`os lint`, the MCP authoring surface, and hand-authoring all get the coverage +cloud graph-lint already has (the ADR-0078 "not cloud-only" principle): + +- `title-format-retired` — flags an object that declares a `titleFormat`. That + key is a render-only template the server can neither return nor query; + ADR-0079 retires it in favour of `nameField`. The schema still parses it + (existing metadata keeps loading), so this is advisory, not an error. +- `title-unresolvable` — flags an object whose title cannot be resolved from any + stored field (`objectTitleCompleteness` reports `status: 'none'`). + +`@objectstack/spec` carries the `titleFormat` `.describe()` deprecation note; +the `@objectstack/cli` `lint` command wires the new validator into its run. diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 6228a5ab89..73dc23a662 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -8,6 +8,7 @@ import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; import { validateWidgetBindings } from '@objectstack/lint'; +import { validateRecordTitle } from '@objectstack/lint'; import { collectAndLintDocs } from '../utils/collect-docs.js'; import { scoreMetadata } from '../lint/score.js'; import { runMetadataEval } from '../lint/metadata-eval.js'; @@ -303,6 +304,24 @@ export function lintConfig(config: any): LintIssue[] { }); } + // ── Record-title contract (ADR-0079) ── + // titleFormat is retired (render-only template the server can't return or + // query) in favour of nameField; and an object with no resolvable title + // (no nameField/displayNameField and nothing derivable) ships records with + // no meaningful name. Both are advisory warnings — the auto-provision + // transform and the `Record #` floor keep a green build from ever + // shipping a fully title-less object (the ADR-0078 "not cloud-only" parity + // with cloud graph-lint). + for (const t of validateRecordTitle(config)) { + issues.push({ + severity: t.severity, + rule: t.rule, + message: `${t.where}: ${t.message}`, + path: t.path, + fix: t.hint, + }); + } + return issues; } diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 8f7ce8c797..c93cf8d5de 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -37,3 +37,10 @@ export { export type { StyleFinding, StyleSeverity } from './validate-responsive-styles.js'; export { validateJsxPages } from './validate-jsx-pages.js'; export type { JsxPageFinding, JsxPageSeverity } from './validate-jsx-pages.js'; + +export { + validateRecordTitle, + TITLE_FORMAT_RETIRED, + TITLE_UNRESOLVABLE, +} from './validate-record-title.js'; +export type { RecordTitleFinding, RecordTitleSeverity } from './validate-record-title.js'; diff --git a/packages/lint/src/validate-record-title.test.ts b/packages/lint/src/validate-record-title.test.ts new file mode 100644 index 0000000000..c13184a79a --- /dev/null +++ b/packages/lint/src/validate-record-title.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { + validateRecordTitle, + TITLE_FORMAT_RETIRED, + TITLE_UNRESOLVABLE, +} from './validate-record-title.js'; + +/** One-object stack; `fields` is a name-keyed map (the parsed-config shape). */ +function objStack(obj: Record) { + return { objects: [{ name: 'invoice', ...obj }] }; +} + +describe('validateRecordTitle (record-title contract, ADR-0079)', () => { + it('an object with an explicit nameField is clean', () => { + const findings = validateRecordTitle( + objStack({ nameField: 'title', fields: { title: { type: 'text' } } }), + ); + expect(findings).toHaveLength(0); + }); + + it('a deprecated displayNameField alias still resolves (no warning)', () => { + const findings = validateRecordTitle( + objStack({ displayNameField: 'subject', fields: { subject: { type: 'text' } } }), + ); + expect(findings).toHaveLength(0); + }); + + it('a derivable title-eligible field (no explicit pointer) is clean', () => { + // `name` is derivable → completeness status 'derived', not 'none'. + const findings = validateRecordTitle( + objStack({ fields: { name: { type: 'text' } } }), + ); + expect(findings).toHaveLength(0); + }); + + it('warns (not errors) when titleFormat is declared', () => { + const findings = validateRecordTitle( + objStack({ + titleFormat: '{{record.first}} {{record.last}}', + fields: { name: { type: 'text' } }, + }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(TITLE_FORMAT_RETIRED); + expect(findings[0].where).toBe('object "invoice"'); + expect(findings[0].path).toBe('objects[0]'); + expect(findings[0].message).toContain('titleFormat is retired (ADR-0079)'); + expect(findings[0].message).toContain('migrate to nameField'); + expect(findings[0].hint).toContain('nameField'); + }); + + it('warns (not errors) when no title is resolvable (status: none)', () => { + const findings = validateRecordTitle( + objStack({ fields: { amount: { type: 'currency' }, when: { type: 'date' } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(TITLE_UNRESOLVABLE); + expect(findings[0].message).toContain('no resolvable record title'); + expect(findings[0].hint).toContain('nameField'); + }); + + it('a nameField that points at a not-yet-present field (synthesized) is not flagged', () => { + // status 'synthesized' (pointer set, field absent) is not 'none' → silent; + // the runtime materializes the primary. + const findings = validateRecordTitle( + objStack({ nameField: 'name', fields: { amount: { type: 'currency' } } }), + ); + expect(findings).toHaveLength(0); + }); + + it('reports BOTH rules when an object has titleFormat and no resolvable title', () => { + const findings = validateRecordTitle( + objStack({ + titleFormat: '{{record.amount}}', + fields: { amount: { type: 'currency' } }, + }), + ); + expect(findings).toHaveLength(2); + expect(findings.map((f) => f.rule).sort()).toEqual( + [TITLE_FORMAT_RETIRED, TITLE_UNRESOLVABLE].sort(), + ); + expect(findings.every((f) => f.severity === 'warning')).toBe(true); + }); + + it('an empty-string titleFormat is not flagged', () => { + const findings = validateRecordTitle( + objStack({ titleFormat: '', fields: { name: { type: 'text' } } }), + ); + expect(findings).toHaveLength(0); + }); + + it('a stack with no objects is clean', () => { + expect(validateRecordTitle({})).toHaveLength(0); + expect(validateRecordTitle({ objects: [] })).toHaveLength(0); + }); +}); diff --git a/packages/lint/src/validate-record-title.ts b/packages/lint/src/validate-record-title.ts new file mode 100644 index 0000000000..0b117cda03 --- /dev/null +++ b/packages/lint/src/validate-record-title.ts @@ -0,0 +1,123 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { objectTitleCompleteness } from '@objectstack/spec/data'; +import type { DisplayNameObjectMeta } from '@objectstack/spec/data'; + +/** + * Build-time record-title diagnostics (ADR-0079). + * + * A record's human title is a structural invariant: every object resolves a + * primary title from a real STORED field via `nameField` (the canonical + * pointer; `displayNameField` is the deprecated alias) or a deterministic + * derivation. Two authoring smells are flagged here so `os build`/`os lint`, + * the MCP authoring surface, and hand-authoring all get the coverage cloud + * graph-lint already has (the ADR-0078 "not cloud-only" principle): + * + * - `title-format-retired` — the object declares a `titleFormat`. That field + * is a RENDER-ONLY template the server can neither return nor query; ADR-0079 + * retires it in favour of `nameField`. The schema still parses it (existing + * metadata keeps loading), so this is advisory, not an error. + * - `title-unresolvable` — `objectTitleCompleteness` reports `status: 'none'`: + * no `nameField`/`displayNameField` pointer and no title-eligible field to + * derive one from. Records will have no meaningful title (the runtime falls + * back to the auto-provisioned primary / `Record #` floor), so this is a + * warning, not an error — nothing is fully broken. + * + * Both are warnings: the auto-provision transform and the id floor mean a + * green build never ships a fully title-less object. Reuses the shared spec + * predicate (`@objectstack/spec/data` → display-name) so cloud and framework + * classify titles identically. + */ + +export const TITLE_FORMAT_RETIRED = 'title-format-retired'; +export const TITLE_UNRESOLVABLE = 'title-unresolvable'; + +export type RecordTitleSeverity = 'error' | 'warning'; + +export interface RecordTitleFinding { + /** Always `warning` today — both rules are advisory (see module note). */ + severity: RecordTitleSeverity; + /** Diagnostic rule id (registry entry), e.g. `title-format-retired`. */ + rule: string; + /** Human-readable location, e.g. `object "invoice"`. */ + where: string; + /** Config path, e.g. `objects[3]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +/** 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 (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +/** + * Validate every object's record-title declaration. Returns the list of + * findings (empty = clean). Both rules are advisory (`warning`): the caller + * must never fail the build on them alone — auto-provision + the `Record #` + * floor guarantee a resolvable title at runtime. + */ +export function validateRecordTitle(stack: AnyRec): RecordTitleFinding[] { + const findings: RecordTitleFinding[] = []; + + const objects = asArray(stack.objects); + for (let i = 0; i < objects.length; i++) { + const obj = objects[i]; + const objName = typeof obj.name === 'string' ? obj.name : `(object ${i})`; + const where = `object "${objName}"`; + const path = `objects[${i}]`; + + // ── (a) titleFormat is retired (ADR-0079) ── + // Render-only template the server cannot return or query. Still parsed by + // the schema for back-compat, so advisory. + if (obj.titleFormat !== undefined && obj.titleFormat !== null && obj.titleFormat !== '') { + findings.push({ + severity: 'warning', + rule: TITLE_FORMAT_RETIRED, + where, + path, + message: + `${objName}: titleFormat is retired (ADR-0079) — migrate to nameField ` + + `(single field) or a formula field designated nameField`, + hint: + `titleFormat is a render-only template the server cannot return or ` + + `query, and an explicit nameField now takes precedence. For a ` + + `single-field title set nameField: ''. For a composite title, ` + + `add a formula field (returnType: 'text') and designate it via ` + + `nameField.`, + }); + } + + // ── (b) no resolvable title (status: 'none') ── + // Reuse the shared spec predicate so cloud graph-lint and framework lint + // classify titles identically. `none` = no pointer AND nothing derivable. + const completeness = objectTitleCompleteness(obj as DisplayNameObjectMeta); + if (completeness.status === 'none') { + findings.push({ + severity: 'warning', + rule: TITLE_UNRESOLVABLE, + where, + path, + message: + `${objName}: no resolvable record title — records will have no ` + + `meaningful name (no nameField and no title-eligible field to derive one)`, + hint: + `Set nameField to a text/email field (or a formula field with ` + + `returnType: 'text'), or add a text field named "name"/"title". The ` + + `runtime auto-provisions a primary and falls back to "Record #", ` + + `but an explicit title is far more useful.`, + }); + } + } + + return findings; +} diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 5d3f5c9351..1cd3e7aab3 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -569,7 +569,7 @@ const ObjectSchemaBase = z.object({ displayFormat: z.string().optional().describe('Auto-number format pattern (e.g., "CASE-{0000}", "INV-{YYYY}-{0000}")'), startNumber: z.number().int().min(0).optional().describe('Starting number for autonumber (default: 1)'), }).optional().describe('Record name generation configuration (Salesforce pattern)'), - titleFormat: TemplateExpressionInputSchema.optional().describe('Title template — supports {{record.field}} interpolation. Overrides displayNameField.'), + 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.'), compactLayout: z.array(z.string()).optional().describe('Primary fields for hover/cards/lookups'), /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8aeac2290..fc20bf6d70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -487,7 +487,7 @@ importers: specifier: ^4.1.0 version: 4.1.0 esbuild: - specifier: ^0.28.1 + specifier: '>=0.28.1' version: 0.28.1 ts-morph: specifier: ^28.0.0 diff --git a/skills/objectstack-data/SKILL.md b/skills/objectstack-data/SKILL.md index b9e96f5fc9..36bfc42d23 100644 --- a/skills/objectstack-data/SKILL.md +++ b/skills/objectstack-data/SKILL.md @@ -76,7 +76,9 @@ database table and exposes automatic CRUD APIs. | `pluralLabel` | — | Plural form (e.g., "Accounts") | | `namespace` | — | **Deprecated** — ignored by the runtime. Embed prefix directly in `name` instead (e.g. `name: 'crm_account'`) | | `datasource` | `'default'` | Target datasource ID for virtualized data | -| `displayNameField` | `'name'` | Field used as record display name | +| `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 | +| `displayNameField` | — | **Deprecated** alias for `nameField` (still honored as a fallback) | +| `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` | | `enable` | — | Capability flags (trackHistory, searchable, apiEnabled, etc.) | | `fieldGroups` | — | Ordered list of logical field groups for forms/detail pages (see [Field Groups](#field-groups-mvp)) |