@metaobjectsdev/cli 1.0.9-rc.3 → 1.0.9-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +15 -0
  2. package/dist/src/commands/eject.d.ts +17 -0
  3. package/dist/src/commands/eject.d.ts.map +1 -1
  4. package/dist/src/commands/eject.js +87 -2
  5. package/dist/src/commands/eject.js.map +1 -1
  6. package/dist/src/commands/gen.js +3 -1
  7. package/dist/src/commands/gen.js.map +1 -1
  8. package/dist/src/commands/generator-help.d.ts +21 -0
  9. package/dist/src/commands/generator-help.d.ts.map +1 -0
  10. package/dist/src/commands/generator-help.js +43 -0
  11. package/dist/src/commands/generator-help.js.map +1 -0
  12. package/dist/src/commands/generator.d.ts +36 -0
  13. package/dist/src/commands/generator.d.ts.map +1 -0
  14. package/dist/src/commands/generator.js +333 -0
  15. package/dist/src/commands/generator.js.map +1 -0
  16. package/dist/src/commands/init.d.ts.map +1 -1
  17. package/dist/src/commands/init.js +10 -1
  18. package/dist/src/commands/init.js.map +1 -1
  19. package/dist/src/commands/verify.d.ts +13 -1
  20. package/dist/src/commands/verify.d.ts.map +1 -1
  21. package/dist/src/commands/verify.js +69 -14
  22. package/dist/src/commands/verify.js.map +1 -1
  23. package/dist/src/index.d.ts.map +1 -1
  24. package/dist/src/index.js +12 -0
  25. package/dist/src/index.js.map +1 -1
  26. package/dist/src/lib/advisory.d.ts +14 -0
  27. package/dist/src/lib/advisory.d.ts.map +1 -1
  28. package/dist/src/lib/advisory.js +64 -5
  29. package/dist/src/lib/advisory.js.map +1 -1
  30. package/dist/src/lib/args.d.ts +12 -0
  31. package/dist/src/lib/args.d.ts.map +1 -1
  32. package/dist/src/lib/args.js +6 -0
  33. package/dist/src/lib/args.js.map +1 -1
  34. package/dist/src/lib/catalog-listing.d.ts +15 -0
  35. package/dist/src/lib/catalog-listing.d.ts.map +1 -1
  36. package/dist/src/lib/catalog-listing.js +38 -3
  37. package/dist/src/lib/catalog-listing.js.map +1 -1
  38. package/dist/src/lib/codegen-drift.d.ts +10 -0
  39. package/dist/src/lib/codegen-drift.d.ts.map +1 -1
  40. package/dist/src/lib/codegen-drift.js +8 -0
  41. package/dist/src/lib/codegen-drift.js.map +1 -1
  42. package/dist/src/lib/install-set.d.ts +12 -2
  43. package/dist/src/lib/install-set.d.ts.map +1 -1
  44. package/dist/src/lib/install-set.js +14 -2
  45. package/dist/src/lib/install-set.js.map +1 -1
  46. package/dist/src/lib/runtime-eject.d.ts +80 -0
  47. package/dist/src/lib/runtime-eject.d.ts.map +1 -0
  48. package/dist/src/lib/runtime-eject.js +212 -0
  49. package/dist/src/lib/runtime-eject.js.map +1 -0
  50. package/package.json +10 -10
  51. package/src/commands/eject.ts +103 -3
  52. package/src/commands/gen.ts +3 -1
  53. package/src/commands/generator-help.ts +47 -0
  54. package/src/commands/generator.ts +390 -0
  55. package/src/commands/init.ts +10 -1
  56. package/src/commands/verify.ts +71 -14
  57. package/src/index.ts +12 -0
  58. package/src/lib/advisory.ts +71 -5
  59. package/src/lib/args.ts +16 -0
  60. package/src/lib/catalog-listing.ts +68 -3
  61. package/src/lib/codegen-drift.ts +17 -0
  62. package/src/lib/install-set.ts +19 -2
  63. package/src/lib/runtime-eject.ts +263 -0
