@metaobjectsdev/cli 1.0.9-rc.4 → 1.0.9-rc.6

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.d.ts.map +1 -1
  7. package/dist/src/commands/gen.js +18 -5
  8. package/dist/src/commands/gen.js.map +1 -1
  9. package/dist/src/commands/generator-help.d.ts +21 -0
  10. package/dist/src/commands/generator-help.d.ts.map +1 -0
  11. package/dist/src/commands/generator-help.js +43 -0
  12. package/dist/src/commands/generator-help.js.map +1 -0
  13. package/dist/src/commands/generator.d.ts +36 -0
  14. package/dist/src/commands/generator.d.ts.map +1 -0
  15. package/dist/src/commands/generator.js +333 -0
  16. package/dist/src/commands/generator.js.map +1 -0
  17. package/dist/src/commands/init.d.ts.map +1 -1
  18. package/dist/src/commands/init.js +10 -1
  19. package/dist/src/commands/init.js.map +1 -1
  20. package/dist/src/commands/verify.d.ts.map +1 -1
  21. package/dist/src/commands/verify.js +5 -3
  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 +8 -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/catalog-listing.d.ts +14 -4
  31. package/dist/src/lib/catalog-listing.d.ts.map +1 -1
  32. package/dist/src/lib/catalog-listing.js +56 -9
  33. package/dist/src/lib/catalog-listing.js.map +1 -1
  34. package/dist/src/lib/install-set.d.ts +12 -2
  35. package/dist/src/lib/install-set.d.ts.map +1 -1
  36. package/dist/src/lib/install-set.js +14 -2
  37. package/dist/src/lib/install-set.js.map +1 -1
  38. package/dist/src/lib/output.d.ts +1 -0
  39. package/dist/src/lib/output.d.ts.map +1 -1
  40. package/dist/src/lib/output.js +2 -0
  41. package/dist/src/lib/output.js.map +1 -1
  42. package/dist/src/lib/runtime-eject.d.ts +80 -0
  43. package/dist/src/lib/runtime-eject.d.ts.map +1 -0
  44. package/dist/src/lib/runtime-eject.js +212 -0
  45. package/dist/src/lib/runtime-eject.js.map +1 -0
  46. package/dist/src/lib/unknown-attr-warnings.d.ts +18 -0
  47. package/dist/src/lib/unknown-attr-warnings.d.ts.map +1 -0
  48. package/dist/src/lib/unknown-attr-warnings.js +43 -0
  49. package/dist/src/lib/unknown-attr-warnings.js.map +1 -0
  50. package/package.json +10 -10
  51. package/src/commands/eject.ts +103 -3
  52. package/src/commands/gen.ts +18 -5
  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 +5 -4
  57. package/src/index.ts +8 -0
  58. package/src/lib/advisory.ts +71 -5
  59. package/src/lib/catalog-listing.ts +68 -7
  60. package/src/lib/install-set.ts +19 -2
  61. package/src/lib/output.ts +3 -0
  62. package/src/lib/runtime-eject.ts +263 -0
  63. package/src/lib/unknown-attr-warnings.ts +48 -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";
@@ -1815,7 +1816,9 @@ function buildVerifyPayload(input: {
1815
1816
  : `${failed.length} of ${ran.length} gate(s) failed (${failed.map((g) => g.gate).join(", ")})`,
1816
1817
  ];
1817
1818
  if (input.antiPatterns.status === "ran" && input.antiPatterns.total > 0) {
1818
- 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)`);
1819
1822
  }
1820
1823
  if (input.requirements.total > 0) {
1821
1824
  parts.push(`${input.requirements.total} requirement diagnostic(s)`);
@@ -1831,9 +1834,7 @@ function buildVerifyPayload(input: {
1831
1834
  );
1832
1835
  }
1833
1836
  if (input.antiPatterns.total > 0) {
1834
- help.push(
1835
- `${input.antiPatterns.total} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
1836
- );
1837
+ help.push(...advisoryHelpLines(input.antiPatterns.rows));
1837
1838
  }
1838
1839
  if (input.overlays.total > 0) {
1839
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:
@@ -613,6 +617,10 @@ export async function run(argv: string[]): Promise<number> {
613
617
  const { genCommand } = await import("./commands/gen.js");
614
618
  return genCommand(rest, cwd, fmt);
615
619
  }
620
+ case "generator": {
621
+ const { generatorCommand } = await import("./commands/generator.js");
622
+ return generatorCommand(rest, cwd);
623
+ }
616
624
  case "eject": {
617
625
  const { ejectCommand } = await import("./commands/eject.js");
618
626
  return ejectCommand(rest, cwd, fmt);
@@ -108,6 +108,72 @@ export function ranSection<Row>(rows: Row[]): AdvisorySection<Row> {
108
108
  return { status: "ran", total: rows.length, rows };
109
109
  }
110
110
 
111
+ // The `rule` of each advisory row that is NOT an anti-pattern-scanner finding. A row whose
112
+ // rule is none of these came from the scanner: an authored site hand-rolling a construct.
113
+ export const RULE_MISSING_BASE_URL = "missing-base-url";
114
+ export const RULE_REMOVED_PROP_PREFIX = "removed-prop:";
115
+ export const RULE_LIBRARY_PREFIX = "library-prefix-unprovenanced";
116
+ export const RULE_UNINDEXED_FK = "unindexed-foreign-key";
117
+ export const RULE_REFERENTIAL_ACTION_CONFLICT = "overridden-referential-action";
118
+
119
+ /** The next-step line for `n` rows of one advisory kind, each naming what the rows are. */
120
+ const ADVISORY_HELP: ReadonlyArray<{ matches: (rule: string) => boolean; line: (n: number) => string }> = [
121
+ {
122
+ matches: (r) => r === RULE_UNINDEXED_FK,
123
+ line: (n) =>
124
+ `${n} foreign key(s) with no covering index — see antiPatterns.rows[] (rule "${RULE_UNINDEXED_FK}"); each names the index.lookup to declare`,
125
+ },
126
+ {
127
+ matches: (r) => r === RULE_REFERENTIAL_ACTION_CONFLICT,
128
+ line: (n) =>
129
+ `${n} foreign key(s) whose two sides disagree on the referential action — see antiPatterns.rows[] (rule "${RULE_REFERENTIAL_ACTION_CONFLICT}")`,
130
+ },
131
+ {
132
+ matches: (r) => r === RULE_MISSING_BASE_URL,
133
+ line: (n) =>
134
+ `${n} entity-fetcher provider(s) mounted with no baseUrl while apiPrefix is set — see antiPatterns.rows[] (rule "${RULE_MISSING_BASE_URL}")`,
135
+ },
136
+ {
137
+ matches: (r) => r.startsWith(RULE_REMOVED_PROP_PREFIX),
138
+ line: (n) =>
139
+ `${n} provider(s) still mounted with a prop 1.0 renamed away — see antiPatterns.rows[] (rule "${RULE_REMOVED_PROP_PREFIX}<prop>")`,
140
+ },
141
+ {
142
+ matches: (r) => r === RULE_LIBRARY_PREFIX,
143
+ line: (n) =>
144
+ `${n} node(s) declared under "metaobjects::" with no ejection provenance — see antiPatterns.rows[] (rule "${RULE_LIBRARY_PREFIX}")`,
145
+ },
146
+ ];
147
+
148
+ /**
149
+ * One next-step line per KIND of advisory row, in a structured payload's `help`.
150
+ *
151
+ * The advisory section carries several kinds of row keyed by `rule`, and the help line used
152
+ * to count them all as "authored site(s) hand-roll what MetaObjects can model" — so a project
153
+ * whose only advisories were unindexed foreign keys was told it hand-rolled three constructs.
154
+ * Scanner findings keep that line; every other kind is counted under its own label.
155
+ */
156
+ export function advisoryHelpLines(rows: readonly AdvisoryFindingRow[]): string[] {
157
+ const counts = ADVISORY_HELP.map(() => 0);
158
+ let handRolled = 0;
159
+ for (const row of rows) {
160
+ const kind = ADVISORY_HELP.findIndex((k) => k.matches(row.rule));
161
+ if (kind === -1) handRolled++;
162
+ else counts[kind] = (counts[kind] ?? 0) + 1;
163
+ }
164
+ const lines: string[] = [];
165
+ if (handRolled > 0) {
166
+ lines.push(
167
+ `${handRolled} authored site(s) hand-roll what MetaObjects can model — see antiPatterns.rows[] and run \`meta types <construct>\``,
168
+ );
169
+ }
170
+ ADVISORY_HELP.forEach((k, i) => {
171
+ const n = counts[i] ?? 0;
172
+ if (n > 0) lines.push(k.line(n));
173
+ });
174
+ return lines;
175
+ }
176
+
111
177
  /** Project the scanner's findings into payload rows. `snippet` is deliberately
112
178
  * dropped: it is a copy of the reader's own source line, and `file`+`line`
113
179
  * already address it. */
@@ -129,7 +195,7 @@ export function missingBaseUrlRows(findings: readonly BaseUrlFinding[]): Advisor
129
195
  return findings.map((f) => ({
130
196
  file: f.file,
131
197
  line: f.line,
132
- rule: "missing-base-url",
198
+ rule: RULE_MISSING_BASE_URL,
133
199
  construct: f.construct,
134
200
  message: f.message,
135
201
  }));
@@ -144,7 +210,7 @@ export function removedPropRows(findings: readonly RemovedPropFinding[]): Adviso
144
210
  return findings.map((f) => ({
145
211
  file: f.file,
146
212
  line: f.line,
147
- rule: `removed-prop:${f.prop}`,
213
+ rule: `${RULE_REMOVED_PROP_PREFIX}${f.prop}`,
148
214
  construct: f.construct,
149
215
  message: f.message,
150
216
  }));
@@ -157,7 +223,7 @@ export function libraryPrefixRows(
157
223
  return findings.map((f) => ({
158
224
  file: f.file,
159
225
  line: 0,
160
- rule: "library-prefix-unprovenanced",
226
+ rule: RULE_LIBRARY_PREFIX,
161
227
  construct: f.fqn,
162
228
  message: f.message,
163
229
  }));
@@ -174,7 +240,7 @@ export function unindexedFkRows(
174
240
  return findings.map((f) => ({
175
241
  file: f.file,
176
242
  line: 0,
177
- rule: "unindexed-foreign-key",
243
+ rule: RULE_UNINDEXED_FK,
178
244
  construct: f.construct,
179
245
  message: f.message,
180
246
  }));
@@ -190,7 +256,7 @@ export function referentialActionConflictRows(
190
256
  return findings.map((f) => ({
191
257
  file: f.file,
192
258
  line: 0,
193
- rule: "overridden-referential-action",
259
+ rule: RULE_REFERENTIAL_ACTION_CONFLICT,
194
260
  construct: f.construct,
195
261
  message: f.message,
196
262
  }));