Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .changeset/20621-retired-key-tsc-names-retirement.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: not-required (no-migration-prescription) this changes only the declared TypeScript type of every `retiredKey()` tombstone, from `undefined` to an uninhabited mark carrying the retirement sentence, on both the input and the parsed side. No authorable key, spelling, export or stored shape moves: the parse, its prescription, the JSON schemas and the authorable surface are unchanged. Every tombstoned key's own retirement is already ledgered under its own ADR-0087 id, and this diff retires nothing new, so `objectstack migrate meta` has nothing to rewrite. The body carries no migration prescription. The only party affected is a TypeScript consumer that reads a tombstoned key into a typed slot, and the compiler shows that consumer the dead read at its own call site. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers this type mark and this diff adds none (not `registered` / `already-registered`); `retiredKey` is referenced in code at 287 sites (not `runtime-interface-only`); and the diff touches `packages/spec` (not `type-surface-only`). -->

**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.
4 changes: 2 additions & 2 deletions packages/spec/src/compose-stacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
11 changes: 7 additions & 4 deletions packages/spec/src/data/driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
Expand Down
17 changes: 10 additions & 7 deletions packages/spec/src/integration/connector-author-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`');
});
}

Expand Down
166 changes: 166 additions & 0 deletions packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts
Original file line number Diff line number Diff line change
@@ -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<ProbeName, readonly ts.Diagnostic[]> {
const paths = new Map<string, string>();
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<ProbeName, readonly ts.Diagnostic[]>();
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<line> TS<code>: <message>`, 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'");
});
});
Loading
Loading