@metaobjectsdev/cli 0.24.4 → 0.25.0

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 (74) hide show
  1. package/README.md +2 -1
  2. package/dist/src/commands/docs.d.ts +14 -1
  3. package/dist/src/commands/docs.d.ts.map +1 -1
  4. package/dist/src/commands/docs.js +153 -8
  5. package/dist/src/commands/docs.js.map +1 -1
  6. package/dist/src/commands/eject.d.ts +38 -0
  7. package/dist/src/commands/eject.d.ts.map +1 -0
  8. package/dist/src/commands/eject.js +233 -0
  9. package/dist/src/commands/eject.js.map +1 -0
  10. package/dist/src/commands/gen.d.ts.map +1 -1
  11. package/dist/src/commands/gen.js +43 -20
  12. package/dist/src/commands/gen.js.map +1 -1
  13. package/dist/src/commands/init.d.ts +15 -1
  14. package/dist/src/commands/init.d.ts.map +1 -1
  15. package/dist/src/commands/init.js +208 -20
  16. package/dist/src/commands/init.js.map +1 -1
  17. package/dist/src/commands/types.d.ts +2 -1
  18. package/dist/src/commands/types.d.ts.map +1 -1
  19. package/dist/src/commands/types.js +165 -28
  20. package/dist/src/commands/types.js.map +1 -1
  21. package/dist/src/commands/verify.d.ts +9 -1
  22. package/dist/src/commands/verify.d.ts.map +1 -1
  23. package/dist/src/commands/verify.js +299 -51
  24. package/dist/src/commands/verify.js.map +1 -1
  25. package/dist/src/index.d.ts.map +1 -1
  26. package/dist/src/index.js +105 -4
  27. package/dist/src/index.js.map +1 -1
  28. package/dist/src/lib/advisory.d.ts +77 -0
  29. package/dist/src/lib/advisory.d.ts.map +1 -0
  30. package/dist/src/lib/advisory.js +97 -0
  31. package/dist/src/lib/advisory.js.map +1 -0
  32. package/dist/src/lib/anti-patterns.d.ts +27 -3
  33. package/dist/src/lib/anti-patterns.d.ts.map +1 -1
  34. package/dist/src/lib/anti-patterns.js +145 -8
  35. package/dist/src/lib/anti-patterns.js.map +1 -1
  36. package/dist/src/lib/args.d.ts +35 -1
  37. package/dist/src/lib/args.d.ts.map +1 -1
  38. package/dist/src/lib/args.js +32 -1
  39. package/dist/src/lib/args.js.map +1 -1
  40. package/dist/src/lib/detect-stack.d.ts.map +1 -1
  41. package/dist/src/lib/detect-stack.js +4 -13
  42. package/dist/src/lib/detect-stack.js.map +1 -1
  43. package/dist/src/lib/docs-drift.d.ts +31 -0
  44. package/dist/src/lib/docs-drift.d.ts.map +1 -0
  45. package/dist/src/lib/docs-drift.js +195 -0
  46. package/dist/src/lib/docs-drift.js.map +1 -0
  47. package/dist/src/lib/format.d.ts +10 -0
  48. package/dist/src/lib/format.d.ts.map +1 -1
  49. package/dist/src/lib/format.js +15 -0
  50. package/dist/src/lib/format.js.map +1 -1
  51. package/dist/src/lib/output.d.ts +13 -0
  52. package/dist/src/lib/output.d.ts.map +1 -1
  53. package/dist/src/lib/output.js +16 -1
  54. package/dist/src/lib/output.js.map +1 -1
  55. package/dist/src/lib/package-manifest.d.ts +27 -0
  56. package/dist/src/lib/package-manifest.d.ts.map +1 -0
  57. package/dist/src/lib/package-manifest.js +52 -0
  58. package/dist/src/lib/package-manifest.js.map +1 -0
  59. package/package.json +11 -11
  60. package/src/commands/docs.ts +194 -8
  61. package/src/commands/eject.ts +282 -0
  62. package/src/commands/gen.ts +54 -19
  63. package/src/commands/init.ts +217 -19
  64. package/src/commands/types.ts +185 -34
  65. package/src/commands/verify.ts +358 -47
  66. package/src/index.ts +114 -5
  67. package/src/lib/advisory.ts +150 -0
  68. package/src/lib/anti-patterns.ts +163 -8
  69. package/src/lib/args.ts +74 -2
  70. package/src/lib/detect-stack.ts +4 -11
  71. package/src/lib/docs-drift.ts +222 -0
  72. package/src/lib/format.ts +14 -0
  73. package/src/lib/output.ts +33 -2
  74. package/src/lib/package-manifest.ts +58 -0
@@ -22,6 +22,7 @@ import {
22
22
  makeRenderContext,
23
23
  buildPkMap,
24
24
  buildRelationMap,
25
+ buildProjectionViews,
25
26
  resolveDocsConfig,
26
27
  apiLabel,
27
28
  } from "@metaobjectsdev/codegen-ts";
