Skip to content

Commit d830d71

Browse files
feat(spec): a retired key's tsc error names the retirement and points at os validate (#20621) (#21023)
Fixes #20621 Clause-②: yes ## What changed `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) now declares an explicit return type. The input and the output of every tombstoned key are both an object type with one property, typed `never`, whose name is the retirement sentence. Writing the key now fails `tsc` with an error that names the retirement, for every kind of value: ``` 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; }'. TS2322: Type 'string' is not assignable to type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'. TS2353: Object literal may only specify known properties, and 'strategy' does not exist in type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'. ``` Before this change, every one of these errors read `Type 'string[]' is not assignable to type 'undefined'`. **Runtime is unchanged.** The schema is still the same `z.never().optional().describe('[REMOVED] …')`. The return type is an upcast that needs no cast, because `never` is assignable to the mark. The parse error, its prescription, the text `os validate` prints, the D2 conversions, `acceptsNothing` and the JSON-schema/authorable-surface walkers all see what they saw before. No value can have the mark's type without a cast, so `tsc` still accepts only absence. ## Measured channel table Probes: a scratch `tsc` project per design over zod directly, plus the real built `@objectstack/spec` dist with a `defineStack` stack. The stack carries a page's `assignedProfiles` (PageSchema, a `strictObject`, array value), a field's `conditionalRequired` (FieldSchema, a `strictObject`, string value, a rename retirement) and an index's `type` (IndexSchema, a non-strict `z.object`, string value). Strict and non-strict printed identically in every design. | design | literal-guidance site | concatenated / constant / helper site (all 287 in the tree) | `strictObject` vs `z.object` | `tsc` channel | hover channel | |---|---|---|---|---|---| | base: input `undefined` | `not assignable to type 'undefined'` | same | same | ✗ names nothing | `key?: undefined` ✗ | | generic brand over the guidance literal (named interface keyed by a unique symbol) | TS2741 names `RetiredKeyG` with the text inside its type argument, truncated by tsc at about 330 chars | `RetiredKeyG` of `string`: no text, and the `string` argument reads as "wants a string" | same | ✓ only at literal sites, which are 0 of 287 | name only | | fixed-name brand interface `RetiredKey` | names `RetiredKey` (the sentence prints only for array values, if it is the property name) | same | same | ~ the name only | `key?: RetiredKey` ~ | | template literal type, `[REMOVED] ` followed by G | full text, untruncated | the template over `string`; it admits any string with that prefix, which the parse refuses, and steers a string author toward writing one | same | ~, but declares a value the runtime refuses | same as `tsc` | | alias of `undefined`, or `undefined` intersected with a brand | collapses to `undefined` | same | same | ✗ | ✗ | | `@deprecated` JSDoc | reaches hover only if written by hand at each of the 287 sites; `tsc` never prints it (a suggestion diagnostic only; 6385 did not fire on an object-literal property in the probe) | same | same | ✗ (hover-only, refused by triage) | ~ per site | | inline anonymous mark, input only | sentence for every value kind | same (text-independent) | same | ✓ | ✓ | | **shipped: inline anonymous mark, input and output** | sentence for every value kind | same | same | ✓ | ✓ on a `z.input`-annotated literal and inside `defineStack` objects | Call-site census: 287 `retiredKey()` calls in 72 files of `packages/spec/src` at `9525651cf7`. 154 pass a `'…' + '…'` concatenation, 83 a constant, 49 a helper's return and 1 a concatenation with a non-literal operand. 0 pass a single literal, and the checker types the argument `string` at all 287. The 796 in the dispatch is a grep line count that includes doc comments. Why the shipped row and not the others: - **Per-key text in `tsc`** would need every guidance to be one literal. That means joining 154 concatenations into lines of 300 to 600 chars, re-typing 83 constants, and giving 49 helper functions template-literal return types, which is not a mechanical rewrite. The named generic brand also needs a new public type name, and tsc truncates the text anyway. So the per-key prescription stays the parse's and `os validate`'s, and the type names the door. - **Inline and anonymous, not named:** a named type prints by name (the sentence is lost for primitive and object values). It would also appear in the declared type of every tombstoned schema, as a type every consumer's declaration emit must be able to name through an entry point. That means new exports on about 15 entry barrels, outside this card's file surface. - **The mark rides both sides:** the input-only row was implemented first (`b8fad3540b`). It turned 21 ADR-0122 isomorphism pins in `type-alias-convention.pin.test.ts` red (TS2344). Each of those schemas differs between `z.input` and `z.infer` only by the mark, and ADR-0122 answers that difference with a synonym `XParsed` alias per schema. With the mark on both sides the pins hold and no alias is needed. Parsed data never holds the key, so the output is as empty as `undefined` was. ## Declared-surface delta - `api-surface/**`, `api-surface-signatures.json` (factory signature hashes), `declaration-map/**`, `export-origins/**`, `authorable-surface/**`, JSON schemas and `content/docs/references/**`: **0 bytes**. `check:generated` reports 15 of 15 current at `9525651cf7`, and nothing needed regenerating. No export was added or removed. `check:entry-nameability` is green, because each site's declared type references `zod` alone. - Published declarations (`dist/**/*.d.ts` + `*.d.mts`): 27,614,911 → 29,691,599 bytes, **+2,076,688 (+7.5%)**. Base `3693a1b50d`, against `b9b13c4599` on the same base. Of the 4,084 occurrences of the old `z.ZodOptional` over `z.ZodNever` declared type, 20 remain; those are hand-written `z.never().optional()` sites, not tombstones. The mark is spelled out 4 times per site, 19,180 occurrences in all: once each as input and output, then again inside zod's internals parameter. - Consumer `tsc --extendedDiagnostics` on the `defineStack` probe, four runs each on a shared box: Lines of Definitions 135,138 → 144,018 (+6.6%), Types 69,736 → 72,753 (+4.3%), memory about 417 MB → about 436 MB (+4.5%). Total time ranged 5.16 to 5.77 s on base and 5.24 to 5.64 s with the change, within noise. - `check:type-check-debt --re-measure`: 53 raw errors, none above its record. `check:type-check-coverage` is green. ## Pins - `packages/spec/src/shared/retired-key-tsc-diagnostic.test.ts` (new) compiles in-memory `defineStack` probes against the spec source with the compiler API and reads each diagnostic. It covers page `assignedProfiles` (`strictObject`, array, TS2741), field `conditionalRequired` (`strictObject`, string, TS2322) and index `type` (non-strict `z.object`, string, TS2322). Each case asserts exactly one diagnostic, on the retired key's own line, containing `[REMOVED]` and `` `os validate` ``. A control probe, the same stack with no retired keys, must compile with zero diagnostics. - Two existing pins asserted the old diagnostic and are updated: `data/driver.test.ts` (DriverCapabilities' retired bits typed exactly `undefined`) and `integration/connector-author-shape.test.ts` (three retired connector keys, `TS2322` / `not assignable to type 'undefined'`). They now assert the tombstone mark. - **Ablation**, run from the committed head `9525651cf7` with `node scripts/ablation-replace.mjs`. It deletes the return annotation (`retiredKey(guidance: string): RetiredKeySchema {` → `retiredKey(guidance: string) {`): anchor 1 → 0, blob `0b3d44fb78eb` → `f09c06a27cf8`. Result: 7 failed and 66 passed across the 3 files. All three new cases went red with `L16 TS2322: Type 'string[]' is not assignable to type 'undefined'`, along with the driver pin and the three connector cases; the control stayed green. Restored: blob equals HEAD (`0b3d44fb78eb`), `git diff HEAD` is empty and the tree is clean. The probes resolve `@objectstack/spec` to `src/` through the compiler's own `paths`, so no dist is involved. ## Fixed in place (same defect class, outside the claimed file surface) - `packages/spec/src/compose-stacks.test.ts`: two fixtures wrote the retired `App.objects` key (`objects: ['account']`), and nothing in the test asserts it. That ledgered debt changed signature under this change, and `check:test-typecheck` called it maintainer-only to re-record. The key is deleted per its own prescription ("Delete the key."). `test-typecheck-debt.json` was re-recorded by `gen:test-typecheck-debt` as a pure shrink (−1 line: the `not assignable to type 'undefined'` signature, 2 errors). - `data/driver.test.ts` and `integration/connector-author-shape.test.ts`, as above. No open PR touched any of these files at the time of the edit, from a read of all open PRs' file lists. ## Verification (head `9525651cf7` unless noted) - `dispatch-gates --commands`: 84 commands, all exit 0, reconciled with `--ran`: 84 derived, 84 run, 0 NOT-MEASURED, derived from the recorded exit codes. - `pnpm --filter @objectstack/spec typecheck` (src `tsc`, scripts, test layer): green. `check:generated`: 15 of 15 current. - `pnpm --filter @objectstack/spec exec vitest run --project local`: 585 files, 17,209 passed, 1 todo. - `--project repo`: 37 of 47 files (608 tests) green in three chunks. The other 10 are NOT MEASURED (see the next section). - `turbo run build` over `./packages/*` and `./packages/*/*`: 71 of 71, including every downstream DTS emit over the new declarations. - Consumer lanes at `9112064873`, which has the same type semantics (the later commits are a comment rewrap and a merge of non-spec `main`): `pnpm --filter @objectstack/downstream-contract run typecheck`, `pnpm --filter './examples/*' run typecheck` (5 apps) and `turbo run typecheck` over `./packages/*`, `./packages/*/*` and `./apps/*` (135 of 135 tasks) are all green. - eslint, narrowed to the 5 touched `.ts` files: `--format json` reports 5 files, 0 errors, 0 warnings. The repo has one `eslint.config.mjs`, and its `files` glob covers every `**/*.ts` here. That config never enables type-aware linting (the resolved `parserOptions` is `{ ecmaVersion, sourceType }`), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ### NOT MEASURED - 10 spec `--project repo` tooling tests that spawn builds or merges (`build-schemas-check-mode`, `dist-freshness-adoption`, `dist-freshness`, `conversions-major18-merge`, `count-shards-merge`, `step18-rationale-merge`, `def-key-collisions`, `sharded-artifacts`, `publish-smoke-boot-failure`, `publish-smoke-port-collision`). Reason: the whole project ran past the ~10-minute foreground cap twice. None of them reads retired-key typing. CI runs `test:repo`. - objectui typecheck against the new declarations. Reason: objectui consumes the published spec, so this reaches it at its next spec bump, and the Console Pin Gate build bundles with vite and does not type-check. ## Acceptance notes - About a dozen spec tests carry `@ts-expect-error` comments that say the tombstone's "input type is `never`" (it was `undefined`, and it is now the mark). The expectations still fire; only the comment text drifted. Carrier: none. - A consumer that reads a tombstoned key into a slot typed exactly `undefined`, or into a typed slot such as `string[] | undefined`, no longer compiles. The key never holds a value, so the fix is to delete the dead read. The changeset says so. In-repo population: zero, per the workspace and consumer typecheck lanes above. - The dispatch said to stop and explain for any file outside the claimed surface. I applied os-dev's bounded in-place fix instead, because all four conditions hold: same defect class, a mechanical form pinned by the retirement's own prescription and by the new pin, no other holder of the files, and the same gate family. The conflict is recorded here and in the report. --- _Generated by [Claude Code](https://claude.ai/code/session_018fxqvRJW12TaHC7DUQ89Y6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ceee88f commit d830d71

