@metaobjectsdev/cli 0.23.2 → 0.24.1

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 (89) hide show
  1. package/README.md +78 -0
  2. package/dist/src/commands/docs.d.ts.map +1 -1
  3. package/dist/src/commands/docs.js +157 -59
  4. package/dist/src/commands/docs.js.map +1 -1
  5. package/dist/src/commands/export.d.ts.map +1 -1
  6. package/dist/src/commands/export.js +28 -6
  7. package/dist/src/commands/export.js.map +1 -1
  8. package/dist/src/commands/gen.d.ts.map +1 -1
  9. package/dist/src/commands/gen.js +66 -22
  10. package/dist/src/commands/gen.js.map +1 -1
  11. package/dist/src/commands/init.d.ts +8 -0
  12. package/dist/src/commands/init.d.ts.map +1 -1
  13. package/dist/src/commands/init.js +142 -52
  14. package/dist/src/commands/init.js.map +1 -1
  15. package/dist/src/commands/migrate.d.ts +9 -2
  16. package/dist/src/commands/migrate.d.ts.map +1 -1
  17. package/dist/src/commands/migrate.js +399 -73
  18. package/dist/src/commands/migrate.js.map +1 -1
  19. package/dist/src/commands/prompt-snapshot.d.ts.map +1 -1
  20. package/dist/src/commands/prompt-snapshot.js +40 -18
  21. package/dist/src/commands/prompt-snapshot.js.map +1 -1
  22. package/dist/src/commands/upgrade.d.ts +2 -0
  23. package/dist/src/commands/upgrade.d.ts.map +1 -0
  24. package/dist/src/commands/upgrade.js +163 -0
  25. package/dist/src/commands/upgrade.js.map +1 -0
  26. package/dist/src/commands/verify.d.ts.map +1 -1
  27. package/dist/src/commands/verify.js +360 -48
  28. package/dist/src/commands/verify.js.map +1 -1
  29. package/dist/src/index.d.ts.map +1 -1
  30. package/dist/src/index.js +55 -19
  31. package/dist/src/index.js.map +1 -1
  32. package/dist/src/lib/allow.d.ts.map +1 -1
  33. package/dist/src/lib/allow.js +4 -0
  34. package/dist/src/lib/allow.js.map +1 -1
  35. package/dist/src/lib/args.d.ts +16 -2
  36. package/dist/src/lib/args.d.ts.map +1 -1
  37. package/dist/src/lib/args.js +19 -3
  38. package/dist/src/lib/args.js.map +1 -1
  39. package/dist/src/lib/codegen-drift.d.ts +8 -1
  40. package/dist/src/lib/codegen-drift.d.ts.map +1 -1
  41. package/dist/src/lib/codegen-drift.js +9 -1
  42. package/dist/src/lib/codegen-drift.js.map +1 -1
  43. package/dist/src/lib/detect-stack.d.ts +1 -1
  44. package/dist/src/lib/detect-stack.d.ts.map +1 -1
  45. package/dist/src/lib/detect-stack.js +22 -37
  46. package/dist/src/lib/detect-stack.js.map +1 -1
  47. package/dist/src/lib/load-metaobjects-config.d.ts +79 -1
  48. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  49. package/dist/src/lib/load-metaobjects-config.js +123 -1
  50. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  51. package/dist/src/lib/manifest-ignored-check.d.ts +12 -0
  52. package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -0
  53. package/dist/src/lib/manifest-ignored-check.js +74 -0
  54. package/dist/src/lib/manifest-ignored-check.js.map +1 -0
  55. package/dist/src/lib/migrate-scope.d.ts +49 -0
  56. package/dist/src/lib/migrate-scope.d.ts.map +1 -0
  57. package/dist/src/lib/migrate-scope.js +92 -0
  58. package/dist/src/lib/migrate-scope.js.map +1 -0
  59. package/dist/src/lib/output.d.ts +1 -1
  60. package/dist/src/lib/output.d.ts.map +1 -1
  61. package/dist/src/lib/output.js +9 -3
  62. package/dist/src/lib/output.js.map +1 -1
  63. package/dist/src/lib/requirement-check.d.ts.map +1 -1
  64. package/dist/src/lib/requirement-check.js +10 -57
  65. package/dist/src/lib/requirement-check.js.map +1 -1
  66. package/package.json +11 -11
  67. package/src/commands/docs.ts +174 -61
  68. package/src/commands/export.ts +30 -6
  69. package/src/commands/gen.ts +68 -22
  70. package/src/commands/init.ts +161 -58
  71. package/src/commands/migrate.ts +423 -75
  72. package/src/commands/prompt-snapshot.ts +43 -18
  73. package/src/commands/upgrade.ts +179 -0
  74. package/src/commands/verify.ts +397 -48
  75. package/src/index.ts +55 -19
  76. package/src/lib/allow.ts +4 -0
  77. package/src/lib/args.ts +35 -4
  78. package/src/lib/codegen-drift.ts +9 -0
  79. package/src/lib/detect-stack.ts +20 -33
  80. package/src/lib/load-metaobjects-config.ts +129 -2
  81. package/src/lib/manifest-ignored-check.ts +75 -0
  82. package/src/lib/migrate-scope.ts +102 -0
  83. package/src/lib/output.ts +8 -4
  84. package/src/lib/requirement-check.ts +10 -58
  85. package/dist/src/lib/verified-by-scan.d.ts +0 -20
  86. package/dist/src/lib/verified-by-scan.d.ts.map +0 -1
  87. package/dist/src/lib/verified-by-scan.js +0 -344
  88. package/dist/src/lib/verified-by-scan.js.map +0 -1
  89. package/src/lib/verified-by-scan.ts +0 -375