@@ -0,0 +1,390 @@
1
+ // `meta generator new <name>` — scaffold a generator of YOUR OWN (ADR-0034 Amendment 4).
2
+ //
3
+ // MetaObjects' product is the core — the model, loader, `verify`, `migrate`, render and
4
+ // extract. Every output an application needs beyond the reference generators (OpenAPI, JSON
5
+ // Schema, a client, a DTO layer, docs) is a generator the adopter writes. This command makes
6
+ // the first minute of that cheap: it writes a working, commented generator into
7
+ // codegen/generators/ and wires it into metaobjects.config.ts, so `meta gen` runs it and
8
+ // `meta verify --codegen` gates it before the author has changed a line.
9
+ //
10
+ // It is deliberately NOT eject: eject copies a reference generator; this writes a new one.
11
+ // The two share only the dependency report.
12
+
13
+ import { existsSync } from "node:fs";
14
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { parseArgs } from "node:util";
17
+ import { catalogEntry } from "../lib/catalog.js";
18
+ import { log } from "../lib/log.js";
19
+ import { dependencyNotesForTemplate } from "./eject.js";
20
+ import {
21
+ CONFIG_FILE,
22
+ GENERATOR_OPTIONS,
23
+ GENERATOR_SCOPES,
24
+ OWNED_GENERATORS_DIR,
25
+ type GeneratorScope,
26
+ } from "./generator-help.js";
27
+
28
+ export { GENERATOR_HELP, GENERATOR_OPTIONS, GENERATOR_SCOPES, OWNED_GENERATORS_DIR } from "./generator-help.js";
29
+ export type { GeneratorScope } from "./generator-help.js";
30
+
31
+ const NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
32
+
33
+ /** kebab-case → the exported factory's name: `json-schema` → `jsonSchemaGenerator`. */
34
+ export function factoryNameFor(name: string): string {
35
+ const camel = name.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
36
+ return `${camel}Generator`;
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // The skeleton
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const HEADER = (name: string, scope: GeneratorScope) => `// ${name} — a generator of your own. It is yours: edit it freely; no MetaObjects release
44
+ // changes it. Scaffolded by \`meta generator new ${name} --scope ${scope}\`.
45
+ //
46
+ // HOW IT RUNS. metaobjects.config.ts lists it in \`generators: [...]\`. \`meta gen\` calls
47
+ // generate() with the loaded model and writes each returned { path, content } under outDir
48
+ // (paths are relative to it). \`meta verify --codegen\` re-runs it and fails when committed
49
+ // output is stale — nothing to register for that. Typecheck this file with
50
+ // npx tsc -p tsconfig.codegen.json
51
+ // because \`meta gen\` loads it WITHOUT typechecking.
52
+ //
53
+ // READING THE MODEL (it is read-only — never mutate it):
54
+ // ctx.entities EVERY object: entities, value objects, projections AND abstract
55
+ // bases. The \`filter\` below decides which ones you get.
56
+ // obj.name / effectivePackage(obj) the object's name and its package (or undefined)
57
+ // obj.fields() all fields, INCLUDING those inherited through \`extends\`
58
+ // field.subType "string" | "long" | "enum" | "object" | ... (\`meta types field\`)
59
+ // field.isRequired, field.maxLength, field.precision, field.scale
60
+ // field.resolvedIsArray() array-ness, inherited too
61
+ // field.attr("description") any attribute's effective value (own, else inherited)
62
+ // enumValues(field) a field.enum's members
63
+ // objectRefTarget(field) the object a field.object points at (package-aware)
64
+ // obj.primaryIdentity()?.fields the primary-key field names
65
+ //
66
+ // THE ONE RULE (ADR-0039): always read through the accessors above. The own*() forms
67
+ // (ownFields, ownAttr, ownChildren) and the raw \`isArray\` flag skip everything an object or
68
+ // field inherits through \`extends\` — the output compiles and is silently wrong.
69
+ //
70
+ // NAMING: toCamelCase / toPascalCase / toSnakeCase / pluralize, and servedPath(obj, prefix)
71
+ // for the REST address the reference routes serve, all from "@metaobjectsdev/codegen-ts".
72
+ // The config's apiPrefix reaches you as ctx.renderContext?.apiPrefix.
73
+ `;
74
+
75
+ /** Exactly the names the scope's skeleton uses — an unused import fails a strict tsconfig. */
76
+ function importsFor(scope: GeneratorScope): string {
77
+ const names = ["effectivePackage", "enumValues", "isAbstract", "objectRefTarget", HELPER_BY_SCOPE[scope]];
78
+ if (scope !== "model") names.push("packageToPath");
79
+ const list = names.sort().map((n) => ` ${n},\n`).join("");
80
+ return `import {\n${list} type Generator,\n} from "@metaobjectsdev/codegen-ts";\n` +
81
+ `import type { MetaField, MetaObject } from "@metaobjectsdev/metadata";\n`;
82
+ }
83
+
84
+ const DESCRIBE_FIELD = `
85
+ /** One field, described. Replace this with whatever your output needs per field. */
86
+ function describeField(field: MetaField): Record<string, unknown> {
87
+ const out: Record<string, unknown> = {
88
+ name: field.name,
89
+ type: field.subType,
90
+ required: field.isRequired,
91
+ array: field.resolvedIsArray(),
92
+ };
93
+ if (field.maxLength !== undefined) out.maxLength = field.maxLength;
94
+ const members = enumValues(field);
95
+ if (members !== undefined) out.values = members;
96
+ const target = objectRefTarget(field);
97
+ if (target !== undefined) out.ref = target.resolutionKey();
98
+ return out;
99
+ }
100
+
101
+ const describeObject = (obj: MetaObject) => ({
102
+ name: obj.name,
103
+ package: effectivePackage(obj) ?? null,
104
+ fields: obj.fields().map(describeField),
105
+ });
106
+ `;
107
+
108
+ const FACTORY_HEAD = (name: string, factory: string) => `
109
+ export interface ${factory.charAt(0).toUpperCase()}${factory.slice(1)}Opts {
110
+ /** Narrow which objects this generator sees (ANDed with the default filter below). */
111
+ filter?: (obj: MetaObject) => boolean;
112
+ /** Named output target from the config's \`targets\`; omit for the top-level outDir. */
113
+ target?: string;
114
+ }
115
+
116
+ export function ${factory}(opts: ${factory.charAt(0).toUpperCase()}${factory.slice(1)}Opts = {}): Generator {
117
+ const generator: Generator = {
118
+ name: "${name}",
119
+ // Which objects: here, every concrete one. Abstract bases only contribute fields to
120
+ // what \`extends\` them. Other predicates to compose: hasAnyRdbSource (persisted),
121
+ // servesReadApi / servesWriteApi (has reference routes), isProjection.
122
+ filter: (obj) => !isAbstract(obj) && (opts.filter?.(obj) ?? true),
123
+ `;
124
+
125
+ const FACTORY_TAIL = ` };
126
+ if (opts.target !== undefined) generator.target = opts.target;
127
+ return generator;
128
+ }
129
+ `;
130
+
131
+ const GENERATE_BY_SCOPE: Record<GeneratorScope, (name: string) => string> = {
132
+ entity: (name) => ` // perEntity: generate() runs once per matched object; return one file (or an array).
133
+ generate: perEntity((obj) => {
134
+ const pkg = effectivePackage(obj);
135
+ const dir = pkg === undefined ? "${name}" : \`${name}/\${packageToPath(pkg)}\`;
136
+ return {
137
+ path: \`\${dir}/\${obj.name}.json\`,
138
+ content: \`\${JSON.stringify(describeObject(obj), null, 2)}\\n\`,
139
+ };
140
+ }),
141
+ `,
142
+ package: (name) => ` // perPackage: generate() runs once per metadata package with that package's objects.
143
+ generate: perPackage((pkg, objects) => ({
144
+ path: \`${name}/\${pkg === "" ? "_root" : packageToPath(pkg)}.json\`,
145
+ content: \`\${JSON.stringify({ package: pkg, objects: objects.map(describeObject) }, null, 2)}\\n\`,
146
+ })),
147
+ `,
148
+ model: (name) => ` // perModel: generate() runs once with every matched object; return one file (or more).
149
+ generate: perModel((objects) => ({
150
+ path: "${name}.json",
151
+ content: \`\${JSON.stringify({ objects: objects.map(describeObject) }, null, 2)}\\n\`,
152
+ })),
153
+ `,
154
+ };
155
+
156
+ const HELPER_BY_SCOPE: Record<GeneratorScope, string> = {
157
+ entity: "perEntity",
158
+ package: "perPackage",
159
+ model: "perModel",
160
+ };
161
+
162
+ /** The generator source `meta generator new` writes. Exported for the gate that runs it. */
163
+ export function renderGeneratorSkeleton(name: string, scope: GeneratorScope): string {
164
+ const factory = factoryNameFor(name);
165
+ return [
166
+ HEADER(name, scope),
167
+ importsFor(scope),
168
+ DESCRIBE_FIELD,
169
+ FACTORY_HEAD(name, factory),
170
+ GENERATE_BY_SCOPE[scope](name),
171
+ FACTORY_TAIL,
172
+ ].join("");
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Wiring the config
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export interface WireResult {
180
+ status: "wired" | "already-wired" | "manual";
181
+ source: string;
182
+ }
183
+
184
+ /**
185
+ * Index of the `]` closing the `[` at `open`, skipping string literals, template literals
186
+ * and comments so a bracket inside `"a]b"` does not end the array. -1 if unbalanced.
187
+ */
188
+ function matchingBracket(src: string, open: number): number {
189
+ let depth = 0;
190
+ for (let i = open; i < src.length; i++) {
191
+ const c = src[i]!;
192
+ if (c === '"' || c === "'" || c === "`") {
193
+ for (i++; i < src.length && src[i] !== c; i++) if (src[i] === "\\") i++;
194
+ continue;
195
+ }
196
+ if (c === "/" && src[i + 1] === "/") {
197
+ while (i < src.length && src[i] !== "\n") i++;
198
+ continue;
199
+ }
200
+ if (c === "/" && src[i + 1] === "*") {
201
+ i = src.indexOf("*/", i + 2);
202
+ if (i === -1) return -1;
203
+ i++;
204
+ continue;
205
+ }
206
+ if (c === "[" || c === "(" || c === "{") depth++;
207
+ else if (c === "]" || c === ")" || c === "}") {
208
+ depth--;
209
+ if (depth === 0) return c === "]" ? i : -1;
210
+ }
211
+ }
212
+ return -1;
213
+ }
214
+
215
+ /**
216
+ * Add `importLine` after the config's last top-level import and `entry` to the end of its
217
+ * `generators: [...]` array. Edits only a config it can read unambiguously — exactly one
218
+ * literal `generators: [` array — and otherwise returns `manual` with the source untouched,
219
+ * so the caller prints the two lines instead of guessing.
220
+ */
221
+ export function wireGeneratorIntoConfig(source: string, importLine: string, entry: string): WireResult {
222
+ if (source.includes(importLine)) return { status: "already-wired", source };
223
+
224
+ const arrays = [...source.matchAll(/\bgenerators\s*:\s*\[/g)];
225
+ if (arrays.length !== 1) return { status: "manual", source };
226
+ const open = arrays[0]!.index! + arrays[0]![0].length - 1;
227
+ const close = matchingBracket(source, open);
228
+ if (close === -1) return { status: "manual", source };
229
+
230
+ const inner = source.slice(open + 1, close);
231
+ let newInner: string;
232
+ if (inner.trim() === "") {
233
+ newInner = entry;
234
+ } else if (inner.includes("\n")) {
235
+ // Multi-line: a new line at the indent of the last element, keeping the closing line.
236
+ const trailing = inner.slice(inner.trimEnd().length); // "\n " before `]`
237
+ const body = inner.trimEnd();
238
+ const lastLine = body.slice(body.lastIndexOf("\n") + 1);
239
+ const indent = lastLine.slice(0, lastLine.length - lastLine.trimStart().length);
240
+ // A trailing line comment means the element's comma sits before the comment.
241
+ const hasComma = /,\s*(\/\/[^\n]*)?$/.test(body);
242
+ newInner = `${body}${hasComma ? "" : ","}\n${indent}${entry},${trailing}`;
243
+ } else {
244
+ const body = inner.trimEnd();
245
+ newInner = body.endsWith(",") ? `${body} ${entry}` : `${body}, ${entry}`;
246
+ }
247
+ let out = source.slice(0, open + 1) + newInner + source.slice(close);
248
+
249
+ // The import: after the last line that starts an import statement (and its `from` line).
250
+ const importRe = /^import[\s\S]*?from\s+["'][^"']+["'];?[^\n]*$/gm;
251
+ let lastEnd = -1;
252
+ for (const m of out.matchAll(importRe)) lastEnd = m.index! + m[0].length;
253
+ out = lastEnd === -1
254
+ ? `${importLine}\n${out}`
255
+ : `${out.slice(0, lastEnd)}\n${importLine}${out.slice(lastEnd)}`;
256
+ return { status: "wired", source: out };
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // The command
261
+ // ---------------------------------------------------------------------------
262
+
263
+ export interface NewGeneratorOptions {
264
+ cwd: string;
265
+ name: string;
266
+ scope?: GeneratorScope;
267
+ force?: boolean;
268
+ wire?: boolean;
269
+ }
270
+
271
+ export interface NewGeneratorResult {
272
+ file: string;
273
+ fileStatus: "created" | "overwritten";
274
+ wireStatus: WireResult["status"] | "skipped";
275
+ importLine: string;
276
+ entry: string;
277
+ dependencyNotes: string[];
278
+ }
279
+
280
+ export async function newGenerator(opts: NewGeneratorOptions): Promise<NewGeneratorResult> {
281
+ const { cwd, name } = opts;
282
+ const scope = opts.scope ?? "entity";
283
+ if (!NAME_PATTERN.test(name)) {
284
+ throw new Error(`generator name "${name}" must be kebab-case (e.g. json-schema, openapi).`);
285
+ }
286
+ if (catalogEntry(name) !== undefined) {
287
+ throw new Error(
288
+ `"${name}" is a reference generator's name. Pick another name for your own generator, ` +
289
+ `or take a copy of that one with \`meta eject ${name}\`.`,
290
+ );
291
+ }
292
+ const configPath = join(cwd, CONFIG_FILE);
293
+ const wire = opts.wire ?? true;
294
+ if (wire && !existsSync(configPath)) {
295
+ throw new Error(`no ${CONFIG_FILE} here — run \`meta init\` first, or pass --no-wire.`);
296
+ }
297
+
298
+ const rel = `${OWNED_GENERATORS_DIR}/${name}.ts`;
299
+ const abs = join(cwd, rel);
300
+ const existed = existsSync(abs);
301
+ if (existed && opts.force !== true) {
302
+ throw new Error(`${rel} already exists — it is yours; edit it, or pass --force to replace it.`);
303
+ }
304
+ const source = renderGeneratorSkeleton(name, scope);
305
+ await mkdir(join(cwd, OWNED_GENERATORS_DIR), { recursive: true });
306
+ await writeFile(abs, source, "utf8");
307
+
308
+ const factory = factoryNameFor(name);
309
+ const importLine = `import { ${factory} } from "./${OWNED_GENERATORS_DIR}/${name}.js";`;
310
+ const entry = `${factory}()`;
311
+
312
+ let wireStatus: NewGeneratorResult["wireStatus"] = "skipped";
313
+ if (wire) {
314
+ const result = wireGeneratorIntoConfig(await readFile(configPath, "utf8"), importLine, entry);
315
+ if (result.status === "wired") await writeFile(configPath, result.source, "utf8");
316
+ wireStatus = result.status;
317
+ }
318
+
319
+ return {
320
+ file: rel,
321
+ fileStatus: existed ? "overwritten" : "created",
322
+ wireStatus,
323
+ importLine,
324
+ entry,
325
+ // The same report eject gives, worded for a file nobody ejected.
326
+ dependencyNotes: (await dependencyNotesForTemplate(cwd, source)).map((n) =>
327
+ n.replace(/^The ejected file imports/, "The new generator imports")),
328
+ };
329
+ }
330
+
331
+ export async function generatorCommand(args: string[], cwd: string): Promise<number> {
332
+ let values: { scope?: string; force?: boolean; "no-wire"?: boolean };
333
+ let positionals: string[];
334
+ try {
335
+ ({ values, positionals } = parseArgs({ args, options: GENERATOR_OPTIONS, strict: true, allowPositionals: true }));
336
+ } catch (err) {
337
+ log.error((err as Error).message);
338
+ return 2;
339
+ }
340
+ const [sub, name, ...extra] = positionals;
341
+ if (sub !== "new" || name === undefined || extra.length > 0) {
342
+ log.error("usage: meta generator new <name> [--scope entity|package|model]. See `meta generator --help`.");
343
+ return 2;
344
+ }
345
+ const scope = values.scope ?? "entity";
346
+ if (!(GENERATOR_SCOPES as readonly string[]).includes(scope)) {
347
+ log.error(`--scope must be one of ${GENERATOR_SCOPES.join(", ")} (got "${scope}").`);
348
+ return 2;
349
+ }
350
+
351
+ let r: NewGeneratorResult;
352
+ try {
353
+ r = await newGenerator({
354
+ cwd,
355
+ name,
356
+ scope: scope as GeneratorScope,
357
+ force: values.force === true,
358
+ wire: values["no-wire"] !== true,
359
+ });
360
+ } catch (err) {
361
+ log.error((err as Error).message);
362
+ return 2;
363
+ }
364
+
365
+ const lines = [`${r.fileStatus === "created" ? "Created" : "Overwrote"} ${r.file} — yours to edit.`];
366
+ if (r.wireStatus === "wired") {
367
+ lines.push(`Wired it into ${CONFIG_FILE}: ${r.entry}`);
368
+ } else if (r.wireStatus === "already-wired") {
369
+ lines.push(`${CONFIG_FILE} already imports it.`);
370
+ } else {
371
+ lines.push(
372
+ r.wireStatus === "manual"
373
+ ? `Could not find one literal \`generators: [...]\` in ${CONFIG_FILE} to edit safely. Add:`
374
+ : `Add to ${CONFIG_FILE}:`,
375
+ ` ${r.importLine}`,
376
+ ` generators: [..., ${r.entry}]`,
377
+ );
378
+ }
379
+ lines.push(
380
+ "",
381
+ "Next:",
382
+ " meta gen # runs it; output lands under outDir",
383
+ " meta verify --codegen # drift-gates it (no registration needed)",
384
+ " npx tsc -p tsconfig.codegen.json # typecheck it — meta gen does not",
385
+ `Then edit ${r.file}: change what it emits, keep reading the model through fields() / attr().`,
386
+ );
387
+ if (r.dependencyNotes.length > 0) lines.push("", ...r.dependencyNotes);
388
+ log.info(lines.join("\n"));
389
+ return 0;
390
+ }
@@ -114,6 +114,13 @@ export default defineConfig({
114
114
  // \`meta gen\` runs — and prints the import line to add here, the entry to add below,
115
115
  // what to install, and any config keys those generators read (\`dbImport\`,
116
116
  // \`apiPrefix\`, \`extStyle\`, …). Add them here as you go.
117
+ //
118
+ // Need an output nothing in that catalog emits — OpenAPI, JSON Schema, a client, a
119
+ // service layer, docs? Write your own generator; that is the normal path:
120
+ //
121
+ // meta generator new openapi --scope model
122
+ //
123
+ // writes a working, commented generator into ./codegen/generators/ and adds it here.
117
124
  generators: [],
118
125
 
119
126
  docs: {
@@ -145,7 +152,9 @@ Next steps:
145
152
  3. meta eject <name>... # take the ones you want — copies them into
146
153
  # codegen/generators/ (yours to edit) and prints the
147
154
  # import, the entry to wire, and what to install
148
- 4. meta gen # generate from exactly what you wired
155
+ meta generator new <name> # an output no reference emits? write your own — this
156
+ # scaffolds a working one and wires it
157
+ 4. meta gen # generate from exactly what you wired
149
158
  5. meta docs # neutral model + API docs (on by default)
150
159
  6. Create your tables: meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply
151
160
  `;
@@ -17,6 +17,7 @@ import {
17
17
  libraryPrefixRows,
18
18
  unindexedFkRows,
19
19
  referentialActionConflictRows,
20
+ advisoryHelpLines,
20
21
  } from "../lib/advisory.js";
21
22
  import { scanForUnprovenancedLibraryPrefix } from "../lib/library-prefix-advisory.js";
22
23
  import { scanForUnindexedForeignKeys, type UnindexedFkFinding } from "../lib/fk-index-advisory.js";
@@ -416,12 +417,12 @@ export async function verifyCommand(
416
417
 
417
418
  // Naming a gate runs ONLY the gates named (ADR-0021 D2), so `verify --codegen --db <url>`
418
419
  // exits 0 without ever looking at the prompt templates. A green partial run must not read
419
- // as a full one: say which gates did not run and how to select each. A bare `verify` has
420
- // already said so in its default note above.
421
- if (flags.anyExplicit) {
422
- const note = notRunNote(gates);
423
- if (note !== undefined) say(note);
424
- }
420
+ // as a full one: say which gates did not run and how to select each. The BARE run says so
421
+ // too, at the END where the verdict is read: its opening note scrolls past, and on a model
422
+ // with no templates the only other line was "nothing to check" over a green exit — easy to
423
+ // wire into CI believing it guards everything. Never changes the exit code.
424
+ const note = notRunNote(gates, { bare: !flags.anyExplicit });
425
+ if (note !== undefined) say(note);
425
426
 
426
427
  if (structured) {
427
428
  emitStructured(
@@ -937,7 +938,9 @@ export async function verifyCommand(
937
938
  // ADR-0039: effective children — resolve rather than rely on root being unextended.
938
939
  const templates = root.children().filter((c) => c.type === TYPE_TEMPLATE);
939
940
  if (templates.length === 0) {
940
- say("meta verify — no template.* nodes found; nothing to check.");
941
+ // Names the GATE that had nothing to check — "nothing to check" alone read as a
942
+ // verdict on the whole project.
943
+ say("meta verify — no template.* nodes found; the template gate had nothing to check.");
941
944
  return 0;
942
945
  }
943
946
 
@@ -1450,13 +1453,19 @@ export async function verifyCommand(
1450
1453
  return 2;
1451
1454
  }
1452
1455
 
1456
+ // Hand edits are not drift, but they must never be INVISIBLE: a validator weakened
1457
+ // inside a generated file used to pass this gate with "every file's generated
1458
+ // contribution is current" and nothing else. Reported on every run; fails the build
1459
+ // only under --forbid-hand-edits.
1460
+ const handEditExit = reportHandEdits(result.handEdited);
1461
+
1453
1462
  if (result.clean) {
1454
1463
  // Says what it CHECKED, not more. Since 0.24.3 this gate asks whether each file's
1455
1464
  // GENERATED contribution is current — a hand edit `meta gen` preserves is exempt —
1456
1465
  // so "output is in sync with the metadata" claimed a stronger property than it had
1457
1466
  // verified, and did so over a file whose committed content contradicted the model.
1458
1467
  say("meta verify — every file's generated contribution is current (no codegen drift).");
1459
- return 0;
1468
+ return handEditExit;
1460
1469
  }
1461
1470
 
1462
1471
  log.error(
@@ -1481,6 +1490,23 @@ export async function verifyCommand(
1481
1490
  return 1;
1482
1491
  }
1483
1492
 
1493
+ /** Print the hand-edit notice (or failure, under --forbid-hand-edits); return its exit code. */
1494
+ function reportHandEdits(handEdited: readonly string[]): number {
1495
+ if (handEdited.length === 0) return 0;
1496
+ const [header, ...rest] = handEditNotice(handEdited, flags.forbidHandEdits);
1497
+ const paths = rest.slice(0, handEdited.length);
1498
+ const footer = rest.slice(handEdited.length);
1499
+ if (flags.forbidHandEdits) {
1500
+ // A failure lists every path: truncating the list a build failed on hides the cause.
1501
+ for (const line of [header!, ...paths, ...footer]) log.error(line);
1502
+ return 1;
1503
+ }
1504
+ log.warn(header!);
1505
+ warnCapped(paths, flags.limit, { structured });
1506
+ for (const line of footer) log.warn(line);
1507
+ return 0;
1508
+ }
1509
+
1484
1510
  // -- docs drift -------------------------------------------------------------
1485
1511
  // Gated on --docs. Runs `meta docs` into a temp dir and diffs the committed docs
1486
1512
  // tree. See lib/docs-drift.ts for the deliberate differences from --codegen: a byte
@@ -1683,6 +1709,30 @@ interface VerifyGateRow {
1683
1709
  ok: boolean;
1684
1710
  }
1685
1711
 
1712
+ /**
1713
+ * The hand-edit report for `verify --codegen`: a header with the count, one indented line
1714
+ * per path, then how to see an edit. Exported for its test.
1715
+ *
1716
+ * The recipe regenerates rather than diffing against `.gen-state`, because the snapshot
1717
+ * BODIES are gitignored — on a fresh clone or a CI runner only the hashes exist, which
1718
+ * prove a file was edited but cannot show the edit. Deleting a generated file and
1719
+ * re-running `meta gen` re-emits its pristine generation; git then shows the difference.
1720
+ */
1721
+ export function handEditNotice(paths: readonly string[], forbidden: boolean): string[] {
1722
+ const header = forbidden
1723
+ ? `meta verify — ${paths.length} generated file(s) carry hand edits, and --forbid-hand-edits is set:`
1724
+ : `meta verify — ${paths.length} generated file(s) carry hand edits ` +
1725
+ `(notice — 'meta gen' preserves them by design; does not fail the build — ` +
1726
+ `pass --forbid-hand-edits to make it):`;
1727
+ return [
1728
+ header,
1729
+ ...paths.map((p) => ` ${p}`),
1730
+ "To see an edit: delete the file and run 'meta gen' (it re-emits the pristine generation), " +
1731
+ "then 'git diff -R -- <file>' shows the edit and 'git checkout -- <file>' restores it" +
1732
+ (forbidden ? "; to drop the edit, commit the regenerated file instead." : "."),
1733
+ ];
1734
+ }
1735
+
1686
1736
  /** The flag that selects each gate, for the "not run" note. `requirements` always runs. */
1687
1737
  const GATE_SELECTOR: Record<string, string> = {
1688
1738
  templates: "--templates",
@@ -1697,14 +1747,21 @@ const GATE_SELECTOR: Record<string, string> = {
1697
1747
  * One line naming every gate this run did NOT run, each with the flag that selects it —
1698
1748
  * undefined when every gate ran. Exported for its test.
1699
1749
  */
1700
- export function notRunNote(gates: readonly VerifyGateRow[]): string | undefined {
1750
+ export function notRunNote(
1751
+ gates: readonly VerifyGateRow[],
1752
+ opts: { bare?: boolean } = {},
1753
+ ): string | undefined {
1701
1754
  const skipped = gates.filter((g) => !g.ran);
1702
1755
  if (skipped.length === 0) return undefined;
1703
1756
  const named = skipped.map((g) => {
1704
1757
  const flag = GATE_SELECTOR[g.gate];
1705
1758
  return flag !== undefined ? `${g.gate} (${flag})` : g.gate;
1706
1759
  });
1707
- return `meta verify — not run: ${named.join(", ")}`;
1760
+ const tail = opts.bare === true
1761
+ ? " — a bare 'meta verify' runs only the template gate (plus the requirement ledger); " +
1762
+ "name each gate to run it"
1763
+ : "";
1764
+ return `meta verify — not run: ${named.join(", ")}${tail}`;
1708
1765
  }
1709
1766
 
1710
1767
  /**
@@ -1759,7 +1816,9 @@ function buildVerifyPayload(input: {
1759
1816
  : `${failed.length} of ${ran.length} gate(s) failed (${failed.map((g) => g.gate).join(", ")})`,
1760
1817
  ];
1761
1818
  if (input.antiPatterns.status === "ran" && input.antiPatterns.total > 0) {
1762
- parts.push(`${input.antiPatterns.total} advisory anti-pattern finding(s)`);
1819
+ // "advisory", not "anti-pattern": the section also carries unindexed foreign keys, a
1820
+ // missing baseUrl and the other non-scanner rows — `help` breaks the count down by kind.
1821
+ parts.push(`${input.antiPatterns.total} advisory finding(s)`);
1763
1822
  }
1764
1823
  if (input.requirements.total > 0) {
1765
1824
  parts.push(`${input.requirements.total} requirement diagnostic(s)`);
@@ -1775,9 +1834,7 @@ function buildVerifyPayload(input: {
1775
1834
  );
1776
1835
  }
1777
1836
  if (input.antiPatterns.total > 0) {
1778
- help.push(
1779
- `${input.antiPatterns.total} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
1780
- );
1837
+ help.push(...advisoryHelpLines(input.antiPatterns.rows));
1781
1838
  }
1782
1839
  if (input.overlays.total > 0) {
1783
1840
  help.push(
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { log } from "./lib/log.js";
3
3
  import { cliVersion } from "./lib/version.js";
4
4
  import { resolveFormat, isValidFormat, VALID_FORMATS, type OutputFormat } from "./lib/format.js";
5
5
  import { resolveCollection } from "@metaobjectsdev/sdk";
6
+ import { GENERATOR_HELP } from "./commands/generator-help.js";
6
7
  export { defineConfig } from "@metaobjectsdev/codegen-ts";
7
8
  export type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
8
9
 
@@ -30,6 +31,8 @@ COMMANDS:
30
31
  init --config-only Write only .metaobjects/config.json — for a Maven- or pip-rooted project
31
32
  agent-docs Scaffold only the agent-context (.metaobjects/ + .claude/skills/) — canonical redirect target for all language ports
32
33
  gen [<entity>...] Codegen TS targets from your declared metadata
34
+ generator new <name> Write a generator of your own into codegen/generators/ and wire it — the
35
+ way to get any output MetaObjects does not ship (OpenAPI, JSON Schema, …)
33
36
  eject <generator> Copy a reference generator into codegen/generators/ to own it (any time after init)
34
37
  eject --list List every ejectable generator name, grouped by package
35
38
  deps sync [<name>...] Resolve declared dependencies (path transport): sync the committed
@@ -158,6 +161,7 @@ See https://metaobjects.com for docs.
158
161
 
159
162
  /** Focused per-subcommand usage slices shown by `<cmd> --help`. */
160
163
  export const COMMAND_HELP: Record<string, string> = {
164
+ generator: GENERATOR_HELP,
161
165
  gen: `meta gen — codegen TS targets from your declared metadata
162
166
 
163
167
  USAGE:
@@ -270,6 +274,10 @@ FLAGS:
270
274
  An <Entity>.names.ts artifact is the exception: it must match a
271
275
  fresh regen exactly — a hand edit in it is itself the defect.
272
276
  Needs metaobjects.config.ts; exit 2 if absent.
277
+ Every generated file that carries a hand edit is still LISTED
278
+ (a notice, exit code unchanged), so an edit is never invisible.
279
+ --forbid-hand-edits With --codegen: fail (exit 1) on ANY hand edit inside generated
280
+ code, for teams that want generated files untouchable.
273
281
  --docs Docs drift — run 'meta docs' to a temp dir and diff docs.outDir
274
282
  Needs metaobjects.config.ts; exit 2 if absent. A page the
275
283
  project GIT-IGNORES is exempt (docs.outDir is a directory, not
@@ -609,6 +617,10 @@ export async function run(argv: string[]): Promise<number> {
609
617
  const { genCommand } = await import("./commands/gen.js");
610
618
  return genCommand(rest, cwd, fmt);
611
619
  }
620
+ case "generator": {
621
+ const { generatorCommand } = await import("./commands/generator.js");
622
+ return generatorCommand(rest, cwd);
623
+ }
612
624
  case "eject": {
613
625
  const { ejectCommand } = await import("./commands/eject.js");
614
626
  return ejectCommand(rest, cwd, fmt);