diff --git a/.changeset/9073-doc-union-members-comment-order.md b/.changeset/9073-doc-union-members-comment-order.md new file mode 100644 index 0000000000..c4b9ab8447 --- /dev/null +++ b/.changeset/9073-doc-union-members-comment-order.md @@ -0,0 +1,39 @@ +--- +--- + +Test-only: `packages/types/src/__tests__/filter-builder-mirror-6939.test.ts`. It sits +under `src/`, so the presence gate counts it, but nothing published moves — the +package's build `tsconfig.json` excludes `**/__tests__/**` and its `files` list is +`["dist", "README.md", "CHANGELOG.md", "LICENSE"]`, so the file never reaches a +consumer. Declared as releasing nothing. + +objectui#9073: the doc reader objectui#8774 introduced located the terminating `;` of a +union in the RAW interface block and stripped line comments only afterwards. A `;` +inside a comment on one of the union's own rows therefore ended the slice early — the +published doc's FOURTEEN-member `type?:` union read as EIGHT — and the mirror/doc pin +then rendered *this mirror accepts `type` members … never published — the mirror +widened past the authority.* for six members the doc does publish. + +⭐ The defect is the FALSE POSITIVE, not the under-count: that verdict sends whoever +reads it to look for a widening nobody made, and a checker that lies confidently is +worse than one that stays quiet. + +Comments now come off BEFORE the key is located, and `at` is computed on the stripped +block — stripping shortens it, so an index taken before the strip addresses a different +place after it, and carrying one across drops the union's LEADING members instead. Both +hazards are pinned, alongside a control that is green in both worlds. + +⛔ No accept set moves and the published doc is untouched: the defect was in the reader, +never in the surface it reads. One existing throw branch changes outcome — a union whose +only `;` lives in a comment is now `is unterminated` (loud) rather than a silently +truncated set; the throw's wording is unchanged and the two throws the floor test pins +are unaffected. + +The card's other two items land in the same file. The floor test's `logic` control +claimed to catch "a reader that stopped at the first line of a multi-line union"; it +cannot, because `logic` is itself single-line — measured by mutating the reader into a +line-bounded one, where that leg stays green and what actually reddens is the +zero-members throw out of `documentedTypes()`. The docblock now names that mechanism. +And the equality pin's message offered its exception as "a LATER ruling", citing +objectui#4814 (2026-08-16/17), which PREDATES batch #88 (2026-09-02); it now reads +"ANY ruling", which is what the exception meant. diff --git a/packages/types/src/__tests__/filter-builder-mirror-6939.test.ts b/packages/types/src/__tests__/filter-builder-mirror-6939.test.ts index cca45a2afc..2040c09fce 100644 --- a/packages/types/src/__tests__/filter-builder-mirror-6939.test.ts +++ b/packages/types/src/__tests__/filter-builder-mirror-6939.test.ts @@ -284,16 +284,28 @@ function docInterfaceBlock(doc: string, iface: string): string { * Every quoted member of the union `.` declares, in the doc's own * order. The union may span LINES — the doc lays `type?:` out over five rows — * so the slice runs to the terminating `;`, not to the end of the line. Line - * comments are stripped first: `// Field type` sits inside that slice, and an - * apostrophe in some future one would otherwise mint a phantom member. + * comments are stripped first: `// Field type` sits inside that slice, an + * apostrophe in some future one would otherwise mint a phantom member, and a + * `;` in one would otherwise be mistaken for the terminator (objectui#9073). */ function docUnionMembers(doc: string, iface: string, key: string): string[] { - const block = docInterfaceBlock(doc, iface); + // ⛔ Comments come off FIRST, and everything after this line addresses the + // stripped block only (objectui#9073). Locating the terminating `;` in the + // raw block and stripping afterwards let a `;` inside a union-row comment + // end the slice early: the fourteen-member union read as eight, and the + // mirror/doc pin then announced a widening that had not happened — a + // confident lie, which is worse than silence. + // + // ⚠️ And it is a two-sided fix, not a one-line one: stripping SHORTENS the + // block, so an index taken before the strip addresses a different place + // after it. `at` is therefore computed here, on the stripped block, and + // never carried across from the raw one. + const block = docInterfaceBlock(doc, iface).replace(/\/\/[^\n]*/g, ''); const at = block.indexOf(`\n ${key}:`); if (at === -1) throw new Error(`${DOC}: \`${iface}\` no longer declares \`${key}\``); const end = block.indexOf(';', at); if (end === -1) throw new Error(`${DOC}: \`${iface}.${key}\` is unterminated`); - const body = block.slice(at, end).replace(/\/\/[^\n]*/g, ''); + const body = block.slice(at, end); const members = [...body.matchAll(/'([^']*)'/g)].map((m) => m[1]); if (members.length === 0) { throw new Error(`${DOC}: \`${iface}.${key}\` parsed to ZERO members`); @@ -317,6 +329,23 @@ function documentedTypes(): string[] { return docUnionMembers(publishedDoc(), 'FilterField', 'type?'); } +/** + * The "the mirror widened past the authority" verdict, as a function and a + * sentence rather than an expression buried in one assertion (objectui#9073). + * + * It is lifted out because a controlled input has to be pushed through THE SAME + * comparison and THE SAME sentence the pin renders — a copy of either could + * drift away from the thing it is there to vouch for, and this diagnosis is + * exactly the one that was observed to fire falsely. + */ +const WIDENED_MESSAGE = + `this mirror accepts \`type\` members ${DOC} never published — the mirror widened ` + + `past the authority.`; + +function widenedPastTheDoc(documented: string[]): string[] { + return mirrorTypeMembers().filter((t) => !documented.includes(t)); +} + /** The enum this mirror actually declares, behind `.optional()`. */ function mirrorTypeMembers(): string[] { return (FilterFieldSchema as unknown as { @@ -421,19 +450,21 @@ describe('objectui#6939 — the type vocabulary', () => { const documented = documentedTypes(); expect( documented.filter((t) => !declared.includes(t)), + // ⚠️ This message used to say "a LATER ruling", which is wrong about the + // example it cites and was corrected in objectui#9073: objectui#4814 + // retired `owner` on 2026-08-16/17, and batch #88 is 2026-09-02 — so the + // retirement PREDATES the batch it was offered as an exception to. The + // exception does not depend on the order anyway: a spelling ANY ruling + // has retired does not come back through the doc. `the published doc offers \`type\` members this mirror refuses. Under decision ` + `batch #88 the DOC is the authority and the MIRROR follows — widen ` + `FilterFieldSchema.type and FilterField['type'] to match, as its own reviewable ` + `change. ⛔ Do NOT narrow ${DOC} to match the mirror. The one exception to ` + - `"the mirror follows": a spelling a LATER ruling RETIRED from this doc — the ` + + `"the mirror follows": a spelling ANY ruling RETIRED from this doc — the ` + `way objectui#4814 retired \`owner\` — reappearing in it is a doc REGRESSION, ` + `not a widening, and the doc edit is what gets reverted.`, ).toEqual([]); - expect( - declared.filter((t) => !documented.includes(t)), - `this mirror accepts \`type\` members ${DOC} never published — the mirror widened ` + - `past the authority.`, - ).toEqual([]); + expect(widenedPastTheDoc(documented), WIDENED_MESSAGE).toEqual([]); expect([...declared].sort()).toEqual([...documented].sort()); }); @@ -459,11 +490,22 @@ describe('objectui#6939 — the type vocabulary', () => { const doc = publishedDoc(); // (1) CONTROL — the same reader, the same file, a DIFFERENT block whose // answer is fixed by the ruling at exactly two members. It can fire in - // the region under test: a reader that matched nothing, matched the - // wrong interface, or stopped at the first line of a multi-line union - // returns something that is not `['and','or']`, and this reddens. And - // it is independent of the `type?:` block it vouches for, so the thing - // being measured cannot be what satisfies it. + // the region under test: a reader that matched nothing, or matched the + // wrong interface, returns something that is not `['and','or']`, and + // this reddens. And it is independent of the `type?:` block it vouches + // for, so the thing being measured cannot be what satisfies it. + // ⛔ One mode this control does NOT cover, corrected in objectui#9073 + // after it was claimed here: a reader that stops at the FIRST LINE of a + // multi-line union. `logic` is itself single-line, so such a reader + // reads it correctly and this leg stays green. + // That mode IS covered — measured, by mutating this reader into a + // line-bounded one: `type?:` then parses to zero members, the + // zero-members throw fires out of `documentedTypes()`, and six tests in + // this file redden, this one among them at leg (2) rather than here. + // So the guard exists; it is the THROW below plus the equality pin, not + // this control. ⚠️ A control that names a mode it cannot catch is the + // same class of defect as the reader objectui#9073 repaired: a + // confident claim that sends the next reader to the wrong place. expect(docUnionMembers(doc, 'FilterGroup', 'logic')).toEqual(['and', 'or']); // (2) The population itself is non-empty and duplicate-free — a duplicated // member would make the sorted-equality above pass on unequal sets. @@ -499,6 +541,116 @@ describe('objectui#6939 — the type vocabulary', () => { }); }); +/* ── objectui#9073 — the reader's comment/terminator ORDER ────────────────── */ + +/** + * The fixtures below are CONTROLLED INPUTS, not declarations found in this + * tree, and that is the shape of the card: objectui#9073 is a defect in a + * test-embedded READER, so nothing in the shipped surface is wrong and there is + * nothing to find. No comment anywhere in the published doc carries a `;` + * today — a fixture claiming to have found one would be describing a tree that + * does not exist. + * + * So each one is the REAL doc with exactly one comment rewritten, anchored to a + * literal row rather than hand-written, so that the fixture cannot quietly + * become a straw man when the doc moves: a vanished anchor THROWS. + */ +const UNION_ROW = " | 'date' | 'datetime' | 'time'\n"; +const EARLIER_ROW = " value: string; // Field identifier\n"; + +function docWith(anchor: string, replacement: string): string { + const doc = publishedDoc(); + if (!doc.includes(anchor)) { + throw new Error( + `${DOC}: objectui#9073 fixture anchor ${JSON.stringify(anchor)} is gone — ` + + `the fixture no longer perturbs the doc it claims to perturb`, + ); + } + // Function replacement: a literal `$&`/`$1` in the text would otherwise be a + // substitution pattern rather than the bytes written here. + return doc.replace(anchor, () => replacement); +} + +describe('objectui#9073 — the doc reader strips comments BEFORE it locates the terminator', () => { + it('a `;` inside a union-row comment no longer truncates the union — nor reports a widening that never happened', () => { + // ⭐ What this card is about is the FALSE POSITIVE, not the under-count. + // The reader used to locate the terminating `;` in the RAW block and strip + // comments only afterwards, so the `;` in the comment injected below ended + // the slice EIGHT members in. The mirror's fourteen then read as six + // members the doc "never published", and the pin above rendered + // WIDENED_MESSAGE — announcing a widening nobody had made and sending + // whoever read it to look for a change that does not exist. A reader that + // under-counts a mirror does not stay quiet; it lies confidently. + const poisoned = docWith( + UNION_ROW, + " | 'date' | 'datetime' | 'time' // dates; and date-times\n", + ); + const documented = docUnionMembers(poisoned, 'FilterField', 'type?'); + // The DIAGNOSIS first, deliberately: through the same function and the + // same sentence the pin renders rather than a copy of either, so the red + // run prints the false verdict itself and not a symptom of it. + expect(widenedPastTheDoc(documented), WIDENED_MESSAGE).toEqual([]); + // …and the read underneath it, seeded from the authority rather than from + // a count kept here — the reason objectui#8774 deleted this file's + // hand-kept `DOCUMENTED_FOURTEEN`. + expect(documented).toEqual(documentedTypes()); + }); + + it('a `;` in a comment BEFORE the key keeps the anchor in ONE coordinate system', () => { + // ⚠️ The trap in the one-line reading of this repair. Stripping comments + // SHORTENS the block, so an index computed on the raw block addresses a + // different place in the stripped one. An implementation that strips the + // comments but carries the old `at` across starts its slice INSIDE the + // union and silently drops the LEADING members — a second wrong answer + // reached from the same fix, and one the test above cannot see. + // + // The comment rewritten here sits BEFORE `type?:`, so it never enters the + // slice at all and cannot affect the terminator search either. It can only + // be caught by that coordinate shift, which is why it is a separate leg. + const shifted = docWith( + EARLIER_ROW, + " value: string; // Field identifier; never the label\n", + ); + expect(docUnionMembers(shifted, 'FilterField', 'type?')).toEqual(documentedTypes()); + }); + + it('CONTROL — a doc with no `;` in any comment reads identically in both worlds', () => { + // ⚠️ Named a control because it CANNOT tell the two worlds apart: the + // published doc carries no `;` inside a comment, so this is green before + // the repair and green after it. It is here so the two legs above are + // readable as perturbations of a known-good answer — ⛔ it is not evidence + // that the repair works, and it must not be counted as any. + expect(docUnionMembers(publishedDoc(), 'FilterGroup', 'logic')).toEqual(['and', 'or']); + expect(widenedPastTheDoc(documentedTypes()), WIDENED_MESSAGE).toEqual([]); + }); + + it('a union whose only `;` is inside a comment is UNTERMINATED — loudly, not truncated silently', () => { + // The ONE existing branch this repair moves, recorded here rather than + // discovered by somebody later. Before: the comment's `;` was accepted as + // the terminator and the reader returned a truncated set in silence. + // After: the comment is gone before the search runs, the block genuinely + // has no terminator past the key, and `is unterminated` fires — the throw + // that was already here, wording untouched. Silent-and-wrong → loud is the + // direction this file already declares for its readers ("absence is LOUD + // here"); the two throws pinned in the floor test are not moved at all. + // + // Hand-written rather than doc-anchored, and it has to be: the real block + // carries further `;` after the union (`options?: Array<{ … }>;`), so no + // edit to a COMMENT can leave the real block unterminated. + const handWritten = [ + 'interface FilterField {', + ' type?:', + " | 'text'", + " | 'number' // no terminator past here; only this comment has one", + '}', + '', + ].join('\n'); + expect(() => docUnionMembers(handWritten, 'FilterField', 'type?')).toThrow( + '`FilterField.type?` is unterminated', + ); + }); +}); + describe('objectui#6939 — the group shape is `{ id, logic, conditions }`', () => { it('accepts the shape the catalog authors and `EMPTY_GROUP` emits', () => { expect(FilterGroupSchema.safeParse({ id: 'root', logic: 'and', conditions: [] }).success).toBe(true);