@@ -1,4 +1,4 @@
1
- // `meta docs <metadata> --out <dir>` — STANDALONE neutral metadata docs.
1
+ // `meta docs [<project-root>] --out <dir>` — STANDALONE neutral metadata docs.
2
2
  //
3
3
  // Emits one neutral page per entity (`<Entity>.md`) and one per
4
4
  // `template.output` (`<Template>.md`) from metadata ALONE — no gen config, no
@@ -14,8 +14,8 @@
14
14
  import { resolve as resolvePath, basename } from "node:path";
15
15
  import { mkdir, writeFile } from "node:fs/promises";
16
16
  import { log } from "../lib/log.js";
17
- import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
18
- import { loadMemory, DEFAULT_METADATA_DIR } from "@metaobjectsdev/sdk";
17
+ import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
18
+ import { loadMemory, resolveCollection, resolveConfigDir, type Collection } from "@metaobjectsdev/sdk";
19
19
  import { existsSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import {
@@ -31,7 +31,7 @@ import type {
31
31
  ResolvedDocsConfig,
32
32
  DocsSurface,
33
33
  } from "@metaobjectsdev/codegen-ts";
34
- import { docsFile, apiDocsFile } from "@metaobjectsdev/codegen-ts/generators";
34
+ import { docsFile, apiDocsFile, requirementsFile } from "@metaobjectsdev/codegen-ts/generators";
35
35
  import { composeRegistry, coreProviders, renderCoreMetamodelDocs } from "@metaobjectsdev/metadata";
36
36
  import type { MetaDataTypeProvider } from "@metaobjectsdev/metadata";
37
37
  import { generateSite, SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } from "@metaobjectsdev/docs-site";
@@ -39,8 +39,12 @@ import { generateSite, SITE_TEMPLATE_NAMES, SITE_ASSET_NAMES, readSiteFile } fro
39
39
  type DocsLayout = "flat" | "package";
40
40
 
41
41
  interface DocsFlags {
42
- /** Project root holding `metaobjects/` (the metadata to document). */
43
- metadata: string;
42
+ /** The PROJECT ROOT to resolve metadata from the directory that CONTAINS
43
+ * the metadata, never the metadata directory itself (#344). Named for what
44
+ * it is: while it was called `metadata` the help text spelled the positional
45
+ * `<metadata>`, which is what the Python and C# `docs` positionals actually
46
+ * mean, and pointing this one at `metaobjects/` fails. */
47
+ projectRoot: string;
44
48
  /** Output directory for the rendered pages. */
45
49
  out: string;
46
50
  /** Page-placement layout. `flat` (default) writes `<Name>.md` at the out
@@ -48,7 +52,7 @@ interface DocsFlags {
48
52
  * for multi-package models with repeated short names). */
49
53
  layout: DocsLayout;
50
54
  /** Optional override for the project root used to resolve adopter
51
- * `templates/` overrides. Defaults to the metadata root. */
55
+ * `templates/` overrides. Defaults to the project root. */
52
56
  templates?: string;
53
57
  /** Optional directory holding the prompt `.mustache` sources, for a project
54
58
  * whose templates live outside the conventional `metaobjects/` or `templates/`
@@ -66,6 +70,12 @@ interface DocsFlags {
66
70
  outProvided: boolean;
67
71
  /** Whether `--layout` was explicitly passed (same override semantics). */
68
72
  layoutProvided: boolean;
73
+ /** Whether the `<project-root>` positional was explicitly passed. An explicit path
74
+ * DEFINES the source set (`resolveCollection`'s `explicitDir` pin); the default
75
+ * cwd discovers one by walking up. Without this distinction the two are
76
+ * indistinguishable at the call site, which is how an explicitly-scoped run came
77
+ * to have an ancestor config's sources unioned into it (#327). */
78
+ projectRootProvided: boolean;
69
79
  /** FR-033 S3 — document the METAMODEL ITSELF (the built-in type/subtype/attr
70
80
  * vocabulary) instead of a user's entities. Needs NO metadata + NO config. */
71
81
  metamodel: boolean;
@@ -87,7 +97,7 @@ function parseLayout(v: string | undefined, flag: string): DocsLayout {
87
97
  }
88
98
 
89
99
  function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
90
- let metadata: string | undefined;
100
+ let projectRoot: string | undefined;
91
101
  let out: string | undefined;
92
102
  let templates: string | undefined;
93
103
  let prompts: string | undefined;
@@ -95,6 +105,7 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
95
105
  let baseUrl: string | undefined;
96
106
  let wantModel = false;
97
107
  let wantApi = false;
108
+ let wantRequirements = false;
98
109
  let wantMetamodel = false;
99
110
  let wantSite = false;
100
111
  let wantScaffoldSite = false;
@@ -120,6 +131,8 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
120
131
  wantModel = true;
121
132
  } else if (a === "--api") {
122
133
  wantApi = true;
134
+ } else if (a === "--requirements") {
135
+ wantRequirements = true;
123
136
  } else if (a === "--metamodel") {
124
137
  wantMetamodel = true;
125
138
  } else if (a === "--site") {
@@ -146,8 +159,8 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
146
159
  prompts = a.slice("--prompts=".length);
147
160
  } else if (a.startsWith("-")) {
148
161
  throw new Error(`unknown flag: ${a}`);
149
- } else if (metadata === undefined) {
150
- metadata = a;
162
+ } else if (projectRoot === undefined) {
163
+ projectRoot = a;
151
164
  } else {
152
165
  throw new Error(`unexpected argument: ${a}`);
153
166
  }
@@ -161,11 +174,13 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
161
174
  const surfaces: DocsSurface[] = [];
162
175
  if (wantModel) surfaces.push("model");
163
176
  if (wantApi) surfaces.push("api");
177
+ if (wantRequirements) surfaces.push("requirements");
164
178
  return {
165
- // `<metadata>` is the project root that contains metaobjects/; default cwd
166
- // (mirrors how migrate/gen treat the working directory as the root).
167
- metadata: metadata ?? cwd,
168
- // Default out dir, resolved against the metadata root below. In --metamodel
179
+ // `<project-root>` is the project root that CONTAINS the metadata; default
180
+ // cwd (mirrors how migrate/gen treat the working directory as the root).
181
+ projectRoot: projectRoot ?? cwd,
182
+ projectRootProvided: projectRoot !== undefined,
183
+ // Default out dir, resolved against the project root below. In --metamodel
169
184
  // mode the renderer writes under <out>/metamodel/, default ./docs/metamodel.
170
185
  out: out ?? (wantMetamodel ? "./docs/metamodel" : "./docs"),
171
186
  // Default flat preserves today's single-package output (+ existing goldens).
@@ -199,23 +214,75 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
199
214
  return metamodelDocsCommand(cwd, flags.out);
200
215
  }
201
216
 
202
- const metaRoot = resolvePath(cwd, flags.metadata);
217
+ const metaRoot = resolvePath(cwd, flags.projectRoot);
203
218
  // Absolute prompt-source dir for the site (--prompts), for a project whose
204
219
  // templates live outside metaobjects/ or templates/ (e.g. data/templates/).
205
220
  const promptsDir = flags.prompts !== undefined ? resolvePath(cwd, flags.prompts) : undefined;
206
221
 
207
222
  // `--scaffold-site`: copy the docs-site templates + assets into codegen/docs-site/
208
223
  // so the consumer owns them (ADR-0034 scaffold-and-own). Scaffold and return —
209
- // it does not also generate.
224
+ // it does not also generate. `resolveConfigDir` rather than `resolveCollection`:
225
+ // scaffolding needs no metadata, but it must write where `emitSite` will READ
226
+ // (below, under the resolved project root), and that is the same walk.
210
227
  if (flags.scaffoldSite) {
211
- return scaffoldSiteCommand(metaRoot);
228
+ // `resolveGenConfigDir` over the bare walk: `emitSite` reads the owned theme
229
+ // from the directory holding `metaobjects.config.ts`, so scaffolding must write
230
+ // to the same one or the theme is scaffolded where nothing will ever read it.
231
+ return scaffoldSiteCommand(resolveGenConfigDir(metaRoot, await resolveConfigDir(metaRoot)));
212
232
  }
213
233
 
234
+ // Discovery and load are two separate failure modes, kept in separate try
235
+ // blocks deliberately — same reasoning as `meta gen` (gen.ts): a broad
236
+ // catch around both would swallow a genuine ParseError as "no metaobjects/
237
+ // found", masking the real failure.
238
+ //
239
+ // Discovery runs BEFORE the config read, deliberately, and `meta gen` calls
240
+ // out the same ordering as the thing it fixed: the project root is whichever
241
+ // directory `resolveCollection` decided the metadata belongs to, so
242
+ // everything project-relative — `metaobjects.config.ts` and its providers,
243
+ // the `docs.outDir` it names, the adopter `templates/` overrides, the owned
244
+ // `codegen/docs-site/` theme — has to come from that same directory. Reading
245
+ // the config from the ambient `<project-root>` argument while the metadata came
246
+ // from an ancestor renders the ancestor's model with the subdirectory's
247
+ // (absent) providers. For a run at the project root the two are the same path.
248
+ //
249
+ // An EXPLICIT `<project-root>` positional pins the collection rather than seeding a
250
+ // walk (#327). The argument has always meant "document this": before sources were
251
+ // resolvable it read `<path>/metaobjects/` and nothing else, and passing it to
252
+ // discovery turned it into a starting point, so the nearest ancestor
253
+ // `.metaobjects/config.json` was found and ITS sources were unioned in — a command
254
+ // whose entire purpose is documenting one subset silently documenting the whole
255
+ // repo, at exit 0. Pinned, `<path>` governs: its own config if it has one, the
256
+ // default `<path>/metaobjects` if not. A bare `meta docs` still discovers, which is
257
+ // the right default for "document the project I am standing in".
258
+ let collection: Awaited<ReturnType<typeof resolveCollection>>;
259
+ try {
260
+ collection = await resolveCollection(
261
+ metaRoot,
262
+ flags.projectRootProvided ? { explicitDir: metaRoot } : undefined,
263
+ );
264
+ } catch (err) {
265
+ log.error(`docs: ${(err as Error).message}`);
266
+ return 2;
267
+ }
268
+
269
+ // Which directory's `metaobjects.config.ts` governs is its OWN nearest-ancestor
270
+ // walk, not the collection's: the two files answer different questions (design
271
+ // §4.6), and in a Maven- or pip-rooted monorepo the collection is declared at the
272
+ // repo root while the TS config sits in the app. Reading the second from the
273
+ // first made docs silently drop that app's providers and skip its api surface
274
+ // entirely (#326). Everything that config names follows it — the `docs.outDir` it
275
+ // carries, the adopter `templates/` overrides and the owned `codegen/docs-site/`
276
+ // theme beside it. Same directory whenever the two files sit together, which is
277
+ // every `meta init` project.
278
+ const genConfigDir = resolveGenConfigDir(metaRoot, collection.configDir);
279
+
214
280
  // The project root used to resolve adopter `templates/` overrides; the
215
- // framework defaults sit underneath via projectProvider's chain.
281
+ // framework defaults sit underneath via projectProvider's chain. `--templates`
282
+ // is the one explicit override.
216
283
  const projectRoot = flags.templates !== undefined
217
284
  ? resolvePath(cwd, flags.templates)
218
- : metaRoot;
285
+ : genConfigDir;
219
286
 
220
287
  // Best-effort load of metaobjects.config.ts to pick up consumer-supplied
221
288
  // providers (e.g. a project's custom field/object subtypes). Unlike `gen`,
@@ -225,12 +292,14 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
225
292
  // if the metadata genuinely uses an unregistered type.
226
293
  let loadedConfig: Awaited<ReturnType<typeof loadMetaobjectsConfig>> | undefined;
227
294
  let configProviders: NonNullable<Awaited<ReturnType<typeof loadMetaobjectsConfig>>["providers"]> | undefined;
295
+ // The same config's contributions to the metadata LOAD — providers plus the shipped
296
+ // `libraries` a project opts into (#333). `configProviders` stays separate because the
297
+ // site surface has its own loader and takes providers alone.
298
+ let configLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
228
299
  // hasConfig gates the api surface: api docs describe the GENERATED REST
229
300
  // surface, which only exists when there is a (loadable) gen config. A config
230
301
  // that EXISTS but fails to load degrades to model-only with a warning.
231
- const hasConfig = existsSync(join(metaRoot, "metaobjects.config.ts"));
232
- // The config lives alongside metaobjects/ at the metadata root (metaRoot);
233
- // projectRoot only diverges when --templates overrides the template lookup.
302
+ const hasConfig = existsSync(join(genConfigDir, "metaobjects.config.ts"));
234
303
  // Only attempt the load when the file is actually present: absence is the
235
304
  // expected config-less case (stay silent), but a config that EXISTS yet fails
236
305
  // to load is surfaced as a warning rather than silently degrading to
@@ -238,8 +307,9 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
238
307
  // cryptic unknown-subtype error instead of the real config error.
239
308
  if (hasConfig) {
240
309
  try {
241
- loadedConfig = await loadMetaobjectsConfig(metaRoot);
310
+ loadedConfig = await loadMetaobjectsConfig(genConfigDir);
242
311
  configProviders = loadedConfig.providers;
312
+ configLoadOptions = loadMemoryOptionsFrom(loadedConfig);
243
313
  } catch (err) {
244
314
  log.warn(
245
315
  `docs: metaobjects.config.ts failed to load (${(err as Error).message}); ` +
@@ -247,6 +317,7 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
247
317
  );
248
318
  loadedConfig = undefined;
249
319
  configProviders = undefined;
320
+ configLoadOptions = {};
250
321
  }
251
322
  }
252
323
 
@@ -269,7 +340,11 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
269
340
  cliOverrides,
270
341
  loadedConfig?.outputLayout ?? "flat",
271
342
  );
272
- const outDir = resolvePath(metaRoot, docsCfg.outDir);
343
+ // Against `genConfigDir`, not the collection's: `docs.outDir` is declared in
344
+ // `metaobjects.config.ts`, so it resolves against the directory that config lives
345
+ // in — the same rule `meta gen` applies to its own `outDir`. Identical whenever
346
+ // the two files sit together.
347
+ const outDir = resolvePath(genConfigDir, docsCfg.outDir);
273
348
 
274
349
  // SITE surface has its OWN model loader (docs-site's loadModel — NOT the sdk
275
350
  // loadMemory below) and needs no gen config. When the site is the ONLY
@@ -277,23 +352,19 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
277
352
  // WITHOUT building the markdown GenContext — decoupled and one fewer failure
278
353
  // surface. Combined with --model/--api it is emitted after them (below).
279
354
  if (flags.site && docsCfg.surfaces.length === 0) {
280
- return emitSite(metaRoot, outDir, configProviders, promptsDir);
355
+ return emitSite(collection, projectRoot, genConfigDir, outDir, configProviders, promptsDir);
281
356
  }
282
357
 
283
358
  // Load metadata standalone — same loader path as migrate/gen. Threads any
284
359
  // consumer providers from the config so custom types resolve.
285
360
  let root;
286
361
  try {
287
- root = await loadMemory(metaRoot, {
288
- ...(configProviders !== undefined ? { providers: configProviders } : {}),
362
+ root = await loadMemory(collection.configDir, {
363
+ files: collection.files,
364
+ ...configLoadOptions,
289
365
  });
290
366
  } catch (err) {
291
- const msg = (err as Error).message;
292
- if (!existsSync(join(metaRoot, DEFAULT_METADATA_DIR))) {
293
- log.error(`docs: no metaobjects/ found in ${metaRoot}; run 'meta init' to scaffold`);
294
- } else {
295
- log.error(`docs: failed to load metadata: ${msg}`);
296
- }
367
+ log.error(`docs: failed to load metadata: ${(err as Error).message}`);
297
368
  return 2;
298
369
  }
299
370
 
@@ -385,6 +456,16 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
385
456
  emit.push(...modelFiles);
386
457
  }
387
458
 
459
+ // REQUIREMENTS surface — the declared ledger as documentation (requirements.md +
460
+ // requirements.toon). Metadata-alone like the model surface: it reads ctx.loadedRoot
461
+ // and nothing else, so it is NOT gated on a loadable gen config the way `api` is.
462
+ //
463
+ // Emits ZERO files when the project declares no `requirement.*` node, which is what
464
+ // makes this surface safe to default ON — a project without a ledger sees no change.
465
+ if (docsCfg.surfaces.includes("requirements")) {
466
+ emit.push(...(await requirementsFile().generate(ctx)));
467
+ }
468
+
388
469
  // API surface — the SDK reference for the GENERATED REST surface, side by side
389
470
  // under each surface's subDir. THIS command only OWNS the surfaces it can
390
471
  // generate — i.e. its own port (lang "ts"). Surfaces owned by other ports are
@@ -444,7 +525,7 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
444
525
 
445
526
  // SITE surface (additive) — emit after the markdown surfaces so both coexist.
446
527
  if (flags.site) {
447
- const siteRc = await emitSite(metaRoot, outDir, configProviders, promptsDir);
528
+ const siteRc = await emitSite(collection, projectRoot, genConfigDir, outDir, configProviders, promptsDir);
448
529
  if (siteRc !== 0) return siteRc;
449
530
  }
450
531
 
@@ -462,7 +543,12 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
462
543
  const apiSummary = apiFiles.length > 0
463
544
  ? `${apiFiles.length} api page(s)`
464
545
  : "no api pages";
465
- log.info(`meta docs wrote ${modelSummary}; ${apiSummary} ${outDir}`);
546
+ // Reported only when it actually wrote something. A project with no ledger emits no
547
+ // requirements file, and saying "0 requirement pages" would advertise a surface that
548
+ // did not run — the opposite of the silence the empty-ledger guard exists to produce.
549
+ const requirementFiles = emit.filter((f) => f.path.startsWith("requirements.")).length;
550
+ const reqSummary = requirementFiles > 0 ? `; ${requirementFiles} requirement file(s)` : "";
551
+ log.info(`meta docs — wrote ${modelSummary}; ${apiSummary}${reqSummary} → ${outDir}`);
466
552
  return 0;
467
553
  }
468
554
 
@@ -471,9 +557,9 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
471
557
  * into `<root>/codegen/docs-site/{templates,assets}`, writing each file ONLY if
472
558
  * absent so a re-run never clobbers a hand-edited file.
473
559
  */
474
- async function scaffoldSiteCommand(metaRoot: string): Promise<number> {
475
- const tplDir = join(metaRoot, "codegen/docs-site/templates");
476
- const astDir = join(metaRoot, "codegen/docs-site/assets");
560
+ async function scaffoldSiteCommand(projectRoot: string): Promise<number> {
561
+ const tplDir = join(projectRoot, "codegen/docs-site/templates");
562
+ const astDir = join(projectRoot, "codegen/docs-site/assets");
477
563
  const created: string[] = [];
478
564
  const preserved: string[] = [];
479
565
  try {
@@ -499,53 +585,80 @@ async function scaffoldSiteCommand(metaRoot: string): Promise<number> {
499
585
  }
500
586
  log.info(
501
587
  `meta docs --scaffold-site — ${created.length} created, ${preserved.length} preserved ` +
502
- `→ ${join(metaRoot, "codegen/docs-site")} (edit these to own your theme)`,
588
+ `→ ${join(projectRoot, "codegen/docs-site")} (edit these to own your theme)`,
503
589
  );
504
590
  return 0;
505
591
  }
506
592
 
507
593
  /**
508
594
  * Emit the browsable HTML documentation site via `@metaobjectsdev/docs-site`.
509
- * The site loads the model with its OWN loader from the metadata source dir
510
- * (`<metaRoot>/metaobjects`), so this is independent of the sdk loadMemory path
511
- * used for the markdown surfaces. Writes under `<outDir>/site` so it can coexist
512
- * with the markdown output. Scaffold-and-own: when the consumer has copied
513
- * templates/assets into `<metaRoot>/codegen/docs-site/` (via `--scaffold-site`),
514
- * those win over the bundled defaults.
595
+ * The site loads the model with its OWN loader from the collection's declared
596
+ * source ROOTS (whole directories, one page group each) rather than from the
597
+ * per-file list the sdk `loadMemory` path takes, so this is independent of the
598
+ * markdown surfaces. Writes under `<outDir>/site` so it can coexist with the markdown
599
+ * output. Scaffold-and-own: when the consumer has copied templates/assets
600
+ * into `<projectRoot>/codegen/docs-site/` (via `--scaffold-site`), those win
601
+ * over the bundled defaults.
602
+ *
603
+ * Takes the ALREADY-RESOLVED collection: `docsCommand` resolved it to read the
604
+ * config from the right directory, and resolving a second time here made the
605
+ * combined `--model --site` path do the whole discovery-and-config walk twice.
515
606
  */
516
607
  async function emitSite(
517
- metaRoot: string,
608
+ collection: Collection,
609
+ projectRoot: string,
610
+ /** Directory holding `metaobjects.config.ts` — where `--scaffold-site` writes the
611
+ * owned theme, so where this must read it from. Distinct from `collection.configDir`
612
+ * since #326: in a polyglot monorepo the collection is declared at the repo root
613
+ * while the TS package (and its owned theme) sits in the app. */
614
+ genConfigDir: string,
518
615
  outDir: string,
519
616
  configProviders?: readonly MetaDataTypeProvider[],
520
617
  promptsDir?: string,
521
618
  ): Promise<number> {
522
619
  const siteOutDir = resolvePath(outDir, "site");
523
- // metaobjects/ is REQUIRED (the site loads the model from it) and always first.
524
- // Prompt `.mustache` source is additionally searched in the conventional
525
- // <root>/templates/ and any explicit --prompts dir (for a project whose templates
526
- // live elsewhere, e.g. data/templates/) else the site can't show the prompt TEXT
527
- // and prints a "source missing" note. Only existing dirs are added, and dirs are
528
- // deduped by BASENAME (the site keys source groups by basename, and rejects a dup).
529
- const sourceDirs = [join(metaRoot, DEFAULT_METADATA_DIR)];
530
- const seenBasenames = new Set([basename(join(metaRoot, DEFAULT_METADATA_DIR))]);
620
+ // The resolved metadata source dir(s) are REQUIRED (the site loads the
621
+ // model from them) and always first. Prompt `.mustache` source is
622
+ // additionally searched in the conventional <root>/templates/ and any
623
+ // explicit --prompts dir (for a project whose templates live elsewhere,
624
+ // e.g. data/templates/) else the site can't show the prompt TEXT and
625
+ // prints a "source missing" note. Only existing dirs are added.
626
+ //
627
+ // Deduped by resolved PATH, not by basename. Two DIFFERENT directories that
628
+ // happen to share a basename are a legitimate multi-source project (`metaobjects`
629
+ // plus `../shared-model/metaobjects`); `loadModel` disambiguates their site
630
+ // group names, so refusing the pair here — which a basename key did, by
631
+ // dropping the second — would break the feature this branch exists to ship.
632
+ // The same directory named twice is the real hazard: it would be symlinked
633
+ // and loaded twice.
634
+ // The DECLARED source roots, not directories re-derived from the resolved
635
+ // files: a declared source directory holding no metadata yet would otherwise
636
+ // vanish from the site's group list entirely, and `sourceDirs` could come back
637
+ // empty where the pre-branch code always passed `<root>/metaobjects`.
638
+ const sourceDirs = [...collection.sourceRoots];
639
+ const seenDirs = new Set(sourceDirs);
531
640
  if (promptsDir !== undefined && !existsSync(promptsDir)) {
532
641
  log.warn(`docs: --prompts dir does not exist: ${promptsDir}`);
533
642
  }
534
- for (const d of [join(metaRoot, "templates"), ...(promptsDir !== undefined ? [promptsDir] : [])]) {
535
- if (existsSync(d) && !seenBasenames.has(basename(d))) {
536
- sourceDirs.push(d);
537
- seenBasenames.add(basename(d));
643
+ for (const d of [join(projectRoot, "templates"), ...(promptsDir !== undefined ? [promptsDir] : [])]) {
644
+ const abs = resolvePath(d);
645
+ if (existsSync(abs) && !seenDirs.has(abs)) {
646
+ sourceDirs.push(abs);
647
+ seenDirs.add(abs);
538
648
  }
539
649
  }
540
650
  // Scaffold-and-own: when the consumer has copied templates/assets into
541
651
  // codegen/docs-site/ (via --scaffold-site), use those; else the bundled defaults.
542
- const ownedTemplates = join(metaRoot, "codegen/docs-site/templates");
543
- const ownedAssets = join(metaRoot, "codegen/docs-site/assets");
652
+ // Keyed on `genConfigDir`, NOT `projectRoot`: `--templates` redirects the adopter
653
+ // RENDER template chain (the `templates/` above), and letting it also move the
654
+ // docs-site theme would read it from somewhere `--scaffold-site` never writes.
655
+ const ownedTemplates = join(genConfigDir, "codegen/docs-site/templates");
656
+ const ownedAssets = join(genConfigDir, "codegen/docs-site/assets");
544
657
  try {
545
658
  const r = await generateSite({
546
659
  sourceDirs,
547
660
  outDir: siteOutDir,
548
- title: basename(metaRoot) || "Metadata",
661
+ title: basename(collection.configDir) || "Metadata",
549
662
  stamp: new Date().toISOString().slice(0, 10),
550
663
  commit: "",
551
664
  core: { n: 15 },
@@ -1,10 +1,10 @@
1
- import { resolve, join } from "node:path";
1
+ import { resolve } from "node:path";
2
2
  import { writeFile } from "node:fs/promises";
3
3
  import { parseExportArgs } from "../lib/args.js";
4
4
  import { log } from "../lib/log.js";
5
- import { loadAndExportJson } from "@metaobjectsdev/metadata/core";
6
- import { TypeRegistry, registerCoreTypes } from "@metaobjectsdev/metadata";
7
- import { DEFAULT_METADATA_DIR, registerForgeTypes } from "@metaobjectsdev/sdk";
5
+ import { FileSource } from "@metaobjectsdev/metadata/core";
6
+ import { TypeRegistry, registerCoreTypes, MetaDataLoader, canonicalSerialize } from "@metaobjectsdev/metadata";
7
+ import { registerForgeTypes, resolveCollection } from "@metaobjectsdev/sdk";
8
8
 
9
9
  export async function exportCommand(args: string[], cwd: string): Promise<number> {
10
10
  let flags;
@@ -16,7 +16,6 @@ export async function exportCommand(args: string[], cwd: string): Promise<number
16
16
  }
17
17
 
18
18
  const projectRoot = cwd;
19
- const metadataDir = join(projectRoot, DEFAULT_METADATA_DIR);
20
19
 
21
20
  // Build a registry with core + forge types so metadata that includes
22
21
  // descriptive types (decision, principle, etc.) loads without errors.
@@ -24,7 +23,32 @@ export async function exportCommand(args: string[], cwd: string): Promise<number
24
23
  registerCoreTypes(registry);
25
24
  registerForgeTypes(registry);
26
25
 
27
- const result = await loadAndExportJson(metadataDir, { registry });
26
+ // `export` has never used exit 2 for a metadata problem — only for a bad CLI
27
+ // flag (see parseExportArgs above). Previously any directory-load failure
28
+ // surfaced through loadAndExportJson's collected result.errors (exit 1); a
29
+ // resolveCollection failure (no declared sources, no default metaobjects/,
30
+ // or a malformed config.json) is the same class of problem and is reported
31
+ // the same way, to keep that contract exactly as it was.
32
+ let files: readonly string[];
33
+ try {
34
+ files = (await resolveCollection(projectRoot)).files;
35
+ } catch (err) {
36
+ log.error((err as Error).message);
37
+ return 1;
38
+ }
39
+
40
+ // `loadAndExportJson` only accepts a scanned directory (`MetaDataLoader.fromDirectory`);
41
+ // `resolveCollection` already resolved the file SET (declared `sources`, or the
42
+ // `metaobjects/` default), so load that list directly via the same loader +
43
+ // serializer `loadAndExportJson` composes, rather than re-deriving a directory.
44
+ const loadResult = await new MetaDataLoader({ registry }).load(
45
+ files.map((f) => new FileSource(f)),
46
+ );
47
+ const result = {
48
+ json: canonicalSerialize(loadResult.root),
49
+ errors: loadResult.errors,
50
+ warnings: loadResult.warnings.map((w) => w.message),
51
+ };
28
52
 
29
53
  for (const w of result.warnings) {
30
54
  log.warn(w);
@@ -1,15 +1,15 @@
1
- import { relative, join } from "node:path";
2
- import { existsSync } from "node:fs";
1
+ import { relative } from "node:path";
3
2
  import { parseGenArgs } from "../lib/args.js";
4
3
  import { resolveGenConfig } from "../lib/config.js";
5
- import { loadMetaobjectsConfig } from "../lib/load-metaobjects-config.js";
4
+ import { loadMemoryOptionsFrom, loadMetaobjectsConfig, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js";
6
5
  import { formatGenResult, formatGenResultToon, type GenFileEntry, type GenFileStatus } from "../lib/output.js";
7
6
  import { formatGenResultJson } from "../lib/output-json.js";
8
7
  import type { OutputFormat } from "../lib/format.js";
9
8
  import { log } from "../lib/log.js";
10
9
  import { warnIfAgentContextStale } from "../lib/agent-context-staleness.js";
10
+ import { warnIfManifestIgnored } from "../lib/manifest-ignored-check.js";
11
11
  import { scanSourceForAntiPatterns } from "../lib/anti-patterns.js";
12
- import { loadMemory, DEFAULT_METADATA_DIR } from "@metaobjectsdev/sdk";
12
+ import { loadMemory, resolveCollection } from "@metaobjectsdev/sdk";
13
13
  import { runGen, listGenerators } from "@metaobjectsdev/codegen-ts";
14
14
  import type { WriteStatus } from "@metaobjectsdev/codegen-ts";
15
15
 
@@ -22,6 +22,10 @@ function mapStatus(s: WriteStatus): GenFileStatus {
22
22
  case "unchanged":
23
23
  case "skipped": return "unchanged";
24
24
  case "refused": return "refused";
25
+ // FR-038 §8 — a generated file deleted because it is no longer generated.
26
+ // Shown as its own outcome, not folded into "unchanged": a run summary that
27
+ // lists writes but hides deletions is how a silent deletion happens.
28
+ case "removed": return "removed";
25
29
  }
26
30
  }
27
31
 
@@ -36,12 +40,59 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
36
40
  return listGeneratorsCommand();
37
41
  }
38
42
 
39
- // Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
40
- warnIfAgentContextStale(cwd);
41
-
42
- const projectRoot = cwd;
43
43
  const cliConfig = resolveGenConfig(flags);
44
44
 
45
+ // Discovery and load are two separate failure modes, kept in separate try
46
+ // blocks deliberately: a broad catch around both previously swallowed
47
+ // genuine ParseErrors (e.g. `origin.@via "X.y" ...: no such relationship
48
+ // "y" on X`) as "no metaobjects/ found", masking the real failure.
49
+ // `resolveCollection` raises `ERR_COLLECTION_NOT_FOUND` with its own
50
+ // message when nothing is discovered and no default directory exists.
51
+ //
52
+ // Discovery runs BEFORE the config read, deliberately: everything named BY the
53
+ // metadata resolves against the directory `resolveCollection` decided the
54
+ // metadata belongs to. Reading THAT from ambient cwd while the metadata came
55
+ // from an ancestor is the divergence this design exists to remove (design
56
+ // §4.6.1).
57
+ let collection;
58
+ try {
59
+ collection = await resolveCollection(cwd);
60
+ } catch (err) {
61
+ log.error((err as Error).message);
62
+ return 2;
63
+ }
64
+ // ...but `metaobjects.config.ts` is not named by the metadata: it is this
65
+ // package's own answer to a different question (design §4.6 — "how is code
66
+ // generated here?", per-port, versus the port-neutral "where does metadata come
67
+ // from?"). It gets its own nearest-ancestor walk, so a JS app under a Maven- or
68
+ // pip-rooted repo generates with the config sitting beside it rather than
69
+ // demanding one at the repo root that no such repo has (#326). Everything the
70
+ // TS config names follows it — `outDir`, `targets`, and the
71
+ // `.metaobjects/.gen-state/` merge base that mirrors that output, which must be
72
+ // per-package or two apps sharing one collection would clobber each other's.
73
+ // Nearest wins, so a subdirectory declaring nothing still walks up to the
74
+ // project root's config exactly as before.
75
+ const projectRoot = resolveGenConfigDir(cwd, collection.configDir);
76
+
77
+ // ...and a package that declares its own sources GENERATES from them (#340). An
78
+ // ancestor collection is the default for a package that declares none, never an
79
+ // addition to one that does — otherwise a sub-project's output silently absorbs
80
+ // metadata from unrelated trees. Identical object when the two configs sit together.
81
+ const genCollection = await resolveGenCollection(collection, projectRoot);
82
+
83
+ // Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
84
+ // Rooted at `projectRoot`, not ambient cwd — the scaffolded agent context sits
85
+ // with the project that declares the metadata, so a run from a subdirectory
86
+ // would find no manifest there and silently skip the nudge. `meta verify` makes
87
+ // the same call for the same reason; the two commands describe this and the
88
+ // anti-pattern scan below as one advisory pass, so they must scan one tree.
89
+ warnIfAgentContextStale(projectRoot);
90
+ // Advisory: the committed hash manifest is what makes hand-edit detection work on a
91
+ // machine that did not generate the output. Silent unless it is ignored. Keyed on
92
+ // projectRoot, not cwd, for the same reason its neighbour is — the manifest belongs to
93
+ // whichever directory `resolveCollection` decided the metadata lives in.
94
+ warnIfManifestIgnored(projectRoot);
95
+
45
96
  let forgeConfig;
46
97
  try {
47
98
  forgeConfig = await loadMetaobjectsConfig(projectRoot);
@@ -52,22 +103,12 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
52
103
 
53
104
  let metadata;
54
105
  try {
55
- metadata = await loadMemory(projectRoot, {
56
- ...(forgeConfig.providers !== undefined ? { providers: forgeConfig.providers } : {}),
106
+ metadata = await loadMemory(genCollection.configDir, {
107
+ files: genCollection.files,
108
+ ...loadMemoryOptionsFrom(forgeConfig),
57
109
  });
58
110
  } catch (err) {
59
- const msg = (err as Error).message;
60
- // Only emit the scaffold hint for the ACTUAL missing-metadata-dir
61
- // condition — checked explicitly here. A broad substring match on
62
- // "no such" / "cannot read" wrongly swallowed genuine ParseErrors (e.g.
63
- // `origin.@via "X.y" ...: no such relationship "y" on X`) as "no
64
- // metaobjects/ found", masking the real failure. Real parse/validation
65
- // errors propagate with their actual message.
66
- if (!existsSync(join(projectRoot, DEFAULT_METADATA_DIR))) {
67
- log.error(`no metaobjects/ found in ${projectRoot}; run 'meta init' to scaffold`);
68
- } else {
69
- log.error(`failed to load metadata: ${msg}`);
70
- }
111
+ log.error(`failed to load metadata: ${(err as Error).message}`);
71
112
  return 2;
72
113
  }
73
114
 
@@ -81,6 +122,11 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
81
122
  // --dry-run must actually preview. This was previously passed only to the
82
123
  // display object below, so a "preview" run wrote every file.
83
124
  dryRun: cliConfig.dryRun,
125
+ // Collection-level `scope` (Task 12b) — the output filter over
126
+ // GENERATED entities, never over what the collection loads. Always
127
+ // passed: an unconfigured project's predicate admits everything, so this
128
+ // is a no-op for the common case, not a behavior change.
129
+ scope: genCollection.inScope,
84
130
  ...(cliConfig.entities.length > 0 ? { entityFilter: cliConfig.entities } : {}),
85
131
  });
86
132
  } catch (err) {