Skip to content

Commit d1a8d40

Browse files
dmealingclaude
andcommitted
fix(cli): the ADR-0034 removal diagnostic missed the door adopters use
`removedGeneratorImportError` reads the config SOURCE, so it fires when the config imports a removed generator directly. A polyglot adopter arrived the other way: its config imported an OWNED generator — ./codegen/generators/entityFileTyped, the exact shape `meta eject` writes — and that file carried the stale import. The scan saw a clean config and the adopter got `(0 , _generators.entityFile) is not a function`, exit 2, naming no file, no symbol origin and no remedy: the failure the first door exists to prevent. The load now also catches the throw. It cannot name the file — the TypeError has no reference back to the import, which was the original reason for reading the source — but that is not a reason to say nothing, so it names the symbol, the removal, the remedy and where such an import usually lives. Matching is on the SYMBOL rather than the message text: Bun says `(0 , _generators.entityFile) is not a function` and Node ESM says `entityFile is not a function`. Verified end to end against the estate: the bare TypeError became the migration message, and following it made `meta gen` write 112 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent 3e91410 commit d1a8d40

3 files changed

Lines changed: 135 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
### Fixed — ADR-0034's removal diagnostic had one door, and adopters arrive through two
11+
12+
1.0 removed `entityFile` / `queriesFile` / `routesFile` / `barrel` from
13+
`@metaobjectsdev/codegen-ts/generators`, and `removedGeneratorImportError` turns that into a
14+
message naming the export, the `meta eject` command and the guide — **when the config file
15+
itself carries the import**. A polyglot adopter arrived the other way: its
16+
`metaobjects.config.ts` imported an OWNED generator (`./codegen/generators/entityFileTyped`,
17+
the exact shape `meta eject` writes), and THAT file held the stale import. The source scan
18+
saw a clean config, and what the adopter got was
19+
`(0 , _generators.entityFile) is not a function`, exit 2 — no file, no symbol origin, no
20+
remedy. Precisely the failure the first door exists to prevent.
21+
22+
`removedGeneratorRuntimeError` is the second door: the config load now catches the throw and,
23+
when the message names a removed export, rethrows the migration guidance. It cannot name the
24+
file — the TypeError carries no reference back to the import — so it names what it does know,
25+
including where the import usually lives (`codegen/generators/`). Matching is on the SYMBOL,
26+
not the message shape, because the shape belongs to whichever transpiler ran.
27+
28+
1029
### Fixed — both advisory scanners reported findings inside BUILD OUTPUT
1130

1231
`meta verify` on an adopter estate reported

‎server/typescript/packages/cli/src/lib/load-metaobjects-config.ts‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,14 @@ function resolveCliPkg(specifier: string): string {
141141
* the supported home of the generators with no ownable copy), so nothing upstream of here
142142
* can tell a correct import from a removed one.
143143
*
144-
* It reads the config SOURCE rather than catching the throw because the throw happens inside
145-
* a generator factory call with no reference back to the import that produced it, and only a
146-
* config declaring one of these in `generators: [...]` throws at all — a stale type-only
147-
* import would fail silently at typecheck-time instead.
144+
* It reads the config SOURCE because that is the arm that can name the FILE and the exact
145+
* import. `removedGeneratorRuntimeError` below is the second arm, and an adopter estate is
146+
* why there are two: the import does not have to be in the config. This project's config
147+
* imported an OWNED generator — `./codegen/generators/entityFileTyped`, the shape `meta
148+
* eject` itself produces — and THAT file carried the stale
149+
* `import { entityFile } from "@metaobjectsdev/codegen-ts/generators"`. The source scan saw
150+
* a clean config and the adopter got exactly the bare TypeError this check exists to
151+
* prevent: `(0 , _generators.entityFile) is not a function`, exit 2, no file, no remedy.
148152
*/
149153
const REMOVED_GENERATOR_EXPORTS = ["entityFile", "queriesFile", "routesFile", "barrel"] as const;
150154
const GENERATORS_SUBPATH = "@metaobjectsdev/codegen-ts/generators";
@@ -180,6 +184,39 @@ export function removedGeneratorImportError(source: string): string | undefined
180184
);
181185
}
182186

