diff --git a/.changeset/11608-partialschema-retire.md b/.changeset/11608-partialschema-retire.md new file mode 100644 index 0000000000..8454b6c333 --- /dev/null +++ b/.changeset/11608-partialschema-retire.md @@ -0,0 +1,23 @@ +--- +'@object-ui/types': minor +--- + +**BREAKING — `PartialSchema` is RETIRED from `@object-ui/types`** (objectui#11608, enforce-or-remove). The utility type leaves the `.` entry, the one entry that published it, with no replacement alias. + +**Clause-②: yes (narrowing)**, shipped as `minor` per this repository's version policy: one name leaves the published surface, and the break is stated here. + +- **Why.** The alias had no reader: no producer, doc or skill in this repository, and none in the sibling repositories the census could read. While `BaseSchema` carried an index signature it declared `type` alone, whatever `T` was (objectui#6397). objectui#8347 removed that signature, which made the alias work as written and brought its published-export question due. A published capability with no reader is retired, not kept for its sunk cost. +- **objectui#8347's note.** That release note says `PartialSchema` works as written once `BaseSchema` lost its index signature. This removal supersedes it. + +**FROM** `import type { PartialSchema } from '@object-ui/types'`, annotating a value as `PartialSchema`. +**TO** the node type's own declared members: annotate a whole node with its node type (`ButtonSchema`, `InputSchema`, …). For a partial value, write `Partial & { type: T['type'] }` inline. It keeps every member `T` declares, with `type` required and the rest optional, and a misspelled key is still refused. + +```ts +// before +import type { ButtonSchema, PartialSchema } from '@object-ui/types'; +const patch: PartialSchema = { type: 'button', label: 'Save' }; + +// after: the import above is a compile error naming the symbol +import type { ButtonSchema } from '@object-ui/types'; +const patch: Partial & { type: ButtonSchema['type'] } = { type: 'button', label: 'Save' }; +``` diff --git a/packages/types/src/__tests__/base-schema-closed-face-8347.test.ts b/packages/types/src/__tests__/base-schema-closed-face-8347.test.ts index 346a1e194e..5ca2612f4c 100644 --- a/packages/types/src/__tests__/base-schema-closed-face-8347.test.ts +++ b/packages/types/src/__tests__/base-schema-closed-face-8347.test.ts @@ -39,8 +39,6 @@ import { describe, it, expect } from 'vitest'; import type { BaseSchema, ComponentRendererProps } from '../base'; import type { CalendarViewSchema } from '../complex'; -import type { ButtonSchema } from '../form'; -import type { PartialSchema } from '../index'; type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; @@ -97,24 +95,10 @@ export const nestedCorrect: BaseSchema = { type: 'card', children: [{ type: 'but const arrived = { type: 'calendar-view' as const, titleField: 'subject', titleFieldd: 'subject' }; export const widenedIsNotRechecked: CalendarViewSchema = arrived; -/* ── 4. `PartialSchema` works as written now (objectui#6397) ─────────────── */ - -// While the signature stood, `keyof T` absorbed every member name and this alias -// declared `type` alone. It keeps `T`'s named members now… -export type _PartialKeepsTheMembers = Expect, keyof ButtonSchema>>; -// …with `type` required and the rest optional… -export const partialButton: PartialSchema = { type: 'button' }; -// …and it refuses a misspelling like every node type. -export const partialMisspelled: PartialSchema = { - type: 'button', - // @ts-expect-error — `labell` is no member of `ButtonSchema`; the key is `label` - labell: 'Save', -}; - describe('`BaseSchema` declares no index signature (objectui#8347, the objectui#7927 pin)', () => { it('keeps the type-level rows alive — `tsc -p tsconfig.test.json` is their reader', () => { - expect([p5Correct.titleField, baseCorrect.className, partialButton.type]).toEqual(['subject', 'font-bold', 'button']); - expect([p5Misspelled, baseMisspelled, nestedMisspelled, nestedCorrect, widenedIsNotRechecked, partialMisspelled]) - .toHaveLength(6); + expect([p5Correct.titleField, baseCorrect.className]).toEqual(['subject', 'font-bold']); + expect([p5Misspelled, baseMisspelled, nestedMisspelled, nestedCorrect, widenedIsNotRechecked]) + .toHaveLength(5); }); }); diff --git a/packages/types/src/__tests__/partial-schema-retired-11608.test.ts b/packages/types/src/__tests__/partial-schema-retired-11608.test.ts new file mode 100644 index 0000000000..c115cd7687 --- /dev/null +++ b/packages/types/src/__tests__/partial-schema-retired-11608.test.ts @@ -0,0 +1,189 @@ +/** + * 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. + */ + +/** + * `PartialSchema` stays RETIRED from `@object-ui/types` (objectui#11608, + * enforce-or-remove). + * + * ## The ruling + * + * The alias was published through the `.` entry and had no reader. While + * `BaseSchema` carried `[key: string]: any` it did not even do what its + * docblock said: every instantiation declared `type` alone (objectui#6397). + * objectui#8347 removed that signature, which made the alias work for the + * first time, and handed the published-export question to this card. The triage + * direction was retire unless a census at work time found a reader; it found + * none, so the export and its docblock went, with no replacement alias. + * ⛔ Putting it back is a published-contract decision, never a convenience. + * + * ## ⚠️ The limit this pin inherits and does NOT close + * + * The tree scan reads this repository's TRACKED files only: ⛔ not sibling + * repositories, ⛔ not customer applications, ⛔ not untracked files. The + * census behind the ruling also read the sibling repositories it could reach, + * and its pull request names the leg it could not. An external TypeScript + * consumer gets a compile error naming the symbol; the changeset's migration is + * what it reads. + * + * ## Three instruments, and why the obvious fourth is absent + * + * - `tsc -p tsconfig.test.json`, chained from this package's `type-check` + * script, reads the `@ts-expect-error` row on the root barrel and the rows + * that compile the changeset's TO spelling. vitest strips types, so those + * rows mean nothing unless `type-check` runs. + * - vitest reads `package.json`'s `exports` map and the SOURCE module behind + * every entry, so an entry added later is covered the day it lands. ⛔ Not + * `dist/`: the per-PR `test` job builds nothing before it runs, the + * constraint `package-exports-manifest.test.ts` records for this package. + * - vitest scans the whole TRACKED tree for the word, with a lit control on + * the same probe. This is the half that sees a re-export chain: an entry + * that forwards with `export *` names nothing itself, but whatever module + * declares the alias does, and that module is tracked. + * - ⛔ A runtime `name in module` leg is deliberately absent: the alias was + * `export type`, so it was never in a runtime namespace, and that leg would + * pass identically before and after this retirement. + */ + +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { dirname, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { ButtonSchema } from '../form'; + +/** Rooted at THIS file, never at `process.cwd()`: the two differ per invocation. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = resolve(HERE, '../..'); +const REPO_ROOT = resolve(HERE, '../../../..'); + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/* ── 1. The type face: the root barrel no longer exports the alias ────────── */ + +// @ts-expect-error objectui#11608 — `PartialSchema` is RETIRED from the published barrel, the `.` entry it shipped through. No replacement alias; the changeset carries the migration. +export type _RetiredFromTheRootBarrel = import('../index').PartialSchema; + +// ⭐ LIT CONTROL for the directive above, through the same import form with no +// directive: the sibling utility it was declared beside. An emptied or moved +// barrel would satisfy the directive on its own; it cannot satisfy this row. +export type _SiblingStillResolves = import('../index').SchemaByType<'button'>; + +/* ── 2. The TO spelling the changeset hands a consumer, compiled ─────────── */ + +// The inline spelling keeps every declared member, `type` required and the +// rest optional, which is what the alias promised. `Partial` maps over the +// members it is given, so it has no `Omit`-over-`keyof` step to collapse. +type ButtonPatch = Partial & { type: ButtonSchema['type'] }; + +export type _ToKeepsTheMembers = Expect>; +export const patchButton: ButtonPatch = { type: 'button' }; +// @ts-expect-error — `type` stays required on the inline spelling +export const patchWithoutType: ButtonPatch = { label: 'Save' }; +export const patchMisspelled: ButtonPatch = { + type: 'button', + // @ts-expect-error — `labell` is no member of `ButtonSchema`; the key is `label` + labell: 'Save', +}; + +describe('objectui#11608 — the migration off `PartialSchema` compiles (type-level rows)', () => { + it('keeps the type-level rows alive — `tsc -p tsconfig.test.json` is their reader', () => { + expect(patchButton.type).toBe('button'); + expect([patchWithoutType, patchMisspelled]).toHaveLength(2); + }); +}); + +/* ── 3. The `exports` map: no entry's source module names the alias ──────── */ + +const WORD = /\bPartialSchema\b/; + +type ExportsMap = Record>; + +/** Every entry of the `exports` map, with the `src/` module its `types` target is emitted from. */ +const entrySources = (): Array<{ entry: string; source: string }> => { + const pkg = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')) as { exports?: ExportsMap }; + return Object.entries(pkg.exports ?? {}).map(([entry, conditions]) => { + const target = typeof conditions === 'string' ? conditions : conditions.types; + // `./dist/X.d.ts` is what `tsc` emits from `src/X.ts`, under this + // package's `rootDir: ./src` and `outDir: ./dist`. + const match = /^\.\/dist\/(.+)\.d\.ts$/.exec(target ?? ''); + expect(match, `entry ${entry} has no ./dist/*.d.ts types target`).not.toBeNull(); + return { entry, source: resolve(PACKAGE_ROOT, 'src', `${match![1]}.ts`) }; + }); +}; + +describe('objectui#11608 — `PartialSchema` is published from no entry of the `exports` map', () => { + it('every entry resolves to a source module this test can read', () => { + const entries = entrySources(); + // The `.` entry is the one the alias shipped through: an enumeration + // without it would make the next assertion vacuous where it matters most. + expect(entries.map((e) => e.entry)).toContain('.'); + expect(entries.filter((e) => !existsSync(e.source)).map((e) => e.entry)).toEqual([]); + }); + + it('no entry source module names `PartialSchema`', () => { + const naming = entrySources() + .filter((e) => WORD.test(readFileSync(e.source, 'utf8'))) + .map((e) => `${e.entry} -> ${relative(REPO_ROOT, e.source)}`); + expect(naming).toEqual([]); + }); + + it('LIT CONTROL — the same read finds `SchemaByType` in the `.` entry source', () => { + const root = entrySources().find((e) => e.entry === '.'); + expect(/\bexport type SchemaByType\b/.test(readFileSync(root!.source, 'utf8'))).toBe(true); + }); +}); + +/* ── 4. The tracked tree: nothing names it ────────────────────────────────── */ + +describe('objectui#11608 — no tracked file outside the release record names `PartialSchema`', () => { + /** + * What is excluded, and why each row is here. ⛔ No allow-list FILE: a list + * that lives on disk outlives the reason for each of its rows. + * + * - `*CHANGELOG.md` — released notes, which must keep naming what shipped + * and what was later removed. + * - `.changeset/` — pending notes, the same record before a release folds + * it into a CHANGELOG. This retirement's own note has to name the alias. + * - this pin, which must write the word to probe for it. + */ + const EXCLUDED = [ + ':!*CHANGELOG.md', + ':!.changeset/', + ':!packages/types/src/__tests__/partial-schema-retired-11608.test.ts', + ]; + + /** `git grep -nE PATTERN -- . EXCLUSIONS`, exit 1 (no match) normalised to an empty list. */ + const grepTree = (pattern: string): string[] => { + try { + const out = execFileSync('git', ['grep', '-nE', pattern, '--', '.', ...EXCLUDED], { + cwd: REPO_ROOT, + encoding: 'utf8', + }); + return out.split('\n').filter(Boolean); + } catch (e) { + // `git grep` exits 1 for "no matches", the PASS case here, told apart + // from a real failure (exit > 1) rather than swallowed. + const status = (e as { status?: number }).status; + if (status === 1) return []; + throw e; + } + }; + + it('the symbol `PartialSchema` appears nowhere: no declaration, no re-export, no reader, no doc', () => { + expect(grepTree('\\bPartialSchema\\b')).toEqual([]); + }); + + it('LIT CONTROL — the same probe finds `SchemaByType`, the sibling utility that stays', () => { + // Without this, the zero above would also come from a broken `git grep` + // invocation, a wrong cwd, or an exclusion list that swallowed the tree, + // and a swallowed tree reads exactly like a clean retirement. + expect(grepTree('\\bSchemaByType\\b').length).toBeGreaterThan(0); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6c9ada3d2a..b1388993e0 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1080,33 +1080,6 @@ export type AnySchema = */ export type SchemaByType = Extract; -/** - * Utility type to make all properties optional except the type. - * Useful for partial schema definitions in editors. - * - * ⚠️ It did NOT deliver that until objectui#8347 (objectui#6397 measured it): - * `Omit` is `Pick>`, and `keyof T` on a type - * carrying a string index signature is `string | number`, so while every - * `T extends BaseSchema` inherited `BaseSchema`'s `[key: string]: any`, the - * literal member names were absorbed and every instantiation declared exactly - * one property, `type`, beside a live index signature. objectui#8347 removed - * that signature, so `keyof T` is the literal member union again and the - * alias works as written, with no edit here. The collapse pin that recorded - * the old reading (`partial-schema-collapse-pin.test.ts`) asked to be deleted - * on that day, and was; `__tests__/base-schema-closed-face-8347.test.ts` pins - * the working reading. - * - * ⚠️ A `T` that declares its OWN index signature still collapses the same way; - * reach for `OmitDeclared` (`./form.ts`) there, the key-remapping spelling - * objectui#9256 used. It had no in-repo consumer at the removal (a reading - * taken then, which nothing re-derives); whether to keep or retire it is a - * published-export question for the maintainer (the pin's own removal - * condition asked for that card), ⛔ not something to settle here. - */ -export type PartialSchema = { - type: T['type']; -} & Partial>; - /** * Schema with required children (for container components). */