|
| 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 | +} |
0 commit comments