Repository navigation
fix(core, fields): refuse an empty or non-string $icontains comparand, and stop the builder emitting it (objectui#8748) - #8791
Conversation
…nd, and stop the builder emitting it (objectui#8748)
`ValueDataSource` answered `{ name: { $icontains: '' } }` and its AST twin
`['name', 'icontains', '']` with EVERY row and not one console line — every
value contains the empty substring, so the arm ran and constrained nothing —
and evaluated a non-string comparand after a `String()` coercion nobody wrote.
`@objectstack/spec`'s `FILTER_TEXT_CASES` carries both shapes as REJECTION rows
(`code: 'INVALID_FILTER'`, `mustMention: ['$icontains']`).
Both halves land together. The matcher refuses in this face's declared shape
(objectui#7349): the row is excluded and one console refusal naming the
operator and field is drained — not a throw. The producer half is what makes
that safe: `FilterConditionField.condToMongo` emitted the empty shape verbatim,
so a builder row with the operator chosen and the value box still empty
authored exactly the refused filter; refusing in the matcher alone would have
flipped that list from "every row" to "no rows", the outcome `ValueDataSource`'s
own `$exists` arm names as worse than the bug.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
✅ 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
|
Contract review at
|
| item | result | evidence |
|---|---|---|
| 1a matcher refuses empty + non-string, both dialects, via exclude-and-log, no throw | ✓ | refuseTextComparand ValueDataSource.ts:194-218 → refuseFilterNode :65-68; AST arm :494-498, $ arm :713-717; one shared Set drained once per distinct message :1081-1089. 0, false, null, undefined, 42 all hit typeof !== 'string'; whitespace-only not refused (matches spec) |
| 1b producer no longer emits | ✓ at the seam, ✗ in the UI | FilterConditionField.tsx:190 drops; F1 |
1c $search path untouched |
✓ | own arm ValueDataSource.ts:1093-1100; ListView.tsx:2044 gates on searchTerm; no $search/ListView lines in diff |
1d fixtures stay lit; pins fail on revert; no .skip/.only/.todo; no vacuous asserts |
✓ (reasoned; not executed) | Every new case asserts ids and refusal count/text. Reverting the $ arm: {$icontains:''}→ALL_IDS/0 warns, {score:{$icontains:5}}→['s5'], null→[]/0 warns ⇒ 4 fail (dollar, agreement, 5, null); AST arm ⇒ 3 — matches the dev's per-test counts. Could not run locally: no node_modules in any checkout, worktree creation barred for the reviewer; CI Test shards 1-4 green on head |
| 2 accept-set movement + disclosure | partial | Matcher: {f:{$icontains:''}} / ['f','icontains',''] all rows → []+1 warn; non-string comparand coerced-eval → []+1 warn ({score:{$icontains:5}}: ['s5']→[]). Producer: 5 ops × {undefined,null,''} → no fragment. In-repo consumers: no producer sends icontains '' into a ValueDataSource (ListView drops incomplete rows :587; ObjectGrid uses $search/schema filter; designer grids have no text-op UI) — only external provider:'value' authors. Changeset minor for @object-ui/core + @object-ui/fields ✓ (both in the fixed group, neither private); Migration text partial (F3) |
3 type gate (#8599) / $like $ilike (#7383) |
✓ untouched | typeof value === 'string' kept :498, :717; not_contains/$notContains complements unchanged :523-524, :724-725; like/ilike still refused by the default arm :532-540 / retired table :771-792; no such lines in diff |
| 4 docs | ✗ | adapters README stale (F2); condToMongo comment updated inline; no content/docs mention of either surface |
| 5 CI / body / report | mixed | 33 check-runs on 598dd6ef96: 30 success, 3 skipped (dependabot, coverage jobs), 0 failure — Lint ✓ Type Check ✓ Changeset Declaration ✓ Governed Surface Queue Guard ✓ (NOT GOVERNED). Labels carry needs:contract-review. Fixes #8748 correct once F1 lands. Body accurate on the matcher; wrong on the producer's safety (F1/F7) |
{"pr":8791,"repo":"objectui","head":"598dd6ef96ced4f4c21add27eb48799c74a57c56","verdict":"CHANGES REQUIRED","blocking":["F1","F2"],"clause2":"yes","semver_ok":true,"governed":false,"ci":"30 success / 3 skipped / 0 failure on 598dd6ef96"}维护者速读
这个 PR 做了两件事:内存值列表适配器 ValueDataSource 现在对「$icontains 比较值为空或不是字符串」两种写法按既定方式拒绝(剔行 + 打一条命名操作符的 console 警告,不抛错,两种过滤方言一致),同时筛选条件表单 FilterConditionField 不再把「选了文本操作符、值框还空着」的行写进条件。匹配器这一半实现正确、测试钉得住、CI 全绿、changeset 定级 minor 也对。但生产者这一半有一个实测的回归:该表单是受控组件,界面上的行完全由已存储的条件反推出来,而共享的 FilterBuilder 一发现传入值和内部行不一致就整体重置——所以用户一把操作符切到「包含 / 忽略大小写包含 / 不包含 / 开头是 / 结尾是」,这行会在能输入值之前直接消失,五个文本操作符在界面上等于不可用(只有先在「等于」下输完值再切操作符才行)。另有一处文档欠账:packages/core/src/adapters/README.md 的拒绝清单自称完整,却没有加上这条新拒绝(上一个改同一文件的 PR #8599 是补了的)。⛔ 阻塞,ui 席下一步:补丁轮回原 dev——把表单的行状态改成本地持有、只在发射时丢空行,并加一条渲染级测试证明切换操作符后行还在;同时补 README 那一行,顺手在 changeset 迁移段写清生产者侧四个操作符的前后差异。修完后 head 会后移,需重新走一次契约复核。
Generated by Claude Code
⏸ Parked for 项目总监 (director-seat) contract review — maintainer's call, verbatim: 「等项目总监评审」
The tier question was put to the maintainer with this diff in hand and answered: wait for the director's review. ⇒ ⛔ The quota-exhaustion downgrade exemption is NOT being spent, and ⛔ this seat is not substituting its own review for the contract review. ⛔ Not flipped ready. No auto-merge. Not enqueued. It stays a draft until a contract review is on record. What the reviewer is being asked to rule onThe accept set of a published adapter moves. ⭐ The direction is worth stating precisely, because it bears on how much latitude the review needs: this narrows the implementation toward the published contract rather than widening it. What this seat verified, so the review starts from measurement rather than from the report
The deliberate asymmetry, flagged so it is not read as an oversight
Generated by Claude Code |
Correction to my comment above — the director's review is not the release gate. The in-seat contract review is, and it belongs to this seat.
⇒ the 总监席 summon is an optional post-hoc audit, ⛔ not a necessary condition for release. What actually gates this PR is 席内契约档 PASS — the first of the three 落地前检:
And ownership is explicit (line 27): 归属该卡派发席,交付后收集复核当轮席内完成 — the review belongs to the dispatching seat, i.e. this one, run in-round after delivery. The performer must be a 达档者: a review subagent launched with an explicit ⛔ And the downgrade I floated to the maintainer was never available
The quota-exhaustion exemption's subject is dispatch/construction; the review is the compensating control for a downgraded build. Downgrading the review would dissolve the very thing the exemption leans on. ⇒ my earlier framing — that spending the exemption a third time was one of the options — was invalid, and the maintainer's 「等项目总监评审」 is consistent with the written rule while my recommendation was not. What that means for this PR, concretely⛔ Unchanged in substance: still a draft, no auto-merge, not enqueued, both carriers still hung. The skill names this state explicitly as correct rather than stuck:
What changes is who owes the next action: this seat, as soon as
Generated by Claude Code |
…rator row survives the drop (objectui#8748) Contract review CHANGES REQUIRED on PR objectui#8791 (#8791 (comment)), blocking items F1 and F2 plus the non-blocking F3 / F4 / F5. F1 — `FilterConditionField` is a CONTROLLED ROUND-TRIP: its builder rows were `mongoToFilterGroup(parsed.mongo)` over the STORED criteria, and `FilterBuilder` re-seeds its internal rows whenever the incoming `value` differs from them. So the producer-side drop this card added at `condToMongo` deleted the row from the screen: switching a row to any of `contains` / `containsCaseInsensitive` / `notContains` / `startsWith` / `endsWith` emitted no fragment, the criteria went back to empty, and the row vanished before a comparand could be typed — the five text operators were unreachable through the UI except by typing the value under `equals` first. The rows are now local state, seeded from the parsed criteria and re-seeded only when an incoming criteria stops being what those rows emit (an outside change); the drop stays at emission. Pinned at RENDER level, which is the only level that can see it — `condToMongo` answers correctly in isolation. F2 — the adapters README refusal table presents itself as the complete list and did not carry the new comparand-shape refusal. F3 — the changeset now states the producer half's effect on STORED criteria for the four sibling operators. F4 — the `TEXT_COMPARAND_OPERATORS` docblock says why it diverges from `isFilterValueComplete` rather than leaving a second unexplained "row finished" rule. F5 — the refusal message can no longer throw while explaining itself: `JSON.stringify` throws on a BigInt or a cyclic comparand, in an exclude-and-log face. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3
The branch was 30 commits behind. Nothing on main touched this PR's files (`ValueDataSource`, `FilterConditionField`, `filter-builder`); the two overlapping files are `DateField.tsx` / `FormulaField.tsx` in the same package, so the fields suite is re-run on the merge result rather than on the old base. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Js5kTpTtxieBjPyScgxJ3
✅ 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
|
Contract review at
|
| file | hunks |
|---|---|
.changeset/8748-icontains-empty-comparand.md |
:44-55 (+13: stored-criteria Migration paragraph, row-survives note) |
packages/core/src/adapters/README.md |
:97-99 (non-empty-string clause), :158 (table row) |
packages/core/src/adapters/ValueDataSource.ts |
:194-213 (describeComparand + docblock), :236, :330 (two call sites) |
.../__tests__/ValueDataSource.nonStringStoredValue.test.ts |
:352-369 (BigInt / cyclic pin) |
packages/fields/src/widgets/FilterConditionField.tsx |
:147-159 (F4 docblock), :270-294 (criteriaKey), :491-529 (local rows + re-seed effect + setLocalGroup in handler), :587 (value={localGroup}) |
.../__tests__/FilterConditionField.builderRows-8748.test.tsx |
new, 204 lines |
textOperatorCase.test.ts, operators.test.ts |
unchanged |
No new exports: criteriaKey and describeComparand are module-private.
F1–F7 status
- F1 — fixed.
FilterConditionField.tsx:516useState<BuilderGroup>(() => group ?? EMPTY_GROUP);:518-519storedKey = criteriaKey(parsed.mongo)vslocalKey = criteriaKey(filterGroupToMongo(localGroup, typeOf));:521-526effect re-seeds only whengroup !== null && storedKey !== null && storedKey !== localKey;:529handler holds the rows locally;:587builder fedlocalGroup. Drop still at emission:condToMongo:203. The RTL test asserts the exact sequence asked: add row →'{"name":""}'(:119) → pickContains (ignore case)→ two comboboxes, operator text shown,Valuebox present (:124-128) and last emission''(:133) → typeacme→'{"name":{"$icontains":"acme"}}'(:137). Stored-seed leg:155-169loads{name:{$icontains:'x'}}into Name / Contains (ignore case) /xand assertsonChangenot called. Extra legs: clear-back-out (:141-152) and outside change re-seeds (:171-204). The harness is a real controlled round-trip (useStateparent,:63-83), which is what the in-repo host is: react-hook-form's...formFieldspreadform.tsx:2797,FieldEditWidget.tsx:376-377.
Re-seed guard / render-loop check:groupis memoised onparsed→rawValue(a string) →value(:454-468), so it is referentially stable while the stored text is. A stored criteria that does not round-trip ($ncontains→notContains:321-322→ emits$notContains:220) leavesstoredKey !== localKeypermanently, but the effect then callssetLocalGroup(group)with the same reference already in state — React'sObject.isbail-out, no re-render, deps unchanged, no loop. After a user edit the host stores the emission verbatim,storedKey === localKey, no re-seed. Reasoned, not executed (nonode_modules, no worktree permitted); CI Test shards 1–4 green on this head. - F2 — fixed. README
:158, same three-column shape as:149-150(spelling | why | write instead), verdict wording; clause:97-99. Accurate toFILTER_TEXT_CASES(../objectstack/packages/spec/src/data/filter-text-conformance.ts:431-444: empty and non-string$icontainsbothexpectRejection: true,mustMention: ['$icontains']). - F3 — fixed. Changeset
:44-51:contains ''/startsWith ''/endsWith ''→ "value is a string",notContains ''→ its complement, all four now store no fragment, already-stored criteria unchanged. Consistent with the matcher's partition (README:104-112) and with the drop living only incondToMongo. Same sentence in the PR body. - F4 — addressed by stating it.
:147-159names the deliberate divergence (equals ''is a predicate) and why composition with the@internalisFilterValueComplete(filter-builder.tsx:852-865) is not adopted. Acceptable per the verdict's either/or. - F5 — fixed, class-wide.
describeComparandValueDataSource.ts:207-213(try/catch aroundJSON.stringify(x) ?? String(x), byte-identical for non-throwing input), used at:236(text comparand) and:330($field). Pin:352-369assertsids === [], one refusal, naming$icontains, forBigInt(10)and a cyclic object. - F6 — left as is; the verdict allowed it ("naming what the author wrote is defensible; note only").
:517stillString(rawOperator). - F7 — answered. PR body now says the drop "alone was not safe in the UI, and the contract review measured it"; the 14:24Z report (5603476982) states the measured UI outcome and the ablation (2/2 F1 tests fail on the projection with the row gone).
Clause-② reading — unchanged, re-derived only where the delta touches it
The delta touches neither refusal arm (:515-519 AST, :734-738 $), nor refuseFilterNode, nor the once-per-message drain; it changes only how the comparand is rendered inside the refusal text, byte-identical for every input that did not throw. The producer's emission set is unchanged (condToMongo:203); the local-rows change is UI state, not a published-face change, and adds no export. So the verdict of record's reading stands verbatim: Clause-②: yes — $icontains/icontains empty or non-string comparand refused in both dialects via exclude-and-log, producer no longer emits the shape. Changeset minor for @object-ui/core + @object-ui/fields (both public, Changeset Fixed Group Check / Bump Policy / Declaration all green) still correct; the added Migration text is accurate. check-clause2-carriers.mjs --pair 8791 → exit 0.
Governed surface
NOT GOVERNED — eight changed paths, none under a governed glob; Governed Surface Queue Guard green on the head. Draft-only terminal rule does not apply.
CI on head
33 check-runs pinned to 93291f45fb: 30 success / 3 skipped (dependabot, Test (coverage), coverage shard) / 0 failure — waited to completion. Lint ✓, Type Check ✓, Test shards 1–4 ✓, Changeset Declaration ✓, Governed Surface Queue Guard ✓, README Export Check ✓ (the gate the dev could not measure locally).
New findings (all non-blocking)
- F8 — orphaned docblock,
ValueDataSource.ts:~170-193→:194-213. The helper was inserted betweenrefuseTextComparand's docblock and its function, sorefuseTextComparand(:215) now has no docblock and two consecutive/** … */blocks precededescribeComparand. Cosmetic; move the new helper above the old docblock in any follow-up touch. - F9 — re-seed guard assumes a verbatim host round-trip.
criteriaKeycompares JSON text, so a host that re-serialises the criteria (different key order, pretty-print, normalisation) would re-seed after every emission and re-open F1 for that host. Both in-repo hosts are verbatim (form.tsx:2797,FieldEditWidget.tsx:376-377) and the verdict itself prescribed this comparison; note only. Same mechanism: anobject_nameswitch resetsfields→typeOf→ may re-seed and drop an unfinished row — acceptable, the field set changes anyway. - F10 — report accuracy nit. The ablation's quoted failure text (
Unable to find an accessible element with the role "combobox") fits test 1 (:124); test 2's first post-switch query isgetByPlaceholderText('Value')(:143), whose failure names the placeholder. Reasoned from the test source; does not affect the 2-failed/2-passed count. - F11 —
react-hooks/set-state-in-effect, third instance (:521-526). Rule iswarn(eslint.config.js:164),--max-warningsdeliberately unset (lint.yml:20),check-lint-coverage.mjs:39-41ignores warnings; Lint green. The render-phase alternative is correctly rejected — a non-round-tripping stored criteria would never converge. Accept.
Acceptance notes
- Tests not executed locally (no
node_modulesin any checkout, worktree creation barred for the reviewer); every reading above is source-reasoned and cross-checked against green CI on the exact head. Per-test ablation counts in the report (2/2, 1/22,Tests 4 passed (4)) are consistent with the diff (4 tests in the new file; 23 innonStringStoredValue). - PR form: draft ✓, base
main✓, first lineFixes #8748and it is the only closing keyword in the body — correct now that both halves land in one PR. - Card: newest
Claim:(5597899326) names this branch; the patch round posted no second claim and did not write the assignee ✓.
Generated by Claude Code
|
Landing provenance — director seat takes the release action under the maintainer's 13:4xZ instruction 「把当前的契约复审全部处理完」 (
Generated by Claude Code |
Fixes #8748
ValueDataSourceanswered an empty$icontains/icontainscomparand with every row and not one console line, and evaluated a non-string comparand after aString()coercion nobody wrote.@objectstack/spec'sFILTER_TEXT_CASEScarries both shapes as REJECTION rows. This PR refuses both, in both dialects, and — in the same change — stopsFilterConditionFieldemitting the shape.Re-measured on current
mainbefore any editThe card's readings were taken on
da5e4f69;mainis now0f5cadfa1(objectui#8513 / PR #8771 landed$and/$orexecution in this very file since). All three probe rows reproduced, over the realValueDataSourceand@objectstack/spec's ownFILTER_TEXT_ROWS, both dialects:0f5cadfa1{ name: { $icontains: '' } }["1","2","3","4","5","6","7","8","9"], console[][]+ 1 refusal naming$icontains['name','icontains','']["1","2","3","4","5","6","7","8","9"], console[][]+ 1 refusal namingicontains{ name: { $icontains: 42 }}[]viaString(42), console[][]+ 1 refusal naming$icontains{ name: { $icontains: 'acme' }}["1","2"], console[]["1","2"], console[]— unchanged[](no filter)The two halves, and why neither ships alone
Matcher (
@object-ui/core). Theicontainsand$icontainsarms now check the comparand before folding: anything that is not a NON-EMPTY STRING excludes the row and drains one console refusal naming the operator and the field.This is objectui#7349's mechanism — exclude the row and log it, not throw. The throwing envelope belongs to the wire-side sibling
@object-ui/data-objectstack, which is deciding whether to send a query at all; this matcher is deciding about one row. Whether this face should carry the throwing envelope is objectui#8600 Q1 and stays closed with it.Producer (
@object-ui/fields).condToMongoemitted{ [field]: { $icontains: value } }verbatim, and the only drop on that path was the missing-FIELD guard (filterGroupToMongodiscards null fragments and nothing else). A builder row with the operator chosen and the value box still empty therefore authored exactly the refused filter. It is now dropped, for the five free-text operators (contains,containsCaseInsensitive,notContains,startsWith,endsWith) onundefined/null/''.ValueDataSource's own$existsdocblock names as the one worse than the bug.isNull/exists/isEmptyand their negations) read no comparand and are untouched; so isequals '', which is a real predicate.The producer half also moves stored criteria for the four sibling operators it covers:
contains ''/startsWith ''/endsWith ''used to store a predicate the server evaluates as "the value is a string", andnotContains ''stored its complement, "the value is not a string". All four now store no fragment at all, so a rule whose only row was one of them saves as empty criteria and is refused on save (objectstack#3896) rather than sharing by storage class. Criteria already stored keep their fragment and keep evaluating as they did.Scope held
FILTER_TEXT_CASESdeclares the empty-comparand refusal for$icontainsand for no other operator, so$contains ''keeps the answer it has always given. The asymmetry is pinned as its own case so the next reader sees a scope boundary rather than an oversight.FILTER_TEXT_CASESenrolment is not here (objectui#8600, closed). Measured on the installed@objectstack/spec17.3.0: the table carries 19 cases, below the 24-case reopen threshold.$optionsshort-circuit does not ride along. Measured:{ name: { $regex: 'x', $options: 'i' } }drains one refusal naming$regexonly. That short-circuit ismatchesFilter's per-operatorreturn false, a different site from the two comparand arms this PR touches — so it is not on the same lines, and per the card it stays with objectui#8600's reopen note.data-objectstackmatcher.Patch round — contract review CHANGES REQUIRED
Answering the audit-tier contract review at #8791 (comment) (head
598dd6ef96). Implemented by the director seat'sos-devround, sessionsession_017Js5kTpTtxieBjPyScgxJ3; the card's existingClaim:and assignee stand and were not touched.FilterConditionFieldholds theBuilderGroupas local state, seeded from the parsed criteria and re-seeded only when the incoming criteria stops being what those rows emit (an outside change). The drop stays at emission. New render-level pin, and an ablation that reproduces the vanishing row.$icontainsparagraph above it.isFilterValueCompleteTEXT_COMPARAND_OPERATORSdocblock now states why the two diverge (equals ''is a real predicate here) and why composing the two is not adopted: it would bind this drop to a helper marked@internalin another package, whose vocabulary is the dropdown's rather than the spec's.JSON.stringifyin the refusal message throws on a BigInt or a cyclic comparanddescribeComparandhelper, used by BOTH sites — the new text-comparand refusal and the pre-existing$fieldone at the same shape the verdict cites as the idiom. Output is byte-identical for every input that did not throw. Pinned.icontains, not$icontainsFILTER_TEXT_CASES'mustMentionis satisfied by the$dialect's own arm.Tests
Pinned as the card asks: the two
ValueDataSourcefiles keep their existing lit controls, every new row asserts the excluded row set and the console refusal, and each row has a live control beside it.ValueDataSource.textOperatorCase.test.ts— the EMPTY comparand, both dialects, plus the dialect-agreement case, plus the untouched-$containsboundary.ValueDataSource.nonStringStoredValue.test.ts— the non-string COMPARAND, both dialects.42and'42'agree" — that assertion passes before the fix too. This fixture discriminates where the card's did not:{ score: { $icontains: 5 } }used to return['s5']through the coercion and now returns[]with a named refusal, while the string comparand'5'still returns['s5'].FilterConditionField.operators.test.ts— the producer drop, with the non-empty comparand, the value-less operators andequals ''as live controls.FilterConditionField.builderRows-8748.test.tsx— new in the patch round, and the only level that can see F1: the widget rendered inside a controlled parent that stores what it emits. Add a row, switch tocontainsCaseInsensitive, and the row is still on screen with the operator shown and the value box empty while the emitted criteria is''; typeacmeand it emits{"name":{"$icontains":"acme"}}. Two guards ride with it — a stored{ name: { $icontains: 'x' } }still seeds the rows and emits nothing on mount, and an outside change to the stored value still replaces them — so a widget that merely IGNORED its incoming value would fail the suite rather than pass it.Ablation — three legs, each mutation proven on disk (git blob hash before/after), each classified per test from vitest's JSON reporter, each restored to
HEADand the restore proven by an emptygit diff HEAD:$icontainsarm (@object-ui/core)$-dialect rows; AST rows and every control stayed greenicontainsarm (@object-ui/core)condToMongodrop (@object-ui/fields)Ablation — patch round, two more legs, same discipline (mutation proven on disk by git blob hash before/after, classified per test from vitest's JSON reporter, restored with
git checkout HEAD -- PATHunder a trap on absolute paths, restore proven by hash equality with the HEAD blob AND an emptygit diff HEAD; the fix was committed first, soHEADcarried the implementation). Neither leg needs a rebuild: both suites reach their subject through a RELATIVE import of the source file, not through a packageexportsentry, so nothing is read out ofdist/.value={localGroup}back to the projectionvalue={group ?? EMPTY_GROUP})TestingLibraryElementError: Unable to find an accessible element with the role "combobox": the row is GONE, which is the defect in its own words. The seed guard and the outside-change guard stayed greendescribeComparand(target)back toJSON.stringify(target) ?? String(target)Commands run in the FIRST round, on
598dd6ef9(every build/test through the container's shared verify lock; verdict lines quoted, never a bare$?):pnpm exec vitest runover the four pinned files →Test Files 4 passed (4),Tests 107 passed (107);VERDICT command-exit 0pnpm --filter @object-ui/core --filter @object-ui/fields test→ core134 passed (134)/2855 passed (2855), fields149 passed (149)/2564 passed (2564);VERDICT command-exit 0pnpm --workspace-concurrency=2 --filter '@object-ui/core^...' --filter '@object-ui/fields^...' build→ 9 packagesDone;VERDICT command-exit 0pnpm --filter @object-ui/core --filter @object-ui/fields type-check→ bothDone(each istsc --noEmit && tsc -p tsconfig.test.json, so the test files are covered too);VERDICT command-exit 0Gates run locally, each printing a non-zero scanned population rather than a silent no-op:
check:control-bytes(6986 files),check:spec-symbols(1366 files / 5050 export names),check:vi-mock-specifiers,check:vi-mock-inherit,check:element-data-source-declaration,check:unreferenced-sources,check:self-import— all OK.check-governed-queue-guard --testover the six changed paths:NOT GOVERNED, and the lit control (the same command shape withAGENTS.mdappended) fires⛔ GOVERNED, so that zero is a reading. Plus a control-byte self-scan over the changed files.Commands run in the PATCH round, on
93291f45f— the merge oforigin/maininto this branch, which is also where every number below was taken (the branch was 30 commits behind; nothing on main touchedValueDataSource,FilterConditionFieldorfilter-builder, and the two overlapping files areDateField.tsx/FormulaField.tsxin the same package, which is why the fields suite is re-run on the merge result):pnpm --workspace-concurrency=2 --filter '@object-ui/core^...' --filter '@object-ui/fields^...' build→ 9 packagesDone;VERDICT command-exit 0pnpm --filter @object-ui/core --filter @object-ui/fields type-check→ bothDone;VERDICT command-exit 0. Each istsc --noEmit && tsc -p tsconfig.test.json, and the new.test.tsxis genuinely IN that program —tsc -p tsconfig.test.json --listFilesnames it among the 1588 files it compiles, so this is a reading and not a NOT-MEASUREDpnpm --filter @object-ui/core --filter @object-ui/fields test→ coreTest Files 134 passed (134)/Tests 2856 passed (2856), fieldsTest Files 153 passed (153)/Tests 2623 passed (2623);VERDICT command-exit 0pnpm exec vitest runover the new file alone →Test Files 1 passed (1),Tests 4 passed (4);VERDICT command-exit 0Gates re-run locally on the merged head, each printing a non-zero scanned population rather than a silent no-op:
check:control-bytes(7043 files),check:vi-mock-specifiers/check:vi-mock-inherit(4659 files, 2901 test-named),check:unreferenced-sources,check:self-import,check:spec-symbols(1366 sources + 184 doc pages),check:element-data-source-declaration,check-type-check-coverage.mjs(46 packages, 42/42 compile their tests) — all exit 0.check-governed-queue-guard --testover this PR's eight changed paths:NOT GOVERNED, and the lit control (the same command withAGENTS.mdappended) fires the governed refusal, so that zero is a reading. Plus a control-byte self-scan over the changed files with the raw-byte regex.check:readme-exportsis NOT MEASURED here, not green and not red. Its own workflow builds every package first (turbo run build --filter='./packages/*') and this container built only the core/fields dependency closure, so the gate reportedthe population COLLAPSED -- this run proves nothingover 27 unbuilt packages. Every path it named is aplugin-*README; it named none of this PR's files, and the README this PR edits ispackages/core/src/adapters/README.md, which is not one of thepackages/PKG/README.mdfiles that gate reads. It is CI's run.Declared narrowing — eslint. Run on this PR's changed files rather than repo-wide; the repo-wide scan is CI's. Re-measured at the final commit⚠️ One of those three is NEW — the re-seed effect this patch round adds. It is the third instance in this same file of a category the file already carried twice (
93291f45f: population 4657 files (read from eslint's own config — every git-tracked file with a lintable extension, 4659, minus everythingisPathIgnoredrejects), 6 files linted (count read from--format json), 0 errors, 93 warnings (--max-warningsis deliberately unset inlint.yml): 86no-explicit-any, 4react-refresh/only-export-components, 3react-hooks/set-state-in-effect.setFields(null)andsetRawMode(true)), and it is deliberate: the alternative, adjusting the state during render, cannot be made to converge when a stored criteria does not round-trip through the builder (a legacy$ncontainsspelling reads back asnotContainsand emits$notContains), and React throws on that rather than settling. The effect form bails out on an identical value instead. Invariance:eslint.config.jsextendstseslint.configs.recommendedwith noparserOptions.project, noprojectServiceand no*TypeCheckedconfig — type-aware linting is not enabled, so every verdict is a function of a file's own text plus the config, and this diff changes neither the config nor any of the other 4623 files. The narrowing therefore excludes nothing.Carriers
Clause-②: yes— the matcher half moves the accept set of a published adapter.needs:contract-reviewis hung on this PR; changesetminorfor@object-ui/coreand@object-ui/fields.CONTRACT_REVIEW_TIERis measured unavailable (429,req_011CesRvRDtifao1i75vuaXb, ~06:57Z). Per objectui#8069 comment 5594065532 that gates the review, not the construction. This PR is therefore expected to sit as a draft: ⛔ not enqueued, ⛔ no auto-merge, ⛔ not flipped ready — the contract review is owed on this diff first, and that is the PM seat's call.验收备注
Noted while reading, deliberately not filed and not fixed here:
$contains/$startsWith/$endsWithwith an empty comparand still evaluate (all string-valued rows / none). Not a contract violation —FILTER_TEXT_CASESdeclares the refusal for$icontainsalone — so there is nothing to file against this repo. The PR that has to answer it is objectui#8600's reopen, which enrols this face in the whole table; it touches exactly these arms. Pinned here as a boundary case so the asymmetry cannot be read as an oversight.$optionsshort-circuit is re-measured and unchanged (see Scope held). It stays with objectui#8600's reopen note, as the card directs.Added in the patch round, and likewise not filed:
icontainsand the$-door one names$icontains, because each names the token its author wrote. Contract review F6 records this as defensible and note-only, so it is left alone; the spec'smustMentionis satisfied on the$side by that side's own arm.react-hooks/set-state-in-effectnow has three instances inFilterConditionField.tsxwhere it had two. Reasoned above under the eslint narrowing, not filed: the rule is a warning here, the file already carried the category, and the render-phase alternative is the one that can fail to converge.Patch round implemented by the director seat's
os-dev, sessionsession_017Js5kTpTtxieBjPyScgxJ3, on the branch's existing claim — no secondClaim:was posted, the assignee was not written, and no label or draft state was touched. Attribution is written here as prose because aPATCHto this body rewrites the footer.🤖 Generated with Claude Code
https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
Generated by Claude Code
Generated by Claude Code