|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * `PartialSchema` stays RETIRED from `@object-ui/types` (objectui#11608, |
| 11 | + * enforce-or-remove). |
| 12 | + * |
| 13 | + * ## The ruling |
| 14 | + * |
| 15 | + * The alias was published through the `.` entry and had no reader. While |
| 16 | + * `BaseSchema` carried `[key: string]: any` it did not even do what its |
| 17 | + * docblock said: every instantiation declared `type` alone (objectui#6397). |
| 18 | + * objectui#8347 removed that signature, which made the alias work for the |
| 19 | + * first time, and handed the published-export question to this card. The triage |
| 20 | + * direction was retire unless a census at work time found a reader; it found |
| 21 | + * none, so the export and its docblock went, with no replacement alias. |
| 22 | + * ⛔ Putting it back is a published-contract decision, never a convenience. |
| 23 | + * |
| 24 | + * ## ⚠️ The limit this pin inherits and does NOT close |
| 25 | + * |
| 26 | + * The tree scan reads this repository's TRACKED files only: ⛔ not sibling |
| 27 | + * repositories, ⛔ not customer applications, ⛔ not untracked files. The |
| 28 | + * census behind the ruling also read the sibling repositories it could reach, |
| 29 | + * and its pull request names the leg it could not. An external TypeScript |
| 30 | + * consumer gets a compile error naming the symbol; the changeset's migration is |
| 31 | + * what it reads. |
| 32 | + * |
| 33 | + * ## Three instruments, and why the obvious fourth is absent |
| 34 | + * |
| 35 | + * - `tsc -p tsconfig.test.json`, chained from this package's `type-check` |
| 36 | + * script, reads the `@ts-expect-error` row on the root barrel and the rows |
| 37 | + * that compile the changeset's TO spelling. vitest strips types, so those |
| 38 | + * rows mean nothing unless `type-check` runs. |
| 39 | + * - vitest reads `package.json`'s `exports` map and the SOURCE module behind |
| 40 | + * every entry, so an entry added later is covered the day it lands. ⛔ Not |
| 41 | + * `dist/`: the per-PR `test` job builds nothing before it runs, the |
| 42 | + * constraint `package-exports-manifest.test.ts` records for this package. |
| 43 | + * - vitest scans the whole TRACKED tree for the word, with a lit control on |
| 44 | + * the same probe. This is the half that sees a re-export chain: an entry |
| 45 | + * that forwards with `export *` names nothing itself, but whatever module |
| 46 | + * declares the alias does, and that module is tracked. |
| 47 | + * - ⛔ A runtime `name in module` leg is deliberately absent: the alias was |
| 48 | + * `export type`, so it was never in a runtime namespace, and that leg would |
| 49 | + * pass identically before and after this retirement. |
| 50 | + */ |
| 51 | + |
| 52 | +import { describe, it, expect } from 'vitest'; |
| 53 | +import { existsSync, readFileSync } from 'node:fs'; |
| 54 | +import { execFileSync } from 'node:child_process'; |
| 55 | +import { dirname, relative, resolve } from 'node:path'; |
| 56 | +import { fileURLToPath } from 'node:url'; |
| 57 | +import type { ButtonSchema } from '../form'; |
| 58 | + |
| 59 | +/** Rooted at THIS file, never at `process.cwd()`: the two differ per invocation. */ |
| 60 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 61 | +const PACKAGE_ROOT = resolve(HERE, '../..'); |
| 62 | +const REPO_ROOT = resolve(HERE, '../../../..'); |
| 63 | + |
| 64 | +type Equal<A, B> = |
| 65 | + (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false; |
| 66 | +type Expect<T extends true> = T; |
| 67 | + |
| 68 | +/* ── 1. The type face: the root barrel no longer exports the alias ────────── */ |
| 69 | + |
| 70 | +// @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. |
| 71 | +export type _RetiredFromTheRootBarrel = import('../index').PartialSchema<ButtonSchema>; |
| 72 | + |
| 73 | +// ⭐ LIT CONTROL for the directive above, through the same import form with no |
| 74 | +// directive: the sibling utility it was declared beside. An emptied or moved |
| 75 | +// barrel would satisfy the directive on its own; it cannot satisfy this row. |
| 76 | +export type _SiblingStillResolves = import('../index').SchemaByType<'button'>; |
| 77 | + |
| 78 | +/* ── 2. The TO spelling the changeset hands a consumer, compiled ─────────── */ |
| 79 | + |
| 80 | +// The inline spelling keeps every declared member, `type` required and the |
| 81 | +// rest optional, which is what the alias promised. `Partial` maps over the |
| 82 | +// members it is given, so it has no `Omit`-over-`keyof` step to collapse. |
| 83 | +type ButtonPatch = Partial<ButtonSchema> & { type: ButtonSchema['type'] }; |
| 84 | + |
| 85 | +export type _ToKeepsTheMembers = Expect<Equal<keyof ButtonPatch, keyof ButtonSchema>>; |
| 86 | +export const patchButton: ButtonPatch = { type: 'button' }; |
| 87 | +// @ts-expect-error — `type` stays required on the inline spelling |
| 88 | +export const patchWithoutType: ButtonPatch = { label: 'Save' }; |
| 89 | +export const patchMisspelled: ButtonPatch = { |
| 90 | + type: 'button', |
| 91 | + // @ts-expect-error — `labell` is no member of `ButtonSchema`; the key is `label` |
| 92 | + labell: 'Save', |
| 93 | +}; |
| 94 | + |
| 95 | +describe('objectui#11608 — the migration off `PartialSchema` compiles (type-level rows)', () => { |
| 96 | + it('keeps the type-level rows alive — `tsc -p tsconfig.test.json` is their reader', () => { |
| 97 | + expect(patchButton.type).toBe('button'); |
| 98 | + expect([patchWithoutType, patchMisspelled]).toHaveLength(2); |
| 99 | + }); |
| 100 | +}); |
| 101 | + |
| 102 | +/* ── 3. The `exports` map: no entry's source module names the alias ──────── */ |
| 103 | + |
| 104 | +const WORD = /\bPartialSchema\b/; |
| 105 | + |
| 106 | +type ExportsMap = Record<string, string | Record<string, string>>; |
| 107 | + |
| 108 | +/** Every entry of the `exports` map, with the `src/` module its `types` target is emitted from. */ |
| 109 | +const entrySources = (): Array<{ entry: string; source: string }> => { |
| 110 | + const pkg = JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf8')) as { exports?: ExportsMap }; |
| 111 | + return Object.entries(pkg.exports ?? {}).map(([entry, conditions]) => { |
| 112 | + const target = typeof conditions === 'string' ? conditions : conditions.types; |
| 113 | + // `./dist/X.d.ts` is what `tsc` emits from `src/X.ts`, under this |
| 114 | + // package's `rootDir: ./src` and `outDir: ./dist`. |
| 115 | + const match = /^\.\/dist\/(.+)\.d\.ts$/.exec(target ?? ''); |
| 116 | + expect(match, `entry ${entry} has no ./dist/*.d.ts types target`).not.toBeNull(); |
| 117 | + return { entry, source: resolve(PACKAGE_ROOT, 'src', `${match![1]}.ts`) }; |
| 118 | + }); |
| 119 | +}; |
| 120 | + |
| 121 | +describe('objectui#11608 — `PartialSchema` is published from no entry of the `exports` map', () => { |
| 122 | + it('every entry resolves to a source module this test can read', () => { |
| 123 | + const entries = entrySources(); |
| 124 | + // The `.` entry is the one the alias shipped through: an enumeration |
| 125 | + // without it would make the next assertion vacuous where it matters most. |
| 126 | + expect(entries.map((e) => e.entry)).toContain('.'); |
| 127 | + expect(entries.filter((e) => !existsSync(e.source)).map((e) => e.entry)).toEqual([]); |
| 128 | + }); |
| 129 | + |
| 130 | + it('no entry source module names `PartialSchema`', () => { |
| 131 | + const naming = entrySources() |
| 132 | + .filter((e) => WORD.test(readFileSync(e.source, 'utf8'))) |
| 133 | + .map((e) => `${e.entry} -> ${relative(REPO_ROOT, e.source)}`); |
| 134 | + expect(naming).toEqual([]); |
| 135 | + }); |
| 136 | + |
| 137 | + it('LIT CONTROL — the same read finds `SchemaByType` in the `.` entry source', () => { |
| 138 | + const root = entrySources().find((e) => e.entry === '.'); |
| 139 | + expect(/\bexport type SchemaByType\b/.test(readFileSync(root!.source, 'utf8'))).toBe(true); |
| 140 | + }); |
| 141 | +}); |
| 142 | + |
| 143 | +/* ── 4. The tracked tree: nothing names it ────────────────────────────────── */ |
| 144 | + |
| 145 | +describe('objectui#11608 — no tracked file outside the release record names `PartialSchema`', () => { |
| 146 | + /** |
| 147 | + * What is excluded, and why each row is here. ⛔ No allow-list FILE: a list |
| 148 | + * that lives on disk outlives the reason for each of its rows. |
| 149 | + * |
| 150 | + * - `*CHANGELOG.md` — released notes, which must keep naming what shipped |
| 151 | + * and what was later removed. |
| 152 | + * - `.changeset/` — pending notes, the same record before a release folds |
| 153 | + * it into a CHANGELOG. This retirement's own note has to name the alias. |
| 154 | + * - this pin, which must write the word to probe for it. |
| 155 | + */ |
| 156 | + const EXCLUDED = [ |
| 157 | + ':!*CHANGELOG.md', |
| 158 | + ':!.changeset/', |
| 159 | + ':!packages/types/src/__tests__/partial-schema-retired-11608.test.ts', |
| 160 | + ]; |
| 161 | + |
| 162 | + /** `git grep -nE PATTERN -- . EXCLUSIONS`, exit 1 (no match) normalised to an empty list. */ |
| 163 | + const grepTree = (pattern: string): string[] => { |
| 164 | + try { |
| 165 | + const out = execFileSync('git', ['grep', '-nE', pattern, '--', '.', ...EXCLUDED], { |
| 166 | + cwd: REPO_ROOT, |
| 167 | + encoding: 'utf8', |
| 168 | + }); |
| 169 | + return out.split('\n').filter(Boolean); |
| 170 | + } catch (e) { |
| 171 | + // `git grep` exits 1 for "no matches", the PASS case here, told apart |
| 172 | + // from a real failure (exit > 1) rather than swallowed. |
| 173 | + const status = (e as { status?: number }).status; |
| 174 | + if (status === 1) return []; |
| 175 | + throw e; |
| 176 | + } |
| 177 | + }; |
| 178 | + |
| 179 | + it('the symbol `PartialSchema` appears nowhere: no declaration, no re-export, no reader, no doc', () => { |
| 180 | + expect(grepTree('\\bPartialSchema\\b')).toEqual([]); |
| 181 | + }); |
| 182 | + |
| 183 | + it('LIT CONTROL — the same probe finds `SchemaByType`, the sibling utility that stays', () => { |
| 184 | + // Without this, the zero above would also come from a broken `git grep` |
| 185 | + // invocation, a wrong cwd, or an exclusion list that swallowed the tree, |
| 186 | + // and a swallowed tree reads exactly like a clean retirement. |
| 187 | + expect(grepTree('\\bSchemaByType\\b').length).toBeGreaterThan(0); |
| 188 | + }); |
| 189 | +}); |
0 commit comments