fix(core): lower $icontains, the canonical operator convertFiltersToAST refused (objectui#8976) - #8996
Conversation
…used `$icontains` is a member of `@objectstack/spec`'s `FILTER_OPERATORS`, `ValueDataSource` executes it, `FilterConditionField` emits it for its "contains (ignore case)" builder row, and `packages/core/src/adapters/README.md` prescribes it as the repair when `$like` / `$ilike` / `$regex` are refused. `convertOperatorToAST` had no row for it, so `convertFiltersToAST` answered the generic unknown-operator refusal (`INVALID_FILTER` / 400) — the one spelling this repo tells an author to write was the one spelling it rejected. Aligning the converter with the contract, per AGENTS.md #0. The lowered value is an identity: `icontains` is itself a member of `VALID_AST_OPERATORS`, so unlike `$startsWith` -> `startswith` there is no case to squash. Two consequences of the same gap ride along: the unknown-operator message now enumerates `$icontains`, and the `$regex` refusal prescribes it by name — the spec's own `FILTER_TEXT_CASES` requires that refusal to mention `$icontains`, and it could not while the converter did not accept it. `packages/data-objectstack/README.md` gains the row it never had, and the reconciliation pin's blind spot is closed in the same change: it held the tables against the code's own two populations (`operatorMap`'s keys and the operators the error calls supported), and `$icontains` was in NEITHER, so the omission was invisible to it by construction. The tables are now also held complete against the spec's `FILTER_OPERATORS`. This is the opposite leg of objectui#8568 and deliberately not folded with it: there the converter was more tolerant than the contract, here less tolerant. A single "make them agree" change would have widened the matcher instead. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
ACCEPT + 席内 Clause-② 复核 PASS — 派发席
|
| 主体 | main | 分支 |
|---|---|---|
operatorMap 内 '$icontains' |
0 | 1 |
data-objectstack/README.md 的 icontains |
0 | 4 |
且那一行是恒等映射,并自带解释:
// The VALUE is an IDENTITY, and that is not a typo: `icontains` is itself a
'$icontains': 'icontains',⇒ 因为 icontains 本身就是 spec 的 VALID_AST_OPERATORS 成员 —— 下游无需发明任何东西,这正是分诊说「协议为基准」的含义。
⭐ 本轮最有价值的产出:ARM A 是对「pin 盲区」的直接证明
把转换器与 README 还原到 4d65991c5(即发布了该缺陷的那棵树的确切状态),pin 留在 HEAD:
结果:1 failed / 11 passed
唯一的红 = 新增的那条(读 spec 的 FILTER_OPERATORS)
两条 OLD 的「完整性」用例 —— 全部 PASSED
⇒ 旧 pin 在恰恰发布了这个缺陷的树上是绿的。 这比任何论证都更硬地证明了它结构性看不见 $icontains:它只对着代码自己的两个集合(operatorMap 的键、unknown-operator 报错自称支持的集合)校验,而 $icontains 两个都不在。新 pin 引入第三个、代码推导不掉的人口 —— spec 的 FILTER_OPERATORS —— 且单向断言,好让 refused 表仍可记录非 spec 拼写。
⭐ 一处本席与分诊都没预见到的发现:spec 自己早就要求了这件事
@objectstack/spec 的 FILTER_TEXT_CASES 在 $regex 的拒绝上带着 mustMention ['$regex','$icontains'] ⇒ 转换器在不接受 $icontains 的前提下根本无法满足它自己的 spec。本 PR 让 $regex 的拒绝按名开方 $icontains,这不是新增的礼貌,是补上一条既有义务。
⚠️ 一条声明的收窄,本席接受
plugin-list / fields / components 的整包套件 NOT MEASURED —— 整包跑超出前台预算,按记录的 PID 终止,exit 144 是 SIGTERM 而不是测试判决,终报如实这么写了。收窄到四个碰 icontains 的文件(169 tests,exit 0),且 diff 不碰那三个包的任何文件。⇒ 这是合规的声明式收窄,⛔ 不是跳过;CI 的分片农场覆盖其余。
operatorMap 不可能承载 $null / $exists),并在文件里写明为何这条相等不得被双向钉死。⇒ 与本仓 ⑭「通过 ≠ 见证」同族。
卡的两处引用被证伪(结论不受影响)
- 卡称
icontains「已是FILTER_OPERATOR_ALIASES的恒等行」——@objectstack/spec无此导出,只有VIEW_FILTER_OPERATOR_ALIASES,而icontains在那里刻意没有行(spec 自己的注释说明了)。卡混淆了本仓data-objectstack/src/index.ts里的同名表。结论经由规范成员身份成立。 - 卡的
git grep -n icontains -- packages/data-objectstack/无输出已过期(src/index.ts:126现在带它)。真正要紧的那条窄断言 —— README 无$icontains行 —— 成立,并由本 PR 修复。
Clause-②
公开面扩大(转换器接受集新增一个规范成员)⇒ Clause-②: yes,按机制申报。依 objectstack-ai/objectstack#17285,本车道默认档自审 + 门禁即 review of record。⇒ PASS。
⇒ 放行:翻 ready、挂 SQUASH,按队列 ref 阳性命中确认,再按内容带亮/死对照核落地。落地后 objectui#8770 解封 ——
Generated by Claude Code
Fixes #8976
What was wrong
$icontainsis a canonical member of@objectstack/spec'sFILTER_OPERATORS,ValueDataSourceexecutes it,FilterConditionFieldemits it for its "contains (ignore case)" builder row, andpackages/core/src/adapters/README.mdprescribes it as the repair when$like/$ilike/$regexare refused.convertOperatorToAST'soperatorMaphad no row for it, soconvertFiltersToASTrefused it with the generic unknown-operator paragraph —INVALID_FILTER/ 400. The one spelling this repo tells an author to write was the one spelling it rejected.Census, re-taken on today's tree
Not the card's readings. Re-measured on
4d65991c5(the tip this branch was cut from), spec17.4.0, both legs in one process, with a control operator both sides agree on:The defect reproduces unchanged after PR #8974, #8977 and #8988.
What changed
packages/core/src/utils/filter-converter.tsoperatorMapgains'$icontains': 'icontains', placed after$endsWithso the map follows the spec's own declaration order. The value is an identity and that is not a typo:icontainsis itself a member ofVALID_AST_OPERATORS, so unlike$startsWithtostartswiththere is no case to squash.$icontainsamong the supported operators.$regexrefusal now prescribes it by name.@objectstack/spec's ownFILTER_TEXT_CASEScarriesmustMention: ['$regex', '$icontains']on that refusal, and the converter could not satisfy it while it did not accept$icontains.packages/data-objectstack/README.mdgains the$icontainsrow it never had, plus prose explaining the identity lowering. The$regexrefused row's prescription is brought back in step with the code.packages/data-objectstack/src/readme-filter-operator-table.test.ts— the pin's blind spot, closed.Why the pin could not see this
PR #8565's reconciliation pin held the README tables against two populations, and both were the code's own:
operatorMap's keys, and the operators the unknown-operator error calls supported. That makes it exact about drift between code and page — and structurally unable to see an operator missing from both sides at once.$icontainswas exactly that operator, so the page could carry no row for it in either table and the suite stayed green. A pin that reads only the code cannot report the code being wrong.A third population is now read, and it is the one the code answers to: the spec's
FILTER_OPERATORS. Every canonical member must be accounted for by the two tables — documented as supported, or documented as refused. Which of the two is not asserted (the existing rows already hold the tables to the code); what is asserted is that no canonical operator is missing from both.Deliberately one-directional. The refused table legitimately carries spellings the spec does not declare (
$regex, and the four lowercase aliases objectui#8568 retired); asserting the converse would delete it.Ablation — proof the closed blind spot would now catch a future omission
Both arms mutate on disk, prove the mutation landed by counting the target text before and after, run, then restore under a trap on absolute paths. Each restore is proven by
git diff HEADplus a blob-hash comparison againstHEAD, never by an exit code. Expectations were written down before each run. Tests resolve@object-ui/corethrough the root vitest alias topackages/core/src, so the arms act on source and no rebuild step can hide a mutation.Arm A — the blind-spot demonstration. Reproduce the exact pre-PR state of the two files the old pin read (converter and README restored to
4d65991c5), keep only the pin at HEAD.One failure, and it is the new case. The two OLD completeness cases — "carries a row for every key of the operator map" and "carries a row for every operator the unknown-operator error calls supported" — are among the 11 that passed, on the exact tree state that shipped the defect. That is the blind spot, executed rather than argued.
Arm B — the fix is load-bearing. From HEAD, delete the
operatorMaprow only; README, pin and acceptance test intact.Acceptance assertion
packages/core/src/utils/__tests__/filter-icontains-alignment-8976.test.tsasserts the harder claim: the spelling the adapters README prescribes must actually work. The prescriptions are read out of that page's "write instead" column and intersected with the spec'sFILTER_OPERATORS, so the population comes from hand-written prose and from the contract — never from the function under test. Three controls guard it against passing while witnessing nothing: the reader must find at least three prescriptions and must find$icontains;$icontainsmust still be canonical in both spec vocabularies; and a case-sensitive probe must select a strictly smaller row set than the case-insensitive one, so "both faces select rows" cannot pass on a matcher that ignores the fold.One control in the pin was wrong on first write and caught by its own run: it asserted the spec list differs from the unknown-operator message's enumeration. After this fix those two agree exactly, so the claim was false. It now rests on a structural fact instead —
operatorMapcannot carry$null/$exists, which pick their AST operator from the value — and the file records why the equality must not be pinned in either direction.Verification
packages/core+packages/data-objectstack, from repo ROOTicontains(scripts,plugin-list,fields,components)type-check(turbo, both packages)lint(turbo, both packages)no-explicit-anywarningscheck:control-bytescheck:changeset-presencecheck-governed-queue-guard --test(my 5 paths)check:doc-snippetscheck:doc-typescheck:doc-examplescheck:doc-fencescheck:readme-exportscheck:new-line-citationscensus:dollar-dialect-aliasescheck:doc-snippetsandcheck:doc-examplesfirst returned exit 2 — PREREQUISITE NOT MET, not a pass. Re-run green after the scoped build they name (turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter) --concurrency=2, 34 packages, 35 tasks successful), taken through the shared verify lock.NOT MEASURED: the full suites of
packages/plugin-list,packages/fieldsandpackages/components. A whole-package run of those four was started, exceeded the foreground budget, and I stopped it by recorded PID (exit 144 = my SIGTERM, not a test verdict). Narrowed to the four test files in them that actually referenceicontains, which passed. My diff touches no file in those packages; CI runs them in full.Scope
objectui#8770(unlowered TRUE-identity groups) also lands in this file. It ispm:queueand unassigned; its shape is untouched here and it queues behind this. Serial adjacency was re-derived against all 7 currently-open PRs by diffing each head against its merge-base: none touchespackages/core/src/utils/filter-converter.ts,packages/data-objectstack/README.mdor the pin.criteria_jsoninto this converter. The criteria store is not in this tree — the consumer-local caveat objectui#6839 established. What is proven is the acceptance-set disagreement and the producer arm.Assumptions falsified
Two premises in the card did not survive re-measurement on today's tree. Neither changes the direction.
icontains"is already an identity row inFILTER_OPERATOR_ALIASES". There is noFILTER_OPERATOR_ALIASESin@objectstack/spec— onlyVIEW_FILTER_OPERATOR_ALIASES, andicontainsdeliberately has no row there. The spec's own comment: "icontains(record:chatter.feed/record:discussion.feed宣称「same shape as record:activity」,但其中的过滤成员(types / limit / showCompleted / unifiedTimeline)在这条路径上被静默丢弃 #8934) deliberately has NO rows here". The conclusion survives by a different mechanism:icontainsis canonical, sonormalizeFilterOperatorreturns it unchanged and there is nothing to bridge. The identity row the card meant is this repo's, inpackages/data-objectstack/src/index.ts.git grep -n icontains -- packages/data-objectstack/"returns nothing" is now false —src/index.ts:126carriesicontains: 'icontains'. The narrow claim that matters, thatREADME.mdhas no$icontainsrow, held.Out of scope, filed as objectui#9001
convertFiltersToASThas no text-comparand guard, so{ name: { $icontains: '' } }lowers to['name', 'icontains', '']whileValueDataSourcerefuses it and the spec'sFILTER_TEXT_CASESdeclares itINVALID_FILTER.FILTER_OPERATOR_ALIASES['icontains']inpackages/data-objectstackis an identity row, so{ field, operator: 'icontains', value: '' }lowered to the wire with no converter involved. The sibling positive operators have the same gap ({ name: { $contains: '' } }lowers today and matches every row). What this PR adds is a second entrance. Whether the guard belongs in the producer, and whether it extends to the siblings, is a ruling this card did not make —ValueDataSourcedeliberately declined to widen by analogy, and objectui#8600 recorded the related enrolment question. Filed rather than folded.🤖 Generated with Claude Code
https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB