Skip to content

fix(core): the TRUE-identity fold counts only the keys the loop processed - #9166

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-9030-and-fold-skipped-keys
Sep 11, 2026
Merged

os-tesla merged 1 commit into
mainfrom
claude/issue-9030-and-fold-skipped-keys

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #9030

What was wrong

convertFiltersToAST folds a filter that is nothing but TRUE-identity combinators to undefined (objectui#8770), and folds a filter whose every key the loop SKIPPED for a null / undefined value to undefined too (objectui#9020). Each fold compared its own count with Object.keys(filter).length.

But the loop's first statement skips the null/undefined keys without incrementing anything the identity fold counts, while that denominator counted them anyway. So one identity group beside one skipped key read 1 === 2, the fold declined, and the caller's original object came back — even though each of those keys, taken alone, folds:

filter folded before folded now
{ $and: [] } yes yes
{ b: undefined } yes yes
{ $and: [], b: undefined } no yes

Read the other way round: adding an always-TRUE $and: [] to a filter that folded could stop it folding. That is behaviour decided by a sibling — the hazard objectui#8555 named on this same function.

The repair

The identity fold's denominator is now the count of keys the loop actually processed — every key minus the ones its own first statement skipped. The skip is untouched, the two counts stay apart, and the second arm keeps its own count and its own guard. One denominator moved.

The family census, measured on this branch

⭐ The dispatch asked for the family's boundary rather than the card's one literal. The family is stated as a rule, not a list: a filter that produces no condition and whose every key was either a TRUE-identity group or a key the loop skipped. The rows below walk that rule's degrees of freedom — which identity spelling, which skipped spelling, key ORDER, more than one of each, and an identity group that only becomes one because its child folded. Driven twice through the same harness, once per tree:

                                                    BEFORE            AFTER
{ $and: [] }                                        undefined         undefined
{ $or: [{}] }                                       undefined         undefined
{ $and: [{}] }                                      undefined         undefined
{ $and: [], $or: [{}] }                             undefined         undefined
{ $and: [{ $and: [] }] }                            undefined         undefined
{ $and: [{ a: null }] }                             undefined         undefined
{ a: null }                                         undefined         undefined
{ b: undefined }                                    undefined         undefined
{ a: null, b: undefined }                           undefined         undefined
{ a: null, c: null }                                undefined         undefined
{ $and: [], a: null }                               THE OBJECT        undefined   <- moved
{ $and: [], b: undefined }                          THE OBJECT        undefined   <- moved
{ $or: [{}], b: undefined }                         THE OBJECT        undefined   <- moved
{ $and: [{}], a: null }                             THE OBJECT        undefined   <- moved
{ b: undefined, $and: [] }                          THE OBJECT        undefined   <- moved
{ $and: [], $or: [{}], a: null, b: undefined }      THE OBJECT        undefined   <- moved
{ $and: [{ a: null }], b: undefined }               THE OBJECT        undefined   <- moved
{}                                                  THE OBJECT        THE OBJECT
{ a: {} }                                           THE OBJECT        THE OBJECT
{ a: {}, b: undefined }                             THE OBJECT        THE OBJECT
{ $and: [], a: {} }                                 THE OBJECT        THE OBJECT
{ $or: [] }            (FALSE identity)             ['$or','=',[]]    ['$or','=',[]]
{ $or: [], a: null }                                ['$or','=',[]]    ['$or','=',[]]
{ $and: [], $or: [] }                               ['$or','=',[]]    ['$or','=',[]]
{ $and: [], a: 'x' }                                ['a','=','x']     ['a','=','x']
{ a: null, s: 1 }                                   ['s','=',1]       ['s','=',1]
{ a: 'x' }                                          ['a','=','x']     ['a','=','x']
{ a: { $null: true } }                              ['a','is_null',true]  ['a','is_null',true]

Exactly the seven mixed members moved. Every other row is byte-identical. The lit control is the last block: a plain filter's AST, the FALSE identity, the skip pin, and the three object-tail shapes are all unchanged, and {} / { a: {} } prove the fold did not become "no conditions were produced".

⚠️ Two mechanism assumptions from the card, re-measured — one falsified

  1. { $and: [], b: undefined } is still not folded. CONFIRMED on this tree (row above).
  2. { $and: [], a: null } should stay excluded, because a null-valued key rides the $expand route as a real a IS NULL predicate. ⛔ FALSIFIED — the card measured that before objectui#9020 (PR objectui#9080) landed. That card ruled the other way without touching the skip: the key contributes no condition, so a filter that is nothing but such keys says so, and { a: null } no longer reaches either route as a predicate. Keeping null out would also have meant reading the two spellings apart for the first time anywhere in this file, and would have left { $and: [], a: null } as the one member of the family still 400'ing on the plain route.

⭐ Both landed sibling test files named the two mixed cases as one pair and deferred both to this card in so many words — "Whether it SHOULD fold is objectui#9030's open question, deliberately not answered by either card." So this card answers the pair the code itself posed, and does not invent a new member.

The wire, driven rather than inherited

The card asked for this explicitly: "Whoever takes it should confirm, rather than inherit from here, that undefined-valued keys really do vanish on BOTH wire routes." filter-identity-skipped-mix-two-routes-9030.test.ts drives every member down both find() routes through a mocked fetch, compares the two answers with each other before either is compared with a literal, and reads the selected rows back through ValueDataSource's matcher. It also asserts the parameter KEYS, which is the half a filter= assertion cannot see: the plain route's failure was never a wrong filter value, it was an object spread into query parameters, so $and became a parameter NAME and the request was refused outright.

The two members failed in two different directions before this change, and that asymmetry is stated in the file rather than smoothed over: { $and: [], b: undefined } had JSON.stringify drop its second key, so the $expand route shipped the ruled answer by accident while the plain route 400'd; { $and: [], a: null } had the $expand route ship a real a IS NULL predicate the converter had already dropped.

Ablation — the pins can fail

⛔ Never read by an exit code. Mutation and restore are each proven on disk, by state.

The mutation puts the old denominator back — trueIdentityGroups === keysTheLoopProcessed becomes trueIdentityGroups === Object.keys(filter).length. Object.keys(filter).length still legitimately appears twice elsewhere in the file (inside keysTheLoopProcessed itself, and in the all-skipped arm), so a bare grep for it proves nothing; the anchor is the whole comparison, counted both ways, and the mutated line is printed rather than counted:

HEAD blob for target            : 868ce24fee93cf165c64a3fb8ae9615a6629a78b
on-disk hash BEFORE mutation    : 868ce24fee93cf165c64a3fb8ae9615a6629a78b
--- occurrence counts BEFORE mutation
  'trueIdentityGroups === keysTheLoopProcessed' : 1
  'trueIdentityGroups === Object.keys(filter).length' : 0
--- occurrence counts AFTER mutation (both directions)
  'trueIdentityGroups === keysTheLoopProcessed' : 0   (must be 0)
  'trueIdentityGroups === Object.keys(filter).length' : 1   (must be 1)
--- the mutated line, printed:
796:    if (trueIdentityGroups > 0 && trueIdentityGroups === Object.keys(filter).length) {
on-disk hash AFTER mutation     : 01ac4381b944db3e73621ec3ced6692568d1b53d

The run on the mutated tree — the pins CAN fail:

ablation vitest exit            : 1
 Test Files  5 failed | 1 passed (6)
 Failed Tests 48

⚠️ The one file that stayed green is filter-all-skipped-two-routes-9020.test.ts, and that is by construction, not by luck: its mixed-filter control was moved in this PR onto { $and: [], a: {} } — the boundary this card does not touch — precisely so that file keeps measuring what it was written to measure. Naming it rather than letting "5 of 6" read as a near miss.

Two readings the ablation produced that are the defect itself, taken from the failure output:

{ $and: [], a: null } — both routes agree
  AssertionError: expected [ '1', '2', '3', '4' ] to deeply equal [ '2' ]
      plain route selected every row; $expand route selected the a = null subset

{ $and: [], a: null } puts no $-prefixed parameter on the wire
  AssertionError: expected [ '$and' ] to deeply equal []
      GET …/data/account?%24and=      <- the field became a parameter NAME

{ $and: [], b: undefined } — both routes agree
  AssertionError: expected undefined to deeply equal { '$and': [] }
      plain route sent no filter at all; $expand route sent filter={"$and":[]}

The restore, proven by state:

on-disk hash AFTER restore      : 868ce24fee93cf165c64a3fb8ae9615a6629a78b   (= the HEAD blob)
git diff HEAD --stat            : ''                                          (empty = clean)
  'trueIdentityGroups === keysTheLoopProcessed' restored count : 1
=== CONTROL RUN (restored tree) — the same pins must go GREEN
restored vitest exit            : 0
 Test Files  6 passed (6)

The script carries a trap on EXIT, INT and TERM that calls its restore function, with an absolute REPO_ROOT; it restores with git checkout HEAD -- PATH naming the absolute path (never a bare git checkout -- PATH, which takes the mutation back out of the index), and it treats an empty git hash-object reading as FAILURE rather than as "nothing to compare".

Tests

Every exit code below was captured by REDIRECT before any pipe. Heavy runs went through this container's shared verify lock; each printed VERDICT command-exit 0.

what command result
affected packages, full pnpm exec vitest run packages/core/ packages/data-objectstack/ 213 files, 4061 tests, all passed
the seven touched files same runner, paths named 7 files, 155 tests passed
type-check, core pnpm --filter @object-ui/core run type-check exit 0 (tsc --noEmit && tsc -p tsconfig.test.json)
type-check, data-objectstack pnpm --filter @object-ui/data-objectstack run type-check exit 0
changeset presence node scripts/check-changeset-presence.mjs exit 0 — "5 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)"
line citations node scripts/check-new-cross-file-line-citations.mjs exit 0 — "0 new citation(s)"
control bytes node scripts/check-control-bytes.mjs exit 0 — 7359 tracked text files scanned
vi.mock specifiers / inherit node scripts/check-vi-mock-specifiers.mjs, …-inherit.mjs exit 0, exit 0
governed surface node scripts/check-governed-queue-guard.mjs --test … "NOT GOVERNED — 4 path(s) checked against 5 governed surface(s); none matched"

⚠️ Both type-checks first returned exit 2 with TS6305 — a PREREQUISITE NOT MET, not a red gate. packages/types/dist had not been built in this fresh worktree. Re-run after pnpm --workspace-concurrency=2 --filter '@object-ui/core^...' --filter '@object-ui/data-objectstack^...' build: both exit 0. Recorded rather than quietly re-run.

⭐ The new test files really are type-checked, verified rather than assumed: tsc -p tsconfig.test.json --listFiles in packages/core names filter-identity-skipped-mix-9030.test.ts, and tsc --noEmit --listFiles in packages/data-objectstack names filter-identity-skipped-mix-two-routes-9030.test.ts. A typecheck that excludes **/*.test.ts would have been NOT MEASURED, not green.

Lint — narrowed, with all three pieces of evidence

Ran eslint . for the two affected packages only, not the repo-wide pnpm lint (turbo run lint). Both exit 0.

  1. The population, read from the repo's own config rather than guessed: all 47 workspace packages define a lint script, so turbo run lint is a 47-package farm. This run covered 2 of them — the 2 that contain every file in this diff.
  2. The file counts, read from --format json: @object-ui/core 252 files linted (0 errors), @object-ui/data-objectstack 70 files linted (0 errors). The seven files this PR touches report 0 errors and 0 new warnings; the pre-existing @typescript-eslint/no-explicit-any warnings on filter-converter.ts (7), filter-true-identity-8770.test.ts (4) and filter-text-comparand-9001.test.ts (5) sit on lines this PR does not add or move, and the two new files carry 0 warnings.
  3. Invariance for the 45 packages not run: this repo's eslint.config.js extends tseslint.configs.recommended, not recommendedTypeChecked, and declares no parserOptions.project and no projectService. Type-aware linting is therefore off, so a file's verdict depends only on its own bytes and on the shared config — neither of which this diff moves outside those two packages. The narrowing excludes nothing.

The full 47-package farm belongs to CI, which runs it on this branch.

Acceptance notes

Found while measuring this card, not fixed here, and not silently dropped:

  • ⭐ Filed as objectui#9164 — convertFiltersToAST still hands an EMPTY OPERATOR MAP back as the caller's object. { a: {} } reaches the plain route as ?a=[object+Object] (the field name became a query-parameter NAME, no filter parameter at all) and the $expand route as filter={"a":{}}, the widening @objectstack/spec refuses. Driven through both routes with a mocked fetch, controls lit in the same run. It is the last member of this tail still violating objectui#8770's own stated invariant, it satisfies none of the three folds by construction, and its repair is a different shape — fold, refuse, or lower — which needs a ruling. ⛔ Deliberately not touched here: it is the live control that proves this PR's fold did not become "no conditions were produced".
  • noted, not filed: skippedNullKeys counts undefined-valued keys too, and its own docblock says so. A naming nit on a landed identifier; renaming it would touch objectui#9020's freshly-landed lines for no behavioural reason. Successor: objectui#9164, whose repair edits this same tail.
  • noted, not filed: .changeset/all-skipped-filter-tail-9020.md — still pending, so it ships in the same CHANGELOG entry as this one — contains the sentence "({ $and: [], a: null }) still returns the object — objectui#9030's open question", which this PR makes false. ⛔ Not edited: it is another PR's delivered artifact and rewriting it would restate history. This PR's own changeset names the superseded sentence instead, so the two read as a narrative rather than as a contradiction. Successor: the next release's changelog assembly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ


Generated by Claude Code

…ssed

`convertFiltersToAST` folds a filter that is nothing but TRUE-identity
combinators to `undefined` (objectui#8770), and folds a filter whose every
key the loop SKIPPED for a null/undefined value to `undefined` too
(objectui#9020). Each fold compared its own count with
`Object.keys(filter).length`.

The loop's first statement skips the null/undefined keys without
incrementing anything the identity fold counts, while that denominator
counted them anyway. So one identity group beside one skipped key read
`1 === 2`, the fold declined, and the caller's original object came back —
though each of those keys, alone, folds. Behaviour decided by a sibling:
adding an always-TRUE `$and: []` to a folding filter could stop it folding.

The denominator is now the count of keys the loop actually processed. Both
skipped spellings come along: the loop skips them with one statement, and
objectui#9020 already ruled what that skip means when it is all that is
left. The two counts stay apart — only one denominator moved.

Unchanged and pinned as controls: the skip itself, the FALSE identity
`{ $or: [] }`, an identity group beside a key that does lower, a plain
filter's AST, and the object tail for `{}`, `{ a: {} }` and an empty
operator map beside a skipped key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 51 chunks) 3091.0 KB 3134.8 KB
Main entry chunk (gzip) 144.2 KB 350 KB
Entry file index-BzEASEi-.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.20KB 114.67KB
core (index.js) 8.28KB 3.31KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 209.38KB 57.92KB
fields (index.js) 247.14KB 62.34KB
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) 8.87KB 3.64KB
i18n (index.js) 5.21KB 2.26KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 32.15KB 10.49KB
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.95KB
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.52KB 19.98KB
plugin-chatbot (index.js) 195.32KB 46.51KB
plugin-dashboard (index.js) 131.22KB 34.63KB
plugin-designer (index.js) 215.68KB 44.27KB
plugin-detail (index.js) 252.16KB 65.43KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.79KB 34.19KB
plugin-gantt (index.js) 166.94KB 41.04KB
plugin-grid (index.js) 211.56KB 57.50KB
plugin-kanban (index.js) 46.10KB 14.33KB
plugin-list (index.js) 112.58KB 27.65KB
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.54KB 3.31KB
plugin-view (index.js) 84.42KB 20.80KB
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) 94.03KB 31.02KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
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.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
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 Author

派单席评审 —— ⭐ ACCEPT,落地。open question 答 A

domain:ui 派单席 · R16 · 全部在源头复核。

open question:A,而且理由是可核的,不是偏好

卡建议「收 undefined、把 null 挡在外面」,理由是 null 值键在 $expand 路线上会变成真的 a IS NULL 谓词。⇒ 那条理由是在 objectui#9020 落地之前测的,而 objectui#9020 把它证伪了:{ a: null } 今天在两条路线上都不再作为谓词到达。

而决定性的一条我到源头核了——两个已落地的兄弟测试文件把这两个成员当作一对,并逐字把它们一起推给本卡:

filter-true-identity-8770.test.ts     「…objectui#9030's open question, deliberately not answered by either card.」
filter-all-skipped-9020.test.ts       「…objectui#9030's open question, and neither card answers it.」
filter-all-skipped-two-routes-9020.test.ts 「Whether it SHOULD fold is objectui#9030's open question…」
filter-converter.ts                   「…which is objectui#9030's open question and deliberately not…」

⇒ 四处,两个包,都把这一对整体挂在本卡上。一对被一起提出的问题,被一起回答,是对的。

选 B 还会带来两笔代价,执行席都写了出来,我认同:在这个文件里第一次把 null 与 undefined 分开读(循环用一条语句跳过两者);以及把 { $and: [], a: null } 留成这一族里唯一仍然在 plain 路线上 400 的成员——那正是 objectui#8770 存在要终结的缺陷。

⭐⭐ 而让这件事从「判断」变成「测量」的,是那两条 wire 读数

修复前,同一条 authored filter:

plain   { $and: [], b: undefined } → ?$and=   无 filter 参数  → 400 UNSUPPORTED_QUERY_PARAM
expand  { $and: [], a: null }      → filter={"$and":[],"a":null}  携带一个转换器已经判定「不贡献任何条件」的 a IS NULL
        ⇒ plainRows ['1','2','3','4']   vs   expandRows ['2']

⇒ 一条筛选,两个行集,而且两个都不等于转换器对它任何一个键单独给出的答案。 $and 甚至成了查询参数的名字。⇒ 旧行为没有任何站得住的读法。

⭐ 而钉子的 oracle 是两条路线之间的分歧,先互相比、再与字面量比 —— 「一个只驱动单条路线并断言形状的测试,会在一个让两者以不同方式继续分歧的修复上变绿」。这个形状是从 objectui#9020 的文件继承来的,继承对了。

修复是减法,不是合并计数器 —— 而证明这一点的控制项移动了位置而不是消失

const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) return undefined;

⇒ trueIdentityGroups > 0 仍然守着这一臂,skipped 计数仍有自己的一臂,两个状态仍然分得开;动的只是其中一个分母。

⭐ 而「没有被合并」的控制项换了位置:原来是 { $and: [], a: null },现在是 { a: {} } —— 一个被循环进入过、却没有产出任何条件的键,⇒ 它既不是恒真组也不是被跳过的键,留在分母里、没有任何一臂认领它。一个改成「只要没产出条件就折叠」的修法会把它吞掉,而落地的这个不会。 三个兄弟文件里的旧控制项被逐一改写成这个新边界,每一处都写明「⚠️ UPDATED」和为什么。

⇒ 这与本轮 objectui#9058 学到的是同一条:一个钉子的证据价值会随它周围的树改变而到期;到期时要把它重新指向仍然会红的那个边界,而不是删掉。

⛔ 围栏逐条成立

  • ⛔ 跳过 null/undefined 的最老行为未动:{ a: null, s: 1 } 仍然 lower 成 ['s','=',1],两条路线都钉了;
  • ⛔ FALSE 恒等 { $or: [] } 仍然选中零行(并且在旁边有被跳过的键、有恒真组时都不变);
  • ⛔ objectui#9152 的共享拒绝模块未卷入;
  • ⛔ 空 filter {}、空算子映射 { a: {} }、{ a: {}, b: undefined }、{ $and: [], a: {} } 全部逐字不变。

⭐ 两条处置分寸,值得点名

  1. objectui#9164 已另立(空算子映射在 plain 路线上变成 ?a=[object+Object]——字段名成了参数名——在 expand 路线上成为 spec 拒绝的加宽),三条候选修法都写出来了、需要裁决。⇒ 这是本 PR 刻意不动的边界,被立成卡而不是顺手改掉。
  2. ⭐ 它发现另一份仍待发布的 changeset(objectui#9020 的)里有一句被本 PR 变成假的话,而它没有去改——「that is another PR's delivered artifact」——改为在自己的 changeset 里点名那句被取代的句子。⇒ 不去动别人已交付的记录,而是让自己的记录承担更正,这个分寸是对的。

Contract review

Reviewed head: PR objectui#9166 的当前 head · Card: objectui#9030

① Derived judgments

⛔ 无任何已声明面移动:无新导出符号、无 schema 变更、@objectstack/spec 只被读取(isFilterAST 被当作判据门使用,⛔ 不是被重述)。改的是一个已导出函数在一族被裁过的输入上的返回值,签名与返回类型未动。

② Semver

'@object-ui/core': patch。行为变更只发生在旧行为无任何站得住读法的那一族上——plain 路线 400、expand 路线携带一个已被判定不贡献条件的谓词、两条路线给出两个行集。按本轮改准的判据,这是修复的代价而非改动的目的 ⇒ 席位落地,⛔ 不上人工门槛。

③ Boundary flags

边界 读数
新增已发布导出 / 依赖 ⛔ 无
null/undefined 跳过 ⛔ 未动,两条路线均有钉子
FALSE 恒等 ⛔ 未动
两个计数臂 ⛔ 未合并——控制项移到 { a: {} } 并在四处重写
⚠️ 刻意未动的边界 空算子映射 ⇒ 另立 objectui#9164,三条候选修法待裁
⚠️ 待发布 changeset 的残句 由本 PR 的 changeset 点名更正,⛔ 未编辑他人交付物

Independence pair

Implemented-by:  claude/issue-9030-and-fold-skipped-keys   (mode:subagent dev — no session of its own)
Reviewed-by:     session_01UzHd6hDYatoDn17BuwKxnZ

⚠️ 本行默认档的席内复核,⛔ 不是独立第二会话。

Verdict

PASS。 摘掉两个载体上的 needs:contract-review。flip ready + SQUASH 入队。

⚠️ 两条诚实处照记:两个包的 type-check 首次都返回 exit 2(TS6305,新 worktree 里 packages/types/dist 未构建),被标为 PREREQUISITE NOT MET 而非红门,构建后重跑绿;ablation 里有一个文件按构造保持绿(filter-all-skipped-two-routes-9020.test.ts,因为它的 mixed 控制项已被本 PR 移到本卡不触碰的边界上),这一点被说了出来而不是被当成噪声。

domain:ui 派单席 · session_01UzHd6hDYatoDn17BuwKxnZ · R16


_Generated by Claude Code


Generated by Claude Code

@os-tesla
os-tesla marked this pull request as ready for review September 11, 2026 13:27
@os-tesla
os-tesla enabled auto-merge September 11, 2026 13:27
@os-tesla
os-tesla added this pull request to the merge queue Sep 11, 2026
Merged via the queue into main with commit 50798f3 Sep 11, 2026
38 checks passed
@os-tesla
os-tesla deleted the claude/issue-9030-and-fold-skipped-keys branch September 11, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants