|
| 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 | + * Retirement pin — `ColumnWidthConfig` / `ColumnWidthConfigSchema` are DELETED |
| 11 | + * (objectui#10582, ADR-0049 enforce-or-remove: a declaration nothing pulls on |
| 12 | + * retires immediately). |
| 13 | + * |
| 14 | + * ## What stood behind the pair before the deletion |
| 15 | + * |
| 16 | + * Nothing. The one schema key that carried the type, the `'kanban'` arm's |
| 17 | + * `columnWidths`, retired with that arm (objectui#8802), and the arm's named |
| 18 | + * refusal now answers the whole `type: "kanban"` literal. The one reader, |
| 19 | + * `plugin-kanban`'s `useColumnWidths` hook, was removed by objectui#8522. What |
| 20 | + * was left was a published authoring type an author (or an AI) could build and |
| 21 | + * nothing would ever read. |
| 22 | + * |
| 23 | + * ## Why a deletion and not a tombstone |
| 24 | + * |
| 25 | + * A tombstone (`?: never`, `retirementTombstone()`) exists for a MEMBER KEY, |
| 26 | + * because `BaseSchema` is `.passthrough()` and a deleted member would be kept |
| 27 | + * unjudged. A standalone type has no such escape hatch: an importer of a |
| 28 | + * deleted name fails to compile with "has no exported member", which is the |
| 29 | + * loud refusal. That is the route objectui#7068 took for `ActionCallback` / |
| 30 | + * `ActionCallbackSchema` and objectui#7664 for the `DeclarativeKanban*` trio, |
| 31 | + * and the shape of this pin follows `action-callback-retired-7068.test.ts`. |
| 32 | + * |
| 33 | + * ## Tree-scoped, not file-scoped |
| 34 | + * |
| 35 | + * The last block walks every TypeScript source under `packages/`, `apps/` and |
| 36 | + * `examples/` for a DECLARATION or an EXPORT of either name — a re-declaration |
| 37 | + * in some other package would type-check cleanly and put the dead shape back, |
| 38 | + * and a file-scoped pin sees only the files its author thought of. Prose that |
| 39 | + * tells the story (docblocks, this file) is not a declaration and is not read |
| 40 | + * as one. |
| 41 | + */ |
| 42 | + |
| 43 | +import { readFileSync, readdirSync, statSync } from 'node:fs'; |
| 44 | +import { dirname, join, relative, resolve } from 'node:path'; |
| 45 | +import { fileURLToPath } from 'node:url'; |
| 46 | +import { describe, it, expect } from 'vitest'; |
| 47 | +import ts from 'typescript'; |
| 48 | +import * as complexZod from '../zod/complex.zod.js'; |
| 49 | +import * as zodBarrel from '../zod/index.zod.js'; |
| 50 | + |
| 51 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 52 | +const ROOT = resolve(HERE, '../../../..'); |
| 53 | +const SELF = fileURLToPath(import.meta.url); |
| 54 | + |
| 55 | +const RETIRED = ['ColumnWidthConfig', 'ColumnWidthConfigSchema'] as const; |
| 56 | +const RETIRED_TOKEN = /\bColumnWidthConfig(?:Schema)?\b/; |
| 57 | + |
| 58 | +/** Every top-level name a source file DECLARES or EXPORTS, read off its AST. */ |
| 59 | +function declaredAndExportedNames(file: string): string[] { |
| 60 | + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; |
| 61 | + const sf = ts.createSourceFile(file, readFileSync(file, 'utf8'), ts.ScriptTarget.ESNext, false, kind); |
| 62 | + const names: string[] = []; |
| 63 | + for (const s of sf.statements) { |
| 64 | + if ( |
| 65 | + (ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s) || ts.isClassDeclaration(s) |
| 66 | + || ts.isFunctionDeclaration(s) || ts.isEnumDeclaration(s)) && s.name |
| 67 | + ) { |
| 68 | + names.push(s.name.text); |
| 69 | + } else if (ts.isVariableStatement(s)) { |
| 70 | + for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name)) names.push(d.name.text); |
| 71 | + } else if (ts.isExportDeclaration(s) && s.exportClause && ts.isNamedExports(s.exportClause)) { |
| 72 | + for (const e of s.exportClause.elements) names.push(e.name.text); |
| 73 | + } |
| 74 | + } |
| 75 | + return names; |
| 76 | +} |
| 77 | + |
| 78 | +/* ── the two entry points: neither exports either name ─────────────────── */ |
| 79 | + |
| 80 | +describe('`ColumnWidthConfig` / `ColumnWidthConfigSchema` are DELETED from both `@object-ui/types` entry points (objectui#10582)', () => { |
| 81 | + it('the Zod mirror is exported from neither `complex.zod.ts` nor the `@object-ui/types/zod` barrel', () => { |
| 82 | + expect('ColumnWidthConfigSchema' in complexZod).toBe(false); |
| 83 | + expect('ColumnWidthConfigSchema' in zodBarrel).toBe(false); |
| 84 | + // Lit control: the sibling mirror declared beside it still exports through both. |
| 85 | + expect('CardTemplateSchema' in complexZod).toBe(true); |
| 86 | + expect('CardTemplateSchema' in zodBarrel).toBe(true); |
| 87 | + }); |
| 88 | + |
| 89 | + it('`complex.ts` declares no `ColumnWidthConfig` — read off the AST, so the docblock that tells the story does not count', () => { |
| 90 | + const names = declaredAndExportedNames(resolve(HERE, '../complex.ts')); |
| 91 | + expect(names).not.toContain('ColumnWidthConfig'); |
| 92 | + expect(names).toContain('CardTemplate'); // lit control |
| 93 | + }); |
| 94 | + |
| 95 | + it.each([ |
| 96 | + ['the root entry `index.ts`', '../index.ts', 'ColumnWidthConfig', 'CardTemplate'], |
| 97 | + ['the zod entry `zod/index.zod.ts`', '../zod/index.zod.ts', 'ColumnWidthConfigSchema', 'CardTemplateSchema'], |
| 98 | + ])('%s exports no retired name, and still exports its sibling', (_label, file, retired, sibling) => { |
| 99 | + const names = declaredAndExportedNames(resolve(HERE, file)); |
| 100 | + expect(names).not.toContain(retired); |
| 101 | + expect(names).toContain(sibling); // lit control |
| 102 | + }); |
| 103 | +}); |
| 104 | + |
| 105 | +/* ── tree-scoped: no source anywhere declares or exports either name ───── */ |
| 106 | + |
| 107 | +describe('no source under packages/, apps/ or examples/ declares or exports a retired name (objectui#10582)', () => { |
| 108 | + const SKIP = new Set(['node_modules', 'dist', 'build', 'coverage', '.turbo', '.next', 'out']); |
| 109 | + const walk = (dir: string): string[] => |
| 110 | + readdirSync(dir).flatMap((name) => { |
| 111 | + if (SKIP.has(name)) return []; |
| 112 | + const p = join(dir, name); |
| 113 | + if (statSync(p).isDirectory()) return walk(p); |
| 114 | + return /\.(?:ts|tsx|mts|cts)$/.test(name) && !name.endsWith('.d.ts') ? [p] : []; |
| 115 | + }); |
| 116 | + const sources = ['packages', 'apps', 'examples'].flatMap((d) => walk(resolve(ROOT, d))); |
| 117 | + |
| 118 | + it('no declaration and no export specifier of either name, in any file', () => { |
| 119 | + const offenders = sources |
| 120 | + .filter((f) => f !== SELF && RETIRED_TOKEN.test(readFileSync(f, 'utf8'))) |
| 121 | + .flatMap((f) => |
| 122 | + declaredAndExportedNames(f) |
| 123 | + .filter((n) => (RETIRED as readonly string[]).includes(n)) |
| 124 | + .map((n) => `${relative(ROOT, f)}: ${n}`), |
| 125 | + ); |
| 126 | + expect(offenders).toEqual([]); |
| 127 | + }); |
| 128 | + |
| 129 | + it('the walk can see the corpus and the reader can find a live export (non-vacuity)', () => { |
| 130 | + const rel = new Set(sources.map((f) => relative(ROOT, f))); |
| 131 | + expect(rel.has('packages/plugin-kanban/src/types.ts')).toBe(true); |
| 132 | + expect(rel.has('packages/types/src/zod/complex.zod.ts')).toBe(true); |
| 133 | + // The same reader, on the files the retired names used to leave through, |
| 134 | + // still sees the sibling that did not retire. |
| 135 | + expect(declaredAndExportedNames(resolve(ROOT, 'packages/plugin-kanban/src/types.ts'))).toContain('CardTemplate'); |
| 136 | + expect(declaredAndExportedNames(resolve(ROOT, 'packages/plugin-kanban/src/index.tsx'))).toContain('CardTemplate'); |
| 137 | + expect(declaredAndExportedNames(resolve(ROOT, 'packages/types/src/zod/complex.zod.ts'))).toContain('CardTemplateSchema'); |
| 138 | + }); |
| 139 | +}); |
0 commit comments