Repository navigation
Commit 1e496f9
fix(lint): enter the publicPicker object reader by schema position, not by key name (#18524)
Fixes #16403
Clause-②: no
## What changed
`bindAncestors` (`packages/lint/src/validate-preset-comparands.ts`)
matched the public-lookup picker reader on the property NAME:
```ts
if (key === PUBLIC_PICKER_KEY) {
```
`walkAuthoredFilters` / `scanForFilters` recognise a filter by KEY at
any depth on all eight scanned collections, so that reader was reachable
from any node spelling `publicPicker`, at any depth, on any surface —
and its unresolvable exit is `undefined`, i.e. no bound object, so arm
2's field-type oracle answers `false` for every key and the whole filter
subtree is judged by nobody.
The branch is now entered by SCHEMA POSITION:
```ts
const formField = key === PUBLIC_PICKER_KEY ? strName(chain[i - 1]?.node.field) : undefined;
if (formField) {
```
`publicPicker` is declared exactly once as a schema key —
`FormFieldBaseSchema` in `packages/spec/src/ui/view.zod.ts`, where the
enclosing record is a form field whose `field` is REQUIRED. So the
enclosing `field` identifies 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.field` guard, and asked for the cost of the choice to
be stated. **The guard was chosen**, for one reason: it introduces **no
new coupling** between `packages/lint` and 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:
- The guard reaches only ONE case: a node reached under `publicPicker`
whose enclosing ancestor declares no string `field`, AND which declares
no `object` of its own. Every other shape is unchanged (an `object` on
the node is read by the picker reader's override and by the ordinary
`r.object` reader alike, to the same value).
- In that one case the node is now bound by the ordinary
nearest-ancestor readers instead of leaving unbound. That is the rule's
general contract — the same contract every other unrecognised key in the
config already runs under — rather than a name-triggered exemption from
it. It is not a new class of risk; it is the removal of a name-shaped
exception to the existing one.
- A malformed form field (one that omits the required `field`) therefore
also falls through. `FormFieldBaseSchema` requires `field`, 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
`publicPicker` has 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.ts`
on 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 over `crm_account.close_date: select` reports
nothing, where the parent `crm_opportunity.close_date` is a `date`).
- `[B1] POSITIVE CONTROL: the same picker filter is still refused when
the REFERENCED object declares the field as a date` — PASS, reporting at
`views[0].sections[0].fields[0].publicPicker.filter[0].value` through
the field's `reference` and through the `object` override 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.
```
pre-mutation : guarded-entry lines 1 · name-only-entry lines 0
post-mutation : guarded-entry lines 0 · name-only-entry lines 1 · mutated blob != HEAD blob
ablated run : Tests 1 failed | 21 passed (22)
FAIL [#16403] a `publicPicker` key outside the declared form-field position …
- Expected ["dashboards[0].widgets[0].publicPicker.filter.close_date"]
+ Received []
restore : git diff HEAD empty · porcelain clean · blob == HEAD blob · guarded-entry lines back to 1
```
The received `[]` is the gap itself: the picker reader claimed the node
on its key, found no enclosing `field`, and left through the `undefined`
exit, 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-wide `eslint .
--no-inline-config`) — exit 0, whole population, no narrowing claimed.
## 验收备注 (Acceptance notes)
The triage ruling (`5579122478`), quoted verbatim — Chinese decisions
are not translated:
> 1. 项 (1)(属性名匹配)与项 (2)(lint 跟了一跳 route 不跟)**可分开落地**,⛔ 不必同一个 PR。**项 (1)
是本卡主项**。
> 2. ⛔ **不动 picker 分支的 `undefined` 出口** —— 它被 #16106 复审第三条消融腿(P3)的 QUIET
pin 钉住,且是有意的。⭐ 分诊把修法的形状定死了:**出口被钉住了,入口没有** ⇒ 要加的是「什么样的节点算 picker」的约束。
> 3. **若加 `chain[i-1].node.field` 守卫**:**阴性对照必测** —— `view.zod.ts`
上那唯一一个真 picker 在守卫之后**仍然**进入 picker 分支并解析到被引用对象。⭐ 这正是 PR #16347 修好的那条 B1
**假拒绝**(表单字段的 `publicPicker.filter` 被拿父表单的对象去判),⛔ 不得被这次加固重新打开。
> 4. **若只加注释**:注释必须说清三件 —— ① 按属性名匹配、② 今天只有一处声明、③ 再出现同名键时会**静默**进入本分支。⛔
不许只写"注意"。
> 5. **项 (2) 若要动**:先给一次读数 —— 今天有没有真实的 dotted picker path 被
`filter-preset-comparand` 报过?⛔ 两个方向都要写。1 parent 2496415 commit 1e496f9
3 files changed
Lines changed: 71 additions & 4 deletions
File tree
- .changeset
- packages/lint/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
621 | 621 | | |
622 | 622 | | |
623 | 623 | | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
| 658 | + | |
| 659 | + | |
| 660 | + | |
624 | 661 | | |
625 | 662 | | |
626 | 663 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
120 | 120 | | |
121 | 121 | | |
122 | 122 | | |
123 | | - | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
124 | 128 | | |
125 | 129 | | |
126 | 130 | | |
| |||
420 | 424 | | |
421 | 425 | | |
422 | 426 | | |
423 | | - | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
424 | 445 | | |
425 | 446 | | |
426 | | - | |
427 | | - | |
428 | 447 | | |
429 | 448 | | |
430 | 449 | | |
| |||
0 commit comments