Skip to content

spec: $regex 按 ADR-0049 退役 + $icontains 入算子词表与 FILTER_LOGIC_CASES(#4706 裁决 B 案 · 契约半边,先行) #5701

Closed

Description

@os-zhuang

Part of #4706(维护者 2026-08-06 裁决 B 案,裁决与三轴分析见该单 2026-08-06 两条评论)。契约半边,先行;驱动半边 Blocked-by 本单。

裁定摘录

$regex 退役:查询校验期响亮拒收,拒收信息点名替代物($icontains);同批新增 $icontains(大小写不敏感包含;$istartsWith / $iendsWith 仅当与既有算子族对称性成立时一并加,不强求)。排除项:五后端真正则(turso remote 线协议无法注册 REGEXP,结构性不可行)、维持现状(名实不符)。

本单范围(packages/spec)

  1. 词表:filter 算子 zod schema / 类型中移除 $regex(含 $options,它只被 $regex 消费 — 实施时核实),新增 $icontains;按 ADR-0049 / ADR-0087 退役路线处理注册表与转换记录(适用面按 spec-property-retirement playbook 判断 — 查询算子词表若不属 authorable-surface 轨道,如实记录即可,不硬套)。
  2. case-set:FILTER_LOGIC_CASES 新增 $icontains 用例 — 至少覆盖:双向大小写折叠命中、% / _ / 正则元字符作字面量处理(转义纪律沿 applyLike,见 driver-sql 的 LIKE 转义(自标 P0 的过滤器旁路)只在 SQLite 上验证过 —— live PG + MySQL 矩阵已存在、testkit 也已存在,LIKE 族一个用例都没接进去 #5589 的方言矩阵)、空串/非字符串比较值的拒收形态(循 ADR-0112 信封惯例);外加 $regex 的拒收 pin(校验期报错,信息含替代物指名)。case-set 落地即由 5×5 conformance 矩阵在各 driver 侧转成待实现红/DEBT — 这是设计内的推进机制。
  3. 全仓 $regex 使用面清点:examples / 测试 / 文档中现存用法随退役同 PR 翻转(裁决实施的 pin sweep 纪律:一轮翻完,翻后的断言仍承重)。

边界

归属:packages/spec → domain:spec 座位(shared contract surfaces have one owner);domain:* 标签待分诊座位补。

Activity

  1. self-assigned this
    on Aug 6, 2026
  2. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    PM 认领(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06,维护者插队令,P0 第 6 席)


    Generated by Claude Code

  3. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    开发前置核验报告(os-dev,claude/issue-5701-regex-retire-icontains 工作树,基于 origin/main@eb26126d5)。结论:不提 PR,回报 needs_decision。

    理由分两层:词表半边的前提已失效(可直接销案),case-set 与拒收半边则撞上三处必须由维护者拍板的契约决定 —— 每一处我一旦动手写,就等于替契约做了决定。以下全部为实测,附 file:line。


    一、前提核验:packages/spec 里没有 $regex,也没有 $options

    本单第 1 点(「filter 算子 zod schema / 类型中移除 $regex(含 $options)」)在 origin/main 上无物可移。逐面核过:

    面 位置 $regex / $options
    FILTER_OPERATORS packages/spec/src/data/filter.zod.ts:957-968 15 个算子,无
    FieldOperatorsSchema filter.zod.ts:178-206 无
    StringOperatorSchema filter.zod.ts:141-153 无(仅注释里出现 MongoDB: $regex,是 mongo 侧译码说明)
    AST_OPERATOR_MAP / VALID_AST_OPERATORS filter.zod.ts:505-586 无
    VIEW_FILTER_OPERATORS / VIEW_FILTER_OPERATOR_ALIASES ui/view.zod.ts:173-214 无
    FilterConditionSchema filter.zod.ts:320-328 z.record(z.string(), z.unknown()) —— 不校验任何算子名

    唯一曾声明过 regex 算子的 spec 面是 api/websocket.zod.ts 的 FilterOperator 枚举,早已被 objectui#2945 删除,墓碑注释(websocket.zod.ts:56-78)还专门写了「将来长出真过滤应 lower 到 AST_OPERATOR_MAP,不要再自建词表」。

    $options 的测量(本单要求「实施时核实」):在 ObjectQL 词表意义上,它确实只作 $regex 的 flags 修饰符存在 —— objectql/having-filter.ts:110 直接 continue 跳过再喂给 $regex;driver-memory/filter-refusal.ts:421-435 专门为「有 $options 无 $regex」写了悬空错误。但同样地,它也不在 spec 里,所以一样无物可移。

    $regex 今天的真实落点是「一个生产者 + 四个消费者」,全在 spec 之外:

    • 生产者(唯一,全仓实测):packages/plugins/plugin-auth/src/objectql-adapter.ts:137 —— better-auth 的 contains 算子被译成 { $regex: condition.value }。
    • 消费者:driver-sql/src/sql-driver.ts:6511、driver-turso/src/remote-transport.ts:58,1258 + turso-driver.ts:689、driver-memory/src/filter-refusal.ts:171 + memory-matcher.ts:209、objectql/src/having-filter.ts:37,140。

    这四处消费者的代码注释已经写明它们是为那一个生产者而存在的。driver-memory/src/filter-refusal.ts:154-158 原话:

    $regex —— not in FILTER_OPERATORS, but really produced: plugin-auth's ObjectQL adapter emits { field: { $regex: value } } for a contains search. ... Refusing it here would break a live producer.

    这直接给出了一条裁决没点名的时序约束:任何「校验期响亮拒收 $regex」在 objectql-adapter.ts:137 翻转之前落地,都会打断 better-auth 的认证路径。 该文件既不属契约半边也不属驱动半边,是第三块,须先排。

    硬约束核验(PM 认领里点名的 conversions/registry.ts):不触发。 退役的不是 authorable key(spec 从未声明该键),ADR-0087 D2 没有对象可登记;且登记处里唯一与 filter 相关的条目是 flow-node-crud-filter-alias(conversions/registry.ts:142),它改写的是节点 config 上的 filters -> filter 键名,并不下潜进 filter body 改算子名 —— 存量 view/flow 里内嵌的 $regex 无法、也从未由登记处触及。本单不需要动 conversions/registry.ts,#4923 无冲突。


    二、实测:$icontains 的方言坑与 A 案的 turso 坑是同一种,只是可解

    裁决把 B 案排在 A 案之前的理由之一是「$icontains LIKE/ILIKE 可实现,无方言正则坑」。这句在大小写折叠的「域」上不成立,已实测。

    在本仓 better-sqlite3@13.0.2(SQLite 3.53.4)上跑 LOWER(col) LIKE LOWER(?)(即 $icontains 最自然的 SQL 下译),与 JS toLowerCase().includes()(即 driver-memory / formula 的下译)对照:

    探针 SQLite LOWER(col) LIKE LOWER(?) JS toLowerCase().includes()
    acme(ASCII) ["ascii-up","ascii-lo"] ["ascii-up","ascii-lo"]
    café ["accent-lo"] ["accent-up","accent-lo"]
    москва ["cyr-lo"] ["cyr-up","cyr-lo"]
    σιγμα ["gr-lo"] ["gr-up","gr-lo"]

    SQLite 的 LOWER() / LIKE 只折叠 ASCII(无 ICU 编译时),JS 折叠全 Unicode,mongo 的 $options: 'i' 亦全 Unicode,Postgres ILIKE 依 collation/ICU。也就是说:「同一条 $icontains 查询在五后端返回同一结果集」在非 ASCII 上结构性不成立,除非契约明确把折叠域限死。这与 turso remote 注册不了 REGEXP 是同一类不可行性 —— 区别在于它有解(把域声明为 ASCII),但那个解要付出可见的能力代价,不能由我在写 fixture 时默默选定。

    而 case-set 的 expected: [...] 正是这个选择本身。 我无法在不回答「折叠域是什么」的前提下写出本单第 2 点要求的「双向大小写折叠命中」用例。


    三、实测:$contains 家族今天已经在同一根轴上分叉,而且是 spec 声明放弃的

    后端 $contains 的大小写行为 证据
    driver-memory 大小写敏感(String.includes) memory-matcher.ts:182
    formula 大小写敏感 matches-filter.ts:182
    driver-sql / driver-sqlite-wasm LIKE '%v%' —— SQLite 上 ASCII 不敏感、Postgres 上敏感、MySQL 看 collation sql-driver.ts:6292-6304(applyLike)
    driver-turso 同 SQLite 继承 SqlDriver + remote-transport.ts
    driver-mongodb 大小写不敏感(全 Unicode) mongodb-filter.ts:504-506,$options: 'i' 硬写

    而 spec 对此的表态是 filter.zod.ts:139 的一行注释:

    Note: Case sensitivity should be handled at backend level.

    这不是漏写,是写下来的「不保证」。 它带来两个后果:

    1. 在 mongodb、SQLite、turso 三个后端上,$icontains 与今天的 $contains 行为完全相同 —— 新算子在 60% 的矩阵上不提供任何新能力,只是给既有的偶然行为补了个名字;真正拿到新能力的只有 Postgres 与两个 JS 面。
    2. 新增 $icontains 等于推翻 filter.zod.ts:139 这条已记录的契约表态。按 AGENTS.md Prime Directive [WIP] Add Chinese version of the documentation #13(已记录的决定在被取代前有效,推翻本身是一项决定),这需要一条明写的取代记录,而不是在旁边悄悄加一个算子。

    四、FILTER_LOGIC_CASES 落点:本单第 2 点与该文件写下的三条规矩正面冲突

    packages/spec/src/data/filter-logic-conformance.ts 的表头(第 37-99 行)自己规定:

    1. 范围(第 47-52 行):「Nothing here exercises null handling, dates, numeric coercion, LIKE escaping, or case sensitivity —— those legitimately differ between a SQL engine and a JS matcher, and folding them in would make the table unpassable rather than more useful. Keep it that way」。本单第 2 点要求加的正是这两条被点名排除的轴。
    2. 时机(第 54-62 行):「a red row here does not enforce a ruling, it just turns another lane's unfinished work into this table's failure ... Add the rows in the PR that closes the gap, not before.」这与派发口径「红/DEBT 是设计内推进机制」直接相反 —— 而且是这张表在两次实践后自己写下的。
    3. 形状(第 85-99 行,{ field: {} }(零个操作符的字段约束)在同仓有三个答案:driver-sql 组合子内 TRUE、顶层抛 INVALID_FILTER、formula/driver-memory FALSE #5240 家族):「FilterLogicCase has no way to spell "this filter must be REJECTED" ... Enrolling this case needs the shape extended first (an expectRejection discriminant, or a sibling table) —— deliberately not invented here」。本单要求的 $regex 拒收 pin 需要的正是这个被刻意推迟的形状扩展。

    补充一条机制事实:conformance 门禁(scripts/check-driver-conformance.mjs)按 import 覆盖判定,不看断言。所以往现有 FILTER_LOGIC_CASES 加用例,五个 driver 套件会直接判红(不是 DEBT)—— 且 #5702 已写明 driver-memory / driver-mongodb 属 #5499 冻结族、$icontains 实现「默认挂起」,即那两格的红没有人被派去清。而新增同级 case-set 则可以用该门禁自带的 DEBT 台账机制记五条实测欠条(temporal-conformance.ts / pagination-conformance.ts 已是同级 case-set 的先例),既机械地绑住每个 driver,又不让 main 变红。


    五、待裁决的四个问题(附建议)

    Q1 —— $icontains 的大小写折叠域?

    • A. ASCII-only([A-Za-z]),明写进 .describe() 与文档。五后端都能真交付,「同查询同结果集」是事实。代价必须写明:café 匹配不到 CAFÉ,中文/欧洲语系应用要知道这条边界。
    • B. 全 Unicode。memory / formula / mongo / Postgres(ICU)可交付,SQLite 与 turso remote 不可(实测见上)—— 与被排除的 A 案是同一种「承诺 + 永久豁免」。
    • 建议 A。 长远合理性:它是唯一让承诺等于事实的选项,而 B 复现的正是维护者已用来否决 A 案的那条判据。防 AI 写错:一个域被明写的算子,AI 写出来在五个后端得到同一答案;一个「基本一致但有豁免」的算子,是 AI 批量错误被掩盖的温床。

    Q2 —— $contains / $startsWith / $endsWith 的大小写语义要不要同批钉死?filter.zod.ts:139 的「backend level」表态要不要取代?

    • A. 同批钉死为大小写敏感(JS 读法,memory / formula / PG 已如此;SQLite/turso 需把 LIKE 改为大小写精确 —— GLOB / instr() / PRAGMA case_sensitive_like 均可;mongo 需去掉那个偶然的 $options: 'i'),$icontains 才是它有意义的折叠孪生。
    • B. 只加 $icontains,$contains 维持「backend level」不保证。代价:三个后端上两个算子同义,作者无法从名字判断自己拿到的是哪种;$regex on driver-sql is not a regex — it compiles to a substring LIKE, so it both over-matches and silently matches nothing #4706 的分叉在 $contains 上原样留存。
    • 建议 A。 长远合理性:B 让新算子建在一条明写的「不保证」之上,是名实不符的第二例。防 AI 写错:A 之后「大小写敏感 = $contains,不敏感 = $icontains」是可从名字读出的全局规则;B 之下 AI 无从判断。注意 A 会扩大驱动半边的工作量(五后端各改一处 $contains 下译),请在排期时计入。

    Q3 —— $regex 的「校验期响亮拒收」落在哪一层?顺序如何?

    • A. 收紧 FilterConditionSchema 成封闭算子词表(spec 侧真校验)。代价极大:该 schema 今天接受任意 $op,且 { profile: { verified: true } } 的嵌套关系形与算子对象在结构上不可区分 —— 收紧会波及每一个 filter 消费者,远超本单范围。
    • B. spec 只声明数据、不做校验:加一张 RETIRED_FILTER_OPERATORS 指引表(FIELD_KEY_GUIDANCE / retiredKey 的既有惯例,纯数据,合 Prime Directive ✨ Set up Copilot instructions #2),由既有拒收点(driver-memory/filter-refusal.ts、objectql/having-filter.ts、service-analytics、sql-driver 的 default:)统一引用,五处说同一句话、同一个替代物指名。执行归 drivers: $regex 响亮拒收 + $icontains 各后端实现(#4706 裁决 B 案 · 驱动半边) #5702。
    • 建议 B,且顺序硬约束:plugin-auth/src/objectql-adapter.ts:137 必须先翻转成 $contains,任何拒收才能落地 —— 否则 better-auth 的 contains 查询在认证路径上开始抛错。我已把这块单独立单(见下),它既不属契约半边也不属驱动半边。

    Q4 —— $icontains 用例落 FILTER_LOGIC_CASES 还是新同级 case-set?

    关于 $istartsWith / $iendsWith(裁决留给实施判断):我的意见是不加。理由不是对称性不成立,而是它们会把 Q1/Q2 的每一个决定各再乘以两倍的实现与用例面,而实测业务拉动(HotCRM)只指向「包含」。等 $icontains 在五后端落定后按需再加,代价是线性的。


    六、为什么不硬写一个 PR

    第 1 点无物可移;第 2 点的每一条用例的 expected 都是 Q1 的答案本身;第 3 点(全仓 $regex 清点)的清单我已经出好(见上「一个生产者 + 四个消费者」),但其中消费者四处全在驱动半边领地,生产者一处需要 Q2 才能确定翻转后的语义是否等价。也就是说本单在这三个问题被回答前,没有一行代码是决定无关的 —— 强行写出来的 fixture 会以「已验证」的样子把契约决定固化下去,这比空着更贵。

    工作树已拆除,分支未推。裁决后可直接重新派发,上面的清单与实测可直接复用。


    Generated by Claude Code

  4. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    PM 复审(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06):dev 的 needs_decision 成立,四问分流如下。

    PM 当场裁(step 8 可决类,否决窗口开放):

    升级维护者(Q1/Q2,见轮次报告): Q1 折叠域(建议 A:ASCII-only 显式声明——五后端唯一都能兑现的事实,B 复刻了你拒绝 A 案的同款结构性不可行);Q2 $contains 族大小写语义是否同批钉死并取代 filter.zod.ts:139 那句已录声明(建议 A,但它扩大驱动半边预算,如实呈报)。

    标签转回 needs-user-decision(Q1/Q2 待裁),认领保留,裁后原认领重派——sweep 清单与两组测量可原样复用。


    Generated by Claude Code

  5. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    维护者裁决转录(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06,维护者原话「接受你的建议」):

    硬顺序不变:#5710(plugin-auth 生产者翻转,认证路径)先落,任何活体拒收(#5702 执行)后落。本单为纯声明 + case-set DEBT,不引入活体拒收,不受该顺序阻塞——即刻重派原认领,上轮 sweep 清单与两组实测原样复用。

    标签:needs-user-decision → pm:dispatched。


    Generated by Claude Code

  6. os-zhuang commented on Aug 6, 2026

    @os-zhuang
    ContributorAuthor

    PM 裁定补充(spec 车道 PM,session_018fxLGQdatPbBUvCgiVxg6D,2026-08-06,针对 PR #5752 标记的判断点,否决窗口开放):

    背书「$icontains 暂不进 FILTER_OPERATORS」的分期执行。 裁决原文「入算子词表」在实施中测得前提精化:FILTER_OPERATORS 不是词表而是运行时白名单——driver-memory 的形状门禁与 service-analytics 的覆盖门禁都从它派生强制。提前入表的实测后果(探针,先预测后跑):driver-memory 门禁放行 $icontains → 匹配器 default: break 静默丢谓词 → match({name:'zzz'}, {name:{$icontains:'acme'}}) 返回 true——谓词被丢是放大不是收窄,在 RLS 读作用域上即越权读(#3948),且在 analytics 编译器上独立复现(echoed no WHERE)。这与本单三条硬约束(零驱动改动/零活体行为/main 不红)直接冲突。

    分期方案的响度保障:声明面(StringOperatorSchema/FieldOperatorsSchema/Filter<T>,已核实无运行时白名单消费者,惰性)先行;filter-operator-vocabulary.test.ts 把「声明面 − 执行面」双向钉死为恰好 {$icontains}——再塞第二个未记录的暂存算子会红,#5702 实现后补入 FILTER_OPERATORS 也会红(失败信息即操作指引)。FILTER_OPERATORS 文档块写明过渡与解除条件。

    对 #5702 的指令性后果:实现 $icontains 的 PR 须同批 ① 把它加进 FILTER_OPERATORS、② 删除该文档块的过渡段、③ 按 pin 的失败信息更新 filter-operator-vocabulary.test.ts。已在五条 DEBT 台账行中写明。

    维护者若要求强行入表并同批修 driver-memory 门禁(跨 #5702 领地),请驳回本条,原认领重做。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions