feat(lint): refuse an interface page's whitelisted visualization that binds to nothing - #15089
Conversation
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
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
Landing provenance (PM seat
Generated by Claude Code |
Fixes #14073
Direction B, ruled on the measurement (#14073 comment 5531217232) and routed by the PM (comment 5531261969). No
packages/specchange:InterfacePageConfigSchemastays 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
listpage whitelists renderers withinterfaceConfig.appearance.allowedVisualizations, and the page config declares nocalendar:/kanban:/map:key at all. #13817's parse-time refinement, which demands acalendar:block on a list VIEW that whitelistscalendar, 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 inventeddue_datedefault),packages/app-shell/src/views/InterfaceListPage.tsx:409-419derives each binding asview.SOMEVIZ ?? deriveFromObject(objectDef)— a real field name orundefined, never a literal. When nothing derives there are exactly two outcomes, and neither reaches the author: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, andgetCalendarConfig(packages/plugin-calendar/src/ObjectCalendar.tsx:142-165) returns null: every visitor lands on the "Calendar configuration required" refusal screen;showViewSwitcherreads 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 siblingvalidatePageFieldBindingstakes — so it runs onos validate/os lint/os compileand onos build. It carries noruntimeTypes, i.e. the frozenflowdefault: it resolves againststack.pagesandstack.views, neither of which the per-write publish snapshot carries, so crossing it ontoviewwrites 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 throughsourceView.gridalways 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.:149-151,:203):409-419)groupByField/status|stage|state|priority|category|kind/ikanbandefaultKanbanFromObject:153startDateField/date|due|start|end|deadline|schedule/icalendardefaultDateField:163+defaultCalendarFromObject:170startDateFieldtimeline, orcalendardefaultCalendarFromObjectat:414;resolveTimelineDateBinding(packages/plugin-list/src/ListView.tsx:411-433) accepts a calendar block as the axiscoverFieldgallerydefaultGalleryFromObject:175startDateField+endDateField/start|begin|kickoff/i, end prefers/end|due|finish|deadline|close/iganttdefaultGanttFromObject:185locationField/location|address|geo|coords?|place|venue/imap(and the legacyoptions.maptwin)defaultMapFromObject:253The renderer's pre-filter is mirrored too: hidden and framework-managed fields never reach a predicate (
firstFieldMatching:137-147, viaisSystemManagedField). This side takes that skip through the lint package's sharedSYSTEM_FIELDSanswer rather than hand-copying objectui's name list — the driftsystem-fields.tsexists 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.chartandtreeget 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:
errorwhen the unbound entry isallowedVisualizations[0]— the runtime reaches the refusal screen and the page IS that screen;warningotherwise — 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
sourceViewas 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 itslocationField.Four skips, so the rule stays quiet where it cannot know
type: 'list'(only alistpage mountsInterfaceListPage);external) — the family's standard skips, ADR-0072 D1;grid, plus any whitelisted value the renderer derives no binding for;sourceViewnaming 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:OBJECTUI_DERIVATION_PREDICATESis 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_taskinterface 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 istype: 'list'with a non-empty whitelist and that every judged visualization is exercised, so the zero cannot go vacuously green.showcase_task's date fields and the same run reports exactly six findings:erroron the leading entry of the calendar, gantt and timeline pages,warningon the three non-leading entries of the all-views switcher. Without this, "zero findings" would be compatible with a rule that never fires.['calendar']leading — oneerror, pathpages[0].interfaceConfig.appearance.allowedVisualizations[0], message naming the page, the visualization,startDateFieldand "a field typed date / datetime / time", hint namingsourceView.['grid', 'calendar']— onewarningat index 1;griditself says nothing.sourceViewbound — a page pointed at a view carryingcalendar: { startDateField }passes; the same page whitelistingkanbanas well still reports the kanban entry; acalendar:block also satisfiestimeline; the ADR-0017 qualifiedobjectname.keyspelling resolves; and asourceViewnaming nothing in the stack skips the page.covertypedtextis 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.runtimeTypes.Cross-package inputs for that corpus are declared in
turbo.json(@objectstack/lint#test) and in the registry thecheck:cross-package-test-inputsgate reads, so both of CI's scoping layers move when the showcase moves. The three showcase modules are loaded through a path built fromimport.meta.urlrather than a static relative import — deliberately: a static import puts them in this package's tsc program where they are outsiderootDir(TS6059), andtest-typecheck-debt.jsonis 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.tsgains the census row the newSYSTEM_FIELDSconsumer owes, recordedasksProvenance: falsewith 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
minorfor@objectstack/lint, following this package's own precedent for a new rule:.changeset/list-view-field-ref-integrity.md(#14107,list-view-field-unknown) recordsminoras "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: patchbecause its lint half was wiring only — the rule itself lived inpackages/spec; that shape does not apply here.Verification record
All commands below were run in a dedicated worktree cut from
origin/main101ad2cc, and the gate union was re-run after the final commit, ongit rev-parse --short HEAD=fbd3e69d.pnpm --filter '@objectstack/lint^...' build— VERDICT command-exit 0 (dependency closure first).pnpm --filter @objectstack/lint test—Test Files 94 passed (94),Tests 2904 passed (2904).pnpm --filter @objectstack/lint run typecheck—tsc --noEmitclean, andcheck: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.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 atfbd3e69d: 48 exit 0, and one NOT MEASURED:node scripts/check-test-completeness.mjsexits 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-debtneeded the full closure built (pnpm exec turbo run build --filter './packages/*' --filter './packages/*/*', 71 tasks successful) and then exits 0.pnpm check:merge-driver,pnpm check:partof-closing-keyword, and@objectstack/lint's owncheck:doc-security-postureandcheck:doc-formula-expressions.@objectstack/example-showcase:build, which runs the authoring rules over the shipped app. It reportsBuild completewith 32 author-time warnings and zero occurrences ofpage/visualization-without-binding— the shipped app is clean under the new rule on the realos buildpath, 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.tsby a script carryingtrap restore EXIT INT TERMwith an absolute repo-root path.1to0, and the file's blob hash moved from the HEAD blobd90e64c91432945e15cbb1aef9848375666b5297to383be61cc907e5f132946e509ba028ae57a95e62. A hash equal to HEAD, or an empty hash, aborts the run.reference-integrity suite / holds exactly the reference-resolution rules, in report orderandsuite 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.exportsintodist/. The ablation going red is itself the evidence — a stale-dist ablation stays green.git checkout HEAD -- ABSOLUTE_PATH(namingHEAD, never the bare form that reads back from a polluted index), thengit diff HEADfor that path is 0 bytes, the worktree blob hash is back tod90e64c91432945e15cbb1aef9848375666b5297, the marker count is back to1, andgit status --shortis empty.pnpm --filter @objectstack/lint buildwas 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.tsand the existingview/layout-without-bindingrule,examples/**(the showcase modules are read as fixtures, never edited),content/docs/releases/**,skills/**, and objectui. No hand-maintained rule catalogue needs a row: greppingcontent/docs/forlayout-without-bindingfinds 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
sourceViewwhen 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