Skip to content

Commit f96d2fe

Browse files
dmealingclaude
andcommitted
feat(cli): meta upgrade — rewrite retired vocabulary, refuse what needs a decision
Plan: docs/superpowers/plans/2026-08-21-meta-upgrade-vocabulary-rewriter.md. Wires the rewriter into the CLI. Previews by default; `--apply` writes; `--to <version>` bounds which retirements apply. DELIBERATELY NOT `meta migrate`. That command owns DATABASE SCHEMA (ADR-0015) and an adopter reading `migrate` expects DDL. Overloading the most destructive command in the toolchain so it sometimes edits metadata instead would make "what does this touch?" ambiguous at exactly the wrong moment. It resolves the file set through `resolveCollection` — the single authority on where metadata lives, so `sources` is honoured — and hands each file's RAW TEXT to the rewriter. It never loads: the metadata this exists to repair is metadata that does not load. EXITS NON-ZERO WHILE ANY REFUSAL STANDS, even when every mechanical change succeeded. A refusal means the metadata still will not load, so exiting 0 would let CI record the migration as complete while the build is broken. Pinned by a test. DOGFOODING FOUND A REAL BUG THE UNIT TESTS COULD NOT. Run against `error-requirement-verified-by-retired` — a fixture that genuinely fails to load today — the rewriter emitted INVALID JSON: dropping `@verifiedBy` when it is the LAST key in its object left the preceding key's comma dangling (`"...",\n}`). Every drop test I had written placed the retired attr in the middle, where a trailing comma is always there to consume, so all of them passed against a document that no longer parsed. Two fixes, one mechanical and one about the tests: - when there is no trailing comma the key is last, so the PRECEDING comma is taken instead - every drop case now asserts `JSON.parse` succeeds. Substring assertions were all TRUE of the broken output — that is precisely how it got through, and the same shape as the vacuous fixture caught earlier today. A tool whose whole job is producing loadable metadata cannot emit invalid JSON, and it would have shipped had I trusted a green suite over running it on real data. Also verified against the shipped `examples/advanced-modeling` project: exit 0, "no retired vocabulary found", nothing touched. The help snapshot gains exactly one line (the new command); reviewed the diff rather than blanket-accepting it. Verified: metadata 2465 pass / 0 fail, cli 615 / 0 fail, typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4a6ef3e commit f96d2fe

6 files changed

Lines changed: 335 additions & 4 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// server/typescript/packages/cli/src/commands/upgrade.ts
2+
//
3+
// `meta upgrade` — rewrite retired vocabulary in this project's metadata.
4+
//
5+
// DELIBERATELY NOT `meta migrate`. That command owns DATABASE SCHEMA (ADR-0015) and an
6+
// adopter reading `migrate` expects DDL. Overloading it with a metadata rewrite would make
7+
// the most destructive command in the toolchain ambiguous about what it touches.
8+
//
9+
// IT DOES NOT LOAD THE METADATA, and cannot. Once vocabulary is deregistered, metadata
10+
// carrying it fails the load — which is exactly the state this command exists to repair. So
11+
// it resolves the file SET through `resolveCollection` (the single authority on where
12+
// metadata lives) and hands each file's RAW TEXT to the rewriter. See
13+
// `metadata/src/vocabulary-rewrite.ts` for why that is the only workable shape.
14+
//
15+
// DRY-RUN BY DEFAULT, per this repo's convention for anything that edits committed files.
16+
// `--apply` writes. Refusals exit NON-ZERO even when every mechanical change succeeded, so
17+
// CI cannot mistake a partial upgrade for a finished one.
18+
19+
import { readFile, writeFile } from "node:fs/promises";
20+
import { relative } from "node:path";
21+
import { resolveCollection } from "@metaobjectsdev/sdk";
22+
import { rewriteDocument, type RewriteResult } from "@metaobjectsdev/metadata";
23+
import { log } from "../lib/log.js";
24+
25+
interface UpgradeFlags {
26+
apply: boolean;
27+
maxVersion?: string;
28+
projectRoot?: string;
29+
}
30+
31+
function parseArgs(argv: string[]): UpgradeFlags {
32+
const flags: UpgradeFlags = { apply: false };
33+
for (let i = 0; i < argv.length; i++) {
34+
const a = argv[i] as string;
35+
if (a === "--apply") flags.apply = true;
36+
else if (a === "--to") {
37+
const v = argv[++i];
38+
if (v === undefined) throw new Error("--to needs a version");
39+
flags.maxVersion = v;
40+
} else if (a.startsWith("--to=")) flags.maxVersion = a.slice("--to=".length);
41+
else if (a === "--help" || a === "-h") throw new Error("__help__");
42+
else if (!a.startsWith("-")) flags.projectRoot = a;
43+
else throw new Error(`unknown option: ${a}`);
44+
}
45+
return flags;
46+
}
47+
48+
/**
49+
* The node type a document's keys belong to.
50+
*
51+
* Retirements are TYPE-SCOPED — `@unique` is retired on `identity.secondary` and live on a
52+
* field — so the rewriter needs a scope per occurrence, not per file. A metadata file holds
53+
* many types, so we run the rewriter once per type key present in the text. Cheap, and it
54+
* keeps the scoping decision in one place (the map) rather than smeared across a parser we
55+
* deliberately do not have.
56+
*/
57+
function typeKeysIn(text: string): string[] {
58+
const keys = new Set<string>();
59+
const re = /"([a-z]+)\.([A-Za-z*]+)"\s*:/g;
60+
let m: RegExpExecArray | null;
61+
while ((m = re.exec(text)) !== null) keys.add(`${m[1]}.${m[2]}`);
62+
return [...keys];
63+
}
64+
65+
/** Run every type scope present in the document, threading the text through each pass. */
66+
function rewriteAllScopes(text: string, maxVersion: string | undefined): RewriteResult {
67+
const changes: RewriteResult["changes"][number][] = [];
68+
const refusals: RewriteResult["refusals"][number][] = [];
69+
let current = text;
70+
for (const typeKeyHint of typeKeysIn(text)) {
71+
const r = rewriteDocument(current, {
72+
typeKeyHint,
73+
...(maxVersion !== undefined ? { maxVersion } : {}),
74+
});
75+
current = r.text;
76+
changes.push(...r.changes);
77+
refusals.push(...r.refusals);
78+
}
79+
return { text: current, changes, refusals };
80+
}
81+
82+
export async function upgradeCommand(args: string[], cwd: string): Promise<number> {
83+
let flags: UpgradeFlags;
84+
try {
85+
flags = parseArgs(args);
86+
} catch (err) {
87+
if ((err as Error).message === "__help__") {
88+
log.info(
89+
"meta upgrade [<project>] [--to <version>] [--apply]\n\n" +
90+
" Rewrites retired metadata vocabulary. Previews by default; --apply writes.\n" +
91+
" Retirements needing a human decision are REFUSED and listed with their guide.",
92+
);
93+
return 0;
94+
}
95+
log.error((err as Error).message);
96+
return 2;
97+
}
98+
99+
const projectRoot = flags.projectRoot ?? cwd;
100+
101+
let files: readonly string[];
102+
try {
103+
files = (await resolveCollection(projectRoot)).files;
104+
} catch (err) {
105+
log.error((err as Error).message);
106+
return 1;
107+
}
108+
109+
let totalChanges = 0;
110+
let totalRefusals = 0;
111+
let filesChanged = 0;
112+
113+
for (const file of files) {
114+
const before = await readFile(file, "utf8");
115+
const r = rewriteAllScopes(before, flags.maxVersion);
116+
if (r.changes.length === 0 && r.refusals.length === 0) continue;
117+
118+
const rel = relative(projectRoot, file);
119+
log.info(`\n${rel}`);
120+
for (const c of r.changes) log.info(` ${c.line}: @${c.from}${c.to}`);
121+
for (const f of r.refusals) {
122+
log.warn(
123+
` ${f.line}: @${f.attr}${f.value !== undefined ? `: ${f.value}` : ""} — needs a decision. ` +
124+
`Retired in ${f.since}. ${f.migration !== undefined ? `See ${f.migration}` : f.why}`,
125+
);
126+
}
127+
128+
totalChanges += r.changes.length;
129+
totalRefusals += r.refusals.length;
130+
if (r.changes.length > 0) {
131+
filesChanged++;
132+
if (flags.apply) await writeFile(file, r.text, "utf8");
133+
}
134+
}
135+
136+
log.info("");
137+
if (totalChanges === 0 && totalRefusals === 0) {
138+
log.info("meta upgrade — no retired vocabulary found.");
139+
return 0;
140+
}
141+
142+
if (flags.apply) {
143+
log.info(`meta upgrade — rewrote ${totalChanges} declaration(s) across ${filesChanged} file(s).`);
144+
} else {
145+
log.info(
146+
`meta upgrade — ${totalChanges} declaration(s) in ${filesChanged} file(s) can be rewritten. ` +
147+
`Re-run with --apply to write.`,
148+
);
149+
}
150+
151+
// Non-zero while ANY refusal stands, applied or not. A partial upgrade that exited 0 would
152+
// let CI record the migration as done while metadata still fails to load.
153+
if (totalRefusals > 0) {
154+
log.error(
155+
`${totalRefusals} declaration(s) need a human decision and were left untouched — ` +
156+
`see the guides listed above.`,
157+
);
158+
return 1;
159+
}
160+
return 0;
161+
}

server/typescript/packages/cli/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ COMMANDS:
2323
export Flatten loaded metadata to one canonical JSON artifact
2424
docs <metadata> --out <dir> Generate neutral metadata documentation (entity + template pages; --site for HTML site)
2525
verify Drift gate — subverbs: --templates / --db / --codegen (bare = --templates)
26+
upgrade Rewrite retired metadata vocabulary (previews; --apply writes)
2627
prompt-snapshot Snapshot rendered template.* output; --check gates drift
2728
migrate Diff metadata vs live DB; emit migration SQL files
2829
--version, -v Print version
@@ -377,6 +378,10 @@ export async function run(argv: string[]): Promise<number> {
377378
const { verifyCommand } = await import("./commands/verify.js");
378379
return verifyCommand(rest, cwd);
379380
}
381+
case "upgrade": {
382+
const { upgradeCommand } = await import("./commands/upgrade.js");
383+
return upgradeCommand(rest, cwd);
384+
}
380385
case "prompt-snapshot": {
381386
const { promptSnapshotCommand } = await import("./commands/prompt-snapshot.js");
382387
return promptSnapshotCommand(rest, cwd);

server/typescript/packages/cli/test/__snapshots__/cli.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ COMMANDS:
1616
export Flatten loaded metadata to one canonical JSON artifact
1717
docs <metadata> --out <dir> Generate neutral metadata documentation (entity + template pages; --site for HTML site)
1818
verify Drift gate — subverbs: --templates / --db / --codegen (bare = --templates)
19+
upgrade Rewrite retired metadata vocabulary (previews; --apply writes)
1920
prompt-snapshot Snapshot rendered template.* output; --check gates drift
2021
migrate Diff metadata vs live DB; emit migration SQL files
2122
--version, -v Print version
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// `meta upgrade` end-to-end, against real files on disk.
2+
//
3+
// The unit tests prove the rewriter transforms text. These prove the COMMAND does the right
4+
// thing to a project: previews without writing, writes only under --apply, and — the one
5+
// that matters for CI — exits NON-ZERO while any refusal stands, so a partial upgrade
6+
// cannot be recorded as a finished one.
7+
8+
import { describe, test, expect, afterAll } from "bun:test";
9+
import { mkdtemp, rm, mkdir, writeFile, readFile } from "node:fs/promises";
10+
import { tmpdir } from "node:os";
11+
import { join } from "node:path";
12+
import { upgradeCommand } from "../src/commands/upgrade.js";
13+
14+
const dirs: string[] = [];
15+
afterAll(async () => {
16+
for (const d of dirs) await rm(d, { recursive: true, force: true });
17+
});
18+
19+
async function project(meta: string): Promise<string> {
20+
const root = await mkdtemp(join(tmpdir(), "meta-upgrade-"));
21+
dirs.push(root);
22+
await mkdir(join(root, "metaobjects"), { recursive: true });
23+
await writeFile(join(root, "metaobjects", "meta.json"), meta, "utf8");
24+
return root;
25+
}
26+
27+
const WITH_RETIRED = `{
28+
"metadata.root": {
29+
"package": "acme::shop",
30+
"children": [
31+
{ "requirement.functional": {
32+
"name": "orderRecord",
33+
"@level": 4,
34+
"@status": "live",
35+
"@statement": "An order records what was bought",
36+
"@violation": "An order that cannot say what was bought",
37+
"@verifiedBy": ["OrderServiceTest"]
38+
}}
39+
]
40+
}
41+
}`;
42+
43+
const NEEDS_DECISION = `{
44+
"metadata.root": {
45+
"package": "acme::shop",
46+
"children": [
47+
{ "requirement.functional": {
48+
"name": "oldThing",
49+
"@level": 4,
50+
"@status": "abandoned",
51+
"@statement": "Something we stopped doing",
52+
"@violation": "n/a"
53+
}}
54+
]
55+
}
56+
}`;
57+
58+
const CLEAN = `{
59+
"metadata.root": {
60+
"package": "acme::shop",
61+
"children": [
62+
{ "requirement.functional": {
63+
"name": "orderRecord",
64+
"@level": 4,
65+
"@status": "live",
66+
"@statement": "An order records what was bought",
67+
"@violation": "An order that cannot say what was bought"
68+
}}
69+
]
70+
}
71+
}`;
72+
73+
describe("meta upgrade", () => {
74+
test("PREVIEWS without writing by default", async () => {
75+
const root = await project(WITH_RETIRED);
76+
const code = await upgradeCommand([root], root);
77+
expect(code).toBe(0);
78+
// Untouched on disk — the default must never edit committed files.
79+
expect(await readFile(join(root, "metaobjects", "meta.json"), "utf8")).toBe(WITH_RETIRED);
80+
});
81+
82+
test("--apply rewrites the file", async () => {
83+
const root = await project(WITH_RETIRED);
84+
expect(await upgradeCommand([root, "--apply"], root)).toBe(0);
85+
const after = await readFile(join(root, "metaobjects", "meta.json"), "utf8");
86+
expect(after).not.toContain("@verifiedBy");
87+
// Everything else survives, byte-for-byte in the untouched regions.
88+
expect(after).toContain('"@statement": "An order records what was bought"');
89+
expect(after).toContain('"name": "orderRecord"');
90+
});
91+
92+
test("a project with nothing retired exits 0 and is untouched", async () => {
93+
const root = await project(CLEAN);
94+
expect(await upgradeCommand([root, "--apply"], root)).toBe(0);
95+
expect(await readFile(join(root, "metaobjects", "meta.json"), "utf8")).toBe(CLEAN);
96+
});
97+
98+
// The CI-facing contract. A refusal means the metadata still will not load, so exiting 0
99+
// would let a pipeline record the migration as complete while the build is broken.
100+
test("EXITS NON-ZERO when a decision is still required", async () => {
101+
const root = await project(NEEDS_DECISION);
102+
expect(await upgradeCommand([root, "--apply"], root)).toBe(1);
103+
// And leaves the judgment case alone rather than guessing.
104+
expect(await readFile(join(root, "metaobjects", "meta.json"), "utf8")).toContain('"abandoned"');
105+
});
106+
107+
test("--to bounds which retirements apply", async () => {
108+
const root = await project(WITH_RETIRED);
109+
// @verifiedBy retired in 0.24.0, so a 0.23.0 ceiling must leave it alone.
110+
expect(await upgradeCommand([root, "--to", "0.23.0", "--apply"], root)).toBe(0);
111+
expect(await readFile(join(root, "metaobjects", "meta.json"), "utf8")).toContain("@verifiedBy");
112+
});
113+
114+
test("rejects an unknown flag with exit 2", async () => {
115+
const root = await project(CLEAN);
116+
expect(await upgradeCommand([root, "--nope"], root)).toBe(2);
117+
});
118+
});

server/typescript/packages/metadata/src/vocabulary-rewrite.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,15 +222,29 @@ export function rewriteDocument(source: string, opts: RewriteOpts): RewriteResul
222222
changes.push({ attr, from: attr, to: rw.to, line });
223223
} else if (rw.kind === "dropAttr") {
224224
if (span === undefined) continue;
225-
// Take the trailing comma and the line's own whitespace with it, so removing a
226-
// middle key does not leave a dangling `,` or a blank line behind.
225+
// Take the TRAILING comma and the line's own whitespace, so removing a middle key
226+
// leaves neither a dangling `,` nor a blank line.
227227
let end = span.end;
228228
while (end < source.length && /[ \t]/.test(source[end] ?? "")) end++;
229-
if (source[end] === ",") end++;
229+
const hadTrailingComma = source[end] === ",";
230+
if (hadTrailingComma) end++;
231+
230232
let start = keyStart;
231233
while (start > 0 && /[ \t]/.test(source[start - 1] ?? "")) start--;
234+
235+
// THE LAST-KEY CASE, found by dogfooding rather than by the unit tests above: when
236+
// the retired attr is last in its object there IS no trailing comma — the comma
237+
// belongs to the PRECEDING key. Dropping without taking it leaves `"...",\n}`,
238+
// which does not parse. A tool whose whole job is producing loadable metadata
239+
// cannot emit invalid JSON.
240+
if (!hadTrailingComma) {
241+
let back = start;
242+
while (back > 0 && /[\s]/.test(source[back - 1] ?? "")) back--;
243+
if (source[back - 1] === ",") start = back - 1;
244+
}
245+
232246
if (source[start - 1] === "\n" && source[end] === "\n") end++;
233-
else if (start > 0 && source[start - 1] !== "\n") start = keyStart;
247+
else if (start > 0 && source[start - 1] !== "\n" && hadTrailingComma) start = keyStart;
234248
edits.push({ start, end, text: "" });
235249
changes.push({ attr, from: attr, to: "(removed)", line });
236250
} else {

server/typescript/packages/metadata/test/vocabulary-rewrite.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ describe("mechanical rewrites", () => {
3333
expect(r.text).toContain('"@level": 4');
3434
expect(r.changes).toHaveLength(1);
3535
expect(r.changes[0]?.attr).toBe("verifiedBy");
36+
// Substring assertions alone are what let the trailing-comma bug through — they were
37+
// all true of a document that no longer parsed. Every drop case now proves the result
38+
// is still valid JSON.
39+
expect(() => JSON.parse(r.text)).not.toThrow();
3640
});
3741

3842
test("rewrites key AND value for @readOnly", () => {
@@ -73,6 +77,34 @@ describe("surgical editing", () => {
7377
expect(r.text.indexOf('"@statement"')).toBeLessThan(r.text.indexOf('"@level"'));
7478
});
7579

80+
// Caught by dogfooding against a real fixture, not by the tests above — the drop cases
81+
// there all had a following key, so the trailing comma was always there to consume. When
82+
// the retired attr is LAST, the comma belongs to the PRECEDING key and dropping naively
83+
// leaves `"...",\n}` — invalid JSON, from a tool whose entire job is producing loadable
84+
// metadata.
85+
test("dropping the LAST key does not leave a trailing comma", () => {
86+
const src = `{
87+
"requirement.architectural": {
88+
"name": "MoneyIsExactMinorUnits",
89+
"@statement": "Amounts are exact integer minor units",
90+
"@verifiedBy": ["MoneyRoundingTest"]
91+
}
92+
}`;
93+
const r = rewriteDocument(src, { typeKeyHint: "requirement.architectural" });
94+
expect(r.text).not.toContain("@verifiedBy");
95+
// The real assertion: the result must still parse.
96+
expect(() => JSON.parse(r.text)).not.toThrow();
97+
expect(JSON.parse(r.text)["requirement.architectural"]["@statement"]).toBe(
98+
"Amounts are exact integer minor units",
99+
);
100+
});
101+
102+
test("dropping the ONLY key leaves a valid empty object", () => {
103+
const src = `{ "requirement.functional": { "@verifiedBy": ["T"] } }`;
104+
const r = rewriteDocument(src, { typeKeyHint: "requirement.functional" });
105+
expect(() => JSON.parse(r.text)).not.toThrow();
106+
});
107+
76108
test("a document with nothing to change comes back BYTE-IDENTICAL", () => {
77109
const src = `{
78110
"requirement.functional": {

0 commit comments

Comments
 (0)