Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/adr-0079-record-title-gate.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 #<id>` 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;
}

Expand Down
7 changes: 7 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
98 changes: 98 additions & 0 deletions packages/lint/src/validate-record-title.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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);
});
});
123 changes: 123 additions & 0 deletions packages/lint/src/validate-record-title.ts
Original file line number Diff line number Diff line change
@@ -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 #<id>` 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<string, unknown>;

/** 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 #<id>`
* 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: '<field>'. 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 #<id>", ` +
`but an explicit title is far more useful.`,
});
}
}

return findings;
}
2 changes: 1 addition & 1 deletion packages/spec/src/data/object.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),

/**
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion skills/objectstack-data/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)) |

Expand Down