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
68 changes: 68 additions & 0 deletions .changeset/page-visualization-binding-integrity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
"@objectstack/lint": minor
---

feat(lint): resolve an interface page's whitelisted visualizations at validate/build (#14073)

Accept-set narrowing, `minor` under the family precedent (#14107, #14105, #14148).

An interface `list` page whitelists renderers with
`interfaceConfig.appearance.allowedVisualizations`, and `InterfacePageConfigSchema`
is a closed shape with **no per-visualization binding key at all** — no
`calendar:`, no `kanban:`, no `map:`. So #13817's parse-time refinement, which
demands a `calendar:` block on a list VIEW that whitelists `calendar`, was
correctly not extended to this door: a requirement the page surface cannot
satisfy would be unauthorable.

That left the page door with no check of any kind. Measured on objectui
`f0f774b0` (after objectui#7029 removed the invented `due_date` default), the
renderer derives each binding from the source object's fields
(`InterfaceListPage.tsx`, `view.<viz> ?? deriveFromObject(objectDef)`), and when
nothing derives there are exactly two outcomes, neither of which reaches the
author:

- the entry LEADS the whitelist — it becomes the page's forced view type, is
force-pushed into the switcher's resolvable set, and every visitor lands on
the renderer's "Calendar configuration required" refusal screen;
- the entry is anywhere else — it is filtered out of the switcher **silently**,
while the switcher chrome still appears (it is shown on whitelist length).

The new rule `page/visualization-without-binding`
(`validatePageVisualizationBindings`, a member of the reference-integrity suite,
so it runs on `validate` / `lint` / `compile`) asks the renderer's own question
at authoring time. For every `list` page, each whitelisted visualization must be
either derivable from the source object's declared fields — using the SAME
predicates the renderer applies, the field TYPE first and then the NAME regex
fallback (kanban: select-like type or status-like name; calendar and timeline: a
date-typed non-hidden non-system field, else a date-like name; gallery:
image-typed, no name leg; gantt: two distinct date fields; map: location-typed or
a geo-like name) — or bound by the block of the list view the page references
through `sourceView` (a `calendar:` block also binds a `timeline`, which is what
`resolveTimelineDateBinding` accepts). `grid` always passes.

Severity tracks what the visitor sees: **`error`** when the unbound entry is
`allowedVisualizations[0]`, **`warning`** otherwise. Every message names
`sourceView` as the remedy, because on this door it is the one schema-legal
channel for a per-visualization binding — exactly how the shipped showcase map
page binds its `locationField`.

Deliberately NOT stricter than the renderer: mirroring both predicates rather
than the type half alone means a page whose only date is a text field called
`due_date` still passes, because it still renders. The mirrored table is exported
(`OBJECTUI_DERIVATION_PREDICATES`) and pinned verbatim by a fixture test, and the
seven shipped `showcase_task` interface pages are pinned as a live regression
corpus — the two halves catch drift on either side of a mirror no build edge
connects. `chart` and `tree` get no verdict: the renderer derives no binding for
them on this seam, so the rule says nothing rather than guessing.

**Migration.** A page reported by the new rule whitelists a visualization that
renders nothing: point the page at a list view that declares the block
(`interfaceConfig.sourceView`), give the source object a field the derivation can
find, or drop the entry from `appearance.allowedVisualizations`. Four skips keep
it quiet where it cannot know: a page that is not `type: 'list'`, an object this
stack does not define, an object with no readable field map (ADR-0015
`external`), and a `sourceView` naming a view this stack does not declare (the
runtime hydrates stored view bodies over the network).

No `packages/spec` change — the page surface stays closed, which is what ADR-0047
§7 open question 3 asks for.
21 changes: 21 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,27 @@ export {
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#14073] The PAGE door's visualization whitelist, one layer out from the key
// resolution above. `appearance.allowedVisualizations` names a RENDERER, not a
// field, and `InterfacePageConfigSchema` has no per-visualization binding key
// for the author to fill in — so the renderer derives the binding from the
// source object's fields, or the page borrows one through `sourceView`. This
// rule mirrors that derivation at authoring time (the table it mirrors is
// exported so a consumer can read the predicates rather than retype them) and
// reports the whitelisted type that binds to nothing: `error` when it leads
// (the runtime reaches its renderer's refusal screen), `warning` otherwise
// (the type is dropped from the switcher silently).
export {
validatePageVisualizationBindings,
PAGE_VISUALIZATION_WITHOUT_BINDING,
OBJECTUI_DERIVATION_PREDICATES,
} from './validate-page-visualization-bindings.js';
export type {
PageVisualizationFinding,
PageVisualizationSeverity,
VisualizationPredicate,
} from './validate-page-visualization-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
Expand Down
5 changes: 5 additions & 0 deletions packages/lint/src/reference-integrity-suite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ describe('reference-integrity suite — membership', () => {
'validateListViewFieldRefs',
'validateActionNameRefs',
'validatePageFieldBindings',
// [#14073] The same page, one question out: the BINDING behind each
// visualization `appearance.allowedVisualizations` whitelists, resolved
// against the source object's fields or the view named by `sourceView`.
// Placed beside the member that resolves the same page's field names.
'validatePageVisualizationBindings',
'validateChartBindings',
// [#14105] The dataset's OWN references, one level below the two binding
// rules above it — the conscious edit this written-out list exists to
Expand Down
32 changes: 32 additions & 0 deletions packages/lint/src/reference-integrity-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@
* miss empties or mis-selects the view's data gates, one whose miss drops a
* decoration advises.
*
* `validatePageVisualizationBindings` is the charter's question with the
* resolution target one level up (#14073): an interface page's
* `appearance.allowedVisualizations` entry names a RENDERER, and
* `InterfacePageConfigSchema` carries no per-visualization binding key for the
* author to fill in — so the binding is either derived from the source
* object's declared fields (objectui's own `InterfaceListPage` derivation,
* mirrored) or supplied by the list view the page names through `sourceView`.
* An entry that resolves to neither renders NOTHING: leading the whitelist it
* becomes the forced view type and every visitor gets the renderer's
* "configuration required" refusal screen (`error`); anywhere else it is
* dropped from the switcher without a word (`warning`). Both severities, like
* the two members above, and the same three skips.
*
* Rules that check SHAPE rather than reference (view containers, responsive
* styles, seed replay safety, seed state machines, seed/security posture) stay
* out — they answer a different question and have their own call sites.
Expand Down Expand Up @@ -92,6 +105,7 @@ import { validateSortableFields } from './validate-sortable-fields.js';
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
import { validateActionNameRefs } from './validate-action-name-refs.js';
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
import { validateChartBindings } from './validate-chart-bindings.js';
import { validateDatasetReferences } from './validate-dataset-references.js';
import { validateNavAccess } from './validate-nav-access.js';
Expand Down Expand Up @@ -220,6 +234,24 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
// [#14073] The same page, one question out. `validatePageFieldBindings`
// above resolves the field NAMES an interface page writes; this member
// resolves the BINDING behind each visualization the page whitelists —
// `appearance.allowedVisualizations` names a renderer, and the page surface
// has no key to bind it with, so the binding is either derived from the
// source object's fields (objectui's `InterfaceListPage` derivation,
// mirrored) or supplied by the view the page names through `sourceView`.
// Squarely the charter's question with the resolution target one level up:
// the whitelist entry is a name written in metadata whose binding must
// resolve against what the stack declares (the object's fields, that view's
// blocks) or it renders nothing.
//
// NO `runtimeTypes`, i.e. the frozen `flow` default, and deliberately: it
// resolves against `stack.pages` and `stack.views`, neither of which the
// per-write snapshot carries, so crossing it onto `view` writes would report
// every page-door binding as unresolvable. Its page-typed sibling above
// takes the default for the same reason.
{ name: 'validatePageVisualizationBindings', run: validatePageVisualizationBindings },
{ name: 'validateChartBindings', run: validateChartBindings },
// [#14105] One level BELOW the two members above it. `validateChartBindings`
// and `validateWidgetBindings` resolve a presentation's binding against the
Expand Down
20 changes: 20 additions & 0 deletions packages/lint/src/system-fields-consumers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,26 @@ const LEDGER: Record<string, LedgerRow> = {
asksProvenance: true,
why: 'Blanket .has read site. Provenance wired by #8340.',
},
'validate-page-visualization-bindings.ts': {
kind: 'rule',
reach: ['direct'],
asksProvenance: false,
why:
'Landed 2026-09-03 (#14073). The one consumer that reads the union in the OPPOSITE direction, which is '
+ 'why the #8116 obligation does not arise here. Every other rule in this ledger consults the union to '
+ 'decide NOT TO FLAG an author-written reference — that silence is what owes a provenance question. '
+ 'This rule resolves no reference at all: it mirrors objectui\'s binding DERIVATION, and reads the union '
+ 'once, to drop framework-managed columns from the candidate set before any predicate sees them '
+ '(`InterfaceListPage.tsx`\'s `isSystemManagedField` pre-filter). The union therefore makes this rule '
+ 'MORE likely to report, never silent about a name, and no field name it skips ever leaves the rule: '
+ 'the finding names a VISUALIZATION and the object, never a column. '
+ 'The unprovisioned-anchor case cannot arise on the path that remains, either: a derivation candidate is '
+ 'by construction an AUTHOR-DECLARED field, and `unprovisionedInjectedColumnsFor` excludes an '
+ 'author-declared column of an anchor\'s name by design (#7859) — so no binding this rule blesses can '
+ 'be an ADR-0015 anchor with no storage behind it. An external object that declares a mapped field map '
+ 'is judged like any other (the shipped `showcase_ext_customer` shape the sortable-fields row measured); '
+ 'one that declares none is skip 2 and unjudged.',
},
'validate-react-page-props.ts': {
kind: 'rule',
reach: ['direct'],
Expand Down
Loading
Loading