Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .changeset/page-field-and-chart-binding-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
'@objectstack/lint': minor
'@objectstack/cli': minor
---

Page-component field bindings and non-dashboard chart bindings (issue #3583, Phase 2)

Two more reference-integrity rules from the #3583 assessment, both wired into
`os validate`, `os lint`, and `os compile`.

**`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 does not have; the component silently skips it. Which object a
component binds follows `dataSource.object` → `properties.object` → the page's
`object`, so multi-object pages are checked per element. `record:related_list`
resolves its columns/sort/filter against the **related** object and its
add-picker against that picker's own object. Advisory (matching
`FORM_FIELD_UNKNOWN`). Relationship paths, system fields, cross-package objects,
and unregistered component types are skipped.

**`validate-chart-bindings`** — extends ADR-0021 axis checking past dashboards to
report charts (`report.chart` and `report.blocks[].chart`), list-view charts
(`views[].list`, `views[].listViews.*`, `objects[].listViews.*`), and
dataset-bound page chart components. An axis naming a raw field instead of a
declared measure is an **error** (the series comes back empty); an axis naming a
declared-but-unselected measure is a **warning**. The report shape needed its own
handling: `ReportChartSchema` narrows `xAxis`/`yAxis` to bare strings, which the
dashboard rule's array guard skips silently. The react `<ObjectChart>` block is
object-bound, not dataset-bound, and is deliberately left out — nothing defines
what its aggregate names the result column.

**Fixes:** the page walk used by `validate-action-name-refs` read a top-level
`page.components` array, which `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`) rather than a
`children` key on the component. The rule was therefore visiting nothing on a
schema-parsed stack. Traversal now lives in one shared, tested module; on the
showcase app it reaches 194 components where the previous shape found 46.
Source-authored pages (`kind: 'html' | 'react' | 'jsx'`) are skipped — their
`regions` hold a derived cache the `source` wins over.
45 changes: 45 additions & 0 deletions content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,49 @@ Interpolated targets (`${…}`, `{…}`) are skipped — they resolve at render
Action names get no third-party softening: the runtime ships no built-in action
names, so a name resolving nowhere is always an **error**.

### 5. Page components bound to fields that don't exist

A page component's `properties` is an untyped bag, so a highlights strip, a KPI
card, or a details section can name a field the bound object does not have. The
component silently skips it and the page renders one item short.

```ts
// page.object = 'crm_lead'
{ type: 'record:highlights', properties: { fields: ['status', 'total_revenue'] } }
// ↑ not a crm_lead field → warning
```

Which object a component binds follows `dataSource.object` → `properties.object`
→ the page's `object`, so a multi-object page is checked per element rather than
against one page-wide guess. A `record:related_list`'s `columns`/`sort`/`filter`
resolve against its **related** object (`objectName`), and its add-picker against
its own. Advisory, like form-layout field references — every consumer degrades
rather than failing.

Skipped, to keep false positives at zero: relationship paths (`account.name`,
resolved by the query engine), registry-injected system fields (`created_at`,
`owner_id`, …), components bound to an object another package defines, and
unregistered component types.

### 6. Chart axes naming raw fields instead of dataset measures

Post-[ADR-0021](/docs/data-modeling/analytics) a chart's result rows are keyed by
the **dataset measure name**, not the underlying column — so an axis pointing at
the raw field renders with an empty series. Dashboard widgets were already
checked; report charts, list-view charts, and dataset-bound page chart components
are checked the same way.

```ts
// dataset declares measure `est_hours` (sum of `estimate_hours`)
chart: { type: 'bar', xAxis: 'status', yAxis: 'estimate_hours' }
// ↑ the base column, not the measure → error
```

An axis naming a measure the dataset declares but this chart does not *select*
(not in `values`) is a **warning**: the query never returns it, so it plots
nothing. The react `<ObjectChart>` block is object-bound rather than
dataset-bound and is not checked here.

## The one gate, two entry points

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

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateFilterTokens } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateSecurityPosture, validateOrgAxisRedLines, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
import { validateReadonlyFlowWrites } from '@objectstack/lint';
Expand Down Expand Up @@ -325,6 +326,10 @@ export default class Compile extends Command {
// `objectOverride`, dashboard filter `optionsFrom.object`, nav
// `requiresObject` gates, and the name-bound action surfaces
// (`bulkActions`/`rowActions`, page quick-actions, nav action items).
// Plus page-component field bindings and the chart surfaces outside
// dashboards (report charts, list-view charts, dataset-bound page
// chart components) — same ADR-0021 semantic layer, where an axis
// naming a raw field instead of a measure renders an empty series.
// All plain strings in the schema, so a name resolving to nothing
// ships and fails silently. Errors fail the build; the
// platform-prefixed-but-unregistered case is advisory (a third-party
Expand All @@ -333,6 +338,8 @@ export default class Compile extends Command {
const refFindings = [
...validateObjectReferences(result.data as Record<string, unknown>),
...validateActionNameRefs(result.data as Record<string, unknown>),
...validatePageFieldBindings(result.data as Record<string, unknown>),
...validateChartBindings(result.data as Record<string, unknown>),
];
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
Expand Down
31 changes: 31 additions & 0 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { lintDataModel } from '../lint/data-model-rules.js';
import { validateWidgetBindings } from '@objectstack/lint';
import { validateRecordTitle, validateSemanticRoles, validateCapabilityReferences, validateSecurityPosture, validateOrgAxisRedLines, validateApprovalApprovers, validateSeedReplaySafety, validateSeedStateMachine } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { collectAndLintDocs } from '../utils/collect-docs.js';
import { scoreMetadata } from '../lint/score.js';
import { runMetadataEval } from '../lint/metadata-eval.js';
Expand Down Expand Up @@ -520,6 +521,36 @@ export function lintConfig(config: any): LintIssue[] {
});
}

// ── Page component field bindings (issue #3583) ──
// `PageComponent.properties` is an untyped bag, so a highlights strip, KPI
// card, or details section can name a field the object does not have; the
// component silently skips it. Advisory, matching `FORM_FIELD_UNKNOWN` —
// every page consumer degrades rather than failing.
for (const t of validatePageFieldBindings(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

// ── Chart bindings outside dashboards (issue #3583) ──
// `validate-widget-bindings` covers dashboard widgets only. Report charts,
// list-view charts, and dataset-bound page chart components bind the same
// semantic layer, where an axis naming a raw field instead of a dataset
// measure renders an empty series (ADR-0021).
for (const t of validateChartBindings(config)) {
issues.push({
severity: t.severity,
rule: t.rule,
message: `${t.where}: ${t.message}`,
path: t.path,
fix: t.hint,
});
}

return issues;
}

Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { validateWidgetBindings } from '@objectstack/lint';
import { validateDashboardActionRefs } from '@objectstack/lint';
import { validateFilterTokens } from '@objectstack/lint';
import { validateObjectReferences, validateActionNameRefs } from '@objectstack/lint';
import { validatePageFieldBindings, validateChartBindings } from '@objectstack/lint';
import { validateResponsiveStyles } from '@objectstack/lint';
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
import { validateCapabilityReferences } from '@objectstack/lint';
Expand Down Expand Up @@ -287,6 +288,10 @@ export default class Validate extends Command {
// `reference`/`objectOverride`, dashboard filter `optionsFrom.object`,
// nav `requiresObject` gates, and the name-bound action surfaces
// (`bulkActions`/`rowActions`, page quick-actions, nav action items).
// Plus page-component field bindings and the chart surfaces outside
// dashboards (report charts, list-view charts, dataset-bound page
// chart components) — same ADR-0021 semantic layer, where an axis
// naming a raw field instead of a measure renders an empty series.
// All are plain strings in the schema, so a name resolving to nothing
// parses, ships, and fails silently at runtime. An unprefixed miss is
// a typo (error); a platform-prefixed name no known package registers
Expand All @@ -295,6 +300,8 @@ export default class Validate extends Command {
const refFindings = [
...validateObjectReferences(result.data as Record<string, unknown>),
...validateActionNameRefs(result.data as Record<string, unknown>),
...validatePageFieldBindings(result.data as Record<string, unknown>),
...validateChartBindings(result.data as Record<string, unknown>),
];
const refErrors = refFindings.filter((f) => f.severity === 'error');
const refWarnings = refFindings.filter((f) => f.severity === 'warning');
Expand Down
12 changes: 12 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,16 @@ export type { ObjectRefFinding, ObjectRefSeverity } from './validate-object-refe
export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

export {
validateChartBindings,
CHART_DIMENSION_UNKNOWN,
CHART_MEASURE_UNKNOWN,
CHART_DATASET_UNKNOWN,
CHART_AXIS_NOT_SELECTED,
} from './validate-chart-bindings.js';
export type { ChartBindingFinding, ChartBindingSeverity } from './validate-chart-bindings.js';

export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';
158 changes: 158 additions & 0 deletions packages/lint/src/page-walk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { walkPageComponents, isSourceAuthoredPage } from './page-walk.js';

const paths = (page: Record<string, unknown>) =>
walkPageComponents(page, 'pages[0]').map((w) => w.path);

describe('walkPageComponents — where components actually live', () => {
it('walks regions[].components[]', () => {
expect(
paths({
regions: [
{ name: 'main', components: [{ type: 'a' }, { type: 'b' }] },
{ name: 'side', components: [{ type: 'c' }] },
],
}),
).toEqual([
'pages[0].regions[0].components[0]',
'pages[0].regions[0].components[1]',
'pages[0].regions[1].components[0]',
]);
});

it('walks slots, normalizing a single component to a list', () => {
expect(
paths({
kind: 'slotted',
slots: {
highlights: { type: 'a' },
tabs: [{ type: 'b' }, { type: 'c' }],
},
}),
).toEqual([
'pages[0].slots.highlights',
'pages[0].slots.tabs[0]',
'pages[0].slots.tabs[1]',
]);
});

it('finds nothing in a top-level `components` array — PageSchema has none', () => {
// Guards the bug this module exists to prevent: a walker reading
// `page.components` visits nothing on a real stack while looking correct.
expect(paths({ components: [{ type: 'a' }] })).toEqual([]);
});

it('recurses through properties.children — the layout-container shape', () => {
expect(
paths({
regions: [
{
name: 'main',
components: [
{
type: 'flex',
properties: {
children: [
{ type: 'flex', properties: { children: [{ type: 'leaf' }] } },
],
},
},
],
},
],
}),
).toEqual([
'pages[0].regions[0].components[0]',
'pages[0].regions[0].components[0].properties.children[0]',
'pages[0].regions[0].components[0].properties.children[0].properties.children[0]',
]);
});

it('recurses through properties.items[].children, body and footer', () => {
expect(
paths({
regions: [
{
name: 'main',
components: [
{ type: 'page:tabs', properties: { items: [{ children: [{ type: 'x' }] }] } },
{ type: 'page:card', properties: { body: [{ type: 'y' }], footer: [{ type: 'z' }] } },
],
},
],
}),
).toEqual([
'pages[0].regions[0].components[0]',
'pages[0].regions[0].components[0].properties.items[0].children[0]',
'pages[0].regions[0].components[1]',
'pages[0].regions[0].components[1].properties.body[0]',
'pages[0].regions[0].components[1].properties.footer[0]',
]);
});
});

describe('walkPageComponents — object binding precedence', () => {
const bindings = (page: Record<string, unknown>) =>
walkPageComponents(page, 'pages[0]').map((w) => w.objectName);

it('inherits the page object, and lets dataSource then properties override', () => {
expect(
bindings({
object: 'page_obj',
regions: [
{
name: 'main',
components: [
{ type: 'a' },
{ type: 'b', dataSource: { object: 'ds_obj' } },
{ type: 'c', properties: { object: 'prop_obj' } },
// dataSource wins over properties.
{ type: 'd', dataSource: { object: 'ds_obj' }, properties: { object: 'prop_obj' } },
],
},
],
}),
).toEqual(['page_obj', 'ds_obj', 'prop_obj', 'ds_obj']);
});

it('propagates an overridden binding down to nested children', () => {
const walked = walkPageComponents(
{
object: 'page_obj',
regions: [
{
name: 'main',
components: [
{
type: 'flex',
dataSource: { object: 'ds_obj' },
properties: { children: [{ type: 'leaf' }] },
},
],
},
],
},
'pages[0]',
);
expect(walked.map((w) => w.objectName)).toEqual(['ds_obj', 'ds_obj']);
});
});

describe('isSourceAuthoredPage', () => {
it('treats html/react/jsx as source-authored and skips their regions', () => {
for (const kind of ['html', 'react', 'jsx']) {
expect(isSourceAuthoredPage({ kind })).toBe(true);
expect(
paths({ kind, regions: [{ name: 'main', components: [{ type: 'a' }] }] }),
).toEqual([]);
}
});

it('treats full/slotted (and an absent kind) as authored metadata', () => {
expect(isSourceAuthoredPage({ kind: 'full' })).toBe(false);
expect(isSourceAuthoredPage({ kind: 'slotted' })).toBe(false);
expect(isSourceAuthoredPage({})).toBe(false);
});
});
Loading
Loading