diff --git a/.changeset/lint-missing-name-field-reads-name-field.md b/.changeset/lint-missing-name-field-reads-name-field.md new file mode 100644 index 0000000000..51786fe0e1 --- /dev/null +++ b/.changeset/lint-missing-name-field-reads-name-field.md @@ -0,0 +1,36 @@ +--- +"@objectstack/lint": patch +--- + +fix(lint): `object/missing-name-field` 认 `nameField`、不再把已退役的 `titleFormat` 当作 name 面(#6108) + +`object/missing-name-field` 的谓词从来不读 `obj.nameField`,却仍然采信 `obj.titleFormat`: + +``` +hasNameField = !!obj.primaryField || !!obj.titleFormat || fields.some(name-like) +``` + +净效果是同一个包里两条规则互相矛盾。`validate-record-title.ts` 把每一处 `titleFormat` +声明都报成 `title-format-retired`,并按 **ADR-0079** 指示作者迁移到 `nameField` +(`titleFormat` 是 render-only 模板,服务端既不能返回也不能查询);而共享的 +`objectTitleCompleteness`(`@objectstack/spec/data`)判定标题面时也从不读它。于是: +**照平台自己的迁移建议把 `titleFormat` 换成 `nameField` 的对象,反而多得一条 +"records will display as raw IDs" suggestion;守着已退役的键不动的对象反而干净。** + +下游实测(hotcrm main,`@objectstack/* 17.0.0-rc.3`):6 处命中里 4 处是误报, +四个对象——`crm_campaign_member` / `crm_event_attendee` / `crm_contract` / +`crm_forecast`——都显式声明了 `nameField`;只有两个 line-item 对象是真命中。 + +本次修正: + +- 谓词补读 `nameField`(ADR-0079 的规范主标题指针),显式声明它的对象不再被告警; +- 摘掉 `titleFormat` 这一支。**只声明 `titleFormat`、没有 `nameField` 的对象因此会 + 新得一条本规则的 suggestion** —— 这是刻意的翻转,不是回归:这类对象正是 ADR-0079 + 要求迁移的那一批,`validate-record-title` 今天已经对它同时报 + `title-format-retired` 与 `title-unresolvable`。两条规则从此对同一个对象给出一致判断; +- `primaryField` 与 name-like 字段两支行为不变; +- 提示文案改为只点名作者真正能声明的面(`nameField` 与 name-like 字段),并新增 `fix` 提示 + 说明 `titleFormat` 不算标题面 —— 读到旧文案的作者很容易顺手再写一个 `titleFormat`, + 又掉回同一个矛盾里。旧文案里的 `primaryField` 同时不再出现:该键在 `packages/spec` 中 + 没有任何声明,`ObjectSchema.create()` 会以 `unrecognized_keys` 拒收它(实测,已立 #6326), + 提示不该向作者广告一个会被 schema 硬拒的键。谓词里的这一支保持不动。 diff --git a/packages/cli/test/data-model-rules.test.ts b/packages/cli/test/data-model-rules.test.ts index b70a6704f4..0f981dfc5d 100644 --- a/packages/cli/test/data-model-rules.test.ts +++ b/packages/cli/test/data-model-rules.test.ts @@ -146,6 +146,115 @@ describe('lintDataModel — fields & objects', () => { }); }); +// #6108 — `object/missing-name-field` used to read `titleFormat` (retired by +// ADR-0079, and reported as `title-format-retired` by validate-record-title in +// the very same package) while never reading `nameField` at all. An author who +// followed the platform's own migration advice therefore EARNED a suggestion. +// The fixtures below replicate the downstream control surface measured on +// hotcrm main (6 hits, 4 of them false positives — hotcrm#715 / #1007). +describe('lintDataModel — object/missing-name-field (ADR-0079 title face)', () => { + const flagged = (objects: any[]) => + lintDataModel(objects) + .filter((i) => i.rule === 'object/missing-name-field') + .map((i) => i.path); + + // (a) The false positive this fixes. `contract_number` is deliberately NOT in + // NAME_LIKE_FIELDS, so the only title face is the explicit pointer. + it('accepts an object whose title face is an explicit nameField', () => { + const issues = lintDataModel([ + { + name: 'crm_contract', + nameField: 'contract_number', + fields: { contract_number: { type: 'text' }, amount: { type: 'currency' } }, + }, + ]); + expect(has(issues, 'object/missing-name-field')).toBe(false); + }); + + // (b) The true hit must survive: a line item with no title face at all. + it('still suggests a name field for an object with no title face', () => { + const issues = lintDataModel([ + { + name: 'crm_quote_line_item', + fields: { + quote: { type: 'master_detail', reference: 'crm_quote' }, + quantity: { type: 'number' }, + unit_price: { type: 'currency' }, + }, + }, + ]); + expect(has(issues, 'object/missing-name-field')).toBe(true); + }); + + // (c) The two untouched limbs, isolated from each other: neither fixture + // carries a field name that the other limb would also rescue. + it('leaves the primaryField and name-like limbs unchanged', () => { + expect( + has( + lintDataModel([ + { name: 'crm_forecast_period', primaryField: 'period_key', fields: { period_key: { type: 'text' } } }, + ]), + 'object/missing-name-field', + ), + ).toBe(false); + expect( + has( + lintDataModel([ + { name: 'crm_campaign', fields: { name: { type: 'text' }, budget: { type: 'currency' } } }, + ]), + 'object/missing-name-field', + ), + ).toBe(false); + }); + + // (d) DELIBERATE FLIP, not a regression: a titleFormat-only object is now + // reported. It has no `nameField`, and ADR-0079 wants exactly this object + // migrated — `validate-record-title` already reports it twice today + // (`title-format-retired` + `title-unresolvable`, pinned in + // packages/lint/src/validate-record-title.test.ts). The two rules used to + // disagree about the same object; now they agree. + it('suggests a name field for a titleFormat-only object (retired key is not a title face)', () => { + const issues = lintDataModel([ + { + name: 'crm_pipeline_snapshot', + titleFormat: '{issued_on} · {amount}', + fields: { issued_on: { type: 'date' }, amount: { type: 'currency' } }, + }, + ]); + expect(has(issues, 'object/missing-name-field')).toBe(true); + }); + + // The measured control surface, end to end: the four objects hotcrm declared + // a `nameField` on must fall out, the two line items must stay. + it('reproduces the hotcrm control surface: 6 objects in, only the 2 line items flagged', () => { + const objects = [ + { name: 'crm_campaign_member', nameField: 'member_number', fields: { member_number: { type: 'text' } } }, + { name: 'crm_event_attendee', nameField: 'attendee_number', fields: { attendee_number: { type: 'text' } } }, + { name: 'crm_contract', nameField: 'contract_number', fields: { contract_number: { type: 'text' } } }, + { name: 'crm_forecast', nameField: 'display_title', fields: { display_title: { type: 'text' } } }, + { name: 'crm_opportunity_line_item', fields: { quantity: { type: 'number' } } }, + { name: 'crm_quote_line_item', fields: { quantity: { type: 'number' } } }, + ]; + expect(flagged(objects)).toEqual(['objects[4].fields', 'objects[5].fields']); + }); + + // The suggestion must name the canonical pointer — an author who reads it + // and reaches for `titleFormat` lands straight back in the contradiction. + // It must equally NOT name `primaryField`: that key is declared nowhere in + // `packages/spec`, so `ObjectSchema.create()` rejects it (#6326). The + // predicate still reads the limb; the diagnostic must not advertise it. + it('steers the author to nameField, and names no key the schema rejects', () => { + const issue = lintDataModel([ + { name: 'crm_quote_line_item', fields: { quantity: { type: 'number' } } }, + ]).find((i) => i.rule === 'object/missing-name-field'); + expect(issue?.severity).toBe('suggestion'); + expect(issue?.message).toContain('nameField'); + expect(issue?.message).not.toContain('primaryField'); + expect(issue?.fix).toContain('ADR-0079'); + expect(issue?.fix).toContain('titleFormat'); + }); +}); + describe('lintConfig integration', () => { it('a clean invoice/line model produces no data-model errors or warnings', () => { const issues = lintConfig({ diff --git a/packages/lint/src/data-model-rules.ts b/packages/lint/src/data-model-rules.ts index 2e76ee1a33..b95683e33e 100644 --- a/packages/lint/src/data-model-rules.ts +++ b/packages/lint/src/data-model-rules.ts @@ -380,16 +380,46 @@ export function lintDataModel(objects: any[]): LintIssue[] { const fields = fieldEntries(obj.fields); // R9 — object should have a derivable display/primary field. + // + // `nameField` is ADR-0079's canonical primary-title pointer, so an object + // that declares one HAS a title face. `titleFormat` is deliberately NOT one: + // the same ADR retires it (it is a render-only template the server can + // neither return nor query), `validate-record-title.ts` reports every + // declaration of it as `title-format-retired` and steers the author to + // `nameField`, and the shared spec predicate `objectTitleCompleteness` + // (packages/spec/src/data/display-name.ts) never reads it either. + // + // Reading `titleFormat` while ignoring `nameField` made this rule + // contradict its own package (#6108): an author who followed the platform's + // own migration advice earned a "records will display as raw IDs" + // suggestion, while one who kept the retired key did not. `primaryField` + // and the name-like derivation are unchanged. + // + // `primaryField` is kept as-is, but do NOT read it as evidence that the key + // is authorable: measured on 17.0.0-rc.5, `ObjectSchema.safeParse` reports + // `unrecognized_keys: ['primaryField']` and `ObjectSchema.create()` rejects + // it outright, so this limb can never be true for an object the spec + // accepts. Filed as #6326 (it is declared nowhere in `packages/spec`, yet + // this rule, `validate-semantic-roles` and the objectstack-data skill doc + // all treat it as a title face) — removing the limb is that issue's call, + // not a rider here. The MESSAGE, however, must not advertise it: telling an + // author to reach for `primaryField` earns them a hard schema rejection, so + // the diagnostic names only the surfaces they can actually declare. const hasNameField = + !!obj.nameField || !!obj.primaryField || - !!obj.titleFormat || fields.some((f) => NAME_LIKE_FIELDS.includes(f.name)); if (fields.length > 0 && !hasNameField) { issues.push({ severity: 'suggestion', rule: 'object/missing-name-field', - message: `Object "${obj.name}" has no name/title field or primaryField — records will display as raw IDs`, + message: `Object "${obj.name}" has no nameField and no name-like field — records will display as raw IDs`, path: `${objPath}.fields`, + fix: + `Set \`nameField: ''\` — ADR-0079's canonical primary-title pointer — to a stored ` + + `text/autonumber field, or to a formula field with \`returnType: 'text'\` for a composite ` + + `title. A \`titleFormat\` template does NOT count: it is retired (ADR-0079) and render-only, ` + + `so the server can neither return nor query the title it renders.`, }); }