187+
/**
188+
* The same diagnostic, recovered from the THROW instead of the source — for the case the
189+
* source scan structurally cannot see: a removed export imported by a module the config
190+
* imports, rather than by the config itself.
191+
*
192+
* It cannot name the file (the TypeError carries no reference back to the import that
193+
* produced it — that limitation is real, it is just not a reason to say nothing), so it
194+
* names what it does know: the symbol, the removal, and the one-command remedy. Matching is
195+
* on the SYMBOL, not the message shape, because the shape is the transpiler's: Bun says
196+
* `(0 , _generators.entityFile) is not a function` and Node ESM says
197+
* `entityFile is not a function`.
198+
*/
199+
export function removedGeneratorRuntimeError(err: unknown): string | undefined {
200+
const message = err instanceof Error ? err.message : String(err ?? "");
201+
if (!/is not a function/.test(message)) return undefined;
202+
const hit = REMOVED_GENERATOR_EXPORTS.find((n) => new RegExp(`\\b${n}\\b`).test(message));
203+
if (hit === undefined) return undefined;
204+
const template = hit.replace(/File$/, "");
205+
return (
206+
`${hit}() is not a function — 1.0 REMOVED it from "${GENERATORS_SUBPATH}" ` +
207+
`(ADR-0034 scaffold-and-own): these generators are yours to own, so the package no ` +
208+
`longer ships them as an import. Your metaobjects.config.ts does not import it, so ` +
209+
`the import is in a module the config imports — most often an OWNED generator under ` +
210+
`codegen/generators/, which is exactly what 'meta eject' writes.\n` +
211+
` Fix — copy it into your repo:\n meta eject ${template}\n` +
212+
` then change that module's import to: ` +
213+
`import { ${hit} } from "./codegen/generators/${template}";\n` +
214+
` The subpath itself is fine — promptRender, outputParser, routesFileHono and the ` +
215+
`rest still live there.\n` +
216+
` Guide: docs/features/migrations/0.x-to-1.0.md §11`
217+
);
218+
}
219+
183220
function rewriteImportSpecifiers(source: string, aliasMap: Record<string, string>): string {
184221
let result = source;
185222
for (const [specifier, resolvedPath] of Object.entries(aliasMap)) {
@@ -461,7 +498,18 @@ export async function loadMetaobjectsConfig(projectRoot: string): Promise<Metaob
461498
const prepareStackTraceBefore = Error.prepareStackTrace;
462499
const stackTraceLimitBefore = Error.stackTraceLimit;
463500
try {
464-
const raw = (await jiti.import(loadPath)) as MetaobjectsGenConfig | { default: MetaobjectsGenConfig };
501+
let raw: MetaobjectsGenConfig | { default: MetaobjectsGenConfig };
502+
try {
503+
raw = (await jiti.import(loadPath)) as MetaobjectsGenConfig | { default: MetaobjectsGenConfig };
504+
} catch (err) {
505+
// The second arm of the ADR-0034 diagnostic: a removed export imported by a module the
506+
// config imports rather than by the config itself. Rethrown as the migration message,
507+
// because what reaches the adopter otherwise is `(0 , _generators.entityFile) is not a
508+
// function` — the exact unactionable failure the source scan above exists to prevent.
509+
const runtime = removedGeneratorRuntimeError(err);
510+
if (runtime !== undefined) throw new Error(runtime);
511+
throw err;
512+
}
465513
// jiti's interopDefault doesn't always unwrap the default export when accessed
466514
// across module boundaries — explicitly unwrap if present.
467515
const cfg = (raw && typeof raw === "object" && "default" in raw && raw.default
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
// ADR-0034's removal has TWO doors, and only one of them was covered.
2+
//
3+
// `removedGeneratorImportError` reads the config SOURCE, so it fires when the config itself
4+
// imports `entityFile` / `barrel` / … from "@metaobjectsdev/codegen-ts/generators". A
5+
// polyglot adopter estate showed the other door: its config imported an OWNED generator —
6+
// `./codegen/generators/entityFileTyped`, the exact shape `meta eject` writes — and THAT
7+
// file carried the stale import. The source scan saw a clean config, and what the adopter
8+
// got was `(0 , _generators.entityFile) is not a function`, exit 2, naming no file, no
9+
// symbol origin and no remedy: the precise failure the first door exists to prevent.
10+
11+
import { describe, test, expect } from "bun:test";
12+
import {
13+
removedGeneratorImportError,
14+
removedGeneratorRuntimeError,
15+
} from "../../src/lib/load-metaobjects-config.js";
16+
17+
describe("removedGeneratorRuntimeError", () => {
18+
test("recognises Bun's transpiled shape", () => {
19+
const msg = removedGeneratorRuntimeError(
20+
new TypeError("(0 , _generators.entityFile) is not a function"),
21+
);
22+
expect(msg).toBeDefined();
23+
expect(msg).toContain("meta eject entity");
24+
expect(msg).toContain("ADR-0034");
25+
expect(msg).toContain("0.x-to-1.0.md");
26+
});
27+
28+
test("recognises Node's plain shape", () => {
29+
// Matching is on the SYMBOL, not the message shape — the shape belongs to whichever
30+
// transpiler ran, and an adopter's runtime is not ours to choose.
31+
const msg = removedGeneratorRuntimeError(new TypeError("barrel is not a function"));
32+
expect(msg).toContain("meta eject barrel");
33+
});
34+
35+
test("says WHERE to look, since the throw cannot name the file", () => {
36+
const msg = removedGeneratorRuntimeError(new TypeError("entityFile is not a function"))!;
37+
expect(msg).toContain("codegen/generators/");
38+
expect(msg).toMatch(/module (the|your) config imports/);
39+
});
40+
41+
test("stays out of the way of unrelated TypeErrors", () => {
42+
expect(removedGeneratorRuntimeError(new TypeError("foo is not a function"))).toBeUndefined();
43+
expect(removedGeneratorRuntimeError(new Error("entityFile exploded"))).toBeUndefined();
44+
expect(removedGeneratorRuntimeError(undefined)).toBeUndefined();
45+
});
46+
47+
test("a non-Error throw does not crash the diagnostic", () => {
48+
expect(removedGeneratorRuntimeError("entityFile is not a function")).toContain("meta eject entity");
49+
});
50+
});
51+
52+
describe("the two doors agree", () => {
53+
test("both name the removal, the remedy and the guide", () => {
54+
const fromSource = removedGeneratorImportError(
55+
`import { entityFile } from "@metaobjectsdev/codegen-ts/generators";`,
56+
)!;
57+
const fromThrow = removedGeneratorRuntimeError(new TypeError("entityFile is not a function"))!;
58+
for (const needle of ["ADR-0034", "meta eject entity", "0.x-to-1.0.md §11"]) {
59+
expect(fromSource).toContain(needle);
60+
expect(fromThrow).toContain(needle);
61+
}
62+
});
63+
});

0 commit comments

Comments
 (0)