Skip to content

Commit 85d95e7

Browse files
os-zhuangclaude
andauthored
feat(spec): $icontains(ASCII 折叠域)+ $contains 族钉死大小写敏感 + $regex 退役指引表 (#5701) (#5752)
* feat(spec): declare $icontains (ASCII fold), pin the $contains family case-sensitive, retire $regex (#5701) The contract half of #4706's maintainer ruling (B). Declaration only: no live rejection, no driver code touched, every backend answers exactly what it answered before. - `$icontains` on StringOperatorSchema / FieldOperatorsSchema / Filter: contains, ignoring ASCII case (A-Z against a-z) and nothing else. The boundary is stated for authors — `café` does NOT match `CAFÉ` — because ASCII is the one fold all five backends can deliver: SQLite without ICU (so turso and sqlite-wasm too) folds ASCII only, and promising Unicode would repeat the defect this retires. - `$contains` / `$notContains` / `$startsWith` / `$endsWith` are declared CASE-SENSITIVE, superseding the recorded "Case sensitivity should be handled at backend level" (Prime Directive #13 — the old sentence is quoted in place so the reversal is findable from the sentence a reader remembers). What that non-guarantee bought, measured: three different answers across five backends, two of them inside driver-memory alone (query path folds full Unicode, the reference matcher is case-sensitive). - `RETIRED_FILTER_OPERATORS`: pure data, no behaviour. `$regex` and `$options` with prescriptions naming `$icontains`. Nothing rejects them here — the five existing refusal sites are wired to it by #5702, and #5710 must flip the one live producer (plugin-auth's ObjectQL adapter, on the authentication path) first or sign-in breaks. - `FILTER_TEXT_CASES` (`data/filter-text-conformance.ts`): the shared standard for case folding, literal comparands and the `$regex` refusal, with the `expectRejection` discriminant `filter-logic-conformance.ts` deliberately never grew. A sibling table rather than rows in that one, whose charter excludes both axes and warns against red rows for work nobody is dispatched to do. Registered in check:driver-conformance with one MEASURED DEBT row per driver pointing at #5702 — 25 covered / 5 DEBT / 0 exempt, main stays green. `$icontains` is deliberately NOT in `FILTER_OPERATORS` yet. That array is a runtime allowlist, not a word list: driver-memory's shape gate derives its ACCEPTED set from it while its matcher's `default:` arm breaks. Measured by adding it early and rebuilding — the gate stopped refusing and `match({name:'zzz'}, {name:{$icontains:'acme'}})` returned true, i.e. the predicate was silently dropped and every row matched. A dropped predicate widens rather than narrows, which on an RLS read scope is a permission bypass (#3948). It joins the array in the PR that implements it; `filter-operator-vocabulary.test.ts` pins the difference at exactly {$icontains} so neither adding a second staged operator nor clearing this one can happen silently. Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude <noreply@anthropic.com> * chore(spec): regenerate api-surface + authorable-surface after merging main The merge driver (`merge=os-regen`, AGENTS.md §11) deferred both artifacts rather than text-merging them, and `pre-commit` collects that debt. Regenerated from the MERGED source, so the union includes #5721's ActionSession entries alongside this branch's $icontains / FILTER_TEXT additions. `authorable-surface.base.json` is deliberately NOT bumped: it is the deletion gate's anchor, main only moves it when a key is REMOVED, and this branch only adds. `check:authorable-surface` confirms it — 'trails the merge base by 5 key(s) — expected right after a surface change lands'. Claude-Session: https://claude.ai/code/session_018fxLGQdatPbBUvCgiVxg6D Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9427c67 commit 85d95e7

11 files changed

Lines changed: 1128 additions & 24 deletions
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): `$icontains` 入算子词表(ASCII 折叠域)、`$contains` 族钉死为大小写敏感、`$regex` 退役指引表(#5701)
6+
7+
#4706 维护者裁决 B 案的**契约半边**。本次只改声明,不改任何运行时行为:
8+
五个后端今天怎么答,落地后还怎么答。驱动侧下译归 #5702,`$regex` 唯一活生产者的
9+
翻转归 #5710
10+
11+
## 1. 新增 `$icontains` —— 折叠域是 ASCII,不是 Unicode
12+
13+
`StringOperatorSchema` / `FieldOperatorsSchema` / `Filter<T>` 新增 `$icontains`:
14+
忽略大小写的子串包含,**只折叠 `A-Z``a-z`**
15+
16+
```ts
17+
{ name: { $contains: 'acme' } } // 大小写敏感:匹配 "acme corp",不匹配 "ACME Corp"
18+
{ name: { $icontains: 'acme' } } // 折叠 ASCII 大小写:两者都匹配
19+
```
20+
21+
**边界必须说清楚:`café` 不匹配 `CAFÉ`** ASCII 以外一律按字面比较。
22+
选 ASCII 而非全 Unicode,是因为它是五个后端唯一都能真兑现的折叠域 ——
23+
无 ICU 的 SQLite(`driver-sqlite-wasm` / `driver-turso` 跑的就是它)的
24+
`LOWER()``LIKE` 只折叠 ASCII,承诺 Unicode 等于承诺三个后端做不到的事,
25+
那正是 #4706 用来否决「五后端真正则」的同一条判据。
26+
27+
比较值一律**字面量**:`%` / `_` 不是 LIKE 通配符,`.` / `*` 不是正则元字符 ——
28+
`{ name: { $icontains: 'a.b' } }` 匹配 `a.b`,不匹配 `axb`
29+
30+
## 2. `$contains` / `$notContains` / `$startsWith` / `$endsWith` = 大小写敏感
31+
32+
这条**取代**`filter.zod.ts` 里那句已记录的声明(Prime Directive #13,
33+
取代记录写在原处):
34+
35+
> Note: Case sensitivity should be handled at backend level.
36+
37+
那不是漏写,是写下来的「不保证」,实测代价是同一个算子三种答案:
38+
`driver-memory` 的参考匹配器与 `formula` 大小写敏感,`driver-mongodb` 硬编码
39+
`$options: 'i'` 全 Unicode 不敏感,SQL 家族看方言(SQLite 折叠 ASCII、
40+
Postgres 不折叠、MySQL 看 collation)。作者无法从算子名判断自己拿到哪一种。
41+
42+
**迁移**:此前依赖某后端偶然大小写不敏感的 `$contains` 查询,应改写为
43+
`$icontains`。行为在 #5702 落地前不变,所以这是一次可以提前做的改写,不是断裂。
44+
45+
## 3. `$regex` / `$options` 退役 —— 指引表 `RETIRED_FILTER_OPERATORS`
46+
47+
`$regex` 从来不在 `FILTER_OPERATORS` 里,却有一个生产者、四个消费者,而且各读各的:
48+
`driver-sql` 编译成 LIKE 转义后的子串匹配(`a.b` 只匹配字面 `a.b`),
49+
`driver-memory` 当真正则求值(`a.b` 还匹配 `axb`;模式非法则被 `catch` 成零行,
50+
无声)。真正则在五后端不可实现 —— `driver-turso` 的 remote 线协议无法注册
51+
SQLite `REGEXP` 函数。
52+
53+
新增 `RETIRED_FILTER_OPERATORS`(**纯数据**,不引入任何拒收行为),
54+
给出逐条处方,供五个既有拒收点引用同一句话:
55+
56+
| 原写法 | 改写为 |
57+
|:---|:---|
58+
| `{ name: { $regex: 'acme' } }` | `{ name: { $icontains: 'acme' } }` |
59+
| `{ name: { $regex: 'acme', $options: 'i' } }` | `{ name: { $icontains: 'acme' } }` |
60+
| `{ name: { $regex: '^acme' } }` | `{ name: { $startsWith: 'acme' } }` |
61+
| `{ name: { $regex: 'acme$' } }` | `{ name: { $endsWith: 'acme' } }` |
62+
63+
真正需要正则的查询没有 filter 层替代物:用已声明算子收窄,再在应用代码里匹配。
64+
65+
## 4. 新姊妹 case-set `FILTER_TEXT_CASES`
66+
67+
`filter-text-conformance.ts` —— 大小写折叠、字面比较值、`$regex` 拒收的共享标准,
68+
`expectRejection` 判别式(`FILTER_LOGIC_CASES` 刻意没长出来的那个形状,
69+
其表头三条章程原样保留)。五个 driver 各记一条**实测** DEBT 台账,指向 #5702
70+
71+
## 什么**没有**
72+
73+
`$icontains` 暂不进 `FILTER_OPERATORS`。那个数组不是词表而是运行时白名单 ——
74+
`driver-memory``SUPPORTED_FIELD_OPERATORS` 由它派生。实测:提前把
75+
`$icontains` 放进去,该驱动的形状门禁就不再拒收它,而匹配器没有对应分支,
76+
`match({name:'zzz'}, {name:{$icontains:'acme'}})` 返回 `true` —— 谓词被静默丢弃,
77+
全表命中。谓词被丢不是收窄而是**放大**,在 RLS 读作用域上是越权读(#3948)。
78+
所以它随 #5702 的实现一起入列,`filter-operator-vocabulary.test.ts` 把这处差异
79+
钉死为恰好 `{ $icontains }`,清偿时该断言会红,提醒作者一并删掉过渡说明。

content/docs/protocol/objectql/query-syntax.mdx

Lines changed: 90 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -237,14 +237,82 @@ const query: QueryAST = {
237237
| `$lte` | Less or equal | `{ discount: { $lte: 20 } }` |
238238
| `$in` | In list | `{ stage: { $in: ['proposal', 'negotiation'] } }` |
239239
| `$nin` | Not in list | `{ status: { $nin: ['deleted', 'archived'] } }` |
240-
| `$contains` | String contains | `{ name: { $contains: 'Inc' } }` |
241-
| `$notContains` | String does not contain | `{ name: { $notContains: 'test' } }` |
242-
| `$startsWith` | String starts with | `{ email: { $startsWith: 'admin' } }` |
243-
| `$endsWith` | String ends with | `{ domain: { $endsWith: '.com' } }` |
240+
| `$contains` | String contains, **case-sensitive** | `{ name: { $contains: 'Inc' } }` |
241+
| `$icontains` | String contains, **ignoring ASCII case** | `{ name: { $icontains: 'inc' } }` |
242+
| `$notContains` | String does not contain, **case-sensitive** | `{ name: { $notContains: 'test' } }` |
243+
| `$startsWith` | String starts with, **case-sensitive** | `{ email: { $startsWith: 'admin' } }` |
244+
| `$endsWith` | String ends with, **case-sensitive** | `{ domain: { $endsWith: '.com' } }` |
244245
| `$between` | Range (inclusive) | `{ close_date: { $between: ['2024-01-01', '2024-12-31'] } }` |
245246
| `$null` | Null check | `{ manager_id: { $null: true } }` / `{ phone: { $null: false } }` |
246247
| `$exists` | Field exists (NoSQL) | `{ metadata: { $exists: true } }` |
247248

249+
### Case Sensitivity
250+
251+
The string operators compare **case-sensitively**. `$icontains` is the one that does
252+
not, and the case it ignores is **ASCII case only**`A-Z` against `a-z`, and nothing
253+
else.
254+
255+
```typescript
256+
// Case-sensitive: matches "acme corp", NOT "ACME Corp"
257+
{ name: { $contains: 'acme' } }
258+
259+
// ASCII case-insensitive: matches BOTH "acme corp" and "ACME Corp"
260+
{ name: { $icontains: 'acme' } }
261+
```
262+
263+
<Callout type="warn">
264+
**`café` does not match `CAFÉ`.** Outside `A-Z`/`a-z`, `$icontains` compares
265+
literally — accented Latin, Cyrillic, Greek and every other script are matched
266+
exactly as written. If your users search non-ASCII text, `$icontains` is not an
267+
accent- or case-blind search, and treating it as one will silently return fewer
268+
rows than expected.
269+
270+
The boundary is ASCII because that is the only fold every backend can actually
271+
deliver. SQLite compiled without ICU — which is what `driver-sqlite-wasm` and
272+
`driver-turso` run on — folds ASCII only in both `LOWER()` and `LIKE`, so a
273+
Unicode promise here would be a guarantee three of the five backends could not
274+
keep. See [#4706](https://github.com/objectstack-ai/objectstack/issues/4706).
275+
</Callout>
276+
277+
The comparand is always matched **literally**. `%` and `_` are ordinary characters,
278+
not `LIKE` wildcards, and `.` / `*` / `+` are ordinary characters, not regex
279+
metacharacters — `{ name: { $icontains: 'a.b' } }` matches `a.b` and not `axb`.
280+
281+
<Callout type="info">
282+
**Status:** the case rules above are the protocol's declaration as of
283+
`@objectstack/spec` 18. The backend lowerings that deliver them — making SQLite's
284+
and turso's `LIKE` case-exact, dropping MongoDB's hardcoded `$options: 'i'`, and
285+
implementing `$icontains` everywhere — are tracked by
286+
[#5702](https://github.com/objectstack-ai/objectstack/issues/5702). Until it
287+
lands, a backend that has not been aligned refuses `$icontains` outright rather
288+
than answering it approximately, and `$contains` still follows its dialect. The
289+
shared standard both halves are measured against is `FILTER_TEXT_CASES`
290+
(`@objectstack/spec/data`).
291+
</Callout>
292+
293+
### `$regex` — removed
294+
295+
`$regex` (and its `$options` companion) was never a declared operator and is
296+
**retired** ([#4706](https://github.com/objectstack-ai/objectstack/issues/4706)). It
297+
could not mean one thing across the backends: `driver-sql` compiled it to a
298+
LIKE-escaped substring match, so `a.b` matched only the literal `a.b`, while
299+
`driver-memory` evaluated it as a real `RegExp`, so the same filter also matched
300+
`axb` — and an invalid pattern was caught and answered zero rows, in silence. A real
301+
regex is not implementable on all five backends: `driver-turso`'s remote transport
302+
speaks a wire protocol with no way to register a SQLite `REGEXP` function.
303+
304+
| Instead of | Write |
305+
|:---|:---|
306+
| `{ name: { $regex: 'acme' } }` | `{ name: { $icontains: 'acme' } }` |
307+
| `{ name: { $regex: 'acme', $options: 'i' } }` | `{ name: { $icontains: 'acme' } }` |
308+
| `{ name: { $regex: '^acme' } }` | `{ name: { $startsWith: 'acme' } }` |
309+
| `{ name: { $regex: 'acme$' } }` | `{ name: { $endsWith: 'acme' } }` |
310+
311+
A pattern that genuinely needs a regular expression has no filter-level
312+
replacement — narrow the query with the declared operators and match in application
313+
code. The prescriptions above are declared as data in `RETIRED_FILTER_OPERATORS`
314+
(`@objectstack/spec/data`), so every backend's refusal quotes the same sentence.
315+
248316
### Multiple Conditions (Implicit AND)
249317

250318
Multiple keys in `where` are combined with **AND** logic:
@@ -783,11 +851,24 @@ search — and over the REST/protocol ingress it is `400 INVALID_FIELD` outright
783851
because the engine-side intersection alone used to drop the unknown name and fall back to
784852
scanning the full searchable set. Internal callers reaching `engine.find()` directly keep
785853
the tolerant intersection. Multiple whitespace-separated terms are AND-ed and
786-
fields are OR-ed. Case sensitivity is the **driver's**, not the expansion's: the
787-
expansion emits a plain `$contains`, which `SqlDriver` compiles to a parameterised
788-
`LIKE '%…%'` with no case folding — so the dialect's own `LIKE`/collation rules decide —
789-
while the in-memory driver matches with a case-insensitive regex. Only `select` /
790-
`status` option *labels* are matched case-insensitively by the expansion itself.
854+
fields are OR-ed. Case sensitivity comes from the operator the expansion emits, not
855+
from the expansion: it emits a plain `$contains`, which is **case-sensitive** by the
856+
rule in [Case Sensitivity](#case-sensitivity) above. Note what that means for search —
857+
a user typing `acme` does not find `ACME Corp`. Only `select` / `status` option
858+
*labels* are matched case-insensitively by the expansion itself.
859+
860+
<Callout type="warn">
861+
**Measured today, and it does not match that rule yet.** The `$contains` alignment
862+
is [#5702](https://github.com/objectstack-ai/objectstack/issues/5702), so until it
863+
lands the answer is still the driver's: `SqlDriver` compiles a parameterised
864+
`LIKE '%…%'` and the dialect decides (SQLite folds ASCII, Postgres does not),
865+
`driver-mongodb` folds the full Unicode range through a hardcoded `$options: 'i'`,
866+
and `driver-memory`'s query path matches with a case-insensitive regex. Which
867+
driver you run therefore still changes which rows a search returns. Whether the
868+
expansion should emit `$icontains` instead of `$contains` — i.e. whether search is
869+
case-insensitive by definition — is a separate question that rides with that issue,
870+
because it can only be answered once both operators mean one thing everywhere.
871+
</Callout>
791872
`fuzzy`, `boost`, `operator`, `minScore`, `language`, and `highlight` carry
792873
`[EXPERIMENTAL — not enforced]` markers (#4286): the schema accepts them, the
793874
expansion ignores them.

content/docs/references/data/filter.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ Type: `[FilterArray](#filterarray)[]`
157157
| **$notContains** | `string` | optional | |
158158
| **$startsWith** | `string` | optional | |
159159
| **$endsWith** | `string` | optional | |
160+
| **$icontains** | `string` | optional | Contains substring, ignoring case — but ONLY ASCII case (A-Z against a-z). Every other character compares literally, so "café" does NOT match "CAFÉ" and "москва" does not match "МОСКВА". The domain is ASCII because that is the one fold all five backends can deliver: SQLite (and therefore turso and sqlite-wasm) folds ASCII only, so a Unicode promise here would be a guarantee three of the five could not keep. The comparand is matched LITERALLY — "%", "_" and regex metacharacters are ordinary characters, not wildcards. Case-SENSITIVE containment is $contains. [#5701: declared by the protocol; the driver lowerings land with #5702.] |
160161

161162

162163
---

packages/spec/api-surface.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,8 @@
365365
"FILTER_LOGIC_CASES (const)",
366366
"FILTER_LOGIC_ROWS (const)",
367367
"FILTER_OPERATORS (const)",
368+
"FILTER_TEXT_CASES (const)",
369+
"FILTER_TEXT_ROWS (const)",
368370
"FeedFilterMode (type)",
369371
"FeedItemType (type)",
370372
"Field (type)",
@@ -398,6 +400,10 @@
398400
"FilterLogicCase (interface)",
399401
"FilterLogicRow (interface)",
400402
"FilterOperatorKey (type)",
403+
"FilterTextCase (type)",
404+
"FilterTextRejectionCase (interface)",
405+
"FilterTextRow (interface)",
406+
"FilterTextRowsCase (interface)",
401407
"FormatValidation (type)",
402408
"FormatValidationSchema (const)",
403409
"FullTextSearch (type)",
@@ -532,6 +538,7 @@
532538
"READ_ONLY_BELONGS_ON_DATASOURCE (const)",
533539
"RECORD_SURFACE_PAGE_THRESHOLD (const)",
534540
"REFERENCE_VALUE_TYPES (const)",
541+
"RETIRED_FILTER_OPERATORS (const)",
535542
"RPC_QUERY_ALIAS_SLOTS (const)",
536543
"RangeOperatorSchema (const)",
537544
"RecordFlow (type)",
@@ -552,6 +559,7 @@
552559
"ResolveApiOptions (interface)",
553560
"ResolveRecordDisplayNameOptions (interface)",
554561
"ResolvedHook (type)",
562+
"RetiredFilterOperatorGuidance (interface)",
555563
"RowCrudActionOverride (type)",
556564
"RowCrudActionOverrideInput (type)",
557565
"RowCrudActionOverrideSchema (const)",

packages/spec/authorable-surface.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3815,6 +3815,7 @@
38153815
"data/StateMachineValidation:type",
38163816
"data/StringOperator:$contains",
38173817
"data/StringOperator:$endsWith",
3818+
"data/StringOperator:$icontains",
38183819
"data/StringOperator:$notContains",
38193820
"data/StringOperator:$startsWith",
38203821
"data/TenancyConfig:enabled",
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The filter operator vocabulary has TWO surfaces, and #5701 made them
5+
* temporarily disagree on purpose. This file is what stops that from being
6+
* silent.
7+
*
8+
* - **Declaration**: `FieldOperatorsSchema` / `StringOperatorSchema` /
9+
* `Filter<T>` — what an author may write and what `tsc` accepts. Nothing
10+
* derives a runtime allowlist from these (verified: `NormalizedFilterSchema`
11+
* is their only consumer and nothing parses a filter through it at runtime).
12+
* - **Enforcement**: `FILTER_OPERATORS` — the array `driver-memory`'s shape
13+
* gate and `service-analytics`' coverage test DERIVE from. An entry here is a
14+
* claim that backends implement the operator.
15+
*
16+
* `$icontains` is declared and not yet enforced (#5701 is the contract half of
17+
* the #4706 ruling; #5702 writes the lowerings). Measured on the branch that
18+
* added it to `FILTER_OPERATORS` early: driver-memory's gate stopped refusing
19+
* it and `match({ name: 'zzz' }, { name: { $icontains: 'acme' } })` returned
20+
* `true` — the predicate silently dropped, every row matched. That is the
21+
* widening #3948 is about, so the staging is not a stylistic choice.
22+
*
23+
* The pin below is deliberately an EQUALITY, not a subset check, so it fails in
24+
* both directions: a second staged operator added without recording it fails
25+
* here, and so does clearing `$icontains` in #5702 — which is the point. The
26+
* failure message is the instruction.
27+
*/
28+
29+
import { describe, it, expect } from 'vitest';
30+
import {
31+
FieldOperatorsSchema,
32+
StringOperatorSchema,
33+
FILTER_OPERATORS,
34+
LOGICAL_OPERATORS,
35+
RETIRED_FILTER_OPERATORS,
36+
} from './filter.zod';
37+
38+
const declaredKeys = () => Object.keys(FieldOperatorsSchema.shape).sort();
39+
40+
describe('the declaration surface and the enforcement surface', () => {
41+
it('differ by EXACTLY the operators staged ahead of their backends', () => {
42+
const declared = new Set(declaredKeys());
43+
const enforced = new Set<string>(FILTER_OPERATORS);
44+
const stagedOnly = [...declared].filter((op) => !enforced.has(op)).sort();
45+
46+
expect(
47+
stagedOnly,
48+
'FieldOperatorsSchema and FILTER_OPERATORS differ by something other than the recorded '
49+
+ 'staging. If you are ADDING an operator: declare it in FieldOperatorsSchema only, and '
50+
+ 'add it here plus a note on FILTER_OPERATORS saying which issue implements it — an '
51+
+ 'operator in FILTER_OPERATORS with no backend arm makes driver-memory accept it and '
52+
+ "silently DROP the predicate (measured, #5701). If you are CLEARING one because you "
53+
+ 'just implemented it (#5702): remove it from this list AND delete the staging paragraph '
54+
+ 'on FILTER_OPERATORS, which is now describing something that is no longer true.',
55+
).toEqual(['$icontains']);
56+
});
57+
58+
it('has no operator enforced that is not declared', () => {
59+
const declared = new Set(declaredKeys());
60+
const undeclared = FILTER_OPERATORS.filter((op) => !declared.has(op));
61+
expect(
62+
undeclared,
63+
'FILTER_OPERATORS demands backends implement an operator FieldOperatorsSchema does not '
64+
+ 'declare, so an author cannot write it and `tsc` will reject it. This direction is '
65+
+ 'never staging — it is a drift.',
66+
).toEqual([]);
67+
});
68+
69+
it('declares $icontains on the string operator schema too', () => {
70+
expect(Object.keys(StringOperatorSchema.shape)).toContain('$icontains');
71+
});
72+
73+
it('accepts a declared $icontains rather than stripping it', () => {
74+
const parsed = FieldOperatorsSchema.parse({ $icontains: 'acme' });
75+
expect(parsed).toEqual({ $icontains: 'acme' });
76+
});
77+
78+
it('rejects a non-string $icontains comparand at the schema', () => {
79+
expect(() => FieldOperatorsSchema.parse({ $icontains: 42 })).toThrow();
80+
});
81+
});
82+
83+
describe('RETIRED_FILTER_OPERATORS', () => {
84+
const entries = Object.entries(RETIRED_FILTER_OPERATORS);
85+
86+
it('covers the operators #4706 retired', () => {
87+
expect(Object.keys(RETIRED_FILTER_OPERATORS).sort()).toEqual(['$options', '$regex']);
88+
});
89+
90+
it('never points at an operator the protocol no longer has', () => {
91+
// The `authoring-key-lint.test.ts` rule, applied to operators: a guidance
92+
// table whose prescriptions name something undeclared is advice that sends
93+
// an author into a second error. Note the check is against the DECLARATION
94+
// surface, because `$icontains` is deliberately not in FILTER_OPERATORS yet.
95+
const declared = new Set(declaredKeys());
96+
for (const [op, guidance] of entries) {
97+
if (guidance.to === undefined) continue;
98+
expect(declared.has(guidance.to), `${op} prescribes ${guidance.to}, which is not declared`).toBe(true);
99+
}
100+
});
101+
102+
it('states the replacement inside the prescription, not only in the `to` field', () => {
103+
// A refusal prints `why`. If the replacement lives only in a sibling field
104+
// the caller may not render, the error tells the author they are wrong
105+
// without telling them what to write — which is the failure the tombstone
106+
// convention exists to prevent (AGENTS.md, Post-Task Checklist step 3).
107+
for (const [op, guidance] of entries) {
108+
if (guidance.to === undefined) continue;
109+
expect(guidance.why, `${op}'s prescription never names ${guidance.to}`).toContain(guidance.to);
110+
}
111+
});
112+
113+
it('names the retired operator itself, so a refusal can quote one string', () => {
114+
for (const [op, guidance] of entries) {
115+
expect(guidance.why, `${op}'s prescription never names ${op}`).toContain(op);
116+
}
117+
});
118+
119+
it('is not simultaneously declared anywhere — retired means gone', () => {
120+
const declared = new Set([...declaredKeys(), ...FILTER_OPERATORS, ...LOGICAL_OPERATORS]);
121+
for (const op of Object.keys(RETIRED_FILTER_OPERATORS)) {
122+
expect(declared.has(op), `${op} is both retired and declared`).toBe(false);
123+
}
124+
});
125+
126+
it('is frozen — a consumer cannot mutate the shared prescriptions', () => {
127+
expect(Object.isFrozen(RETIRED_FILTER_OPERATORS)).toBe(true);
128+
});
129+
});

0 commit comments

Comments
 (0)