@metaobjectsdev/cli 0.24.5 → 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 (64) 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 +1 -1
  7. package/dist/src/commands/eject.js +3 -3
  8. package/dist/src/commands/eject.js.map +1 -1
  9. package/dist/src/commands/gen.d.ts.map +1 -1
  10. package/dist/src/commands/gen.js +43 -20
  11. package/dist/src/commands/gen.js.map +1 -1
  12. package/dist/src/commands/init.d.ts +4 -0
  13. package/dist/src/commands/init.d.ts.map +1 -1
  14. package/dist/src/commands/init.js +31 -6
  15. package/dist/src/commands/init.js.map +1 -1
  16. package/dist/src/commands/types.d.ts +2 -1
  17. package/dist/src/commands/types.d.ts.map +1 -1
  18. package/dist/src/commands/types.js +165 -28
  19. package/dist/src/commands/types.js.map +1 -1
  20. package/dist/src/commands/verify.d.ts +9 -1
  21. package/dist/src/commands/verify.d.ts.map +1 -1
  22. package/dist/src/commands/verify.js +291 -50
  23. package/dist/src/commands/verify.js.map +1 -1
  24. package/dist/src/index.d.ts.map +1 -1
  25. package/dist/src/index.js +82 -5
  26. package/dist/src/index.js.map +1 -1
  27. package/dist/src/lib/advisory.d.ts +77 -0
  28. package/dist/src/lib/advisory.d.ts.map +1 -0
  29. package/dist/src/lib/advisory.js +97 -0
  30. package/dist/src/lib/advisory.js.map +1 -0
  31. package/dist/src/lib/anti-patterns.d.ts +27 -3
  32. package/dist/src/lib/anti-patterns.d.ts.map +1 -1
  33. package/dist/src/lib/anti-patterns.js +145 -8
  34. package/dist/src/lib/anti-patterns.js.map +1 -1
  35. package/dist/src/lib/args.d.ts +27 -1
  36. package/dist/src/lib/args.d.ts.map +1 -1
  37. package/dist/src/lib/args.js +13 -1
  38. package/dist/src/lib/args.js.map +1 -1
  39. package/dist/src/lib/docs-drift.d.ts +31 -0
  40. package/dist/src/lib/docs-drift.d.ts.map +1 -0
  41. package/dist/src/lib/docs-drift.js +195 -0
  42. package/dist/src/lib/docs-drift.js.map +1 -0
  43. package/dist/src/lib/format.d.ts +10 -0
  44. package/dist/src/lib/format.d.ts.map +1 -1
  45. package/dist/src/lib/format.js +15 -0
  46. package/dist/src/lib/format.js.map +1 -1
  47. package/dist/src/lib/output.d.ts +13 -0
  48. package/dist/src/lib/output.d.ts.map +1 -1
  49. package/dist/src/lib/output.js +16 -1
  50. package/dist/src/lib/output.js.map +1 -1
  51. package/package.json +10 -10
  52. package/src/commands/docs.ts +194 -8
  53. package/src/commands/eject.ts +3 -3
  54. package/src/commands/gen.ts +54 -19
  55. package/src/commands/init.ts +35 -6
  56. package/src/commands/types.ts +185 -34
  57. package/src/commands/verify.ts +350 -46
  58. package/src/index.ts +91 -6
  59. package/src/lib/advisory.ts +150 -0
  60. package/src/lib/anti-patterns.ts +163 -8
  61. package/src/lib/args.ts +40 -2
  62. package/src/lib/docs-drift.ts +222 -0
  63. package/src/lib/format.ts +14 -0
  64. package/src/lib/output.ts +33 -2
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  // FR-040 §4.2(a) — `meta eject <generator>` takes ownership of any reference-template
2
2
  // generator, in any package, at any time after `meta init`. ADR-0034 scaffold-and-own
3
- // has `init` copy four of them eagerly (entity, queries, routes, barrel); this is the
3
+ // has `init` copy five of them eagerly (entity, queries, routes, barrel, names); this is the
4
4
  // SAME copy operation, generalised to every ejectable name and callable on demand — for
5
5
  // a generator you skipped at init time, or one a package gained since.
6
6
  import { mkdir, writeFile, stat, readFile } from "node:fs/promises";
@@ -147,7 +147,7 @@ function requiredPackages(templateSource: string): string[] {
147
147
  * declared dependencies or their `tsc` reports TS2307 on the file we just told them
148
148
  * they own — and under a strict (pnpm/npm) node_modules layout `meta gen` cannot
149
149
  * resolve it either. `meta init` already calls this out by ADDING the two packages its
150
- * four scaffolded generators need; the on-demand templates import two more
150
+ * five scaffolded generators need; the on-demand templates import two more
151
151
  * (codegen-ts-react, codegen-ts-tanstack) that nothing declares.
152
152
  *
153
153
  * This REPORTS rather than edits: init is a scaffolder writing a whole project and has
@@ -255,7 +255,7 @@ export async function ejectCommand(args: string[], cwd: string): Promise<number>
255
255
  //
256
256
  // But eject reads no config, so it cannot know WHICH of the three states this project
257
257
  // is in, and stating one of them as fact is wrong in the other two — including for the
258
- // four `meta init` scaffolds, whose config already imports from ./codegen/generators/,
258
+ // five `meta init` scaffolds, whose config already imports from ./codegen/generators/,
259
259
  // which is precisely the `meta eject <name> --force` re-sync case. Name the goal, then
260
260
  // the three branches; the reader knows which one they are looking at.
261
261
  log.info(`In metaobjects.config.ts, "${result.exportName}" must resolve to this file:`);
@@ -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
  *
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile, readFile, readdir, stat } from "node:fs/promises";
1
+ import { mkdir, writeFile, readFile, readdir, stat, rm } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { basename, dirname } from "node:path";
4
4
  import { existsSync as existsSyncWrap, readFileSync as readFileSyncWrap } from "node:fs";
@@ -19,7 +19,7 @@ import { readReferenceTemplate, type ReferenceGeneratorName } from "@metaobjects
19
19
  // the consumer's repo so they OWN them; metaobjects.config.ts imports them locally.
20
20
  const OWNED_GENERATORS_DIR = "codegen/generators";
21
21
 
22
- // The FOUR reference generators `meta init` copies EAGERLY — deliberately an explicit
22
+ // The FIVE reference generators `meta init` copies EAGERLY — deliberately an explicit
23
23
  // literal, not derived from @metaobjectsdev/codegen-ts's REFERENCE_GENERATOR_NAMES (the
24
24
  // full list of everything `meta eject` can copy). Looping over that array unconditionally
25
25
  // used to mean init scaffolded whatever it contained: when a later task registered
@@ -28,7 +28,13 @@ const OWNED_GENERATORS_DIR = "codegen/generators";
28
28
  // scaffolded metaobjects.config.ts's import list (buildMetaobjectsConfigBody below) made
29
29
  // explicit and checkable — anything else is eject-on-demand via `meta eject <name>`, which
30
30
  // exists exactly so eager copying isn't the only way to take ownership of a template.
31
- const SCAFFOLDED_GENERATOR_NAMES: readonly ReferenceGeneratorName[] = ["entity", "queries", "routes", "barrel"];
31
+ //
32
+ // "names" joined this list by spec §A5's ruling, not by falling out of the general rule
33
+ // above: TypeScript is opt-in by construction under ADR-0034 (meta gen runs the adopter's
34
+ // copy, so a packaged change to a generator can never reach anyone who has ejected), so
35
+ // the honest maximum for the names artifact is every NEW `meta init` getting it and every
36
+ // existing project adding one config line by hand.
37
+ export const SCAFFOLDED_GENERATOR_NAMES: readonly ReferenceGeneratorName[] = ["entity", "queries", "routes", "barrel", "names"];
32
38
 
33
39
  // The scaffolded config's outDir + dbImport, as named constants so the throwing-stub
34
40
  // path below is DERIVED from the same values the config template embeds rather than