@@ -30,11 +31,28 @@ import type {
30
31
  EmittedFile,
31
32
  ResolvedDocsConfig,
32
33
  DocsSurface,
34
+ Dialect,
35
+ ColumnNamingStrategy,
33
36
  } from "@metaobjectsdev/codegen-ts";
34
- import { docsFile, apiDocsFile, requirementsFile } from "@metaobjectsdev/codegen-ts/generators";
35
- import { composeRegistry, coreProviders, renderCoreMetamodelDocs } from "@metaobjectsdev/metadata";
36
- import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata";
37
+ import { docsFile, apiDocsFile, requirementsFile, agentDocsFile } from "@metaobjectsdev/codegen-ts/generators";
38
+ import {
39
+ composeRegistry,
40
+ coreProviders,
41
+ DEFAULT_COLUMN_NAMING_STRATEGY,
42
+ renderCoreMetamodelDocs,
43
+ } from "@metaobjectsdev/metadata";
44
+ import { dbEmittingObjects, DEFAULT_DIALECT, missingDialectMessage } from "@metaobjectsdev/codegen-ts";
45
+ import type { MetaDataTypeProvider, MetaRoot } from "@metaobjectsdev/metadata";
37
46
  import { generateSite, SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "@metaobjectsdev/docs-site";
47
+ // The `agent` schema surface takes the physical schema as an ARGUMENT with its resolvers
48
+ // injected — codegen-ts deliberately owns none of it (see agent-schema-input.ts). THIS is
49
+ // where the two packages meet: `meta docs` already depends on both.
50
+ import {
51
+ buildExpectedSchemaWithProvenance,
52
+ columnTypeSql,
53
+ qualifiedDbName,
54
+ } from "@metaobjectsdev/migrate-ts";
55
+ import type { AgentSchemaInput, SchemaColumnLike } from "@metaobjectsdev/codegen-ts";
38
56
 
39
57
  type DocsLayout = "flat" | "package";
40
58
 
@@ -106,6 +124,7 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
106
124
  let wantModel = false;
107
125
  let wantApi = false;
108
126
  let wantRequirements = false;
127
+ let wantAgent = false;
109
128
  let wantMetamodel = false;
110
129
  let wantSite = false;
111
130
  let wantScaffoldSite = false;
@@ -133,6 +152,8 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
133
152
  wantApi = true;
134
153
  } else if (a === "--requirements") {
135
154
  wantRequirements = true;
155
+ } else if (a === "--agent") {
156
+ wantAgent = true;
136
157
  } else if (a === "--metamodel") {
137
158
  wantMetamodel = true;
138
159
  } else if (a === "--site") {
@@ -175,6 +196,7 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
175
196
  if (wantModel) surfaces.push("model");
176
197
  if (wantApi) surfaces.push("api");
177
198
  if (wantRequirements) surfaces.push("requirements");
199
+ if (wantAgent) surfaces.push("agent");
178
200
  return {
179
201
  // `<project-root>` is the project root that CONTAINS the metadata; default
180
202
  // cwd (mirrors how migrate/gen treat the working directory as the root).
@@ -197,7 +219,99 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
197
219
  };
198
220
  }
199
221
 
