feat(lint): field-typed equality/membership arm for filter-preset-comparand on declared date/datetime fields - #16347
Conversation
…parand On a declared date / datetime field, refuse a dashboard date-range preset name in every comparand position (bare, $eq/$ne, $in/$nin, and the view-rule and triple spellings), binding each authored filter to the object its conditions address through the stack's own object graph. The field-agnostic schema door keeps its ordering-only boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…eset-comparand-field-typed
📓 Docs Drift CheckThis PR changes 2 package(s): 4 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 — 130 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 21cdd19d646e98f7edecadad42665033bd8e1636 && git checkout 21cdd19d646e98f7edecadad42665033bd8e1636
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f377394ae2c2510636cdb12a34a49b5a442457d6 3b6ba7b409033b13694e95e802ba3da354ab016f && git checkout -B drift-repro f377394ae2c2510636cdb12a34a49b5a442457d6 && git merge --no-ff 3b6ba7b409033b13694e95e802ba3da354ab016f
node scripts/docs-audit/affected-docs.mjs --json f377394ae2c2510636cdb12a34a49b5a442457d6
|
… in the shared message's TSDoc The rule's registration comment said it needs no resolution context; arm 2 reads the stack's objects and datasets when present. The shared refusal wording's TSDoc said "ordering comparand"; it is now also the wording for the field-typed equality/membership positions. The TSDoc publishes in dist/*.d.ts (measured), so the spec change carries a patch changeset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…-② gate Check Changeset reads clause-② at PR level and requires at least minor on every package the PR grows. The spec diff is a TSDoc comment only; the public-surface widening of this PR is in @objectstack/lint, already minor. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…object, never the parent form The public-lookup route runs a publicPicker filter on the referenced object (picker.object, else the field's reference). The field-typed arm's binding fell through to the view's own object, so a parent date column and a referenced select column sharing a name produced a false refusal on a legitimate picker filter. The picker is now a claiming reader: it resolves the referenced object or leaves the position unjudged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
落地前检三条 ·
|
| 载体 | 读回 |
|---|---|
| 卡 #16106 | 5 → 4:bug · priority:p2 · pm:dispatched · domain:spec |
| PR #16347 | 6 → 5:documentation · size/l · tests · tooling · protocol:data |
两边都先读当前集再写合并集 —— ⛔ 不猜。
③ 检查 —— ⚠️ 绿是真的,但它是过时聚合器判的
Test Core (5/6) cancelled 16:20:54Z (30m01s)
Test Core (必需) success 16:21:16Z
⛔ 我不把这个 success 当作分片跑过了。 PR #16316(Part of #16157,汇总修复)在 14:48:55Z 落地为 b9a14dd64f,而这个分支的基线早于它。带点亮对照实测:
origin/main scripts/check-shard-attestation.mjs "an untested shard is not a passing shard" → 1 ← 对照亮
3b6ba7b409 同一文件 → 0
⇒ 这条分支跑的是旧聚合器 —— 就是那个把被杀分片当作「不欠 attestation」、用空集满足名册检查的版本。对照:兄弟 PR #16342 合并过修复后的 main,同样的 5/6 cancelled,它的汇总诚实地判红,并逐字打印 "an untested shard is not a passing shard, see #16157"。
落地判断
转 ready、挂 auto-merge,理由是机制而非侥幸:合并队列的构建跑在 gh-readonly-queue/main/… = 当前 main(已含修复)+ 本 PR,所以队列会用诚实的聚合器重判。
- 分片在队列里跑进墙内 → 诚实落地,六个分片都真的执行过;
- 分片再被杀 → 汇总红,本 PR 被踢出队列并保持 open —— 那是正确结果,我按阻塞处理,⛔ 不当作可披露的成本。
⇒ 无论哪一种,把关的都是诚实的那一版。⛔ 错的做法是让这个过时的绿成为它落地的理由。
5560401741)。这一张不会再是第七张,因为队列现在会重判它。
两条 advisory,⛔ 不在本 PR 修
复核给的两条都不阻塞,我另行立卡:① picker 分支按属性名匹配而非 schema 位置(今天 11 处子片段命中全在 view.zod.ts,但未来别处出现同名键会静默继承此分支);② 点号 field 与嵌套 picker 上 lint 与路由发散(lint 跟随跳转,路由做顶层直查),发散方向永不产生假拒绝。
domain:spec PM 派发席 · 席位帖 #6017 · R4 · 落地前检 ①②③ 逐条读数
Generated by Claude Code
Generated by Claude Code
Fixes #16106
Clause-②: yes
Ruling executed, not re-opened: comment 5557019138 on the card (director seat, summon #16, batch #52 item 5; maintainer verbatim 「其他同意」 adopting recommendation 1′). Clause-②: yes — constructed at
CONTRACT_REVIEW_TIER, no quota exemption taken. Authored by a Claude Code dev seat under PM dispatch, sessionsession_01T6HeZvT9wdSJD1ZxJb5Eno(theClaim:on the card, comment 5559619826, is the identity record). The standaloneClause-②: yesline above is the durable declaration: it keeps the clause-② signal readable byCheck Changesetafter the review seat clears theneeds:contract-reviewcarrier.needs:contract-reviewis hung on this PR and on #16106 together. This PR stays a draft until an at-tier review clears it — it is not flipped ready and not enqueued by its author; that is the review's outcome to decide. The at-tier review (card comment 5560322399) returned FAIL on one blocking finding, B1; the repair commit below closes it and a narrow re-review of that delta is pending.What changed
packages/lint/src/validate-preset-comparands.tsgains arm 2 — the FIELD-TYPED refusal (ruling item 1):date/datetimefield, one of the 13 declared preset names is refused in EVERY comparand position: bare (implicit equality),$eq/$ne,$in/$nin(every member of the list), the view-rule spellingsequals/not_equals/in/not_in(with every aliasnormalizeFilterOperatorfolds —eq,ne,neq,notIn, …), and the triple spellings=/!=/in/nin(with every aliascanonicalAstOperatorfolds —==,equals,not_in, …).bareDateRangePresetComparandMessage(preset, operator)— one condition, one wording (the{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 convention). The implicit-equality position is reported under the operator it lowers to,$eq, at the field's own path (…filter.close_date, no operator segment).object/objectName(datasets, a summary field's child object,optionsFrom, page data sources,record:related_listproperties,element:record_picker, time-relative triggers);dataset→ that dataset'sobject(dashboard widgets A dashboard widget's OWNfilterkeys andoptions.sortByare not resolved at author time — validate/build exit 0, widget renders empty #14148, reports, report blocks);data.objecton theobjectprovider (views and list views No field reference on a list view is checked at author time — columns, filter, sort, grouping and every binding block accept a misspelt field name through bothos validateandos build#14107, any other provider unjudged);config.objectName/config.object(flow CRUD nodes, a templated value skipped);dataSource.objectthenproperties.object/properties.objectName(page components); a form field'spublicPicker→picker.object, else the field'sreferenceresolved on the view's object, else unjudged (repair commit, below); and underobjects, the object itself (its list views, tabs,relatedListFilter). A bare or dotted field key then resolves throughresolveFieldPath, and only an author-declareddate/datetimeleaf is judged.23443ce169(PM-authorized, comment-only):authoring-rules.ts's registration comment now describes both arms — arm 1 needs no resolution context, arm 2 readsobjects/datasetswhen present and stays silent where absent, soRUNTIME_NEEDS_FULL_SNAPSHOTstill does not apply;date-range-presets.ts:101's TSDoc names both moments the shared wording is reported at. The message text itself is unchanged.e05cb18796(no source change): the@objectstack/specchangeset raised frompatchtominor— see Changesets below for why.3b6ba7b409(review finding B1, card comment 5560322399):views[].sections[].fields[].publicPicker.filteris a static pre-filter the public-lookup route runs on the REFERENCED object (picker.object, else the field definition'sreference), never on the form's own object.boundObjectOfdid not recognise apublicPickerancestor and fell through to the view'sdata.object— the parent form object — so a parentdatecolumn and a referencedselectcolumn sharing a name (with a preset-named option value) produced a FALSE refusal on a legitimate publish. The picker is now a claiming reader:picker.objectwhen written, else the enclosing form field'sfieldresolved on the view's object throughresolveFieldPathand itsmeta.reference, elseundefined— unjudged, never the parent. Mechanically:ancestorsOfnow records the property name each ancestor was reached under, and the reader loop becamebindAncestors(collection, chain, from, …)so the picker branch can bind the enclosing form's object by recursing outward from the section. No other binding path changed.element:record_pickeris not the same class — it declares its ownobject, which the direct-objectreader already binds.Deliberately unjudged — a missed catch is the only failure direction this arm may have (the at-tier review found exactly one position where that did not hold, B1 above, closed by the repair commit), and the engine door still refuses each of these at query time with the registry's field map in hand: a position no ancestor binds; an unknown dataset or object, an object with no field map, a non-
objectview provider; apublicPickerwhose field the form object does not declare or whose field carries noreference; a registry-injected column (created_at/updated_at— the graph carries their names, not their types; filed as #16340); atimefield (the ruling namesdate/datetime); a field the object does not declare (the*-filter-field-unknownrules' finding); and every select / text column —stage: 'this_quarter'stays a working filter, which is the whole reason the schema door cannot do this blind.Ruling item 2:
packages/spec/src/data/filter.zod.tsis untouched (read only); the field-agnostic door keeps its ordering-only boundary and its header's reason.Item 1's conditional ("and, if stack-level publish validation resolves field types,
defineStacktoo"): measured no —stack.zod.tscarries zero filter-walking code (grep -c 'FilterCondition|walkFilter|runtimeFilter' packages/spec/src/stack.zod.ts→0), sodefineStackkeeps accepting these positions and the refusal lives atobjectstack lintand at the runtime publish gate (validatePresetComparandsis registeredCLI_AND_RUNTIME; the per-write snapshot carriesobjectsanddatasets, so adashboard/view/object/page/flowwrite is judged with the field types present).Item 4: with
objectsin the stack,filter-preset-comparandnow fires on a filterdefineStackaccepts — the rule id becomes countable as live coverage on adefineStackapp for the first time.First premise (ruling item 3) — measured before any edit
Written onto the card: #16106 (comment). Real engine, two real drivers (
@objectstack/driver-memory,@objectstack/driver-sqlite-wasm), declareddate+datetimefields, 30 rows seeded: bare,$eq,$in,$ne,$ninare ALL refused at the engine door withINVALID_FILTER/ 400 before any driver read, identically on both drivers; positive controls pass ({ $gte: '{30_days_ago}' }→ 20 rows, a bare ISO day → 1,$inof ISO days → 2);stage: 'this_quarter'on a select column → 10 rows. The at-tier review reproduced this independently.Correction to the card, stated plainly: its sentence "the surface answers 200 with zero rows and no diagnostic" was inherited from the pre-door #8690 measurement and is not true today for the residue positions on a declared
date/datetimefield — the runtime answers a loud 400 on first render. The gap was purely at authoring time (objectstack lintpassed,defineStackaccepted), which is what this PR shuts. The ruling stands either way.Changesets
@objectstack/lint: minor (new refusal arm) — the package whose public surface this PR widens; the review confirmed the level.@objectstack/spec: minor — gate-required, not semantically earned. The spec diff is a TSDoc comment only (no schema, no export, no runtime behaviour). It owes a changeset at all because the TSDoc publishes — measured, not assumed: after the edit, 2 non-mapdistfiles carry the new text (dist/date-range-presets-BQj1X-Id.d.tsand.d.mts, the chunkdist/data/index.d.tsre-exports from;distis inpackage.jsonfiles), 0 non-map files still carry the old phrase; baseline: before the edit the old phrase sat in exactly those two files, and a known.describe()string (Presentation-scope filter (runtimeFilter)) reached 18 non-map files. Semantically that is apatch. It is gradedminorbecause theCheck Changesetgate reads clause-② at PR level, not per package, and requires at leastminoron every package the PR grows (maintainer ruling 2026-09-04, batch [WIP] Add query enhancements and advanced validation features #35, on finding(changeset): two independent contract reviews read the repo's own history to opposite bumps for "add an exported symbol to a published index" #15294); the surface widening that earns clause-② lives in@objectstack/lint. This reading is recorded here rather than in the changeset prose, which ships to consumers as CHANGELOG text.check:generatedafter the rebuild: "All 15 generated artifacts are up to date" (no docs / api-surface artifact moved).Verification — final head
3b6ba7b409(=34949aa1b0+ merge oforigin/mainat6c546ab9d0=96ff8f59d7, +23443ce169comments, +e05cb18796changeset level, + the B1 repair)pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-preset-comparands.test.ts—Tests 17 passed (17)(8 existing + 7 arm-2 + the 2 B1 pins: the false-refusal case QUIET, the referenced-object-is-a-date POSITIVE CONTROL refused).pnpm --filter @objectstack/lint teston3b6ba7b409(lint'sdistbuilt) —Test Files 99 passed (99)/Tests 3389 passed (3389);pnpm --filter @objectstack/lint typecheck—tsc --noEmitclean,check:test-typecheck: OK.pnpm --filter @objectstack/spec exec vitest run src/data/date-range-presets.test.ts—Tests 6 passed (6);pnpm --filter @objectstack/spec build+check:generatedgreen on the edited TSDoc. Spec's full test / typecheck suites are declared to CI (comment-only spec diff, compiled by the dts build).96ff8f59d7; no consumer-visible change since):pnpm --filter @objectstack/metadata-protocol test—Test Files 166 passed | 2 skipped (168)/Tests 2414 passed | 10 skipped (2424)(pre-existing skips).34949aa1b0, re-run by the review):EQUALITY_DOLLAR_OPSmutated to accept nothing → 3 failed / 12 passed; restore proven by blob1f9c5f48fef72bcd457390fa036c751d804243b8and emptygit diff HEAD. The review added two legs in the false-refusal direction (FIELD_TYPED_TEMPORAL_TYPES + 'select'→ 1 failed / 14 passed;UNBOUND = () => true→ 2 failed / 13 passed), both as predicted.3b6ba7b409, both directions): leg P1 — the picker reader disabled (key === 'ablated_' + PUBLIC_PICKER_KEY; on-disk proof anchor 1 / remaining 0 / injected 1, blobcd3fbe6a6e08…) → vitest exit 1, red on exactly the QUIET pin (the false refusal returns); leg P2 — every picker position forced unjudged (const override = undefined …; return override;, blob553db859ecfe…) → exit 1, red on exactly the POSITIVE CONTROL pin (blanket silence). Each restored withgit checkout HEAD -- …: on-disk blob = HEAD blob23a8afdb33349274645cf2f54a36d364e31052e2,git diff HEADempty, porcelain empty. A discarded reading, stated: the first P2 attempt used a replacement that CONTAINED its anchor as a substring, so the on-disk proof's "anchor remaining" count read 1 although the blob had changed (75a7be68…) — the proof refused it as not-landed and the leg was re-run with a non-superset replacement; nothing was read from the discarded attempt.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon3b6ba7b409derived 74 families (identical toe05cb18796's); all 74 run on that head: 72 green, 2PREREQUISITE NOT MET(exit 3:check:dual-build-cjs-loads,check:type-check-debt— both read the whole-repo build only CI performs; NOT MEASURED locally, not red).--ranreconciliation: 74 derived / 74 run / 0 unrun.Check Changesetone05cb18796(CI job 101510513543): fired and passed; offline on the same head with this body: reads both the carrier and theClause-②: yesline, passes.turbo ls --affectedlists 54 packages (everything downstream of@objectstack/lintand@objectstack/spec). Run locally:@objectstack/lint,@objectstack/metadata-protocol, the spec file's own test.packages/cli(a 56-cache-miss closure build), spec's full suites and the rest are declared to CI.Notes for the reviewer
temporal-comparand-door.ts:186— read, not measured); arm 2 does judgeaccount.created_on-style paths through the graph's lookup hops, the one place such a path gets a verdict at all (review advisory A1: the shared message's "refuses the query at the engine" clause is not literally true there;{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240's one-wording rule wins).created_at/updated_at) — the object graph carries their names, not their types #16340 is filed for the injected-column blind spot (created_at/updated_atmeasured refused at the engine, invisible to the lint graph). Not addressed here.