From d0d08e2c65283729df45ca148b90af224bc20b48 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 16:21:41 +0000 Subject: [PATCH 1/3] docs(plugin-detail): state the driver's real arity rule and the arity-dependent parent scope Two published texts objectui#8886 left behind, both measured false on origin/main: - packages/plugin-detail/README.md ships (it is in files[]) and said the node's `filter` is AND-combined with `{ [relationshipField]: parentId }`, full stop. Since objectui#7299 the parent condition is compiled to match the relationship field's arity, so a multi-valued one gets `{ $contains: parentId }` instead. The paragraph now states both spellings and names the arbiter. - `parentRelationshipFieldDef`'s docblock, and the same sentence in the objectui#7299 test header, said the SQL driver decides arity on the spec's `isMultiValueField`. It does not: driver-sql gates the equality family on its own storage question, which reads `multiple` as truthy on ANY type. The two rules therefore disagree for a type outside MULTI_CAPABLE_TYPES carrying `multiple: true`; that divergence is now recorded as one and pointed at the upstream card that owns it (objectstack#17469). No predicate moved and no wire changed. Both claims are pinned by re-derivation rather than transcription, in relatedListParentScopeResidue-8937.test.ts. The manifest floor finding on the same card is NOT actioned here: measurement falsified its premise. See the pull request body. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...plugin-detail-8937-parent-scope-residue.md | 25 +++ packages/plugin-detail/README.md | 11 +- packages/plugin-detail/src/RelatedList.tsx | 41 +++- ...edList.multiValueParentScope-7299.test.tsx | 43 ++-- ...relatedListParentScopeResidue-8937.test.ts | 193 ++++++++++++++++++ 5 files changed, 292 insertions(+), 21 deletions(-) create mode 100644 .changeset/plugin-detail-8937-parent-scope-residue.md create mode 100644 packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts diff --git a/.changeset/plugin-detail-8937-parent-scope-residue.md b/.changeset/plugin-detail-8937-parent-scope-residue.md new file mode 100644 index 0000000000..0f66d53950 --- /dev/null +++ b/.changeset/plugin-detail-8937-parent-scope-residue.md @@ -0,0 +1,25 @@ +--- +'@object-ui/plugin-detail': patch +--- + +docs(plugin-detail): correct the shipped README's parent-scope claim and the false driver-arity docblock (objectui#8937) + +Two published texts that objectui#8886 left behind, both measured false on `origin/main`: + +- **`README.md` (it is in `files[]`, so it ships).** It said the node's `filter` is + AND-combined with `{ [relationshipField]: parentId }`, full stop. Since objectui#7299 + the parent condition is compiled to match the relationship field's arity, so a + multi-valued relationship gets `{ [relationshipField]: { $contains: parentId } }` + instead. The paragraph now states both spellings and names the arbiter + (`@objectstack/spec/data`'s `isMultiValueField`). +- **`parentRelationshipFieldDef`'s docblock, and the same sentence in the + objectui#7299 test header.** They said the SQL driver decides arity on the spec's + `isMultiValueField`. It does not: `driver-sql` gates the equality family on its own + storage question, which reads `multiple` as truthy on ANY type. The two rules + therefore disagree for a type outside `MULTI_CAPABLE_TYPES` carrying + `multiple: true`, and that divergence is now recorded as a divergence and pointed at + the upstream card that owns it (objectstack#17469). + +No predicate moved and no wire changed — this release carries corrected published text +only. Both claims are now pinned by re-derivation rather than transcription +(`relatedListParentScopeResidue-8937.test.ts`). diff --git a/packages/plugin-detail/README.md b/packages/plugin-detail/README.md index c0f4bc64c9..a5971eecc2 100644 --- a/packages/plugin-detail/README.md +++ b/packages/plugin-detail/README.md @@ -329,9 +329,16 @@ filter can express). The node's `filter` (spec `RecordRelatedListProps.filter`, "additional filter criteria") narrows the list beyond the parent relationship: it is -**AND-combined** with `{ [relationshipField]: parentId }`, never substituted for +**AND-combined** with the parent-relationship condition, never substituted for it, so a related list stays scoped to the record it appears on and an additional -criterion can only ever narrow that set. Authors write it in the spec's own +criterion can only ever narrow that set. That condition is **not one fixed +spelling** — it is compiled to match the relationship field's ARITY on the +child object (`objectui#7299`). A single-valued relationship gets the equality +form `{ [relationshipField]: parentId }`; a multi-valued one gets the membership +form `{ [relationshipField]: { $contains: parentId } }`, because the stored +value is an array of ids and equality would ask whether the whole array *is* one +id. The arity verdict is `@objectstack/spec/data`'s own `isMultiValueField`, +never a rule this package keeps locally. Authors write `filter` in the spec's own vocabulary (`[{ field, operator, value }]`); a `dataSource` binding's composed filter (component AND saved view AND binding) lands on the same key. Both are lowered to ObjectQL through the repo's single filter sink, so no second dialect diff --git a/packages/plugin-detail/src/RelatedList.tsx b/packages/plugin-detail/src/RelatedList.tsx index 9c985aa192..b74e383d51 100644 --- a/packages/plugin-detail/src/RelatedList.tsx +++ b/packages/plugin-detail/src/RelatedList.tsx @@ -365,13 +365,40 @@ export const RelatedToolbarButton: React.FC<{ * as a default. * * ⛔ Do not reintroduce a local arity rule here, however small. This component - * decides `$contains` vs `=` on the answer, and the driver that refuses the - * query decides on the spec's — two readers of one question, disagreeing, is - * exactly the defect objectui#7299 is about, and putting it one layer up would - * be a worse version of it. The spec's rule is BROADER than an eyeballed - * `multiple === true` in both directions: `multiselect` / `checkboxes` / `tags` - * persist an array with no flag at all, and `multiple: true` is INERT on a type - * outside `MULTI_CAPABLE_TYPES` (`master_detail`, say). Both are pinned. + * decides `$contains` vs `=` on the answer, and a second copy of that rule + * living here would be two readers of one question inside one package — + * exactly the defect objectui#7299 is about, rebuilt one layer up. The spec's + * rule is BROADER than an eyeballed `multiple === true` in both directions: + * `multiselect` / `checkboxes` / `tags` persist an array with no flag at all, + * and `multiple: true` is INERT on a type outside `MULTI_CAPABLE_TYPES` + * (`master_detail`, say). Both are pinned. + * + * ## The storage side does NOT read this predicate — the two rules DIVERGE + * + * This docblock used to say the driver that refuses the query decides on the + * spec's `isMultiValueField`. It does not (objectui#8937). Measured on + * objectstack `origin/main`, `driver-sql` gates the equality family on its own + * STORAGE question, `isJsonField`: a column is JSON when the field's type is in + * that driver's `JSON_COLUMN_TYPES` — the spec's `STRUCTURED_JSON_TYPES` and + * `MULTI_OPTION_TYPES`, plus the driver-internal `object` / `array` aliases — + * OR when `multiple` is merely TRUTHY, on ANY type; a single-value media type + * answers from the ADR-0104 dual-encoding window instead. The spec's predicate + * is `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) && + * multiple === true)`. + * + * ⇒ They diverge for a type OUTSIDE `MULTI_CAPABLE_TYPES` carrying + * `multiple: true` (`master_detail` / `tree` / `text`): the spec says + * single-valued, so this component sends `=`, while the driver stores a JSON + * column and refuses `=` with the same `400 INVALID_FILTER` objectui#7299 was + * filed for. That is a KNOWN divergence, not a regression — the pre-objectui#8886 + * renderer sent `=` for that shape too. + * + * ⛔ Do NOT close it here by widening the predicate: which of the two rules is + * right is an upstream question, filed as objectstack#17469 (enforce-or-align), + * and this component is deliberately not blocked on it. The divergence is + * re-derived from the installed spec each run by + * `relatedListParentScopeResidue-8937.test.ts`, so this paragraph reddens when + * the spec side moves. */ function parentRelationshipFieldDef( objectSchema: unknown, diff --git a/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx b/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx index c9d48f29ff..eb46b8765f 100644 --- a/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx +++ b/packages/plugin-detail/src/__tests__/RelatedList.multiValueParentScope-7299.test.tsx @@ -54,12 +54,21 @@ * * The arity VERDICT is `@objectstack/spec/data`'s exported `isMultiValueField`, * imported rather than re-implemented — the component only finds the field def - * to hand it. That is not hygiene: this component picks `$contains` vs `=` on - * the answer, and the driver that refuses the query picks on the spec's, so a - * local copy would be two readers of one question disagreeing, which is the - * defect this card is about. The three `SPEC PREDICATE` cases below pin the - * places where the spec's rule and an eyeballed `multiple === true` DIFFER, - * measured on `@objectstack/spec` 17.4.0, in both directions. + * to hand it. That is not hygiene: a second copy of the rule inside this + * package would be two readers of one question disagreeing, which is the defect + * this card is about. The three `SPEC PREDICATE` cases below pin the places + * where the spec's rule and an eyeballed `multiple === true` DIFFER, measured + * on `@objectstack/spec` 17.4.0, in both directions. + * + * ⚠️ This header used to add that "the driver that refuses the query picks on + * the spec's". It does NOT (objectui#8937). `driver-sql` gates the equality + * family on its own STORAGE question — a JSON column when the type is one it + * stores as JSON OR when `multiple` is merely TRUTHY, on ANY type — so the two + * rules diverge for a type outside `MULTI_CAPABLE_TYPES` carrying + * `multiple: true`. See the `INERT` case below and `parentRelationshipFieldDef`'s + * docblock in `RelatedList.tsx`; the divergence itself is re-derived each run by + * `relatedListParentScopeResidue-8937.test.ts` and owned upstream by + * objectstack#17469. * * ## The control * @@ -308,12 +317,22 @@ describe('RelatedList — a related list on a MULTI-VALUE relationship returns d }); it('SPEC PREDICATE — `multiple: true` is INERT on a type outside MULTI_CAPABLE_TYPES', async () => { - // The same delegation read the other way. `master_detail` is not a - // multi-capable type, so the flag does not make its stored value an array - // and equality is the correct predicate. An eyeballed `multiple === true` - // would send `$contains` against a scalar column — wrong in the opposite - // direction, and invisible, because it fails as "no rows" rather than as a - // refusal. + // The same delegation read the other way: `master_detail` is not a + // multi-capable type, so the SPEC's rule says the flag does not make the + // stored value an array, and this component sends `=`. + // + // ⚠️ What this case pins is the component's DELEGATION, ⛔ not that `=` is + // the predicate the storage will answer (objectui#8937). `driver-sql` reads + // `multiple` as truthy on ANY type, stores a JSON column, and refuses `=` + // here with the same `400 INVALID_FILTER` this card was filed for. That + // divergence is KNOWN and owned upstream (objectstack#17469); it is ⛔ not a + // regression of this card — the pre-fix renderer sent `=` for this shape + // too — and ⛔ not repairable by widening the predicate on this side, which + // would only move the disagreement rather than end it. + // + // The opposite error is still real and still guarded: an eyeballed + // `multiple === true` would send `$contains` against a genuinely scalar + // column, which fails as "no rows" rather than as a refusal. const ds = makeDS( recordSchema('contact', { account: { type: 'master_detail', reference: 'account', multiple: true } }), SINGLE_ROWS, diff --git a/packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts b/packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts new file mode 100644 index 0000000000..36bc50af84 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts @@ -0,0 +1,193 @@ +/** + * 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. + */ + +/** + * objectui#8937 — the two published texts objectui#8886 left behind, each + * pinned to the thing it describes rather than to a transcription of it. + * + * Both pins RE-DERIVE on every run. A pin that hard-coded the sentence would + * only assert that nobody edited the sentence, which is the failure mode these + * two defects already are: text that stayed put while the thing it described + * moved. + * + * ## Pin one — the README's parent-scope claim, against the composition + * + * `packages/plugin-detail/README.md` is in this package's `files[]`, so it + * SHIPS. It used to say the node's `filter` is AND-combined with + * `{ [relationshipField]: parentId }` full stop, which stopped being true when + * objectui#7299 made the parent condition arity-dependent. The pin reads the + * spellings out of `RelatedList.tsx`'s own `parentScope` literal and requires + * the README paragraph to name EXACTLY that set — both directions, so the + * README cannot over-claim an operator the component never sends either. + * + * ## Pin two — the divergence the docblock now records, against the spec + * + * `parentRelationshipFieldDef`'s docblock used to say the SQL driver decides + * arity on `@objectstack/spec/data`'s `isMultiValueField`. It does not: the + * driver gates the equality family on its own storage question, which treats + * `multiple` as truthy on ANY type, so the two rules disagree for a type + * OUTSIDE `MULTI_CAPABLE_TYPES` carrying `multiple: true`. The docblock now + * records that divergence and points at objectstack#17469, which owns the + * question of which rule is right. + * + * The DRIVER half of that divergence cannot be imported here — it lives in + * objectstack's `driver-sql`. What can be re-derived is the SPEC half, and it + * is the half that moves: the likely resolution of objectstack#17469 is the + * spec admitting these types, at which point the docblock's list stops being a + * divergence and this file reddens. The driver half is a constant for the cases + * listed — its rule contains the disjunct "`multiple` is truthy", which is + * `true` for every `multiple: true` def regardless of the type set beside it, + * so no import is needed to know the driver answers "JSON column" for all of + * them. + */ + +import { readFileSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + FieldType, + MULTI_CAPABLE_TYPES, + MULTI_OPTION_TYPES, + isMultiValueField, +} from '@objectstack/spec/data'; +import { describe, it, expect } from 'vitest'; + +/** + * Rooted on THIS FILE, never on `process.cwd()` (objectui#7799): the package + * and repo-root invocations have different cwds and would read different trees. + * Bare `import.meta.url` taken apart by hand — `new URL(rel, import.meta.url)` + * is rewritten by Vite into a dev-server URL that `fileURLToPath` rejects. + */ +const SELF_DEPTH_BELOW_REPO_ROOT = 5; // packages / plugin-detail / src / __tests__ / this file +const REPO_ROOT = decodeURIComponent(new URL(import.meta.url).pathname) + .split('/') + .slice(0, -SELF_DEPTH_BELOW_REPO_ROOT) + .join('/'); + +/** Read a tracked file, and prove the read landed or every assertion on it is vacuous. */ +function sourceOf(rel: string): string { + const path = join(REPO_ROOT, rel); + expect(existsSync(path), `source not found at ${path}`).toBe(true); + const text = readFileSync(path, 'utf8'); + expect(text.length, `${rel} read back empty`).toBeGreaterThan(0); + return text; +} + +const COMPONENT = 'packages/plugin-detail/src/RelatedList.tsx'; +const README = 'packages/plugin-detail/README.md'; + +// ── pin one: README ↔ the composition it describes ─────────────────────────── + +/** The `parentScope` object literal's body, as written in the component. */ +function parentScopeBody(source: string): string { + const match = /const parentScope = \{([\s\S]*?)\} as Record;/.exec(source); + expect(match, 'could not find the `parentScope` literal in RelatedList.tsx').not.toBeNull(); + return (match as RegExpExecArray)[1]; +} + +/** Query-DSL operators the parent condition composes, e.g. `$contains`. */ +function composedOperators(body: string): string[] { + return [...new Set(body.match(/\$[A-Za-z][A-Za-z0-9_]*/g) ?? [])].sort(); +} + +/** + * Does the parent condition still have a BARE equality arm? + * + * Operator objects are stripped first, so `{ $contains: parentId }` does not + * masquerade as one — the question is whether `parentId` is ever placed on the + * field key directly. + */ +function hasBareEqualityArm(body: string): boolean { + return /:\s*parentId\s*[,}]/.test(body.replace(/\{[^{}]*\}/g, '')); +} + +/** The blank-line-delimited README paragraph carrying the AND-combination claim. */ +function parentScopeParagraph(readme: string): string { + const paragraphs = readme.split(/\n\s*\n/); + const hits = paragraphs.filter((p) => p.includes('**AND-combined**')); + expect(hits, 'expected exactly one README paragraph claiming the AND-combination').toHaveLength(1); + return hits[0]; +} + +describe('objectui#8937 pin one — the shipped README states the parent condition the component composes', () => { + it('names every operator the composition sends, and no operator it does not', () => { + const body = parentScopeBody(sourceOf(COMPONENT)); + const paragraph = parentScopeParagraph(sourceOf(README)); + + const composed = composedOperators(body); + // Landing proof: a body this extraction failed to read would make every + // assertion below pass by describing nothing. + expect(composed.length, 'the parent condition composes no operator at all — extraction likely missed').toBeGreaterThan(0); + + const claimed = [...new Set(paragraph.match(/\$[A-Za-z][A-Za-z0-9_]*/g) ?? [])].sort(); + expect(claimed, 'the README paragraph must name exactly the operators the component composes').toEqual(composed); + }); + + it('names the equality spelling exactly when the composition still has a bare equality arm', () => { + const body = parentScopeBody(sourceOf(COMPONENT)); + const paragraph = parentScopeParagraph(sourceOf(README)); + const EQUALITY = '{ [relationshipField]: parentId }'; + + expect(paragraph.includes(EQUALITY)).toBe(hasBareEqualityArm(body)); + }); + + it('spells the membership form in full whenever the composition sends `$contains`', () => { + const body = parentScopeBody(sourceOf(COMPONENT)); + const paragraph = parentScopeParagraph(sourceOf(README)); + const MEMBERSHIP = '{ [relationshipField]: { $contains: parentId } }'; + + expect(paragraph.includes(MEMBERSHIP)).toBe(composedOperators(body).includes('$contains')); + }); +}); + +// ── pin two: the docblock's divergence list ↔ the installed spec ───────────── + +/** The docblock prose, un-commented: leading ` * ` stripped, whitespace collapsed. */ +function docblockProse(source: string): string { + return source + .split('\n') + .map((line) => line.replace(/^\s*\*\s?/, '')) + .join(' ') + .replace(/\s+/g, ' '); +} + +/** The field types the docblock names as diverging, read back out of the docblock. */ +function divergentTypesFromDocblock(source: string): string[] { + const prose = docblockProse(source); + const match = /They diverge for[^(]*\(([^)]*)\)/.exec(prose); + expect(match, 'the docblock no longer records a divergence set — pin two has nothing to judge').not.toBeNull(); + return [...((match as RegExpExecArray)[1].match(/`([a-z_]+)`/g) ?? [])].map((t) => t.replace(/`/g, '')); +} + +describe('objectui#8937 pin two — the docblock divergence is re-derived from the installed spec', () => { + it('names real spec field types that the spec predicate still calls single-valued', () => { + const types = divergentTypesFromDocblock(sourceOf(COMPONENT)); + expect(types.length, 'expected the docblock to name the diverging types').toBeGreaterThanOrEqual(3); + + for (const type of types) { + expect(FieldType.safeParse(type).success, `${type} is not a spec field type`).toBe(true); + expect(MULTI_CAPABLE_TYPES.has(type), `${type} joined MULTI_CAPABLE_TYPES`).toBe(false); + expect(MULTI_OPTION_TYPES.has(type), `${type} joined MULTI_OPTION_TYPES`).toBe(false); + // The spec half of the divergence. The driver half is its `multiple` + // disjunct, which is `true` for each of these by construction. + expect(isMultiValueField({ type, multiple: true }), `the spec now calls ${type} multi-valued`).toBe(false); + } + }); + + it('CONTROL — the same predicate answers `true` where it should, so the cases above are not vacuous', () => { + // Without this, a predicate stuck at `false` would satisfy every assertion + // in the case above while asserting nothing about any divergence. + expect(isMultiValueField({ type: 'lookup', multiple: true })).toBe(true); + expect(isMultiValueField({ type: 'user', multiple: true })).toBe(true); + expect(isMultiValueField({ type: 'multiselect' })).toBe(true); + }); + + it('points at the upstream card that owns which of the two rules is right', () => { + expect(sourceOf(COMPONENT)).toContain('objectstack#17469'); + }); +}); From 2d5a7b8d585369fbfb7a7ffe9ea7c24c07233483 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 16:46:48 +0000 Subject: [PATCH 2/3] docs(core): record the parent-scope seam's divergence from driver storage arity objectui#9184 moved the arity compiler into @object-ui/core's parent-scope seam and newly wrote the false claim into its header: that isMultiValueField is "the same predicate the driver that executes the query decides on". Measured on objectstack origin/main, driver-sql gates the equality family on its own storage question, which reads `multiple` as truthy on ANY type -- so the two rules diverge for a type outside MULTI_CAPABLE_TYPES carrying `multiple: true` (master_detail / tree / text). The seam now states the driver's measured rule, records that divergence as a divergence rather than as agreement, and points at objectstack#17469, which owns which of the two rules is right. No predicate and no assertion moved: the arity rules are upstream business. The pin re-anchors onto the seam, since the composition and the docblock it judged both left RelatedList.tsx: - pin one re-derives the operator set from composeParentScopeFilter's returned literal and holds the shipped README to exactly that set, both directions; - pin two re-derives the divergence list from the INSTALLED spec, so it reddens when the spec side moves -- the likely settlement of the upstream card; - pin three is new and is the one that would have caught this rework's cause: every file relating the spec predicate to the driver must cite the card that owns their disagreement, so the claim cannot be copied into a fresh file silently. A discriminator case proves the driver filter narrows rather than demanding the citation of every mention. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- ...plugin-detail-8937-parent-scope-residue.md | 35 +-- packages/core/src/utils/parent-scope.ts | 40 +++- ...relatedListParentScopeResidue-8937.test.ts | 206 +++++++++++++----- 3 files changed, 202 insertions(+), 79 deletions(-) diff --git a/.changeset/plugin-detail-8937-parent-scope-residue.md b/.changeset/plugin-detail-8937-parent-scope-residue.md index 0f66d53950..2ab4898fb1 100644 --- a/.changeset/plugin-detail-8937-parent-scope-residue.md +++ b/.changeset/plugin-detail-8937-parent-scope-residue.md @@ -1,25 +1,28 @@ --- '@object-ui/plugin-detail': patch +'@object-ui/core': patch --- -docs(plugin-detail): correct the shipped README's parent-scope claim and the false driver-arity docblock (objectui#8937) +docs(parent-scope): state the driver's real arity rule, and the arity-dependent parent scope in the shipped README (objectui#8937) Two published texts that objectui#8886 left behind, both measured false on `origin/main`: -- **`README.md` (it is in `files[]`, so it ships).** It said the node's `filter` is - AND-combined with `{ [relationshipField]: parentId }`, full stop. Since objectui#7299 - the parent condition is compiled to match the relationship field's arity, so a - multi-valued relationship gets `{ [relationshipField]: { $contains: parentId } }` - instead. The paragraph now states both spellings and names the arbiter - (`@objectstack/spec/data`'s `isMultiValueField`). -- **`parentRelationshipFieldDef`'s docblock, and the same sentence in the - objectui#7299 test header.** They said the SQL driver decides arity on the spec's - `isMultiValueField`. It does not: `driver-sql` gates the equality family on its own - storage question, which reads `multiple` as truthy on ANY type. The two rules - therefore disagree for a type outside `MULTI_CAPABLE_TYPES` carrying - `multiple: true`, and that divergence is now recorded as a divergence and pointed at - the upstream card that owns it (objectstack#17469). +- **`packages/plugin-detail/README.md` (it is in `files[]`, so it ships).** It said the + node's `filter` is AND-combined with `{ [relationshipField]: parentId }`, full stop. + Since objectui#7299 the parent condition is compiled to match the relationship field's + arity, so a multi-valued relationship gets + `{ [relationshipField]: { $contains: parentId } }` instead. The paragraph now states + both spellings and names the arbiter (`@objectstack/spec/data`'s `isMultiValueField`). +- **The claim that the SQL driver decides arity on that same predicate.** It does not: + `driver-sql` gates the equality family on its own storage question, which reads + `multiple` as truthy on ANY type. The two rules therefore disagree for a type outside + `MULTI_CAPABLE_TYPES` carrying `multiple: true`. objectui#9184 moved the arity compiler + into `@object-ui/core`'s `parent-scope` seam and carried the claim with it, so the + correction is recorded there — the seam now states the driver's measured rule, records + the divergence as a divergence, and points at the upstream card that owns which of the + two rules is right (objectstack#17469). `RelatedList.tsx`'s pointer comment and the + objectui#7299 test header carried the same sentence and are corrected to match. No predicate moved and no wire changed — this release carries corrected published text -only. Both claims are now pinned by re-derivation rather than transcription -(`relatedListParentScopeResidue-8937.test.ts`). +only. All three claims are pinned by re-derivation rather than transcription, in +`relatedListParentScopeResidue-8937.test.ts`. diff --git a/packages/core/src/utils/parent-scope.ts b/packages/core/src/utils/parent-scope.ts index 5ecc618c42..ce8bb586d4 100644 --- a/packages/core/src/utils/parent-scope.ts +++ b/packages/core/src/utils/parent-scope.ts @@ -23,11 +23,11 @@ * * ## The rule itself is not invented here * - * The arity verdict is `@objectstack/spec/data`'s own `isMultiValueField`, the - * same predicate the driver that executes the query decides on. That matters - * more here than anywhere: this function chooses `$contains` vs `=`, the driver - * chooses whether to accept it, and two readers of one question disagreeing is - * the entire defect class. The spec's rule is BROADER than an eyeballed + * The arity verdict is `@objectstack/spec/data`'s own `isMultiValueField`. That + * matters more here than anywhere: this function chooses `$contains` vs `=` and + * every surface that asks the question now reads that one choice, so two + * readers of one question disagreeing — the entire defect class — cannot + * recur INSIDE this repository. The spec's rule is BROADER than an eyeballed * `multiple === true` in both directions — `multiselect` / `checkboxes` / * `tags` persist an array with no flag at all, and `multiple: true` is INERT on * a type outside the spec's multi-capable set (`master_detail`, say) — so a @@ -36,6 +36,36 @@ * ⛔ Do not add a local arity rule at any call site, however small, and ⛔ do * not widen this function to accept an arity the caller computed: the parameter * it takes is METADATA, and the verdict is drawn from it here. + * + * ## ⚠️ The STORAGE side does not read this predicate — the two rules DIVERGE + * + * This header used to say `isMultiValueField` is "the same predicate the driver + * that executes the query decides on". It is not, and the difference is + * observable (objectui#8937). Measured on objectstack `origin/main`, + * `driver-sql` gates the equality family on its own STORAGE question, + * `isJsonField`: a column is JSON when the field's type is in that driver's + * `JSON_COLUMN_TYPES` — the spec's `STRUCTURED_JSON_TYPES` and + * `MULTI_OPTION_TYPES`, plus the driver-internal `object` / `array` aliases — + * OR when `multiple` is merely TRUTHY, on ANY type; a single-value media type + * answers from the ADR-0104 dual-encoding window instead. The spec's predicate + * is `MULTI_OPTION_TYPES.has(type) || (MULTI_CAPABLE_TYPES.has(type) && + * multiple === true)`. + * + * ⇒ They diverge for a type OUTSIDE `MULTI_CAPABLE_TYPES` carrying + * `multiple: true` (`master_detail` / `tree` / `text`): the spec says + * single-valued, so this seam compiles `=`, while the driver stores a JSON + * column and refuses `=` with the same `400 INVALID_FILTER` objectui#7299 was + * filed for. That is a KNOWN divergence and ⛔ not a regression — both + * surfaces sent `=` for that shape before objectui#7299 and objectui#8882 too. + * + * ⛔ Do NOT close it by widening the predicate here: which of the two rules is + * right is an upstream question, filed as objectstack#17469 (enforce-or-align), + * and this seam is deliberately not blocked on it. Widening on this side would + * move the disagreement across the wire rather than end it, and would make the + * ONE compiler this module exists to be disagree with the spec it delegates to. + * The divergence is re-derived from the installed spec on every run by + * `relatedListParentScopeResidue-8937.test.ts`, so this section reddens when the + * spec side moves. */ import { isMultiValueField, type ValueShapeFieldDef } from '@objectstack/spec/data'; diff --git a/packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts b/packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts index 36bc50af84..af29f8fc7c 100644 --- a/packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts +++ b/packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts @@ -7,45 +7,60 @@ */ /** - * objectui#8937 — the two published texts objectui#8886 left behind, each - * pinned to the thing it describes rather than to a transcription of it. + * objectui#8937 — the published texts objectui#8886 left behind, each pinned to + * the thing it describes rather than to a transcription of it. * - * Both pins RE-DERIVE on every run. A pin that hard-coded the sentence would + * Every pin RE-DERIVES on every run. A pin that hard-coded the sentence would * only assert that nobody edited the sentence, which is the failure mode these - * two defects already are: text that stayed put while the thing it described - * moved. + * defects already are: text that stayed put while the thing it described moved. * - * ## Pin one — the README's parent-scope claim, against the composition + * ⚠️ Two of these pins read `@object-ui/core`'s `parent-scope` seam from a + * plugin-detail test file, which is deliberate and is why it is said out loud: + * objectui#8882 / PR objectui#9184 moved the arity compiler OUT of + * `RelatedList.tsx` and into that seam while this card was in flight, taking + * the false sentence with it. The pins follow the claim, not the package — and + * the README half of the subject is still plugin-detail's own published text, + * so this is the one file that can see both ends. * - * `packages/plugin-detail/README.md` is in this package's `files[]`, so it + * ## Pin one — the shipped README's parent-scope claim, against the compiler + * + * `packages/plugin-detail/README.md` is in that package's `files[]`, so it * SHIPS. It used to say the node's `filter` is AND-combined with * `{ [relationshipField]: parentId }` full stop, which stopped being true when * objectui#7299 made the parent condition arity-dependent. The pin reads the - * spellings out of `RelatedList.tsx`'s own `parentScope` literal and requires - * the README paragraph to name EXACTLY that set — both directions, so the - * README cannot over-claim an operator the component never sends either. + * spellings out of `composeParentScopeFilter`'s own returned literal and + * requires the README paragraph to name EXACTLY that set — both directions, so + * the README cannot over-claim an operator the seam never composes either. + * + * ## Pin two — the divergence the seam records, against the installed spec + * + * The seam's header used to say `isMultiValueField` is "the same predicate the + * driver that executes the query decides on", and `RelatedList.tsx` used to say + * the equivalent. Neither is true: `driver-sql` gates the equality family on + * its own storage question, which treats `multiple` as truthy on ANY type, so + * the two rules disagree for a type OUTSIDE `MULTI_CAPABLE_TYPES` carrying + * `multiple: true`. * - * ## Pin two — the divergence the docblock now records, against the spec + * The DRIVER half cannot be imported here — it lives in objectstack's + * `driver-sql`. What can be re-derived is the SPEC half, and it is the half + * that moves: the likely settlement of objectstack#17469 is the spec admitting + * these types, at which point the seam's list stops being a divergence and this + * file reddens. The driver half is a constant for the cases listed — its rule + * contains the disjunct "`multiple` is truthy", `true` for every + * `multiple: true` def regardless of the type set beside it — so no import is + * needed to know the driver answers "JSON column" for all of them. * - * `parentRelationshipFieldDef`'s docblock used to say the SQL driver decides - * arity on `@objectstack/spec/data`'s `isMultiValueField`. It does not: the - * driver gates the equality family on its own storage question, which treats - * `multiple` as truthy on ANY type, so the two rules disagree for a type - * OUTSIDE `MULTI_CAPABLE_TYPES` carrying `multiple: true`. The docblock now - * records that divergence and points at objectstack#17469, which owns the - * question of which rule is right. + * ## Pin three — a THIRD file may not relate the two sides without the citation * - * The DRIVER half of that divergence cannot be imported here — it lives in - * objectstack's `driver-sql`. What can be re-derived is the SPEC half, and it - * is the half that moves: the likely resolution of objectstack#17469 is the - * spec admitting these types, at which point the docblock's list stops being a - * divergence and this file reddens. The driver half is a constant for the cases - * listed — its rule contains the disjunct "`multiple` is truthy", which is - * `true` for every `multiple: true` def regardless of the type set beside it, - * so no import is needed to know the driver answers "JSON column" for all of - * them. + * The rework this file is part of exists because the claim was COPIED into a + * new file rather than edited in place. Pins one and two judge the text where + * it lives today and would both stay green through exactly that move, so the + * sweep below is the one that would have caught it: every file in this tree + * that names both the spec predicate and the driver must cite the card that + * owns their disagreement. */ +import { execFileSync } from 'node:child_process'; import { readFileSync, existsSync } from 'node:fs'; import { join } from 'node:path'; @@ -78,16 +93,20 @@ function sourceOf(rel: string): string { return text; } -const COMPONENT = 'packages/plugin-detail/src/RelatedList.tsx'; +/** The one compiler of the parent-relationship condition (objectui#8882). */ +const SEAM = 'packages/core/src/utils/parent-scope.ts'; +/** The shipped text that describes what that compiler puts on the wire. */ const README = 'packages/plugin-detail/README.md'; -// ── pin one: README ↔ the composition it describes ─────────────────────────── +// ── pin one: README ↔ the compiler it describes ────────────────────────────── -/** The `parentScope` object literal's body, as written in the component. */ -function parentScopeBody(source: string): string { - const match = /const parentScope = \{([\s\S]*?)\} as Record;/.exec(source); - expect(match, 'could not find the `parentScope` literal in RelatedList.tsx').not.toBeNull(); - return (match as RegExpExecArray)[1]; +/** The object literal `composeParentScopeFilter` returns, as written. */ +function composedLiteralBody(source: string): string { + const fn = /export function composeParentScopeFilter\([\s\S]*?\n\}/.exec(source); + expect(fn, `could not find composeParentScopeFilter in ${SEAM}`).not.toBeNull(); + const lit = /return \{([\s\S]*?)\};/.exec((fn as RegExpExecArray)[0]); + expect(lit, 'could not find the returned condition literal').not.toBeNull(); + return (lit as RegExpExecArray)[1]; } /** Query-DSL operators the parent condition composes, e.g. `$contains`. */ @@ -98,46 +117,45 @@ function composedOperators(body: string): string[] { /** * Does the parent condition still have a BARE equality arm? * - * Operator objects are stripped first, so `{ $contains: parentId }` does not + * Operator objects are stripped first, so `{ $contains: parentId }` cannot * masquerade as one — the question is whether `parentId` is ever placed on the * field key directly. */ function hasBareEqualityArm(body: string): boolean { - return /:\s*parentId\s*[,}]/.test(body.replace(/\{[^{}]*\}/g, '')); + return /:\s*parentId\s*(?:[,}]|$)/.test(body.replace(/\{[^{}]*\}/g, '').trim()); } /** The blank-line-delimited README paragraph carrying the AND-combination claim. */ function parentScopeParagraph(readme: string): string { - const paragraphs = readme.split(/\n\s*\n/); - const hits = paragraphs.filter((p) => p.includes('**AND-combined**')); + const hits = readme.split(/\n\s*\n/).filter((p) => p.includes('**AND-combined**')); expect(hits, 'expected exactly one README paragraph claiming the AND-combination').toHaveLength(1); return hits[0]; } -describe('objectui#8937 pin one — the shipped README states the parent condition the component composes', () => { - it('names every operator the composition sends, and no operator it does not', () => { - const body = parentScopeBody(sourceOf(COMPONENT)); +describe('objectui#8937 pin one — the shipped README states the condition the seam compiles', () => { + it('names every operator the seam composes, and no operator it does not', () => { + const body = composedLiteralBody(sourceOf(SEAM)); const paragraph = parentScopeParagraph(sourceOf(README)); const composed = composedOperators(body); // Landing proof: a body this extraction failed to read would make every // assertion below pass by describing nothing. - expect(composed.length, 'the parent condition composes no operator at all — extraction likely missed').toBeGreaterThan(0); + expect(composed.length, 'the condition composes no operator at all — extraction likely missed').toBeGreaterThan(0); const claimed = [...new Set(paragraph.match(/\$[A-Za-z][A-Za-z0-9_]*/g) ?? [])].sort(); - expect(claimed, 'the README paragraph must name exactly the operators the component composes').toEqual(composed); + expect(claimed, 'the README paragraph must name exactly the operators the seam composes').toEqual(composed); }); - it('names the equality spelling exactly when the composition still has a bare equality arm', () => { - const body = parentScopeBody(sourceOf(COMPONENT)); + it('names the equality spelling exactly when the seam still has a bare equality arm', () => { + const body = composedLiteralBody(sourceOf(SEAM)); const paragraph = parentScopeParagraph(sourceOf(README)); const EQUALITY = '{ [relationshipField]: parentId }'; expect(paragraph.includes(EQUALITY)).toBe(hasBareEqualityArm(body)); }); - it('spells the membership form in full whenever the composition sends `$contains`', () => { - const body = parentScopeBody(sourceOf(COMPONENT)); + it('spells the membership form in full whenever the seam composes `$contains`', () => { + const body = composedLiteralBody(sourceOf(SEAM)); const paragraph = parentScopeParagraph(sourceOf(README)); const MEMBERSHIP = '{ [relationshipField]: { $contains: parentId } }'; @@ -145,9 +163,9 @@ describe('objectui#8937 pin one — the shipped README states the parent conditi }); }); -// ── pin two: the docblock's divergence list ↔ the installed spec ───────────── +// ── pin two: the seam's divergence list ↔ the installed spec ───────────────── -/** The docblock prose, un-commented: leading ` * ` stripped, whitespace collapsed. */ +/** Comment prose, un-commented: leading ` * ` stripped, whitespace collapsed. */ function docblockProse(source: string): string { return source .split('\n') @@ -156,18 +174,17 @@ function docblockProse(source: string): string { .replace(/\s+/g, ' '); } -/** The field types the docblock names as diverging, read back out of the docblock. */ -function divergentTypesFromDocblock(source: string): string[] { - const prose = docblockProse(source); - const match = /They diverge for[^(]*\(([^)]*)\)/.exec(prose); - expect(match, 'the docblock no longer records a divergence set — pin two has nothing to judge').not.toBeNull(); +/** The field types the seam names as diverging, read back out of its header. */ +function divergentTypesFrom(source: string): string[] { + const match = /They diverge for[^(]*\(([^)]*)\)/.exec(docblockProse(source)); + expect(match, 'the seam no longer records a divergence set — pin two has nothing to judge').not.toBeNull(); return [...((match as RegExpExecArray)[1].match(/`([a-z_]+)`/g) ?? [])].map((t) => t.replace(/`/g, '')); } -describe('objectui#8937 pin two — the docblock divergence is re-derived from the installed spec', () => { +describe('objectui#8937 pin two — the seam divergence is re-derived from the installed spec', () => { it('names real spec field types that the spec predicate still calls single-valued', () => { - const types = divergentTypesFromDocblock(sourceOf(COMPONENT)); - expect(types.length, 'expected the docblock to name the diverging types').toBeGreaterThanOrEqual(3); + const types = divergentTypesFrom(sourceOf(SEAM)); + expect(types.length, 'expected the seam to name the diverging types').toBeGreaterThanOrEqual(3); for (const type of types) { expect(FieldType.safeParse(type).success, `${type} is not a spec field type`).toBe(true); @@ -188,6 +205,79 @@ describe('objectui#8937 pin two — the docblock divergence is re-derived from t }); it('points at the upstream card that owns which of the two rules is right', () => { - expect(sourceOf(COMPONENT)).toContain('objectstack#17469'); + expect(sourceOf(SEAM)).toContain('objectstack#17469'); + }); +}); + +// ── pin three: relating the spec predicate to the driver obliges a citation ── + +/** + * The population: every tracked file under `packages` that mentions BOTH the + * spec predicate and the driver, i.e. every place in this tree that relates the + * two sides of the divergence. Each one must cite the card that owns which side + * is right, so a fresh comment asserting they agree cannot land silently. + * + * ⚠️ Why the sweep is not for the retired SENTENCE. The obvious pin would grep + * for the wording objectui#8886 and objectui#9184 each used. It was built that + * way first and measured degenerate: the two corrections rephrase the claim + * across a line break, a line-oriented grep therefore misses them, and the only + * remaining match in the tree was the pattern string in this file matching + * itself. A sweep whose sole carrier is its own pattern asserts nothing. This + * one keys on the two SUBJECTS instead, which no rewording moves. + * + * Boundary, stated rather than implied: a file that discusses the divergence + * without naming the driver in those words is outside the population, and + * generated release history (`CHANGELOG.md`) is excluded because it records + * what was once written rather than what the tree now claims. + * + * Enumeration and reading come from the SAME tree — the working tree — so the + * population cannot be drawn from one source and the content from another + * (AGENTS.md's enumeration-vs-read rule). + */ +function filesRelatingPredicateToDriver(): { carriers: string[]; predicateOnly: string[] } { + const out = execFileSync('git', ['grep', '-l', '--', 'isMultiValueField', 'packages'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }); + const mentionsPredicate = out + .split('\n') + .filter(Boolean) + .filter((rel) => !rel.endsWith('CHANGELOG.md')); + const carriers: string[] = []; + const predicateOnly: string[] = []; + for (const rel of mentionsPredicate) { + (/driver/i.test(sourceOf(rel)) ? carriers : predicateOnly).push(rel); + } + return { carriers, predicateOnly }; +} + +describe('objectui#8937 pin three — relating the predicate to the driver obliges the divergence citation', () => { + it('cites objectstack#17469 in every file that relates the two sides', () => { + const { carriers } = filesRelatingPredicateToDriver(); + + // Landing proof: a sweep that matched nothing would pass while checking + // nothing. Three is under the four carriers this tree has today, so the + // number is an alarm rather than a census needing an edit per new file. + expect(carriers.length, 'the sweep found almost no carrier — the enumeration has gone stale').toBeGreaterThanOrEqual(3); + + const uncited = carriers.filter((rel) => !sourceOf(rel).includes('objectstack#17469')); + expect( + uncited, + 'a file relates the spec predicate to the driver without citing the card that owns their divergence', + ).toEqual([]); + }); + + it('DISCRIMINATOR — the driver filter narrows, so the citation demand is not made of every mention', () => { + // Without this, a filter that matched every file would turn the case above + // into "every file mentioning the predicate must cite objectstack#17469", + // which is a different and wrong demand that would happen to be green. + const { carriers, predicateOnly } = filesRelatingPredicateToDriver(); + expect(predicateOnly.length, 'the driver filter excluded nothing — it is a no-op').toBeGreaterThan(0); + // `plugin-grid`'s wrapper names the spec predicate and the server-side + // WRITE pipeline that shares it, and says nothing about the driver. It is + // the nearest true neighbour of the retired claim, so it is the right file + // to require on the excluded side. + expect(predicateOnly).toContain('packages/plugin-grid/src/hooks/multiValueFields.ts'); + expect(carriers).not.toContain('packages/plugin-grid/src/hooks/multiValueFields.ts'); }); }); From d663a1e1dab89bd7ff9997b304beba967a30e7e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 17:16:41 +0000 Subject: [PATCH 3/3] test(scripts): adjudicate the objectui#8937 pin as a markdown test input `Test (shard 1/4)` was red on this branch's own omission: the new pin reads `packages/plugin-detail/README.md`, and a test that reads markdown without a ledger entry can be SKIPPED by a markdown-only pull request -- ci.yml's "needs a full run" step excludes markdown paths, so someone could edit that README and the pin would never execute (objectui#8861). That is the same failure mode the pin's own third case exists to prevent, one level up, and this gate caught it before it shipped. The declared set is MEASURED, not inferred from the constants. Two of the three pins name the README by constant; the third enumerates `git grep -l isMultiValueField packages` and opens every hit, so the population is derived. Measured: the enumeration offers 18 paths, ten of them CHANGELOGs dropped before anything is opened, and exactly one markdown document survives into the read set -- the same README. The root `CHANGELOG.md` the scanner resolves is never in the population: the pathspec is `packages`, and the bare literal it resolved comes from the drop filter. Both CHANGELOG candidates are therefore recorded as `notRead` rather than dropped, and no `walker` is declared because the scanner sees no directory walk here. Readings: `--audit` exit 1 before ("1 finding(s)", unadjudicated-test naming all three paths) and exit 0 after ("47 candidate test files, all adjudicated"). The gate's own suite goes 1 failed / 11 passed -> 12 passed. `--changed` now answers in both directions: it prints the README (a markdown-only edit forces the run) and prints nothing for the CHANGELOG. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UzHd6hDYatoDn17BuwKxnZ --- scripts/markdown-test-inputs.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index e32eb12152..4457d08898 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -260,6 +260,23 @@ export const ADJUDICATED = new Map([ 'packages/plugin-detail/src/__tests__/guideCrudAppRenders.test.tsx', { reads: ['content/docs/guide/building-crud-app.md'] }, ], + // The `reads` below is a MEASURED subset of a DERIVED population, which is + // why it is one document rather than a tree. Two of this test's three pins + // name `packages/plugin-detail/README.md` by constant; the third enumerates + // `git grep -l isMultiValueField packages` and opens every hit, so any + // markdown that starts mentioning that symbol joins the set. Measured on this + // card: the enumeration offers 18 paths, ten of them CHANGELOGs the test + // drops before opening anything, and exactly one markdown document survives + // into the read set -- the same README the other two pins name. The root + // `CHANGELOG.md` the scanner resolves is never in the population at all: the + // pathspec is `packages`, and the bare literal comes from the drop filter. + [ + 'packages/plugin-detail/src/__tests__/relatedListParentScopeResidue-8937.test.ts', + { + reads: ['packages/plugin-detail/README.md'], + notRead: ['CHANGELOG.md', 'packages/plugin-detail/CHANGELOG.md'], + }, + ], [ 'packages/plugin-detail/src/renderers/__tests__/docsActivityMapTranscription-7876.test.ts', { reads: ['content/docs/plugins/plugin-detail.mdx'] },