Skip to content

Commit dbbaef8

Browse files
os-try2026claude
andcommitted
test(lint): re-pin the moved highlightFields clauses; scope the member to object writes
The #5378 injected-column counter-examples and the #8116 withheld-anchor case move to the rule that now owns the position, at `error` rather than `warning`. `validateSemanticRoles` keeps its own stageField copies and the provenance question, and gains the controls that keep its new silence non-vacuous. The suite member declares `runtimeTypes: ['object']` only: on a flow or view snapshot the objects are context, present in baseline and candidate alike, so every finding it could raise there cancels in the gate's differential. Adds the changeset (minor/minor, with the Migration section). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
1 parent df49855 commit dbbaef8

4 files changed

Lines changed: 165 additions & 59 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/metadata-protocol": minor
4+
---
5+
6+
A publish now refuses an object whose `highlightFields` names a field that does not exist on it — the same gate that refuses a code-authored stack.
7+
8+
`list-view-field-unknown` inspects `view.columns`, and Studio's app builder mints no `view` items at all, so the reference-integrity family had nothing to inspect on the only artifacts the click path authors. What it authors is the **object**, and an object-level field-name list was covered by nothing that could refuse: measured on `origin/main`, `runtimeAuthoringRulesFor('object')` dispatched seven rules with no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is `warning`, advisory-tier and CLI-only. So `os validate` exited 0 on a dangling reference and the runtime publish door — the only door a Studio, REST `/meta` or MCP author has — said nothing at all.
9+
10+
The reproduction is the natural click order, not a contrived one: click-create a field (Studio mints it as `field_10`), add it to `highlightFields`, then give it a label — the API name auto-derives to `health_score` and `highlightFields` keeps `field_10`. Anyone who names a field after placing it produces this.
11+
12+
- **New rule `object-field-ref-unknown` (`error`)**, in `@objectstack/lint`, over the object-level field-name **lists** that no rule owned: `highlightFields` (ADR-0085) and `publicSharing.redactFields`. It resolves through the same `object-graph` seam as the rest of the family, so the three shared skips hold — an object outside the stack, an object with no readable field map (ADR-0015 `external`), and a registry-injected system column resolved **per object** (`highlightFields: ['owner_id']` is a live pointer on an owned object and a real miss under `ownership: 'none'`).
13+
- **It runs on the runtime publish door.** The reference-integrity suite entry's `runtimeTypes` gains `object`, and the suite's per-member declaration keeps the crossing narrow: this is the only member that judges an object snapshot; every other member keeps `['flow', 'view']` or the frozen `['flow']` default.
14+
- **`validateSemanticRoles` keeps the provenance question** at the same position (`semantic-role-field-unprovisioned`, still `warning`) and no longer restates existence — one finding per path, at one tier.
15+
- **`probes.checked` gained an `objects` counter.** Its absence was the tell: a receipt reading `{seeds: 0, views: 0, widgets: 0}` was accurate while the objects the package published were probed by nothing.
16+
17+
## Migration
18+
19+
**A publish that used to succeed can now be refused (HTTP 422, `INVALID_METADATA`).** The receipt names the rule id `object-field-ref-unknown` and the offending path, name-keyed on the wire — for example `objects.proj_task.highlightFields[1]` — plus the string that was written and the fields the object actually has.
20+
21+
To fix a dangling reference, do one of:
22+
23+
- rewrite the entry to the field's current API name (after a Studio label edit the derived name is the one to use — `field_10` becomes `health_score`); or
24+
- drop the entry from the list.
25+
26+
`os validate` / `os build` / `os lint` report the same finding at `error`, so a stack can be repaired before it reaches a publish. If an object legitimately points at a platform-injected system column, no change is needed — the rule resolves those per object and stays silent where the platform really provisions them.

