Skip to content

fix(lint): refuse dotted list-view field references at author time (#14282) - #14368

Merged
baozhoutao merged 1 commit into
mainfrom
claude/issue-14282-list-view-dotted-field-refs
Sep 2, 2026
Merged

fix(lint): refuse dotted list-view field references at author time (#14282)#14368
baozhoutao merged 1 commit into
mainfrom
claude/issue-14282-list-view-dotted-field-refs

Conversation

@claude

@claude claude Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 — 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. 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/main

Re-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:

  • assertProjectionHasNoDottedPathspackages/objectql/src/engine.ts:1049, called from find (:8967) and findOne (:9202). It filters the projection with fields.filter((f): f is string => typeof f === 'string' && f.includes('.')) and throws with err.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.
  • The filter dotted-head doorassertFilterIsMaterializable in packages/objectql/src/filter-comparand-shape.ts:189, running inside lowerWhereFilterArray, "the one seam EVERY caller-supplied where passes 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, through classifyDottedFilterHead (packages/spec/src/data/filter-dotted-head.ts:119): relation / virtual / scalar heads 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: true heads, file heads and unreadable heads pass through unchanged.
  • assertSortFieldsExist — present; untouched here (see "sort keeps its owner" below).
  • ListViewSchema still declares no ADR-0021 include, 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:

// Apply the active filters in memory: a task matches when, for every dimension
// with a non-empty selection, its resolved key is among the selected values.
const displayTasks = useMemo(() => {
  ...
  const key = resolveFilterKey((t as any).data, field);

and resolveFilterKey walks the dot-path through the record object:

let cur: any = record;
for (const p of field.split('.')) {
  if (cur == null) return null;
  cur = cur[p];
}

The selection never becomes a query predicate, so a dot-path here is served, not refused — matching the spec's description. gantt.quickFilters[].field is 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 through resolvePath(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:

position door verdict
columns[] (both authored spellings) projection any dotted entry, unconditionally
filter, tabs[].filter, userFilters.tabs[].filter filter only when classifyDottedFilterHead classifies the head
filterableFields[], userFilters.fields[] filter same classifier

columns[] reaches the projection because objectui's ListView.tsx builds the $select from it — "Build a $select projection from the columns the listview actually shows". filterableFields is spelled by the spec as "Legacy shorthand for userFilters.fields — bare field names enabled for end-user filtering", and ListView.tsx folds the resulting conditions into the fetched query through buildEffectiveFilter(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 (a json head) 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 / tree scalars, 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 read record[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.ts records 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 by validate-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 a 400. 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 a warning tier has to re-read the line rather than inherit it silently.

A new id, per the family's own convention — validate-sortable-fields ships sort-field-unknown / sort-field-unsortable / sort-field-unprovisioned, validate-searchable-fields and validate-dataset-references do the same: one id per finding class, because suppressWarnings: ['<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.ts gains one optional field on GraphFieldmultiple?: boolean — read in graphObjectOf. No signature changes; #14275 (dashboard dotted filter fields) consumes the same seam unchanged. It is there because classifyDottedFilterHead is a function of both type and multiple, and a caller handed only type would 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 validateListViewFieldRefs over the metadata of all three shipped example apps, on the base and on this branch:

objects view aggregates list views findings
before (72adb7fcb) 29 10 38 0
after (3aed196ef) 29 10 38 0

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_leaf and 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 paths block pinned both halves of that card's decision, including two cases asserting that owner.name and title.x in columns pass 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.nope reports the dotted class and its message never mentions duly_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 runAuthoringRules for both validate and build plus the reference-integrity suite.

Verification

All readings from commit 3aed196ef, the branch head this PR opens on.

  • pnpm --filter @objectstack/lint test93 files, 2737 passed, 5 skipped, re-run on the committed head.
  • pnpm --filter @objectstack/lint run typecheck — clean (exit 0). The package's tsconfig.json excludes **/*.test.ts, so that run says nothing about the test file; --listFiles confirms 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).
  • Gate families derived mechanically from the real change set — 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 saved turbo run test log;
    • pnpm check:dual-build-cjs-loads — exit 3, "Run pnpm build first. 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.
  • Also green: 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: minor for @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

…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
@github-actions github-actions Bot added the size/l label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/lint, touching 9 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.

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

  • content/docs/deployment/validating-metadata.mdx (via userFilters (symbol, a field of const object DOTTED_AXIS))
  • content/docs/ui/pages.mdx (via userFilters (symbol, a field of const object DOTTED_AXIS))

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

  • content/docs/releases/v12.mdx (via userFilters (symbol, a field of const object DOTTED_AXIS))
  • content/docs/releases/v15.mdx (via userFilters (symbol, a field of const object DOTTED_AXIS))

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
  • 1 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 d62f990a985eb15152579ad34c99f43e76e3657apackageMentionDocs.

Which tree this was computed on

This run read content/docs from 49e7fedfd8e44c952203be4736969a81fc652c30 — the merge of head 3aed196ef3de96fa1929994c8fc918be7316ffd9 into base d62f990a985eb15152579ad34c99f43e76e3657a, 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 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

⚠️ 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 d62f990a985eb15152579ad34c99f43e76e3657a → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

lint: a list view's dotted field reference passes author time although every runtime door refuses it (follow-up to #14107)

2 participants