Skip to content

Commit 70cc227

Browse files
dmealingclaude
andcommitted
fix: two commands answered a question they had the evidence to answer properly
F50 — `meta upgrade` reported "nothing to rewrite (5 file(s) checked)", exit 0, on an estate whose `{"object.base": …}` the loader refuses. That is §10A, the flagship metadata migration of the 1.0 line, and the one command whose job is "what does the new version need me to change?" answered "nothing" about it. The adopter then hit the load failure, with reason to distrust the tool. Same shape as #339. An authored `<type>.base` is now a REFUSAL in both rewriter arms — JSON and YAML — naming the file line and the guide. A refusal, not a rewrite, because `object.base` → `entity | value | projection` is a decision about what the node IS, which no rewriter can make. Whether `base` anchors anything is a REGISTRY question — it is an anchor exactly when the type registers some other subtype for it to anchor, which a third-party provider's base-only type does not — and both rewriters are registry-free on purpose. So the fact arrives as `RewriteOpts.abstractAnchorTypes`, derived by the CLI from the core registry, and a caller that cannot say gets no anchor refusals at all. Both arms read the same option, so neither can be given a different answer to the same question. F51 — `ERR_ABSTRACT_SUBTYPE_AUTHORED` reached the adopter as a paragraph with no file, no line, no node name and no code, and `--format json` carried the same bare string. The loader had all of it: the ParseError already carries the ADR-0009 `code` and a `source` with `files` and `jsonPath`. Five commands then printed `err.message` and threw the envelope away. Five metadata files made that a `grep`; two hundred would not. `describeLoadError` renders one load failure for every command that can hit one — code first, then WHERE, then the loader's own `suggestions[]` verbatim — and `verify`'s structured payload now carries `code`, `files` and `jsonPath` so a CI job can branch on the code ADR-0009 promises and the docs name. It never invents provenance: each field is present only when the loader supplied it. The message also never named a subtype to use instead. It does now, DERIVED from the registry rather than written down, so a new subtype appears in the advice the day it is registered. metadata 2621 pass, cli 846 pass, workspace typecheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NTcEKXTQMYt84fAjuw5A2M
1 parent bdc3689 commit 70cc227

10 files changed

Lines changed: 320 additions & 8 deletions

File tree

server/typescript/packages/cli/src/commands/docs.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ import {
5353
qualifiedDbName,
5454
} from "@metaobjectsdev/migrate-ts";
5555
import type { AgentSchemaInput, SchemaColumnLike } from "@metaobjectsdev/codegen-ts";
56+
import { describeLoadError } from "../lib/load-error.js";
57+
58+
/**
59+
* Print a load failure with everything the loader's ADR-0009 envelope carried — the stable
60+
* code, the file, the json path, and the loader's own next steps — instead of the bare
61+
* `err.message` five commands used to print. See `lib/load-error.ts`.
62+
*/
63+
function reportLoadError(
64+
log: { error: (msg: string) => void },
65+
prefix: string,
66+
err: unknown,
67+
): void {
68+
const report = describeLoadError(err);
69+
log.error(`${prefix}: ${report.text}`);
70+
for (const s of report.suggestions ?? []) log.error(` ${s}`);
71+
}
72+
5673

5774
type DocsLayout = "flat" | "package";
5875

@@ -505,7 +522,7 @@ export async function docsCommand(
505522
...configLoadOptions,
506523
});
507524
} catch (err) {
508-
log.error(`docs: failed to load metadata: ${(err as Error).message}`);
525+
reportLoadError(log, "docs: failed to load metadata", err);
509526
return 2;
510527
}
511528

server/typescript/packages/cli/src/commands/gen.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@ import {
1616
import { loadMemory, resolveCollection } from "@metaobjectsdev/sdk";
1717
import { runGen, listGenerators } from "@metaobjectsdev/codegen-ts";
1818
import type { WriteStatus } from "@metaobjectsdev/codegen-ts";
19+
import { describeLoadError } from "../lib/load-error.js";
20+
21+
/**
22+
* Print a load failure with everything the loader's ADR-0009 envelope carried — the stable
23+
* code, the file, the json path, and the loader's own next steps — instead of the bare
24+
* `err.message` five commands used to print. See `lib/load-error.ts`.
25+
*/
26+
function reportLoadError(
27+
log: { error: (msg: string) => void },
28+
prefix: string,
29+
err: unknown,
30+
): void {
31+
const report = describeLoadError(err);
32+
log.error(`${prefix}: ${report.text}`);
33+
for (const s of report.suggestions ?? []) log.error(` ${s}`);
34+
}
35+
1936

2037
export function mapStatus(s: WriteStatus): GenFileStatus {
2138
switch (s) {
@@ -114,7 +131,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
114131
...loadMemoryOptionsFrom(forgeConfig),
115132
});
116133
} catch (err) {
117-
log.error(`failed to load metadata: ${(err as Error).message}`);
134+
reportLoadError(log, "failed to load metadata", err);
118135
return 2;
119136
}
120137

