Skip to content

Commit 18a7796

Browse files
committed
fix(lint): remove the primaryField phantom key from both title-face rules (#6326)
`primaryField` is declared nowhere in `packages/spec`. Measured on the 17.0.0-rc.5 dist, `ObjectSchema.safeParse` returns `unrecognized_keys: ['primaryField']` and `ObjectSchema.create()` throws, while the same shape with `nameField` parses clean. The key was therefore never a declarable authoring surface, yet three consumers treated it as a legal title face. Per the maintainer ruling on #6326 (remove, do not declare — `nameField` is ADR-0079's canonical title pointer and a second parallel pointer contradicts Prime Directive #7), this drops it at every consumer, with zero change to `packages/spec`: - `data-model-rules.ts`: `object/missing-name-field` loses the `!!obj.primaryField` disjunct. - `validate-semantic-roles.ts`: rule (d)'s title-resolution chain narrows to `[nameField, displayNameField]`. - `skills/objectstack-data/SKILL.md`: the rule row now names only surfaces an author can actually declare. This was the live half — the skill doc is what an AI author reads, and it advertised a key `ObjectSchema.create()` rejects. Test disposition, per the three-way fixture triage: - `packages/cli/test/data-model-rules.test.ts` "accepts an object with a name field or primaryField": REPLACED WHOLESALE. Its `primaryField` assertion was vacuous twice over — the fixture is one the schema rejects, and its `code` field is itself in NAME_LIKE_FIELDS, so the name-like limb accepted the object regardless and the assertion stayed green with the limb deleted. It never pinned the limb. The surviving half (a name-like field is a title face) is kept; the replacement pins the `nameField` limb on an isolated fixture (`invoice_number`) that no other limb can rescue. - The `(c)` case is re-pinned as an exact reported set rather than the absence of a string, so it fails in both directions. - A new pin in `validate-semantic-roles.test.ts` covers the chain removal, which previously had no test at all, paired with a positive `nameField` assertion so neither half can pass vacuously. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3
1 parent 82bf47b commit 18a7796

6 files changed

Lines changed: 179 additions & 39 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): 摘掉 `primaryField` 这个幽灵键——两条规则不再把它当作标题面(#6326)
6+
7+
`primaryField``packages/spec`**没有任何声明**。实测(`17.0.0-rc.5` dist):
8+
9+
```
10+
ObjectSchema.safeParse({ name: 'probe_obj', label: 'Probe', primaryField: 'code',
11+
fields: { code: { type: 'text', label: 'Code' } } })
12+
// => success: false
13+
// => issues: [{ code: 'unrecognized_keys', keys: ['primaryField'], path: [] }]
14+
15+
ObjectSchema.create(/* 同上 */)
16+
// => throws: ObjectSchema.create('probe_obj'): unknown key(s) — primaryField.
17+
```
18+
19+
同一形状换成 `nameField: 'code'``safeParse` 通过。也就是说,这个键**从来不是可声明面**,
20+
而三处消费者没跟上——两面同源,却各自有一个可达面:
21+
22+
- **文档面(作者会照做,当下活着的那一半)**:`skills/objectstack-data/SKILL.md` 是 AI 编写
23+
元数据时读的技能文档,它把 `primaryField` 明说成 `object/missing-name-field` 的合法逃逸口。
24+
照它写出来的对象在 `ObjectSchema.create()` 上被 ADR-0032「不静默丢弃未知键」的闸硬拒——
25+
**这是在教 AI 写出必然失败的元数据。**
26+
- **规则面(判定永不成立)**:`data-model-rules.ts``!!obj.primaryField` 一支,以及
27+
`validate-semantic-roles.ts` 标题解析链里的那一项,对任何 schema 收得下的对象恒为 false,
28+
属于 #4984 家族的死支——看起来在保护什么,实际什么都判不到。
29+
30+
本次按维护者裁定 **remove,不 declare**(`nameField` 已是 ADR-0079 的规范主标题指针,
31+
再立一个平行指针没有拉力,且与 Prime Directive #7「One Zod source per metadata type」相悖):
32+
33+
- `data-model-rules.ts`:`object/missing-name-field` 的谓词收敛为
34+
`!!obj.nameField || fields.some(name-like)`;
35+
- `validate-semantic-roles.ts`:规则 (d) 的标题解析链收敛为
36+
`[nameField, displayNameField]`(`displayNameField` 实测可声明,保留);
37+
- `skills/objectstack-data/SKILL.md`:该规则的表述改为只点名作者真正能声明的面——
38+
`nameField` 与 name-like 字段(并列出这七个名字)。
39+
40+
**`packages/spec` 改动,不需要迁移:`primaryField` 从来不是可声明键,写了它的对象在
41+
schema 上本来就发布不了,所以没有任何能工作的 app 会因此回归。** 行为上唯一的变化是:
42+
一个只靠 `primaryField` 充当标题面的对象,现在会新得一条 `object/missing-name-field`
43+
的 suggestion(severity 为 suggestion,不失败命令)——而这类对象本就通不过 `ObjectSchema`
44+
真正的修法是改声明 `nameField`

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

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,23 @@ describe('lintDataModel — fields & objects', () => {
133133
expect(has(issues, 'object/missing-name-field')).toBe(true);
134134
});
135135

136-
it('accepts an object with a name field or primaryField', () => {
136+
// #6326 replaced the second assertion here wholesale. It used to read
137+
// `{ name: 'b', primaryField: 'code', fields: { code: … } }` and claim to pin
138+
// the `primaryField` limb of `object/missing-name-field`. It pinned nothing,
139+
// in two independent ways: (1) the fixture is one `ObjectSchema` REJECTS
140+
// (`unrecognized_keys: ['primaryField']`), so no author could ever write the
141+
// shape it was green about; and (2) `code` is itself in NAME_LIKE_FIELDS, so
142+
// the name-like limb accepted the object regardless — the assertion stayed
143+
// green with the `primaryField` limb deleted, which is exactly what makes it
144+
// a vacuous green rather than coverage. What SURVIVES is the first assertion
145+
// (a name-like field is a title face). The replacement below pins the limb
146+
// that actually exists — the explicit `nameField` pointer — on a fixture no
147+
// other limb can rescue, so it can genuinely fail.
148+
it('accepts an object with a name-like field, or an explicit nameField', () => {
137149
expect(has(lintDataModel([{ name: 'a', fields: { name: { type: 'text' } } }]), 'object/missing-name-field')).toBe(false);
138-
expect(has(lintDataModel([{ name: 'b', primaryField: 'code', fields: { code: { type: 'text' } } }]), 'object/missing-name-field')).toBe(false);
150+
// `invoice_number` is deliberately NOT in NAME_LIKE_FIELDS: the explicit
151+
// ADR-0079 pointer is the only thing that can clear this object.
152+
expect(has(lintDataModel([{ name: 'b', nameField: 'invoice_number', fields: { invoice_number: { type: 'text' } } }]), 'object/missing-name-field')).toBe(false);
139153
});
140154

141155
it('handles array-shaped fields', () => {
@@ -186,25 +200,32 @@ describe('lintDataModel — object/missing-name-field (ADR-0079 title face)', ()
186200
expect(has(issues, 'object/missing-name-field')).toBe(true);
187201
});
188202

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', () => {
203+
// (c) #6326 — `primaryField` is NOT a title face, and the limb that read it
204+
// is gone. The key is declared nowhere in `packages/spec`: measured on
205+
// 17.0.0-rc.5, `ObjectSchema.safeParse` returns
206+
// `unrecognized_keys: ['primaryField']` and `ObjectSchema.create()` throws,
207+
// so the limb was dead for every object the spec accepts (#4984 family)
208+
// while still reading, here and in the skill doc, as a legal escape hatch.
209+
//
210+
// This fixture is DELIBERATELY off-spec — that is the point. `lintDataModel`
211+
// runs over metadata as AUTHORED, before/independently of schema parse, so a
212+
// rejected key can physically reach it; the assertion is that the rule
213+
// IGNORES the key, not that the key is legal. Do not "fix" the fixture by
214+
// making it schema-valid: that would delete the coverage.
215+
//
216+
// Asserted as the EXACT reported set rather than as the absence of a string,
217+
// so it fails in both directions: put the `primaryField` limb back and
218+
// `objects[0]` drops out (set becomes `[]`); break the name-like limb and
219+
// `objects[1]` joins it. `period_key` is not in NAME_LIKE_FIELDS and carries
220+
// no `nameField`, so nothing else can rescue objects[0]; `crm_campaign` has
221+
// a `name` field and must stay clean.
222+
it('does not treat primaryField as a title face — it is not a declarable key (#6326)', () => {
192223
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);
224+
flagged([
225+
{ name: 'crm_forecast_period', primaryField: 'period_key', fields: { period_key: { type: 'text' } } },
226+
{ name: 'crm_campaign', fields: { name: { type: 'text' }, budget: { type: 'currency' } } },
227+
]),
228+
).toEqual(['objects[0].fields']);
208229
});
209230

210231
// (d) DELIBERATE FLIP, not a regression: a titleFormat-only object is now
@@ -241,8 +262,16 @@ describe('lintDataModel — object/missing-name-field (ADR-0079 title face)', ()
241262
// The suggestion must name the canonical pointer — an author who reads it
242263
// and reaches for `titleFormat` lands straight back in the contradiction.
243264
// 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.
265+
// `packages/spec`, so `ObjectSchema.create()` rejects it (#6326).
266+
//
267+
// ⚠️ Honest labelling of the `not.toContain('primaryField')` line: it is a
268+
// NEGATIVE assertion and it was already green before #6326 (PR for #6108 had
269+
// scrubbed the message text; #6326 only removed the predicate limb, which
270+
// this message never mentioned). It is NOT evidence of the removal — the
271+
// real pin for that is case (c) above. It is kept because it is paired with
272+
// the three positive assertions below, which fail if the message stops
273+
// steering to `nameField`/ADR-0079 at all; on its own it would be a bare
274+
// vacuous green.
246275
it('steers the author to nameField, and names no key the schema rejects', () => {
247276
const issue = lintDataModel([
248277
{ name: 'crm_quote_line_item', fields: { quantity: { type: 'number' } } },

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

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -392,22 +392,23 @@ export function lintDataModel(objects: any[]): LintIssue[] {
392392
// Reading `titleFormat` while ignoring `nameField` made this rule
393393
// contradict its own package (#6108): an author who followed the platform's
394394
// 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.
395+
// suggestion, while one who kept the retired key did not. The name-like
396+
// derivation is unchanged.
397397
//
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.
398+
// A third limb, `!!obj.primaryField`, was REMOVED here in #6326. That key
399+
// is declared nowhere in `packages/spec`: measured on 17.0.0-rc.5,
400+
// `ObjectSchema.safeParse` reports `unrecognized_keys: ['primaryField']`
401+
// and `ObjectSchema.create()` rejects it outright, so the limb could never
402+
// be true for an object the spec accepts — a #4984-family dead branch that
403+
// nonetheless read as a title face here, in `validate-semantic-roles` and
404+
// in the objectstack-data skill doc. The maintainer ruled remove, not
405+
// declare: `nameField` is ADR-0079's one canonical title pointer and a
406+
// second parallel pointer contradicts "one Zod source per metadata type"
407+
// (Prime Directive #7). Do not reintroduce it as a tolerated alias — a
408+
// consumer-side `??` for a key the producer rejects is exactly the second
409+
// de-facto contract Prime Directive #12 bans.
408410
const hasNameField =
409411
!!obj.nameField ||
410-
!!obj.primaryField ||
411412
fields.some((f) => NAME_LIKE_FIELDS.includes(f.name));
412413
if (fields.length > 0 && !hasNameField) {
413414
issues.push({

packages/lint/src/validate-semantic-roles.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,66 @@ describe('validateSemanticRoles (ADR-0085)', () => {
128128
expect(hiddenMember.map((f) => f.rule)).toEqual([FIELD_GROUP_SHADOWED]);
129129
});
130130

131+
// #6326 — rule (d)'s title resolution used to read
132+
// `[nameField, primaryField, displayNameField]`. `primaryField` is declared
133+
// NOWHERE in `packages/spec`: measured on 17.0.0-rc.5,
134+
// `ObjectSchema.safeParse` returns `unrecognized_keys: ['primaryField']` and
135+
// `ObjectSchema.create()` throws, so the entry could never match on an object
136+
// the spec accepts, while advertising a title pointer authors cannot write.
137+
// `nameField` is ADR-0079's canonical one; the entry is gone.
138+
//
139+
// The fixture is DELIBERATELY off-spec — that is what is under test.
140+
// `validateSemanticRoles` lints metadata as AUTHORED, so a schema-rejected
141+
// key can physically reach it; the assertion is that it is IGNORED. Do not
142+
// make the fixture schema-valid: that deletes the coverage.
143+
//
144+
// The pair below is the discriminating one. Title resolution matters here
145+
// because the title is filtered OUT of the 4-entry strip, so whether
146+
// `ref_no` counts as the title decides whether entry #5 (`d`) lands inside
147+
// the strip. Read as the title → strip is [a,b,c,d], group "tail" (only
148+
// member `d`) is fully hidden → SHADOWED. Not read → strip is
149+
// [ref_no,a,b,c], `d` still renders → clean. No field here is one of the
150+
// conventional fallbacks (name/full_name/title/subject/display_name), and
151+
// none is an injected system column, so nothing else can supply a title.
152+
it('ignores primaryField in title resolution; nameField still resolves (#6326)', () => {
153+
const withPhantomKey = validateSemanticRoles(stack([{
154+
name: 'ledger_entry',
155+
primaryField: 'ref_no',
156+
highlightFields: ['ref_no', 'a', 'b', 'c', 'd'],
157+
fieldGroups: [{ key: 'tail', label: 'Tail' }],
158+
fields: {
159+
ref_no: { type: 'text' }, a: { type: 'text' }, b: { type: 'text' },
160+
c: { type: 'text' }, d: { type: 'text', group: 'tail' },
161+
},
162+
}]));
163+
// Goes red the moment the `primaryField` entry returns to the chain:
164+
// `ref_no` would become the title, `d` would fall inside the strip, and
165+
// one FIELD_GROUP_SHADOWED finding would appear.
166+
expect(withPhantomKey).toEqual([]);
167+
168+
// Paired positive — the SAME shape with the one declarable pointer. This
169+
// proves the assertion above is about `primaryField` being ignored, not
170+
// about the rule being inert on this fixture.
171+
const withNameField = validateSemanticRoles(stack([{
172+
name: 'ledger_entry',
173+
nameField: 'ref_no',
174+
highlightFields: ['ref_no', 'a', 'b', 'c', 'd'],
175+
fieldGroups: [{ key: 'tail', label: 'Tail' }],
176+
fields: {
177+
ref_no: { type: 'text' }, a: { type: 'text' }, b: { type: 'text' },
178+
c: { type: 'text' }, d: { type: 'text', group: 'tail' },
179+
},
180+
}]));
181+
// Indexed rather than `.map((f) => f.rule)` on purpose: this file's
182+
// relative import of the module under test omits the `.js` extension, so
183+
// under NodeNext every symbol it names degrades to `any` (TS2835) and each
184+
// callback over a finding adds a TS7006 to the package's TEST_DEBT ledger.
185+
// Same assertion strength, no new ledger entry.
186+
expect(withNameField).toHaveLength(1);
187+
expect(withNameField[0]?.rule).toBe(FIELD_GROUP_SHADOWED);
188+
expect(withNameField[0]?.message).toContain('tail');
189+
});
190+
131191
it('flags stageField pointing at a missing field; false is fine', () => {
132192
const bad = validateSemanticRoles(stack([{
133193
name: 'lead', stageField: 'pipeline', fields: { status: {} },

packages/lint/src/validate-semantic-roles.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,15 @@ export function validateSemanticRoles(stack: AnyRec): SemanticRoleFinding[] {
185185
);
186186
if (declaredStrings.length > 0 && declaredGroups.size > 0) {
187187
// Mirror the renderer's title resolution: declared role first
188-
// (nameField / primaryField / deprecated displayNameField), else the
189-
// first conventional display-field name present on the object.
190-
const declaredTitle = [obj.nameField, obj.primaryField, obj.displayNameField]
188+
// (nameField, else the deprecated displayNameField), else the first
189+
// conventional display-field name present on the object.
190+
//
191+
// `primaryField` sat between those two until #6326 removed it. It is
192+
// declared nowhere in `packages/spec` — `ObjectSchema` rejects it with
193+
// `unrecognized_keys` — so the entry could never match on an object the
194+
// spec accepts, and reading it here advertised a title pointer authors
195+
// cannot write. `nameField` is ADR-0079's canonical one.
196+
const declaredTitle = [obj.nameField, obj.displayNameField]
191197
.find((v): v is string => typeof v === 'string' && v.length > 0 && fieldNames.has(v));
192198
const titleField = declaredTitle
193199
?? ['name', 'full_name', 'title', 'subject', 'display_name'].find((c) => fieldNames.has(c));

skills/objectstack-data/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -998,7 +998,7 @@ Data-model rules (in addition to naming/label/i18n):
998998
| `relationship/association-inline-edit` | warning | an association (comment/audit/activity) marked `inlineEdit` (clutters the parent form — use a detail-page related list) |
999999
| `rollup/missing-summary` | suggestion | a parent of numeric master_detail children with no roll-up `summary` |
10001000
| `field/select-missing-options` | warning | a `select`/`multiselect`/`radio` with no `options` (or options source) |
1001-
| `object/missing-name-field` | suggestion | an object with no name/title field or `primaryField` |
1001+
| `object/missing-name-field` | suggestion | an object with no `nameField` (ADR-0079's canonical title pointer) and no name-like field (`name`/`title`/`subject`/`label`/`full_name`/`display_name`/`code`) |
10021002

10031003
These same rules are the **rubric for AI-generated metadata** — a generation is
10041004
"good" exactly when it is schema-valid and lint-clean:

0 commit comments

Comments
 (0)