fix(lint): enter the publicPicker object reader by schema position, not by key name - #18524
Conversation
…ot by key name `bindAncestors` in `validate-preset-comparands.ts` matched the public-lookup picker reader on the property NAME `publicPicker`. `scanForFilters` recognises a filter by key at any depth on all eight scanned collections, so that reader was reachable from any node spelling the same name — and its unresolvable exit is `undefined`, which leaves the whole filter subtree unbound and therefore unjudged by the field-typed arm, silently. The branch now requires the enclosing ancestor to be a form field (`field`, required on `FormFieldBaseSchema`) — the same read the branch already made one line later, so no new coupling. Outside that position a node falls through to the ordinary nearest-ancestor readers like any other unrecognised key. The three exits the #16106 review pinned are unchanged. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 4 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 6aae8b74a8a7bbd8fd2876e5ed5172fc5a462efd && git checkout 6aae8b74a8a7bbd8fd2876e5ed5172fc5a462efd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2882528786ee33c870a9c14ee0c4d81030250c76 dffc5b965fe7ef8fd1ccfcad94a6d2dd05efb68f && git checkout -B drift-repro 2882528786ee33c870a9c14ee0c4d81030250c76 && git merge --no-ff dffc5b965fe7ef8fd1ccfcad94a6d2dd05efb68f
node scripts/docs-audit/affected-docs.mjs --json 2882528786ee33c870a9c14ee0c4d81030250c76 |
…l three held The diff deletes `if (!formField) return undefined;`, so "the three exits are byte-for-byte unchanged" was inaccurate in text that ships to CHANGELOG.md. Two exits are verbatim untouched; the third is deleted, and deleting it is the fix — outside the declared position it was the silent exit, inside it it is unreachable by construction. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk
Fixes #16403
Clause-②: no
What changed
bindAncestors(packages/lint/src/validate-preset-comparands.ts) matched the public-lookup picker reader on the property NAME:walkAuthoredFilters/scanForFiltersrecognise a filter by KEY at any depth on all eight scanned collections, so that reader was reachable from any node spellingpublicPicker, at any depth, on any surface — and its unresolvable exit isundefined, i.e. no bound object, so arm 2's field-type oracle answersfalsefor every key and the whole filter subtree is judged by nobody.The branch is now entered by SCHEMA POSITION:
publicPickeris declared exactly once as a schema key —FormFieldBaseSchemainpackages/spec/src/ui/view.zod.ts, where the enclosing record is a form field whosefieldis REQUIRED. So the enclosingfieldidentifies the position, and it is the same read the branch already had to make one line later. Outside that position the node falls through to the ordinary nearest-ancestor readers, exactly like every other key this walk does not recognise.Coupling: which size was chosen, and what it costs
The triage authorised either a comment alone or a
chain[i-1].node.fieldguard, and asked for the cost of the choice to be stated. The guard was chosen, for one reason: it introduces no new coupling betweenpackages/lintand the form-view schema. The read it adds at the entry is byte-for-byte the read the branch already performed at its second line — the guard moves it, it does not invent it. A comment alone would have left the rule's behaviour dependent on a key NAME staying globally unique, which is not a property any gate holds.What it costs, stated plainly:
publicPickerwhose enclosing ancestor declares no stringfield, AND which declares noobjectof its own. Every other shape is unchanged (anobjecton the node is read by the picker reader's override and by the ordinaryr.objectreader alike, to the same value).field) therefore also falls through.FormFieldBaseSchemarequiresfield, so such metadata is refused at the schema door; this rule is not the door that holds it.No live defect today — and why the gap was still worth closing
publicPickerhas exactly one schema declaration today, and there the reader is correct. What the guard changes is the failure mode the day a second schema declares the same name: that schema's filters would have inherited this branch silently. Under-reporting is this rule's only permitted failure direction, so the hole could never have VIOLATED the invariant — it would have quietly spent it, where no test asking "was the invariant violated?" can see it.Evidence
Negative control (triage item 3) — the #16106 B1 false refusal stays closed. The one real picker still enters the branch and still resolves to the referenced object. Measured,
pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-preset-comparands.test.tson the fixed tree:Test Files 1 passed (1) · Tests 22 passed (22), both B1 tests among them —[B1] stays QUIET on a publicPicker filter over a referenced select column that shares its name with a parent date column— PASS (a form field's picker filter overcrm_account.close_date: selectreports nothing, where the parentcrm_opportunity.close_dateis adate).[B1] POSITIVE CONTROL: the same picker filter is still refused when the REFERENCED object declares the field as a date— PASS, reporting atviews[0].sections[0].fields[0].publicPicker.filter[0].valuethrough the field'sreferenceand through theobjectoverride alike.Reverse verification. The fix was committed first; the guard was then reverted in place, on disk, and the same suite re-run. Direction: RED, exactly one test.
The received
[]is the gap itself: the picker reader claimed the node on its key, found no enclosingfield, and left through theundefinedexit, taking the subtree out of arm 2 without a word. The two B1 tests stayed GREEN under the ablation, which is the other half of the reading — the guard is orthogonal to the behaviour #16347 fixed.Package verification.
pnpm --filter @objectstack/lint test—Test Files 103 passed (103) · Tests 3845 passed (3845).pnpm --filter @objectstack/lint typecheck— exit 0.pnpm lint(repo-wideeslint . --no-inline-config) — exit 0, whole population, no narrowing claimed.验收备注 (Acceptance notes)
The triage ruling (
5579122478), quoted verbatim — Chinese decisions are not translated:Disposition, item by item:
picker.objectoverride (if (override) return override;) and bothundefinedlegs of thereferenceresolution (if (!formObject) return undefined;and theverdict?.kind === 'ok' ? … : undefinedtail). The THIRD —!formFieldreturningundefined— is DELETED by this diff, and deleting it IS the fix: outside the declared position that line was the silent exit this card is about, while inside the declared position it is unreachable by construction, since the guard holding meansformFieldis truthy. So the behaviour P3's QUIET pin holds did not move, and nothing but the entry condition changed semantically. (An earlier revision of this body and of the changeset said "all three byte-for-byte unchanged"; that was wrong against the diff and is corrected here and in.changeset/16403-picker-reader-position-guard.md.)filter-preset-comparandstill reports only what its own name covers.created_at/updated_at) — the object graph carries their names, not their types #16340 is not merged in and is not touched.Noted, not filed
check:cross-package-test-inputsreds on a checkout wherepackages/spec/dist/has been built, atorigin/main, with this diff absent — and stays green where it has not. Two-leg control taken at BASE97233b90cin a detached control worktree: withoutpackages/spec/dist/present, exit 0 (OK: 29 package(s) read outside themselves, all declared …); with the identicaldisttree hard-linked in and nothing else changed, exit 1 with the same FAIL text this branch produced (@objectstack/cli descends a directory tree from packages/spec/dist/).lint.yml'slintjob installs but never builds, so the walked-root leg of that gate cannot fire in CI at all. Reported to the dispatching seat for triage; it is outside this card's file surface and is not touched here.Generated by Claude Code