Skip to content

Commit e38da2b

Browse files
claude[bot]claude
andauthored
feat(lint): refuse an interface page's whitelisted visualization that binds to nothing (#15089)
* feat(lint): resolve an interface page's whitelisted visualizations WIP — rule, wiring, tests and changeset in place; verification pending. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i * feat(lint): resolve an interface page's whitelisted visualizations Register the new SYSTEM_FIELDS consumer in the census ledger and load the showcase corpus through an import.meta.url path rather than a static import, so the example modules stay out of this package's tsc rootDir. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i * feat(lint): resolve an interface page's whitelisted visualizations Keep repo path literals out of the test's prose: the cross-package input gate reads path-shaped strings out of source text, comments included. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ab47816 commit e38da2b

9 files changed

Lines changed: 1151 additions & 0 deletions
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
feat(lint): resolve an interface page's whitelisted visualizations at validate/build (#14073)
6+
7+
Accept-set narrowing, `minor` under the family precedent (#14107, #14105, #14148).
8+
9+
An interface `list` page whitelists renderers with
10+
`interfaceConfig.appearance.allowedVisualizations`, and `InterfacePageConfigSchema`
11+
is a closed shape with **no per-visualization binding key at all** — no
12+
`calendar:`, no `kanban:`, no `map:`. So #13817's parse-time refinement, which
13+
demands a `calendar:` block on a list VIEW that whitelists `calendar`, was
14+
correctly not extended to this door: a requirement the page surface cannot
15+
satisfy would be unauthorable.
16+
17+
That left the page door with no check of any kind. Measured on objectui
18+
`f0f774b0` (after objectui#7029 removed the invented `due_date` default), the
19+
renderer derives each binding from the source object's fields
20+
(`InterfaceListPage.tsx`, `view.<viz> ?? deriveFromObject(objectDef)`), and when
21+
nothing derives there are exactly two outcomes, neither of which reaches the
22+
author:
23+
24+
- the entry LEADS the whitelist — it becomes the page's forced view type, is
25+
force-pushed into the switcher's resolvable set, and every visitor lands on
26+
the renderer's "Calendar configuration required" refusal screen;
27+
- the entry is anywhere else — it is filtered out of the switcher **silently**,
28+
while the switcher chrome still appears (it is shown on whitelist length).
29+
30+
The new rule `page/visualization-without-binding`
31+
(`validatePageVisualizationBindings`, a member of the reference-integrity suite,
32+
so it runs on `validate` / `lint` / `compile`) asks the renderer's own question
33+
at authoring time. For every `list` page, each whitelisted visualization must be
34+
either derivable from the source object's declared fields — using the SAME
35+
predicates the renderer applies, the field TYPE first and then the NAME regex
36+
fallback (kanban: select-like type or status-like name; calendar and timeline: a
37+
date-typed non-hidden non-system field, else a date-like name; gallery:
38+
image-typed, no name leg; gantt: two distinct date fields; map: location-typed or
39+
a geo-like name) — or bound by the block of the list view the page references
40+
through `sourceView` (a `calendar:` block also binds a `timeline`, which is what
41+
`resolveTimelineDateBinding` accepts). `grid` always passes.
42+
43+
Severity tracks what the visitor sees: **`error`** when the unbound entry is
44+
`allowedVisualizations[0]`, **`warning`** otherwise. Every message names
45+
`sourceView` as the remedy, because on this door it is the one schema-legal
46+
channel for a per-visualization binding — exactly how the shipped showcase map
47+
page binds its `locationField`.
48+
49+
Deliberately NOT stricter than the renderer: mirroring both predicates rather
50+
than the type half alone means a page whose only date is a text field called
51+
`due_date` still passes, because it still renders. The mirrored table is exported
52+
(`OBJECTUI_DERIVATION_PREDICATES`) and pinned verbatim by a fixture test, and the
53+
seven shipped `showcase_task` interface pages are pinned as a live regression
54+
corpus — the two halves catch drift on either side of a mirror no build edge
55+
connects. `chart` and `tree` get no verdict: the renderer derives no binding for
56+
them on this seam, so the rule says nothing rather than guessing.
57+
58+
**Migration.** A page reported by the new rule whitelists a visualization that
59+
renders nothing: point the page at a list view that declares the block
60+
(`interfaceConfig.sourceView`), give the source object a field the derivation can
61+
find, or drop the entry from `appearance.allowedVisualizations`. Four skips keep
62+
it quiet where it cannot know: a page that is not `type: 'list'`, an object this
63+
stack does not define, an object with no readable field map (ADR-0015
64+
`external`), and a `sourceView` naming a view this stack does not declare (the
65+
runtime hydrates stored view bodies over the network).
66+
67+
No `packages/spec` change — the page surface stays closed, which is what ADR-0047
68+
§7 open question 3 asks for.

packages/lint/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,27 @@ export {
468468
} from './validate-page-field-bindings.js';
469469
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';
470470

471+
// [#14073] The PAGE door's visualization whitelist, one layer out from the key
472+
// resolution above. `appearance.allowedVisualizations` names a RENDERER, not a
473+
// field, and `InterfacePageConfigSchema` has no per-visualization binding key
474+
// for the author to fill in — so the renderer derives the binding from the
475+
// source object's fields, or the page borrows one through `sourceView`. This
476+
// rule mirrors that derivation at authoring time (the table it mirrors is
477+
// exported so a consumer can read the predicates rather than retype them) and
478+
// reports the whitelisted type that binds to nothing: `error` when it leads
479+
// (the runtime reaches its renderer's refusal screen), `warning` otherwise
480+
// (the type is dropped from the switcher silently).
481+
export {
482+
validatePageVisualizationBindings,
483+
PAGE_VISUALIZATION_WITHOUT_BINDING,
484+
OBJECTUI_DERIVATION_PREDICATES,
485+
} from './validate-page-visualization-bindings.js';
486+
export type {
487+
PageVisualizationFinding,
488+
PageVisualizationSeverity,
489+
VisualizationPredicate,
490+
} from './validate-page-visualization-bindings.js';
491+
471492
// [#13855] The shared field-group reference half both layout surfaces resolve
472493
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
473494
// authoring path) can ask the same question of the same index rather than

packages/lint/src/reference-integrity-suite.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ describe('reference-integrity suite — membership', () => {
2424
'validateListViewFieldRefs',
2525
'validateActionNameRefs',
2626
'validatePageFieldBindings',
27+
// [#14073] The same page, one question out: the BINDING behind each
28+
// visualization `appearance.allowedVisualizations` whitelists, resolved
29+
// against the source object's fields or the view named by `sourceView`.
30+
// Placed beside the member that resolves the same page's field names.
31+
'validatePageVisualizationBindings',
2732
'validateChartBindings',
2833
// [#14105] The dataset's OWN references, one level below the two binding
2934
// rules above it — the conscious edit this written-out list exists to

packages/lint/src/reference-integrity-suite.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@
6060
* miss empties or mis-selects the view's data gates, one whose miss drops a
6161
* decoration advises.
6262
*
63+
* `validatePageVisualizationBindings` is the charter's question with the
64+
* resolution target one level up (#14073): an interface page's
65+
* `appearance.allowedVisualizations` entry names a RENDERER, and
66+
* `InterfacePageConfigSchema` carries no per-visualization binding key for the
67+
* author to fill in — so the binding is either derived from the source
68+
* object's declared fields (objectui's own `InterfaceListPage` derivation,
69+
* mirrored) or supplied by the list view the page names through `sourceView`.
70+
* An entry that resolves to neither renders NOTHING: leading the whitelist it
71+
* becomes the forced view type and every visitor gets the renderer's
72+
* "configuration required" refusal screen (`error`); anywhere else it is
73+
* dropped from the switcher without a word (`warning`). Both severities, like
74+
* the two members above, and the same three skips.
75+
*
6376
* Rules that check SHAPE rather than reference (view containers, responsive
6477
* styles, seed replay safety, seed state machines, seed/security posture) stay
6578
* out — they answer a different question and have their own call sites.
@@ -92,6 +105,7 @@ import { validateSortableFields } from './validate-sortable-fields.js';
92105
import { validateListViewFieldRefs } from './validate-list-view-field-refs.js';
93106
import { validateActionNameRefs } from './validate-action-name-refs.js';
94107
import { validatePageFieldBindings } from './validate-page-field-bindings.js';
108+
import { validatePageVisualizationBindings } from './validate-page-visualization-bindings.js';
95109
import { validateChartBindings } from './validate-chart-bindings.js';
96110
import { validateDatasetReferences } from './validate-dataset-references.js';
97111
import { validateNavAccess } from './validate-nav-access.js';
@@ -220,6 +234,24 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [
220234
{ name: 'validateListViewFieldRefs', runtimeTypes: ['flow', 'view'], run: validateListViewFieldRefs },
221235
{ name: 'validateActionNameRefs', run: validateActionNameRefs },
222236
{ name: 'validatePageFieldBindings', run: validatePageFieldBindings },
237+
// [#14073] The same page, one question out. `validatePageFieldBindings`
238+
// above resolves the field NAMES an interface page writes; this member
239+
// resolves the BINDING behind each visualization the page whitelists —
240+
// `appearance.allowedVisualizations` names a renderer, and the page surface
241+
// has no key to bind it with, so the binding is either derived from the
242+
// source object's fields (objectui's `InterfaceListPage` derivation,
243+
// mirrored) or supplied by the view the page names through `sourceView`.
244+
// Squarely the charter's question with the resolution target one level up:
245+
// the whitelist entry is a name written in metadata whose binding must
246+
// resolve against what the stack declares (the object's fields, that view's
247+
// blocks) or it renders nothing.
248+
//
249+
// NO `runtimeTypes`, i.e. the frozen `flow` default, and deliberately: it
250+
// resolves against `stack.pages` and `stack.views`, neither of which the
251+
// per-write snapshot carries, so crossing it onto `view` writes would report
252+
// every page-door binding as unresolvable. Its page-typed sibling above
253+
// takes the default for the same reason.
254+
{ name: 'validatePageVisualizationBindings', run: validatePageVisualizationBindings },
223255
{ name: 'validateChartBindings', run: validateChartBindings },
224256
// [#14105] One level BELOW the two members above it. `validateChartBindings`
225257
// and `validateWidgetBindings` resolve a presentation's binding against the

packages/lint/src/system-fields-consumers.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,26 @@ const LEDGER: Record<string, LedgerRow> = {
407407
asksProvenance: true,
408408
why: 'Blanket .has read site. Provenance wired by #8340.',
409409
},
410+
'validate-page-visualization-bindings.ts': {
411+
kind: 'rule',
412+
reach: ['direct'],
413+
asksProvenance: false,
414+
why:
415+
'Landed 2026-09-03 (#14073). The one consumer that reads the union in the OPPOSITE direction, which is '
416+
+ 'why the #8116 obligation does not arise here. Every other rule in this ledger consults the union to '
417+
+ 'decide NOT TO FLAG an author-written reference — that silence is what owes a provenance question. '
418+
+ 'This rule resolves no reference at all: it mirrors objectui\'s binding DERIVATION, and reads the union '
419+
+ 'once, to drop framework-managed columns from the candidate set before any predicate sees them '
420+
+ '(`InterfaceListPage.tsx`\'s `isSystemManagedField` pre-filter). The union therefore makes this rule '
421+
+ 'MORE likely to report, never silent about a name, and no field name it skips ever leaves the rule: '
422+
+ 'the finding names a VISUALIZATION and the object, never a column. '
423+
+ 'The unprovisioned-anchor case cannot arise on the path that remains, either: a derivation candidate is '
424+
+ 'by construction an AUTHOR-DECLARED field, and `unprovisionedInjectedColumnsFor` excludes an '
425+
+ 'author-declared column of an anchor\'s name by design (#7859) — so no binding this rule blesses can '
426+
+ 'be an ADR-0015 anchor with no storage behind it. An external object that declares a mapped field map '
427+
+ 'is judged like any other (the shipped `showcase_ext_customer` shape the sortable-fields row measured); '
428+
+ 'one that declares none is skip 2 and unjudged.',
429+
},
410430
'validate-react-page-props.ts': {
411431
kind: 'rule',
412432
reach: ['direct'],

0 commit comments

Comments
 (0)