200
- export async function docsCommand(args: string[], cwd: string): Promise<number> {
222
+ /**
223
+ * How a caller other than the CLI wants `meta docs` to behave.
224
+ *
225
+ * `verify --docs` runs this exact command into a temp directory and diffs the result, so
226
+ * that the gate and the door can never be two implementations of "what the docs are". The
227
+ * only thing it needs differently is silence: a verify run announcing "meta docs — wrote 8
228
+ * entity pages" in the middle of its own report describes work the user is not getting.
229
+ */
230
+ export interface DocsCommandOptions {
231
+ /** Suppress the informational output. Warnings and errors still print — a docs run that
232
+ * degraded is something a verify caller must see, not something to swallow. */
233
+ silent?: boolean;
234
+ }
235
+
236
+ /**
237
+ * The physical schema `agent/schema.md` renders from, built by the package that OWNS it
238
+ * (`migrate-ts`) with its own resolvers handed across — see codegen-ts's
239
+ * agent-schema-input.ts for why the docs generator refuses to compute any of this itself.
240
+ *
241
+ * Returns undefined, after warning, when the page is to be SKIPPED — and a skipped page is
242
+ * not a silent pass: `verify --docs` convicts a committed `agent/<page>.md` that a fresh
243
+ * run no longer emits (see lib/docs-drift.ts), so a page describing the previous schema
244
+ * fails the gate rather than surviving it on exactly the change it most needs to flag.
245
+ */
246
+ function buildAgentSchemaInput(
247
+ root: MetaRoot,
248
+ configured: Dialect | undefined,
249
+ strategy: ColumnNamingStrategy,
250
+ ): AgentSchemaInput | undefined {
251
+ // THE RUNNER'S OWN GUARD DECIDES THIS, not a default applied here.
252
+ //
253
+ // `DEFAULT_DIALECT` is INERT: `runGen` throws when a model emits database code and the
254
+ // config declares no dialect, and it throws BEFORE `normalizeConfig` fills that default
255
+ // in, precisely so a DB project that forgot one gets a named error instead of
256
+ // "a Postgres project quietly emitting sqlite". So a persisted model with no dialect is
257
+ // not a sqlite project — it is a project `meta gen` REFUSES. Documenting it as sqlite
258
+ // would state an answer the toolchain never gave, about a schema it will not build.
259
+ //
260
+ // Two wrong answers were tried here before this one, and both were ASSERTIONS. First a
261
+ // hardcoded `?? "sqlite"`; then a skip whenever `dialect` was absent, on the theory that
262
+ // an undeclared dialect is an unknown one. The compute answer is to ask the predicate
263
+ // `runGen` asks — `dbEmittingObjects` — and skip only when that guard would fire. A model
264
+ // with no persisted object needs no dialect and renders an empty schema page anyway, so
265
+ // the inert default is correct for exactly the projects the guard lets through.
266
+ const dbEmitting = dbEmittingObjects(root.objects());
267
+ if (dbEmitting.length > 0 && configured === undefined) {
268
+ log.warn(
269
+ `docs: agent/schema.md skipped — ${missingDialectMessage(dbEmitting)} ` +
270
+ `('meta gen' refuses this model for the same reason.)`,
271
+ );
272
+ return undefined;
273
+ }
274
+ const dialect = configured ?? DEFAULT_DIALECT;
275
+ try {
276
+ const built = buildExpectedSchemaWithProvenance(root, {
277
+ dialect,
278
+ columnNamingStrategy: strategy,
279
+ // Views come from codegen-ts (migrate-ts never generates view DDL), exactly as
280
+ // `verify --db` threads them.
281
+ views: buildProjectionViews(root, { dialect, columnNamingStrategy: strategy }),
282
+ });
283
+ return {
284
+ dialect,
285
+ tables: built.snapshot.tables,
286
+ views: built.snapshot.views,
287
+ provenance: built.provenance,
288
+ // The structural `SchemaColumnLike` is migrate-ts's own ColumnDescriptor,
289
+ // narrowed to what the page reads; the cast hands the full descriptor back to
290
+ // the renderer that produced it.
291
+ columnType: (c: SchemaColumnLike) => columnTypeSql(c as never, dialect),
292
+ qualify: qualifiedDbName,
293
+ };
294
+ } catch (err) {
295
+ // A model the schema builder refuses is a real condition (a primary-key move, a
296
+ // duplicate physical name) that `meta migrate` will report properly. Docs must not
297
+ // be the command that fails on it, so the schema page is skipped and the other two
298
+ // agent pages still emit.
299
+ log.warn(
300
+ `docs: agent/schema.md skipped — the expected schema could not be built ` +
301
+ `(${(err as Error).message}). Run 'meta migrate' for the full diagnosis.`,
302
+ );
303
+ return undefined;
304
+ }
305
+ }
306
+
307
+ export async function docsCommand(
308
+ args: string[],
309
+ cwd: string,
310
+ opts?: DocsCommandOptions,
311
+ ): Promise<number> {
312
+ // Informational output only. `log.warn` / `log.error` are deliberately NOT routed
313
+ // through this: a skipped surface or a failed render is a finding either way.
314
+ const info = opts?.silent === true ? (_m: string): void => {} : log.info;
201
315
  let flags: DocsFlags;
202
316
  try {
203
317
  flags = parseDocsArgs(args, cwd);
@@ -211,6 +325,33 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
211
325
  // NEITHER a user's metadata NOR a config — there is nothing to load. It writes
212
326
  // the renderer's files under <out>/metamodel/ (default ./docs/metamodel).
213
327
  if (flags.metamodel) {
328
+ // `--site` builds HTML from a MODEL — docs-site's own loader and templates over the
329
+ // user's metadata. The metamodel surface is a different renderer entirely, over the
330
+ // registry, and it emits markdown; there is no renderer here to bridge them. The flag
331
+ // used to be parsed, accepted and then dropped by this very return: the command wrote
332
+ // sixteen markdown files, printed a success line and exited 0, so asking for a site
333
+ // produced no site and no complaint.
334
+ //
335
+ // It refuses rather than growing an HTML renderer, which would put a markdown-
336
+ // rendering dependency into a published package for one surface. The website renders
337
+ // it instead, keeping that dependency dev-only and giving the pages the metaobjects.dev
338
+ // look rather than the docs-site adopter theme.
339
+ // `--scaffold-site` is here for the same reason and was missed the first time: it is
340
+ // the OTHER flag that asks for files to be written, and this same early return dropped
341
+ // it identically — `--metamodel --scaffold-site` wrote 16 markdown pages, scaffolded no
342
+ // theme anywhere, and exited 0. Fixing one of a matched pair leaves the defect wearing
343
+ // a different flag name.
344
+ const dropped = [
345
+ ...(flags.site ? ["--site"] : []),
346
+ ...(flags.scaffoldSite ? ["--scaffold-site"] : []),
347
+ ];
348
+ if (dropped.length > 0) {
349
+ log.error(
350
+ `docs: ${dropped.join(" and ")} ${dropped.length > 1 ? "are" : "is"} not supported ` +
351
+ "with --metamodel. The metamodel reference is markdown; the rendered form is " +
352
+ "published at https://metaobjects.dev/reference");
353
+ return 2;
354
+ }
214
355
  return metamodelDocsCommand(cwd, flags.out);
215
356
  }
216
357
 
@@ -376,6 +517,13 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
376
517
  loadedRoot: root,
377
518
  outDir,
378
519
  dbImport: "",
520
+ // The project's own prefix, not the "" default. `agent/ui.md` documents an ENDPOINT,
521
+ // and the routes generator mounts every one of them inside
522
+ // `fastify.register(…, { prefix: apiPrefix })` — so a project configuring "/api" is
523
+ // served at /api/authors and was being told, by the page whose whole job is to be
524
+ // right about addresses, that it was /authors. `meta gen` threads this (runner.ts);
525
+ // `meta docs` is the OTHER door onto the same page and did not.
526
+ apiPrefix: loadedConfig?.apiPrefix ?? "",
379
527
  pkMap: buildPkMap(root),
380
528
  relationMap: buildRelationMap(root),
381
529
  });
@@ -498,16 +646,46 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
498
646
  // Surfaces owned by other ports: link only, with a pointer to where they
499
647
  // get produced.
500
648
  for (const s of labeled.filter((s) => s.lang !== "ts")) {
501
- log.info(
649
+ info(
502
650
  `meta docs: api surface '${s.lang}' (${s.subDir}) is produced by that port's docs command — run it to populate those pages.`,
503
651
  );
504
652
  }
505
653
  } else if (hasConfig) {
506
654
  // Config present but failed to load — already warned above; don't claim an
507
655
  // api surface we couldn't build.
508
- log.info("meta docs: api surface skipped — metaobjects.config.ts failed to load.");
656
+ info("meta docs: api surface skipped — metaobjects.config.ts failed to load.");
657
+ } else {
658
+ info("meta docs: api surface skipped — no metaobjects.config.ts (nothing generated to document).");
659
+ }
660
+ }
661
+
662
+ // AGENT surface — three pages an agent reads BEFORE touching a tier (`agent/schema.md`
663
+ // before persistence, `agent/ui.md` before a form or grid, `agent/requirements.md`
664
+ // before adding a capability). The fourth file the always-on pointer names,
665
+ // `api/AGENT-API.md`, belongs to the api surface above.
666
+ //
667
+ // Gated on a loadable gen config exactly as `api` is, and for a stronger reason: the
668
+ // physical schema depends on the project's DIALECT and column-naming strategy, and the
669
+ // neutral model surface above runs on a placeholder because it documents no SQL at all.
670
+ // The dialect here is the project's own, resolved by the same default `meta gen` and
671
+ // `meta migrate` apply — see `buildAgentSchemaInput`.
672
+ if (docsCfg.surfaces.includes("agent")) {
673
+ if (loadedConfig !== undefined) {
674
+ const strategy = loadedConfig.columnNamingStrategy ?? DEFAULT_COLUMN_NAMING_STRATEGY;
675
+ const schema = buildAgentSchemaInput(root, loadedConfig.dialect, strategy);
676
+ emit.push(
677
+ ...(await agentDocsFile({
678
+ ...(schema !== undefined && { schema }),
679
+ columnNamingStrategy: strategy,
680
+ }).generate(ctx)),
681
+ );
682
+ } else if (hasConfig) {
683
+ info("meta docs: agent surface skipped — metaobjects.config.ts failed to load.");
509
684
  } else {
510
- log.info("meta docs: api surface skipped — no metaobjects.config.ts (nothing generated to document).");
685
+ info(
686
+ "meta docs: agent surface skipped — no metaobjects.config.ts (the physical schema " +
687
+ "and the generated UI are what it describes).",
688
+ );
511
689
  }
512
690
  }