‎packages/lint/src/reference-integrity-suite.ts‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -239,21 +239,29 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
239239
// on a list view. Placed directly after them because it completes the same
240240
// sweep — every field name an object or its built-in views write down.
241241
//
242-
// `runtimeTypes` gains `object`, and that is the point of the member rather
243-
// than a convenience: Studio's app builder mints no `view` items at all, so
244-
// the list-view members have nothing to inspect on the only artifacts the
245-
// click path authors. What it authors is the OBJECT. The crossing carries
246-
// the #9313 property that makes it safe — this member resolves only against
247-
// `stack.objects`, the collection the per-write snapshot does carry, so it
248-
// has no missing-collection false-positive channel; and it resolves each
249-
// name against the object's OWN field map, so a one-object snapshot is not
242+
// `runtimeTypes` is `['object']` — the ONLY member of this suite that names
243+
// that type, and the only one that does not name `flow`. Both halves are
244+
// deliberate.
245+
//
246+
// It names `object` because that is the point of the member: Studio's app
247+
// builder mints no `view` items at all, so the list-view members have
248+
// nothing to inspect on the only artifacts the click path authors. What it
249+
// authors is the OBJECT. The crossing carries the #9313 property that makes
250+
// it safe — this member resolves only against `stack.objects`, the
251+
// collection the per-write snapshot does carry, so it has no
252+
// missing-collection false-positive channel; and it resolves each name
253+
// against the object's OWN field map, so a one-object snapshot is not
250254
// merely sufficient, it is the whole universe the question has.
251255
//
252-
// `flow` and `view` ride along on the default reasoning: an object reached
253-
// as context on either snapshot is judged for its own lists exactly as the
254-
// CLI judges it, and the gate's differential means a stored object already
255-
// in violation is never charged to someone else's write.
256-
{ name: 'validateObjectFieldRefs', runtimeTypes: ['flow', 'view', 'object'], run: validateObjectFieldRefs },
256+
// It does NOT name `flow` or `view`, and that is not an omission. On either
257+
// of those snapshots the objects are CONTEXT, present in the baseline and
258+
// the candidate alike, so every finding this member could raise there
259+
// cancels in the gate's differential (#4463 D4 — a stored object already in
260+
// violation is never charged to someone else's write). Declaring them would
261+
// buy two extra passes over the tenant's object model per flow write and
262+
// change no verdict. CLI commands ignore this field entirely, so all three
263+
// commands judge every object either way.
264+
{ name: 'validateObjectFieldRefs', runtimeTypes: ['object'], run: validateObjectFieldRefs },
257265
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
258266
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
259267
// [#14073] The same page, one question out. `validatePageFieldBindings`

‎packages/lint/src/validate-object-field-refs.test.ts‎

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,89 @@ describe('validateObjectFieldRefs — the three shared skips', () => {
195195
})).toEqual([]);
196196
});
197197

