Skip to content

Commit df49855

Browse files
os-try2026claude
andcommitted
fix(lint,metadata-protocol): refuse an object whose field-name lists name a missing field
Studio's app builder mints no `view` items, so `list-view-field-unknown` had nothing to inspect on the only artifacts the click path authors, and `highlightFields` is an object-level list no gating rule covered: `runtimeAuthoringRulesFor('object')` dispatched seven rules and no reference-integrity rule among them, while the object-level existence check that did exist (`semantic-role-field-unknown`) is warning-tier and CLI-only. Adds `object-field-ref-unknown` (error) over the object-level field-name LISTS nothing owns — `highlightFields` and `publicSharing.redactFields` — registers it in the reference-integrity suite with `runtimeTypes` including `object`, and crosses the suite entry onto the object write door so its one object-judging member runs there. The existence half moves out of `validateSemanticRoles`, which keeps the provenance question at that position. Adds an `objects` plane to the ADR-0038 L3 build probes so `probes.checked` reports how many objects were inspected — the absent key was the tell. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
1 parent f01adfa commit df49855

13 files changed

Lines changed: 1021 additions & 29 deletions

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,8 +682,37 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
682682
// crossing would be a silent no-op that reads as coverage — the very shape
683683
// #9313 was filed about. Flow snapshots are unchanged: every member keeps
684684
// the default `flow` declaration.
685+
//
686+
// [#15254] `object` joins, and the granularity mechanism above is what
687+
// makes it a NARROW crossing rather than the whole suite arriving at the
688+
// hottest write door: this entry says an `object` write dispatches the
689+
// suite; the suite's per-member `runtimeTypes` says exactly ONE member
690+
// judges that snapshot (`validateObjectFieldRefs`), because it is the
691+
// only member that resolves an object's references against the object's
692+
// OWN field map. Every other member keeps `['flow', 'view']` (or the
693+
// frozen `['flow']` default) and does not run on an object write.
694+
//
695+
// The measured state that forced it: `runtimeAuthoringRulesFor('object')`
696+
// dispatched seven rules and no reference-integrity rule among them, so
697+
// the ONLY door a Studio tenant has ran no reference-integrity judgement
698+
// at all on the artifact Studio actually authors — while the app builder
699+
// mints no `view` items, leaving the members crossed by #9313 with
700+
// nothing to inspect on the click path. That is not a rule that missed a
701+
// case; it is a wall whose fourth door was pointed at a surface the click
702+
// path never produces.
703+
//
704+
// ⚠️ This is a REFUSAL widening on the object door, and unlike #4716 it is
705+
// not fenced by the advisory tier: an object republished with a
706+
// pre-existing dangling `highlightFields` entry is now refused (422)
707+
// rather than warned. That is the acceptance bar of the card, stated in
708+
// its own words ("Warning-level is not enough for the claim; it has to
709+
// refuse"), and it is called out in the changeset's Migration section
710+
// because it turns a publish that used to succeed into one that does not.
711+
// The gate's differential keeps it honest in the one direction that
712+
// matters: a STORED sibling already in violation is never charged to this
713+
// write (#4463 D4).
685714
surfaces: CLI_AND_RUNTIME,
686-
runtimeTypes: ['flow', 'view'],
715+
runtimeTypes: ['flow', 'view', 'object'],
687716
run: (stack, ctx) => validateReferenceIntegrity(stack, ctx),
688717
},
689718
// ADR-0078 / #5068 — the SDUI component-props gate. `PageComponent.properties`

‎packages/lint/src/index.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,22 @@ export type {
454454
ListViewFieldRefSeverity,
455455
} from './validate-list-view-field-refs.js';
456456