513
691
 
@@ -548,7 +726,15 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
548
726
  // did not run — the opposite of the silence the empty-ledger guard exists to produce.
549
727
  const requirementFiles = emit.filter((f) => f.path.startsWith("requirements.")).length;
550
728
  const reqSummary = requirementFiles > 0 ? `; ${requirementFiles} requirement file(s)` : "";
551
- log.info(`meta docs wrote ${modelSummary}; ${apiSummary}${reqSummary} ${outDir}`);
729
+ // Same rule as the requirements line: NAMED rather than counted, and only when written.
730
+ // Each agent page is skipped when its tier has nothing to describe, so "3 agent pages"
731
+ // would leave a reader unable to tell which three — and the pages are the thing the
732
+ // always-on agent context points at by name.
733
+ const agentPages = emit
734
+ .filter((f) => f.path.startsWith("agent/"))
735
+ .map((f) => basename(f.path));
736
+ const agentSummary = agentPages.length > 0 ? `; agent/${agentPages.sort().join(" + agent/")}` : "";
737
+ info(`meta docs — wrote ${modelSummary}; ${apiSummary}${reqSummary}${agentSummary} → ${outDir}`);
552
738
  return 0;
553
739
  }
554
740
 
@@ -0,0 +1,282 @@
1
+ // FR-040 §4.2(a) — `meta eject <generator>` takes ownership of any reference-template
2
+ // generator, in any package, at any time after `meta init`. ADR-0034 scaffold-and-own
3
+ // has `init` copy five of them eagerly (entity, queries, routes, barrel, names); this is the
4
+ // SAME copy operation, generalised to every ejectable name and callable on demand — for
5
+ // a generator you skipped at init time, or one a package gained since.
6
+ import { mkdir, writeFile, stat, readFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { cliVersion } from "../lib/version.js";
9
+ import * as coreTpl from "@metaobjectsdev/codegen-ts";
10
+ import * as reactTpl from "@metaobjectsdev/codegen-ts-react";
11
+ import * as tanstackTpl from "@metaobjectsdev/codegen-ts-tanstack";
12
+ import { parseEjectArgs } from "../lib/args.js";
13
+ import { log } from "../lib/log.js";
14
+ import { declaredDependencyNames, readPackageManifest } from "../lib/package-manifest.js";
15
+
16
+ // Mirrors `OWNED_GENERATORS_DIR` in init.ts's `writeOwnedGenerators` — same directory,
17
+ // same never-clobber-without-consent contract. Kept as its own local constant rather
18
+ // than shared: eject is a standalone operation on ANY name, not a byproduct of init,
19
+ // and the two call sites have no other state in common worth coupling over one string.
20
+ const OWNED_GENERATORS_DIR = "codegen/generators";
21
+
22
+ interface TemplateSource {
23
+ packageName: string;
24
+ names: readonly string[];
25
+ /** That package's own `src/reference/` directory. */
26
+ root: () => string;
27
+ }
28
+
29
+ // One registry, three packages — a package that gains templates later registers itself
30
+ // here and `meta eject` picks it up with no other change.
31
+ //
32
+ // Each entry exposes its reference ROOT rather than a read function. The packages' own
33
+ // `readReferenceTemplate` narrows its parameter to a literal union, so calling it with
34
+ // a CLI-supplied `string` used to need a generic `asserts name is N` helper — ~20 lines
35
+ // to re-establish, for the compiler, a fact `resolveSource` has ALREADY established at
36
+ // runtime by selecting this entry via `names.includes(name)`. Reading from the root
37
+ // deletes that machinery without weakening anything: membership is still checked, once,
38
+ // where the untrusted value enters.
39
+ const SOURCES: TemplateSource[] = [
40
+ {
41
+ packageName: "@metaobjectsdev/codegen-ts",
42
+ names: coreTpl.REFERENCE_GENERATOR_NAMES,
43
+ root: coreTpl.resolveReferenceRoot,
44
+ },
45
+ {
46
+ packageName: "@metaobjectsdev/codegen-ts-react",
47
+ names: reactTpl.REFERENCE_GENERATOR_NAMES,
48
+ root: reactTpl.resolveReferenceRoot,
49
+ },
50
+ {
51
+ packageName: "@metaobjectsdev/codegen-ts-tanstack",
52
+ names: tanstackTpl.REFERENCE_GENERATOR_NAMES,
53
+ root: tanstackTpl.resolveReferenceRoot,
54
+ },
55
+ ];
56
+
57
+ function resolveSource(name: string): TemplateSource | undefined {
58
+ return SOURCES.find((s) => s.names.includes(name));
59
+ }
60
+
61
+ /** Every ejectable name, in registry order (stable — matches `meta eject --list`). */
62
+ export function ejectableNames(): string[] {
63
+ return SOURCES.flatMap((s) => s.names);
64
+ }
65
+
66
+ // Every reference template's header documents its own paste-ready import line, e.g.
67
+ // codegen-ts/src/reference/entity.ts:
68
+ // // Then import it LOCALLY in metaobjects.config.ts:
69
+ // // import { entityFile } from "./codegen/generators/entity.js";
70
+ // Extracting it here — rather than re-deriving an export symbol from the file name —
71
+ // means eject can never drift from what the template itself already tells a human to
72
+ // paste, and needs no per-name export-symbol map: a generator's exported symbol does
73
+ // NOT follow its file name (`hooks.ts` exports `tanstackQuery`, `routes-hono.ts`
74
+ // exports `routesFileHono`, `grid.ts` exports `tanstackGrid`).
75
+ const HEADER_IMPORT_RE = /^\/\/\s+(import \{ \w+ \} from "\.\/codegen\/generators\/[\w.-]+\.js";)\s*$/m;
76
+
77
+ /** The bound symbol out of the already-validated import line — same single source of
78
+ * truth as the line itself, so the "replace this binding" message can never name a
79
+ * symbol the template does not actually export. */
80
+ function extractExportName(importLine: string, name: string): string {
81
+ const match = /^import \{ (\w+) \}/.exec(importLine);
82
+ if (!match?.[1]) {
83
+ throw new Error(`reference template "${name}" has an unparseable import line: ${importLine}`);
84
+ }
85
+ return match[1];
86
+ }
87
+
88
+ function extractImportLine(templateSource: string, name: string): string {
89
+ const match = HEADER_IMPORT_RE.exec(templateSource);
90
+ if (!match?.[1]) {
91
+ throw new Error(
92
+ `reference template "${name}" has no documented "// import { ... }" header line — ` +
93
+ "cannot report the import line to paste.",
94
+ );
95
+ }
96
+ return match[1];
97
+ }
98
+
99
+ async function fileExists(p: string): Promise<boolean> {
100
+ try {
101
+ return (await stat(p)).isFile();
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ export interface EjectOptions {
108
+ cwd: string;
109
+ name: string;
110
+ /** Overwrite an already-ejected file. Without it, eject NEVER clobbers — matching
111
+ * `writeOwnedGenerators`'s unconditional preserve-if-present rule in init.ts. */
112
+ force?: boolean;
113
+ }
114
+
115
+ export interface EjectResult {
116
+ path: string;
117
+ importLine: string;
118
+ /** The symbol the template exports — the binding to REPLACE in the config. */
119
+ exportName: string;
120
+ /** The package the generator currently comes from, i.e. the import to remove. */
121
+ packageName: string;
122
+ /** Advisory lines about packages the ejected file imports but the project lacks. */
123
+ dependencyNotes: string[];
124
+ status: "created" | "preserved";
125
+ }
126
+
127
+ /** The `@metaobjectsdev/*` packages an ejected template imports, read from the file
128
+ * itself rather than from a per-name table — the template is the only thing that
129
+ * knows, and a table would drift from it the moment a template gains an import. */
130
+ function requiredPackages(templateSource: string): string[] {
131
+ const found = new Set<string>();
132
+ // The optional trailing group matches a SUBPATH and is deliberately not captured: what
133
+ // has to be installed is the package, and `@metaobjectsdev/metadata/constants` is a
134
+ // real, documented subpath this codebase already uses (the browser-safe pure-constants
135
+ // entry). Requiring the closing quote straight after the package name — as this did —
136
+ // made any such import match nothing at all, so a template gaining one would get no
137
+ // note. That is the drift this function exists to prevent, reappearing inside the
138
+ // function itself.
139
+ for (const m of templateSource.matchAll(/from\s+"(@metaobjectsdev\/[\w-]+)(?:\/[\w./-]+)?"/g)) {
140
+ if (m[1] !== undefined) found.add(m[1]);
141
+ }
142
+ return [...found].sort();
143
+ }
144
+
145
+ /**
146
+ * An ejected file is ordinary source in the adopter's repo, so its imports must be
147
+ * declared dependencies or their `tsc` reports TS2307 on the file we just told them
148
+ * they own — and under a strict (pnpm/npm) node_modules layout `meta gen` cannot
149
+ * resolve it either. `meta init` already calls this out by ADDING the two packages its
150
+ * five scaffolded generators need; the on-demand templates import two more
151
+ * (codegen-ts-react, codegen-ts-tanstack) that nothing declares.
152
+ *
153
+ * This REPORTS rather than edits: init is a scaffolder writing a whole project and has
154
+ * a manifest in hand, while eject copies one file into a repo whose dependency policy
155
+ * (workspace protocol, catalog, pinned ranges) is the adopter's. Naming the exact
156
+ * missing package and the version to match is the useful half; silently rewriting
157
+ * someone's manifest is not.
158
+ */
159
+ export async function dependencyNotesForTemplate(cwd: string, templateSource: string): Promise<string[]> {
160
+ const required = requiredPackages(templateSource);
161
+ if (required.length === 0) return [];
162
+
163
+ const pkg = readPackageManifest(cwd);
164
+ if (pkg === undefined) {
165
+ // No readable manifest — say what the file needs and let the adopter place it.
166
+ return [`This file imports: ${required.join(", ")}. Make sure each is installed.`];
167
+ }
168
+ const declared = declaredDependencyNames(pkg);
169
+
170
+ const missing = required.filter((p) => !declared.has(p));
171
+ if (missing.length === 0) return [];
172
+ return [
173
+ `The ejected file imports ${missing.join(", ")}, which your package.json does not ` +
174
+ "declare — your typecheck will report TS2307 until it does. Install with:",
175
+ ` npm i -D ${missing.map((p) => `${p}@^${cliVersion()}`).join(" ")}`,
176
+ ];
177
+ }
178
+
179
+ export async function ejectGenerator(opts: EjectOptions): Promise<EjectResult> {
180
+ const source = resolveSource(opts.name);
181
+ if (source === undefined) {
182
+ throw new Error(
183
+ `unknown generator "${opts.name}". Ejectable generators: ${ejectableNames().join(", ")}. ` +
184
+ "Run `meta eject --list` to see them grouped by package.",
185
+ );
186
+ }
187
+
188
+ const templateSource = await readFile(join(source.root(), `${opts.name}.ts`), "utf8");
189
+ const importLine = extractImportLine(templateSource, opts.name);
190
+ const exportName = extractExportName(importLine, opts.name);
191
+ const rel = `${OWNED_GENERATORS_DIR}/${opts.name}.ts`;
192
+ const abs = join(opts.cwd, rel);
193
+ const notes = await dependencyNotesForTemplate(opts.cwd, templateSource);
194
+ const common = {
195
+ path: rel,
196
+ importLine,
197
+ exportName,
198
+ packageName: source.packageName,
199
+ dependencyNotes: notes,
200
+ };
201
+
202
+ if (!opts.force && (await fileExists(abs))) {
203
+ return { ...common, status: "preserved" };
204
+ }
205
+
206
+ await mkdir(join(opts.cwd, OWNED_GENERATORS_DIR), { recursive: true });
207
+ await writeFile(abs, templateSource, "utf8");
208
+ return { ...common, status: "created" };
209
+ }
210
+
211
+ function listOutput(): string {
212
+ const lines: string[] = [];
213
+ lines.push("Ejectable generators (copy any of these into codegen/generators/ and own it):");
214
+ lines.push("");
215
+ for (const source of SOURCES) {
216
+ lines.push(`${source.packageName}:`);
217
+ lines.push(` ${source.names.join(", ")}`);
218
+ lines.push("");
219
+ }
220
+ lines.push("Run: meta eject <name>");
221
+ return lines.join("\n");
222
+ }
223
+
224
+ export async function ejectCommand(args: string[], cwd: string): Promise<number> {
225
+ let flags;
226
+ try {
227
+ flags = parseEjectArgs(args);
228
+ } catch (err) {
229
+ log.error((err as Error).message);
230
+ return 2;
231
+ }
232
+
233
+ if (flags.list) {
234
+ log.info(listOutput());
235
+ return 0;
236
+ }
237
+
238
+ if (flags.name === undefined) {
239
+ log.error("meta eject requires a generator name, or --list to see what's ejectable.");
240
+ return 2;
241
+ }
242
+
243
+ try {
244
+ const result = await ejectGenerator({ cwd, name: flags.name, force: flags.force });
245
+ if (result.status === "preserved") {
246
+ log.info(`${result.path} already exists — left untouched (pass --force to overwrite).`);
247
+ } else {
248
+ log.info(`Ejected "${flags.name}" -> ${result.path}. You own it now (ADR-0034 scaffold-and-own).`);
249
+ }
250
+ // REPLACE, never "paste". A generator reaches `generators: [...]` under ONE binding,
251
+ // so a reader told to "paste" gets a duplicate identifier at best — and at worst
252
+ // deletes nothing, keeps `formFile()` in the array bound to the PACKAGE import, and
253
+ // silently runs the packaged generator while editing the ejected file. That failure
254
+ // is invisible and is the exact one ejecting exists to prevent.
255
+ //
256
+ // But eject reads no config, so it cannot know WHICH of the three states this project
257
+ // is in, and stating one of them as fact is wrong in the other two — including for the
258
+ // five `meta init` scaffolds, whose config already imports from ./codegen/generators/,
259
+ // which is precisely the `meta eject <name> --force` re-sync case. Name the goal, then
260
+ // the three branches; the reader knows which one they are looking at.
261
+ log.info(`In metaobjects.config.ts, "${result.exportName}" must resolve to this file:`);
262
+ log.info(` ${result.importLine}`);
263
+ log.info(
264
+ ` - If it is imported from "${result.packageName}", REPLACE that import with the ` +
265
+ "line above. Adding a second one leaves `generators` bound to the PACKAGED " +
266
+ "generator, and your edits to this file do nothing.",
267
+ );
268
+ log.info(
269
+ " - If it is already imported from ./codegen/generators/ (what `meta init` " +
270
+ "scaffolds), it points here already — nothing to change.",
271
+ );
272
+ log.info(
273
+ ` - If ${result.exportName}() is not in \`generators\` yet, add the import above ` +
274
+ "AND the entry.",
275
+ );
276
+ for (const line of result.dependencyNotes) log.info(line);
277
+ return 0;
278
+ } catch (err) {
279
+ log.error((err as Error).message);
280
+ return 1;
281
+ }
282
+ }
@@ -9,6 +9,10 @@ import { log } from "../lib/log.js";
9
9
  import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
10
10
  import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js";
11
11
  import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js";
12
+ import {
13
+ antiPatternRows, ranSection, skippedSection, warnCapped,
14
+ type AdvisoryFindingRow, type AdvisorySection,
15
+ } from "../lib/advisory.js";
12
16
  import { loadMemory, resolveCollection } from "@metaobjectsdev/sdk";
13
17
  import { runGen, listGenerators } from "@metaobjectsdev/codegen-ts";
14
18
  import type { WriteStatus } from "@metaobjectsdev/codegen-ts";
@@ -149,12 +153,23 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
149
153
  (forgeConfig.targets ? Object.values(forgeConfig.targets).map((t) => t.outDir) : [])
150
154
  .concat([forgeConfig.outDir]),
151
155
  ));
156
+
157
+ // The advisory verify-as-teacher pass now runs BEFORE the result is rendered,
158
+ // because its findings ride IN the result. It used to run after the payload was
159
+ // printed and write only to stderr as text, so `meta gen --format json` on a run
160
+ // with hundreds of findings emitted a document that mentioned none of them — the
161
+ // structured output being the documented default for an agent on a pipe.
162
+ // Warnings ONLY — nothing here reaches the exit code (bias to under-flagging).
163
+ const antiPatterns = runAntiPatternScan(
164
+ projectRoot, cliConfig.dryRun, flags.noAntipatterns, forgeConfig.verify?.antiPatternIgnore);
165
+
152
166
  const genResult = {
153
167
  files,
154
168
  outDir: targetDirs.length > 1 ? targetDirs.join(", ") : forgeConfig.outDir,
155
169
  dialect: forgeConfig.dialect,
156
170
  dryRun: cliConfig.dryRun,
157
171
  warnings: [],
172
+ antiPatterns,
158
173
  };
159
174
  const output =
160
175
  fmt === "toon" ? formatGenResultToon(genResult)
@@ -175,31 +190,51 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
175
190
  );
