Skip to content

feat(lint): field-typed equality/membership arm for filter-preset-comparand on declared date/datetime fields - #16347

Queued
huangyiirene wants to merge 5 commits into
mainfrom
claude/issue-16106-preset-comparand-field-typed
Queued

feat(lint): field-typed equality/membership arm for filter-preset-comparand on declared date/datetime fields#16347
huangyiirene wants to merge 5 commits into
mainfrom
claude/issue-16106-preset-comparand-field-typed

Conversation

@huangyiirene

@huangyiirene huangyiirene commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

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, session session_01T6HeZvT9wdSJD1ZxJb5Eno (the Claim: on the card, comment 5559619826, is the identity record). The standalone Clause-②: yes line above is the durable declaration: it keeps the clause-② signal readable by Check Changeset after the review seat clears the needs:contract-review carrier.

⚠️ Carrier discipline: needs:contract-review is 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.ts gains arm 2 — the FIELD-TYPED refusal (ruling item 1):

  • On a declared date / datetime field, 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 spellings equals / not_equals / in / not_in (with every alias normalizeFilterOperator folds — eq, ne, neq, notIn, …), and the triple spellings = / != / in / nin (with every alias canonicalAstOperator folds — ==, equals, not_in, …).
  • Message and prescription reused verbatim: 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).
  • Binding — which object a filter is judged against. The shared walk finds the subtrees; arm 2 re-walks each subtree's config path to the NEAREST ancestor that declares an object, in the carriers' own spellings, each the same read a sibling rule already makes at that position: object / objectName (datasets, a summary field's child object, optionsFrom, page data sources, record:related_list properties, element:record_picker, time-relative triggers); dataset → that dataset's object (dashboard widgets A dashboard widget's OWN filter keys and options.sortBy are not resolved at author time — validate/build exit 0, widget renders empty #14148, reports, report blocks); data.object on the object provider (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 both os validate and os build #14107, any other provider unjudged); config.objectName / config.object (flow CRUD nodes, a templated value skipped); dataSource.object then properties.object / properties.objectName (page components); a form field's publicPickerpicker.object, else the field's reference resolved on the view's object, else unjudged (repair commit, below); and under objects, the object itself (its list views, tabs, relatedListFilter). A bare or dotted field key then resolves through resolveFieldPath, and only an author-declared date / datetime leaf is judged.
  • Arm 1 (ordering, field-agnostic) is the same judgement as before, byte for byte in behaviour; the existing eight tests are the regression pin.
  • Follow-up commit 23443ce169 (PM-authorized, comment-only): authoring-rules.ts's registration comment now describes both arms — arm 1 needs no resolution context, arm 2 reads objects / datasets when present and stays silent where absent, so RUNTIME_NEEDS_FULL_SNAPSHOT still 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.
  • Changeset-level commit e05cb18796 (no source change): the @objectstack/spec changeset raised from patch to minor — see Changesets below for why.
  • Repair commit 3b6ba7b409 (review finding B1, card comment 5560322399): views[].sections[].fields[].publicPicker.filter is a static pre-filter the public-lookup route runs on the REFERENCED object (picker.object, else the field definition's reference), never on the form's own object. boundObjectOf did not recognise a publicPicker ancestor and fell through to the view's data.object — the parent form object — so a parent date column and a referenced select column 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.object when written, else the enclosing form field's field resolved on the view's object through resolveFieldPath and its meta.reference, else undefined — unjudged, never the parent. Mechanically: ancestorsOf now records the property name each ancestor was reached under, and the reader loop became bindAncestors(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_picker is not the same class — it declares its own object, which the direct-object reader 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-object view provider; a publicPicker whose field the form object does not declare or whose field carries no reference; a registry-injected column (created_at / updated_at — the graph carries their names, not their types; filed as #16340); a time field (the ruling names date / datetime); a field the object does not declare (the *-filter-field-unknown rules' 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.ts is 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, defineStack too"): measured no — stack.zod.ts carries zero filter-walking code (grep -c 'FilterCondition|walkFilter|runtimeFilter' packages/spec/src/stack.zod.ts0), so defineStack keeps accepting these positions and the refusal lives at objectstack lint and at the runtime publish gate (validatePresetComparands is registered CLI_AND_RUNTIME; the per-write snapshot carries objects and datasets, so a dashboard / view / object / page / flow write is judged with the field types present).
Item 4: with objects in the stack, filter-preset-comparand now fires on a filter defineStack accepts — the rule id becomes countable as live coverage on a defineStack app 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), declared date + datetime fields, 30 rows seeded: bare, $eq, $in, $ne, $nin are ALL refused at the engine door with INVALID_FILTER / 400 before any driver read, identically on both drivers; positive controls pass ({ $gte: '{30_days_ago}' } → 20 rows, a bare ISO day → 1, $in of 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 / datetime field — the runtime answers a loud 400 on first render. The gap was purely at authoring time (objectstack lint passed, defineStack accepted), 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: minorgate-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-map dist files carry the new text (dist/date-range-presets-BQj1X-Id.d.ts and .d.mts, the chunk dist/data/index.d.ts re-exports from; dist is in package.json files), 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 a patch. It is graded minor because the Check Changeset gate reads clause-② at PR level, not per package, and requires at least minor on 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:generated after the rebuild: "All 15 generated artifacts are up to date" (no docs / api-surface artifact moved).

Verification — final head 3b6ba7b409 (= 34949aa1b0 + merge of origin/main at 6c546ab9d0 = 96ff8f59d7, + 23443ce169 comments, + e05cb18796 changeset level, + the B1 repair)

  • pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-preset-comparands.test.tsTests 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 test on 3b6ba7b409 (lint's dist built) — Test Files 99 passed (99) / Tests 3389 passed (3389); pnpm --filter @objectstack/lint typechecktsc --noEmit clean, check:test-typecheck: OK.
  • pnpm --filter @objectstack/spec exec vitest run src/data/date-range-presets.test.tsTests 6 passed (6); pnpm --filter @objectstack/spec build + check:generated green on the edited TSDoc. Spec's full test / typecheck suites are declared to CI (comment-only spec diff, compiled by the dts build).
  • Consumer, against the rebuilt lint dist (on 96ff8f59d7; no consumer-visible change since): pnpm --filter @objectstack/metadata-protocol testTest Files 166 passed | 2 skipped (168) / Tests 2414 passed | 10 skipped (2424) (pre-existing skips).
  • Ablation, arm 2 (on 34949aa1b0, re-run by the review): EQUALITY_DOLLAR_OPS mutated to accept nothing → 3 failed / 12 passed; restore proven by blob 1f9c5f48fef72bcd457390fa036c751d804243b8 and empty git 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.
  • Ablation, B1 repair (on 3b6ba7b409, both directions): leg P1 — the picker reader disabled (key === 'ablated_' + PUBLIC_PICKER_KEY; on-disk proof anchor 1 / remaining 0 / injected 1, blob cd3fbe6a6e08…) → 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;, blob 553db859ecfe…) → exit 1, red on exactly the POSITIVE CONTROL pin (blanket silence). Each restored with git checkout HEAD -- …: on-disk blob = HEAD blob 23a8afdb33349274645cf2f54a36d364e31052e2, git diff HEAD empty, 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.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 3b6ba7b409 derived 74 families (identical to e05cb18796's); all 74 run on that head: 72 green, 2 PREREQUISITE 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). --ran reconciliation: 74 derived / 74 run / 0 unrun. Check Changeset on e05cb18796 (CI job 101510513543): fired and passed; offline on the same head with this body: reads both the carrier and the Clause-②: yes line, passes.
  • Declared narrowing: turbo ls --affected lists 54 packages (everything downstream of @objectstack/lint and @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

…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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@huangyiirene huangyiirene added needs:contract-review and removed documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026 — with Claude
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 28 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/src/data/date-range-presets.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/deployment/validating-metadata.mdx (via AUTHORING_RULES (symbol, a top-level const object), last_30_days (literal, a string literal on a changed line))
  • content/docs/getting-started/common-patterns.mdx (via not_equals (literal, a string literal in EQUALITY_RULE_OPS))
  • content/docs/ui/dashboards.mdx (via last_30_days (literal, a string literal on a changed line), this_quarter (literal, a string literal on a changed line))
  • content/docs/ui/forms.mdx (via publicPicker (literal, a string literal in PUBLIC_PICKER_KEY))

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

  • content/docs/releases/v15.mdx (via publicPicker (literal, a string literal in PUBLIC_PICKER_KEY))
  • content/docs/releases/v17.mdx (via AUTHORING_RULES (symbol, a top-level const object), publicPicker (literal, a string literal in PUBLIC_PICKER_KEY))

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/spec/src/data/date-range-presets.ts) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 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 — 130 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 f377394ae2c2510636cdb12a34a49b5a442457d6packageMentionDocs.

Which tree this was computed on

This run read content/docs from 21cdd19d646e98f7edecadad42665033bd8e1636 — the merge of head 3b6ba7b409033b13694e95e802ba3da354ab016f into base f377394ae2c2510636cdb12a34a49b5a442457d6, 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 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

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
… 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

Copy link
Copy Markdown
Collaborator Author

落地前检三条 · ⚠️ 第三条要说清楚:这张 PR 的绿是旧聚合器给的

domain:spec PM 派发席(session_01T6HeZvT9wdSJD1ZxJb5Eno),2026-09-06T16:2xZ,head 3b6ba7b409

① 达档 PASS —— 已备

首轮全面复核 FAIL(B1 假拒绝),修复后窄面再复核 PASS,两条都逐字落在卡 #16106 上:5560322399 · 5560556035。档位分别核过(121 / 188 条 claude-fable-5-1 盖章,对照 106 / 140)。

② 双载体 —— 已清,读回核对

载体 读回
#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 —— 那是正确结果,我按阻塞处理,⛔ 不当作可披露的成本。

⇒ 无论哪一种,把关的都是诚实的那一版。⛔ 错的做法是让这个过时的绿成为它落地的理由

⚠️ 本车道今天已经有六张从那个洞里落地(清单在 #16157 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

@huangyiirene
huangyiirene marked this pull request as ready for review September 6, 2026 16:26
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 6, 2026
Any commits made after this event will not be merged.
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 6, 2026
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 6, 2026
Any commits made after this event will not be merged.
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 protocol:data size/l tests tooling

Projects

None yet

2 participants