Skip to content

Commit a5ca08d

Browse files
hotlongclaude
andauthored
fix(lint): object/missing-name-field 认 nameField、不再采信已退役的 titleFormat (#6108) (#6338)
`object/missing-name-field` 的谓词从不读 `obj.nameField`,却仍采信 `obj.titleFormat`, 于是同一个包里两条规则互相矛盾:`validate-record-title.ts` 把每一处 `titleFormat` 都报成 `title-format-retired` 并按 ADR-0079 指示迁移到 `nameField`,共享判定 `objectTitleCompleteness` 也从不读它——照这条迁移建议做的对象反而多得一条 "records will display as raw IDs" suggestion。下游 hotcrm main 实测 6 命中 4 误报, 四个对象都显式声明了 `nameField`。 - 谓词补读 `nameField`,摘掉 `titleFormat` 一支;`primaryField` 与 name-like 两支不动。 - titleFormat-only 的对象因此新得本规则一条 suggestion:刻意翻转,不是回归—— 这类对象正是 ADR-0079 要迁移的那批,`validate-record-title` 今天已对其同时报 `title-format-retired` 与 `title-unresolvable`,两条规则从此判定一致。 - 提示文案只点名作者真能声明的面,并新增 `fix` 说明 `titleFormat` 不算标题面。 旧文案里的 `primaryField` 不再出现:实测 `ObjectSchema.create()` 以 `unrecognized_keys` 拒收该键(另立 #6326),提示不该广告一个会被 schema 硬拒的键。 - 新增断言复刻 hotcrm 对照面(6 进 2 出),并覆盖 nameField / 无 name 面 / primaryField / name-like / titleFormat-only 五条路径。 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a585374 commit a5ca08d

3 files changed

Lines changed: 177 additions & 2 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): `object/missing-name-field``nameField`、不再把已退役的 `titleFormat` 当作 name 面(#6108)
6+
7+
`object/missing-name-field` 的谓词从来不读 `obj.nameField`,却仍然采信 `obj.titleFormat`:
8+
9+
```
10+
hasNameField = !!obj.primaryField || !!obj.titleFormat || fields.some(name-like)
11+
```
12+
13+
净效果是同一个包里两条规则互相矛盾。`validate-record-title.ts` 把每一处 `titleFormat`
14+
声明都报成 `title-format-retired`,并按 **ADR-0079** 指示作者迁移到 `nameField`
15+
(`titleFormat` 是 render-only 模板,服务端既不能返回也不能查询);而共享的
16+
`objectTitleCompleteness`(`@objectstack/spec/data`)判定标题面时也从不读它。于是:
17+
**照平台自己的迁移建议把 `titleFormat` 换成 `nameField` 的对象,反而多得一条
18+
"records will display as raw IDs" suggestion;守着已退役的键不动的对象反而干净。**
19+
20+
下游实测(hotcrm main,`@objectstack/* 17.0.0-rc.3`):6 处命中里 4 处是误报,
21+
四个对象——`crm_campaign_member` / `crm_event_attendee` / `crm_contract` /
22+
`crm_forecast`——都显式声明了 `nameField`;只有两个 line-item 对象是真命中。
23+
24+
本次修正:
25+
26+
- 谓词补读 `nameField`(ADR-0079 的规范主标题指针),显式声明它的对象不再被告警;
27+
- 摘掉 `titleFormat` 这一支。**只声明 `titleFormat`、没有 `nameField` 的对象因此会
28+
新得一条本规则的 suggestion** —— 这是刻意的翻转,不是回归:这类对象正是 ADR-0079
29+
要求迁移的那一批,`validate-record-title` 今天已经对它同时报
30+
`title-format-retired``title-unresolvable`。两条规则从此对同一个对象给出一致判断;
31+
- `primaryField` 与 name-like 字段两支行为不变;
32+
- 提示文案改为只点名作者真正能声明的面(`nameField` 与 name-like 字段),并新增 `fix` 提示
33+
说明 `titleFormat` 不算标题面 —— 读到旧文案的作者很容易顺手再写一个 `titleFormat`,
34+
又掉回同一个矛盾里。旧文案里的 `primaryField` 同时不再出现:该键在 `packages/spec`
35+
没有任何声明,`ObjectSchema.create()` 会以 `unrecognized_keys` 拒收它(实测,已立 #6326),
36+
提示不该向作者广告一个会被 schema 硬拒的键。谓词里的这一支保持不动。

packages/cli/test/data-model-rules.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,115 @@ describe('lintDataModel — fields & objects', () => {
146146
});
147147
});
148148

149+
// #6108 — `object/missing-name-field` used to read `titleFormat` (retired by
150+
// ADR-0079, and reported as `title-format-retired` by validate-record-title in
151+
// the very same package) while never reading `nameField` at all. An author who
152+
// followed the platform's own migration advice therefore EARNED a suggestion.
153+
// The fixtures below replicate the downstream control surface measured on
154+
// hotcrm main (6 hits, 4 of them false positives — hotcrm#715 / #1007).
155+
describe('lintDataModel — object/missing-name-field (ADR-0079 title face)', () => {
156+
const flagged = (objects: any[]) =>
157+
lintDataModel(objects)
158+
.filter((i) => i.rule === 'object/missing-name-field')
159+
.map((i) => i.path);
160+
161+
// (a) The false positive this fixes. `contract_number` is deliberately NOT in
162+
// NAME_LIKE_FIELDS, so the only title face is the explicit pointer.
163+
it('accepts an object whose title face is an explicit nameField', () => {
164+
const issues = lintDataModel([
165+
{
166+
name: 'crm_contract',
167+
nameField: 'contract_number',
168+
fields: { contract_number: { type: 'text' }, amount: { type: 'currency' } },
169+
},
170+
]);
171+
expect(has(issues, 'object/missing-name-field')).toBe(false);
172+
});
173+
174+
// (b) The true hit must survive: a line item with no title face at all.
175+
it('still suggests a name field for an object with no title face', () => {
176+
const issues = lintDataModel([
177+
{
178+
name: 'crm_quote_line_item',
179+
fields: {
180+
quote: { type: 'master_detail', reference: 'crm_quote' },
181+
quantity: { type: 'number' },
182+
unit_price: { type: 'currency' },
183+
},
184+
},
185+
]);
186+
expect(has(issues, 'object/missing-name-field')).toBe(true);
187+
});
188+
189+
// (c) The two untouched limbs, isolated from each other: neither fixture
190+
// carries a field name that the other limb would also rescue.
191+
it('leaves the primaryField and name-like limbs unchanged', () => {
192+
expect(
193+
has(
194+
lintDataModel([
195+
{ name: 'crm_forecast_period', primaryField: 'period_key', fields: { period_key: { type: 'text' } } },
196+
]),
197+
'object/missing-name-field',
198+
),
199+
).toBe(false);
200+
expect(
201+
has(
202+
lintDataModel([
203+
{ name: 'crm_campaign', fields: { name: { type: 'text' }, budget: { type: 'currency' } } },
204+
]),
205+
'object/missing-name-field',
206+
),
207+
).toBe(false);
208+
});
209+
210+
// (d) DELIBERATE FLIP, not a regression: a titleFormat-only object is now
211+
// reported. It has no `nameField`, and ADR-0079 wants exactly this object
212+
// migrated — `validate-record-title` already reports it twice today
213+
// (`title-format-retired` + `title-unresolvable`, pinned in
214+
// packages/lint/src/validate-record-title.test.ts). The two rules used to
215+
// disagree about the same object; now they agree.
216+
it('suggests a name field for a titleFormat-only object (retired key is not a title face)', () => {
217+
const issues = lintDataModel([
218+
{
219+
name: 'crm_pipeline_snapshot',
220+
titleFormat: '{issued_on} · {amount}',
221+
fields: { issued_on: { type: 'date' }, amount: { type: 'currency' } },
222+
},
223+
]);
224+
expect(has(issues, 'object/missing-name-field')).toBe(true);
225+
});
226+
227+
// The measured control surface, end to end: the four objects hotcrm declared
228+
// a `nameField` on must fall out, the two line items must stay.
229+
it('reproduces the hotcrm control surface: 6 objects in, only the 2 line items flagged', () => {
230+
const objects = [
231+
{ name: 'crm_campaign_member', nameField: 'member_number', fields: { member_number: { type: 'text' } } },
232+
{ name: 'crm_event_attendee', nameField: 'attendee_number', fields: { attendee_number: { type: 'text' } } },
233+
{ name: 'crm_contract', nameField: 'contract_number', fields: { contract_number: { type: 'text' } } },
234+
{ name: 'crm_forecast', nameField: 'display_title', fields: { display_title: { type: 'text' } } },
235+
{ name: 'crm_opportunity_line_item', fields: { quantity: { type: 'number' } } },
236+
{ name: 'crm_quote_line_item', fields: { quantity: { type: 'number' } } },
237+
];
238+
expect(flagged(objects)).toEqual(['objects[4].fields', 'objects[5].fields']);
239+
});
240+
241+
// The suggestion must name the canonical pointer — an author who reads it
242+
// and reaches for `titleFormat` lands straight back in the contradiction.
243+
// It must equally NOT name `primaryField`: that key is declared nowhere in
244+
// `packages/spec`, so `ObjectSchema.create()` rejects it (#6326). The
245+
// predicate still reads the limb; the diagnostic must not advertise it.
246+
it('steers the author to nameField, and names no key the schema rejects', () => {
247+
const issue = lintDataModel([
248+
{ name: 'crm_quote_line_item', fields: { quantity: { type: 'number' } } },
249+
]).find((i) => i.rule === 'object/missing-name-field');
250+
expect(issue?.severity).toBe('suggestion');
251+
expect(issue?.message).toContain('nameField');
252+
expect(issue?.message).not.toContain('primaryField');
253+
expect(issue?.fix).toContain('ADR-0079');
254+
expect(issue?.fix).toContain('titleFormat');
255+
});
256+
});
257+
149258
describe('lintConfig integration', () => {
150259
it('a clean invoice/line model produces no data-model errors or warnings', () => {
151260
const issues = lintConfig({

packages/lint/src/data-model-rules.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -380,16 +380,46 @@ export function lintDataModel(objects: any[]): LintIssue[] {
380380
const fields = fieldEntries(obj.fields);
381381

382382
// R9 — object should have a derivable display/primary field.
383+
//
384+
// `nameField` is ADR-0079's canonical primary-title pointer, so an object
385+
// that declares one HAS a title face. `titleFormat` is deliberately NOT one:
386+
// the same ADR retires it (it is a render-only template the server can
387+
// neither return nor query), `validate-record-title.ts` reports every
388+
// declaration of it as `title-format-retired` and steers the author to
389+
// `nameField`, and the shared spec predicate `objectTitleCompleteness`
390+
// (packages/spec/src/data/display-name.ts) never reads it either.
391+
//
392+
// Reading `titleFormat` while ignoring `nameField` made this rule
393+
// contradict its own package (#6108): an author who followed the platform's
394+
// own migration advice earned a "records will display as raw IDs"
395+
// suggestion, while one who kept the retired key did not. `primaryField`
396+
// and the name-like derivation are unchanged.
397+
//
398+
// `primaryField` is kept as-is, but do NOT read it as evidence that the key
399+
// is authorable: measured on 17.0.0-rc.5, `ObjectSchema.safeParse` reports
400+
// `unrecognized_keys: ['primaryField']` and `ObjectSchema.create()` rejects
401+
// it outright, so this limb can never be true for an object the spec
402+
// accepts. Filed as #6326 (it is declared nowhere in `packages/spec`, yet
403+
// this rule, `validate-semantic-roles` and the objectstack-data skill doc
404+
// all treat it as a title face) — removing the limb is that issue's call,
405+
// not a rider here. The MESSAGE, however, must not advertise it: telling an
406+
// author to reach for `primaryField` earns them a hard schema rejection, so
407+
// the diagnostic names only the surfaces they can actually declare.
383408
const hasNameField =
409+
!!obj.nameField ||
384410
!!obj.primaryField ||
385-
!!obj.titleFormat ||
386411
fields.some((f) => NAME_LIKE_FIELDS.includes(f.name));
387412
if (fields.length > 0 && !hasNameField) {
388413
issues.push({
389414
severity: 'suggestion',
390415
rule: 'object/missing-name-field',
391-
message: `Object "${obj.name}" has no name/title field or primaryField — records will display as raw IDs`,
416+
message: `Object "${obj.name}" has no nameField and no name-like field — records will display as raw IDs`,
392417
path: `${objPath}.fields`,
418+
fix:
419+
`Set \`nameField: '<field>'\` — ADR-0079's canonical primary-title pointer — to a stored ` +
420+
`text/autonumber field, or to a formula field with \`returnType: 'text'\` for a composite ` +
421+
`title. A \`titleFormat\` template does NOT count: it is retired (ADR-0079) and render-only, ` +
422+
`so the server can neither return nor query the title it renders.`,
393423
});
394424
}
395425

0 commit comments

Comments
 (0)