server/typescript/packages/cli/src/commands/migrate.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,23 @@ import {
5858
} from "../lib/wrangler.js";
5959
import { buildProjectionViews } from "@metaobjectsdev/codegen-ts";
6060
import { tokensToAllowOptions, describeChange } from "../lib/allow.js";
61+
import { describeLoadError } from "../lib/load-error.js";
62+
63+
/**
64+
* Print a load failure with everything the loader's ADR-0009 envelope carried — the stable
65+
* code, the file, the json path, and the loader's own next steps — instead of the bare
66+
* `err.message` five commands used to print. See `lib/load-error.ts`.
67+
*/
68+
function reportLoadError(
69+
log: { error: (msg: string) => void },
70+
prefix: string,
71+
err: unknown,
72+
): void {
73+
const report = describeLoadError(err);
74+
log.error(`${prefix}: ${report.text}`);
75+
for (const s of report.suggestions ?? []) log.error(` ${s}`);
76+
}
77+
6178

6279
export const MIGRATE_HELP_TEXT = `meta migrate — diff metadata vs live DB; emit migration SQL files
6380
@@ -628,7 +645,7 @@ export async function migrateCommand(
628645
...postgresLoadOptions,
629646
});
630647
} catch (err) {
631-
log.error(`failed to load metadata: ${(err as Error).message}`);
648+
reportLoadError(log, "failed to load metadata", err);
632649
return 2;
633650
}
634651

@@ -1056,7 +1073,7 @@ export async function runBaseline(
10561073
...baselineLoadOptions,
10571074
});
10581075
} catch (err) {
1059-
log.error(`migrate baseline: failed to load metadata: ${(err as Error).message}`);
1076+
reportLoadError(log, "migrate baseline: failed to load metadata", err);
10601077
return 2;
10611078
}
10621079
const baselineViews = buildProjectionViews(metadata, { dialect: config.dialect, columnNamingStrategy: baselineStrategy });
@@ -1195,7 +1212,7 @@ export async function runOfflineGenerate(
11951212
...offlineLoadOptions,
11961213
});
11971214
} catch (err) {
1198-
log.error(`migrate: failed to load metadata: ${(err as Error).message}`);
1215+
reportLoadError(log, "migrate: failed to load metadata", err);
11991216
return 2;
12001217
}
12011218

server/typescript/packages/cli/src/commands/upgrade.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
import { readFile, writeFile } from "node:fs/promises";
2222
import { extname, relative } from "node:path";
2323
import { resolveCollection } from "@metaobjectsdev/sdk";
24-
import { rewriteDocument } from "@metaobjectsdev/metadata";
24+
import {
25+
rewriteDocument,
26+
composeRegistry,
27+
coreProviders,
28+
SUBTYPE_BASE,
29+
} from "@metaobjectsdev/metadata";
2530
import { log } from "../lib/log.js";
2631

2732
/** YAML authoring (ADR-0006). Rewritten by the `yaml`-backed arm, loaded on demand below. */
@@ -96,7 +101,29 @@ export async function upgradeCommand(args: string[], cwd: string): Promise<numbe
96101
? (await import("@metaobjectsdev/metadata/vocabulary-rewrite-yaml")).rewriteYamlDocument
97102
: undefined;
98103

99-
const opts = flags.maxVersion !== undefined ? { maxVersion: flags.maxVersion } : {};
104+
// Which types have an ABSTRACT ANCHOR at `<type>.base`, derived from the core registry
105+
// rather than listed here: `base` is an anchor exactly when the type registers some OTHER
106+
// subtype for it to anchor. The rewriter is registry-free on purpose, so the caller — this
107+
// command, which can build a registry — supplies the fact.
108+
//
109+
// Without it, `meta upgrade` answered "nothing to rewrite" on an estate whose
110+
// `{"object.base": …}` the loader refuses (ERR_ABSTRACT_SUBTYPE_AUTHORED) — the change
111+
// §10A of the 1.0 guide LEADS WITH. The one command whose job is "what does the new
112+
// version need me to change?" said "nothing", and the adopter then hit the load failure.
113+
const registry = composeRegistry(coreProviders);
114+
// `allTypes()` yields one TypeId per registered type.subType pair, so take the distinct
115+
// TYPE names off it.
116+
const abstractAnchorTypes = [...new Set(registry.allTypes().map((id) => id.type))]
117+
.filter((t) => {
118+
const subs = registry.allSubTypesOf(t);
119+
return subs.includes(SUBTYPE_BASE) && subs.some((sub) => sub !== SUBTYPE_BASE);
120+
})
121+
.sort();
122+
123+
const opts = {
124+
abstractAnchorTypes,
125+
...(flags.maxVersion !== undefined ? { maxVersion: flags.maxVersion } : {}),
126+
};
100127

101128
for (const file of files) {
102129
const rel = relative(projectRoot, file);

server/typescript/packages/cli/src/commands/verify.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import {
8585
REQUIREMENT_STATUSES,
8686
} from "@metaobjectsdev/metadata";
8787
import { verify, ERR_REQUIRED_SLOT_UNUSED, ERR_PARTIAL_UNRESOLVED } from "@metaobjectsdev/render";
88+
import { describeLoadError } from "../lib/load-error.js";
8889

8990
const DEFAULT_PROMPTS_DIR = "prompts";
9091

@@ -250,7 +251,10 @@ export async function verifyCommand(
250251
});
251252
} catch (err) {
252253
const msg = (err as Error).message;
253-
log.error(`failed to load metadata: ${msg}`);
254+
// Everything the loader's envelope carried, not just the message: the stable code a
255+
// CI job keys on, and the FILE + json path the message itself can never name.
256+
const report = describeLoadError(err);
257+
log.error(`failed to load metadata: ${report.text}`);
254258
// Strict-load rejection (ADR-0023). Two different failures reach here and they need
255259
// different advice:
256260
//
@@ -280,6 +284,14 @@ export async function verifyCommand(
280284
// report — only why there is none.
281285
emitStructured({
282286
error: `failed to load metadata: ${msg}`,
287+
// ADR-0009 promises a stable `code`, and the docs name codes a CI job is meant to
288+
// branch on — but this payload used to carry the message string and nothing else,
289+
// so there was nothing to branch on. Each field is present only when the loader
290+
// actually supplied it; absence here means the loader had none, never that it was
291+
// dropped in transit.
292+
...(report.code !== undefined ? { code: report.code } : {}),
293+
...(report.files !== undefined ? { files: [...report.files] } : {}),
294+
...(report.jsonPath !== undefined ? { jsonPath: report.jsonPath } : {}),
283295
hint: suggestions !== undefined && suggestions.length > 0
284296
? suggestions.join(" ")
285297
: isStrictAttr ? strictHint : "fix the metadata error above and re-run",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { LoaderError } from "@metaobjectsdev/metadata";
2+
3+
/**
4+
* One rendering of a metadata LOAD failure, for every command that can hit one.
5+
*
6+
* The loader builds a full ADR-0009 envelope — a stable `code`, the `files` the node came
7+
* from, the `jsonPath` inside the document, and often `suggestions[]` naming the next step.
8+
* Five commands then printed `` `failed to load metadata: ${err.message}` `` and threw all
9+
* of it away. So `ERR_ABSTRACT_SUBTYPE_AUTHORED` reached an adopter as a paragraph with no
10+
* file, no line, no node name and no code — and `--format json` carried the same bare
11+
* string, so a CI job could not key on the code ADR-0009 promises and the docs name. Five
12+
* metadata files made that a `grep`; two hundred would not.
13+
*
14+
* This does not invent provenance. It reports exactly what the error carries and stays
15+
* silent about what it does not, so a caller can never read more precision into the line
16+
* than the loader actually had.
17+
*/
18+
export interface LoadErrorReport {
19+
/** The message plus whatever provenance the envelope carried, for a human. */
20+
readonly text: string;
21+
/** ADR-0009 stable code, when the loader attached one. */
22+
readonly code?: string;
23+
/** The file(s) the failing node was read from. */
24+
readonly files?: readonly string[];
25+
/** JSON path of the failing node within its document. */
26+
readonly jsonPath?: string;
27+
/** The loader's own next steps. Printed verbatim; never paraphrased. */
28+
readonly suggestions?: readonly string[];
29+
}
30+
31+
/** True for a thrown value carrying the loader's ADR-0009 envelope. */
32+
function isLoaderError(err: unknown): err is LoaderError {
33+
return (
34+
typeof err === "object" && err !== null
35+
&& typeof (err as { code?: unknown }).code === "string"
36+
&& typeof (err as { source?: unknown }).source === "object"
37+
);
38+
}
39+
40+
export function describeLoadError(err: unknown): LoadErrorReport {
41+
const message = err instanceof Error ? err.message : String(err);
42+
if (!isLoaderError(err)) return { text: message };
43+
44+
const source = err.source as { files?: readonly string[]; jsonPath?: string };
45+
const files = source.files?.filter((f) => f.length > 0);
46+
const jsonPath = source.jsonPath;
47+
const suggestions = err.suggestions?.filter((s) => s.length > 0);
48+
49+
// `code` first: it is the one part a machine keys on, and a human scanning a terminal
50+
// finds it fastest at the front of the line. Then WHERE, which is the question the
51+
// message itself can never answer.
52+
const where = [
53+
files !== undefined && files.length > 0 ? files.join(", ") : undefined,
54+
jsonPath !== undefined && jsonPath.length > 0 ? `at ${jsonPath}` : undefined,
55+
].filter((p): p is string => p !== undefined).join(" ");
56+
57+
const head = `${err.code}: ${message}`;
58+
return {
59+
text: where.length > 0 ? `${head}\n in ${where}` : head,
60+
code: err.code,
61+
...(files !== undefined && files.length > 0 ? { files } : {}),
62+
...(jsonPath !== undefined && jsonPath.length > 0 ? { jsonPath } : {}),
63+
...(suggestions !== undefined && suggestions.length > 0 ? { suggestions } : {}),
64+
};
65+
}

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,25 @@ export function rewriteYamlDocument(source: string, opts: RewriteOpts = {}): Yam
304304
if (`${entry.type}.${entry.subType}` !== key) continue;
305305
refusals.push({ ...note(entry), subject: key, line: lineOf(span.keyStart) });
306306
}
307+
// The SAME rule as the JSON arm: an authored `<type>.base` is a load error, not a
308+
// retirement, and `meta upgrade` must not answer "nothing to rewrite" about it. Kept
309+
// beside its sibling rather than in one shared pass because these two rewriters walk
310+
// different structures — the shared thing is `RewriteOpts.abstractAnchorTypes`, which
311+
// both read, so neither can be given a different answer to the same question.
312+
const dot = key.lastIndexOf(".");
313+
if (dot >= 0
314+
&& key.slice(dot + 1) === "base"
315+
&& (opts.abstractAnchorTypes ?? []).includes(key.slice(0, dot))) {
316+
refusals.push({
317+
since: "1.0.0",
318+
why:
319+
`"${key}" may not be authored — every "base" subtype is an abstract registry ` +
320+
"anchor that concrete subtypes inherit from, with no runtime semantics of its own.",
321+
migration: "docs/features/migrations/base-subtypes-are-not-authorable.md",
322+
subject: key,
323+
line: lineOf(span.keyStart),
324+
});
325+
}
307326
return;
308327
}
309328

server/typescript/packages/metadata/src/parser-core.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,30 @@ function abstractSubtypeMessage(type: string): string {
243243
);
244244
}
245245

246+
/**
247+
* The concrete subtypes to offer instead, as ADR-0009 `suggestions[]`.
248+
*
249+
* DERIVED from the registry, never a written list: the substitution the migration guide
250+
* prints for `object.base` is exactly "the other subtypes this type registers", so reading
251+
* it from the registry means a new subtype appears in the advice the day it is registered,
252+
* and a removed one stops being suggested. The message alone said "declare a concrete
253+
* subtype" without ever naming one, so the next step was in a guide the reader had to
254+
* already know existed.
255+
*/
256+
function abstractSubtypeSuggestions(type: string, registry: TypeRegistry): string[] {
257+
const concrete = registry.allSubTypesOf(type).filter((sub) => sub !== SUBTYPE_BASE).sort();
258+
const out = [
259+
concrete.length > 0
260+
? `declare one of: ${concrete.map((sub) => `"${type}.${sub}"`).join(" | ")}`
261+
: `declare a concrete "${type}.<subType>"`,
262+
];
263+
out.push(
264+
"which one is a decision about the node, not a rename — see " +
265+
"docs/features/migrations/base-subtypes-are-not-authorable.md",
266+
);
267+
return out;
268+
}
269+
246270
// The same rule reached by the OTHER spelling: a BARE wrapper key (`{"field": …}`, no fused
247271
// subType) whose registry default resolves to the abstract anchor. The author did not type
248272
// `.base`, so this is a MISSING subtype rather than an authored-anchor error — and
@@ -507,6 +531,7 @@ export function buildTree(parsed: unknown, opts: ParseOptions): ParseResult {
507531
? new ParseError(abstractSubtypeMessage(rootType), {
508532
code: "ERR_ABSTRACT_SUBTYPE_AUTHORED",
509533
source: src,
534+
suggestions: abstractSubtypeSuggestions(rootType, opts.registry),
510535
})
511536
: new ParseError(missingSubtypeMessage(rootType), {
512537
code: "ERR_MISSING_SUBTYPE",
@@ -1372,6 +1397,7 @@ function processChildren(
13721397
? new ParseError(abstractSubtypeMessage(childType), {
13731398
code: "ERR_ABSTRACT_SUBTYPE_AUTHORED",
13741399
source: errSource(),
1400+
suggestions: abstractSubtypeSuggestions(childType, registry),
13751401
})
13761402
: new ParseError(missingSubtypeMessage(childType), {
13771403
code: "ERR_MISSING_SUBTYPE",

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ export interface RewriteResult {
8888
export interface RewriteOpts {
8989
/** Only apply retirements at or before this version. */
9090
readonly maxVersion?: string;
91+
/**
92+
* Types whose `<type>.base` is an ABSTRACT ANCHOR, so authoring one is a load error
93+
* (`ERR_ABSTRACT_SUBTYPE_AUTHORED`). Supplied by the CALLER rather than derived here,
94+
* because this module is deliberately registry-free — and the fact is a registry
95+
* question: `base` is an anchor exactly when the type registers some OTHER subtype for
96+
* it to anchor, which a third-party provider's base-only type does not.
97+
*
98+
* Omitted ⇒ no anchor refusals, which is the honest default for a caller that cannot
99+
* say. `meta upgrade` supplies the core set.
100+
*/
101+
readonly abstractAnchorTypes?: readonly string[];
91102
}
92103

93104
/** `0.24.0` → `[0,24,0]`, for an ordered comparison rather than a string one. */
@@ -346,6 +357,28 @@ export function rewriteDocument(source: string, opts: RewriteOpts = {}): Rewrite
346357
}
347358
}
348359

360+
// An authored `<type>.base` is a LOAD ERROR, not a retirement — the anchor was never
361+
// authorable, and three of five ports accepted it anyway until 1.0. It belongs here for
362+
// the same reason retired subtypes do: `meta upgrade` is the command an adopter runs to
363+
// ask "what does the new version need me to change?", and answering "nothing" about the
364+
// change the migration guide LEADS WITH is worse than not being asked. It is a REFUSAL
365+
// because choosing the concrete subtype is a decision about what the node IS, which no
366+
// rewriter can make.
367+
for (const r of ranges) {
368+
const dot = r.typeKey.lastIndexOf(".");
369+
if (dot < 0 || r.typeKey.slice(dot + 1) !== "base") continue;
370+
if (!(opts.abstractAnchorTypes ?? []).includes(r.typeKey.slice(0, dot))) continue;
371+
refusals.push({
372+
since: "1.0.0",
373+
why:
374+
`"${r.typeKey}" may not be authored — every "base" subtype is an abstract registry ` +
375+
"anchor that concrete subtypes inherit from, with no runtime semantics of its own.",
376+
migration: "docs/features/migrations/base-subtypes-are-not-authorable.md",
377+
subject: r.typeKey,
378+
line: lineAt(source, r.keyIndex),
379+
});
380+
}
381+
349382
// ── Attribute contradictions: two LIVE attrs that may not sit on one node ──
350383
//
351384
// Matched per NODE, not per occurrence, because the illegal thing is the pair. `ownKeys`

0 commit comments

Comments
 (0)