176
191
  }
177
192
 
178
- // Advisory verify-as-teacher pass (same as `meta verify`): on a real write run,
179
- // surface authored source that hand-rolls what the metadata could model. `gen`
180
- // is the command an agent always runs, so this is where the teaching actually
181
- // reaches it. Warnings ONLY — never affects the exit code. Suppress with
182
- // --no-antipatterns or META_NO_ANTIPATTERNS=1 (both opt-outs work on `meta gen`
183
- // and `meta verify`).
184
- if (!cliConfig.dryRun && !flags.noAntipatterns && process.env.META_NO_ANTIPATTERNS !== "1") {
185
- try {
186
- const findings = scanSourceForAntiPatterns(projectRoot);
187
- if (findings.length > 0) {
188
- const CAP = 10;
189
- log.warn(
190
- `\nmeta gen — ${findings.length} place(s) hand-roll what MetaObjects can model ` +
191
- `(advisory — declaring the construct lets codegen own it):`,
192
- );
193
- for (const f of findings.slice(0, CAP)) log.warn(` ${f.message}`);
194
- if (findings.length > CAP) log.warn(` …and ${findings.length - CAP} more.`);
195
- }
196
- } catch { /* never let an advisory scan break gen */ }
193
+ // The human-readable half of the advisory pass, printed after the file listing
194
+ // where a reader expects it. Capped for a terminal; the structured payload above
195
+ // already carried every finding, and the tail line says so.
196
+ if (antiPatterns.total > 0) {
197
+ log.warn(
198
+ `\nmeta gen — ${antiPatterns.total} place(s) hand-roll what MetaObjects can model ` +
199
+ `(advisory declaring the construct lets codegen own it):`,
200
+ );
201
+ warnCapped(antiPatterns.rows.map((r) => ` ${r.message}`), flags.limit, { structured: fmt !== "text" });
197
202
  }
198
203
 
199
204
  const hasFailure = files.some((f) => f.status === "conflict" || f.status === "refused");
200
205
  return hasFailure ? 1 : 0;
201
206
  }