198-
it('is inert on junk: no objects, junk entries, non-array lists', () => {
198+
it('is inert on junk: no objects, unnamed entries, non-array lists, non-string members', () => {
199199
expect(validateObjectFieldRefs({})).toEqual([]);
200-
expect(validateObjectFieldRefs({ objects: [null, 7, 'x'] as never })).toEqual([]);
200+
expect(validateObjectFieldRefs({ objects: [] })).toEqual([]);
201+
// An entry with no `name` indexes into no graph and is skipped.
202+
expect(validateObjectFieldRefs({ objects: [{ highlightFields: ['x'] }] })).toEqual([]);
201203
expect(validateObjectFieldRefs(stackOf({ highlightFields: 'not-an-array' }))).toEqual([]);
202204
expect(validateObjectFieldRefs(stackOf({ highlightFields: [null, 3, ''] }))).toEqual([]);
205+
// A name-keyed `objects` map, the other shape the raw `lint` path carries.
206+
expect(validateObjectFieldRefs({
207+
objects: { proj_task: { fields: { name: {} }, highlightFields: ['nope'] } },
208+
})).toHaveLength(1);
209+
// ⛔ NOT asserted here: `objects: [null, …]`. `indexObjectGraph` throws a
210+
// TypeError on a null collection entry before any member of this suite is
211+
// reached — a pre-existing fragility of the shared seam, filed separately
212+
// rather than worked around in one member (a local guard here would leave
213+
// the same crash in every sibling and hide it).
214+
});
215+
});
216+
217+
// ---------------------------------------------------------------------------
218+
// [#5378] The injected-column derivation, at the position that moved.
219+
//
220+
// These counter-examples used to stand in `validate-semantic-roles.test.ts`,
221+
// against the warning-tier clause this rule took over. They are re-pinned here
222+
// at `error` rather than dropped: the derivation is exactly as load-bearing
223+
// under a gate as it was under an advisory, and a false finding now REFUSES a
224+
// publish instead of adding a line to a warning list.
225+
// ---------------------------------------------------------------------------
226+
describe('validateObjectFieldRefs — the #5378 derivation under a gate', () => {
227+
const tag = (over: Record<string, unknown>) => ({
228+
objects: [{ name: 'crm_tag', fields: { name: {} }, ...over }],
229+
});
230+
231+
it.each(['none', 'org'])(
232+
"REFUSES highlightFields: [owner_id] on ownership: '%s' — the platform injects none",
233+
(ownership) => {
234+
const findings = validateObjectFieldRefs(tag({
235+
ownership, highlightFields: ['name', 'owner_id'],
236+
}));
237+
expect(findings).toHaveLength(1);
238+
expect(findings[0]).toMatchObject({ severity: 'error', rule: OBJECT_FIELD_REF_UNKNOWN });
239+
expect(findings[0]!.message).toContain('owner_id');
240+
},
241+
);
242+
243+
it('REFUSES an organization_id highlight when the object opts out of tenancy', () => {
244+
const findings = validateObjectFieldRefs(tag({
245+
tenancy: { enabled: false }, highlightFields: ['organization_id'],
246+
}));
247+
expect(findings).toHaveLength(1);
248+
expect(findings[0]!.message).toContain('organization_id');
249+
});
250+
251+
it('REFUSES a typo that merely LOOKS like a system column', () => {
252+
const findings = validateObjectFieldRefs({
253+
objects: [{ name: 'crm_contact', fields: { name: {} }, highlightFields: ['owner_ids', 'creatd_at'] }],
254+
});
255+
expect(findings).toHaveLength(2);
256+
expect(findings.map((f) => f.message).join(' ')).toMatch(/owner_ids/);
257+
expect(findings.map((f) => f.message).join(' ')).toMatch(/creatd_at/);
258+
});
259+
260+
it.each([
261+
'created_at', 'created_by', 'updated_at', 'updated_by',
262+
'organization_id', 'owning_business_unit_id', 'id',
263+
])('stays silent on highlightFields: [%s] — a real column at render time', (column) => {
264+
expect(validateObjectFieldRefs({
265+
objects: [{ name: 'crm_contact', fields: { name: {} }, highlightFields: ['name', column] }],
266+
})).toEqual([]);
267+
});
268+
269+
it('a withheld anchor on an EXTERNAL object is still the existence verdict (#8116)', () => {
270+
const findings = validateObjectFieldRefs({
271+
objects: [{
272+
name: 'ext_customer',
273+
external: { remoteName: 'customers' },
274+
fields: { email: { type: 'email' } },
275+
ownership: 'none',
276+
highlightFields: ['owner_id'],
277+
}],
278+
});
279+
expect(findings).toHaveLength(1);
280+
expect(findings[0]!.rule).toBe(OBJECT_FIELD_REF_UNKNOWN);
203281
});
204282
});
205283

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

Lines changed: 38 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -202,22 +202,18 @@ describe('validateSemanticRoles (ADR-0085)', () => {
202202
expect(optedOut).toEqual([]);
203203
});
204204

