1717 *
1818 * - `visibility-alias-deprecated` — a `visibleOn` / `visibility` key in authored
1919 * source. Autofix intent: rename the key to `visibleWhen` (same value).
20- * - `visibility-root-mislayered` — a runtime view/page visibility predicate
21- * rooted at `data.` (the metadata-editing-form root, ADR-0089 §Context). Runtime
22- * record surfaces bind `record` + `current_user` (pages also expose `page.<var>`),
23- * so a `data.`-rooted predicate here is almost always a wrong-layer paste that
24- * silently never matches.
20+ * - `visibility-root-mislayered` — a visibility predicate whose binding root does
21+ * not match its layer (ADR-0089 D3, §Context). The check is **bidirectional**:
22+ * - **runtime** view/page surfaces (`*.view.ts` / `*.page.ts`) bind
23+ * `record` + `current_user` (pages also expose `page.<var>`), so a `data.`-rooted
24+ * predicate here is a wrong-layer paste that silently never matches; and
25+ * - **metadata-editing** forms (`*.form.ts` — the row under edit) bind `data`, so
26+ * a `record.`-rooted predicate there is the same bug in the other direction.
27+ * The layer is supplied by the caller (`opts.layer`, default `'runtime'`): the
28+ * app-lint path (`os validate` / `compile`) always lints runtime surfaces, while a
29+ * file-aware caller linting a `*.form.ts` passes `layer: 'metadata'`.
2530 *
2631 * Scope: `views` (form `sections` / legacy `groups`, and their `fields`) and
2732 * `pages` (`regions[].components[]`). Data-field `visibleWhen` is already covered
@@ -33,6 +38,19 @@ export const VISIBILITY_ROOT_MISLAYERED = 'visibility-root-mislayered';
3338
3439export type VisibilitySeverity = 'error' | 'warning' ;
3540
41+ /**
42+ * Which binding environment the linted surface belongs to (ADR-0089 §Context):
43+ * - `runtime` — `*.view.ts` / `*.page.ts`; binds `record` + `current_user` (+ `page`).
44+ * - `metadata` — `*.form.ts` metadata-editing forms; binds `data` (the row under edit).
45+ */
46+ export type VisibilityLayer = 'runtime' | 'metadata' ;
47+
48+ /** Options for {@link validateVisibilityPredicates}. */
49+ export interface VisibilityOptions {
50+ /** Binding layer of the surface being linted. Defaults to `'runtime'`. */
51+ layer ?: VisibilityLayer ;
52+ }
53+
3654export interface VisibilityFinding {
3755 /** Always `warning` today — both rules are advisory (see module note). */
3856 severity : VisibilitySeverity ;
@@ -72,22 +90,58 @@ function predicateSource(v: unknown): string | undefined {
7290 return undefined ;
7391}
7492
75- /** Does the predicate reference `data.<x>` as a binding root? */
76- function usesDataRoot ( source : string ) : boolean {
77- // `data` as a leading identifier followed by a property access — the
78- // metadata-editing-form root. Excludes `foo.data` (a field named data).
79- return / ( ^ | [ ^ . \w $ ] ) d a t a \. \w / . test ( source ) ;
93+ /** Does the predicate reference `<root>.<x>` as a leading binding root? */
94+ function usesRoot ( source : string , root : string ) : boolean {
95+ // `<root>` as a leading identifier followed by a property access. The leading
96+ // `(^|[^.\w$])` guard excludes a nested access like `foo.data` (a field named
97+ // `data`) or `my_record.x` (an identifier that merely ends in `record`).
98+ return new RegExp ( `(^|[^.\\w$])${ root } \\.\\w` ) . test ( source ) ;
8099}
81100
101+ /**
102+ * Per-layer mis-rooted-predicate description. The `runtime` layer forbids the
103+ * metadata-editing-form root (`data.`); the `metadata` layer forbids the runtime
104+ * record-surface root (`record.`) — ADR-0089 D3 spells out both directions.
105+ */
106+ const MISLAYER_BY_LAYER : Record <
107+ VisibilityLayer ,
108+ { forbiddenRoot : string ; message : string ; hint : string }
109+ > = {
110+ runtime : {
111+ forbiddenRoot : 'data' ,
112+ message :
113+ 'visibility predicate is rooted at `data.` — that is the ' +
114+ 'metadata-editing-form root (a `*.form.ts` row under edit), not a runtime ' +
115+ 'surface. A runtime view/page predicate that binds `data.` never matches ' +
116+ 'and the element renders unconditionally (ADR-0089).' ,
117+ hint :
118+ 'Runtime record surfaces bind `record` + `current_user` (pages also ' +
119+ "expose `page.<var>`). Use e.g. `record.status == 'open'` instead of " +
120+ "`data.status == 'open'`." ,
121+ } ,
122+ metadata : {
123+ forbiddenRoot : 'record' ,
124+ message :
125+ 'visibility predicate is rooted at `record.` — that is the runtime ' +
126+ 'record-surface root (a `*.view.ts` / `*.page.ts` live record), not a ' +
127+ 'metadata-editing form. A `*.form.ts` predicate that binds `record.` never ' +
128+ 'matches and the element renders unconditionally (ADR-0089).' ,
129+ hint :
130+ 'Metadata-editing forms bind `data` (the row under edit). Use e.g. ' +
131+ "`data.type == 'grid'` instead of `record.type == 'grid'`." ,
132+ } ,
133+ } ;
134+
82135/**
83136 * Inspect one element carrying a visibility predicate. Emits the alias-deprecated
84137 * finding (when an alias key is present) and the mis-layered-root finding (when
85- * the effective predicate is rooted at `data. `).
138+ * the effective predicate's binding root does not match `layer `).
86139 */
87140function checkElement (
88141 el : AnyRec ,
89142 where : string ,
90143 path : string ,
144+ layer : VisibilityLayer ,
91145 findings : VisibilityFinding [ ] ,
92146) : void {
93147 // (1) deprecated alias key present → steer to `visibleWhen`.
@@ -107,24 +161,19 @@ function checkElement(
107161 }
108162 }
109163
110- // (2) mis-layered binding root — check the effective predicate (canonical wins).
164+ // (2) mis-layered binding root — check the effective predicate (canonical wins)
165+ // against the root expected for this layer.
111166 const raw = el [ CANONICAL ] ?? el . visibleOn ?? el . visibility ;
112167 const source = predicateSource ( raw ) ;
113- if ( source && usesDataRoot ( source ) ) {
168+ const rule = MISLAYER_BY_LAYER [ layer ] ;
169+ if ( source && usesRoot ( source , rule . forbiddenRoot ) ) {
114170 findings . push ( {
115171 severity : 'warning' ,
116172 rule : VISIBILITY_ROOT_MISLAYERED ,
117173 where,
118174 path,
119- message :
120- `visibility predicate is rooted at \`data.\` — that is the ` +
121- `metadata-editing-form root (a \`*.form.ts\` row under edit), not a runtime ` +
122- `surface. A runtime view/page predicate that binds \`data.\` never matches ` +
123- `and the element renders unconditionally (ADR-0089).` ,
124- hint :
125- `Runtime record surfaces bind \`record\` + \`current_user\` (pages also ` +
126- `expose \`page.<var>\`). Use e.g. \`record.status == 'open'\` instead of ` +
127- `\`data.status == 'open'\`.` ,
175+ message : rule . message ,
176+ hint : rule . hint ,
128177 } ) ;
129178 }
130179}
@@ -141,8 +190,18 @@ function isFieldObject(entry: unknown): entry is AnyRec {
141190 * `visibleOn` / `visibility` aliases before the schema folds them into
142191 * `visibleWhen`. Returns findings (empty = clean); all advisory (`warning`) —
143192 * the caller must never fail the build on these alone.
193+ *
194+ * The binding-root check is layer-directional (ADR-0089 D3): pass
195+ * `opts.layer = 'metadata'` when linting a `*.form.ts` metadata-editing form (so a
196+ * `record.`-rooted predicate is flagged), or leave it at the `'runtime'` default for
197+ * `*.view.ts` / `*.page.ts` surfaces (so a `data.`-rooted predicate is flagged). The
198+ * alias-deprecated check is layer-agnostic.
144199 */
145- export function validateVisibilityPredicates ( stack : AnyRec ) : VisibilityFinding [ ] {
200+ export function validateVisibilityPredicates (
201+ stack : AnyRec ,
202+ opts : VisibilityOptions = { } ,
203+ ) : VisibilityFinding [ ] {
204+ const layer : VisibilityLayer = opts . layer ?? 'runtime' ;
146205 const findings : VisibilityFinding [ ] = [ ] ;
147206
148207 // ── Views: form sections / legacy groups, and their fields ──────────
@@ -161,13 +220,13 @@ export function validateVisibilityPredicates(stack: AnyRec): VisibilityFinding[]
161220 const sec = sections [ s ] ;
162221 if ( ! sec || typeof sec !== 'object' ) continue ;
163222 const secPath = `views[${ i } ].${ bucket } [${ s } ]` ;
164- checkElement ( sec as AnyRec , where , secPath , findings ) ;
223+ checkElement ( sec as AnyRec , where , secPath , layer , findings ) ;
165224
166225 const secFields = Array . isArray ( ( sec as AnyRec ) . fields ) ? ( ( sec as AnyRec ) . fields as unknown [ ] ) : [ ] ;
167226 for ( let f = 0 ; f < secFields . length ; f ++ ) {
168227 const entry = secFields [ f ] ;
169228 if ( isFieldObject ( entry ) ) {
170- checkElement ( entry , where , `${ secPath } .fields[${ f } ]` , findings ) ;
229+ checkElement ( entry , where , `${ secPath } .fields[${ f } ]` , layer , findings ) ;
171230 }
172231 }
173232 }
@@ -190,7 +249,7 @@ export function validateVisibilityPredicates(stack: AnyRec): VisibilityFinding[]
190249 for ( let c = 0 ; c < components . length ; c ++ ) {
191250 const comp = components [ c ] ;
192251 if ( comp && typeof comp === 'object' ) {
193- checkElement ( comp as AnyRec , where , `pages[${ i } ].regions[${ r } ].components[${ c } ]` , findings ) ;
252+ checkElement ( comp as AnyRec , where , `pages[${ i } ].regions[${ r } ].components[${ c } ]` , layer , findings ) ;
194253 }
195254 }
196255 }
0 commit comments