@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
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ import { resolve } from "node:path";
2
2
  import { log } from "./lib/log.js";
3
3
  import { cliVersion } from "./lib/version.js";
4
4
  import { resolveFormat, isValidFormat, VALID_FORMATS } from "./lib/format.js";
5
+ import { resolveCollection } from "@metaobjectsdev/sdk";
5
6
  export { defineConfig } from "@metaobjectsdev/codegen-ts";
6
7
  export type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
7
8
 
@@ -13,14 +14,16 @@ USAGE:
13
14
  meta <command> [flags]
14
15
 
15
16
  COMMANDS:
16
- init Scaffold metaobjects/ + .metaobjects/ in the current repo
17
+ init Scaffold a MetaObjects project in the current repo
17
18
  init --refresh-docs Refresh .metaobjects/AGENTS.md + CLAUDE.md after CLI upgrades
19
+ init --config-only Write only .metaobjects/config.json — for a Maven- or pip-rooted project
18
20
  agent-docs Scaffold only the agent-context (.metaobjects/ + .claude/skills/) — canonical redirect target for all language ports
19
- gen [<entity>...] Codegen TS targets from metaobjects/ entities
21
+ gen [<entity>...] Codegen TS targets from your declared metadata
20
22
  types [query] Search the metadata vocabulary (types, subtypes, @attrs) by name or description
21
23
  export Flatten loaded metadata to one canonical JSON artifact
22
- docs <metadata> --out <dir> Generate neutral metadata documentation (entity + template pages; --site for HTML site)
24
+ docs [<project-root>] --out <dir> Generate neutral metadata documentation (entity + template pages; --site for HTML site)
23
25
  verify Drift gate — subverbs: --templates / --db / --codegen (bare = --templates)
26
+ upgrade Rewrite retired metadata vocabulary (previews; --apply writes)
24
27
  prompt-snapshot Snapshot rendered template.* output; --check gates drift
25
28
  migrate Diff metadata vs live DB; emit migration SQL files
26
29
  --version, -v Print version
@@ -39,9 +42,11 @@ EXPORT FLAGS:
39
42
  --out <file> Write output to a file (default: stdout)
40
43
 
41
44
  DOCS FLAGS:
42
- <metadata> Project root holding metaobjects/ (default: current directory)
45
+ [<project-root>] PROJECT ROOT to resolve metadata from — the directory that CONTAINS
46
+ your metadata, NOT the metadata directory. Passing it SCOPES the run
47
+ (default: cwd)
43
48
  --out <dir>, -o Output directory for the pages (default: ./docs)
44
- --templates <dir> Project root to resolve adopter templates/ overrides (default: <metadata>)
49
+ --templates <dir> Project root to resolve adopter templates/ overrides (default: <project-root>)
45
50
  --prompts <dir> Extra dir holding prompt .mustache sources for --site (e.g. data/templates/)
46
51
 
47
52
  VERIFY FLAGS (ADR-0021 D2 — explicit subverbs; combine any; exit 1 on ANY drift):
@@ -90,7 +95,7 @@ ship in later sub-projects. See https://metaobjects.com for docs.
90
95
 
91
96
  /** Focused per-subcommand usage slices shown by `<cmd> --help`. */
