fix(lint): refuse dotted list-view field references at author time (#14282) - #14368
Conversation
…ime door refuses it #14107's rule judges only the HEAD segment of a list-view field reference, so a dotted path whose head resolves to a real relationship field passed `os validate` and `os build` clean while every query door a list view reaches refuses it by name. That half was recorded in the rule's docblock and pinned in tests rather than closed, because its failure mode is the opposite of the silent-blank class #14107 gates: a loud `400 INVALID_FIELD` on the first fetch. Adds it as a second finding class with its own id, `list-view-field-dotted`, scoped by the DOOR rather than by the position table: - projection (`columns[]`): both doors refuse a dotted entry unconditionally; - filter (`filter`, `tabs[].filter`, `userFilters.tabs[].filter`, `filterableFields`, `userFilters.fields`): judged by the same `classifyDottedFilterHead` the runtime doors ask, so the #8371 carve-outs the doors serve are not refused at author time. `gantt.quickFilters[].field` and `gantt.tooltipFields[]` are excluded: measured client-side, applied in memory over already-fetched rows through walkers that split on `.`, so a dot-path is served there rather than refused. Every renderer binding that reaches no measured door stays unjudged. `GraphField` gains an optional `multiple` flag (additive) so the shared seam can answer the classifier's second input without a second copy of the field read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WLJQhde67SeTccsmnBVarV
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 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 49e7fedfd8e44c952203be4736969a81fc652c30 && git checkout 49e7fedfd8e44c952203be4736969a81fc652c30
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d62f990a985eb15152579ad34c99f43e76e3657a 3aed196ef3de96fa1929994c8fc918be7316ffd9 && git checkout -B drift-repro d62f990a985eb15152579ad34c99f43e76e3657a && git merge --no-ff 3aed196ef3de96fa1929994c8fc918be7316ffd9
node scripts/docs-audit/affected-docs.mjs --json d62f990a985eb15152579ad34c99f43e76e3657a
|
Fixes #14282
#14107 judges only the HEAD segment of a list-view field reference, so
columns: [{ field: 'owner.name' }]— a dotted path whose head resolves to a real relationship field — passedos validateandos buildclean while every query door a list view reaches refuses it by name. That half was recorded in the rule's docblock and pinned in tests rather than closed, because its failure mode is the opposite of the silent-blank class #14107 gates: a loud400 INVALID_FIELDon the first fetch. This is the ruled resolution of that half.One new finding class on the same rule, with its own id:
list-view-field-dotted.Premise re-check on today's
origin/mainRe-measured on this branch's merge base (
72adb7fcb), not inferred from the card. Every runtime door the card names is present, and each says what the card says it says:assertProjectionHasNoDottedPaths—packages/objectql/src/engine.ts:1049, called fromfind(:8967) andfindOne(:9202). It filters the projection withfields.filter((f): f is string => typeof f === 'string' && f.includes('.'))and throws witherr.status = 400; err.code = 'INVALID_FIELD'. No head classification, no carve-out — every dotted entry is refused.assertProjectionFieldsExist— the REST ingress twin (Data query: a dotted projection entry silently WIDENS the response —fields:["name","account.name"]returns every field #7532),400 INVALID_FIELD, named in the engine's own docblock at:996.assertFilterIsMaterializableinpackages/objectql/src/filter-comparand-shape.ts:189, running insidelowerWhereFilterArray, "the one seam EVERY caller-suppliedwherepasses through". Its message ends "No backend serves the path, so the predicate can only match zero records: the query was refused instead of answered with an empty list." Unlike the projection door it does classify, throughclassifyDottedFilterHead(packages/spec/src/data/filter-dotted-head.ts:119):relation/virtual/scalarheads are refused; structured/JSON heads (the [finding] The FILTER axis has no DOTTED-path verdict —where: { project_id.name: 'x' }rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 ruling's deliberate carve-out, live on memory and mongodb),multiple: trueheads, file heads and unreadable heads pass through unchanged.assertSortFieldsExist— present; untouched here (see "sort keeps its owner" below).ListViewSchemastill declares no ADR-0021include, so a list view compiles no joins.premise_still_valid: true.The gantt measurement, which the ruling required first — and it went the other way
The card's one named exception is
gantt.quickFilters[].field, described in the spec as "Record field / dot-path" (packages/spec/src/ui/view.zod.ts:1198). The ruling said to judge that position only after reading the runtime door. There is no query door: the position is applied client-side, in memory, over already-fetched rows.In objectui's
packages/plugin-gantt/src/ObjectGantt.tsx:and
resolveFilterKeywalks the dot-path through the record object:The selection never becomes a query predicate, so a dot-path here is served, not refused — matching the spec's description.
gantt.quickFilters[].fieldis therefore excluded from the new class, and the exclusion is pinned with the reason. Its head is still judged for existence, so #14107's verdict on that position is untouched (also pinned).The same measurement pulled out a second exclusion the card did not name:
gantt.tooltipFields[]is read throughresolvePath(record, fieldName)in the same file, which walks dots too. Excluded, pinned.The contrast is what makes the scoping honest: the gantt scalars next to them read
record[startDateField]flat, with no dot walk.Scoped by the DOOR, not by "a list view compiles no joins"
That premise is true but does not by itself make every dotted binding a defect — the two gantt positions above are the proof. So the class is scoped to positions whose written name was measured to reach a query door, and each is judged by that door's own verdict:
columns[](both authored spellings)filter,tabs[].filter,userFilters.tabs[].filterclassifyDottedFilterHeadclassifies the headfilterableFields[],userFilters.fields[]columns[]reaches the projection because objectui'sListView.tsxbuilds the$selectfrom it — "Build a$selectprojection from the columns the listview actually shows".filterableFieldsis spelled by the spec as "Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering", andListView.tsxfolds the resulting conditions into the fetched query throughbuildEffectiveFilter(schema.filter, currentFilters, userFilterConditions)— so both declaring positions become filter keys.The filter axis asks the runtime's own classifier rather than re-listing its types. A dotted
payload.theme(ajsonhead) is therefore reported as a column and not as a filter key — because the projection door refuses it and the filter door serves it. Both directions are pinned. Refusing at author time what the door serves at run time would delete a working capability on two of three backends, which is exactly what the shared classifier exists to prevent.Deliberately left unjudged, rather than guessed at: every renderer binding (
kanban/calendar/timeline/gallery/map/treescalars,rowColor.field,hiddenFields,fieldOrder,grouping.fields,columns[].summary.field,columns[].prefix.field). A dotted name at one of them is very likely still wrong — the gantt scalars readrecord[field]flat — but "likely wrong" is not a verdict a gate may invent (ADR-0072 D1), and the failure there would be the SILENT class, not this loud one. Recorded as a follow-up in the dev report rather than folded in.sort[]keeps its owner.validate-sortable-fields.tsrecords that it "deliberately does not add a third finding" for a dotted name because "the dotted verdict is a posture shared with the FILTER and PROJECTION axes (#4256 / #7532 / #7589) and giving one axis its own authoring answer is how those doors drifted apart before". This card is that posture being ruled for the list-view surface, on the two axes this rule owns; the sort axis is untouched and no finding here duplicates one of its (pinned).Severity, and why a new rule id
error, at every position in the class. The family's severity line (stated byvalidate-searchable-fields.ts, quoted in the #14107 docblock) is: a consumer that SKIPS an unknown name and renders the rest may be warned about; a declaration that selects the wrong set, empties the surface or is refused outright must not ship. Every judged position here is refused outright with a400. It coincides with each of their declared #14107 tiers, and the code says so at the push site, so a position added to the axis table with awarningtier has to re-read the line rather than inherit it silently.A new id, per the family's own convention —
validate-sortable-fieldsshipssort-field-unknown/sort-field-unsortable/sort-field-unprovisioned,validate-searchable-fieldsandvalidate-dataset-referencesdo the same: one id per finding class, becausesuppressWarnings: ['<rule-id>']and Studio's finding renderer filter on that string and must be able to name one class without silencing the other. Pinned: a fixture carrying one of each yields[list-view-field-unknown, list-view-field-dotted].Seam: additive only
packages/lint/src/object-graph.tsgains one optional field onGraphField—multiple?: boolean— read ingraphObjectOf. No signature changes; #14275 (dashboard dotted filter fields) consumes the same seam unchanged. It is there becauseclassifyDottedFilterHeadis a function of bothtypeandmultiple, and a caller handed onlytypewould have to re-derive the flag from the raw stack — the second copy that module exists to prevent. Every existing consumer that ignores the key keeps its verdicts byte-for-byte.Fixture pass over the shipped view corpus — before / after
The ruling required this because the change narrows a gating rule's accept set. A throwaway probe (deleted before the first commit) ran
validateListViewFieldRefsover the metadata of all three shipped example apps, on the base and on this branch:72adb7fcb)3aed196ef)Delta: 0. No shipped list view carries a dotted field reference at any judged position, so nothing currently green turns red.
A zero that has only ever been zero cannot be told apart from a sweep that did not run, so the probe carried a control: one shipped CRM view's first column mutated to
name.injected_leafand pushed through the same call, which reported exactly one finding —[error] list-view-field-dotted @ views[0].list.columns[0].field. The zero is a measurement, not a vacuous green.Tests
The
#14107 — dotted pathsblock pinned both halves of that card's decision, including two cases asserting thatowner.nameandtitle.xincolumnspass clean. Those are the cases this card was filed for, and they now report. They were rewritten rather than deleted, with the reason stated in the block, so the pair still reads as one decision — what changed is which class each lands in, not whether the rule has an opinion. A new case replaces the deleted assurance that hops are still not walked:owner.nopereports the dotted class and its message never mentionsduly_person, which a hop-walking implementation would have named.Added: 24 cases over the projection axis (including the bare-string spelling, the no-carve-out case, and the JSON head that reports as a column), the filter axis (relation / virtual / scalar refused; structured-JSON, array-valued and registry-injected heads not refused), the tab and user-filter presets, the two declaring positions, the four exclusions, the skips still winning, the two ids being distinct, and acceptance through
runAuthoringRulesfor bothvalidateandbuildplus the reference-integrity suite.Verification
All readings from commit
3aed196ef, the branch head this PR opens on.pnpm --filter @objectstack/lint test— 93 files, 2737 passed, 5 skipped, re-run on the committed head.pnpm --filter @objectstack/lint run typecheck— clean (exit 0). The package'stsconfig.jsonexcludes**/*.test.ts, so that run says nothing about the test file;--listFilesconfirms it does cover both edited source files. A throwaway config including tests was run separately: 22 errors, zero of them naming this PR's test file — all 22 are pre-existing, in seven sibling test files (the same 22 fix(lint): resolve every field reference on a list view at validate and build #14283 recorded).node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, 33 families (25 by path/kind plus the 8 the changeset adds), every one run, exit code captured before any pipe. 29 green. 4 NOT MEASURED, none of them a red and none a finding about this diff:node scripts/check-test-completeness.mjs— exit 3, needs a savedturbo run testlog;pnpm check:dual-build-cjs-loads— exit 3, "Runpnpm buildfirst. This is NOT a pass: nothing was measured.";pnpm check:type-check-debt— exit 3, needs the built workspace closure;node scripts/pm/check-half-states.mjs— exit 124 at a 300s budget, making live GitHub calls.pnpm check:nul-bytes(7830 files, plus its 75-assertion self-test), and a direct control-byte scan over the five changed files.pnpm lint(repo-wide ESLint,eslint . --no-inline-config) — clean, run whole rather than narrowed.Changeset:
minorfor@objectstack/lint— an accept-set narrowing, matching the level #14107 and the two family landings before it (#14105, #14148) were given.Generated by Claude Code