202
207
 
208
+ /**
209
+ * Run the advisory verify-as-teacher scan (same pass `meta verify` runs): surface
210
+ * authored source that hand-rolls what the metadata could model. `gen` is the
211
+ * command an agent always runs, so this is where the teaching actually reaches it.
212
+ *
213
+ * Returns the section EITHER WAY — a skip is reported with its reason rather than
214
+ * dropped, so a reader can tell "found nothing" from "never looked". Warnings only;
215
+ * it can never affect the exit code. Suppress with --no-antipatterns or
216
+ * META_NO_ANTIPATTERNS=1 (both opt-outs work on `meta gen` and `meta verify`).
217
+ */
218
+ function runAntiPatternScan(
219
+ projectRoot: string,
220
+ dryRun: boolean,
221
+ noAntipatterns: boolean,
222
+ ignore: readonly string[] | undefined,
223
+ ): AdvisorySection<AdvisoryFindingRow> {
224
+ // A --dry-run writes nothing, so it teaches nothing; the scan is skipped, as it
225
+ // always has been. The payload now says that instead of looking clean.
226
+ if (dryRun) return skippedSection("skipped on --dry-run (the advisory pass runs on a real write run)");
227
+ if (noAntipatterns) return skippedSection("suppressed by --no-antipatterns");
228
+ if (process.env.META_NO_ANTIPATTERNS === "1") return skippedSection("suppressed by META_NO_ANTIPATTERNS=1");
229
+ try {
230
+ return ranSection(antiPatternRows(scanSourceForAntiPatterns(
231
+ projectRoot, ignore !== undefined ? { ignore } : undefined)));
232
+ } catch (err) {
233
+ // Never let an advisory scan break gen — but never claim it found nothing either.
234
+ return skippedSection(`the scan failed: ${(err as Error).message}`);
235
+ }
236
+ }
237
+
203
238
  /**
204
239
  * `meta gen --list` — print the stable-name generator registry (ADR-0021 D3).
205
240
  *