7 files changed

Lines changed: 299 additions & 29 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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)
6+
7+
Clause-②: yes
8+
9+
<!-- 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`). -->
10+
11+
**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:
12+
13+
```
14+
error TS2322: Type 'string[]' is not assignable to type 'undefined'.
15+
```
16+
17+
Upgrading authors read those errors as typing bugs. The same line now fails like this, for arrays, objects and primitive values alike:
18+
19+
```
20+
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; }'.
21+
error TS2322: Type 'string' is not assignable to type '{ '[REMOVED] Key retired: run `os validate` for its migration.': never; }'.
22+
```
23+
24+
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.
25+
26+
**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.
27+
28+
**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.
29+
30+
**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.
31+
32+
**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.

‎packages/spec/src/compose-stacks.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,10 +88,10 @@ describe('composeStacks', () => {
8888

8989
it('should concatenate apps from multiple stacks', () => {
9090
const crm = makeStack({
91-
apps: [{ name: 'sales', label: 'Sales', objects: ['account'] }],
91+
apps: [{ name: 'sales', label: 'Sales' }],
9292
});
9393
const todo = makeStack({
94-
apps: [{ name: 'tasks', label: 'Tasks', objects: ['task'] }],
94+
apps: [{ name: 'tasks', label: 'Tasks' }],
9595
});
9696

9797
const result = composeStacks([crm, todo]);

‎packages/spec/src/data/driver.test.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,17 @@ describe('[#4634] tsc channel: the retired bits are unwritable in DriverCapabili
199199
const typeAt = (name: string) =>
200200
checker.typeToString(checker.getTypeOfSymbolAtLocation(props.get(name)!, decl!));
201201

202+
// `retiredKey()` types the property as its tombstone mark or `undefined`:
203+
// an object whose one property is named by the `[REMOVED]` sentence and
204+
// typed `never`, so there is no value an author can write. A resurrected
205+
// live bit reads `boolean` (or wider) here and turns this red.
206+
const TOMBSTONE = /^\{ '\[REMOVED\] [^']*': never; \} \| undefined$/;
202207
for (const bit of RETIRED_BITS) {
203-
// `retiredKey()` is `z.never().optional()`: the property type collapses
204-
// to `undefined` — there is no value an author can write. A resurrected
205-
// live bit reads `boolean` (or wider) here and turns this red.
206-
expect(typeAt(bit), `retired bit ${bit} must be unwritable`).toBe('undefined');
208+
expect(typeAt(bit), `retired bit ${bit} must be unwritable`).toMatch(TOMBSTONE);
207209
}
208210
for (const bit of LIVE_BITS) {
209211
expect(typeAt(bit), `live bit ${bit} must stay writable`).not.toBe('undefined');
212+
expect(typeAt(bit), `live bit ${bit} must not be a tombstone`).not.toMatch(TOMBSTONE);
210213
}
211214
});
212215
});

‎packages/spec/src/integration/connector-author-shape.test.ts‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -269,16 +269,19 @@ describe('[#5515] the spellings the example used to carry are rejected', () => {
269269
const results = compileProbes(probes);
270270

271271
// ⚠ Measured, not assumed — and it is the one place the two tombstone
272-
// channels are NOT equally good. `retiredKey()` is `z.never().optional()`, so
273-
// its `z.input` type is `undefined`, and tsc reports the assignment failure
274-
// against that type: "Type '{ … }' is not assignable to type 'undefined'".
275-
// The compile channel therefore REFUSES the key but does not NAME it, while
276-
// the parse channel (the runtime block below) carries the full prescription.
272+
// channels are NOT equally good. `retiredKey()` types the key as its
273+
// tombstone mark (an object whose one property is named by the `[REMOVED]`
274+
// sentence and typed `never`), so tsc reports the assignment failure against
275+
// that mark, worded by the value's kind: TS2741 for the two arrays, TS2353
276+
// for the `syncConfig` object. The compile channel therefore REFUSES the key
277+
// and says it was retired, pointing at `os validate`, while only the parse
278+
// channel (the runtime block below) carries the key's own prescription.
277279
for (const probe of ['field-mappings-retired', 'sync-config-retired', 'webhooks-retired'] as const) {
278280
it(`\`${probe}\`: the key no longer type-checks, whatever it holds`, () => {
279281
const message = render(results.get(probe)!);
280-
expect(message).toContain('TS2322');
281-
expect(message).toContain("not assignable to type 'undefined'");
282+
expect(message).toMatch(/^TS(2741|2353): /);
283+
expect(message).toContain('[REMOVED]');
284+
expect(message).toContain('`os validate`');
282285
});
283286
}
284287

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A retired key's `tsc` diagnostic names the retirement.
5+
*
6+
* `retiredKey()` makes a removal audible in two channels: the parse (the
7+
* prescription) and `tsc`. Until this pin the `tsc` channel only said
8+
* `Type 'string[]' is not assignable to type 'undefined'` — audible, but it
9+
* named no retirement, and an upgrading author read forty of them as typing
10+
* bugs. The tombstone's input type now carries a fixed sentence
11+
* (`RetiredKeySchema` in `retired-key.ts`), and this file measures what `tsc`
12+
* actually prints for an authored retired key.
13+
*
14+
* ## Why the compiler API
15+
*
16+
* A `@ts-expect-error` proves only THAT the line fails, never what the
17+
* diagnostic says — and the text is the whole payload here. So each probe
18+
* below is compiled with `ts.createProgram` against this package's real
19+
* source (`@objectstack/spec` is mapped to `src/` through `paths`) and the
20+
* assertion reads the diagnostic itself. The probes are in-memory overlays;
21+
* nothing is written to disk.
22+
*
23+
* ## What is asserted, and what is not
24+
*
25+
* The diagnostic code, the line (the retired key's own), and the two named
26+
* subjects the sentence exists to deliver: the house `[REMOVED]` marker and the
27+
* door that prints the per-key prescription, `os validate`. The sentence's
28+
* remaining wording is deliberately not pinned.
29+
*
30+
* The cases span both object modes and both value kinds, because `tsc` words
31+
* them differently: an array value reads as a missing property (TS2741), a
32+
* string as a plain non-assignable type (TS2322). `PageSchema` and
33+
* `FieldSchema` are `strictObject`s; `IndexSchema` is a plain `z.object`.
34+
*
35+
* ⚠️ Anti-vacuity: a harness that resolves nothing reports import errors or
36+
* nothing at all, and both look like "no retirement text". The control probe
37+
* authors the same stack WITHOUT the retired keys and must compile with ZERO
38+
* diagnostics, which proves `@objectstack/spec` resolved and `defineStack`
39+
* type-checks the literal.
40+
*/
41+
42+
import { describe, it, expect } from 'vitest';
43+
import ts from 'typescript';
44+
import { dirname, resolve } from 'node:path';
45+
import { fileURLToPath } from 'node:url';
46+
47+
const SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
48+
const PROBE_DIR = resolve(SRC_DIR, 'shared/__retired_key_tsc_probes__');
49+
50+
/** The stack every probe authors; `retired` splices retired keys into it. */
51+
function stackProbe(retired: { page?: string; field?: string; index?: string }): string {
52+
return [
53+
"import { defineStack } from '@objectstack/spec';",
54+
'',
55+
'export const stack = defineStack({',
56+
" manifest: { id: 'com.example.probe', name: 'probe', version: '1.0.0', type: 'app' },",
57+
' objects: [',
58+
' {',
59+
" name: 'deal',",
60+
" label: 'Deal',",
61+
' fields: {',
62+
" amount: { type: 'number', label: 'Amount' },",
63+
` stage: { type: 'text', label: 'Stage'${retired.field ?? ''} },`,
64+
' },',
65+
` indexes: [{ fields: ['amount']${retired.index ?? ''} }],`,
66+
' },',
67+
' ],',
68+
` pages: [{ name: 'home_page', label: 'Home', type: 'home'${retired.page ?? ''} }],`,
69+
'});',
70+
'',
71+
].join('\n');
72+
}
73+
74+
const PROBES = {
75+
control: stackProbe({}),
76+
// HotCRM's measured case: `page.assignedProfiles` (strictObject, array value).
77+
page: stackProbe({ page: ", assignedProfiles: ['sales_rep']" }),
78+
// A rename retirement on a strictObject, string value.
79+
field: stackProbe({ field: ", conditionalRequired: 'amount > 0'" }),
80+
// A non-strict `z.object` tombstone, string value.
81+
index: stackProbe({ index: ", type: 'btree'" }),
82+
} as const;
83+
84+
type ProbeName = keyof typeof PROBES;
85+
86+
function compileProbes(): Map<ProbeName, readonly ts.Diagnostic[]> {
87+
const paths = new Map<string, string>();
88+
for (const [name, text] of Object.entries(PROBES)) paths.set(resolve(PROBE_DIR, `${name}.ts`), text);
89+
90+
const options: ts.CompilerOptions = {
91+
target: ts.ScriptTarget.ES2022,
92+
module: ts.ModuleKind.ESNext,
93+
moduleResolution: ts.ModuleResolutionKind.Bundler,
94+
strict: true,
95+
skipLibCheck: true,
96+
noEmit: true,
97+
types: ['node'],
98+
paths: {
99+
'@objectstack/spec': [resolve(SRC_DIR, 'index.ts')],
100+
'@objectstack/spec/*': [resolve(SRC_DIR, '*/index.ts')],
101+
},
102+
};
103+
104+
const host = ts.createCompilerHost(options, true);
105+
const realGetSourceFile = host.getSourceFile.bind(host);
106+
const realFileExists = host.fileExists.bind(host);
107+
const realReadFile = host.readFile.bind(host);
108+
host.getSourceFile = (fileName, languageVersion, onError, shouldCreate) => {
109+
const overlay = paths.get(resolve(fileName));
110+
return overlay === undefined
111+
? realGetSourceFile(fileName, languageVersion, onError, shouldCreate)
112+
: ts.createSourceFile(fileName, overlay, languageVersion, true);
113+
};
114+
host.fileExists = (fileName) => paths.has(resolve(fileName)) || realFileExists(fileName);
115+
host.readFile = (fileName) => paths.get(resolve(fileName)) ?? realReadFile(fileName);
116+
117+
const program = ts.createProgram([...paths.keys()], options, host);
118+
const out = new Map<ProbeName, readonly ts.Diagnostic[]>();
119+
for (const name of Object.keys(PROBES) as ProbeName[]) {
120+
const file = program.getSourceFile(resolve(PROBE_DIR, `${name}.ts`));
121+
if (file === undefined) throw new Error(`probe ${name} did not enter the program`);
122+
out.set(name, [...program.getSyntacticDiagnostics(file), ...program.getSemanticDiagnostics(file)]);
123+
}
124+
return out;
125+
}
126+
127+
/** `L<line> TS<code>: <message>`, one per diagnostic. */
128+
function render(diagnostics: readonly ts.Diagnostic[]): string[] {
129+
return diagnostics.map((d) => {
130+
const line = d.file && d.start !== undefined ? d.file.getLineAndCharacterOfPosition(d.start).line + 1 : 0;
131+
return `L${line} TS${d.code}: ${ts.flattenDiagnosticMessageText(d.messageText, ' ')}`;
132+
});
133+
}
134+
135+
/** The 1-based line of `needle` in a probe's text. */
136+
function lineOf(name: ProbeName, needle: string): number {
137+
const lines = PROBES[name].split('\n');
138+
const index = lines.findIndex((l) => l.includes(needle));
139+
if (index < 0) throw new Error(`${needle} is not in probe ${name}`);
140+
return index + 1;
141+
}
142+
143+
describe('a retired key names its retirement in the tsc diagnostic', () => {
144+
const diagnostics = compileProbes();
145+
146+
it('control: the same stack without retired keys compiles clean (the harness resolves the spec)', () => {
147+
expect(render(diagnostics.get('control')!)).toEqual([]);
148+
});
149+
150+
const cases: ReadonlyArray<[ProbeName, string, number]> = [
151+
['page', 'assignedProfiles', 2741],
152+
['field', 'conditionalRequired', 2322],
153+
['index', "type: 'btree'", 2322],
154+
];
155+
156+
it.each(cases)('%s: writing %s fails with TS%i, and the message says [REMOVED] and names `os validate`', (name, key, code) => {
157+
const rendered = render(diagnostics.get(name)!);
158+
expect(rendered, 'exactly one diagnostic: the retired key').toHaveLength(1);
159+
const [only] = rendered;
160+
expect(only.startsWith(`L${lineOf(name, key)} TS${code}: `), only).toBe(true);
161+
expect(only).toContain('[REMOVED]');
162+
expect(only).toContain('`os validate`');
163+
// The form this replaced named no retirement at all.
164+
expect(only).not.toContain("to type 'undefined'");
165+
});
166+
});

0 commit comments

Comments
 (0)