From 30e320ab7666f1e4aec4c557d9f7acd82136dca2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 05:16:47 +0000 Subject: [PATCH 1/6] fix(components): fold the operator at FilterBuilder's value-input gate (objectui#9302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `needsValueInput` did a raw `has()` against `VALUELESS_FILTER_BUILDER_OPERATORS`, whose members are this builder's six camelCase dropdown ids. A row carrying the spec's canonical spelling (`is_null`, `is_empty`, `is_not_null`, `is_not_empty`) missed the set and was treated as value-taking, so it drew a box to type a value into directly beside a trigger reading `Is null`. One operator, two spellings, two different rows. Both sides of the lookup now fold through the spec's `normalizeFilterOperator` — the same fold `filterValueArity` and `reconcileOperatorForField` already perform in this file — and the canonical lookup set is DERIVED from the exported one rather than restated beside it. The exported set keeps its dropdown-only membership. Two other layers read it, and `app-shell`'s `foldFilterGroupToSpecRules` already documents itself as this set plus the canonical spellings only that layer sees; widening here would have made that compensation redundant by side effect. The defect was a reader that forgot to normalize its input, so the reader is what was repaired. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...filter-builder-valueless-canonical-fold.md | 40 +++ ...valueless-canonical-spelling-9302.test.tsx | 265 ++++++++++++++++++ .../components/src/custom/filter-builder.tsx | 41 ++- 3 files changed, 345 insertions(+), 1 deletion(-) create mode 100644 .changeset/9302-filter-builder-valueless-canonical-fold.md create mode 100644 packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx diff --git a/.changeset/9302-filter-builder-valueless-canonical-fold.md b/.changeset/9302-filter-builder-valueless-canonical-fold.md new file mode 100644 index 0000000000..d9d6a2c6bb --- /dev/null +++ b/.changeset/9302-filter-builder-valueless-canonical-fold.md @@ -0,0 +1,40 @@ +--- +'@object-ui/components': patch +--- + +fix(components): `FilterBuilder`'s value-input gate folds the operator, so one operator draws one row whichever spelling it arrives in (objectui#9302) + +`needsValueInput` did a raw `has()` against `VALUELESS_FILTER_BUILDER_OPERATORS`, +whose members are this builder's six camelCase dropdown ids. A row carrying the +spec's CANONICAL spelling — `is_null`, `is_empty`, `is_not_null`, +`is_not_empty`, which is what `foldFilterGroupToSpecRules` persists and what any +spec-side producer emits — missed the set and was treated as value-taking. The +row drew a box to type a value into, directly beside a trigger reading +`Is null`. Measured through the real builder on one `text` column: the canonical +spelling drew **1** value input where its camelCase twin drew **0**, with +`equals` drawing 1 in both (it really does take a value). + +Both sides of the lookup now fold through the spec's `normalizeFilterOperator`. +That is the same fold `filterValueArity` and `reconcileOperatorForField` in this +component already perform, so this is one more site joining a fold the file does +rather than a new dialect, and the canonical set is DERIVED from the exported one +rather than restated beside it. + +Pre-existing, and objectui#7561 did not cause it: before that repair the same +stray input was drawn under a **blank** trigger. What changed is that the +contradiction became legible. + +**The exported set is unchanged, deliberately.** Its stated job is *which rows +this builder leaves value-less* — a fact about the dropdown's own ids — and two +other layers read it: `plugin-list`'s `convertFilterGroupToAST` (what the live +grid queries) and `app-shell`'s `foldFilterGroupToSpecRules` (what a saved view +persists, already documented as this set plus the canonical spellings only that +layer sees). Widening it here would have made that layer's deliberate +compensation redundant by side effect, in a file nobody is editing. The defect +was never a set missing members; it was a reader that forgot to normalize its +input, so the reader is what was repaired. No published type or export changes, +and no row's stored `operator` is rewritten — rendering is not an edit. + +Which operator vocabulary should WIN is a separate, still-open question and is +not decided here. The `contains` / `icontains` boundary is untouched and pinned: +the fold this gate routes through maps neither onto the other. diff --git a/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx b/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx new file mode 100644 index 0000000000..7319814d45 --- /dev/null +++ b/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx @@ -0,0 +1,265 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The value-input gate reads the operator through the same fold the rest of + * this component already uses, so BOTH spellings of one value-less operator + * draw the SAME row (objectui#9302). + * + * ## The one site + * + * `needsValueInput` did a raw `has()` against + * `VALUELESS_FILTER_BUILDER_OPERATORS`, whose members are this builder's six + * camelCase dropdown ids. A row carrying the spec's canonical spelling + * (`is_null`) missed the set and was treated as value-taking, so it drew a + * value box next to a trigger that reads `Is null` — two spellings of ONE + * operator drawing two different rows. + * + * This is the same CLASS as objectui#7561 and not the same site: that card + * repaired the operator trigger's identity comparison, this is the value-input + * gate. The component already imports `normalizeFilterOperator` and already + * folds through it in `filterValueArity` and `reconcileOperatorForField`, so + * the repair is one more site joining a fold this file already performs. + * + * ## What the repair deliberately does NOT do (the PM ruling on this card) + * + * - ⛔ the EXPORTED set is not widened. Its stated job is *which rows this + * builder leaves value-less* — a fact about the dropdown's own six ids — + * and two other layers read it: `plugin-list`'s `convertFilterGroupToAST` + * (what the live grid QUERIES) and `app-shell`'s + * `foldFilterGroupToSpecRules` (what a saved view PERSISTS, documented as + * this set PLUS the canonical spellings only that layer sees). Widening + * here would make another layer's deliberate compensation redundant by + * side effect, in a file nobody is editing. + * - ⛔ no row's stored `operator` is rewritten; rendering is not an edit. + * - ⛔ no second spelling joins the dropdown; the emitted vocabulary is + * unchanged. + * + * ⛔ Out of scope, and untouched here: WHICH operator vocabulary wins + * (objectui#7561's open question, filed as decision card objectui#9306). It + * carries a hard constraint recorded on objectui#7379 — `AST_OPERATOR_MAP` + * holds that `contains` / `icontains` "must never be folded onto" each other, + * "That is a semantic boundary, not two spellings of one thing." The boundary + * guard below pins that the fold this repair routes through does not cross it. + * + * ## DIRECTION, predicted before running + * + * On the unmodified tree the four CANONICAL rows (`is_empty`, `is_not_empty`, + * `is_null`, `is_not_null`) are RED — each draws 1 value input where 0 is + * required. Everything else is GREEN in both directions and is carried for a + * named reason, not for coverage: + * + * - the six DROPDOWN ids draw 0 today and must keep drawing 0 — the + * over-reach guard. A repair that folded only one direction, or that + * replaced the set's members with canonical spellings, moves these; + * - `equals` is the FIRING CONTROL. It really does take a value and must + * draw exactly 1 both before and after. Without it "0 inputs everywhere" + * and a broken renderer are the same reading; + * - `exists` / `notExists` are builder-only ids with no canonical twin in + * the spec's vocabulary, so the fold returns them verbatim. They pin that + * routing the lookup through the fold did not drop the two members that + * have nothing to fold to. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { normalizeFilterOperator } from '@objectstack/spec/ui'; +import { + FilterBuilder, + VALUELESS_FILTER_BUILDER_OPERATORS, +} from '../custom/filter-builder'; + +/** One `text` column — the card's reproduction shape. */ +const FIELDS = [{ value: 'title', label: 'Title', type: 'text' }]; + +/** + * One condition row. `value: ''` is the seed `addCondition` writes and what a + * row still carries after the operator dropdown is changed, i.e. the exact + * state the panel holds while the user edits. + * + * The `value` prop is hoisted out of the JSX because the builder re-seeds its + * internal state when that prop's IDENTITY changes. + */ +function renderRow(operator: string, extraOperators: readonly string[] = []) { + const onChange = vi.fn(); + const value = { + id: 'root', + logic: 'and', + conditions: [{ id: 'c1', field: 'title', operator, value: '' }], + }; + const utils = render( + , + ); + return { ...utils, onChange }; +} + +/** + * The value input is the only `` the row draws — the field and operator + * pickers are Radix selects, i.e. buttons. Counting elements rather than + * matching a placeholder keeps this independent of the active locale. + */ +const valueInputCount = (container: HTMLElement) => container.querySelectorAll('input').length; + +/** What the operator trigger DISPLAYS — the label the stray box contradicts. */ +const operatorTriggerText = () => screen.getAllByRole('combobox')[1].textContent; + +/** + * The card's acceptance table, measured through the real `FilterBuilder`. + * + * `dialect` is load-bearing for reading a failure, not decoration: + * - `dropdown` — one of this builder's own camelCase ids, i.e. a member of + * the exported set. 0 inputs today and after: the over-reach guard; + * - `canonical` — `@objectstack/spec`'s snake_case spelling of the SAME + * operator, which a stored view carries. 1 input today (the defect), 0 + * after: the firing cases; + * - `control` — really does take a value. 1 input in both directions. + */ +const ROWS: ReadonlyArray<{ + operator: string; + label: string; + inputs: number; + dialect: 'dropdown' | 'canonical' | 'control'; + /** `OPT_IN_OPERATORS` ids the consumer must grant before they are mounted. */ + extraOperators?: readonly string[]; +}> = [ + { operator: 'isNull', label: 'Is null', inputs: 0, dialect: 'dropdown' }, + { operator: 'is_null', label: 'Is null', inputs: 0, dialect: 'canonical' }, + { operator: 'isNotNull', label: 'Is not null', inputs: 0, dialect: 'dropdown' }, + { operator: 'is_not_null', label: 'Is not null', inputs: 0, dialect: 'canonical' }, + { operator: 'isEmpty', label: 'Is empty', inputs: 0, dialect: 'dropdown' }, + { operator: 'is_empty', label: 'Is empty', inputs: 0, dialect: 'canonical' }, + { operator: 'isNotEmpty', label: 'Is not empty', inputs: 0, dialect: 'dropdown' }, + { operator: 'is_not_empty', label: 'Is not empty', inputs: 0, dialect: 'canonical' }, + // No canonical twin exists for these two — the spec's vocabulary has no + // `exists` member and its alias table deliberately has no row for one. + // + // They are `OPT_IN_OPERATORS` (objectui#4736), so a consumer that does not + // grant them never mounts their `SelectItem` and the trigger reads blank. + // Granting them here is what makes this a reading about the value gate + // rather than about the opt-in gate — measured: without the grant the + // trigger is `''`, which is the documented opt-in behaviour and not a defect. + { + operator: 'exists', + label: 'Is set', + inputs: 0, + dialect: 'dropdown', + extraOperators: ['exists', 'notExists'], + }, + { + operator: 'notExists', + label: 'Is not set', + inputs: 0, + dialect: 'dropdown', + extraOperators: ['exists', 'notExists'], + }, + // THE FIRING CONTROL, in the same render as the rest. + { operator: 'equals', label: 'Equals', inputs: 1, dialect: 'control' }, +]; + +describe('objectui#9302 — one operator, one row, whichever spelling it arrives in', () => { + it.each(ROWS)( + '$dialect `$operator` draws "$label" and $inputs value input(s)', + ({ operator, label, inputs, extraOperators }) => { + const { container } = renderRow(operator, extraOperators); + // Read the trigger in the same render: a row that draws no value input + // under a BLANK trigger is a different (and worse) outcome than the one + // this card asks for, and only reading both can tell them apart. + expect(operatorTriggerText()).toBe(label); + expect( + valueInputCount(container), + `the builder drew ${valueInputCount(container)} value input(s) for "${operator}" ` + + `but the row reads "${label}". Two spellings of one operator must draw one row`, + ).toBe(inputs); + }, + ); + + it('the firing cases really can fire — they are outside the exported set', () => { + // The instrument standard: a table built only on spellings the raw `has()` + // already matched would be green before ANY fix and would measure nothing. + const canonical = ROWS.filter((r) => r.dialect === 'canonical'); + expect(canonical.length).toBeGreaterThanOrEqual(4); + for (const row of canonical) { + // Not a member — so the raw lookup could not have matched it literally… + expect(VALUELESS_FILTER_BUILDER_OPERATORS.has(row.operator)).toBe(false); + // …and it folds ONTO a member, which is what makes the repair reach it. + const foldedMembers = new Set( + [...VALUELESS_FILTER_BUILDER_OPERATORS].map(normalizeFilterOperator), + ); + expect(foldedMembers.has(normalizeFilterOperator(row.operator))).toBe(true); + } + // …and the control is in neither, which is why it keeps its input. + expect(VALUELESS_FILTER_BUILDER_OPERATORS.has('equals')).toBe(false); + expect(normalizeFilterOperator('equals')).toBe('equals'); + }); +}); + +describe('objectui#9302 — ⛔ the repair moves nothing but the gate', () => { + it('the EXPORTED set keeps its dropdown-only membership', () => { + // Acceptance criterion 3, and the ruling's whole point: two other layers + // read this set, and one of them already compensates for the canonical + // spellings. Widening it would make that layer's deliberate half redundant + // by side effect. Green in both directions by construction — it fails only + // for a repair that widened the export instead of folding at the gate. + expect([...VALUELESS_FILTER_BUILDER_OPERATORS].sort()).toEqual([ + 'exists', + 'isEmpty', + 'isNotEmpty', + 'isNotNull', + 'isNull', + 'notExists', + ]); + }); + + it.each(['is_null', 'isNull'])('a row spelled `%s` is not migrated on render', (operator) => { + // Rendering is not an edit. A "repair" that rewrote the stored id to the + // dropdown's spelling would also make the table above green, and would be + // the wrong fix — it changes what a saved view carries. + const { onChange } = renderRow(operator); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('an unknown operator still draws a value input', () => { + // The gate's default must stay "takes a value". A fold that swallowed + // unknown spellings into the value-less set would hide the input on a row + // nobody can then fill in — the objectui#4744 failure, inverted. + expect(normalizeFilterOperator('totally_unknown')).toBe('totally_unknown'); + const { container } = renderRow('totally_unknown'); + expect(valueInputCount(container)).toBe(1); + }); +}); + +describe('objectui#9302 — ⛔ the fold does not cross the `contains` boundary', () => { + it('`contains` and `icontains` are not folded onto each other', () => { + // objectui#7379 / objectstack#7536: `AST_OPERATOR_MAP` holds that these + // "must never be folded onto" each other — "That is a semantic boundary, + // not two spellings of one thing." The gate now routes through + // `normalizeFilterOperator`, so pin that this fold leaves the boundary + // standing. A seat that folds those two has broken a ruling, not fixed a + // bug. Green in both directions by construction — a boundary guard, not a + // control. + expect(normalizeFilterOperator('contains')).toBe('contains'); + expect(normalizeFilterOperator('icontains')).toBe('icontains'); + expect(normalizeFilterOperator('contains')).not.toBe(normalizeFilterOperator('icontains')); + // …and neither is value-less under either spelling, so the gate this card + // repairs never had an opinion about them. + for (const op of ['contains', 'icontains', 'containsCaseInsensitive']) { + expect(VALUELESS_FILTER_BUILDER_OPERATORS.has(op)).toBe(false); + expect( + new Set([...VALUELESS_FILTER_BUILDER_OPERATORS].map(normalizeFilterOperator)).has( + normalizeFilterOperator(op), + ), + ).toBe(false); + } + }); +}); diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 6ec603fca6..4c0b108908 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -266,6 +266,34 @@ export const VALUELESS_FILTER_BUILDER_OPERATORS: ReadonlySet = new Set([ "notExists", ]) +/** + * The same six operators, keyed by the spelling `normalizeFilterOperator` + * folds each of them to — the lookup table the value-input gate below reads + * (objectui#9302). + * + * DERIVED, never a second literal: a hand-kept canonical copy beside the + * exported set is exactly how the two could come to disagree, and the + * disagreement would be invisible — a row that draws an input the label says + * it does not take. + * + * Why it exists separately instead of widening the export: the exported set + * states a fact about what THIS DROPDOWN draws, and its members are this + * builder's own camelCase ids. Two other layers read it — `plugin-list`'s + * `convertFilterGroupToAST` and `app-shell`'s `foldFilterGroupToSpecRules`, + * the latter already documented as this set PLUS the canonical spellings only + * that layer sees. Folding the canonical spellings INTO the export would make + * that layer's deliberate compensation redundant by side effect, in a file + * nobody is editing. The defect was never a set missing members; it was a + * reader that forgot to normalize its input. + * + * `exists` / `notExists` fold to themselves — the spec's vocabulary has no + * member for either and its alias table deliberately has no row for them — so + * this set is the same size as the one it derives from. + */ +const VALUELESS_FILTER_BUILDER_OPERATORS_CANONICAL: ReadonlySet = new Set( + [...VALUELESS_FILTER_BUILDER_OPERATORS].map(normalizeFilterOperator), +) + /** * The SHAPE an operator's `value` must have — the question * `ViewFilterRuleSchema` asks, asked here in the one place that decides what @@ -1279,8 +1307,19 @@ function FilterBuilder({ // The complement of the exported set, never a second literal beside it: // that set's whole job is to let other layers know which rows this builder // leaves value-less, and a hand-kept copy here is how they drifted apart. + // + // BOTH sides are folded through the spec's `normalizeFilterOperator` — the + // same fold `filterValueArity` and `reconcileOperatorForField` already + // perform, so this is one more site joining a fold this file does rather + // than a new dialect. The gate used to do a raw `has()` on whatever spelling + // the row carried, and the set's members are the dropdown's camelCase ids: + // a stored rule spelled `is_null` — the spec's CANONICAL form, which is what + // `foldFilterGroupToSpecRules` persists and what any spec-side producer + // emits — missed the set and was treated as value-taking. The row then drew + // a box to type a value into, directly beside a trigger reading `Is null` + // (objectui#9302). One operator, two spellings, two different rows. const needsValueInput = (operator: string) => { - return !VALUELESS_FILTER_BUILDER_OPERATORS.has(operator) + return !VALUELESS_FILTER_BUILDER_OPERATORS_CANONICAL.has(normalizeFilterOperator(operator)) } // Derived from the value FAMILY rather than from a second branch ladder over From 7daa352f98c76784467628912abe225f365049b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 17:05:03 +0000 Subject: [PATCH 2/6] test(components): derive the value-less row set from the published tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin measured four spellings where the repair moves eight. `needsValueInput` now folds through `normalizeFilterOperator`, so EVERY spelling the spec accepts for a value-less operator moves 1 -> 0 value inputs — the canonical members of `VIEW_FILTER_OPERATORS` and every row of `VIEW_FILTER_OPERATOR_ALIASES` pointing at one of them. The hand-written table named the canonical spellings and the camelCase dropdown ids; the all-lowercase alias rows (`isnull`, `isnotnull`, `isempty`, `isnotempty`) moved the same way with nothing pinning them, so a hand-rolled snake_case-only map would have kept the suite green while those regressed. Replace the gap with an instrument rather than four more literals: a table re-derived on every run from those two published tables. Each derived case reads its row against the dropdown TWIN that folds onto the same canonical spelling and asserts they draw the same row with zero value inputs, so no label literal is restated here and a spelling the spec adds tomorrow is measured without an edit. Anti-vacuity is guarded rather than trusted: an `it.each` over an empty or mis-derived array runs zero cases and passes. The guard pins floors (not a census) — the derived table is non-empty, every canonical row has a twin and at least one alias, the twin's trigger is neither blank nor an echo of its raw id, and the derived table both reaches past the literal one and never drifts behind it. The literal table keeps the human labels and the `equals` firing control. The changeset's enumeration of four is replaced the same way: it now points at the two published tables and at the pin that walks them, per AGENTS.md #9, and records no count of its own. No production file is touched. `needsValueInput`, the exported set and every other export are unchanged. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- ...filter-builder-valueless-canonical-fold.md | 25 ++- ...valueless-canonical-spelling-9302.test.tsx | 173 ++++++++++++++++-- 2 files changed, 176 insertions(+), 22 deletions(-) diff --git a/.changeset/9302-filter-builder-valueless-canonical-fold.md b/.changeset/9302-filter-builder-valueless-canonical-fold.md index d9d6a2c6bb..0e0e8a0090 100644 --- a/.changeset/9302-filter-builder-valueless-canonical-fold.md +++ b/.changeset/9302-filter-builder-valueless-canonical-fold.md @@ -5,14 +5,23 @@ fix(components): `FilterBuilder`'s value-input gate folds the operator, so one operator draws one row whichever spelling it arrives in (objectui#9302) `needsValueInput` did a raw `has()` against `VALUELESS_FILTER_BUILDER_OPERATORS`, -whose members are this builder's six camelCase dropdown ids. A row carrying the -spec's CANONICAL spelling — `is_null`, `is_empty`, `is_not_null`, -`is_not_empty`, which is what `foldFilterGroupToSpecRules` persists and what any -spec-side producer emits — missed the set and was treated as value-taking. The -row drew a box to type a value into, directly beside a trigger reading -`Is null`. Measured through the real builder on one `text` column: the canonical -spelling drew **1** value input where its camelCase twin drew **0**, with -`equals` drawing 1 in both (it really does take a value). +whose members are this builder's six camelCase dropdown ids. Every OTHER +spelling the spec publishes for those same operators missed the set and was +treated as value-taking — the canonical members of `VIEW_FILTER_OPERATORS` that +those ids fold onto, which is what `foldFilterGroupToSpecRules` persists and what +any spec-side producer emits, and every row of `VIEW_FILTER_OPERATOR_ALIASES` +pointing at one of them. The row drew a box to type a value into, directly beside +a trigger reading `Is null`. + +Which spellings those are is deliberately not listed here. The pin walks those +two published tables and names every row it measured, so the population is +re-derived on each run rather than restated in prose that cannot move with it — +the first pass of this changeset named four and the behaviour covered twice that +(objectui#9358). Measured through the real builder on one `text` column: every +spelling the pin enumerates drew **1** value input where its camelCase dropdown +twin drew **0**, with `equals` drawing 1 in both arms (it really does take a +value) — the firing control that makes a uniform zero a reading rather than a +dead harness. Both sides of the lookup now fold through the spec's `normalizeFilterOperator`. That is the same fold `filterValueArity` and `reconcileOperatorForField` in this diff --git a/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx b/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx index 7319814d45..0791610ae6 100644 --- a/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx +++ b/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx @@ -49,10 +49,17 @@ * * ## DIRECTION, predicted before running * - * On the unmodified tree the four CANONICAL rows (`is_empty`, `is_not_empty`, - * `is_null`, `is_not_null`) are RED — each draws 1 value input where 0 is - * required. Everything else is GREEN in both directions and is carried for a - * named reason, not for coverage: + * On the unmodified tree every spelling of a value-less operator that is NOT + * literally a member of the exported set is RED — each draws 1 value input + * where 0 is required. ⛔ That population is not written down here: the derived + * table below walks `VIEW_FILTER_OPERATORS` and `VIEW_FILTER_OPERATOR_ALIASES` + * and `--reporter=verbose` names each row it measured. Writing the count down + * is what went wrong the first time — the literal table named the canonical + * spellings and missed the all-lowercase alias rows, which move exactly the + * same way and had nothing pinning them (objectui#9358). + * + * Everything else is GREEN in both directions and is carried for a named + * reason, not for coverage: * * - the six DROPDOWN ids draw 0 today and must keep drawing 0 — the * over-reach guard. A repair that folded only one direction, or that @@ -67,9 +74,13 @@ */ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, cleanup } from '@testing-library/react'; import '@testing-library/jest-dom'; -import { normalizeFilterOperator } from '@objectstack/spec/ui'; +import { + normalizeFilterOperator, + VIEW_FILTER_OPERATORS, + VIEW_FILTER_OPERATOR_ALIASES, +} from '@objectstack/spec/ui'; import { FilterBuilder, VALUELESS_FILTER_BUILDER_OPERATORS, @@ -167,6 +178,134 @@ const ROWS: ReadonlyArray<{ { operator: 'equals', label: 'Equals', inputs: 1, dialect: 'control' }, ]; +/** + * ## The DERIVED table — the instrument, not a second literal list + * + * `ROWS` above is a hand-written table, and a hand-written table is exactly how + * a spelling goes unmeasured: the first pass of this pin named four canonical + * rows and stopped there, while the repair moves every spelling the spec + * publishes for those operators (objectui#9358). So the row set below is not + * listed — it is re-derived on every run from the two published tables, and + * whatever they hold on the day of the run is what gets measured. + * + * - `VIEW_FILTER_OPERATORS` — the spec's canonical vocabulary. Intersected + * with the exported set folded onto it, this yields the canonical spellings + * THIS builder leaves value-less. Nothing here decides which those are; the + * builder's own export does, and the fold carries it across. + * - `VIEW_FILTER_OPERATOR_ALIASES` — every other spelling the spec accepts + * for one of those canonical members. The camelCase rows of this table are + * the builder's own dropdown ids; the rest (today: the all-lowercase rows) + * are spellings no literal table here ever named. + * + * ⛔ Do not replace this with the list it currently produces. The count is not + * written down anywhere in this file on purpose — `--reporter=verbose` names + * each case it ran, and the guards below fail rather than run zero cases. + */ +const foldedExportedSet: ReadonlySet = new Set( + [...VALUELESS_FILTER_BUILDER_OPERATORS].map(normalizeFilterOperator), +); + +/** The canonical spellings this builder leaves value-less — derived, not listed. */ +const CANONICAL_VALUELESS: readonly string[] = ( + VIEW_FILTER_OPERATORS as readonly string[] +).filter((op) => foldedExportedSet.has(op)); + +/** + * The dropdown id that folds onto a canonical spelling — that row's TWIN, and + * the anchor each derived case is read against. `exists` / `notExists` have no + * canonical member to fold to, so they never appear here; they stay in `ROWS`. + */ +const dropdownTwin = (canonical: string): string | undefined => + [...VALUELESS_FILTER_BUILDER_OPERATORS].find( + (id) => normalizeFilterOperator(id) === canonical, + ); + +/** Every spelling the spec publishes for one of those operators. */ +const SPEC_SPELLINGS: ReadonlyArray<{ + spelling: string; + canonical: string; + dialect: 'canonical' | 'alias(dropdown id)' | 'alias'; +}> = [ + ...CANONICAL_VALUELESS.map( + (canonical) => ({ spelling: canonical, canonical, dialect: 'canonical' }) as const, + ), + ...Object.entries(VIEW_FILTER_OPERATOR_ALIASES) + .filter(([, canonical]) => CANONICAL_VALUELESS.includes(canonical)) + .map(([spelling, canonical]) => ({ + spelling, + canonical: canonical as string, + dialect: VALUELESS_FILTER_BUILDER_OPERATORS.has(spelling) + ? ('alias(dropdown id)' as const) + : ('alias' as const), + })), +]; + +describe('objectui#9358 — every spelling the spec publishes for these operators, derived', () => { + it.each(SPEC_SPELLINGS)( + '$dialect `$spelling` folds to `$canonical` and draws its twin row with 0 value inputs', + ({ spelling, canonical }) => { + const twin = dropdownTwin(canonical); + expect(twin, `no dropdown id folds onto \`${canonical}\``).toBeDefined(); + + // The twin is this reading's anchor, so read it first and refuse a + // degenerate one: a BLANK trigger, or one echoing the raw id, would make + // "draws the same row" vacuously true for every spelling at once. + const twinRender = renderRow(twin as string); + const twinTrigger = operatorTriggerText(); + expect(twinTrigger, `the twin \`${twin}\` drew a blank trigger`).toMatch(/\S/); + expect(twinTrigger, `the twin \`${twin}\` echoed its raw id`).not.toBe(twin); + expect(valueInputCount(twinRender.container)).toBe(0); + cleanup(); + + const { container } = renderRow(spelling); + expect(operatorTriggerText()).toBe(twinTrigger); + expect( + valueInputCount(container), + `the builder drew ${valueInputCount(container)} value input(s) for "${spelling}" ` + + `but the row reads "${twinTrigger}". Two spellings of one operator must draw one row`, + ).toBe(0); + }, + ); + + it('⛔ the derived table is not vacuous, and it reaches past the literal one', () => { + // An `it.each` over an empty or mis-derived array runs ZERO cases and is + // green, so the derivation is guarded here rather than trusted. These are + // FLOORS, deliberately not a census — they fail when the derivation breaks + // and stay quiet when the spec grows a spelling (which is the point). + expect(CANONICAL_VALUELESS.length).toBeGreaterThanOrEqual(4); + expect(SPEC_SPELLINGS.length).toBeGreaterThan(CANONICAL_VALUELESS.length); + + // Every canonical row has a twin to be read against and at least one alias, + // so no canonical is measured alone and no case silently skips its anchor. + for (const canonical of CANONICAL_VALUELESS) { + expect(dropdownTwin(canonical), canonical).toBeDefined(); + expect( + SPEC_SPELLINGS.filter((r) => r.dialect !== 'canonical' && r.canonical === canonical).length, + canonical, + ).toBeGreaterThanOrEqual(1); + } + + // The instrument reaches spellings `ROWS` never named — the objectui#9358 + // gap. Without this the derived table could quietly shrink to the literal + // one and still be green. + const literal = new Set(ROWS.map((r) => r.operator)); + const beyond = SPEC_SPELLINGS.filter((r) => !literal.has(r.spelling)); + expect(beyond.length, 'the derived table adds nothing over the literal one').toBeGreaterThanOrEqual(4); + + // …and it never drifts BEHIND it: every literal row the spec knows is + // derived too, so the two tables cannot disagree about a shared spelling. + const derived = new Set(SPEC_SPELLINGS.map((r) => r.spelling)); + for (const op of literal) { + const specKnows = + (VIEW_FILTER_OPERATORS as readonly string[]).includes(op) + || Object.prototype.hasOwnProperty.call(VIEW_FILTER_OPERATOR_ALIASES, op); + if (specKnows && foldedExportedSet.has(normalizeFilterOperator(op))) { + expect(derived.has(op), op).toBe(true); + } + } + }); +}); + describe('objectui#9302 — one operator, one row, whichever spelling it arrives in', () => { it.each(ROWS)( '$dialect `$operator` draws "$label" and $inputs value input(s)', @@ -187,20 +326,26 @@ describe('objectui#9302 — one operator, one row, whichever spelling it arrives it('the firing cases really can fire — they are outside the exported set', () => { // The instrument standard: a table built only on spellings the raw `has()` // already matched would be green before ANY fix and would measure nothing. - const canonical = ROWS.filter((r) => r.dialect === 'canonical'); - expect(canonical.length).toBeGreaterThanOrEqual(4); - for (const row of canonical) { + // + // Read off the DERIVED table, not a literal one, so the answer to "which + // rows actually fire" comes from the published tables too. The firing rows + // are exactly those the raw `has()` could not match literally — today that + // is every canonical member plus every alias that is not itself a dropdown + // id (objectui#9358; the first pass of this pin saw only half of them). + const firing = SPEC_SPELLINGS.filter( + (r) => !VALUELESS_FILTER_BUILDER_OPERATORS.has(r.spelling), + ); + expect(firing.length).toBeGreaterThan(CANONICAL_VALUELESS.length); + for (const row of firing) { // Not a member — so the raw lookup could not have matched it literally… - expect(VALUELESS_FILTER_BUILDER_OPERATORS.has(row.operator)).toBe(false); + expect(VALUELESS_FILTER_BUILDER_OPERATORS.has(row.spelling), row.spelling).toBe(false); // …and it folds ONTO a member, which is what makes the repair reach it. - const foldedMembers = new Set( - [...VALUELESS_FILTER_BUILDER_OPERATORS].map(normalizeFilterOperator), - ); - expect(foldedMembers.has(normalizeFilterOperator(row.operator))).toBe(true); + expect(foldedExportedSet.has(normalizeFilterOperator(row.spelling)), row.spelling).toBe(true); } // …and the control is in neither, which is why it keeps its input. expect(VALUELESS_FILTER_BUILDER_OPERATORS.has('equals')).toBe(false); expect(normalizeFilterOperator('equals')).toBe('equals'); + expect(foldedExportedSet.has(normalizeFilterOperator('equals'))).toBe(false); }); }); From dfbee6340a37f1c7f9671bbdd4cc82b7de4a993d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 23:24:06 +0000 Subject: [PATCH 3/6] docs(components): tell the truth in the published doc-comment of the value-less set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This branch's fold falsified the doc-comment of the exported set it deliberately left alone, and that comment SHIPS: tsc emits it verbatim into `dist/custom/filter-builder.d.ts` immediately above the declaration, so the only published statement about the symbol was the false one. The corrective prose existed only on the module-private canonical set, which never reaches the emitted declarations — measured, not assumed: the private symbol and its docblock have 0 hits across all 208 emitted `.d.ts`, with the exported symbol lit twice in the same file as the control. What was false: "`needsValueInput` below is defined as the complement of this set, so the two cannot say different things". At the merge-base that was literally true. After the fold the gate is the complement of this set's FOLD-CLOSURE under `normalizeFilterOperator`, so it answers value-less for spellings that are not members — re-derived here over the whole published universe (`VIEW_FILTER_OPERATORS`, `VIEW_FILTER_OPERATOR_ALIASES` and the dropdown ids, read from the installed `@objectstack/spec` and from the built export rather than restated), with value-taking operators carried as unmoved controls so the divergence is a reading rather than a uniform answer. The amended text says what the set is (this dropdown's own camelCase ids), what the gate actually reads (that set's fold-closure), and what a consumer therefore has to do before asking it (fold). Per AGENTS.md #9 it writes down no count of how much wider the gate's preimage is; it points at the pin that walks the two published tables and names every row it measures. The in-function comment above the gate carried the same "complement of the exported set" phrasing. It does not ship, and the lines the fold commit added below it already state the fold, but it is the same sentence that failed review, so it is made precise in the same pass. Also, while that file was open: the changeset cited objectui#9358 — the PR — where its other citations are cards, so the shipped CHANGELOG would carry a PR number as if it were a finding. Corrected to the card. The declared level, `patch`, is untouched. Comment text only. No membership change, no runtime line moved, and the ruling on this card is untouched. Every changed line in the diff is a comment line; the emitted declaration file grows by exactly the 12 doc-comment lines and the declaration itself shifts by exactly 12, while the extra in-function line does not appear there at all. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- ...filter-builder-valueless-canonical-fold.md | 2 +- .../components/src/custom/filter-builder.tsx | 27 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/.changeset/9302-filter-builder-valueless-canonical-fold.md b/.changeset/9302-filter-builder-valueless-canonical-fold.md index 0e0e8a0090..74ce7701ff 100644 --- a/.changeset/9302-filter-builder-valueless-canonical-fold.md +++ b/.changeset/9302-filter-builder-valueless-canonical-fold.md @@ -17,7 +17,7 @@ Which spellings those are is deliberately not listed here. The pin walks those two published tables and names every row it measured, so the population is re-derived on each run rather than restated in prose that cannot move with it — the first pass of this changeset named four and the behaviour covered twice that -(objectui#9358). Measured through the real builder on one `text` column: every +(objectui#9302). Measured through the real builder on one `text` column: every spelling the pin enumerates drew **1** value input where its camelCase dropdown twin drew **0**, with `equals` drawing 1 in both arms (it really does take a value) — the firing control that makes a uniform zero a reading rather than a diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 4c0b108908..1371c38608 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -225,14 +225,26 @@ export const FILTER_BUILDER_OPERATORS = defaultOperators.map(o => o.value) export type FilterBuilderOperator = (typeof defaultOperators)[number]['value'] /** - * Operator ids for which this builder renders NO value input — so "no value" - * is the row's FINISHED state, not an unfinished one (objectui#4744). + * Operator ids — this dropdown's OWN camelCase ids — for which this builder + * renders no value input, so "no value" is the row's FINISHED state, not an + * unfinished one (objectui#4744). * * This is the source of truth for that distinction, and it lives here because - * this component is the thing that decides it: `needsValueInput` below is - * defined as the complement of this set, so the two cannot say different - * things. Every consumer that has to tell a complete value-less row from a - * half-filled one reads it FROM here rather than restating it: + * this component is the thing that decides it. But membership here is not the + * whole question: `needsValueInput` below is the complement of this set's + * FOLD-CLOSURE under the spec's `normalizeFilterOperator`, not of this set + * itself (objectui#9302), so the gate answers "no value" for spellings that + * are NOT members — the canonical form `foldFilterGroupToSpecRules` persists, + * and the alias rows the spec publishes for those same operators. A consumer + * holding a spelling that did not come from this dropdown must therefore fold + * it through `normalizeFilterOperator` before asking this set, exactly as that + * gate does. How much wider the gate's preimage is is NOT restated here: the + * pin `filter-builder-valueless-canonical-spelling-9302.test.tsx` walks the + * spec's two published tables and names every row it measures, which is the + * only form of that answer that moves when those tables do (AGENTS.md #9). + * + * Every consumer that has to tell a complete value-less row from a half-filled + * one reads the membership FROM here rather than restating it: * * - `plugin-list`'s `convertFilterGroupToAST` — what the live grid QUERIES; * - `app-shell`'s `foldFilterGroupToSpecRules` — what a saved view PERSISTS @@ -1304,7 +1316,8 @@ function FilterBuilder({ }) } - // The complement of the exported set, never a second literal beside it: + // The complement of the exported set's FOLD-CLOSURE, never a second literal + // beside it: // that set's whole job is to let other layers know which rows this builder // leaves value-less, and a hand-kept copy here is how they drifted apart. // From 60559b4857e6c4f101b51c5fa6a8111fe05bd71b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 00:05:23 +0000 Subject: [PATCH 4/6] docs(components): prescribe the fold the gate actually performs, on both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous repair replaced two false published sentences with a third. It told a consumer holding a foreign spelling to fold that spelling through `normalizeFilterOperator` and then ask `VALUELESS_FILTER_BUILDER_OPERATORS` — the exported set, unfolded. That is the mirror image of what the gate does, and it is wrong on exactly the population the sentence exists to serve: executed literally over the whole published universe, with the set read from the built bundle, it answers "takes a value" for every canonical and all-lowercase-alias spelling of the null/empty operators, and for this dropdown's own camelCase ids as well — so a consumer following it landed strictly further from the gate than one still doing the pre-branch raw `has()`. It also claimed to be doing what the gate does, while the gate folds BOTH sides and consults a set that is module private and unreachable from the published declarations; and it contradicted the sentence two lines above it, which says the gate is the complement of this set's FOLD-CLOSURE and not of this set itself. The block now prescribes the procedure a consumer can actually run from the published surface: fold this set's own members as well as the spelling, and ask the result. Executed over that same universe it agrees with the gate on every spelling, with the two wrong procedures run through the same harness as the controls that keep the agreement a reading rather than a uniform answer. That prescription is not hypothetical — it is verbatim what `plugin-list` already derives on `main`. No membership change, no code change: the comment-stripped code-only sha256 of this file is unchanged across this commit while the raw blob moves, and the merge-base's differs as the control that the instrument can see a real edit. Also folded in, ranked below the repair: the changeset stops restating a live population count in prose and names the instrument instead (AGENTS.md #9). The declared level is untouched. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../9302-filter-builder-valueless-canonical-fold.md | 4 ++-- packages/components/src/custom/filter-builder.tsx | 10 ++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.changeset/9302-filter-builder-valueless-canonical-fold.md b/.changeset/9302-filter-builder-valueless-canonical-fold.md index 74ce7701ff..b20e60bfae 100644 --- a/.changeset/9302-filter-builder-valueless-canonical-fold.md +++ b/.changeset/9302-filter-builder-valueless-canonical-fold.md @@ -16,8 +16,8 @@ a trigger reading `Is null`. Which spellings those are is deliberately not listed here. The pin walks those two published tables and names every row it measured, so the population is re-derived on each run rather than restated in prose that cannot move with it — -the first pass of this changeset named four and the behaviour covered twice that -(objectui#9302). Measured through the real builder on one `text` column: every +an earlier draft of this changeset hand-listed that population and undercounted +it, which is why it is named by instrument here (objectui#9302). Measured through the real builder on one `text` column: every spelling the pin enumerates drew **1** value input where its camelCase dropdown twin drew **0**, with `equals` drawing 1 in both arms (it really does take a value) — the firing control that makes a uniform zero a reading rather than a diff --git a/packages/components/src/custom/filter-builder.tsx b/packages/components/src/custom/filter-builder.tsx index 1371c38608..9b92ee4f9d 100644 --- a/packages/components/src/custom/filter-builder.tsx +++ b/packages/components/src/custom/filter-builder.tsx @@ -236,10 +236,12 @@ export type FilterBuilderOperator = (typeof defaultOperators)[number]['value'] * itself (objectui#9302), so the gate answers "no value" for spellings that * are NOT members — the canonical form `foldFilterGroupToSpecRules` persists, * and the alias rows the spec publishes for those same operators. A consumer - * holding a spelling that did not come from this dropdown must therefore fold - * it through `normalizeFilterOperator` before asking this set, exactly as that - * gate does. How much wider the gate's preimage is is NOT restated here: the - * pin `filter-builder-valueless-canonical-spelling-9302.test.tsx` walks the + * holding a spelling that did not come from this dropdown must therefore ask + * that FOLD-CLOSURE, not this set: fold this set's own members through + * `normalizeFilterOperator` as well as the spelling, and ask the result — + * the same fold the gate applies to BOTH sides. How much wider the gate's + * preimage is is NOT restated here: the pin + * `filter-builder-valueless-canonical-spelling-9302.test.tsx` walks the * spec's two published tables and names every row it measures, which is the * only form of that answer that moves when those tables do (AGENTS.md #9). * From 095830673ab717bfa432e44c8170d46c8f8a4940 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 00:17:56 +0000 Subject: [PATCH 5/6] test(components): cite the card, not this PR, in the pin's prose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin cited a PR number five times in prose where its siblings cite cards — the same class as the changeset citation corrected two commits ago, and the class the contract review has now named in two consecutive rounds. Four of the five sites did not mean a card at all: they mean "the first pass of this pin got this wrong", a fact about the branch that adds the file. Those now say that in words, so nothing points a reader at a number that will read as a sibling card once this lands. The fifth is the derived suite's own title, and there the subject really is the defect being pinned, so it cites the card. Prose only, and mechanically so: filtering the diff to lines that are neither a block-comment gutter nor a line comment leaves exactly one line, the suite title. No assertion, no import, no table entry and no control moved; the suite still runs the same number of cases. Nothing published moves either — the only file this commit touches lives under `src/__tests__/`, which the package tsconfig excludes by directory, and the source of the shipped declarations is byte-identical to the previous head. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- ...valueless-canonical-spelling-9302.test.tsx | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx b/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx index 0791610ae6..4db003dc75 100644 --- a/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx +++ b/packages/components/src/__tests__/filter-builder-valueless-canonical-spelling-9302.test.tsx @@ -56,7 +56,8 @@ * and `--reporter=verbose` names each row it measured. Writing the count down * is what went wrong the first time — the literal table named the canonical * spellings and missed the all-lowercase alias rows, which move exactly the - * same way and had nothing pinning them (objectui#9358). + * same way and had nothing pinning them — the first pass of this pin, on the + * branch that adds it, made exactly that mistake. * * Everything else is GREEN in both directions and is carried for a named * reason, not for coverage: @@ -182,11 +183,11 @@ const ROWS: ReadonlyArray<{ * ## The DERIVED table — the instrument, not a second literal list * * `ROWS` above is a hand-written table, and a hand-written table is exactly how - * a spelling goes unmeasured: the first pass of this pin named four canonical - * rows and stopped there, while the repair moves every spelling the spec - * publishes for those operators (objectui#9358). So the row set below is not - * listed — it is re-derived on every run from the two published tables, and - * whatever they hold on the day of the run is what gets measured. + * a spelling goes unmeasured: the first pass of this pin, on the branch that + * adds it, named four canonical rows and stopped there, while the repair moves + * every spelling the spec publishes for those operators. So the row set below + * is not listed — it is re-derived on every run from the two published tables, + * and whatever they hold on the day of the run is what gets measured. * * - `VIEW_FILTER_OPERATORS` — the spec's canonical vocabulary. Intersected * with the exported set folded onto it, this yields the canonical spellings @@ -240,7 +241,7 @@ const SPEC_SPELLINGS: ReadonlyArray<{ })), ]; -describe('objectui#9358 — every spelling the spec publishes for these operators, derived', () => { +describe('objectui#9302 — every spelling the spec publishes for these operators, derived', () => { it.each(SPEC_SPELLINGS)( '$dialect `$spelling` folds to `$canonical` and draws its twin row with 0 value inputs', ({ spelling, canonical }) => { @@ -285,9 +286,9 @@ describe('objectui#9358 — every spelling the spec publishes for these operator ).toBeGreaterThanOrEqual(1); } - // The instrument reaches spellings `ROWS` never named — the objectui#9358 - // gap. Without this the derived table could quietly shrink to the literal - // one and still be green. + // The instrument reaches spellings `ROWS` never named — the gap this pin's + // own first pass left. Without this the derived table could quietly shrink + // to the literal one and still be green. const literal = new Set(ROWS.map((r) => r.operator)); const beyond = SPEC_SPELLINGS.filter((r) => !literal.has(r.spelling)); expect(beyond.length, 'the derived table adds nothing over the literal one').toBeGreaterThanOrEqual(4); @@ -331,7 +332,7 @@ describe('objectui#9302 — one operator, one row, whichever spelling it arrives // rows actually fire" comes from the published tables too. The firing rows // are exactly those the raw `has()` could not match literally — today that // is every canonical member plus every alias that is not itself a dropdown - // id (objectui#9358; the first pass of this pin saw only half of them). + // id (the first pass of this pin saw only half of them). const firing = SPEC_SPELLINGS.filter( (r) => !VALUELESS_FILTER_BUILDER_OPERATORS.has(r.spelling), ); From 4448cf70b9b8b092362667338987c3a6304f84ce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 00:54:28 +0000 Subject: [PATCH 6/6] docs(components): scope the changeset's value-input measurement to the rows that fired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The changeset said "every spelling the pin enumerates drew 1 value input where its camelCase dropdown twin drew 0". Executed at the merge-base through the real `FilterBuilder` on one `text` column, over the population the pin actually enumerates: 8 of those 12 spellings drew 1, and four drew 0. The four are `isEmpty`, `isNotEmpty`, `isNull` and `isNotNull` — rows of `VIEW_FILTER_OPERATOR_ALIASES` that are also this builder's own dropdown ids, so they are members of the exported set and the raw `has()` always matched them. For each of those four the "camelCase dropdown twin" IS the spelling itself, which is what makes the old sentence not merely overstated but self-refuting: it asserted that one render drew both 1 and 0. The distinction was already encoded in the pin, which is why the repair names an instrument instead of a number. Its `firing` filter drops exactly the rows whose spelling is a member of the exported set, leaving the 8 the raw lookup could not match literally — and those are precisely the rows for which the claim holds. The sentence now quantifies over that filter, states the arm it was measured in, and keeps `equals` as the firing control. Executed before writing, not after. Rendering all 12 derived spellings and their twins through the real builder at the merge-base: the old claim holds for 8/12 (FALSE), the new claim for 8/8 (TRUE), and the sub-clause that each firing row's twin is a different spelling for 8/8 — both claims read off one rendered data set, so scope is the only variable between them. `equals`, `contains`, `in` and `totally_unknown` each drew 1 in the same run, and `equals` drew 1 in both arms, so the zeros are a reading rather than a dead harness. Changeset prose only. The declared level, `patch`, is untouched; no assertion, no runtime line and no membership moved. The code-only sha256 of `filter-builder.tsx` is `0ca48c2227040d6e` across this commit and the four before it, against `325dac8a7ce2830d` at the merge-base — with the instrument proved first: a planted real code edit moves it, a planted comment-only edit (verified comment-only, 1127 of 1127 code lines identical) does not, and without whitespace normalisation that same comment-only edit reports movement that is not there. Refs: objectui#9302. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L5xpA5q533BgTTNADibEFt --- .../9302-filter-builder-valueless-canonical-fold.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.changeset/9302-filter-builder-valueless-canonical-fold.md b/.changeset/9302-filter-builder-valueless-canonical-fold.md index b20e60bfae..235fb83ea1 100644 --- a/.changeset/9302-filter-builder-valueless-canonical-fold.md +++ b/.changeset/9302-filter-builder-valueless-canonical-fold.md @@ -17,11 +17,13 @@ Which spellings those are is deliberately not listed here. The pin walks those two published tables and names every row it measured, so the population is re-derived on each run rather than restated in prose that cannot move with it — an earlier draft of this changeset hand-listed that population and undercounted -it, which is why it is named by instrument here (objectui#9302). Measured through the real builder on one `text` column: every -spelling the pin enumerates drew **1** value input where its camelCase dropdown -twin drew **0**, with `equals` drawing 1 in both arms (it really does take a -value) — the firing control that makes a uniform zero a reading rather than a -dead harness. +it, which is why it is named by instrument here (objectui#9302). Measured +through the real builder on one `text` column, before the repair: every +spelling the pin's own `firing` filter yields — the enumerated rows that are +not themselves members of the exported set, so each has a DIFFERENT spelling as +its twin — drew **1** value input where that camelCase dropdown twin drew **0**, +with `equals` drawing 1 in both arms (it really does take a value) — the firing +control that makes a uniform zero a reading rather than a dead harness. Both sides of the lookup now fold through the spec's `normalizeFilterOperator`. That is the same fold `filterValueArity` and `reconcileOperatorForField` in this