Skip to content

Commit cdbffca

Browse files
os-zhuangclaude
andauthored
fix(lint): judge a schema-bound metadata form at its own binding layer (#7815) (#8041)
The runtime publish gate calls `validateVisibilityPredicates(stack)` with no options, so every view was judged at the `'runtime'` layer default — including schema-bound metadata forms, which bind the row under edit as `data`. Correct metadata therefore drew a `visibility-root-mislayered` advisory telling its author to write `record.`, and `visibility-bare-identifier`'s hint prescribed `record.<word>`, on a surface that binds no `record` at all. The layer is now read off the metadata where the metadata states it: a form view declaring `data: { provider: 'schema', schemaId }` is judged at `metadata`, and every other site — a plain runtime view, every page component — still takes `opts.layer`. Derived from the same `schemaIdOf` call that decides the #7696 right-hand-literal-slot stand-down, so the two cannot disagree about which surface they are on. The rule itself is unchanged; this is layer plumbing at the invocation side. `visibility-root-mislayered` is `warning` in both directions, so acceptance is untouched — measured as identical error sets across the 19-row controls corpus, and pinned as a property in `runtime-gate.test.ts`. Claude-Session: https://claude.ai/code/session_01WocN37om5bw81JDoEEMA2e Co-authored-by: Claude <noreply@anthropic.com>
1 parent 690ccf2 commit cdbffca

4 files changed

Lines changed: 351 additions & 13 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): judge a schema-bound metadata form at its own binding layer (#7815)
6+
7+
At the runtime publish gate, `validateVisibilityPredicates` ran at its
8+
`'runtime'` layer default for **every** view, including schema-bound metadata
9+
forms (`data: { provider: 'schema', schemaId }`). Those forms bind the row under
10+
edit as `data`, so correct metadata drew a `visibility-root-mislayered` advisory
11+
telling its author to write `record.` — a root that surface binds nothing under —
12+
and `visibility-bare-identifier`'s hint prescribed `record.<word>` for the same
13+
reason. Nothing went red, which is how it survived: the gate was green, the
14+
advisory was simply wrong, and the only symptom was authors and AI authors being
15+
steered to the wrong root at the publish door.
16+
17+
The layer is now read off the metadata where the metadata states it. A form view
18+
declaring `data: { provider: 'schema', schemaId }` is judged at `metadata`; every
19+
other site — a plain runtime view, every page component — still takes the
20+
caller's `opts.layer` (default `'runtime'`), so `os validate` / `compile` and any
21+
file-aware caller are unchanged. It is derived from the same `schemaIdOf` call
22+
that already decides the right-hand-literal-slot stand-down (#7696), so the two
23+
verdicts cannot disagree about which surface they are on.
24+
25+
Three consequences on a schema-bound form, all advisory:
26+
27+
- a correctly `data.`-rooted predicate no longer draws the mis-layer advisory;
28+
- a `record.`-rooted one now does, in ADR-0089 D3's other direction — that
29+
predicate can never match, and this door was silent about it;
30+
- `visibility-bare-identifier` prescribes `data.<word>`, the root the surface
31+
actually binds.
32+
33+
`visibility-root-mislayered` is `warning` in both directions and no other rule's
34+
severity or firing condition moves, so **acceptance is untouched**: the same
35+
inputs are refused, with the same ids, at the same paths. That boundary is pinned
36+
as a property in `runtime-gate.test.ts` rather than left as a claim.

packages/lint/src/runtime-gate.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,3 +491,95 @@ describe('the views[] visibility-predicate family at the runtime publish gate (#
491491
expect(sawAdvisory, 'the corpus must contain an advisory').toBe(true);
492492
});
493493
});
494+
495+
// ─────────────────────────────────────────────────────────────────────
496+
// #7815 — the layer the gate judges a schema-bound form at.
497+
//
498+
// A separate block, added at the end rather than woven into the family suite
499+
// above (#7576 serialization): nothing in that suite is touched.
500+
// ─────────────────────────────────────────────────────────────────────
501+
502+
describe('the publish gate judges a schema-bound form at its own layer (#7815)', () => {
503+
it('a correctly `data.`-rooted form is SILENT — no advisory, no refusal', () => {
504+
// The finding. `authoring-rules.ts` calls `validateVisibilityPredicates(stack)`
505+
// with no options, so every view was judged at the `'runtime'` default and
506+
// this exact body — correct metadata — came back with an advisory telling
507+
// its author to write `record.`, on a surface that binds no `record` at all.
508+
// Nothing went red then and nothing goes red now; the difference is only
509+
// what the author is told, which is the whole harm of the class.
510+
const result = gateView(schemaBoundForm("data.type == 'text'"));
511+
expect(result.errors, JSON.stringify(result.errors)).toEqual([]);
512+
expect(result.advisories, JSON.stringify(result.advisories)).toEqual([]);
513+
// "clean" and "nothing ran" must stay distinguishable.
514+
expect(result.rulesRun).toEqual([
515+
'validateVisibilityPredicates',
516+
'validatePredicatePathRefs',
517+
]);
518+
});
519+
520+
it('the runtime view one fixture-line away still draws it — the rule is live', () => {
521+
// The negative control. Standing an advisory down and going blind look
522+
// identical from the inside, so the case above is only worth its ink beside
523+
// one that still fires: same predicate root, same gate, opposite verdict,
524+
// decided by the `data:` source alone.
525+
const f = gateView(runtimeView("data.status == 'open'"))
526+
.advisories.find((a) => a.rule === 'visibility-root-mislayered');
527+
expect(f, 'a wrong-layer paste on a RUNTIME view is still a wrong-layer paste').toBeDefined();
528+
expect(f!.severity).toBe('warning');
529+
});
530+
531+
it('a `record.`-rooted form now draws the advisory the OTHER way', () => {
532+
// ADR-0089 D3's second direction, unreachable at this door until now: told
533+
// `'runtime'`, the rule forbids `data.` and has nothing to say about
534+
// `record.`, so a form predicate that can never match published in silence.
535+
// Not new behaviour — the rule's existing metadata arm, finally addressed.
536+
const result = gateView(schemaBoundForm("record.type == 'text'"));
537+
expect(result.errors, 'still advisory-only: acceptance is untouched').toEqual([]);
538+
const f = result.advisories.find((a) => a.rule === 'visibility-root-mislayered');
539+
expect(f, 'a predicate that never matches must reach the author').toBeDefined();
540+
expect(f!.severity).toBe('warning');
541+
expect(f!.hint).toMatch(/data/);
542+
});
543+
544+
it('prescribes the root the surface actually binds when it REFUSES', () => {
545+
// The second half of the finding: the layer default also chose the root
546+
// quoted inside `visibility-bare-identifier`'s hint, so the loudest finding
547+
// on this surface — the one that BLOCKS the publish — told the author to
548+
// write `record.status` on a form that binds `data`. Same id, same
549+
// severity, same input: only the prescription moved.
550+
const f = gateView(schemaBoundForm('status == active'))
551+
.errors.find((e) => e.rule === 'visibility-bare-identifier');
552+
expect(f, 'a bare word on the LEFT is still refused').toBeDefined();
553+
expect(f!.severity).toBe('error');
554+
expect(f!.hint).toContain('`data.status`');
555+
expect(f!.hint).not.toContain('`record.status`');
556+
});
557+
558+
it('moves NO finding across the error/advisory boundary', () => {
559+
// The acceptance guarantee this card is bounded by, as a property over the
560+
// schema-bound half of the family corpus: the derivation may only ever
561+
// change which ADVISORIES are emitted. Asserted as exact error sets — each
562+
// one is what the `'runtime'` reading produced on the same input — so a
563+
// later edit to the layer plumbing that promoted or demoted anything goes
564+
// red here rather than at a tenant's publish door.
565+
const cases: Array<[string, string[]]> = [
566+
["data.type == 'text'", []],
567+
["record.type == 'text'", []],
568+
['data.type == active', []],
569+
['status == active', ['visibility-bare-identifier']],
570+
['data.name == active && active', ['visibility-bare-identifier']],
571+
['active == data.type', ['predicate-rhs-path-shaped', 'visibility-bare-identifier']],
572+
["data.tpye == 'text'", ['predicate-path-unresolved']],
573+
["type == 'text'", ['predicate-path-unrooted']],
574+
['data.type == data.label', ['predicate-rhs-path-shaped']],
575+
["country === 'USA'", ['visibility-predicate-syntax']],
576+
];
577+
for (const [predicate, expected] of cases) {
578+
expect(
579+
gateView(schemaBoundForm(predicate)).errors.map((e) => e.rule).sort(),
580+
`the refusal set for \`${predicate}\` changed — the #7815 layer derivation is `
581+
+ `advisory-only by construction, so anything moving here is a scope breach`,
582+
).toEqual([...expected].sort());
583+
}
584+
});
585+
});

