Skip to content

feat(lint): refuse an interface page's whitelisted visualization that binds to nothing - #15089

Merged
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-14073-interface-page-visualization-lint
Sep 3, 2026
Merged

feat(lint): refuse an interface page's whitelisted visualization that binds to nothing#15089
zhuangjianguo merged 3 commits into
mainfrom
claude/issue-14073-interface-page-visualization-lint

Conversation

@claude

@claude claude Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #14073

Direction B, ruled on the measurement (#14073 comment 5531217232) and routed by the PM (comment 5531261969). No packages/spec change: InterfacePageConfigSchema stays a closed shape with no per-visualization binding key, and nothing in objectui is touched.

Clause-2: no — a lint rule. It adds no authorable key, removes none, and changes no spec accept/reject verdict; the only public-surface movement is @objectstack/lint's new exports.

The defect

An interface list page whitelists renderers with interfaceConfig.appearance.allowedVisualizations, and the page config declares no calendar: / kanban: / map: key at all. #13817's parse-time refinement, which demands a calendar: block on a list VIEW that whitelists calendar, was therefore correctly not extended to this door: a requirement the page surface cannot satisfy would be unauthorable. That left the page door checked by nothing.

Measured on objectui f0f774b0 (after objectui#7029 removed the invented due_date default), packages/app-shell/src/views/InterfaceListPage.tsx:409-419 derives each binding as view.SOMEVIZ ?? deriveFromObject(objectDef) — a real field name or undefined, never a literal. When nothing derives there are exactly two outcomes, and neither reaches the author:

  • the entry leads the whitelist — viewType = allowed[0] (:460) is force-pushed into the switcher's resolvable set (packages/plugin-list/src/ListView.tsx:2137-2140), the renderer mounts with no field, and getCalendarConfig (packages/plugin-calendar/src/ObjectCalendar.tsx:142-165) returns null: every visitor lands on the "Calendar configuration required" refusal screen;
  • the entry is anywhere else — it is filtered out of the switcher silently, while the switcher chrome still appears, because showViewSwitcher reads the whitelist's LENGTH (:482).

The rule

page/visualization-without-binding (validatePageVisualizationBindings, packages/lint/src/validate-page-visualization-bindings.ts), registered as a member of the reference-integrity suite — the path its page-typed sibling validatePageFieldBindings takes — so it runs on os validate / os lint / os compile and on os build. It carries no runtimeTypes, i.e. the frozen flow default: it resolves against stack.pages and stack.views, neither of which the per-write publish snapshot carries, so crossing it onto view writes would report every page-door binding as unresolvable.

For every type: 'list' page, each whitelisted visualization must be either derivable from the source object's declared fields or bound by the block of the list view the page names through sourceView. grid always passes.

The mirrored predicate table

Transcribed from objectui f0f774b0, packages/app-shell/src/views/InterfaceListPage.tsx — field TYPE first (in the object's declaration order), then the NAME regex fallback, exactly as the renderer applies them. Both predicates, not the type half alone: per the ruling quoted in the dispatch, #13748 targets silent WRONG screens, not working derivations, so a lint stricter than the runtime would refuse pages that render correctly today.

viz binding field TYPE (:149-151, :203) NAME fallback view block read first (:409-419) source
kanban groupByField select, multiselect, radio, enum, boolean /status|stage|state|priority|category|kind/i kanban defaultKanbanFromObject :153
calendar startDateField date, datetime, time /date|due|start|end|deadline|schedule/i calendar defaultDateField :163 + defaultCalendarFromObject :170
timeline startDateField date, datetime, time same as calendar timeline, or calendar borrows defaultCalendarFromObject at :414; resolveTimelineDateBinding (packages/plugin-list/src/ListView.tsx:411-433) accepts a calendar block as the axis
gallery coverField image, file, attachment, avatar, photo none — the renderer has no name leg here gallery defaultGalleryFromObject :175
gantt startDateField + endDateField two DISTINCT date-typed fields; start prefers /start|begin|kickoff/i, end prefers /end|due|finish|deadline|close/i none gantt defaultGanttFromObject :185
map locationField location, geo, geolocation, geopoint, point /location|address|geo|coords?|place|venue/i map (and the legacy options.map twin) defaultMapFromObject :253

The renderer's pre-filter is mirrored too: hidden and framework-managed fields never reach a predicate (firstFieldMatching :137-147, via isSystemManagedField). This side takes that skip through the lint package's shared SYSTEM_FIELDS answer rather than hand-copying objectui's name list — the drift system-fields.ts exists to prevent. The two sets differ on a handful of legacy spellings objectui also lists (_id, createdAt, modified, locked, space, company_id); every one of those differences makes this side skip FEWER fields, i.e. derive more and report less, which is the safe direction under the ruling above.

chart and tree get no verdict: the renderer derives no binding for them on this seam, so the rule says nothing rather than guessing. That is honest under-coverage, written down in the rule header and pinned by a test, not an assertion that they are fine.

Severity

Tracks what the visitor actually sees, per the design answer in 5531261969:

  • error when the unbound entry is allowedVisualizations[0] — the runtime reaches the refusal screen and the page IS that screen;
  • warning otherwise — the type is dropped from the switcher with no signal at build time or run time.

Every message names the page, the visualization and what the derivation looked for, and every hint names sourceView as the remedy — on this door it is the one schema-legal channel for a per-visualization binding, which is exactly how the shipped showcase map page binds its locationField.

Four skips, so the rule stays quiet where it cannot know

  1. a page that is not type: 'list' (only a list page mounts InterfaceListPage);
  2. an object this stack does not define, or one with no readable field map (ADR-0015 external) — the family's standard skips, ADR-0072 D1;
  3. grid, plus any whitelisted value the renderer derives no binding for;
  4. a sourceView naming a view this stack does not declare — the runtime hydrates a stored view body over the network (InterfaceListPage.tsx:370-384), so a build-time miss there is unknowable, never unbound.

Fixture outcomes

packages/lint/src/validate-page-visualization-bindings.test.ts, 24 cases:

  • Predicate pinOBJECTUI_DERIVATION_PREDICATES is asserted verbatim against a transcription in the test, plus a boundary assertion that the table covers exactly those six visualizations and claims no others. A change on this side of the mirror cannot be silent.
  • Showcase regression pin — all seven shipped showcase_task interface pages (examples/app-showcase/src/ui/pages/task-visualizations.pages.ts: board / calendar / gallery / gantt / timeline / map / all-views) over the shipped object and view aggregate: zero findings. A guard test first asserts each is type: 'list' with a non-empty whitelist and that every judged visualization is exercised, so the zero cannot go vacuously green.
  • The same corpus, inverted — strip showcase_task's date fields and the same run reports exactly six findings: error on the leading entry of the calendar, gantt and timeline pages, warning on the three non-leading entries of the all-views switcher. Without this, "zero findings" would be compatible with a rule that never fires.
  • Dateless object, ['calendar'] leading — one error, path pages[0].interfaceConfig.appearance.allowedVisualizations[0], message naming the page, the visualization, startDateField and "a field typed date / datetime / time", hint naming sourceView.
  • Dateless object, ['grid', 'calendar'] — one warning at index 1; grid itself says nothing.
  • sourceView bound — a page pointed at a view carrying calendar: { startDateField } passes; the same page whitelisting kanban as well still reports the kanban entry; a calendar: block also satisfies timeline; the ADR-0017 qualified objectname.key spelling resolves; and a sourceView naming nothing in the stack skips the page.
  • Predicate by predicate — kanban by type and by name; calendar by type and by name; gallery by type ONLY (a field literally called cover typed text is still reported, because accepting it would be looser than the renderer); gantt needing two distinct dates; map by type and by name; and hidden / system / union-named fields skipped before any predicate.
  • Suite wiring — the member is present, wired to the exported function, and carries no runtimeTypes.

Cross-package inputs for that corpus are declared in turbo.json (@objectstack/lint#test) and in the registry the check:cross-package-test-inputs gate reads, so both of CI's scoping layers move when the showcase moves. The three showcase modules are loaded through a path built from import.meta.url rather than a static relative import — deliberately: a static import puts them in this package's tsc program where they are outside rootDir (TS6059), and test-typecheck-debt.json is an exact, shrink-only ratchet whose expansion is maintainer-only. The path expression used is the spelling that gate recognises, so the coupling stays visible. This is stated in the test's header so it is not "tidied" back.

packages/lint/src/system-fields-consumers.test.ts gains the census row the new SYSTEM_FIELDS consumer owes, recorded asksProvenance: false with its reason: this is the one consumer that reads the union in the opposite direction — to drop candidates from a DERIVATION, never to stay silent about an author-written reference — so the #8116 provenance question has no path to arise here.

Changeset level

minor for @objectstack/lint, following this package's own precedent for a new rule: .changeset/list-view-field-ref-integrity.md (#14107, list-view-field-unknown) records minor as "accept-set narrowing, minor under the family precedent (#14105, #14148)". This rule narrows the accept set the same way. The sibling .changeset/view-binding-blocks-complete.md (#14331) is @objectstack/lint: patch because its lint half was wiring only — the rule itself lived in packages/spec; that shape does not apply here.

Verification record

All commands below were run in a dedicated worktree cut from origin/main 101ad2cc, and the gate union was re-run after the final commit, on git rev-parse --short HEAD = fbd3e69d.

  • pnpm --filter '@objectstack/lint^...' build — VERDICT command-exit 0 (dependency closure first).
  • pnpm --filter @objectstack/lint testTest Files 94 passed (94), Tests 2904 passed (2904).
  • pnpm --filter @objectstack/lint run typechecktsc --noEmit clean, and check:test-typecheck: OK — @objectstack/lint's test layer compiles under packages/lint/tsconfig.test.json; 2 file(s) / 6 error(s) / 2 pinned signature(s) held in test-typecheck-debt.json. The ratchet is unchanged: the six errors and two files are the pre-existing ones, and no entry was added.
  • Gate family re-derived on the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths passed; the script takes its own change set from the merge base) — 49 families. All 49 were run at fbd3e69d: 48 exit 0, and one NOT MEASURED: node scripts/check-test-completeness.mjs exits 3 with "there is no local log to hand it, so the local reading for this gate is NOT MEASURED" — its own words; CI tees a real test log into it. pnpm check:type-check-debt needed the full closure built (pnpm exec turbo run build --filter './packages/*' --filter './packages/*/*', 71 tasks successful) and then exits 0.
  • The dispatch's named-but-not-derived gates were run too, all exit 0: pnpm check:merge-driver, pnpm check:partof-closing-keyword, and @objectstack/lint's own check:doc-security-posture and check:doc-formula-expressions.
  • Production corpus, live: the full closure build includes @objectstack/example-showcase:build, which runs the authoring rules over the shipped app. It reports Build complete with 32 author-time warnings and zero occurrences of page/visualization-without-binding — the shipped app is clean under the new rule on the real os build path, not only in the unit fixture.

Reverse verification (ablation)

The fix was committed first, then the registration line was removed from packages/lint/src/reference-integrity-suite.ts by a script carrying trap restore EXIT INT TERM with an absolute repo-root path.

  • Mutation proven on disk, not by an editor's exit code: marker count for the registration line went 1 to 0, and the file's blob hash moved from the HEAD blob d90e64c91432945e15cbb1aef9848375666b5297 to 383be61cc907e5f132946e509ba028ae57a95e62. A hash equal to HEAD, or an empty hash, aborts the run.
  • Expected direction, declared before running: red. Observed: the run exits 1 with exactly two failures — reference-integrity suite / holds exactly the reference-resolution rules, in report order and suite wiring / runs as a member of the reference-integrity suite — and 32 of 34 still passing, i.e. the rule's behavioural pins are independent of its wiring pin, which is the point of having both.
  • No rebuild leg is owed here, and that is a property of the resolution path rather than a shortcut: the ablated symbol and both test files resolve through intra-package relative source specifiers under vitest, never through a dependency's exports into dist/. The ablation going red is itself the evidence — a stale-dist ablation stays green.
  • Restore proven by state, not by the trap firing: git checkout HEAD -- ABSOLUTE_PATH (naming HEAD, never the bare form that reads back from a polluted index), then git diff HEAD for that path is 0 bytes, the worktree blob hash is back to d90e64c91432945e15cbb1aef9848375666b5297, the marker count is back to 1, and git status --short is empty. pnpm --filter @objectstack/lint build was re-run afterwards so the type-check-debt gate measured a fresh entry point.

Not touched

packages/spec/src/ui/page.zod.ts, packages/spec/src/ui/view.zod.ts, validate-functional-completeness.ts and the existing view/layout-without-binding rule, examples/** (the showcase modules are read as fixtures, never edited), content/docs/releases/**, skills/**, and objectui. No hand-maintained rule catalogue needs a row: grepping content/docs/ for layout-without-binding finds only the v17 release note and an ADR narrative, and the positive control — list-view-field-unknown, the package's most recent new rule (#14107) — has zero hits there, so this family is not catalogued by hand or by generation.

Left for the PM to route

The measurement's optional objectui rider still stands and is out of scope here: the renderer's refusal screen could name sourceView when it is reached from an interface page. Card #14074, archived by the same measurement, is untouched by this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_0174WZTU6XcFcS7g2kykC53i

Generated by Claude Code


Generated by Claude Code

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
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
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
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 34 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/lint/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/architecture.mdx (via groupByField (literal, a string literal in binding))
  • content/docs/getting-started/common-patterns.mdx (via groupByField (literal, a string literal in binding))
  • content/docs/ui/field-grouping-and-order.mdx (via groupByField (literal, a string literal in binding))
  • content/docs/ui/views.mdx (via groupByField (literal, a string literal in binding), startDateField (literal, a string literal in binding))

3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via groupByField (literal, a string literal in binding))
  • content/docs/releases/v17.mdx (via locationField (literal, a string literal in binding))
  • content/docs/releases/v9.mdx (via groupByField (literal, a string literal in binding))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/lint/src/index.ts) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 1db254fceafb3273c21e7a611be9002722783756 — the merge of head fbd3e69dcd353bc21e966626eda6bd4272560b96 into base 08706f0e038714947c098165b3a2d65177ea183e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1db254fceafb3273c21e7a611be9002722783756 && git checkout 1db254fceafb3273c21e7a611be9002722783756
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 08706f0e038714947c098165b3a2d65177ea183e fbd3e69dcd353bc21e966626eda6bd4272560b96 && git checkout -B drift-repro 08706f0e038714947c098165b3a2d65177ea183e && git merge --no-ff fbd3e69dcd353bc21e966626eda6bd4272560b96

node scripts/docs-audit/affected-docs.mjs --json 08706f0e038714947c098165b3a2d65177ea183e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 08706f0e038714947c098165b3a2d65177ea183e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Copy link
Copy Markdown
Collaborator

Landing provenance (PM seat domain:spec, session_0174WZTU6XcFcS7g2kykC53i, 2026-09-03T21:37Z) — flipping to ready and enabling auto-merge (squash).


Generated by Claude Code

@zhuangjianguo
zhuangjianguo marked this pull request as ready for review September 3, 2026 21:36
@zhuangjianguo
zhuangjianguo added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit e38da2b Sep 3, 2026
35 checks passed
@zhuangjianguo
zhuangjianguo deleted the claude/issue-14073-interface-page-visualization-lint branch September 3, 2026 21:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants