Skip to content

Commit 17749fc

Browse files
os-zhuangclaude
andauthored
feat(lint): page-component field bindings + non-dashboard chart bindings (#3583) (#3684)
Phase 2 of the #3583 assessment: the two remaining reference classes the HotCRM audit found, both wired into validate/lint/compile together. validate-page-field-bindings — `PageComponent.properties` is an untyped bag, so a highlights strip, KPI card, or details section can name a field the bound object lacks and the component silently skips it. Object binding follows dataSource.object -> properties.object -> page.object, so a multi-object page is judged per element instead of against one page-wide guess; `record:related_list` resolves columns/sort/filter against the RELATED object and its add-picker against the picker's own. Advisory, matching FORM_FIELD_UNKNOWN. The descriptor table is hand-written on purpose: a Zod schema cannot say which `z.string()` prop is a field name, and `PageComponent.type` accepts arbitrary strings, so unknown types are skipped silently. It also covers shapes the props schemas do not describe but real pages author anyway (record:details `sections[].fields` and `hideFields`, the picker's `labelField`) — linting the schema shape alone would find nothing on the actual corpus. validate-chart-bindings — extends ADR-0021 axis checking past dashboards to report charts (report.chart + blocks[].chart), list-view charts (three container paths), and dataset-bound page chart components. An axis naming a raw field instead of a declared measure is an error (post-cutover rows are keyed by measure NAME, so the series returns empty); a declared but unselected measure is a warning. Reports needed their own handling: ReportChartSchema narrows xAxis/yAxis to bare STRINGS, which the dashboard rule's Array.isArray guard skips silently. The react <ObjectChart> block is object-bound rather than dataset-bound and nothing in the repo defines what its aggregate names the result column, so it is left out rather than guessed at (ADR-0078: verify, then enforce). Fixes a defect in the rule shipped by #3657: its page walk read a top-level `page.components` array that PageSchema does not have — components live under regions[].components[] and slots, and sub-trees nest inside the untyped properties bag (children, items[].children, body, footer), not a `children` key on the component (PageComponentSchema is strict). The quick-actions check was visiting nothing on a parsed stack, and its tests encoded the invented shape so they passed. Traversal is now one shared, tested module; on the showcase app it reaches 194 components where the old shape found 46. Source-authored pages (html/react/jsx) are skipped — their regions hold a derived cache the source wins over. Verified zero false positives on app-crm, app-showcase and app-todo with the fuller traversal, after confirming the walk actually visits their 194 components, 1 report chart and 2 list charts (a silently-empty walk would pass vacuously). Suites: lint 435, cli 637 passing; eslint, nul-bytes and api-surface clean. Claude-Session: https://claude.ai/code/session_01GBks5G7AkwrvgF2kL5rfkT Co-authored-by: Claude <noreply@anthropic.com>
1 parent fc5f126 commit 17749fc

14 files changed

Lines changed: 1877 additions & 33 deletions
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
'@objectstack/lint': minor
3+
'@objectstack/cli': minor
4+
---
5+
6+
Page-component field bindings and non-dashboard chart bindings (issue #3583, Phase 2)
7+
8+
Two more reference-integrity rules from the #3583 assessment, both wired into
9+
`os validate`, `os lint`, and `os compile`.
10+
11+
**`validate-page-field-bindings`**`PageComponent.properties` is an untyped
12+
bag, so a highlights strip, KPI card, or details section can name a field the
13+
bound object does not have; the component silently skips it. Which object a
14+
component binds follows `dataSource.object``properties.object` → the page's
15+
`object`, so multi-object pages are checked per element. `record:related_list`
16+
resolves its columns/sort/filter against the **related** object and its
17+
add-picker against that picker's own object. Advisory (matching
18+
`FORM_FIELD_UNKNOWN`). Relationship paths, system fields, cross-package objects,
19+
and unregistered component types are skipped.
20+
21+
**`validate-chart-bindings`** — extends ADR-0021 axis checking past dashboards to
22+
report charts (`report.chart` and `report.blocks[].chart`), list-view charts
23+
(`views[].list`, `views[].listViews.*`, `objects[].listViews.*`), and
24+
dataset-bound page chart components. An axis naming a raw field instead of a
25+
declared measure is an **error** (the series comes back empty); an axis naming a
26+
declared-but-unselected measure is a **warning**. The report shape needed its own
27+
handling: `ReportChartSchema` narrows `xAxis`/`yAxis` to bare strings, which the
28+
dashboard rule's array guard skips silently. The react `<ObjectChart>` block is
29+
object-bound, not dataset-bound, and is deliberately left out — nothing defines
30+
what its aggregate names the result column.
31+
32+
**Fixes:** the page walk used by `validate-action-name-refs` read a top-level
33+
`page.components` array, which `PageSchema` does not have — components live under
34+
`regions[].components[]` and `slots`, and sub-trees nest inside the untyped
35+
`properties` bag (`children`, `items[].children`, `body`, `footer`) rather than a
36+
`children` key on the component. The rule was therefore visiting nothing on a
37+
schema-parsed stack. Traversal now lives in one shared, tested module; on the
38+
showcase app it reaches 194 components where the previous shape found 46.
39+
Source-authored pages (`kind: 'html' | 'react' | 'jsx'`) are skipped — their
40+
`regions` hold a derived cache the `source` wins over.

content/docs/deployment/validating-metadata.mdx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,49 @@ Interpolated targets (`${…}`, `{…}`) are skipped — they resolve at render
116116
Action names get no third-party softening: the runtime ships no built-in action
117117
names, so a name resolving nowhere is always an **error**.
118118

119+
### 5. Page components bound to fields that don't exist
120+
121+
A page component's `properties` is an untyped bag, so a highlights strip, a KPI
122+
card, or a details section can name a field the bound object does not have. The
123+
component silently skips it and the page renders one item short.
124+
125+
```ts
126+
// page.object = 'crm_lead'
127+
{ type: 'record:highlights', properties: { fields: ['status', 'total_revenue'] } }
128+
// ↑ not a crm_lead field → warning
129+
```
130+
131+
Which object a component binds follows `dataSource.object``properties.object`
132+
→ the page's `object`, so a multi-object page is checked per element rather than
133+
against one page-wide guess. A `record:related_list`'s `columns`/`sort`/`filter`
134+
resolve against its **related** object (`objectName`), and its add-picker against
135+
its own. Advisory, like form-layout field references — every consumer degrades
136+
rather than failing.
137+
138+
Skipped, to keep false positives at zero: relationship paths (`account.name`,
139+
resolved by the query engine), registry-injected system fields (`created_at`,
140+
`owner_id`, …), components bound to an object another package defines, and
141+
unregistered component types.
142+
143+
### 6. Chart axes naming raw fields instead of dataset measures
144+
145+
Post-[ADR-0021](/docs/data-modeling/analytics) a chart's result rows are keyed by
146+
the **dataset measure name**, not the underlying column — so an axis pointing at
147+
the raw field renders with an empty series. Dashboard widgets were already
148+
checked; report charts, list-view charts, and dataset-bound page chart components
149+
are checked the same way.
150+
151+
```ts
152+
// dataset declares measure `est_hours` (sum of `estimate_hours`)
153+
chart: { type: 'bar', xAxis: 'status', yAxis: 'estimate_hours' }
154+
// ↑ the base column, not the measure → error
155+
```
156+
157+
An axis naming a measure the dataset declares but this chart does not *select*
158+
(not in `values`) is a **warning**: the query never returns it, so it plots
159+
nothing. The react `<ObjectChart>` block is object-bound rather than
160+
dataset-bound and is not checked here.
161+
119162
## The one gate, two entry points
120163

121164
`os validate` and `os build` (alias of `os compile`) run the **same** validator:
@@ -127,6 +170,8 @@ names, so a name resolving nowhere is always an **error**.
127170
| Widget-binding integrity |||
128171
| Dashboard action/route references (ADR-0049) |||
129172
| Object & action name references (#3583) |||
173+
| Page-component field bindings (#3583) |||
174+
| Chart bindings outside dashboards (#3583) |||
130175
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) |||
131176
| Emits `dist/objectstack.json` |||
132177

packages/cli/src/commands/compile.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { validateWidgetBindings } from '@objectstack/lint';
1414
import { validateDashboardActionRefs } from '@objectstack/lint';
1515
import { validateFilterTokens } from '@objectstack/lint';
1616
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
17+
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
1718
import { validateResponsiveStyles } from '@objectstack/lint';
1819
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
1920
import { validateReadonlyFlowWrites } from '@objectstack/lint';
@@ -325,6 +326,10 @@ export default class Compile extends Command {
325326
// `objectOverride`, dashboard filter `optionsFrom.object`, nav
326327
// `requiresObject` gates, and the name-bound action surfaces
327328
// (`bulkActions`/`rowActions`, page quick-actions, nav action items).
329+
// Plus page-component field bindings and the chart surfaces outside
330+
// dashboards (report charts, list-view charts, dataset-bound page
331+
// chart components) — same ADR-0021 semantic layer, where an axis
332+
// naming a raw field instead of a measure renders an empty series.
328333
// All plain strings in the schema, so a name resolving to nothing
329334
// ships and fails silently. Errors fail the build; the
330335
// platform-prefixed-but-unregistered case is advisory (a third-party
@@ -333,6 +338,8 @@ export default class Compile extends Command {
333338
const refFindings = [
334339
...validateObjectReferences(result.data as Record<string, unknown>),
335340
...validateActionNameRefs(result.data as Record<string, unknown>),
341+
...validatePageFieldBindings(result.data as Record<string, unknown>),
342+
...validateChartBindings(result.data as Record<string, unknown>),
336343
];
337344
const refErrors = refFindings.filter((f) => f.severity === 'error');
338345
const refWarnings = refFindings.filter((f) => f.severity === 'warning');

packages/cli/src/commands/lint.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { lintDataModel } from '../lint/data-model-rules.js';
1111
import { validateWidgetBindings } from '@objectstack/lint';
1212
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateOrgAxisRedLines, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint';
1313
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
14+
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
1415
import { collectAndLintDocs } from '../utils/collect-docs.js';
1516
import { scoreMetadata } from '../lint/score.js';
1617
import { runMetadataEval } from '../lint/metadata-eval.js';
@@ -520,6 +521,36 @@ export function lintConfig(config: any): LintIssue[] {
520521
});
521522
}
522523

524+
// ── Page component field bindings (issue #3583) ──
525+
// `PageComponent.properties` is an untyped bag, so a highlights strip, KPI
526+
// card, or details section can name a field the object does not have; the
527+
// component silently skips it. Advisory, matching `FORM_FIELD_UNKNOWN` —
528+
// every page consumer degrades rather than failing.
529+
for (const t of validatePageFieldBindings(config)) {
530+
issues.push({
531+
severity: t.severity,
532+
rule: t.rule,
533+
message: `${t.where}: ${t.message}`,
534+
path: t.path,
535+
fix: t.hint,
536+
});
537+
}
538+
539+
// ── Chart bindings outside dashboards (issue #3583) ──
540+
// `validate-widget-bindings` covers dashboard widgets only. Report charts,
541+
// list-view charts, and dataset-bound page chart components bind the same
542+
// semantic layer, where an axis naming a raw field instead of a dataset
543+
// measure renders an empty series (ADR-0021).
544+
for (const t of validateChartBindings(config)) {
545+
issues.push({
546+
severity: t.severity,
547+
rule: t.rule,
548+
message: `${t.where}: ${t.message}`,
549+
path: t.path,
550+
fix: t.hint,
551+
});
552+
}
553+
523554
return issues;
524555
}
525556

packages/cli/src/commands/validate.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { validateWidgetBindings } from '@objectstack/lint';
1515
import { validateDashboardActionRefs } from '@objectstack/lint';
1616
import { validateFilterTokens } from '@objectstack/lint';
1717
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
18+
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
1819
import { validateResponsiveStyles } from '@objectstack/lint';
1920
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
2021
import { validateCapabilityReferences } from '@objectstack/lint';
@@ -287,6 +288,10 @@ export default class Validate extends Command {
287288
// `reference`/`objectOverride`, dashboard filter `optionsFrom.object`,
288289
// nav `requiresObject` gates, and the name-bound action surfaces
289290
// (`bulkActions`/`rowActions`, page quick-actions, nav action items).
291+
// Plus page-component field bindings and the chart surfaces outside
292+
// dashboards (report charts, list-view charts, dataset-bound page
293+
// chart components) — same ADR-0021 semantic layer, where an axis
294+
// naming a raw field instead of a measure renders an empty series.
290295
// All are plain strings in the schema, so a name resolving to nothing
291296
// parses, ships, and fails silently at runtime. An unprefixed miss is
292297
// a typo (error); a platform-prefixed name no known package registers
@@ -295,6 +300,8 @@ export default class Validate extends Command {
295300
const refFindings = [
296301
...validateObjectReferences(result.data as Record<string, unknown>),
297302
...validateActionNameRefs(result.data as Record<string, unknown>),
303+
...validatePageFieldBindings(result.data as Record<string, unknown>),
304+
...validateChartBindings(result.data as Record<string, unknown>),
298305
];
299306
const refErrors = refFindings.filter((f) => f.severity === 'error');
300307
const refWarnings = refFindings.filter((f) => f.severity === 'warning');

packages/lint/src/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,4 +191,16 @@ export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-refe
191191
export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
192192
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';
193193

194+
export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js';
195+
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';
196+
197+
export {
198+
validateChartBindings,
199+
CHART_DIMENSION_UNKNOWN,
200+
CHART_MEASURE_UNKNOWN,
201+
CHART_DATASET_UNKNOWN,
202+
CHART_AXIS_NOT_SELECTED,
203+
} from './validate-chart-bindings.js';
204+
export type { ChartBindingFinding, ChartBindingSeverity } from './validate-chart-bindings.js';
205+
194206
export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { walkPageComponents, isSourceAuthoredPage } from './page-walk.js';
5+
6+
const paths = (page: Record<string, unknown>) =>
7+
walkPageComponents(page, 'pages[0]').map((w) => w.path);
8+
9+
describe('walkPageComponents — where components actually live', () => {
10+
it('walks regions[].components[]', () => {
11+
expect(
12+
paths({
13+
regions: [
14+
{ name: 'main', components: [{ type: 'a' }, { type: 'b' }] },
15+
{ name: 'side', components: [{ type: 'c' }] },
16+
],
17+
}),
18+
).toEqual([
19+
'pages[0].regions[0].components[0]',
20+
'pages[0].regions[0].components[1]',
21+
'pages[0].regions[1].components[0]',
22+
]);
23+
});
24+
25+
it('walks slots, normalizing a single component to a list', () => {
26+
expect(
27+
paths({
28+
kind: 'slotted',
29+
slots: {
30+
highlights: { type: 'a' },
31+
tabs: [{ type: 'b' }, { type: 'c' }],
32+
},
33+
}),
34+
).toEqual([
35+
'pages[0].slots.highlights',
36+
'pages[0].slots.tabs[0]',
37+
'pages[0].slots.tabs[1]',
38+
]);
39+
});
40+
41+
it('finds nothing in a top-level `components` array — PageSchema has none', () => {
42+
// Guards the bug this module exists to prevent: a walker reading
43+
// `page.components` visits nothing on a real stack while looking correct.
44+
expect(paths({ components: [{ type: 'a' }] })).toEqual([]);
45+
});
46+
47+
it('recurses through properties.children — the layout-container shape', () => {
48+
expect(
49+
paths({
50+
regions: [
51+
{
52+
name: 'main',
53+
components: [
54+
{
55+
type: 'flex',
56+
properties: {
57+
children: [
58+
{ type: 'flex', properties: { children: [{ type: 'leaf' }] } },
59+
],
60+
},
61+
},
62+
],
63+
},
64+
],
65+
}),
66+
).toEqual([
67+
'pages[0].regions[0].components[0]',
68+
'pages[0].regions[0].components[0].properties.children[0]',
69+
'pages[0].regions[0].components[0].properties.children[0].properties.children[0]',
70+
]);
71+
});
72+
73+
it('recurses through properties.items[].children, body and footer', () => {
74+
expect(
75+
paths({
76+
regions: [
77+
{
78+
name: 'main',
79+
components: [
80+
{ type: 'page:tabs', properties: { items: [{ children: [{ type: 'x' }] }] } },
81+
{ type: 'page:card', properties: { body: [{ type: 'y' }], footer: [{ type: 'z' }] } },
82+
],
83+
},
84+
],
85+
}),
86+
).toEqual([
87+
'pages[0].regions[0].components[0]',
88+
'pages[0].regions[0].components[0].properties.items[0].children[0]',
89+
'pages[0].regions[0].components[1]',
90+
'pages[0].regions[0].components[1].properties.body[0]',
91+
'pages[0].regions[0].components[1].properties.footer[0]',
92+
]);
93+
});
94+
});
95+
96+
describe('walkPageComponents — object binding precedence', () => {
97+
const bindings = (page: Record<string, unknown>) =>
98+
walkPageComponents(page, 'pages[0]').map((w) => w.objectName);
99+
100+
it('inherits the page object, and lets dataSource then properties override', () => {
101+
expect(
102+
bindings({
103+
object: 'page_obj',
104+
regions: [
105+
{
106+
name: 'main',
107+
components: [
108+
{ type: 'a' },
109+
{ type: 'b', dataSource: { object: 'ds_obj' } },
110+
{ type: 'c', properties: { object: 'prop_obj' } },
111+
// dataSource wins over properties.
112+
{ type: 'd', dataSource: { object: 'ds_obj' }, properties: { object: 'prop_obj' } },
113+
],
114+
},
115+
],
116+
}),
117+
).toEqual(['page_obj', 'ds_obj', 'prop_obj', 'ds_obj']);
118+
});
119+
120+
it('propagates an overridden binding down to nested children', () => {
121+
const walked = walkPageComponents(
122+
{
123+
object: 'page_obj',
124+
regions: [
125+
{
126+
name: 'main',
127+
components: [
128+
{
129+
type: 'flex',
130+
dataSource: { object: 'ds_obj' },
131+
properties: { children: [{ type: 'leaf' }] },
132+
},
133+
],
134+
},
135+
],
136+
},
137+
'pages[0]',
138+
);
139+
expect(walked.map((w) => w.objectName)).toEqual(['ds_obj', 'ds_obj']);
140+
});
141+
});
142+
143+
describe('isSourceAuthoredPage', () => {
144+
it('treats html/react/jsx as source-authored and skips their regions', () => {
145+
for (const kind of ['html', 'react', 'jsx']) {
146+
expect(isSourceAuthoredPage({ kind })).toBe(true);
147+
expect(
148+
paths({ kind, regions: [{ name: 'main', components: [{ type: 'a' }] }] }),
149+
).toEqual([]);
150+
}
151+
});
152+
153+
it('treats full/slotted (and an absent kind) as authored metadata', () => {
154+
expect(isSourceAuthoredPage({ kind: 'full' })).toBe(false);
155+
expect(isSourceAuthoredPage({ kind: 'slotted' })).toBe(false);
156+
expect(isSourceAuthoredPage({})).toBe(false);
157+
});
158+
});

0 commit comments

Comments
 (0)