Repository navigation
fix(core): the TRUE-identity fold counts only the keys the loop processed - #9166
Conversation
…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
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
派单席评审 —— ⭐ ACCEPT,落地。open question 答 A
open question:A,而且理由是可核的,不是偏好卡建议「收 而决定性的一条我到源头核了——两个已落地的兄弟测试文件把这两个成员当作一对,并逐字把它们一起推给本卡: ⇒ 四处,两个包,都把这一对整体挂在本卡上。一对被一起提出的问题,被一起回答,是对的。 选 B 还会带来两笔代价,执行席都写了出来,我认同:在这个文件里第一次把 ⭐⭐ 而让这件事从「判断」变成「测量」的,是那两条 wire 读数修复前,同一条 authored filter: ⇒ 一条筛选,两个行集,而且两个都不等于转换器对它任何一个键单独给出的答案。 ⭐ 而钉子的 oracle 是两条路线之间的分歧,先互相比、再与字面量比 —— 「一个只驱动单条路线并断言形状的测试,会在一个让两者以不同方式继续分歧的修复上变绿」。这个形状是从 objectui#9020 的文件继承来的,继承对了。 修复是减法,不是合并计数器 —— 而证明这一点的控制项移动了位置而不是消失const keysTheLoopProcessed = Object.keys(filter).length - skippedNullKeys;
if (trueIdentityGroups > 0 && trueIdentityGroups === keysTheLoopProcessed) return undefined;⇒ ⭐ 而「没有被合并」的控制项换了位置:原来是 ⇒ 这与本轮 objectui#9058 学到的是同一条:一个钉子的证据价值会随它周围的树改变而到期;到期时要把它重新指向仍然会红的那个边界,而不是删掉。 ⛔ 围栏逐条成立
⭐ 两条处置分寸,值得点名
Contract reviewReviewed head: PR objectui#9166 的当前 head · Card: objectui#9030 ① Derived judgments⛔ 无任何已声明面移动:无新导出符号、无 schema 变更、 ② Semver
③ Boundary flags
Independence pairVerdictPASS。 摘掉两个载体上的
_Generated by Claude Code Generated by Claude Code |
Fixes #9030
What was wrong
convertFiltersToASTfolds a filter that is nothing but TRUE-identity combinators toundefined(objectui#8770), and folds a filter whose every key the loop SKIPPED for anull/undefinedvalue toundefinedtoo (objectui#9020). Each fold compared its own count withObject.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:{ $and: [] }{ b: undefined }{ $and: [], b: undefined }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:
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".{ $and: [], b: undefined }is still not folded. CONFIRMED on this tree (row above).{ $and: [], a: null }should stay excluded, because a null-valued key rides the$expandroute as a reala IS NULLpredicate. ⛔ 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. Keepingnullout 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.tsdrives every member down bothfind()routes through a mockedfetch, compares the two answers with each other before either is compared with a literal, and reads the selected rows back throughValueDataSource's matcher. It also asserts the parameter KEYS, which is the half afilter=assertion cannot see: the plain route's failure was never a wrongfiltervalue, it was an object spread into query parameters, so$andbecame 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 }hadJSON.stringifydrop its second key, so the$expandroute shipped the ruled answer by accident while the plain route 400'd;{ $and: [], a: null }had the$expandroute ship a reala IS NULLpredicate 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 === keysTheLoopProcessedbecomestrueIdentityGroups === Object.keys(filter).length.Object.keys(filter).lengthstill legitimately appears twice elsewhere in the file (insidekeysTheLoopProcesseditself, 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:The run on the mutated tree — the pins CAN fail:
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:
The restore, proven by state:
The script carries a
trapon EXIT, INT and TERM that calls its restore function, with an absoluteREPO_ROOT; it restores withgit checkout HEAD -- PATHnaming the absolute path (never a baregit checkout -- PATH, which takes the mutation back out of the index), and it treats an emptygit hash-objectreading 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.pnpm exec vitest run packages/core/ packages/data-objectstack/pnpm --filter @object-ui/core run type-checktsc --noEmit && tsc -p tsconfig.test.json)pnpm --filter @object-ui/data-objectstack run type-checknode scripts/check-changeset-presence.mjsnode scripts/check-new-cross-file-line-citations.mjsnode scripts/check-control-bytes.mjsnode scripts/check-vi-mock-specifiers.mjs,…-inherit.mjsnode scripts/check-governed-queue-guard.mjs --test …TS6305— a PREREQUISITE NOT MET, not a red gate.packages/types/disthad not been built in this fresh worktree. Re-run afterpnpm --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 --listFilesinpackages/corenamesfilter-identity-skipped-mix-9030.test.ts, andtsc --noEmit --listFilesinpackages/data-objectstacknamesfilter-identity-skipped-mix-two-routes-9030.test.ts. Atypecheckthat excludes**/*.test.tswould 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-widepnpm lint(turbo run lint). Both exit 0.lintscript, soturbo run lintis a 47-package farm. This run covered 2 of them — the 2 that contain every file in this diff.--format json:@object-ui/core252 files linted (0 errors),@object-ui/data-objectstack70 files linted (0 errors). The seven files this PR touches report 0 errors and 0 new warnings; the pre-existing@typescript-eslint/no-explicit-anywarnings onfilter-converter.ts(7),filter-true-identity-8770.test.ts(4) andfilter-text-comparand-9001.test.ts(5) sit on lines this PR does not add or move, and the two new files carry 0 warnings.eslint.config.jsextendstseslint.configs.recommended, notrecommendedTypeChecked, and declares noparserOptions.projectand noprojectService. 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:
convertFiltersToASTstill 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, nofilterparameter at all) and the$expandroute asfilter={"a":{}}, the widening@objectstack/specrefuses. Driven through both routes with a mockedfetch, 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".skippedNullKeyscountsundefined-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..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