Skip to content

fix(core): a non-string stored value satisfies not_contains instead of failing the operator and its negation both - #8599

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-8452-not-contains-non-string
Sep 8, 2026
Merged

os-justin merged 2 commits into
mainfrom
claude/issue-8452-not-contains-non-string

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8452

What was wrong

ValueDataSource's two negation arms — not_contains (AST / infix dialect, matchesComparisonNode) and $notContains ($ dialect, matchesDollarOperator) — were both written as:

return typeof value === 'string' && !value.includes(String(target));

The leading typeof is a type test standing in for the predicate. A row whose column holds the number 5 fails contains '5' (correct — a number cannot contain a substring) and also fails not_contains '5' (wrong — for that same reason it does not contain it). The row is excluded from both halves of a partition: it appears in no filter answer at all, and the opposite filter, the one thing a user has to debug a missing row with, is silent too.

Measured on 21529629c over an eight-row fixture mixing numeric, string, boolean, null and absent values:

filter before after
['score','contains','5'] ['s5'] ['s5'] — unchanged
['score','not_contains','5'] ['sx'] ['n5','n50','n0','sx','bool','nul','gone']

Six of the eight rows were in neither half.

The direction is ruled upstream, not decided here

objectstack#14079, maintainer ruling 2026-09-05, option A. Quoted verbatim from the contract that carries it — packages/spec/src/data/filter-text-conformance.ts in @objectstack/spec, section "A stored value that is not a string":

The ruling took the type-gate (option A): a stored value that is not a string never satisfies a positive text operator ($contains / $startsWith / $endsWith / $icontains / $like / $ilike) and satisfies $notContains — complementarity holds, on every face.

FILTER_TEXT_CASES pins it as five rows over a numeric score column, and driver-memory's reference matcher — the face the defect was originally measured on — now answers the predicate:

if (typeof value === 'string' && value.includes(target)) return false;

This adapter's two arms are that same expression, negated the same way:

return !(typeof value === 'string' && value.includes(String(target)));

No second dialect was needed and none was invented. The only other statement about this operator in the package is filter-converter.ts's $not refusal, which says the negated operators "follow each operator's own answer for a missing value" — this change is what supplies that answer here, so the two files agree.

The same axis on the other text operators — checked, already conformant

The card asked for the whole class, not one spelling. Both dialects were swept; the vocabulary has exactly one negated text operator.

operator non-string stored value verdict
contains / $contains never matches already correct, unchanged, now pinned
icontains / $icontains never matches already correct, unchanged, now pinned
starts_with / $startsWith never matches already correct, unchanged, now pinned
ends_with / $endsWith never matches already correct, unchanged, now pinned
not_contains / $notContains must match the defect — both arms changed
like / ilike n/a refused by name with a prescription; this matcher has no pattern engine, unchanged

VALID_AST_OPERATORS and ALL_OPERATORS (read from the installed @objectstack/spec) carry no other negated text spelling — no not_starts_with, no not_icontains — so the class is shut by these two arms. The type gate on the positive family is deliberately kept: it is the other half of the same ruling, not a leftover to clean up, and the code comment says so.

Evidence — four legs, each mutation proven on disk in both directions

Harness: pnpm exec vitest run packages/core/src/adapters/__tests__/ValueDataSource.nonStringStoredValue.test.ts, from the repo root, paths not behind a bare separator. Every leg restores by state: git hash-object PATH compared against git rev-parse HEAD:PATH, plus git diff HEAD empty; the script traps on EXIT INT TERM with absolute paths.

leg mutation on-disk proof vitest exit AssertionError lines harness-death lines
B control none, tree as committed n/a 0 0 0
A fix removed both arms back to the typeof spelling injected 2, fixed spelling remaining 0, lines 1103 to 1103 (one-for-one swap) 1 5 0
C caricature both arms return true unconditionally injected marker 2, fixed spelling remaining 0, lines 1103 to 1103 1 7 0
D empty file the test file truncated to 0 bytes lines 265 to 0, bytes 0 1 0 3

LEG C is the caricature the card named — "not_contains returns true for every value" restores the partition and makes the operator mean nothing. It is not predicted here, it was executed. Three cases go red in C and not in A, and they are precisely the string-side, load-bearing ones:

  • `not_contains` still EXCLUDES a real string that contains the comparand
  • `$notContains` still withholds the string that contains the comparand
  • the inclusion half is non-empty and the exclusion half is not everything

One case goes red in A and not in C — a null and an absent key both satisfy not_contains — because the caricature happens to admit those rows. So every discriminating assertion was observed to fail in the leg it was written for; none of them passed by comparing one absent thing to another.

LEG D is the harness-death control, and it corrected the instrument rather than confirming it. The alternation borrowed from objectui#8582 (Cannot read properties of null, Unable to find, TypeError, is not a function) matched 0 lines on this repo's empty-file mode: vitest here reports Failed Suites / Error: No test suite found in file / Tests no tests, none of which is a TypeError. Counted with the narrow alternation alone, leg D would have read as "a clean run with zero assertion failures" — the exact misreading the leg exists to prevent. The widened alternation (adding No test suite found, Failed Suites, Tests no tests) reads 3 on D and 0 on B, A and C, so it separates the two failure modes without firing on a real red.

Nothing in this change refuses anything, so objectui#8530's envelope-only-refusal hazard has no surface here: there is no new error code, status or message to pin.

Other checks

Run from the repo root, exit codes captured before any pipe.

  • pnpm exec vitest run packages/core/ — 132 files, 2788 tests, all pass (re-run after merging current main in).
  • pnpm exec vitest run over the eight packages that consume ValueDataSource (react, types, data-objectstack, plugin-designer, plugin-map, plugin-gantt, plugin-list, fields) — 592 files, 8505 tests, all pass.
  • pnpm --filter '@object-ui/core^...' build then pnpm --filter @object-ui/core type-check — exit 0, both programs (tsc --noEmit and tsc -p tsconfig.test.json). Verified with tsc -p tsconfig.test.json --listFiles that the new test file really is in the test program: 1 hit, and the control (the sibling textOperatorCase file) also 1.
  • eslint . over the whole of packages/core at c3fbc7b14 — 231 files in eslint's own selection, 0 errors, 532 warnings, all pre-existing no-explicit-any. The new test file contributes exactly 1 warning, identical to the sibling ValueDataSource.textOperatorCase.test.ts baseline.
  • check:control-bytes OK (6789 files); check:doc-fences OK; check-changeset-presence / check-changeset-fixed / check-changeset-no-major / check-changeset-overwrite OK; check:vi-mock-specifiers / check:vi-mock-inherit / check:unreferenced-sources OK.
  • Zero-width and control characters: a Python scanner over all four changed files reads 0 hits, with a lit control string that reads 2. check:control-bytes does not cover zero-width spaces inside string literals, which is why the separate scan exists.
  • node scripts/check-governed-queue-guard.mjs --test over the four changed paths: NOT GOVERNED — an ordinary pull request.

Declared narrowing: the repo-wide turbo run lint and the full pnpm test farm are left to CI. The diff touches one package, and eslint.config.js configures no type-aware linting (0 hits for projectService / parserOptions / project:; lit control rules reads 11), so this diff cannot move the verdict on any file it does not edit.

History cited by sha

Verified with git log -1 and git show --stat in a non-shallow checkout rather than taken on trust:

  • f76f43628 — $-operator vocabulary and the refuseFilterNode idiom, ValueDataSource.ts
  • e76634cc8 — comparand shapes (array by reference, { $field })
  • f5cfbbd81 — both filter-converter arms gate the comparand
  • 617707a48 — $and / $or lowered to real AST group nodes

refuseFilterNode is defined at ValueDataSource.ts:65; FilterOperatorError at filter-converter.ts:53. Different files, different conventions — this change touches neither idiom.

Base

Branched from 21529629c; current main (70c452369) was brought in by merge, not rebase, and the branch has never been force-pushed. The merge touched nothing this change depends on, and packages/core was re-verified afterwards.

Not addressed here, deliberately

  • Enrolling ValueDataSource in FILTER_TEXT_CASES itself. That table's own rule 2 is that a face's rows join it in the pull request that shuts the gap, and enrolling means answering all of it, including the folding and comparand-literalness rows. Out of scope for this card; recorded for the PM instead.
  • objectui#8447 is already landed — the $ dialect refuses what it does not recognise, so no unimplemented arm returns every row underneath this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

…than failing it and its negation both

`ValueDataSource`'s `not_contains` and `$notContains` arms were written as
`typeof value === 'string' && !value.includes(target)` — a TYPE test standing in
for the predicate. A row whose column held the number 5 failed `contains '5'`
(correct: a number cannot contain a substring) and also failed `not_contains '5'`
(wrong: for that same reason it does not contain it), so the row was excluded from
both halves of a partition. No filter answer included it, and the opposite filter —
the one thing a user has to debug a missing row with — was silent too.

Both arms are now the exact complement of `contains` / `$contains`:
`!(typeof value === 'string' && value.includes(String(target)))`. The positive text
operators are unchanged and keep their type gate; that is the other half of the same
ruling, not a leftover.

objectstack#14079, maintainer ruling 2026-09-05, option A, as carried by
`filter-text-conformance.ts` in `@objectstack/spec`: "a stored value that is not a
string never satisfies a positive text operator (`$contains` / `$startsWith` /
`$endsWith` / `$icontains` / `$like` / `$ilike`) and satisfies `$notContains` —
complementarity holds, on every face."

Measured before the change, over a fixture of eight rows mixing numeric, string,
boolean, null and absent values: `['score','contains','5']` answered `['s5']` and
`['score','not_contains','5']` answered `['sx']` — six of eight rows appeared in
neither half. After: the same two filters partition all eight rows exactly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
@github-actions github-actions Bot added documentation Improvements or additions to documentation package: core tests labels Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3476.3 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-CHBjCh2b.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) 498.93KB 114.12KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 192.72KB 53.55KB
fields (index.js) 243.54KB 61.49KB
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) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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.00KB 13.91KB
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) 213.21KB 43.63KB
plugin-detail (index.js) 250.72KB 64.81KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.30KB 56.63KB
plugin-kanban (index.js) 55.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
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.33KB 3.25KB
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 (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

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 tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(core): not_contains on a non-string stored value fails the operator AND its negation — against the platform's objectstack#14079 ruling

2 participants