Skip to content

Commit 9d47323

Browse files
dmealingclaude
andcommitted
fix(metadata): five retirements named a release that was never cut
The `@emit*` family retired in 0.25.0, but every entry — and the migration guide's own header — said `0.24.6`. That version does not exist: the line went 0.24.5 → 0.25.0 when the breaking batch made it a MINOR, and the `since` strings were authored while 0.24.6 was still the expected next number. It is user-facing. `formatRetirementNote` renders "retired in 0.24.6 — …" into the load error an adopter gets for `@emitRoutes`, and sends them to a CHANGELOG entry that isn't there — the exact failure #337 built this map to prevent, since an adopter who cannot find the decision reads the error as a registration bug. Nothing caught it because the gate that exists checks the version's SHAPE: expect(e.since).toMatch(/^\d+\.\d+\.\d+$/) and `0.24.6` matches that perfectly. So the new check asks whether the release happened, against the CHANGELOG headings — the same way the adjacent test already resolves each `migration` path against the file that must exist. It deliberately does NOT require every `since` to be a shipped release. A retirement is authored before the release carrying it, under `[Unreleased]`, where the number is a forecast; failing that would make the gate red for the whole development window. The rule is narrower: a `since` BELOW the version we currently ship must have shipped. That is false exactly when a forecast is superseded, and it fires at the release bump — the moment the guess becomes wrong. Verified green → red → green by restamping one entry 0.24.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3v6M6uqECZ2Sb5eUv6YY
1 parent 2908268 commit 9d47323

5 files changed

Lines changed: 50 additions & 10 deletions

File tree

‎docs/features/migrations/emit-attrs-to-generator-config.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Migration: the `@emit*` attributes become generator configuration
22

33
**Applies to:** `@emitRoutes`, `@emitTanstack`, `@emitForm`, `@emitAngular`, `@emitGrid`
4-
**Retired in:** 0.24.6 · **Ports affected:** TypeScript only
4+
**Retired in:** 0.25.0 · **Ports affected:** TypeScript only
55
**Automatic fix:** `meta upgrade --apply`
66

77
## What changed

‎scripts/test-doc-examples.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ const CASES: readonly Case[] = [
168168
// starts flagging correct documentation gets switched off, and then catches nothing.
169169
{
170170
// The exact shape that motivated the work: a retired attribute two lines above the
171-
// `...` that hid it. `@emitTanstack` is retired vocabulary as of 0.24.6 (FR-040 —
171+
// `...` that hid it. `@emitTanstack` is retired vocabulary as of 0.25.0 (FR-040 —
172172
// the @emit* family left the model), which is what makes it the right fixture and
173173
// not merely an unused name: this is a spelling the docs really did teach.
174174
name: "an elision no longer hides a retired attribute",

‎server/typescript/packages/metadata/src/retired-vocabulary.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
255255
// caught this exists to prevent.
256256
rewrite: { kind: "dropAttr" },
257257
},
258-
// ── FR-040: the @emit* family leaves the model (0.24.6) ──
258+
// ── FR-040: the @emit* family leaves the model (0.25.0) ──
259259
//
260260
// Five attributes — @emitRoutes, @emitTanstack, @emitForm, @emitGrid, @emitAngular — were
261261
// READ by the TypeScript generators as per-entity kill switches, and documented as THE way
@@ -280,7 +280,7 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
280280
// run also warns by name, rather than letting the file appear with no explanation.
281281
{
282282
type: "object", subType: "*", attr: "emitRoutes",
283-
since: "0.24.6",
283+
since: "0.25.0",
284284
why: "it was never registered vocabulary, so the opt-out we documented passed `meta gen` " +
285285
"and failed `meta verify`",
286286
replacedBy: "the routes generator's own `filter` — routesFile({ filter: (e) => … })",
@@ -289,7 +289,7 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
289289
},
290290
{
291291
type: "object", subType: "*", attr: "emitTanstack",
292-
since: "0.24.6",
292+
since: "0.25.0",
293293
why: "it was never registered vocabulary, so the opt-out we documented passed `meta gen` " +
294294
"and failed `meta verify`",
295295
replacedBy: "the `filter` option on tanstackQuery() / tanstackGrid() / tanstackGridHook()",
@@ -298,7 +298,7 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
298298
},
299299
{
300300
type: "object", subType: "*", attr: "emitForm",
301-
since: "0.24.6",
301+
since: "0.25.0",
302302
why: "it was never registered vocabulary, and its own doc comment described it backwards " +
303303
"— as opt-IN via `true`, while the code implemented opt-OUT via `false`",
304304
replacedBy: "formFile({ filter: (e) => … })",
@@ -307,7 +307,7 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
307307
},
308308
{
309309
type: "object", subType: "*", attr: "emitAngular",
310-
since: "0.24.6",
310+
since: "0.25.0",
311311
why: "it was never registered vocabulary, and unlike its four siblings it was not even a " +
312312
"named constant — just a bare string literal in four generators",
313313
replacedBy: "the `filter` option on the Angular generators",
@@ -319,7 +319,7 @@ export const RETIRED_VOCABULARY: readonly RetiredEntry[] = [
319319
// ANDed with the built-in gates and can only ever narrow. It became a generator OPTION,
320320
// which must be passed to BOTH grid generators — they emit a matched pair of files.
321321
type: "object", subType: "*", attr: "emitGrid",
322-
since: "0.24.6",
322+
since: "0.25.0",
323323
why: "it was never registered vocabulary, and being an opt-IN it could not be expressed " +
324324
"by a generator filter, which only ever narrows",
325325
replacedBy: "the `tphSubtypeGrids` option on BOTH tanstackGrid() and tanstackGridHook()",

‎server/typescript/packages/metadata/test/retired-vocabulary.test.ts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// fails, with the same error code. Only the message improves.
1313

1414
import { describe, test, expect } from "bun:test";
15-
import { existsSync } from "node:fs";
15+
import { existsSync, readFileSync } from "node:fs";
1616
import { join, resolve } from "node:path";
1717
import {
1818
retiredAttr,
@@ -116,6 +116,46 @@ describe("the map itself", () => {
116116
}
117117
});
118118

119+
// The shape check above passed on `0.24.6` — a release that was never cut. The @emit*
120+
// retirements were authored while 0.24.6 was the expected next version, the line was
121+
// renumbered to 0.25.0 when the breaking batch made it a MINOR, and the `since` strings
122+
// stayed behind. Nothing noticed: no test reads the number, and the message it feeds
123+
// ("retired in 0.24.6 — …") sends an adopter to a CHANGELOG entry that does not exist.
124+
//
125+
// A version is ALLOWED to have no heading while it is genuinely the upcoming release —
126+
// a retirement is authored before the release that carries it, under `[Unreleased]`. So
127+
// the rule is: a `since` BELOW the version we currently ship must have shipped. That is
128+
// false exactly when a planned number is superseded, and it fails at the release bump,
129+
// which is when the mistake becomes real.
130+
test("every `since` below the current version names a release the CHANGELOG carries", () => {
131+
const repoRoot = resolve(import.meta.dir, "../../../../..");
132+
const changelog = readFileSync(join(repoRoot, "CHANGELOG.md"), "utf8");
133+
const shipped = new Set(
134+
[...changelog.matchAll(/^## \[(\d+\.\d+\.\d+)\]/gm)].map((m) => m[1] as string),
135+
);
136+
const current = JSON.parse(
137+
readFileSync(join(import.meta.dir, "../package.json"), "utf8"),
138+
).version as string;
139+
140+
const order = (v: string): number[] => v.split(".").map(Number);
141+
const below = (a: string, b: string): boolean => {
142+
const [x, y] = [order(a), order(b)];
143+
for (let i = 0; i < 3; i++) {
144+
if ((x[i] as number) !== (y[i] as number)) return (x[i] as number) < (y[i] as number);
145+
}
146+
return false;
147+
};
148+
149+
for (const e of RETIRED_VOCABULARY) {
150+
if (!below(e.since, current) || shipped.has(e.since)) continue;
151+
throw new Error(
152+
`${e.type}.${e.subType} ${e.attr ?? ""}: retired in ${e.since}, but no such release — ` +
153+
`we ship ${current} and the CHANGELOG has no [${e.since}] heading. ` +
154+
`A planned version was superseded; restamp it with the release that carried it.`,
155+
);
156+
}
157+
});
158+
119159
test("every entry says something about what to do instead", () => {
120160
for (const e of RETIRED_VOCABULARY) {
121161
expect((e.replacedBy ?? "") + (e.migration ?? "") + e.why).not.toBe("");

‎server/typescript/packages/sdk/test/agent-context-capability-grounding.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const DELIBERATELY_NAMED_RETIRED_OR_CUT = new Set<string>([
4141
"@violation", // renamed to @counterexample
4242
"@verifiedBy", // dropped — it proved a NAME existed, never that the test verified the claim
4343
"@supersededBy", // dropped — a requirement is prescriptive, never a journal
44-
// FR-040 (0.24.6) — the @emit* family. Named for the same reason as the requirement rows
44+
// FR-040 (0.25.0) — the @emit* family. Named for the same reason as the requirement rows
4545
// above, one step further: these were never REGISTERED AT ALL. They were read off metadata
4646
// by the TS generators and documented as the per-entity opt-out, so they passed `meta gen`
4747
// (open load) and failed `meta verify` (strict). The audit skill names all five so an

0 commit comments

Comments
 (0)