@metaobjectsdev/cli 0.23.2 → 0.24.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 (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 +148 -50
  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 +61 -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 +139 -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 +336 -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 +46 -15
  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 +48 -1
  48. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  49. package/dist/src/lib/load-metaobjects-config.js +80 -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 +158 -49
  68. package/src/commands/export.ts +30 -6
  69. package/src/commands/gen.ts +62 -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 +156 -0
  74. package/src/commands/verify.ts +373 -48
  75. package/src/index.ts +46 -15
  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 +84 -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
@@ -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";
@@ -66,6 +66,12 @@ interface DocsFlags {
66
66
  outProvided: boolean;
67
67
  /** Whether `--layout` was explicitly passed (same override semantics). */
68
68
  layoutProvided: boolean;
69
+ /** Whether the `<metadata>` positional was explicitly passed. An explicit path
70
+ * DEFINES the source set (`resolveCollection`'s `explicitDir` pin); the default
71
+ * cwd discovers one by walking up. Without this distinction the two are
72
+ * indistinguishable at the call site, which is how an explicitly-scoped run came
73
+ * to have an ancestor config's sources unioned into it (#327). */
74
+ metadataProvided: boolean;
69
75
  /** FR-033 S3 — document the METAMODEL ITSELF (the built-in type/subtype/attr
70
76
  * vocabulary) instead of a user's entities. Needs NO metadata + NO config. */
71
77
  metamodel: boolean;
@@ -95,6 +101,7 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
95
101
  let baseUrl: string | undefined;
96
102
  let wantModel = false;
97
103
  let wantApi = false;
104
+ let wantRequirements = false;
98
105
  let wantMetamodel = false;
99
106
  let wantSite = false;
100
107
  let wantScaffoldSite = false;
@@ -120,6 +127,8 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
120
127
  wantModel = true;
121
128
  } else if (a === "--api") {
122
129
  wantApi = true;
130
+ } else if (a === "--requirements") {
131
+ wantRequirements = true;
123
132
  } else if (a === "--metamodel") {
124
133
  wantMetamodel = true;
125
134
  } else if (a === "--site") {
@@ -161,10 +170,12 @@ function parseDocsArgs(argv: string[], cwd: string): DocsFlags {
161
170
  const surfaces: DocsSurface[] = [];
162
171
  if (wantModel) surfaces.push("model");
163
172
  if (wantApi) surfaces.push("api");
173
+ if (wantRequirements) surfaces.push("requirements");
164
174
  return {
165
175
  // `<metadata>` is the project root that contains metaobjects/; default cwd
166
176
  // (mirrors how migrate/gen treat the working directory as the root).
167
177
  metadata: metadata ?? cwd,
178
+ metadataProvided: metadata !== undefined,
168
179
  // Default out dir, resolved against the metadata root below. In --metamodel
169
180
  // mode the renderer writes under <out>/metamodel/, default ./docs/metamodel.
170
181
  out: out ?? (wantMetamodel ? "./docs/metamodel" : "./docs"),
@@ -206,16 +217,68 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
206
217
 
207
218
  // `--scaffold-site`: copy the docs-site templates + assets into codegen/docs-site/
208
219
  // so the consumer owns them (ADR-0034 scaffold-and-own). Scaffold and return —
209
- // it does not also generate.
220
+ // it does not also generate. `resolveConfigDir` rather than `resolveCollection`:
221
+ // scaffolding needs no metadata, but it must write where `emitSite` will READ
222
+ // (below, under the resolved project root), and that is the same walk.
210
223
  if (flags.scaffoldSite) {
211
- return scaffoldSiteCommand(metaRoot);
224
+ // `resolveGenConfigDir` over the bare walk: `emitSite` reads the owned theme
225
+ // from the directory holding `metaobjects.config.ts`, so scaffolding must write
226
+ // to the same one or the theme is scaffolded where nothing will ever read it.
227
+ return scaffoldSiteCommand(resolveGenConfigDir(metaRoot, await resolveConfigDir(metaRoot)));
212
228
  }
213
229
 
230
+ // Discovery and load are two separate failure modes, kept in separate try
231
+ // blocks deliberately — same reasoning as `meta gen` (gen.ts): a broad
232
+ // catch around both would swallow a genuine ParseError as "no metaobjects/
233
+ // found", masking the real failure.
234
+ //
235
+ // Discovery runs BEFORE the config read, deliberately, and `meta gen` calls
236
+ // out the same ordering as the thing it fixed: the project root is whichever
237
+ // directory `resolveCollection` decided the metadata belongs to, so
238
+ // everything project-relative — `metaobjects.config.ts` and its providers,
239
+ // the `docs.outDir` it names, the adopter `templates/` overrides, the owned
240
+ // `codegen/docs-site/` theme — has to come from that same directory. Reading
241
+ // the config from the ambient `<metadata>` argument while the metadata came
242
+ // from an ancestor renders the ancestor's model with the subdirectory's
243
+ // (absent) providers. For a run at the project root the two are the same path.
244
+ //
245
+ // An EXPLICIT `<metadata>` positional pins the collection rather than seeding a
246
+ // walk (#327). The argument has always meant "document this": before sources were
247
+ // resolvable it read `<path>/metaobjects/` and nothing else, and passing it to
248
+ // discovery turned it into a starting point, so the nearest ancestor
249
+ // `.metaobjects/config.json` was found and ITS sources were unioned in — a command
250
+ // whose entire purpose is documenting one subset silently documenting the whole
251
+ // repo, at exit 0. Pinned, `<path>` governs: its own config if it has one, the
252
+ // default `<path>/metaobjects` if not. A bare `meta docs` still discovers, which is
253
+ // the right default for "document the project I am standing in".
254
+ let collection: Awaited<ReturnType<typeof resolveCollection>>;
255
+ try {
256
+ collection = await resolveCollection(
257
+ metaRoot,
258
+ flags.metadataProvided ? { explicitDir: metaRoot } : undefined,
259
+ );
260
+ } catch (err) {
261
+ log.error(`docs: ${(err as Error).message}`);
262
+ return 2;
263
+ }
264
+
265
+ // Which directory's `metaobjects.config.ts` governs is its OWN nearest-ancestor
266
+ // walk, not the collection's: the two files answer different questions (design
267
+ // §4.6), and in a Maven- or pip-rooted monorepo the collection is declared at the
268
+ // repo root while the TS config sits in the app. Reading the second from the
269
+ // first made docs silently drop that app's providers and skip its api surface
270
+ // entirely (#326). Everything that config names follows it — the `docs.outDir` it
271
+ // carries, the adopter `templates/` overrides and the owned `codegen/docs-site/`
272
+ // theme beside it. Same directory whenever the two files sit together, which is
273
+ // every `meta init` project.
274
+ const genConfigDir = resolveGenConfigDir(metaRoot, collection.configDir);
275
+
214
276
  // The project root used to resolve adopter `templates/` overrides; the
215
- // framework defaults sit underneath via projectProvider's chain.
277
+ // framework defaults sit underneath via projectProvider's chain. `--templates`
278
+ // is the one explicit override.
216
279
  const projectRoot = flags.templates !== undefined
217
280
  ? resolvePath(cwd, flags.templates)
218
- : metaRoot;
281
+ : genConfigDir;
219
282
 
220
283
  // Best-effort load of metaobjects.config.ts to pick up consumer-supplied
221
284
  // providers (e.g. a project's custom field/object subtypes). Unlike `gen`,
@@ -225,12 +288,14 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
225
288
  // if the metadata genuinely uses an unregistered type.
226
289
  let loadedConfig: Awaited<ReturnType<typeof loadMetaobjectsConfig>> | undefined;
227
290
  let configProviders: NonNullable<Awaited<ReturnType<typeof loadMetaobjectsConfig>>["providers"]> | undefined;
291
+ // The same config's contributions to the metadata LOAD — providers plus the shipped
292
+ // `libraries` a project opts into (#333). `configProviders` stays separate because the
293
+ // site surface has its own loader and takes providers alone.
294
+ let configLoadOptions: ReturnType<typeof loadMemoryOptionsFrom> = {};
228
295
  // hasConfig gates the api surface: api docs describe the GENERATED REST
229
296
  // surface, which only exists when there is a (loadable) gen config. A config
230
297
  // 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.
298
+ const hasConfig = existsSync(join(genConfigDir, "metaobjects.config.ts"));
234
299
  // Only attempt the load when the file is actually present: absence is the
235
300
  // expected config-less case (stay silent), but a config that EXISTS yet fails
236
301
  // to load is surfaced as a warning rather than silently degrading to
@@ -238,8 +303,9 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
238
303
  // cryptic unknown-subtype error instead of the real config error.
239
304
  if (hasConfig) {
240
305
  try {
241
- loadedConfig = await loadMetaobjectsConfig(metaRoot);
306
+ loadedConfig = await loadMetaobjectsConfig(genConfigDir);
242
307
  configProviders = loadedConfig.providers;
308
+ configLoadOptions = loadMemoryOptionsFrom(loadedConfig);
243
309
  } catch (err) {
244
310
  log.warn(
245
311
  `docs: metaobjects.config.ts failed to load (${(err as Error).message}); ` +
@@ -247,6 +313,7 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
247
313
  );
248
314
  loadedConfig = undefined;
249
315
  configProviders = undefined;
316
+ configLoadOptions = {};
250
317
  }
251
318
  }
252
319
 
@@ -269,7 +336,11 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
269
336
  cliOverrides,
270
337
  loadedConfig?.outputLayout ?? "flat",
271
338
  );
272
- const outDir = resolvePath(metaRoot, docsCfg.outDir);
339
+ // Against `genConfigDir`, not the collection's: `docs.outDir` is declared in
340
+ // `metaobjects.config.ts`, so it resolves against the directory that config lives
341
+ // in — the same rule `meta gen` applies to its own `outDir`. Identical whenever
342
+ // the two files sit together.
343
+ const outDir = resolvePath(genConfigDir, docsCfg.outDir);
273
344
 
274
345
  // SITE surface has its OWN model loader (docs-site's loadModel — NOT the sdk
275
346
  // loadMemory below) and needs no gen config. When the site is the ONLY
@@ -277,23 +348,19 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
277
348
  // WITHOUT building the markdown GenContext — decoupled and one fewer failure
278
349
  // surface. Combined with --model/--api it is emitted after them (below).
279
350
  if (flags.site && docsCfg.surfaces.length === 0) {
280
- return emitSite(metaRoot, outDir, configProviders, promptsDir);
351
+ return emitSite(collection, projectRoot, genConfigDir, outDir, configProviders, promptsDir);
281
352
  }
282
353
 
283
354
  // Load metadata standalone — same loader path as migrate/gen. Threads any
284
355
  // consumer providers from the config so custom types resolve.
285
356
  let root;
286
357
  try {
287
- root = await loadMemory(metaRoot, {
288
- ...(configProviders !== undefined ? { providers: configProviders } : {}),
358
+ root = await loadMemory(collection.configDir, {
359
+ files: collection.files,
360
+ ...configLoadOptions,
289
361
  });
290
362
  } 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
- }
363
+ log.error(`docs: failed to load metadata: ${(err as Error).message}`);
297
364
  return 2;
298
365
  }
299
366
 
@@ -385,6 +452,16 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
385
452
  emit.push(...modelFiles);
386
453
  }
387
454
 
455
+ // REQUIREMENTS surface — the declared ledger as documentation (requirements.md +
456
+ // requirements.toon). Metadata-alone like the model surface: it reads ctx.loadedRoot
457
+ // and nothing else, so it is NOT gated on a loadable gen config the way `api` is.
458
+ //
459
+ // Emits ZERO files when the project declares no `requirement.*` node, which is what
460
+ // makes this surface safe to default ON — a project without a ledger sees no change.
461
+ if (docsCfg.surfaces.includes("requirements")) {
462
+ emit.push(...(await requirementsFile().generate(ctx)));
463
+ }
464
+
388
465
  // API surface — the SDK reference for the GENERATED REST surface, side by side
389
466
  // under each surface's subDir. THIS command only OWNS the surfaces it can
390
467
  // generate — i.e. its own port (lang "ts"). Surfaces owned by other ports are
@@ -444,7 +521,7 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
444
521
 
445
522
  // SITE surface (additive) — emit after the markdown surfaces so both coexist.
446
523
  if (flags.site) {
447
- const siteRc = await emitSite(metaRoot, outDir, configProviders, promptsDir);
524
+ const siteRc = await emitSite(collection, projectRoot, genConfigDir, outDir, configProviders, promptsDir);
448
525
  if (siteRc !== 0) return siteRc;
449
526
  }
450
527
 
@@ -462,7 +539,12 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
462
539
  const apiSummary = apiFiles.length > 0
463
540
  ? `${apiFiles.length} api page(s)`
464
541
  : "no api pages";
465
- log.info(`meta docs wrote ${modelSummary}; ${apiSummary} ${outDir}`);
542
+ // Reported only when it actually wrote something. A project with no ledger emits no
543
+ // requirements file, and saying "0 requirement pages" would advertise a surface that
544
+ // did not run — the opposite of the silence the empty-ledger guard exists to produce.
545
+ const requirementFiles = emit.filter((f) => f.path.startsWith("requirements.")).length;
546
+ const reqSummary = requirementFiles > 0 ? `; ${requirementFiles} requirement file(s)` : "";
547
+ log.info(`meta docs — wrote ${modelSummary}; ${apiSummary}${reqSummary} → ${outDir}`);
466
548
  return 0;
467
549
  }
468
550
 
@@ -471,9 +553,9 @@ export async function docsCommand(args: string[], cwd: string): Promise<number>
471
553
  * into `<root>/codegen/docs-site/{templates,assets}`, writing each file ONLY if
472
554
  * absent so a re-run never clobbers a hand-edited file.
473
555
  */
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");
556
+ async function scaffoldSiteCommand(projectRoot: string): Promise<number> {
557
+ const tplDir = join(projectRoot, "codegen/docs-site/templates");
558
+ const astDir = join(projectRoot, "codegen/docs-site/assets");
477
559
  const created: string[] = [];
478
560
  const preserved: string[] = [];
479
561
  try {
@@ -499,53 +581,80 @@ async function scaffoldSiteCommand(metaRoot: string): Promise<number> {
499
581
  }
500
582
  log.info(
501
583
  `meta docs --scaffold-site — ${created.length} created, ${preserved.length} preserved ` +
502
- `→ ${join(metaRoot, "codegen/docs-site")} (edit these to own your theme)`,
584
+ `→ ${join(projectRoot, "codegen/docs-site")} (edit these to own your theme)`,
503
585
  );
504
586
  return 0;
505
587
  }
506
588
 
507
589
  /**
508
590
  * 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.
591
+ * The site loads the model with its OWN loader from the collection's declared
592
+ * source ROOTS (whole directories, one page group each) rather than from the
593
+ * per-file list the sdk `loadMemory` path takes, so this is independent of the
594
+ * markdown surfaces. Writes under `<outDir>/site` so it can coexist with the markdown
595
+ * output. Scaffold-and-own: when the consumer has copied templates/assets
596
+ * into `<projectRoot>/codegen/docs-site/` (via `--scaffold-site`), those win
597
+ * over the bundled defaults.
598
+ *
599
+ * Takes the ALREADY-RESOLVED collection: `docsCommand` resolved it to read the
600
+ * config from the right directory, and resolving a second time here made the
601
+ * combined `--model --site` path do the whole discovery-and-config walk twice.
515
602
  */
516
603
  async function emitSite(
517
- metaRoot: string,
604
+ collection: Collection,
605
+ projectRoot: string,
606
+ /** Directory holding `metaobjects.config.ts` — where `--scaffold-site` writes the
607
+ * owned theme, so where this must read it from. Distinct from `collection.configDir`
608
+ * since #326: in a polyglot monorepo the collection is declared at the repo root
609
+ * while the TS package (and its owned theme) sits in the app. */
610
+ genConfigDir: string,
518
611
  outDir: string,
519
612
  configProviders?: readonly MetaDataTypeProvider[],
520
613
  promptsDir?: string,
521
614
  ): Promise<number> {
522
615
  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))]);
