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
36 changes: 36 additions & 0 deletions .changeset/lint-missing-name-field-reads-name-field.md
Original file line number Diff line number Diff line change
@@ -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 硬拒的键。谓词里的这一支保持不动。
109 changes: 109 additions & 0 deletions packages/cli/test/data-model-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
34 changes: 32 additions & 2 deletions packages/lint/src/data-model-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<field>'\` — 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.`,
});
}

Expand Down
Loading