Skip to content

Commit 891ea81

Browse files
os-zhuangclaude
andauthored
feat(lint): bidirectional binding-root check for visibility predicates (ADR-0089 D3b) (#2931)
The `visibility-root-mislayered` rule only flagged the runtime direction (`data.` root on a `*.view.ts` / `*.page.ts` surface). ADR-0089 D3 spells out the check in both directions, so make it layer-aware: - `validateVisibilityPredicates(stack, { layer })` gains an optional layer. `'runtime'` (default) is unchanged; `'metadata'` flags a `record.`-rooted predicate on a `*.form.ts` metadata-editing form (which binds `data`). - Generalize the root matcher to any leading identifier and drive the message/hint from a per-layer table. - Export `VisibilityLayer` / `VisibilityOptions`. Back-compat: the single-argument `os validate` / `compile` call sites keep runtime behavior. Adds metadata-direction tests; full lint suite green (204 passed), tsc clean. Claude-Session: https://claude.ai/code/session_01R7oGohmS4pT9H73zoV6Jdb Co-authored-by: Claude <noreply@anthropic.com>
1 parent cbf654b commit 891ea81

4 files changed

Lines changed: 154 additions & 27 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
ADR-0089 D3b: make the `visibility-root-mislayered` lint check bidirectional. `validateVisibilityPredicates` now accepts an optional `{ layer }` option — `'runtime'` (default, unchanged) flags a `data.`-rooted predicate on a `*.view.ts` / `*.page.ts` surface, and `'metadata'` flags a `record.`-rooted predicate on a `*.form.ts` metadata-editing form. Both directions of the ADR's binding-root rule are now covered. Adds the `VisibilityLayer` / `VisibilityOptions` exported types. Fully back-compat: existing single-argument callers keep the runtime behavior.

packages/lint/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,12 @@ export {
7474
VISIBILITY_ALIAS_DEPRECATED,
7575
VISIBILITY_ROOT_MISLAYERED,
7676
} from './validate-visibility-predicates.js';
77-
export type { VisibilityFinding, VisibilitySeverity } from './validate-visibility-predicates.js';
77+
export type {
78+
VisibilityFinding,
79+
VisibilitySeverity,
80+
VisibilityLayer,
81+
VisibilityOptions,
82+
} from './validate-visibility-predicates.js';
7883

7984
export {
8085
validateCapabilityReferences,

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

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,4 +126,62 @@ describe('validateVisibilityPredicates (ADR-0089 D3b)', () => {
126126
expect(validateVisibilityPredicates({})).toEqual([]);
127127
expect(validateVisibilityPredicates({ views: [], pages: [] })).toEqual([]);
128128
});
129+
130+
// ── Layer-directional binding-root check (ADR-0089 D3, both directions) ──
131+
describe('metadata layer (opts.layer: "metadata")', () => {
132+
it('flags a `record.`-rooted predicate in a metadata-editing form as mis-layered', () => {
133+
const stack = {
134+
views: [
135+
{ name: 'page_form', sections: [{ fields: [{ field: 'template', visibleWhen: "record.type != 'list'" }] }] },
136+
],
137+
};
138+
const findings = validateVisibilityPredicates(stack, { layer: 'metadata' });
139+
expect(findings.map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]);
140+
expect(findings[0].severity).toBe('warning');
141+
expect(findings[0].message).toContain('record.');
142+
expect(findings[0].hint).toContain('data');
143+
});
144+
145+
it('is clean for the canonical `data.` root in a metadata-editing form', () => {
146+
const stack = {
147+
views: [
148+
{ name: 'page_form', sections: [{ visibleWhen: "data.type != 'list'", fields: [{ field: 'template', visibleWhen: "data.type == 'grid'" }] }] },
149+
],
150+
};
151+
expect(validateVisibilityPredicates(stack, { layer: 'metadata' })).toEqual([]);
152+
});
153+
154+
it('does NOT flag `data.` as mis-layered in the metadata layer (the runtime rule is the opposite)', () => {
155+
const stack = {
156+
views: [{ name: 'f', sections: [{ fields: [{ field: 'x', visibleWhen: "data.type == 'grid'" }] }] }],
157+
};
158+
// Runtime (default) flags `data.`…
159+
expect(validateVisibilityPredicates(stack).map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]);
160+
// …metadata does not.
161+
expect(validateVisibilityPredicates(stack, { layer: 'metadata' })).toEqual([]);
162+
});
163+
164+
it('still flags a deprecated alias key in the metadata layer (alias check is layer-agnostic)', () => {
165+
const stack = {
166+
views: [{ name: 'f', sections: [{ visibleOn: "data.a == 1", fields: [] }] }],
167+
};
168+
const rules = validateVisibilityPredicates(stack, { layer: 'metadata' }).map((f) => f.rule);
169+
// alias present, but `data.` is correct for the metadata layer → only the alias finding.
170+
expect(rules).toEqual([VISIBILITY_ALIAS_DEPRECATED]);
171+
});
172+
173+
it('does not confuse an identifier ending in `record` (e.g. `my_record.x`) for a record root', () => {
174+
const stack = {
175+
views: [{ name: 'f', sections: [{ fields: [{ field: 'x', visibleWhen: "my_record.x == 1" }] }] }],
176+
};
177+
expect(validateVisibilityPredicates(stack, { layer: 'metadata' })).toEqual([]);
178+
});
179+
});
180+
181+
it('runtime is the default layer (no opts) — flags `data.`, not `record.`', () => {
182+
const dataStack = { views: [{ name: 'f', sections: [{ fields: [{ field: 'x', visibleWhen: 'data.a == 1' }] }] }] };
183+
const recordStack = { views: [{ name: 'f', sections: [{ fields: [{ field: 'x', visibleWhen: 'record.a == 1' }] }] }] };
184+
expect(validateVisibilityPredicates(dataStack).map((f) => f.rule)).toEqual([VISIBILITY_ROOT_MISLAYERED]);
185+
expect(validateVisibilityPredicates(recordStack)).toEqual([]);
186+
});
129187
});

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

Lines changed: 85 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@
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

3439
export 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+
3654
export 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$])data\.\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
*/
87140
function 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

Comments
 (0)