616
+ // The resolved metadata source dir(s) are REQUIRED (the site loads the
617
+ // model from them) and always first. Prompt `.mustache` source is
618
+ // additionally searched in the conventional <root>/templates/ and any
619
+ // explicit --prompts dir (for a project whose templates live elsewhere,
620
+ // e.g. data/templates/) else the site can't show the prompt TEXT and
621
+ // prints a "source missing" note. Only existing dirs are added.
622
+ //
623
+ // Deduped by resolved PATH, not by basename. Two DIFFERENT directories that
624
+ // happen to share a basename are a legitimate multi-source project (`metaobjects`
625
+ // plus `../shared-model/metaobjects`); `loadModel` disambiguates their site
626
+ // group names, so refusing the pair here — which a basename key did, by
627
+ // dropping the second — would break the feature this branch exists to ship.
628
+ // The same directory named twice is the real hazard: it would be symlinked
629
+ // and loaded twice.
630
+ // The DECLARED source roots, not directories re-derived from the resolved
631
+ // files: a declared source directory holding no metadata yet would otherwise
632
+ // vanish from the site's group list entirely, and `sourceDirs` could come back
633
+ // empty where the pre-branch code always passed `<root>/metaobjects`.
634
+ const sourceDirs = [...collection.sourceRoots];
635
+ const seenDirs = new Set(sourceDirs);
531
636
  if (promptsDir !== undefined && !existsSync(promptsDir)) {
532
637
  log.warn(`docs: --prompts dir does not exist: ${promptsDir}`);
533
638
  }
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));
639
+ for (const d of [join(projectRoot, "templates"), ...(promptsDir !== undefined ? [promptsDir] : [])]) {
640
+ const abs = resolvePath(d);
641
+ if (existsSync(abs) && !seenDirs.has(abs)) {
642
+ sourceDirs.push(abs);
643
+ seenDirs.add(abs);
538
644
  }
539
645
  }
540
646
  // Scaffold-and-own: when the consumer has copied templates/assets into
541
647
  // 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");
648
+ // Keyed on `genConfigDir`, NOT `projectRoot`: `--templates` redirects the adopter
649
+ // RENDER template chain (the `templates/` above), and letting it also move the
650
+ // docs-site theme would read it from somewhere `--scaffold-site` never writes.
651
+ const ownedTemplates = join(genConfigDir, "codegen/docs-site/templates");
652
+ const ownedAssets = join(genConfigDir, "codegen/docs-site/assets");
544
653
  try {
545
654
  const r = await generateSite({
546
655
  sourceDirs,
547
656
  outDir: siteOutDir,
548
- title: basename(metaRoot) || "Metadata",
657
+ title: basename(collection.configDir) || "Metadata",
549
658
  stamp: new Date().toISOString().slice(0, 10),
550
659
  commit: "",
551
660
  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, 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,53 @@ 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
+ // Advisory: nudge to refresh the .claude/skills docs if they predate this CLI.
78
+ // Rooted at `projectRoot`, not ambient cwd — the scaffolded agent context sits
79
+ // with the project that declares the metadata, so a run from a subdirectory
80
+ // would find no manifest there and silently skip the nudge. `meta verify` makes
81
+ // the same call for the same reason; the two commands describe this and the
82
+ // anti-pattern scan below as one advisory pass, so they must scan one tree.
83
+ warnIfAgentContextStale(projectRoot);
84
+ // Advisory: the committed hash manifest is what makes hand-edit detection work on a
85
+ // machine that did not generate the output. Silent unless it is ignored. Keyed on
86
+ // projectRoot, not cwd, for the same reason its neighbour is — the manifest belongs to
87
+ // whichever directory `resolveCollection` decided the metadata lives in.
88
+ warnIfManifestIgnored(projectRoot);
89
+
45
90
  let forgeConfig;
46
91
  try {
47
92
  forgeConfig = await loadMetaobjectsConfig(projectRoot);
@@ -52,22 +97,12 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
52
97
 
53
98
  let metadata;
54
99
  try {
55
- metadata = await loadMemory(projectRoot, {
56
- ...(forgeConfig.providers !== undefined ? { providers: forgeConfig.providers } : {}),
100
+ metadata = await loadMemory(collection.configDir, {
101
+ files: collection.files,
102
+ ...loadMemoryOptionsFrom(forgeConfig),
57
103
  });
58
104
  } 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
- }
105
+ log.error(`failed to load metadata: ${(err as Error).message}`);
71
106
  return 2;
72
107
  }
73
108
 
@@ -81,6 +116,11 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat
81
116
  // --dry-run must actually preview. This was previously passed only to the
82
117
  // display object below, so a "preview" run wrote every file.
83
118
  dryRun: cliConfig.dryRun,
119
+ // Collection-level `scope` (Task 12b) — the output filter over
120
+ // GENERATED entities, never over what the collection loads. Always
121
+ // passed: an unconfigured project's predicate admits everything, so this
122
+ // is a no-op for the common case, not a behavior change.
123
+ scope: collection.inScope,
84
124
  ...(cliConfig.entities.length > 0 ? { entityFilter: cliConfig.entities } : {}),
85
125
  });
86
126
  } catch (err) {