diff --git a/.changeset/20621-retired-key-tsc-names-retirement.md b/.changeset/20621-retired-key-tsc-names-retirement.md new file mode 100644 index 00000000000..4b37bff5626 --- /dev/null +++ b/.changeset/20621-retired-key-tsc-names-retirement.md @@ -0,0 +1,32 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): writing a retired key now fails `tsc` with an error that says the key was retired and where its migration is printed (#20621) + +Clause-②: yes + + + +**What an author sees.** Every key a `retiredKey()` tombstone declares used to be typed `undefined`, so writing one failed `tsc` with an error that named no retirement: + +``` +error TS2322: Type 'string[]' is not assignable to type 'undefined'. +``` + +Upgrading authors read those errors as typing bugs. The same line now fails like this, for arrays, objects and primitive values alike: + +``` +error TS2741: Property ''[REMOVED] Key retired: run `os validate` for its migration.'' is missing in type 'string[]' but required in type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'. +error TS2322: Type 'string' is not assignable to type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'. +``` + +A hover on the key shows the same text. `os validate` and the parse print the key's own prescription, which says what replaced the key and gives the one-line fix. + +**What changed.** The declared type of each tombstoned key, on both the input side (`z.input`, the bare `X` aliases) and the parsed side (`z.infer`, the `XParsed` aliases), is now `` { '[REMOVED] Key retired: run `os validate` for its migration.': never } | undefined `` instead of `undefined`. No value can have that object type, because its one property is typed `never`. So `tsc` still accepts only absence, as before. Both sides carry the same type, which keeps the ADR-0122 isomorphism pins true. + +**What did not change.** Runtime behaviour is the same. Each tombstone is still `z.never().optional()`. The parse error and its prescription, the text `os validate` prints, the ADR-0087 conversions, the JSON schemas and the authorable-surface artifacts are all unchanged. No export was added or removed. + +**BREAKING**: a read of a tombstoned key into a slot typed `undefined`, or typed with the key's old type, no longer compiles, because the key's declared type is now its tombstone mark. The key never holds a value, so delete the dead read. It ships as `minor` under the launch-window convention. + +**Who might notice.** Code that assigns a tombstoned key's value to a slot typed exactly `undefined` (for example `const x: undefined = page.assignedProfiles`) no longer compiles. The key never holds a value, so delete the read. The published declaration files are about 7.5% larger, because the declaration emitter writes the type out at every site of the 287 `retiredKey()` calls. diff --git a/packages/spec/src/compose-stacks.test.ts b/packages/spec/src/compose-stacks.test.ts index 4537b504a93..5d627cc3fe4 100644 --- a/packages/spec/src/compose-stacks.test.ts +++ b/packages/spec/src/compose-stacks.test.ts @@ -88,10 +88,10 @@ describe('composeStacks', () => { it('should concatenate apps from multiple stacks', () => { const crm = makeStack({ - apps: [{ name: 'sales', label: 'Sales', objects: ['account'] }], + apps: [{ name: 'sales', label: 'Sales' }], }); const todo = makeStack({ - apps: [{ name: 'tasks', label: 'Tasks', objects: ['task'] }], + apps: [{ name: 'tasks', label: 'Tasks' }], }); const result = composeStacks([crm, todo]); diff --git a/packages/spec/src/data/driver.test.ts b/packages/spec/src/data/driver.test.ts index 5135220bd00..ceb41bb9c20 100644 --- a/packages/spec/src/data/driver.test.ts +++ b/packages/spec/src/data/driver.test.ts @@ -199,14 +199,17 @@ describe('[#4634] tsc channel: the retired bits are unwritable in DriverCapabili const typeAt = (name: string) => checker.typeToString(checker.getTypeOfSymbolAtLocation(props.get(name)!, decl!)); + // `retiredKey()` types the property as its tombstone mark or `undefined`: + // an object whose one property is named by the `[REMOVED]` sentence and + // typed `never`, so there is no value an author can write. A resurrected + // live bit reads `boolean` (or wider) here and turns this red. + const TOMBSTONE = /^\{ '\[REMOVED\] [^']*': never; \} \| undefined$/; for (const bit of RETIRED_BITS) { - // `retiredKey()` is `z.never().optional()`: the property type collapses - // to `undefined` — there is no value an author can write. A resurrected - // live bit reads `boolean` (or wider) here and turns this red. - expect(typeAt(bit), `retired bit ${bit} must be unwritable`).toBe('undefined'); + expect(typeAt(bit), `retired bit ${bit} must be unwritable`).toMatch(TOMBSTONE); } for (const bit of LIVE_BITS) { expect(typeAt(bit), `live bit ${bit} must stay writable`).not.toBe('undefined'); + expect(typeAt(bit), `live bit ${bit} must not be a tombstone`).not.toMatch(TOMBSTONE); } }); }); diff --git a/packages/spec/src/integration/connector-author-shape.test.ts b/packages/spec/src/integration/connector-author-shape.test.ts index 341684d5f19..eafdce8fca6 100644 --- a/packages/spec/src/integration/connector-author-shape.test.ts +++ b/packages/spec/src/integration/connector-author-shape.test.ts @@ -269,16 +269,19 @@ describe('[#5515] the spellings the example used to carry are rejected', () => { const results = compileProbes(probes); // ⚠ Measured, not assumed — and it is the one place the two tombstone - // channels are NOT equally good. `retiredKey()` is `z.never().optional()`, so - // its `z.input` type is `undefined`, and tsc reports the assignment failure - // against that type: "Type '{ … }' is not assignable to type 'undefined'". - // The compile channel therefore REFUSES the key but does not NAME it, while - // the parse channel (the runtime block below) carries the full prescription. + // channels are NOT equally good. `retiredKey()` types the key as its + // tombstone mark (an object whose one property is named by the `[REMOVED]` + // sentence and typed `never`), so tsc reports the assignment failure against + // that mark, worded by the value's kind: TS2741 for the two arrays, TS2353 + // for the `syncConfig` object. The compile channel therefore REFUSES the key + // and says it was retired, pointing at `os validate`, while only the parse + // channel (the runtime block below) carries the key's own prescription. for (const probe of ['field-mappings-retired', 'sync-config-retired', 'webhooks-retired'] as const) { it(`\`${probe}\`: the key no longer type-checks, whatever it holds`, () => { const message = render(results.get(probe)!); - expect(message).toContain('TS2322'); - expect(message).toContain("not assignable to type 'undefined'"); + expect(message).toMatch(/^TS(2741|2353): /); + expect(message).toContain('[REMOVED]'); + expect(message).toContain('`os validate`'); }); } diff --git a/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts b/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts new file mode 100644 index 00000000000..c8af6fa59d9 --- /dev/null +++ b/packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts @@ -0,0 +1,166 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A retired key's `tsc` diagnostic names the retirement. + * + * `retiredKey()` makes a removal audible in two channels: the parse (the + * prescription) and `tsc`. Until this pin the `tsc` channel only said + * `Type 'string[]' is not assignable to type 'undefined'` — audible, but it + * named no retirement, and an upgrading author read forty of them as typing + * bugs. The tombstone's input type now carries a fixed sentence + * (`RetiredKeySchema` in `retired-key.ts`), and this file measures what `tsc` + * actually prints for an authored retired key. + * + * ## Why the compiler API + * + * A `@ts-expect-error` proves only THAT the line fails, never what the + * diagnostic says — and the text is the whole payload here. So each probe + * below is compiled with `ts.createProgram` against this package's real + * source (`@objectstack/spec` is mapped to `src/` through `paths`) and the + * assertion reads the diagnostic itself. The probes are in-memory overlays; + * nothing is written to disk. + * + * ## What is asserted, and what is not + * + * The diagnostic code, the line (the retired key's own), and the two named + * subjects the sentence exists to deliver: the house `[REMOVED]` marker and the + * door that prints the per-key prescription, `os validate`. The sentence's + * remaining wording is deliberately not pinned. + * + * The cases span both object modes and both value kinds, because `tsc` words + * them differently: an array value reads as a missing property (TS2741), a + * string as a plain non-assignable type (TS2322). `PageSchema` and + * `FieldSchema` are `strictObject`s; `IndexSchema` is a plain `z.object`. + * + * ⚠️ Anti-vacuity: a harness that resolves nothing reports import errors or + * nothing at all, and both look like "no retirement text". The control probe + * authors the same stack WITHOUT the retired keys and must compile with ZERO + * diagnostics, which proves `@objectstack/spec` resolved and `defineStack` + * type-checks the literal. + */ + +import { describe, it, expect } from 'vitest'; +import ts from 'typescript'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const PROBE_DIR = resolve(SRC_DIR, 'shared/__retired_key_tsc_probes__'); + +/** The stack every probe authors; `retired` splices retired keys into it. */ +function stackProbe(retired: { page?: string; field?: string; index?: string }): string { + return [ + "import { defineStack } from '@objectstack/spec';", + '', + 'export const stack = defineStack({', + " manifest: { id: 'com.example.probe', name: 'probe', version: '1.0.0', type: 'app' },", + ' objects: [', + ' {', + " name: 'deal',", + " label: 'Deal',", + ' fields: {', + " amount: { type: 'number', label: 'Amount' },", + ` stage: { type: 'text', label: 'Stage'${retired.field ?? ''} },`, + ' },', + ` indexes: [{ fields: ['amount']${retired.index ?? ''} }],`, + ' },', + ' ],', + ` pages: [{ name: 'home_page', label: 'Home', type: 'home'${retired.page ?? ''} }],`, + '});', + '', + ].join('\n'); +} + +const PROBES = { + control: stackProbe({}), + // HotCRM's measured case: `page.assignedProfiles` (strictObject, array value). + page: stackProbe({ page: ", assignedProfiles: ['sales_rep']" }), + // A rename retirement on a strictObject, string value. + field: stackProbe({ field: ", conditionalRequired: 'amount > 0'" }), + // A non-strict `z.object` tombstone, string value. + index: stackProbe({ index: ", type: 'btree'" }), +} as const; + +type ProbeName = keyof typeof PROBES; + +function compileProbes(): Map { + const paths = new Map(); + for (const [name, text] of Object.entries(PROBES)) paths.set(resolve(PROBE_DIR, `${name}.ts`), text); + + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + noEmit: true, + types: ['node'], + paths: { + '@objectstack/spec': [resolve(SRC_DIR, 'index.ts')], + '@objectstack/spec/*': [resolve(SRC_DIR, '*/index.ts')], + }, + }; + + const host = ts.createCompilerHost(options, true); + const realGetSourceFile = host.getSourceFile.bind(host); + const realFileExists = host.fileExists.bind(host); + const realReadFile = host.readFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => { + const overlay = paths.get(resolve(fileName)); + return overlay === undefined + ? realGetSourceFile(fileName, languageVersion, onError, shouldCreate) + : ts.createSourceFile(fileName, overlay, languageVersion, true); + }; + host.fileExists = (fileName) => paths.has(resolve(fileName)) || realFileExists(fileName); + host.readFile = (fileName) => paths.get(resolve(fileName)) ?? realReadFile(fileName); + + const program = ts.createProgram([...paths.keys()], options, host); + const out = new Map(); + for (const name of Object.keys(PROBES) as ProbeName[]) { + const file = program.getSourceFile(resolve(PROBE_DIR, `${name}.ts`)); + if (file === undefined) throw new Error(`probe ${name} did not enter the program`); + out.set(name, [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)]); + } + return out; +} + +/** `L TS: `, one per diagnostic. */ +function render(diagnostics: readonly ts.Diagnostic[]): string[] { + return diagnostics.map((d) => { + const line = d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start).line + 1 : 0; + return `L${line} TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`; + }); +} + +/** The 1-based line of `needle` in a probe's text. */ +function lineOf(name: ProbeName, needle: string): number { + const lines = PROBES[name].split('\n'); + const index = lines.findIndex((l) => l.includes(needle)); + if (index < 0) throw new Error(`${needle} is not in probe ${name}`); + return index + 1; +} + +describe('a retired key names its retirement in the tsc diagnostic', () => { + const diagnostics = compileProbes(); + + it('control: the same stack without retired keys compiles clean (the harness resolves the spec)', () => { + expect(render(diagnostics.get('control')!)).toEqual([]); + }); + + const cases: ReadonlyArray<[ProbeName, string, number]> = [ + ['page', 'assignedProfiles', 2741], + ['field', 'conditionalRequired', 2322], + ['index', "type: 'btree'", 2322], + ]; + + it.each(cases)('%s: writing %s fails with TS%i, and the message says [REMOVED] and names `os validate`', (name, key, code) => { + const rendered = render(diagnostics.get(name)!); + expect(rendered, 'exactly one diagnostic: the retired key').toHaveLength(1); + const [only] = rendered; + expect(only.startsWith(`L${lineOf(name, key)} TS${code}: `), only).toBe(true); + expect(only).toContain('[REMOVED]'); + expect(only).toContain('`os validate`'); + // The form this replaced named no retirement at all. + expect(only).not.toContain("to type 'undefined'"); + }); +}); diff --git a/packages/spec/src/shared/retired-key.ts b/packages/spec/src/shared/retired-key.ts index f367f8ba014..0b3d44fb78e 100644 --- a/packages/spec/src/shared/retired-key.ts +++ b/packages/spec/src/shared/retired-key.ts @@ -31,8 +31,10 @@ * lands in the two channels an upgrading author — very often an AI (ADR-0033) — * actually reads: * - * 1. **`tsc`.** The input type becomes `never`, so assigning anything to the - * key fails to compile at the authoring site, before anything runs. + * 1. **`tsc`.** The input type admits nothing but absence, so assigning + * anything to the key fails to compile at the authoring site, before + * anything runs — and the diagnostic itself says the key was removed and + * where its migration is printed ({@link RetiredKeySchema}). * 2. **The parse.** A value reaching the runtime raises the prescription * itself — not a generic "unrecognized key". This matters because upgrades * do not happen in the order we imagine: someone jumping several majors at @@ -88,12 +90,76 @@ import { z } from 'zod'; +/** + * The declared type of every {@link retiredKey} tombstone: optional, and both + * its INPUT and its OUTPUT are an object type whose one property is named by + * the retirement sentence and typed `never` — a mark no value can carry. + * + * The input type is what `tsc` and the editor show an author who writes the + * key, so it is spelled to be read there. Writing any value prints the + * sentence, whatever the value's kind: + * + * TS2322: Type 'string' is not assignable to type '{ '[REMOVED] Key retired: …': never; }'. + * TS2741: Property ''[REMOVED] Key retired: …'' is missing in type 'string[]' but required in type '{ … }'. + * TS2353: Object literal may only specify known properties, and 'field' does not exist in type '{ … }'. + * + * and a hover on the key reads `key?: { '[REMOVED] Key retired: …': never; } | undefined`. + * Before this type the input was `undefined`, and the diagnostic read + * `Type 'string[]' is not assignable to type 'undefined'` — audible, but it + * said nothing about a retirement, so an upgrading author read it as a typing + * bug. + * + * Four choices here are measured, not stylistic: + * + * - **The mark is written INLINE and anonymous.** A named alias or + * interface is printed BY NAME in the diagnostic (the sentence is lost for + * every value that is not an array), and the name would appear in the + * declared type of every tombstoned schema — a new type every consumer's + * declaration emit must be able to name through an entry point. Inline, + * each site's declared type references `zod` alone. The price is bytes: + * the declaration emitter spells the mark out at every site. + * - **The sentence is fixed, not the key's own prescription.** A type can + * carry a prescription only as a string LITERAL type, and TypeScript types + * `'…' + '…'`, a constant built that way and a helper's return all as + * `string` — the form every guidance in this tree takes. So a type + * generic over `guidance` would carry the text at no site at all. The + * sentence names what happened and the door that prints the per-key + * prescription (`os validate`, or the parse itself); it never says + * "delete", because some retirements are renames. + * - **The OUTPUT carries the same mark.** ADR-0122 declares an `XParsed` + * alias exactly for the schemas whose `z.input` and `z.infer` differ, and + * pins the rest isomorphic (`type-alias-convention.pin.test.ts`). A mark + * on the input alone would split every tombstoned schema that is + * otherwise isomorphic into two "shapes" that differ by a diagnostic + * device, and demand a synonym `XParsed` for each. Parsed data never + * holds the key, and no value can be the mark, so the output stays + * exactly as empty as `undefined` was. + * - **No value satisfies it.** The property's type is `never`, so no + * authored value — array, object or primitive — is assignable without a + * cast, exactly as `undefined` refused everything but absence. The + * runtime is untouched: the schema IS the `z.never().optional()` below + * (an upcast, `never` being assignable to anything), so the parse, the + * prescription and every walker reading `_zod.def` (`acceptsNothing`, + * the JSON-schema build) see what they saw before. + */ +type RetiredKeySchema = Tombstone<{ + '[REMOVED] Key retired: run `os validate` for its migration.': never; +}>; + +/** + * Spells the mark once in source for both sides. A generic alias over a + * `zod` reference is not preserved at the call sites — they print the `zod` + * types with the mark inline, which is the point (see above). + */ +type Tombstone = z.ZodOptional>; + /** * Declare a key that has been REMOVED from the spec. * * Accepts only `undefined` — i.e. absence. Any authored value is rejected with - * `guidance`, and `z.input` types the key as `never` so the same mistake fails - * `tsc` first. + * `guidance`, and `z.input` types the key so the same mistake fails `tsc` + * first, with a diagnostic that says the key was removed + * ({@link RetiredKeySchema}). * * @param guidance - The upgrade prescription. State what replaced the key, the * version that removed it, and the one-line fix — this string IS the migration @@ -110,7 +176,7 @@ import { z } from 'zod'; * ), * ``` */ -export function retiredKey(guidance: string) { +export function retiredKey(guidance: string): RetiredKeySchema { return z.never({ error: () => guidance }).optional().describe(`[REMOVED] ${guidance}`); } @@ -133,12 +199,13 @@ export type RetiredDefaultResidue = Readonly