205-
it('flags unknown highlightFields entries, including via the compactLayout alias', () => {
205+
// [#15254] `highlightFields` EXISTENCE — and the `compactLayout` alias read
206+
// that went with it — moved to `validate-object-field-refs.ts`, where it
207+
// gates instead of advising. The assertions that used to live here moved
208+
// with it (`validate-object-field-refs.test.ts`); the boundary they now pin
209+
// from THIS side is below, under "the existence clause that moved out".
210+
it('stays silent on a dangling highlightFields entry — no longer this rule\'s (#15254)', () => {
206211
const findings = validateSemanticRoles(stack([{
207212
name: 'account',
208213
highlightFields: ['name', 'industy'], // typo
209214
fields: { name: {}, industry: {} },
210215
}]));
211-
expect(findings).toHaveLength(1);
212-
expect(findings[0].message).toContain('industy');
213-
214-
const aliased = validateSemanticRoles(stack([{
215-
name: 'account',
216-
compactLayout: ['ghost'],
217-
fields: { name: {} },
218-
}]));
219-
expect(aliased).toHaveLength(1);
220-
expect(aliased[0]).toMatchObject({ rule: SEMANTIC_ROLE_FIELD_UNKNOWN });
216+
expect(findings, JSON.stringify(findings)).toEqual([]);
221217
});
222218

223219
it('accepts objects as a name-keyed map and tolerates junk shapes', () => {
@@ -275,41 +271,25 @@ describe('validateSemanticRoles — injected system columns (#5378)', () => {
275271

276272
// ── Counter-examples ──
277273

278-
it.each(['none', 'org'])(
279-
"still warns for highlightFields: [owner_id] on ownership: '%s'",
280-
(ownership) => {
281-
const findings = validateSemanticRoles(stack([{
282-
name: 'crm_tag',
283-
ownership,
284-
highlightFields: ['name', 'owner_id'],
285-
fields: { name: {} },
286-
}]));
287-
expect(findings).toHaveLength(1);
288-
expect(findings[0]).toMatchObject({ rule: SEMANTIC_ROLE_FIELD_UNKNOWN });
289-
expect(findings[0].message).toContain('owner_id');
290-
},
291-
);
292-
293-
it('still warns for an organization_id highlight when the object opts out of tenancy', () => {
274+
// [#15254] The three counter-examples that stood here — `ownership: 'none'`
275+
// / `'org'` withholding `owner_id`, tenancy opt-out withholding
276+
// `organization_id`, and a typo that merely LOOKS like a system column —
277+
// pinned the DERIVATION at the `highlightFields` position, and that position
278+
// is `object-field-ref-unknown`'s now. They moved verbatim in substance to
279+
// `validate-object-field-refs.test.ts` (at `error`), rather than being
280+
// dropped: the #5378 derivation is exactly as load-bearing there, and a
281+
// counter-example deleted in a move is a counter-example nobody re-runs.
282+
// `stageField` keeps its own copies below — this rule still owns that one.
283+
it("still warns for a stageField the derivation withholds — ownership: 'none'", () => {
294284
const findings = validateSemanticRoles(stack([{
295285
name: 'crm_tag',
296-
tenancy: { enabled: false },
297-
highlightFields: ['organization_id'],
286+
ownership: 'none',
287+
stageField: 'owner_id',
298288
fields: { name: {} },
299289
}]));
300290
expect(findings).toHaveLength(1);
301-
expect(findings[0].message).toContain('organization_id');
302-
});
303-
304-
it('still warns for a typo that merely LOOKS like a system column', () => {
305-
const findings = validateSemanticRoles(stack([{
306-
name: 'crm_contact',
307-
highlightFields: ['owner_ids', 'creatd_at'],
308-
fields: { name: {} },
309-
}]));
310-
expect(findings).toHaveLength(2);
311-
expect(findings.map((f) => f.message).join(' ')).toMatch(/owner_ids/);
312-
expect(findings.map((f) => f.message).join(' ')).toMatch(/creatd_at/);
291+
expect(findings[0]).toMatchObject({ rule: SEMANTIC_ROLE_FIELD_UNKNOWN });
292+
expect(findings[0].message).toContain('owner_id');
313293
});
314294

315295
// Widening the EXISTENCE question must not make an injected column a group
@@ -395,12 +375,26 @@ describe('validateSemanticRoles — unprovisioned anchors on external objects (#
395375
expect(findings).toEqual([]);
396376
});
397377

398-
it('a withheld anchor still gets the UNKNOWN finding, never the provenance one', () => {
399-
// `ownership: 'none'` ⇒ no owner_id anywhere ⇒ rule (c)'s existence warning
400-
// owns the defect. One pointer, one finding, the right one.
378+
it('a withheld anchor gets the EXISTENCE finding, never the provenance one', () => {
379+
// `ownership: 'none'` ⇒ no owner_id anywhere ⇒ the existence verdict owns
380+
// the defect, not this rule's provenance one. One pointer, one finding,
381+
// the right one — and since #15254 the existence half is
382+
// `object-field-ref-unknown`, so what this rule must do here is stay
383+
// SILENT. The positive half is pinned in
384+
// `validate-object-field-refs.test.ts`.
401385
const findings = validateSemanticRoles(stack([
402386
externalObject({ ownership: 'none', highlightFields: ['owner_id'] }),
403387
]));
388+
expect(findings, JSON.stringify(findings)).toEqual([]);
389+
});
390+
391+
it('the same withheld anchor on a stageField DOES still warn here', () => {
392+
// The control that keeps the test above from being vacuous: this rule's
393+
// silence is about the position that moved, not about the rule going
394+
// quiet everywhere.
395+
const findings = validateSemanticRoles(stack([
396+
externalObject({ ownership: 'none', stageField: 'owner_id' }),
397+
]));
404398
expect(findings).toHaveLength(1);
405399
expect(findings[0].rule).toBe(SEMANTIC_ROLE_FIELD_UNKNOWN);
406400
});

0 commit comments

Comments
 (0)