@@ -105,6 +111,7 @@ function buildMetaobjectsConfigBody(dialect: "sqlite" | "postgres" | "d1" = "sql
105
111
  import { entityFile } from "./codegen/generators/entity.js";
106
112
  import { queriesFile } from "./codegen/generators/queries.js";
107
113
  import { routesFile } from "./codegen/generators/routes.js";
114
+ import { namesFile } from "./codegen/generators/names.js";
108
115
  import { barrel } from "./codegen/generators/barrel.js";
109
116
 
110
117
  export default defineConfig({
@@ -126,6 +133,7 @@ export default defineConfig({
126
133
  entityFile(),
127
134
  queriesFile(),
128
135
  routesFile(),
136
+ namesFile(), // <Entity>Names — physical table/column constants (spec §A1/§A5)
129
137
  barrel(),
130
138
  ],
131
139
  docs: {
@@ -246,6 +254,8 @@ export interface InitOptions {
246
254
  export interface InitResult {
247
255
  created: string[];
248
256
  preserved: string[];
257
+ /** agent-context files deleted because this stack no longer assembles them. */
258
+ removed: string[];
249
259
  warnings: string[];
250
260
  }
251
261
 
@@ -324,6 +334,12 @@ async function writeUnlessDryRun(cwd: string, dryRun: boolean, path: string, con
324
334
  await writeFile(abs, contents, "utf8");
325
335
  }
326
336
 
337
+ /** Delete twin of writeUnlessDryRun — a dry run must not remove anything either. */
338
+ async function removeUnlessDryRun(cwd: string, dryRun: boolean, path: string): Promise<void> {
339
+ if (dryRun) return;
340
+ await rm(join(cwd, path), { force: true });
341
+ }
342
+
327
343
  /** "would be VERBED" during a dry run, plain VERBED otherwise — the one tense
328
344
  * marker every reported write shares, so each call site states only its own
329
345
  * past participle instead of writing out both tenses of the whole sentence. */
@@ -387,8 +403,21 @@ async function writeAgentContext(opts: InitOptions, result: InitResult): Promise
387
403
  );
388
404
  result.created.push(AGENT_CONTEXT_MANIFEST_PATH);
389
405
 
406
+ // A fragment this stack no longer assembles. `prunes` are ones we wrote that nobody has
407
+ // touched — deleting is exactly as safe as the overwrite the same hash predicate already
408
+ // authorises above, and leaving them contradicts every SKILL.md footer, which tells the
409
+ // reader to read every references/*.md "one per server language in this project's stack".
410
+ for (const orphan of decision.prunes) {
411
+ await removeUnlessDryRun(opts.cwd, dryRun, orphan);
412
+ result.removed.push(orphan);
413
+ }
414
+ // Hand-edited ones are never deleted — losing an adopter's writing is worse than leaving
415
+ // a stale file behind — so they are named, with the reason and the remedy.
390
416
  for (const orphan of decision.removed) {
391
- result.warnings.push(`${orphan} is no longer part of this stack; orphaned (safe to delete).`);
417
+ result.warnings.push(
418
+ `${orphan} is no longer part of this stack but appears hand-edited, so it was kept; ` +
419
+ "delete it yourself once you have salvaged anything you want from it.",
420
+ );
392
421
  }
393
422
 
394
423
  if (opts.wireRoot) await wireRootMemory(opts.cwd, result, dryRun);
@@ -426,7 +455,7 @@ async function wireRootMemory(cwd: string, result: InitResult, dryRun = false):
426
455
  * so a re-run with --force never clobbers a hand-edited generator. The scaffolded
427
456
  * metaobjects.config.ts imports these local copies (not the package `/generators` export).
428
457
  *
429
- * Copies SCAFFOLDED_GENERATOR_NAMES only — the four the scaffolded config actually
458
+ * Copies SCAFFOLDED_GENERATOR_NAMES only — the five the scaffolded config actually
430
459
  * wires — not every name @metaobjectsdev/codegen-ts happens to register. Anything else
431
460
  * (routes-hono, and any UI-tier template from codegen-ts-react/-tanstack) is reached with
432
461
  * `meta eject <name>`, not by eager copying.
@@ -513,7 +542,7 @@ async function writeConfigFile(opts: InitOptions, result: InitResult, agentDir:
513
542
  }
514
543
 
515
544
  export async function init(opts: InitOptions): Promise<InitResult> {
516
- const result: InitResult = { created: [], preserved: [], warnings: [] };
545
+ const result: InitResult = { created: [], preserved: [], removed: [], warnings: [] };
517
546
  const agentDir = join(opts.cwd, DEFAULT_METAOBJECTS_DIR);
518
547
  const metaobjectsDir = join(opts.cwd, DEFAULT_METADATA_DIR);
519
548