92
97
  const COMMAND_HELP: Record<string, string> = {
93
- gen: `meta gen — codegen TS targets from metaobjects/ entities
98
+ gen: `meta gen — codegen TS targets from your declared metadata
94
99
 
95
100
  USAGE:
96
101
  meta gen [<entity>...] [flags]
@@ -109,7 +114,7 @@ or META_NO_ANTIPATTERNS=1.
109
114
 
110
115
  NOTE: outDir, dialect, dbImport, extStyle are read from metaobjects.config.ts
111
116
  `,
112
- verify: `meta verify — drift gate (templates / DB schema / codegen)
117
+ verify: `meta verify — drift gate (templates / DB schema / codegen / migration replay)
113
118
 
114
119
  USAGE:
115
120
  meta verify [flags]
@@ -121,6 +126,17 @@ FLAGS:
121
126
  --db <url> Schema drift — live DB URL enables the schema-drift gate.
122
127
  Supports: file:, libsql:, postgres:, postgresql:
123
128
  D1 has no URL — use --dialect d1 / --d1 <binding> instead.
129
+ --replay Migration-chain drift — replay the committed chain into an
130
+ EMPTY throwaway database and assert it applies. Needs no --db:
131
+ the engine is in-process (PGlite for postgres, a temp file for
132
+ sqlite) and provisions nothing. Dialect comes from --dialect,
133
+ else migrate.dialect. flyway and d1 are refused.
134
+ Postgres needs the optional peer '@electric-sql/pglite'.
135
+ --replay-snapshot ...and assert the replayed schema EQUALS the committed
136
+ snapshot — catches a hand-edited up.sql that still applies but
137
+ no longer builds the recorded schema. Does NOT apply to a
138
+ project adopted with 'migrate baseline --from-db' (its chain
139
+ does not build the schema); use --replay there.
124
140
  --prompts <dir> Directory of provider-resolved template text (default: prompts)
125
141
  --dialect sqlite|postgres|d1 Optional override (auto-detected from --db URL scheme)
126
142
  --allow <csv> Accepted for parity with 'migrate'; does NOT affect the drift gate
@@ -149,22 +165,29 @@ FLAGS:
149
165
  docs: `meta docs — generate neutral metadata documentation (entity + template pages)
150
166
 
151
167
  USAGE:
152
- meta docs [<metadata>] [flags]
168
+ meta docs [<project-root>] [flags]
153
169
 
154
170
  FLAGS:
155
- <metadata> Project root holding metaobjects/ (default: current directory)
171
+ [<project-root>] PROJECT ROOT to resolve metadata from — the directory that CONTAINS
172
+ your metadata, NOT the metadata directory itself. (The Python and C#
173
+ 'docs' positionals mean the metadata dir; this one does not.) Passing
174
+ it SCOPES the run to that directory's own sources; no ancestor
175
+ .metaobjects/config.json is consulted. Omitted (default), the project
176
+ is discovered by walking up.
156
177
  --out <dir>, -o Output directory for the pages (default: ./docs)
157
178
  --model Emit the markdown model surface (entity + template pages)
158
179
  --api Emit the markdown api surface (generated SDK reference)
180
+ --requirements Emit the declared requirement ledger (requirements.md + .toon).
181
+ Emits nothing when the project declares no requirement.* node.
159
182
  --metamodel Document the built-in metamodel vocabulary (no metadata needed)
160
183
  --site Generate the browsable HTML documentation site (<out>/site/)
161
184
  --scaffold-site Copy the site's templates + assets into codegen/docs-site/ to own (theme) them
162
- --templates <dir> Project root to resolve adopter templates/ overrides (default: <metadata>)
185
+ --templates <dir> Project root to resolve adopter templates/ overrides (default: <project-root>)
163
186
  --prompts <dir> Extra dir holding prompt .mustache sources (for --site) when they
164
- live outside metaobjects/ or templates/ (e.g. data/templates/)
187
+ live outside the metadata sources or templates/ (e.g. data/templates/)
165
188
  --help, -h Print this help
166
189
  `,
167
- init: `meta init — scaffold metaobjects/ + .metaobjects/ in the current repo
190
+ init: `meta init — scaffold a MetaObjects project in the current repo
168
191
 
169
192
  USAGE:
170
193
  meta init [flags]
@@ -176,6 +199,8 @@ FLAGS:
176
199
  --print-only Print what would be written, don't write
177
200
  --d1 Include D1 (Cloudflare) migration config
178
201
  --no-wire-root Skip wiring root metaobjects.config.ts
202
+ --config-only Write only .metaobjects/config.json (no TS scaffold) — declares
203
+ metadata sources for the Node CLI from a Maven- or pip-rooted project
179
204
  --help, -h Print this help
180
205
  `,
181
206
  "agent-docs": `meta agent-docs — scaffold the agent-context (.metaobjects/ always-on files + .claude/skills/)
@@ -271,12 +296,19 @@ export async function run(argv: string[]): Promise<number> {
271
296
  case undefined: {
272
297
  // Content-first no-args view: concise status + next-step help[] rather than
273
298
  // dumping the full manual (full manual is still available via `meta --help`).
274
- const metaobjectsExists = await import("node:fs/promises")
275
- .then(({ stat }) => stat(resolve(cwd, "metaobjects")).then(() => true).catch(() => false));
276
- const statusLine = metaobjectsExists
277
- ? `meta MetaObjects CLI (v${VERSION}) · metaobjects/ found`
278
- : `meta MetaObjects CLI (v${VERSION}) · no metaobjects/ here`;
279
- const nextSteps = metaobjectsExists
299
+ // "Is this a MetaObjects project?" routes through resolveCollection — the
300
+ // single authority on where metadata lives rather than assuming the
301
+ // default `metaobjects/` directory name. The status line must not assert
302
+ // it either: a project whose config points `sources` at
303
+ // `../shared-model/metadata` would be told "metaobjects/ found", which is
304
+ // false, and one that resolves nothing would be told there is no
305
+ // `metaobjects/` here when the real problem may be a declared source that
306
+ // failed to resolve.
307
+ const metadataResolves = await resolveCollection(cwd).then(() => true).catch(() => false);
308
+ const statusLine = metadataResolves
309
+ ? `meta — MetaObjects CLI (v${VERSION}) · metadata found`
310
+ : `meta — MetaObjects CLI (v${VERSION}) · no MetaObjects project here`;
311
+ const nextSteps = metadataResolves
280
312
  ? [
281
313
  " meta gen Run codegen",
282
314
  " meta verify Check for drift",
@@ -284,7 +316,7 @@ export async function run(argv: string[]): Promise<number> {
284
316
  " meta --help Full command reference",
285
317
  ]
286
318
  : [
287
- " meta init Scaffold metaobjects/ in this directory",
319
+ " meta init Scaffold a MetaObjects project in this directory",
288
320
  " meta --help Full command reference",
289
321
  ];
290
322
  log.info(`${statusLine}\n\n${nextSteps.join("\n")}\n`);
@@ -351,6 +383,10 @@ export async function run(argv: string[]): Promise<number> {
351
383
  const { verifyCommand } = await import("./commands/verify.js");
352
384
  return verifyCommand(rest, cwd);
353
385
  }
386
+ case "upgrade": {
387
+ const { upgradeCommand } = await import("./commands/upgrade.js");
388
+ return upgradeCommand(rest, cwd);
389
+ }
354
390
  case "prompt-snapshot": {
355
391
  const { promptSnapshotCommand } = await import("./commands/prompt-snapshot.js");
356
392
  return promptSnapshotCommand(rest, cwd);
package/src/lib/allow.ts CHANGED
@@ -33,6 +33,10 @@ export const ALLOW_TOKEN_MAP: Record<string, keyof AllowOptions> = {
33
33
  // @generation at all — ambiguous between "never declared it" and
34
34
  // "deliberately removing auto-increment", so migrate refuses without it.
35
35
  "drop-identity-default": "dropIdentityDefault",
36
+ // Gates dropping an object the committed snapshot never contained (#313). Read by
37
+ // migrate's generation-time provenance guard, not by diff()'s status pass — see
38
+ // AllowOptions.dropUnmanaged for why it still belongs in that shape.
39
+ "drop-unmanaged": "dropUnmanaged",
36
40
  };
37
41
 
38
42
  /** Translate parsed `--allow` tokens into the migrate-ts `AllowOptions` shape. */
package/src/lib/args.ts CHANGED
@@ -15,6 +15,7 @@ export interface InitFlags {
15
15
  noSkills: boolean;
16
16
  wireRoot: boolean;
17
17
  docsOnly: boolean;
18
+ configOnly: boolean;
18
19
  }
19
20
 
20
21
  export function parseInitArgs(argv: string[]): InitFlags {
@@ -31,6 +32,7 @@ export function parseInitArgs(argv: string[]): InitFlags {
31
32
  "no-skills": { type: "boolean", default: false },
32
33
  "no-wire-root": { type: "boolean", default: false },
33
34
  "docs-only": { type: "boolean", default: false },
35
+ "config-only": { type: "boolean", default: false },
34
36
  },
35
37
  strict: true,
36
38
  allowPositionals: false,
@@ -46,6 +48,7 @@ export function parseInitArgs(argv: string[]): InitFlags {
46
48
  noSkills: !!values["no-skills"],
47
49
  wireRoot: !values["no-wire-root"],
48
50
  docsOnly: !!values["docs-only"],
51
+ configOnly: !!values["config-only"],
49
52
  };
50
53
  }
51
54
 
@@ -197,6 +200,13 @@ export const ALLOW_TOKENS = [
197
200
  // metadata declares no @generation at all — ambiguous between "never
198
201
  // declared it" and "deliberately removing auto-increment".
199
202
  "drop-identity-default",
203
+ // drop-unmanaged permits dropping an object the COMMITTED SNAPSHOT never
204
+ // contained — i.e. one this toolchain never managed, typically a table another
205
+ // tool owns. Without it such a drop is refused at generation time, because the
206
+ // migration it writes cannot replay against a database where that object never
207
+ // existed (#313). Unlike its neighbours this one is enforced by `migrate` itself
208
+ // rather than by `diff()`'s status pass; see AllowOptions.dropUnmanaged.
209
+ "drop-unmanaged",
200
210
  ] as const;
201
211
  type AllowToken = (typeof ALLOW_TOKENS)[number];
202
212
 
@@ -232,7 +242,20 @@ export interface VerifyFlags {
232
242
  templates: boolean;
233
243
  /** Run the codegen-drift gate (regenerate-to-temp and diff committed output). */
234
244
  codegen: boolean;
235
- /** Whether ANY explicit subverb flag (--templates/--db/--codegen) was passed. */
245
+ /**
246
+ * Replay the committed migration chain into an empty throwaway database and assert
247
+ * it applies (#313). Needs no `--db`: the engine is local and disposable (PGlite
248
+ * for postgres, a temp sqlite file), so the gate provisions nothing.
249
+ */
250
+ replay: boolean;
251
+ /**
252
+ * `--replay` plus: assert the replayed schema EQUALS the committed snapshot. A
253
+ * separate subverb rather than a `--strict` modifier, because `--lax` below is a
254
+ * different axis (ADR-0023 attribute strictness) and `--strict` beside it would
255
+ * read as that flag's opposite rather than as a replay depth.
256
+ */
257
+ replaySnapshot: boolean;
258
+ /** Whether ANY explicit subverb flag (--templates/--db/--codegen/--replay*) was passed. */
236
259
  anyExplicit: boolean;
237
260
  /** Suppress the advisory anti-pattern (verify-as-teacher) pass. */
238
261
  noAntipatterns: boolean;
@@ -255,6 +278,8 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
255
278
  "skip-schema": { type: "boolean", default: false },
256
279
  templates: { type: "boolean", default: false },
257
280
  codegen: { type: "boolean", default: false },
281
+ replay: { type: "boolean", default: false },
282
+ "replay-snapshot": { type: "boolean", default: false },
258
283
  "no-antipatterns": { type: "boolean", default: false },
259
284
  lax: { type: "boolean", default: false },
260
285
  "d1": { type: "string" },
@@ -283,11 +308,15 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
283
308
 
284
309
  const templates = !!values.templates;
285
310
  const codegen = !!values.codegen;
311
+ const replay = !!values.replay;
312
+ const replaySnapshot = !!values["replay-snapshot"];
286
313
  // --db is itself an explicit subverb selector: passing a connection URL means
287
314
  // "run the schema-drift mode". So is `--dialect d1` (D1 has no --db connection
288
- // URL — see the `d1` field doc above). So "any explicit subverb" is
289
- // templates|codegen|db|dialect==d1.
290
- const anyExplicit = templates || codegen || values.db !== undefined || dialect === "d1";
315
+ // URL — see the `d1` field doc above). The replay flags are subverbs too, and
316
+ // must be listed here or `meta verify --replay` would ALSO run the template gate
317
+ // as the bare-verify default.
318
+ const anyExplicit =
319
+ templates || codegen || values.db !== undefined || dialect === "d1" || replay || replaySnapshot;
291
320
 
292
321
  return {
293
322
  prompts: values.prompts,
@@ -297,6 +326,8 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
297
326
  skipSchema: !!values["skip-schema"],
298
327
  templates,
299
328
  codegen,
329
+ replay,
330
+ replaySnapshot,
300
331
  anyExplicit,
301
332
  noAntipatterns: !!values["no-antipatterns"],
302
333
  lax: !!values.lax,
@@ -81,11 +81,19 @@ function listFiles(dir: string): string[] {
81
81
  * @param config the loaded metaobjects config (provides outDir/targets).
82
82
  * @param metadata the loaded MetaRoot (same object `meta gen` would use).
83
83
  * @param projectRoot absolute project root (committed outDirs are keyed off it).
84
+ * @param scope the SAME output-scope predicate `meta gen` used to produce the
85
+ * committed output (Task 12b / design §7 open question 3). A `verify --codegen`
86
+ * that regenerates unscoped while the committed output was scoped would read
87
+ * every out-of-scope entity as drift — regen would try to emit it, but it was
88
+ * never committed because the `meta gen` that produced the committed tree
89
+ * never emitted it either. Undefined ⇒ everything is in scope (byte-identical
90
+ * to a project with no `scope` declared).
84
91
  */
85
92
  export async function computeCodegenDrift(
86
93
  config: MetaobjectsGenConfig,
87
94
  metadata: MetaData,
88
95
  projectRoot: string,
96
+ scope?: (fqn: string) => boolean,
89
97
  ): Promise<CodegenDriftResult> {
90
98
  const root = isAbsolute(projectRoot) ? projectRoot : resolve(projectRoot);
91
99
 
@@ -157,6 +165,7 @@ export async function computeCodegenDrift(
157
165
  genStateDir: join(tempRoot, ".gen-state"),
158
166
  mergeStrategy: "overwrite",
159
167
  baseline: "fresh",
168
+ ...(scope !== undefined ? { scope } : {}),
160
169
  });
161
170
 
162
171
  // Diff each committed outDir against its temp mirror.
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
- import type { Dirent } from "node:fs";
3
2
  import { join } from "node:path";
3
+ import { resolveCollection } from "@metaobjectsdev/sdk";
4
4
  import {
5
5
  detectStack, detectConcerns, makeStack,
6
6
  type ServerLang, type ClientFramework, type Stack, type ProjectProbe,
@@ -21,62 +21,49 @@ function depNames(cwd: string): Set<string> {
21
21
  return out;
22
22
  }
23
23
 
24
- const METADATA_DIR = "metaobjects";
25
- const METADATA_FILE_PATTERN = /\.(json|ya?ml)$/i;
26
24
  // Cheap substring probe, not a metamodel load: matches both canonical JSON's
27
25
  // quoted `"requirement.functional"` key and sigil-free YAML's bare
28
26
  // `requirement.functional:` authoring form.
29
27
  const REQUIREMENT_NODE_MARKER = "requirement.";
30
28
 
31
- /** Recursively scans `metaobjects/` for any `.json`/`.yaml`/`.yml` file containing a
32
- * `requirement.*` node marker. Defensive throughout: a missing/unreadable directory
33
- * or file is treated as "not found", never thrown — this is a cheap heuristic, not
34
- * a metamodel load. */
35
- function hasRequirementNodes(cwd: string): boolean {
36
- const root = join(cwd, METADATA_DIR);
37
- if (!existsSync(root)) return false;
38
- const pending: string[] = [root];
39
- while (pending.length > 0) {
40
- const dir = pending.pop()!;
41
- let entries: Dirent[];
42
- try {
43
- entries = readdirSync(dir, { withFileTypes: true });
44
- } catch {
45
- continue; // unreadable directory — skip it, keep scanning siblings
46
- }
47
- for (const entry of entries) {
48
- const full = join(dir, entry.name);
49
- if (entry.isDirectory()) {
50
- pending.push(full);
51
- } else if (METADATA_FILE_PATTERN.test(entry.name)) {
52
- try {
53
- if (readFileSync(full, "utf8").includes(REQUIREMENT_NODE_MARKER)) return true;
54
- } catch { /* unreadable file — treat as no match */ }
55
- }
29
+ /** Scans the project's resolved metadata collection (`resolveCollection` the
30
+ * single authority on where metadata lives, honouring declared `sources` rather
31
+ * than assuming `metaobjects/`) for any file containing a `requirement.*` node
32
+ * marker. Defensive throughout: no declared sources and no default directory, an
33
+ * unresolvable source, or an unreadable file are all treated as "not found",
34
+ * never thrown — this is a cheap heuristic, not a metamodel load. */
35
+ async function hasRequirementNodes(cwd: string): Promise<boolean> {
36
+ try {
37
+ const { files } = await resolveCollection(cwd);
38
+ for (const file of files) {
39
+ if (readFileSync(file, "utf8").includes(REQUIREMENT_NODE_MARKER)) return true;
56
40
  }
41
+ return false;
42
+ } catch {
43
+ return false;
57
44
  }
58
- return false;
59
45
  }
60
46
 
61
- function probe(cwd: string): ProjectProbe {
47
+ async function probe(cwd: string): Promise<ProjectProbe> {
62
48
  const deps = depNames(cwd);
63
49
  const names = existsSync(cwd) ? readdirSync(cwd) : [];
50
+ const requirementNodes = await hasRequirementNodes(cwd);
64
51
  return {
65
52
  hasDep: (name) => deps.has(name),
66
53
  hasFileMatching: (re) => names.some((n) => re.test(n)),
67
- hasRequirementNodes: () => hasRequirementNodes(cwd),
54
+ hasRequirementNodes: () => requirementNodes,
68
55
  };
69
56
  }
70
57
 
71
58
  /** Resolve the stack: explicit --server/--client overrides take precedence; otherwise detect.
72
59
  * Concern tokens (e.g. requirements) are always OBSERVED from project state, independent of
73
60
  * any --server/--client override — a concern is not a stack axis. */
74
- export function resolveStack(cwd: string, overrides: { servers: string[]; clients: string[] }): Stack {
61
+ export async function resolveStack(cwd: string, overrides: { servers: string[]; clients: string[] }): Promise<Stack> {
75
62
  const validServers = SERVER_LANGS as readonly string[];
76
63
  const validClients = CLIENT_FRAMEWORKS as readonly string[];
77
64
  const oServers = overrides.servers.filter((s): s is ServerLang => validServers.includes(s));
78
65
  const oClients = overrides.clients.filter((c): c is ClientFramework => validClients.includes(c));
79
- const p = probe(cwd);
66
+ const p = await probe(cwd);
80
67
  const concerns = detectConcerns(p);
81
68
  if (oServers.length > 0 || oClients.length > 0) return makeStack(oServers, oClients, concerns);
82
69
  const detected = detectStack(p);
@@ -1,11 +1,12 @@
1
1
  import { existsSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { createRequire } from "node:module";
4
- import { dirname, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { randomBytes } from "node:crypto";
7
7
  import { createJiti } from "jiti";
8
- import type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
8
+ import type { MetaDataTypeProvider, MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
9
+ import { resolveCollection, type Collection } from "@metaobjectsdev/sdk";
9
10
 
10
11
  const CONFIG_FILE = "metaobjects.config.ts";
11
12
 
@@ -149,6 +150,113 @@ function rewriteImportSpecifiers(source: string, aliasMap: Record<string, string
149
150
  return result;
150
151
  }
151
152
 
153
+ /**
154
+ * The `loadMemory` options a project's gen config contributes.
155
+ *
156
+ * One helper rather than a spread pair repeated at each of the eight load sites,
157
+ * because threading one of these and forgetting the other is exactly how #333
158
+ * happened: `providers` reached every command while `libraries` reached none, so a
159
+ * generator was registered FOR the CLI with its input unreachable THROUGH it. Adding a
160
+ * third contribution later should not mean finding eight call sites again.
161
+ *
162
+ * Conditional spreads honour `exactOptionalPropertyTypes` — a key is omitted rather
163
+ * than set to `undefined`, which is what lets `loadMemory` apply its own defaults.
164
+ */
165
+ export function loadMemoryOptionsFrom(
166
+ cfg: Pick<MetaobjectsGenConfig, "providers" | "libraries"> | undefined,
167
+ ): { providers?: readonly MetaDataTypeProvider[]; libraries?: readonly string[] } {
168
+ return {
169
+ ...(cfg?.providers !== undefined ? { providers: cfg.providers } : {}),
170
+ ...(cfg?.libraries !== undefined ? { libraries: cfg.libraries } : {}),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * The directory whose `metaobjects.config.ts` governs a run started in `startDir` —
176
+ * the nearest ancestor carrying that file, `fallback` when there is none.
177
+ *
178
+ * This is a SECOND walk, deliberately separate from `resolveCollection`'s. The two
179
+ * files answer different questions and design §4.6 already says so: `.metaobjects/
180
+ * config.json` declares where metadata comes from — port-neutral, read by all five
181
+ * CLIs, and reasonably repo-global in a polyglot monorepo — while
182
+ * `metaobjects.config.ts` declares how THIS TypeScript package generates code. Reading
183
+ * the second from the directory that carried the first (#326) meant a Maven- or
184
+ * pip-rooted repo with a JS app underneath could not run `meta gen` at all: the
185
+ * collection resolved to the repo root, and the app's config, sitting in the very
186
+ * directory the command was invoked from, was never looked at.
187
+ *
188
+ * Nearest wins, which is what keeps the opposite arm — the divergence commit
189
+ * 0c8fd136e fixed — closed: a run from a subdirectory that declares NO config of its
190
+ * own walks up to the project root's, exactly as before, rather than silently
191
+ * defaulting `columnNamingStrategy` and emitting a migration that renames every
192
+ * column. When the two files sit together, as they do in every `meta init` project,
193
+ * this walk and the collection walk return the same directory by construction.
194
+ *
195
+ * The `fallback` is the collection's directory, so a project with no
196
+ * `metaobjects.config.ts` anywhere keeps today's diagnostics unchanged: this walk can
197
+ * only ever move the answer CLOSER to the invocation, never further away.
198
+ *
199
+ * Boundaries mirror `discoverCollectionRoot` (sdk `discovery.ts`): the marker is
200
+ * checked before `.git`, so a repo-root project sharing its directory with `.git` is
201
+ * still reachable from any subdirectory, and the walk stops there so a monorepo can
202
+ * never adopt a parent checkout's codegen config.
203
+ */
204
+ export function resolveGenConfigDir(startDir: string, fallback: string): string {
205
+ let dir = resolve(startDir);
206
+ for (;;) {
207
+ if (existsSync(join(dir, CONFIG_FILE))) return dir;
208
+ if (existsSync(join(dir, ".git"))) break;
209
+ const parent = dirname(dir);
210
+ if (parent === dir) break;
211
+ dir = parent;
212
+ }
213
+ return fallback;
214
+ }
215
+
216
+ /**
217
+ * The collection a TypeScript package GENERATES FROM (#340).
218
+ *
219
+ * #326/#327 established that the two config files answer different questions, and gave
220
+ * `metaobjects.config.ts` its own walk. This is the remaining half of the same split:
221
+ * a sub-project whose TS config sits below the collection root was still LOADING the
222
+ * ancestor's whole source set, so its `src/generated` absorbed metadata belonging to
223
+ * unrelated parts of the repository — one adopter's web app went from 376 files to 831,
224
+ * the surplus being another module's server-side prompt payload DTOs. It fails OPEN
225
+ * (`tsc` passes, tests pass), so the only symptom is a directory that quietly doubled.
226
+ *
227
+ * The rule: an ancestor `.metaobjects/config.json` is the DEFAULT for a package that
228
+ * declares no sources of its own, never an ADDITION to one that does. So when the TS
229
+ * config sits somewhere the collection did not, that directory is re-resolved as a
230
+ * collection in its own right, and it wins if it actually resolves any metadata.
231
+ *
232
+ * It can only ever NARROW, and only in a shape that could not have worked before:
233
+ * - the two directories coincide (every `meta init` project, and every run from a
234
+ * project root) — returns the original, untouched, without a second resolve;
235
+ * - the sub-project declares no sources — the pinned resolve throws
236
+ * `ERR_SOURCE_UNRESOLVED` or comes back empty, and the ancestor stands, so a
237
+ * package that genuinely lives off an ancestor tree keeps working;
238
+ * - the sub-project has its own metadata — it generates from exactly that, which is
239
+ * what it did before source resolution learned to walk upward.
240
+ *
241
+ * Deliberately NOT applied to `.metaobjects/` STATE. Migrations, snapshots and the
242
+ * operational block stay keyed on the discovered collection's directory (#326 settled
243
+ * that); this narrows what is LOADED, and nothing about where state lives.
244
+ */
245
+ export async function resolveGenCollection(
246
+ collection: Collection,
247
+ genConfigDir: string,
248
+ ): Promise<Collection> {
249
+ if (resolve(genConfigDir) === resolve(collection.configDir)) return collection;
250
+ try {
251
+ const pinned = await resolveCollection(genConfigDir, { explicitDir: genConfigDir });
252
+ return pinned.files.length > 0 ? pinned : collection;
253
+ } catch {
254
+ // The sub-project declares nothing resolvable of its own — inherit, exactly as a
255
+ // package with no config always has.
256
+ return collection;
257
+ }
258
+ }
259
+
152
260
  export async function loadMetaobjectsConfig(projectRoot: string): Promise<MetaobjectsGenConfig> {
153
261
  const fullPath = resolve(projectRoot, CONFIG_FILE);
154
262
  if (!existsSync(fullPath)) {
@@ -254,6 +362,25 @@ export async function loadMetaobjectsConfig(projectRoot: string): Promise<Metaob
254
362
  if (!cfg || typeof cfg !== "object" || !Array.isArray(cfg.generators)) {
255
363
  throw new Error(`metaobjects.config.ts at ${fullPath} did not export a valid MetaobjectsGenConfig (missing 'generators' array).`);
256
364
  }
365
+ // An unknown `libraries` name is a hard config error naming the valid ones, while
366
+ // `librarySources` keeps skipping one silently for a programmatic caller. The two
367
+ // are deliberately different: an API caller asking for a package this version does
368
+ // not ship should still be able to load its own metadata, but a name a human typed
369
+ // into a config file is a mistake worth failing on — skipped, it resurfaces later as
370
+ // ERR_UNRESOLVED_SUPER pointing at the adopter's own metadata, which is the wrong
371
+ // place to send someone looking. Python's `project_config` draws the same line in
372
+ // the same place, and the two ports agreeing here is the point.
373
+ if (cfg.libraries !== undefined && cfg.libraries.length > 0) {
374
+ const { knownLibraryPackages } = await import("@metaobjectsdev/metadata/library");
375
+ const available = knownLibraryPackages();
376
+ const unknown = cfg.libraries.filter((n) => !available.includes(n));
377
+ if (unknown.length > 0) {
378
+ throw new Error(
379
+ `metaobjects.config.ts at ${fullPath}: 'libraries' names unknown package(s) ` +
380
+ `${JSON.stringify(unknown)}; available: ${JSON.stringify(available)}.`,
381
+ );
382
+ }
383
+ }
257
384
  return cfg;
258
385
  } finally {
259
386
  // Restoring is safe: Babel's installer self-neuters after the first call, so
@@ -0,0 +1,75 @@
1
+ // Advisory: is the codegen hash manifest git-ignored?
2
+ //
3
+ // `.gen-state/.hashes.json` is what lets `meta gen` tell "this file is exactly what
4
+ // I wrote" from "somebody edited this" on a machine that did not generate it. If it
5
+ // is ignored, that knowledge never leaves the generating machine, and on every other
6
+ // machine a hand-edited generated file cannot be recognised as such.
7
+ //
8
+ // The reason this check exists rather than only a migration doc: the failure is
9
+ // INVISIBLE. A project with the manifest ignored looks completely normal — `meta gen`
10
+ // succeeds, output is correct — right up until a fresh clone refuses a wall of files
11
+ // or (before the fix) silently ate an edit. This repo's own 0.23.1 lesson was that a
12
+ // fix ships with the check that would have caught it, or it survives as folklore.
13
+ //
14
+ // Deliberately advisory. It cannot be an error: a project that genuinely does not
15
+ // commit generated output has no use for the manifest, and failing its build over an
16
+ // unused artifact would be wrong.
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { spawnSync } from "node:child_process";
20
+ import { join } from "node:path";
21
+ import { log } from "./log.js";
22
+
23
+ /** Project-relative path of the manifest — the one artifact of `.gen-state/` that is
24
+ * meant to be committed. */
25
+ export const HASH_MANIFEST_REL = join(".metaobjects", ".gen-state", ".hashes.json");
26
+
27
+ /**
28
+ * Ask git whether `relPath` is ignored, or undefined when the question cannot be
29
+ * answered (not a repository, git missing).
30
+ *
31
+ * `check-ignore` is the only reliable answer: parsing `.gitignore` by hand would have
32
+ * to reimplement precedence, negation and nested ignore files — and the specific trap
33
+ * this check exists to catch (`.gen-state/` excluding a directory so a `!` negation
34
+ * inside it can never apply) is exactly the rule a hand-rolled parser gets wrong.
35
+ */
36
+ function isGitIgnored(cwd: string, relPath: string): boolean | undefined {
37
+ const gitBin = process.env.META_GEN_GIT ?? "git";
38
+ let res;
39
+ try {
40
+ res = spawnSync(gitBin, ["-C", cwd, "check-ignore", "-q", "--", relPath], {
41
+ encoding: "utf-8",
42
+ });
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ if (res.error !== undefined) return undefined;
47
+ // 0 = ignored, 1 = not ignored, 128 = not a git repo / other git error.
48
+ if (res.status === 0) return true;
49
+ if (res.status === 1) return false;
50
+ return undefined;
51
+ }
52
+
53
+ /**
54
+ * Warn once when the hash manifest is git-ignored, naming the fix.
55
+ *
56
+ * Silent when: it is tracked correctly, the project is not a git repository, git is
57
+ * unavailable, or no generated output has ever been produced (nothing to protect yet).
58
+ * Never throws, never blocks.
59
+ */
60
+ export function warnIfManifestIgnored(cwd: string): void {
61
+ // Nothing generated yet ⇒ nothing at risk ⇒ nothing worth saying.
62
+ if (!existsSync(join(cwd, HASH_MANIFEST_REL))) return;
63
+
64
+ if (isGitIgnored(cwd, HASH_MANIFEST_REL) !== true) return;
65
+
66
+ log.warn(
67
+ `${HASH_MANIFEST_REL} is git-ignored, so it never reaches another machine — ` +
68
+ `on a fresh clone or CI runner 'meta gen' cannot tell your hand edits from its ` +
69
+ `own stale output, and will refuse to overwrite rather than guess. Fix it in ` +
70
+ `.metaobjects/.gitignore by replacing '.gen-state/' with '.gen-state/*' plus ` +
71
+ `'!.gen-state/.hashes.json' (the glob matters — git will not descend into an ` +
72
+ `excluded directory, so a negation under '.gen-state/' can never apply), then ` +
73
+ `commit the manifest. If you deliberately do not commit generated output, ignore this.`,
74
+ );
75
+ }