packages/lint/src/validate-visibility-predicates.test.ts

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,148 @@ describe('validateVisibilityPredicates (ADR-0089 D3b)', () => {
255255
});
256256
});
257257

258+
// ─────────────────────────────────────────────────────────────────────
259+
// #7815 — WHICH LAYER a site is on, when the caller does not say.
260+
//
261+
// The rule above is correct for the layer it is told. What it was told at the
262+
// runtime publish gate was the `'runtime'` default for EVERY view, including
263+
// schema-bound metadata forms — so a correctly `data.`-rooted form drew the
264+
// advisory telling its author to write `record.`. These cases pin the
265+
// derivation itself; `runtime-gate.test.ts` pins it at the door it was wrong at.
266+
// ─────────────────────────────────────────────────────────────────────
267+
268+
describe('the layer a site declares for itself (#7815)', () => {
269+
/**
270+
* A schema-bound metadata form, with the data source on the CONTAINER (the
271+
* `self` rung of the `formViewSites` ladder).
272+
*/
273+
const metaForm = (predicate: string) => ({
274+
views: [{
275+
name: 'field_editor',
276+
data: { provider: 'schema', schemaId: 'field' },
277+
sections: [{ fields: [{ field: 'notes', visibleWhen: predicate }] }],
278+
}],
279+
});
280+
281+
/** The same predicate on a plain runtime view — the negative control. */
282+
const runtimeForm = (predicate: string) => ({
283+
views: [{ name: 'task_form', sections: [{ fields: [{ field: 'notes', visibleWhen: predicate }] }] }],
284+
});
285+
286+
it('a `data.`-rooted predicate on a schema-bound form is CORRECT — no advisory', () => {
287+
// The finding this card is about. `data` IS the root that surface binds.
288+
expect(validateVisibilityPredicates(metaForm("data.type == 'grid'"))).toEqual([]);
289+
});
290+
291+
it('the same predicate on a plain runtime view still draws it — the rule is live', () => {
292+
// The negative control that keeps the case above from being a walk that
293+
// simply went blind: one character of difference in the fixture (the
294+
// `data:` source), opposite verdicts.
295+
const findings = validateVisibilityPredicates(runtimeForm("data.type == 'grid'"));
296+
expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]);
297+
expect(findings[0].severity).toBe('warning');
298+
});
299+
300+
it('a `record.`-rooted predicate on a schema-bound form draws it the OTHER way', () => {
301+
// ADR-0089 D3 is bidirectional and this direction was unreachable at the
302+
// runtime gate: told `'runtime'`, the rule forbids `data.` and says nothing
303+
// about `record.`, so a form predicate that never matches published silent.
304+
// No new behaviour — this is the metadata-layer arm the rule already had.
305+
const findings = validateVisibilityPredicates(metaForm("record.type == 'grid'"));
306+
expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]);
307+
expect(findings[0].severity).toBe('warning');
308+
expect(findings[0].message).toContain('record.');
309+
expect(findings[0].hint).toContain('data');
310+
});
311+
312+
it('derives per SITE, not per stack — one entry can carry both kinds', () => {
313+
// `formViews.<key>` sub-containers each declare their own `data`, so a
314+
// stack-level layer would be wrong for one of these two no matter which
315+
// value it took.
316+
const stack = {
317+
views: [{
318+
name: 'mixed',
319+
object: 'account',
320+
formViews: {
321+
meta: {
322+
data: { provider: 'schema', schemaId: 'field' },
323+
sections: [{ fields: [{ field: 'a', visibleWhen: "data.type == 'grid'" }] }],
324+
},
325+
live: {
326+
sections: [{ fields: [{ field: 'b', visibleWhen: "data.type == 'grid'" }] }],
327+
},
328+
},
329+
}],
330+
};
331+
const findings = validateVisibilityPredicates(stack);
332+
expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]);
333+
expect(findings[0].path).toBe('views[0].formViews.live.sections[0].fields[0]');
334+
});
335+
336+
it('`opts.layer` still governs every site that declares no data source', () => {
337+
// The file-aware caller's contract is unchanged: a `*.form.ts` whose form
338+
// carries no `data: { provider: 'schema' }` is still only reachable through
339+
// the option, and a page component always is.
340+
expect(validateVisibilityPredicates(runtimeForm("data.type == 'grid'"), { layer: 'metadata' }))
341+
.toEqual([]);
342+
expect(
343+
validateVisibilityPredicates(runtimeForm("record.type == 'grid'"), { layer: 'metadata' })
344+
.map((f) => f.rule),
345+
).toEqual([VISIBILITY_ROOT_MISLAYERED]);
346+
347+
const page = (predicate: string) => ({
348+
pages: [{ name: 'p', regions: [{ components: [{ type: 'element:text', visibleWhen: predicate }] }] }],
349+
});
350+
expect(validateVisibilityPredicates(page("data.x == 'y'")).map((f) => f.rule))
351+
.toEqual([VISIBILITY_ROOT_MISLAYERED]);
352+
expect(validateVisibilityPredicates(page("data.x == 'y'"), { layer: 'metadata' })).toEqual([]);
353+
});
354+
355+
it('an unresolvable `schemaId` is still a schema-bound SURFACE', () => {
356+
// The layer follows the data SOURCE, not whether the id resolves — the same
357+
// boundary `literalRhs` draws off the same `schemaIdOf` call, so the two
358+
// cannot disagree about which surface they are on.
359+
expect(validateVisibilityPredicates({
360+
views: [{
361+
name: 'f',
362+
data: { provider: 'schema', schemaId: 'no_such_schema' },
363+
sections: [{ fields: [{ field: 'x', visibleWhen: "data.a == 'b'" }] }],
364+
}],
365+
})).toEqual([]);
366+
});
367+
368+
it('a non-schema provider is NOT a metadata form', () => {
369+
// `schemaIdOf` reads `provider === 'schema'` only; an ObjectQL-backed data
370+
// source is a runtime surface and keeps the runtime direction.
371+
const findings = validateVisibilityPredicates({
372+
views: [{
373+
name: 'f',
374+
data: { provider: 'object', object: 'account' },
375+
sections: [{ fields: [{ field: 'x', visibleWhen: "data.a == 'b'" }] }],
376+
}],
377+
});
378+
expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]);
379+
});
380+
381+
it('moves NO finding across the error/advisory boundary', () => {
382+
// The acceptance guarantee, asserted rather than argued: the derivation may
383+
// only ever change which ADVISORIES an author hears. Every fixture here is
384+
// schema-bound — the set the derivation moves — and every `error` on it is
385+
// the same id, at the same path, that the `'runtime'` reading produced.
386+
const errorsOf = (predicate: string) =>
387+
validateVisibilityPredicates(metaForm(predicate))
388+
.filter((f) => f.severity === 'error')
389+
.map((f) => f.rule)
390+
.sort();
391+
392+
expect(errorsOf("data.type == 'grid'")).toEqual([]);
393+
expect(errorsOf("record.type == 'grid'")).toEqual([]);
394+
expect(errorsOf('status == active')).toEqual([VISIBILITY_BARE_IDENTIFIER]);
395+
expect(errorsOf('active == data.type')).toEqual([VISIBILITY_BARE_IDENTIFIER]);
396+
expect(errorsOf("country === 'USA'")).toEqual([VISIBILITY_PREDICATE_SYNTAX]);
397+
});
398+
});
399+
258400
// ─────────────────────────────────────────────────────────────────────
259401
// `visibility-bare-identifier` — #6128 (the build-time half of #5149's
260402
// 2026-08-06 ruling; the runtime warn-once half landed as objectui#3541).
@@ -475,8 +617,15 @@ describe('visibility-bare-identifier (#6128 / #5149 requirement 3)', () => {
475617
it('proves the scanner still sees — the stand-down is per IDENTIFIER', () => {
476618
// Every one of these is the same schema-bound form, so a walk that had
477619
// gone blind would report nothing here either.
620+
//
621+
// #7815: this pin used to read `record.status`, which is what the rule
622+
// said here while the caller's `'runtime'` default decided the layer for a
623+
// form that binds no `record` at all. The refusal is unchanged — same id,
624+
// same `error`, same one finding; only the ROOT it prescribes moved to the
625+
// one this surface actually binds. (That the pin had to change is the
626+
// measurement: an assertion was holding the wrong prescription in place.)
478627
expect(bareFindings(metaForm('status == active')).map((f) => f.hint))
479-
.toEqual([expect.stringContaining('`record.status`')]);
628+
.toEqual([expect.stringContaining('`data.status`')]);
480629
expect(bareFindings(metaForm('active == data.type'))).toHaveLength(1);
481630
expect(bareFindings(metaForm('data.type == active && active'))).toHaveLength(1);
482631
// A macro body produces no replacement finding, so nothing stands down.

0 commit comments

Comments
 (0)