457+
// [#15254] The object-level half of the same sweep: the field-name LISTS an
458+
// object carries about its own fields (`highlightFields`,
459+
// `publicSharing.redactFields`). `error`, and on the runtime publish door as
460+
// well as the three commands — Studio's app builder mints no `view` items, so
461+
// the list-view members above have nothing to inspect on the only artifacts
462+
// the click path authors, and a dangling `highlightFields` reference produced
463+
// by clicking in the natural order published green.
464+
export {
465+
validateObjectFieldRefs,
466+
OBJECT_FIELD_REF_UNKNOWN,
467+
} from './validate-object-field-refs.js';
468+
export type {
469+
ObjectFieldRefFinding,
470+
ObjectFieldRefSeverity,
471+
} from './validate-object-field-refs.js';
472+
457473
export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
458474
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';
459475

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ describe('reference-integrity suite — membership', () => {
2222
// field-naming position the two members above do not own. Placed beside
2323
// them because the three walk the identical rungs.
2424
'validateListViewFieldRefs',
25+
// [#15254] The object-level half of the same sweep: the field-name lists
26+
// an object writes about its OWN fields. Placed beside the list-view
27+
// members because it completes the object's field surface — and because
28+
// Studio's app builder mints no `view` items, which is what left those
29+
// members with nothing to inspect on the click path.
30+
'validateObjectFieldRefs',
2531
'validateActionNameRefs',
2632
'validatePageFieldBindings',
2733
// [#14073] The same page, one question out: the BINDING behind each

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ import { validateObjectReferences } from './validate-object-references.js';
103103
import { validateSearchableFields } from './validate-searchable-fields.js';
104104
import { validateSortableFields } from './validate-sortable-fields.js';
105105
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
106+
import { validateObjectFieldRefs } from './validate-object-field-refs.js';
106107
import { validateActionNameRefs } from './validate-action-name-refs.js';
107108
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
108109
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
@@ -232,6 +233,27 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
232233
// MCP/AI author writes goes through that door and no CLI, so a
233234
// build-time-only rule would never reach the author who made the typo.
234235
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
236+
// [#15254] The same question, one surface IN: the field-name lists the
237+
// OBJECT itself carries (`highlightFields`, `publicSharing.redactFields`),
238+
// which the three list-view members above do not walk because they are not
239+
// on a list view. Placed directly after them because it completes the same
240+
// sweep — every field name an object or its built-in views write down.
241+
//
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
250+
// merely sufficient, it is the whole universe the question has.
251+
//
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 },
235257
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
236258
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
237259
// [#14073] The same page, one question out. `validatePageFieldBindings`

‎packages/lint/src/runtime-gate.object-writes.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
113113
'validateFunctionalCompleteness',
114114
'validateManagedApiMethods',
115115
'validatePresetComparands', // #8793 — at this door before #4716
116+
// [#15254] The reference-integrity suite, dispatched here so its ONE
117+
// object-judging member runs (`validateObjectFieldRefs`). The entry
118+
// arrives; the suite's per-member `runtimeTypes` decides who judges the
119+
// snapshot, and every other member keeps `['flow','view']` or the frozen
120+
// `['flow']` default. Before this, the only door a Studio tenant has ran
121+
// no reference-integrity rule at all on an object write.
122+
'validateReferenceIntegrity',
116123
'lintAutonumberFormats',
117124
'validateSecurityPosture', // #8310 — at this door before #4716
118125
'validateRuleCompilability',
@@ -240,6 +247,7 @@ describe('the object write door dispatches at the adjudicated scope (#4716)', ()
240247
'validateFunctionalCompleteness',
241248
'validateManagedApiMethods',
242249
'validatePresetComparands',
250+
'validateReferenceIntegrity', // [#15254]
243251
'lintAutonumberFormats',
244252
'validateSecurityPosture',
245253
'validateRuleCompilability',
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, expect, it } from 'vitest';
4+
5+
import { AUTHORING_RULES } from './authoring-rules.js';
6+
import {
7+
OBJECT_FIELD_REF_UNKNOWN,
8+
validateObjectFieldRefs,
9+
} from './validate-object-field-refs.js';
10+
import { runRuntimeAuthoringRules, runtimeAuthoringRulesFor } from './runtime-gate.js';
11+
import { SEMANTIC_ROLE_FIELD_UNKNOWN, validateSemanticRoles } from './validate-semantic-roles.js';
12+
13+
const obj = (over: Record<string, unknown> = {}) => ({
14+
name: 'proj_task',
15+
label: 'Task',
16+
sharingModel: 'private',
17+
fields: {
18+
name: { type: 'text', label: 'Name' },
19+
health_score: { type: 'number', label: 'Health Score' },
20+
},
21+
nameField: 'name',
22+
...over,
23+
});
24+
25+
const stackOf = (over: Record<string, unknown> = {}) => ({ objects: [obj(over)] });
26+
27+
describe('validateObjectFieldRefs — highlightFields', () => {
28+
it('REFUSES a dangling entry at `error`, naming the rule id and the offending path', () => {
29+
const findings = validateObjectFieldRefs(stackOf({ highlightFields: ['name', 'field_10'] }));
30+
31+
expect(findings).toHaveLength(1);
32+
expect(findings[0]).toMatchObject({
33+
severity: 'error',
34+
rule: OBJECT_FIELD_REF_UNKNOWN,
35+
path: 'objects[0].highlightFields[1]',
36+
});
37+
// The author must read back the string they typed and the object it was
38+
// resolved against — a finding that names neither is unactionable.
39+
expect(findings[0]!.message).toContain('field_10');
40+
expect(findings[0]!.message).toContain('proj_task');
41+
// …and the fields that DO exist, so the fix is one read away.
42+
expect(findings[0]!.hint).toContain('health_score');
43+
});
44+
45+
it('passes a list whose every entry names a real field', () => {
46+
expect(validateObjectFieldRefs(stackOf({ highlightFields: ['name', 'health_score'] })))
47+
.toEqual([]);
48+
});
49+
50+
it('reports EVERY dangling entry, not only the first', () => {
51+
const findings = validateObjectFieldRefs(
52+
stackOf({ highlightFields: ['nope_one', 'name', 'nope_two'] }),
53+
);
54+
expect(findings.map((f) => f.path)).toEqual([
55+
'objects[0].highlightFields[0]',
56+
'objects[0].highlightFields[2]',
57+
]);
58+
});
59+
60+
it('judges the retired `compactLayout` spelling at the same position (raw `lint` input)', () => {
61+
// Not an accepted spelling — `ObjectSchema` refuses it and the ADR-0085
62+
// conversion normalizes it away before the parsed tier. Read here only so
63+
// the raw `lint` path keeps the coverage this clause took over from
64+
// `validateSemanticRoles`. See the rule's module note.
65+
const findings = validateObjectFieldRefs(stackOf({ compactLayout: ['name', 'field_10'] }));
66+
expect(findings).toHaveLength(1);
67+
expect(findings[0]).toMatchObject({
68+
severity: 'error',
69+
rule: OBJECT_FIELD_REF_UNKNOWN,
70+
path: 'objects[0].compactLayout[1]',
71+
});
72+
});
73+
});
74+
75+
describe('validateObjectFieldRefs — the Studio click path (#15254)', () => {
76+
// The reproduction from the card, in the order an author actually clicks:
77+
// 1. click-create a Number field → it is minted as `field_10`
78+
// 2. add it to `highlightFields` → the list references `field_10`
79+
// 3. set its label to "Health Score" → the API name auto-derives to
80+
// `health_score`, and `highlightFields` still says `field_10`
81+
// Naming a field after placing it is the natural order, so this is the
82+
// shape ANY author produces — not a contrived mutation.
83+
const afterDerivedRename = stackOf({
84+
// step 3 has happened: the field is `health_score` …
85+
fields: { name: { type: 'text' }, health_score: { type: 'number', label: 'Health Score' } },
86+
// … and step 2's reference was never rewritten.
87+
highlightFields: ['field_10'],
88+
});
89+
90+
it('REFUSES the derived-rename scenario `field_10` → `health_score`', () => {
91+
const findings = validateObjectFieldRefs(afterDerivedRename);
92+
expect(findings).toHaveLength(1);
93+
expect(findings[0]!.severity).toBe('error');
94+
expect(findings[0]!.rule).toBe(OBJECT_FIELD_REF_UNKNOWN);
95+
expect(findings[0]!.path).toBe('objects[0].highlightFields[0]');
96+
});
97+
98+
it('the SAME body publishes clean once the reference is rewritten', () => {
99+
expect(validateObjectFieldRefs(stackOf({
100+
fields: { name: { type: 'text' }, health_score: { type: 'number' } },
101+
highlightFields: ['health_score'],
102+
}))).toEqual([]);
103+
});
104+
105+
it('the RUNTIME publish door refuses it — the door a Studio tenant actually has', () => {
106+
// The whole point of the card: this is the fourth wall (#4463), reached by
107+
// Studio, REST `/meta` and MCP authors alike, and it is the ONLY one a
108+
// tenant has. Before #15254 it dispatched no reference-integrity rule at
109+
// all on an object write.
110+
expect(runtimeAuthoringRulesFor('object').map((r) => r.name))
111+
.toContain('validateReferenceIntegrity');
112+
113+
const result = runRuntimeAuthoringRules({
114+
type: 'object',
115+
item: afterDerivedRename.objects[0],
116+
context: { objects: [] },
117+
});
118+
119+
const refusal = result.errors.find((f) => f.rule === OBJECT_FIELD_REF_UNKNOWN);
120+
expect(refusal, JSON.stringify(result.errors)).toBeDefined();
121+
// Name-keyed on the wire (#10064), which is the path the card asks to see
122+
// on screen: `objects.<name>.highlightFields[i]`, never a snapshot index.
123+
expect(refusal!.path).toBe('objects.proj_task.highlightFields[0]');
124+
expect(refusal!.severity).toBe('error');
125+
// `rulesRun` is non-empty, so "clean" and "nothing ran" stay distinguishable.
126+
expect(result.rulesRun).toContain('validateReferenceIntegrity');
127+
});
128+
129+
it('a clean object still publishes through that door', () => {
130+
const result = runRuntimeAuthoringRules({
131+
type: 'object',
132+
item: obj({ highlightFields: ['name', 'health_score'] }),
133+
context: { objects: [] },
134+
});
135+
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
136+
});
137+
138+
it('does not blame this write for a STORED sibling already dangling (#4463 D4)', () => {
139+
const stored = obj({ name: 'legacy_thing', highlightFields: ['long_gone'] });
140+
const result = runRuntimeAuthoringRules({
141+
type: 'object',
142+
item: obj({ highlightFields: ['name'] }),
143+
context: { objects: [stored] },
144+
});
145+
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
146+
});
147+
});
148+
149+
describe('validateObjectFieldRefs — publicSharing.redactFields', () => {
150+
it('REFUSES a dangling redaction — the one that fails OPEN', () => {
151+
const findings = validateObjectFieldRefs(stackOf({
152+
publicSharing: { enabled: true, redactFields: ['helth_score'] },
153+
}));
154+
expect(findings).toHaveLength(1);
155+
expect(findings[0]).toMatchObject({
156+
severity: 'error',
157+
rule: OBJECT_FIELD_REF_UNKNOWN,
158+
path: 'objects[0].publicSharing.redactFields[0]',
159+
});
160+
// The consequence sentence is the point of the position: it is not that
161+
// the field renders short, it is that it is SERVED.
162+
expect(findings[0]!.message).toMatch(/fails OPEN/i);
163+
// A near-miss carries the suggestion.
164+
expect(findings[0]!.message).toContain('health_score');
165+
});
166+
167+
it('passes a redaction list whose entries all resolve', () => {
168+
expect(validateObjectFieldRefs(stackOf({
169+
publicSharing: { enabled: true, redactFields: ['health_score'] },
170+
}))).toEqual([]);
171+
});
172+
});
173+
174+
describe('validateObjectFieldRefs — the three shared skips', () => {
175+
it('skip 3: a registry-injected system column is a LIVE pointer, not a miss (#5378)', () => {
176+
expect(validateObjectFieldRefs({
177+
objects: [obj({ ownership: 'user', highlightFields: ['name', 'owner_id'] })],
178+
})).toEqual([]);
179+
});
180+
181+
it('skip 3, the other direction: `ownership: none` injects no owner_id, so it IS a miss', () => {
182+
const findings = validateObjectFieldRefs({
183+
objects: [obj({ ownership: 'none', highlightFields: ['owner_id'] })],
184+
});
185+
expect(findings.map((f) => f.rule)).toEqual([OBJECT_FIELD_REF_UNKNOWN]);
186+
});
187+
188+
it('skip 2: an object with no readable field map is never judged (ADR-0015 external)', () => {
189+
expect(validateObjectFieldRefs({
190+
objects: [{
191+
name: 'remote_thing',
192+
external: { remoteName: 'things', writable: false },
193+
highlightFields: ['whatever_the_remote_calls_it'],
194+
}],
195+
})).toEqual([]);
196+
});
197+
198+
it('is inert on junk: no objects, junk entries, non-array lists', () => {
199+
expect(validateObjectFieldRefs({})).toEqual([]);
200+
expect(validateObjectFieldRefs({ objects: [null, 7, 'x'] as never })).toEqual([]);
201+
expect(validateObjectFieldRefs(stackOf({ highlightFields: 'not-an-array' }))).toEqual([]);
202+
expect(validateObjectFieldRefs(stackOf({ highlightFields: [null, 3, ''] }))).toEqual([]);
203+
});
204+
});
205+
206+
describe('the clause that moved out of validateSemanticRoles', () => {
207+
it('semantic roles no longer double-reports highlightFields EXISTENCE', () => {
208+
const stack = stackOf({ highlightFields: ['field_10'] });
209+
const semantic = validateSemanticRoles(stack).filter(
210+
(f) => f.rule === SEMANTIC_ROLE_FIELD_UNKNOWN && f.path.includes('highlightFields'),
211+
);
212+
expect(semantic, JSON.stringify(semantic)).toEqual([]);
213+
// One finding on the path, at the gating tier, not two at two tiers.
214+
expect(validateObjectFieldRefs(stack)).toHaveLength(1);
215+
});
216+
217+
it('semantic roles KEEPS stageField — a scalar role pointer, still advisory', () => {
218+
const findings = validateSemanticRoles(stackOf({ stageField: 'pipeline' }));
219+
expect(findings.map((f) => f.rule)).toContain(SEMANTIC_ROLE_FIELD_UNKNOWN);
220+
expect(findings.find((f) => f.rule === SEMANTIC_ROLE_FIELD_UNKNOWN)!.severity).toBe('warning');
221+
// …and this rule does not take it: lists only, see the module note.
222+
expect(validateObjectFieldRefs(stackOf({ stageField: 'pipeline' }))).toEqual([]);
223+
});
224+
225+
it('semantic roles KEEPS the PROVENANCE question at the highlightFields position', () => {
226+
// An ADR-0015 external object: the injected anchor RESOLVES (so this rule
227+
// is silent, skip 3) but nothing provisions storage behind it (#8116).
228+
const external = {
229+
name: 'remote_thing',
230+
external: { remoteName: 'things', writable: false },
231+
fields: { email: { type: 'text' } },
232+
ownership: 'user',
233+
highlightFields: ['email', 'owner_id'],
234+
};
235+
const findings = validateSemanticRoles({ objects: [external] });
236+
expect(findings.map((f) => f.rule)).toContain('semantic-role-field-unprovisioned');
237+
});
238+
});
239+
240+
describe('registry wiring', () => {
241+
it('reaches all three commands through the reference-integrity suite entry', () => {
242+
const entry = AUTHORING_RULES.find((r) => r.name === 'validateReferenceIntegrity')!;
243+
expect(entry.tier).toBe('gating');
244+
expect(entry.commands).toEqual(expect.arrayContaining(['validate', 'build', 'lint']));
245+
expect(entry.surfaces).toContain('runtime-publish');
246+
expect(entry.runtimeTypes).toContain('object');
247+
});
248+
});

0 commit comments

Comments
 (0)