From 8a5fcd4f0323d24be0009bf1a904f10d7f58e985 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 22:14:53 +0000 Subject: [PATCH] feat(types): export ComboboxOption from the root entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#7691 made `@object-ui/types` the single AUTHORITY for `ComboboxOption` — `@object-ui/components` stopped declaring its own copy and now re-exports this one — but reached it only through the `@object-ui/types/form` subpath, because the root barrel was held by objectui#7683 at the time. Measured on `a4611b3e2`, the name read 0 hits on `packages/types/src/index.ts` while `SelectOption` (2), `RadioOption` (1) and `ComboboxSchema` (1) all read non-zero under the same word-boundary query, so the zero was a reading rather than a dark instrument, and `import type { ComboboxOption } from '@object-ui/types'` read TS2305. Add the name to the barrel's existing named re-export list from `./form.js`, next to `ComboboxSchema` the way `SelectSchema`/`SelectOption` and `RadioGroupSchema`/`RadioOption` already sit. Additive only: the list stays an explicit named list (never a wildcard, which would publish every other name in `form.ts` as a side effect), the declaration stays in `form.ts`, the three declared members are unchanged, and the `/form` subpath keeps working. The new pin asserts BOTH spellings, because having put the name on the barrel the most likely later "tidy" is to drop the subpath as redundant: the type level pins each declared member through each entry point plus their invariant equality (one declaration, not two forks), the source scan pins the explicit named list and the declaration's location, and the manifest pin keeps `exports["./form"]` pointing at the built subpath entry. The source scan is what runs under `pnpm test`, which has no build step ahead of it, so nothing here reads `dist/`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../7697-combobox-option-root-barrel.md | 26 ++ .../combobox-option-root-barrel-7697.test.ts | 225 ++++++++++++++++++ packages/types/src/index.ts | 10 + 3 files changed, 261 insertions(+) create mode 100644 .changeset/7697-combobox-option-root-barrel.md create mode 100644 packages/types/src/__tests__/combobox-option-root-barrel-7697.test.ts diff --git a/.changeset/7697-combobox-option-root-barrel.md b/.changeset/7697-combobox-option-root-barrel.md new file mode 100644 index 0000000000..683025ba97 --- /dev/null +++ b/.changeset/7697-combobox-option-root-barrel.md @@ -0,0 +1,26 @@ +--- +'@object-ui/types': minor +--- + +Export `ComboboxOption` from the `@object-ui/types` root entry (objectui#7697). + +**Additive only.** `ComboboxOption` is added to the root barrel's existing named +re-export list from `./form.js`, next to the sibling option types that were already +there (`SelectOption`, `RadioOption`). Nothing is removed, retyped or narrowed: the +declaration stays in `src/form.ts`, its three members (`value`, `label`, `disabled?`) +are unchanged, and the `@object-ui/types/form` subpath spelling keeps working exactly +as before. Both spellings now resolve to the same declaration. + +**Why it was missing.** objectui#7691 made this package the single authority for the +name — `@object-ui/components` stopped declaring its own copy and now re-exports this +one — but reached it through the `/form` subpath, because the root barrel was held by +objectui#7683 at the time. That choice was deliberate and was judged sound on its own +merits (the subpath is a house pattern, alongside `@object-ui/types/zod` and +`@object-ui/types/internal/retired-field-keys`); this release is the follow-up +objectui#7691 could not take, not a correction of it. + +**What changes for you.** `import type { ComboboxOption } from '@object-ui/types'` now +compiles; on the previous release it read `TS2305` while its two siblings on the same +list resolved. If you already import from `@object-ui/types/form`, or through +`@object-ui/components`, nothing changes and no migration is needed — both remain +supported and are pinned as such. diff --git a/packages/types/src/__tests__/combobox-option-root-barrel-7697.test.ts b/packages/types/src/__tests__/combobox-option-root-barrel-7697.test.ts new file mode 100644 index 0000000000..32e5fc26c7 --- /dev/null +++ b/packages/types/src/__tests__/combobox-option-root-barrel-7697.test.ts @@ -0,0 +1,225 @@ +/** + * 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#7697 — `ComboboxOption` is reachable from BOTH published entry + * points of `@object-ui/types`: the root barrel and the `/form` subpath. + * + * ## The defect + * + * objectui#7691 made this package the single AUTHORITY for `ComboboxOption`: + * `@object-ui/components` stopped declaring its own copy and now re-exports + * this one (`packages/components/src/custom/combobox.tsx` imports and + * re-exports it `from "@object-ui/types/form"`). But the authority was + * reachable only through that subpath. Measured on `origin/main` `a4611b3e2`, + * with the same word-boundary grep on `packages/types/src/index.ts`: + * + * | name | hits | + * | -------------------------- | ---: | + * | `ComboboxOption` | 0 | + * | `SelectOption` (control) | 2 | + * | `RadioOption` (control) | 1 | + * | `ComboboxSchema` (control) | 1 | + * + * The instrument was lit — the three controls read non-zero under the same + * query — so the zero was a reading, not a dark instrument. The consequence a + * consumer saw was `TS2305` on `import type { ComboboxOption } from + * '@object-ui/types'` while the two sibling option types on the same list, + * `SelectOption` and `RadioOption`, resolved. + * + * ⛔ This is NOT a defect in objectui#7691. The subpath was chosen there + * deliberately (the root barrel was held by objectui#7683 at the time, and a + * barrel line would have been both a fence breach and a second + * published-surface addition), and that review judged it sound on its own + * merits — the subpath is a house pattern, alongside `@object-ui/types/zod` + * and `@object-ui/types/internal/retired-field-keys`. This card is the + * follow-up objectui#7691 could not take. + * + * ## Why BOTH spellings are pinned, and not just the new one + * + * The fix is ADDITIVE: one name added to the root barrel's existing named + * re-export list. Nothing is removed, retyped or narrowed, and the subpath + * keeps working. That is exactly the property a later "tidy" is most likely to + * undo without noticing — having put the name on the barrel, dropping the + * `./form` subpath or the components-side import looks like cleanup. Pinning + * only the root spelling would have let that through green. So both are + * asserted, at the type level AND at the manifest level, and neither is + * allowed to stand in for the other. + * + * ## Why there is a SOURCE scan next to the type-level pins + * + * The type-level pins below are erased by the compiler, so they say nothing + * during `pnpm test` — their enforcement is `tsc -p tsconfig.test.json`, the + * third leg of this package's `type-check` script, which CI runs as its own + * job. The source scan is the half that runs under `vitest`, and it is + * deliberately NOT a `dist/` read: this repo's per-PR `test` job runs + * `pnpm test` with no build step ahead of it (turbo's `test` task depends on + * `^build`, the DEPENDENCY closure, never the package's own build), so a test + * that needed a fresh `dist/` would be vacuously absent-or-red on a cold + * cache. `package-exports-manifest.test.ts` records that same constraint for + * the same package; this file follows it rather than re-litigating it. + * + * The scan also pins the two shapes the card ruled out by name: + * ⛔ the list must stay an EXPLICIT named list, never `export * from + * './form.js'` (a wildcard would satisfy "the name is reachable" while + * deleting the deliberate list — and would publish every other name in + * `form.ts` as a side effect), and ⛔ the declaration must stay in `form.ts`, + * never move to `index.ts`. + */ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { describe, it, expect } from 'vitest'; + +import type { ComboboxOption as FromRootBarrel } from '../index'; +import type { ComboboxOption as FromFormSubpath } from '../form'; +// The two controls: sibling option types that were ALREADY on the root +// barrel's `./form.js` list before this change. If either of these fails to +// resolve, every reading in this file is dark. +import type { SelectOption as SelectFromRootBarrel } from '../index'; +import type { RadioOption as RadioFromRootBarrel } from '../index'; + +const require = createRequire(import.meta.url); + +const readSource = (relative: string): string => + readFileSync(require.resolve(relative), 'utf8'); + +const INDEX_SRC = readSource('../index.ts'); +const FORM_SRC = readSource('../form.ts'); + +const pkg = JSON.parse(readSource('../../package.json')) as { + exports?: Record | string>; +}; + +/** + * Invariant type equality — the house spelling (`chart-series-keys-7546`, + * `chat-message-avatar-keys-7295`, and others). Assignability alone would call + * a widened or `any`-resolved type a match; this does not. + */ +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +/** + * The bodies of every `export type { … } from './form.js';` clause in the root + * barrel. There is exactly one, and the assertion below says so. + * + * The brace class is load-bearing, not tidiness. A lazy `[\s\S]*?` body reads + * TWO matches here: the second one opens at some later `export type {` block + * and closes on `index.ts`'s `import type { FormComponentSchema } from + * './form.js';` — the only other line in the file ending in that exact + * sequence — swallowing every export clause in between. Measured: it returned + * 2, and the count assertion is what caught it. + */ +const formReExportBodies = (): string[] => + [...INDEX_SRC.matchAll(/export type \{([^{}]*?)\} from '\.\/form\.js';/gu)].map((m) => m[1] ?? ''); + +/** Every name on the root barrel's `./form.js` named re-export list. */ +const formReExportNames = (): string[] => { + const bodies = formReExportBodies(); + // One clause, or the extraction below is reading a shape this file was not + // written against and every membership answer under it is unreliable. + expect(bodies).toHaveLength(1); + return (bodies[0] ?? '') + .split('\n') + .map((line) => line.trim()) + // Drop the prose. The list carries `//` commentary that spells sibling + // names (`SelectOption`, `RadioOption`), so a substring read of the raw + // block would count comments as exports and the controls would read high + // for the wrong reason. + .filter((line) => line.length > 0 && !line.startsWith('//')) + .map((line) => /^([A-Za-z_$][\w$]*)\s*,?$/u.exec(line)?.[1] ?? '') + .filter((name) => name.length > 0); +}; + +/* ── (a) the type level: both spellings resolve, to ONE declaration ───────── */ + +describe('objectui#7697 — `ComboboxOption` resolves from BOTH entry points', () => { + it('resolves from the ROOT barrel, with its three declared members', () => { + // RED on the untouched base: `../index` has no exported member + // `ComboboxOption`, so the import above fails to resolve under + // `tsc -p tsconfig.test.json` (TS2305) and each alias below is an error. + // + // Members are pinned one at a time rather than as a whole-shape equality: + // a later key ADDED to the declaration is a decision for its own card, not + // a reason for this one to red, but a member RETYPED here would be exactly + // the "nothing retyped or narrowed" claim breaking. + const value: Eq = true; + const label: Eq = true; + const disabled: Eq = true; + expect([value, label, disabled]).toEqual([true, true, true]); + }); + + it('STILL resolves from the `/form` subpath — the fix removes nothing', () => { + const value: Eq = true; + const label: Eq = true; + const disabled: Eq = true; + expect([value, label, disabled]).toEqual([true, true, true]); + }); + + it('the two spellings are the SAME declaration, not two forks of one name', () => { + // The point of objectui#7691 was one authority. A root-barrel line that + // published a *different* `ComboboxOption` would satisfy both assertions + // above and still reintroduce the two-meanings defect + // `scripts/__tests__/one-authority-per-exported-name-6273.test.ts` guards. + const same: Eq = true; + expect(same).toBe(true); + }); + + it('CONTROL — the two sibling option types already on the list still resolve', () => { + const select: Eq = true; + const radio: Eq = true; + expect([select, radio]).toEqual([true, true]); + }); +}); + +/* ── (b) the source: an explicit named list, the declaration left in place ── */ + +describe('objectui#7697 — the root barrel lists the name, explicitly', () => { + it('`ComboboxOption` is on the `./form.js` named re-export list', () => { + // RED on the untouched base: the list closed without this name. + expect(formReExportNames()).toContain('ComboboxOption'); + }); + + it('CONTROL — the siblings and the schema are on the same list', () => { + // If these three fail, the extraction is dark and the reading above says + // nothing. They are the same three controls the card measured. + const names = formReExportNames(); + expect(names).toEqual(expect.arrayContaining(['SelectOption', 'RadioOption', 'ComboboxSchema'])); + }); + + it('the list is still an EXPLICIT named list — no wildcard from `./form.js`', () => { + // ⛔ Ruled out by the card. A wildcard would make the assertion above + // vacuous and would publish every other name in `form.ts` as a side + // effect — a far wider surface change than the one this card authorises. + expect(INDEX_SRC).not.toMatch(/export (?:type )?\* (?:as \w+ )?from '\.\/form\.js';/u); + }); + + it('the declaration did NOT move — `form.ts` still owns it', () => { + // ⛔ Also ruled out by the card: the fix is a barrel line, not a + // relocation. `index.ts` re-exports; it never declares. + expect(FORM_SRC).toMatch(/^export interface ComboboxOption\b/mu); + expect(INDEX_SRC).not.toMatch(/\b(?:interface|type)\s+ComboboxOption\b/u); + }); +}); + +/* ── (c) the manifest: the subpath cannot be silently dropped ─────────────── */ + +describe('objectui#7697 — the `/form` subpath stays a published entry point', () => { + it('`exports["./form"]` still points at the built `form` entry', () => { + // The type-level pin above proves `../form` COMPILES; only this proves the + // spelling `@object-ui/types/form` is still resolvable by a consumer. + // `packages/components/src/custom/combobox.tsx` is the live one. + expect(pkg.exports?.['./form']).toEqual({ + types: './dist/form.d.ts', + import: './dist/form.js', + }); + }); + + it('CONTROL — the root entry point is declared too', () => { + // Its exact shape is pinned by `package-exports-manifest.test.ts` + // (objectui#4896); restated here only as the control that keeps the + // reading above from being a dark instrument. + expect(pkg.exports?.['.']).toBeDefined(); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 98b6c5fade..67b5bfc65f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -174,6 +174,16 @@ export type { FormFieldTab, FormFieldPane, ComboboxSchema, + // The element type of `ComboboxSchema.options`, listed next to its schema the + // way `SelectSchema`/`SelectOption` and `RadioGroupSchema`/`RadioOption` + // already are (objectui#7697). objectui#7691 made this package the single + // AUTHORITY for the name — `@object-ui/components` re-exports this + // declaration instead of forking its own — but left it reachable only + // through the `@object-ui/types/form` subpath, so the root spelling read + // TS2305. This line is purely ADDITIVE: the subpath keeps working, and + // `combobox-option-root-barrel-7697.test.ts` pins BOTH spellings so a later + // tidy cannot silently drop either one. + ComboboxOption, CommandSchema, InputOTPSchema, ToggleSchema,