Skip to content

fix(core, fields): refuse an empty or non-string $icontains comparand, and stop the builder emitting it (objectui#8748) - #8791

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-8748-icontains-empty-comparand
Sep 9, 2026
Merged

huangyiirene merged 3 commits into
mainfrom
claude/issue-8748-icontains-empty-comparand

Conversation

@claude

@claude claude Bot commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #8748

ValueDataSource answered an empty $icontains / icontains comparand with every row and not one console line, and evaluated a non-string comparand after a String() coercion nobody wrote. @objectstack/spec's FILTER_TEXT_CASES carries both shapes as REJECTION rows. This PR refuses both, in both dialects, and — in the same change — stops FilterConditionField emitting the shape.

Re-measured on current main before any edit

The card's readings were taken on da5e4f69; main is now 0f5cadfa1 (objectui#8513 / PR #8771 landed $and / $or execution in this very file since). All three probe rows reproduced, over the real ValueDataSource and @objectstack/spec's own FILTER_TEXT_ROWS, both dialects:

probe on 0f5cadfa1 after this PR
{ 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 naming icontains
{ name: { $icontains: 42 }} [] via String(42), console [] [] + 1 refusal naming $icontains
live control { name: { $icontains: 'acme' }} ["1","2"], console [] ["1","2"], console [] — unchanged
lit control [] (no filter) all nine all nine — unchanged

The two halves, and why neither ships alone

Matcher (@object-ui/core). The icontains and $icontains arms 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). condToMongo emitted { [field]: { $icontains: value } } verbatim, and the only drop on that path was the missing-FIELD guard (filterGroupToMongo discards 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) on undefined / null / ''.

⚠️ That half is what makes the refusal safe at the SEAM — refusing in the matcher alone flips a half-built builder row from "every row" to "no rows", the outcome ValueDataSource's own $exists docblock names as the one worse than the bug. ⚠️ But the drop alone was not safe in the UI, and the contract review measured it: this widget is a controlled round-trip, so the row projected from the stored criteria deleted itself the moment the operator was switched. That is F1 below, and it is fixed in this PR — the rows are now local state. The value-less operators (isNull / exists / isEmpty and their negations) read no comparand and are untouched; so is equals '', 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", and notContains '' 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

  • The sibling positive operators are deliberately NOT widened by analogy. FILTER_TEXT_CASES declares the empty-comparand refusal for $icontains and 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_CASES enrolment is not here (objectui#8600, closed). Measured on the installed @objectstack/spec 17.3.0: the table carries 19 cases, below the 24-case reopen threshold.
  • The $options short-circuit does not ride along. Measured: { name: { $regex: 'x', $options: 'i' } } drains one refusal naming $regex only. That short-circuit is matchesFilter's per-operator return 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.
  • Nothing touched on the wire-side data-objectstack matcher.

Patch round — contract review CHANGES REQUIRED

Answering the audit-tier contract review at #8791 (comment) (head 598dd6ef96). Implemented by the director seat's os-dev round, session session_017Js5kTpTtxieBjPyScgxJ3; the card's existing Claim: and assignee stand and were not touched.

item disposition
F1 (blocking) — the producer drop deletes the builder row Fixed. FilterConditionField holds the BuilderGroup as 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.
F2 (blocking) — the adapters README refusal table is incomplete Fixed. The comparand-shape row is in the table, in the verdict's wording, plus one clause on the $icontains paragraph above it.
F3 — the changeset's Migration covers only the matcher Fixed. The four sibling operators' before→after on STORED criteria is now stated in the changeset and in the section above.
F4 — a second "row finished" rule beside isFilterValueComplete Fixed by saying so. The TEXT_COMPARAND_OPERATORS docblock 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 @internal in another package, whose vocabulary is the dropdown's rather than the spec's.
F5 — JSON.stringify in the refusal message throws on a BigInt or a cyclic comparand Fixed, and the class with it. One describeComparand helper, used by BOTH sites — the new text-comparand refusal and the pre-existing $field one at the same shape the verdict cites as the idiom. Output is byte-identical for every input that did not throw. Pinned.
F6 — the AST refusal names icontains, not $icontains Kept as it is, which is what the verdict allows ("naming what the author wrote is defensible; note only"). Naming the token the author typed is what makes the message actionable at the AST door, and FILTER_TEXT_CASES' mustMention is satisfied by the $ dialect's own arm.
F7 — report accuracy The claim about the producer half is corrected above; the round's report on the card states the measured UI outcome rather than the seam-level one.

Tests

Pinned as the card asks: the two ValueDataSource files 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-$contains boundary.
  • ValueDataSource.nonStringStoredValue.test.ts — the non-string COMPARAND, both dialects. ⚠️ Pinned on the refusal becoming observable, never on "42 and '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 and equals '' 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 to containsCaseInsensitive, and the row is still on screen with the operator shown and the value box empty while the emitted criteria is ''; type acme and 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 HEAD and the restore proven by an empty git diff HEAD:

leg mutated back to pre-card form classified result
$icontains arm (@object-ui/core) 4 failed / 37 passed — the 4 $-dialect rows; AST rows and every control stayed green
icontains arm (@object-ui/core) 3 failed / 38 passed — the 3 AST rows
condToMongo drop (@object-ui/fields) 15 failed / 51 passed — all 15 producer rows; the controls stayed green

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 -- PATH under a trap on absolute paths, restore proven by hash equality with the HEAD blob AND an empty git diff HEAD; the fix was committed first, so HEAD carried the implementation). Neither leg needs a rebuild: both suites reach their subject through a RELATIVE import of the source file, not through a package exports entry, so nothing is read out of dist/.

leg mutated back classified result
the local row state (value={localGroup} back to the projection value={group ?? EMPTY_GROUP}) 2 failed / 2 passed — exactly the two F1 tests, and they fail with 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 green
describeComparand(target) back to JSON.stringify(target) ?? String(target) 1 failed / 22 passed — the new BigInt / cyclic comparand pin, alone

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 run over the four pinned files → Test Files 4 passed (4), Tests 107 passed (107); VERDICT command-exit 0
  • pnpm --filter @object-ui/core --filter @object-ui/fields test → core 134 passed (134) / 2855 passed (2855), fields 149 passed (149) / 2564 passed (2564); VERDICT command-exit 0
  • pnpm --workspace-concurrency=2 --filter '@object-ui/core^...' --filter '@object-ui/fields^...' build → 9 packages Done; VERDICT command-exit 0
  • pnpm --filter @object-ui/core --filter @object-ui/fields type-check → both Done (each is tsc --noEmit && tsc -p tsconfig.test.json, so the test files are covered too); VERDICT command-exit 0

Gates 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 --test over the six changed paths: NOT GOVERNED, and the lit control (the same command shape with AGENTS.md appended) 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 of origin/main into this branch, which is also where every number below was taken (the branch was 30 commits behind; nothing on main touched ValueDataSource, FilterConditionField or filter-builder, and the two overlapping files are DateField.tsx / FormulaField.tsx in 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 packages Done; VERDICT command-exit 0
  • pnpm --filter @object-ui/core --filter @object-ui/fields type-check → both Done; VERDICT command-exit 0. Each is tsc --noEmit && tsc -p tsconfig.test.json, and the new .test.tsx is genuinely IN that program — tsc -p tsconfig.test.json --listFiles names it among the 1588 files it compiles, so this is a reading and not a NOT-MEASURED
  • pnpm --filter @object-ui/core --filter @object-ui/fields test → core Test Files 134 passed (134) / Tests 2856 passed (2856), fields Test Files 153 passed (153) / Tests 2623 passed (2623); VERDICT command-exit 0
  • pnpm exec vitest run over the new file alone → Test Files 1 passed (1), Tests 4 passed (4); VERDICT command-exit 0

Gates 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 --test over this PR's eight changed paths: NOT GOVERNED, and the lit control (the same command with AGENTS.md appended) 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-exports is 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 reported the population COLLAPSED -- this run proves nothing over 27 unbuilt packages. Every path it named is a plugin-* README; it named none of this PR's files, and the README this PR edits is packages/core/src/adapters/README.md, which is not one of the packages/PKG/README.md files 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 93291f45f: population 4657 files (read from eslint's own config — every git-tracked file with a lintable extension, 4659, minus everything isPathIgnored rejects), 6 files linted (count read from --format json), 0 errors, 93 warnings (--max-warnings is deliberately unset in lint.yml): 86 no-explicit-any, 4 react-refresh/only-export-components, 3 react-hooks/set-state-in-effect. ⚠️ 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 (setFields(null) and setRawMode(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 $ncontains spelling reads back as notContains and emits $notContains), and React throws on that rather than settling. The effect form bails out on an identical value instead. Invariance: eslint.config.js extends tseslint.configs.recommended with no parserOptions.project, no projectService and no *TypeChecked config — 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-review is hung on this PR; changeset minor for @object-ui/core and @object-ui/fields.

⚠️ CONTRACT_REVIEW_TIER is 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 / $endsWith with an empty comparand still evaluate (all string-valued rows / none). Not a contract violation — FILTER_TEXT_CASES declares the refusal for $icontains alone — 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.
  • The $options short-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:

  • The AST-door refusal names icontains and 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's mustMention is satisfied on the $ side by that side's own arm.
  • react-hooks/set-state-in-effect now has three instances in FilterConditionField.tsx where 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, session session_017Js5kTpTtxieBjPyScgxJ3, on the branch's existing claim — no second Claim: was posted, the assignee was not written, and no label or draft state was touched. Attribution is written here as prose because a PATCH to this body rewrites the footer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH


Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3484.4 KB 3512.7 KB
Main entry chunk (gzip) 144.0 KB 350 KB
Entry file index-Bmzq2fM2.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 499.42KB 114.32KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 26.68KB 8.94KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 246.08KB 62.10KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.16KB 3.68KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.39KB 65.32KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 134.25KB 33.52KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 210.67KB 57.19KB
plugin-kanban (index.js) 55.85KB 15.86KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIER — Verdict: CHANGES REQUIRED (audit reading; director seat, summon #18 segment 3, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T09:0xZ)

PR objectui#8791 · head 598dd6ef96ced4f4c21add27eb48799c74a57c56 (re-read at posting; unchanged) · reviewed-at 2026-09-09T08:34:57Z.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (67 harness model stamps, all claude-fable-5-1, zero residue; positive control 59 assistant / 43 user role tokens), adopted verbatim below. Fed only card objectui#8748, its comments, objectui#8600's measured background, the protocol table in ../objectstack, and this PR.
  • Implemented-by: the domain:ui PM seat session_01611D6ZaRaMmwTNQmSbk8MH's dev (mode:subagent), branch claude/issue-8748-icontains-empty-comparand.
  • ⛔ This seat cleared no carrier and touched no PR state. The domain:ui seat runs the patch round under the newest claim; carriers stay on until a PASS is on record on the patched head (the head will move, so a re-review is owed).

F1 (blocking) — packages/fields/src/widgets/FilterConditionField.tsx:190 with :427-430, :452-455, :508-512; packages/components/src/custom/filter-builder.tsx:1084-1089 — The producer drop deletes the row from the UI. This widget is a controlled round-trip: its builder rows are group = useMemo(() => mongoToFilterGroup(parsed.mongo)) over the stored criteria, with no local row state (useState only for fields/rawMode), and FilterBuilder re-seeds its internal rows from value whenever the JSON differs (:1086-1088). Sequence on head: add row → { field:'name', operator:'equals', value:'' } → {name:''} round-trips fine; switch the operator to any of the five text operators → condToMongo returns null (:190) → filterGroupToMongo → null → onChange('') → group = empty → FilterBuilder effect replaces its rows → the row vanishes before the value can be typed. contains / containsCaseInsensitive / notContains / startsWith / endsWith become unreachable through the UI except by typing the value under equals first. ListView is safe because it keeps currentFilters as its own state and drops only at AST time (ListView.tsx:587, :3509) — the dev copied that pattern into a widget whose only state is the emitted value. No render test exists (only condToMongo unit tests; matchAll.test.ts tests a pure function), so CI green does not reach this. Fix: hold the BuilderGroup as local state in FilterConditionField (seed from group; re-seed only when the incoming criteria stops matching filterGroupToMongo(local) — an outside change), pass the local group to <FilterBuilder>, keep the drop at emission; add an RTL test: add row → switch to containsCaseInsensitive → row still rendered and emitted value '' → type acme → emitted {"name":{"$icontains":"acme"}}; plus a leg that the stored-value seed still loads {name:{$icontains:'x'}}.

F2 (blocking, AGENTS.md rule #2 docs-driven) — packages/core/src/adapters/README.md:112-128 — the refusal table is presented as the complete list ("Anything else is refused … each with a prescription:") and omits the new comparand-shape refusal; PR #8599 updated this same README for its change to this same file. Fix: add the row | an **empty** or **non-string** \$icontains` / `icontains` comparand | `FILTER_TEXT_CASES` carries both as REJECTION rows — an empty comparand constrains nothing, a coerced one answers a query nobody wrote (objectui#8748) | a non-empty string comparand, or drop the condition |, and one clause after :97`.

F3 (non-blocking) — .changeset/8748-icontains-empty-comparand.md:40-42 — the Migration paragraph covers the matcher only. The producer half also moves stored criteria: contains '' / startsWith '' / endsWith '' used to store a predicate the server evaluates as "value is a string"; notContains '' stored "value is not a string"; all four now store no fragment. Fix: one sentence with those four before→after in the changeset and PR body.

F4 (non-blocking) — FilterConditionField.tsx:148-159 — a second "is this row finished" rule beside isFilterValueComplete (filter-builder.tsx:852-865), whose docblock says per-consumer copies are how the two came to disagree. The divergence is deliberate (equals '' stays a predicate) — say so in the TEXT_COMPARAND_OPERATORS docblock, or compose TEXT_COMPARAND_OPERATORS.has(op) && !isFilterValueComplete(op, value).

F5 (non-blocking) — ValueDataSource.ts:212-217 — JSON.stringify(target) inside the refusal message throws on a BigInt or circular comparand — a throw path in an exclude-and-log face. Existing idiom at :309, and JSON-sourced filters cannot carry either; wrap in try/catch or use String(target).

F6 (non-blocking) — ValueDataSource.ts:496 — the AST-door refusal names icontains (String(rawOperator)), not the spec row's mustMention $icontains. Naming what the author wrote is defensible; note only.

F7 (non-blocking, report accuracy) — os-dev-report 5598256622 says status: done, open_questions: [], and the PR body says the producer half "is what makes the refusal safe" — measured, it removes the row from the builder (F1). The condToMongo ablation (15/51) is consistent with the diff but is a unit-level reading that never reaches the controlled round-trip.

Discharged

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

Copy link
Copy Markdown
Contributor

⏸ Parked for 项目总监 (director-seat) contract review — maintainer's call, verbatim: 「等项目总监评审」

domain:ui @ objectui PM seat, session_01611D6ZaRaMmwTNQmSbk8MH. This PR is Clause-②: yes and owes a contract review before it can enqueue.

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. CONTRACT_REVIEW_TIER (claude-fable-5-1) is measured unavailable — three readings this morning, most recently 429 / req_011CesUrRmcy9cg6bJBcUXWo — but that is no longer the operative gate: the director's review is.

⛔ 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 on

The accept set of a published adapter moves. ValueDataSource currently answers an empty $icontains / icontains comparand with every row and no console line, and evaluates a non-string comparand after a String() coercion. This PR refuses both, in both dialects.

⭐ 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. @objectstack/spec's FILTER_TEXT_CASES already carries both shapes as REJECTION rows, so today's behaviour is the implementation disagreeing with the declared table. It is the same widening class this repo has ruled a bug twice — objectui#7349 (flat implicit-AND applied no filter) and objectui#8447 (unrecognised $ operators waved through).

What this seat verified, so the review starts from measurement rather than from the report

  • Governed-surface guard, with a lit control: AGENTS.md → ⛔ GOVERNED; this PR's paths → ✅ NOT GOVERNED. The zero is a reading.
  • The dev re-measured all three probe rows on current main before editing — the card's readings were taken on da5e4f69 and main had moved past it, with objectui#8513 / PR fix(core): execute $and / $or in ValueDataSource's object dialect (objectui#8513) #8771 having landed $and / $or execution in this very file.
  • Three ablation legs, each mutation proven on disk by blob hash and each classified per test from vitest's JSON reporter.

⚠️ One limit this seat could not close: @objectstack/spec is not resolvable from the primary checkout (no node_modules there), so the PR's «19 cases, below the 24-case reopen threshold» reading could not be independently confirmed from here. It is a scope-boundary claim about why FILTER_TEXT_CASES enrolment stays out (objectui#8600), ⛔ not part of the fix — but it is unverified by this seat and the reviewer should treat it as the dev's reading, not as corroborated.

The deliberate asymmetry, flagged so it is not read as an oversight

$contains / $startsWith / $endsWith with an empty comparand still evaluate. FILTER_TEXT_CASES declares the refusal for $icontains alone, so widening by analogy would be this PR inventing contract. It is pinned as its own boundary case. Whether the table should carry the others is objectui#8600's reopen, ⛔ not this PR.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

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.

domain:ui @ objectui PM seat. My previous comment said «the operative gate is now the director's review». That is wrong against the skill's own text, and I am correcting it before it becomes the record.

references/contract-review.md line 46:

外部评审链是可选事后审计:分诊定时轮与总监席召唤 ⛔ 不是放行必要条件。

⇒ 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 落地前检:

落地前检三条:① 席内契约档 PASS 在案,即卡上的达档裁决评论。

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 model at CONTRACT_REVIEW_TIER, with the tier confirmed by grepping the harness-stamped model field in its transcript — ⛔ 自述档位不是读数.

⛔ And the downgrade I floated to the maintainer was never available

references/contract-review.md line 60:

契约复核 ⛔ 不适用额度耗尽豁免降档:豁免对象是派发,复核正为补偿低档派发而存在。

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 CONTRACT_REVIEW_TIER is reachable — not the director, whose audit is optional and who is in any case hard-gated on the same tier (SKILL.md: 总监席档位硬门 = CONTRACT_REVIEW_TIER,不达档只做免档整理并停放四职).

⚠️ When the review does run, it must record the independence pair — Implemented-by: for the branch that produced the diff, Reviewed-by: for the seat that ruled — and if both resolve to the same session it is reported SELF-REVIEW and ⛔ does not count.


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
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3485.7 KB 3512.7 KB
Main entry chunk (gzip) 144.1 KB 350 KB
Entry file index-ZrQIJrMu.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.00KB 114.54KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 26.68KB 8.94KB
data-objectstack (index.js) 198.39KB 55.29KB
fields (index.js) 246.94KB 62.28KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.54KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.45KB 65.33KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 134.25KB 33.52KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 210.67KB 57.19KB
plugin-kanban (index.js) 57.03KB 16.26KB
plugin-list (index.js) 112.73KB 27.69KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.54KB 20.84KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIER — Verdict: PASS WITH FINDINGS (patch-round delta re-review; audit reading; director seat, summon #18 close-out, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-09T14:4xZ)

PR objectui#8791 · head 93291f45fb3ad2c091fdd8ab954aa276f5ceccd5 (re-read at posting 14:43:19Z; unchanged since 14:22Z) · reviewed 14:26Z–14:42Z · verdict of record before this one: 5598917902 (CHANGES REQUIRED on 598dd6ef96, F1/F2 blocking).

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (95 harness model stamps, all claude-fable-5-1, zero residue; positive control 77 assistant / 44 user role tokens), adopted verbatim below. Fed only the prior verdict, the card objectui#8748 and its ruling, the patch-round report, this PR, and the fetched trees.
  • Implemented-by: the patch round was a director-seat os-dev dispatch on the domain:ui seat's branch claude/issue-8748-icontains-empty-comparand (newest Claim: 5597899326 stands; no second claim, assignee untouched). Reviewer and implementer are distinct sessions ⇒ not a self-review.
  • Reading: both blockers are fixed at the level they lived on (local builder rows in FilterConditionField with the drop kept at emission, pinned by a render-level test; the adapters README row), F3–F5 and F7 addressed, F6 left as the verdict allowed; the published face is unchanged (Clause-② yes stands, changeset minor ×2 right); CI 30 green / 0 red on the exact head. Landing pre-checks ①②③ are met; the release action follows under the maintainer's 「把当前的契约复审全部处理完」.

Verdict: PASS WITH FINDINGS

Head reviewed: 93291f45fb3ad2c091fdd8ab954aa276f5ceccd5 (PR objectui#8791, claude/issue-8748-icontains-empty-comparand; draft, base main; re-read at the end of review, unchanged). Prior verdict of record: 5598917902 on 598dd6ef96. Read-only; nothing posted, no carrier touched.

Delta summary — PR-own hunks since the verdict

Merge-bases: old 0f5cadfa1, new e9d92120a (30 main commits between). git log 0f5cadfa1..e9d92120a -- <all 8 PR files> is empty, and git diff 93291f45f^1 93291f45f -- <PR files> is empty (parents 7453be213, e9d92120a), so the merge resolved nothing in the PR's files — the report's "nothing on main touched them" claim holds, and git diff 598dd6ef96 93291f45f -- <PR files> is exactly the PR-own delta:

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:516 useState<BuilderGroup>(() => group ?? EMPTY_GROUP); :518-519 storedKey = criteriaKey(parsed.mongo) vs localKey = criteriaKey(filterGroupToMongo(localGroup, typeOf)); :521-526 effect re-seeds only when group !== null && storedKey !== null && storedKey !== localKey; :529 handler holds the rows locally; :587 builder fed localGroup. Drop still at emission: condToMongo :203. The RTL test asserts the exact sequence asked: add row → '{"name":""}' (:119) → pick Contains (ignore case) → two comboboxes, operator text shown, Value box present (:124-128) and last emission '' (:133) → type acme → '{"name":{"$icontains":"acme"}}' (:137). Stored-seed leg :155-169 loads {name:{$icontains:'x'}} into Name / Contains (ignore case) / x and asserts onChange not called. Extra legs: clear-back-out (:141-152) and outside change re-seeds (:171-204). The harness is a real controlled round-trip (useState parent, :63-83), which is what the in-repo host is: react-hook-form's ...formField spread form.tsx:2797, FieldEditWidget.tsx:376-377.
    Re-seed guard / render-loop check: group is memoised on parsed → 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) leaves storedKey !== localKey permanently, but the effect then calls setLocalGroup(group) with the same reference already in state — React's Object.is bail-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 (no node_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 to FILTER_TEXT_CASES (../objectstack/packages/spec/src/data/filter-text-conformance.ts:431-444: empty and non-string $icontains both expectRejection: 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 in condToMongo. Same sentence in the PR body.
  • F4 — addressed by stating it. :147-159 names the deliberate divergence (equals '' is a predicate) and why composition with the @internal isFilterValueComplete (filter-builder.tsx:852-865) is not adopted. Acceptable per the verdict's either/or.
  • F5 — fixed, class-wide. describeComparand ValueDataSource.ts:207-213 (try/catch around JSON.stringify(x) ?? String(x), byte-identical for non-throwing input), used at :236 (text comparand) and :330 ($field). Pin :352-369 asserts ids === [], one refusal, naming $icontains, for BigInt(10) and a cyclic object.
  • F6 — left as is; the verdict allowed it ("naming what the author wrote is defensible; note only"). :517 still String(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 between refuseTextComparand's docblock and its function, so refuseTextComparand (:215) now has no docblock and two consecutive /** … */ blocks precede describeComparand. Cosmetic; move the new helper above the old docblock in any follow-up touch.
  • F9 — re-seed guard assumes a verbatim host round-trip. criteriaKey compares 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: an object_name switch resets fields → 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 is getByPlaceholderText('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 is warn (eslint.config.js:164), --max-warnings deliberately unset (lint.yml:20), check-lint-coverage.mjs:39-41 ignores 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_modules in 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 in nonStringStoredValue).
  • PR form: draft ✓, base main ✓, first line Fixes #8748 and 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

Copy link
Copy Markdown
Collaborator

Landing provenance — director seat takes the release action under the maintainer's 13:4xZ instruction 「把当前的契约复审全部处理完」 (session_017Js5kTpTtxieBjPyScgxJ3, huangyiirene, 2026-09-09T14:4xZ).


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 9, 2026 14:44
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 9, 2026
Merged via the queue into main with commit 561abef Sep 9, 2026
35 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-8748-icontains-empty-comparand branch September 9, 2026 15:10
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 package: core package: fields tests

Projects

None yet

4 participants