@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
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
24
  docs <metadata> --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,7 +42,7 @@ 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
+ <metadata> Project root to resolve metadata from; passing it SCOPES the run (default: cwd)
43
46
  --out <dir>, -o Output directory for the pages (default: ./docs)
44
47
  --templates <dir> Project root to resolve adopter templates/ overrides (default: <metadata>)
45
48
  --prompts <dir> Extra dir holding prompt .mustache sources for --site (e.g. data/templates/)
@@ -90,7 +93,7 @@ ship in later sub-projects. See https://metaobjects.com for docs.
90
93
 
91
94
  /** Focused per-subcommand usage slices shown by `<cmd> --help`. */
92
95
  const COMMAND_HELP: Record<string, string> = {
93
- gen: `meta gen — codegen TS targets from metaobjects/ entities
96
+ gen: `meta gen — codegen TS targets from your declared metadata
94
97
 
95
98
  USAGE:
96
99
  meta gen [<entity>...] [flags]
@@ -109,7 +112,7 @@ or META_NO_ANTIPATTERNS=1.
109
112
 
110
113
  NOTE: outDir, dialect, dbImport, extStyle are read from metaobjects.config.ts
111
114
  `,
112
- verify: `meta verify — drift gate (templates / DB schema / codegen)
115
+ verify: `meta verify — drift gate (templates / DB schema / codegen / migration replay)
113
116
 
114
117
  USAGE:
115
118
  meta verify [flags]
@@ -121,6 +124,17 @@ FLAGS:
121
124
  --db <url> Schema drift — live DB URL enables the schema-drift gate.
122
125
  Supports: file:, libsql:, postgres:, postgresql:
123
126
  D1 has no URL — use --dialect d1 / --d1 <binding> instead.
127
+ --replay Migration-chain drift — replay the committed chain into an
128
+ EMPTY throwaway database and assert it applies. Needs no --db:
129
+ the engine is in-process (PGlite for postgres, a temp file for
130
+ sqlite) and provisions nothing. Dialect comes from --dialect,
131
+ else migrate.dialect. flyway and d1 are refused.
132
+ Postgres needs the optional peer '@electric-sql/pglite'.
133
+ --replay-snapshot ...and assert the replayed schema EQUALS the committed
134
+ snapshot — catches a hand-edited up.sql that still applies but
135
+ no longer builds the recorded schema. Does NOT apply to a
136
+ project adopted with 'migrate baseline --from-db' (its chain
137
+ does not build the schema); use --replay there.
124
138
  --prompts <dir> Directory of provider-resolved template text (default: prompts)
125
139
  --dialect sqlite|postgres|d1 Optional override (auto-detected from --db URL scheme)
126
140
  --allow <csv> Accepted for parity with 'migrate'; does NOT affect the drift gate
@@ -152,19 +166,23 @@ USAGE:
152
166
  meta docs [<metadata>] [flags]
153
167
 
154
168
  FLAGS:
155
- <metadata> Project root holding metaobjects/ (default: current directory)
169
+ <metadata> Project root to resolve metadata from. Passing it SCOPES the run to
170
+ that directory's own sources; no ancestor .metaobjects/config.json is
171
+ consulted. Omitted (default), the project is discovered by walking up.
156
172
  --out <dir>, -o Output directory for the pages (default: ./docs)
157
173
  --model Emit the markdown model surface (entity + template pages)
158
174
  --api Emit the markdown api surface (generated SDK reference)
175
+ --requirements Emit the declared requirement ledger (requirements.md + .toon).
176
+ Emits nothing when the project declares no requirement.* node.
159
177
  --metamodel Document the built-in metamodel vocabulary (no metadata needed)
160
178
  --site Generate the browsable HTML documentation site (<out>/site/)
161
179
  --scaffold-site Copy the site's templates + assets into codegen/docs-site/ to own (theme) them
162
180
  --templates <dir> Project root to resolve adopter templates/ overrides (default: <metadata>)
163
181
  --prompts <dir> Extra dir holding prompt .mustache sources (for --site) when they
164
- live outside metaobjects/ or templates/ (e.g. data/templates/)
182
+ live outside the metadata sources or templates/ (e.g. data/templates/)
165
183
  --help, -h Print this help
166
184
  `,
167
- init: `meta init — scaffold metaobjects/ + .metaobjects/ in the current repo
185
+ init: `meta init — scaffold a MetaObjects project in the current repo
168
186
 
169
187
  USAGE:
170
188
  meta init [flags]
@@ -176,6 +194,8 @@ FLAGS:
176
194
  --print-only Print what would be written, don't write
177
195
  --d1 Include D1 (Cloudflare) migration config
178
196
  --no-wire-root Skip wiring root metaobjects.config.ts
197
+ --config-only Write only .metaobjects/config.json (no TS scaffold) — declares
198
+ metadata sources for the Node CLI from a Maven- or pip-rooted project
179
199
  --help, -h Print this help
180
200
  `,
181
201
  "agent-docs": `meta agent-docs — scaffold the agent-context (.metaobjects/ always-on files + .claude/skills/)
@@ -271,12 +291,19 @@ export async function run(argv: string[]): Promise<number> {
271
291
  case undefined: {
272
292
  // Content-first no-args view: concise status + next-step help[] rather than
273
293
  // 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
294
+ // "Is this a MetaObjects project?" routes through resolveCollection — the
295
+ // single authority on where metadata lives rather than assuming the
296
+ // default `metaobjects/` directory name. The status line must not assert
297
+ // it either: a project whose config points `sources` at
298
+ // `../shared-model/metadata` would be told "metaobjects/ found", which is
299
+ // false, and one that resolves nothing would be told there is no
300
+ // `metaobjects/` here when the real problem may be a declared source that
301
+ // failed to resolve.
302
+ const metadataResolves = await resolveCollection(cwd).then(() => true).catch(() => false);
303
+ const statusLine = metadataResolves
304
+ ? `meta — MetaObjects CLI (v${VERSION}) · metadata found`
305
+ : `meta — MetaObjects CLI (v${VERSION}) · no MetaObjects project here`;
306
+ const nextSteps = metadataResolves
280
307
  ? [
281
308
  " meta gen Run codegen",
282
309
  " meta verify Check for drift",
@@ -284,7 +311,7 @@ export async function run(argv: string[]): Promise<number> {
284
311
  " meta --help Full command reference",
285
312
  ]
286
313
  : [
287
- " meta init Scaffold metaobjects/ in this directory",
314
+ " meta init Scaffold a MetaObjects project in this directory",
288
315
  " meta --help Full command reference",
289
316
  ];
290
317
  log.info(`${statusLine}\n\n${nextSteps.join("\n")}\n`);
@@ -351,6 +378,10 @@ export async function run(argv: string[]): Promise<number> {
351
378
  const { verifyCommand } = await import("./commands/verify.js");
352
379
  return verifyCommand(rest, cwd);
353
380
  }
381
+ case "upgrade": {
382
+ const { upgradeCommand } = await import("./commands/upgrade.js");
383
+ return upgradeCommand(rest, cwd);
384
+ }
354
385
  case "prompt-snapshot": {
355
386
  const { promptSnapshotCommand } = await import("./commands/prompt-snapshot.js");
356
387
  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,11 @@
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
9
 
10
10
  const CONFIG_FILE = "metaobjects.config.ts";
11
11
 
@@ -149,6 +149,69 @@ function rewriteImportSpecifiers(source: string, aliasMap: Record<string, string
149
149
  return result;
150
150
  }
151
151
 
152
+ /**
153
+ * The `loadMemory` options a project's gen config contributes.
154
+ *
155
+ * One helper rather than a spread pair repeated at each of the eight load sites,
156
+ * because threading one of these and forgetting the other is exactly how #333
157
+ * happened: `providers` reached every command while `libraries` reached none, so a
158
+ * generator was registered FOR the CLI with its input unreachable THROUGH it. Adding a
159
+ * third contribution later should not mean finding eight call sites again.
160
+ *
161
+ * Conditional spreads honour `exactOptionalPropertyTypes` — a key is omitted rather
162
+ * than set to `undefined`, which is what lets `loadMemory` apply its own defaults.
163
+ */
164
+ export function loadMemoryOptionsFrom(
165
+ cfg: Pick<MetaobjectsGenConfig, "providers" | "libraries"> | undefined,
166
+ ): { providers?: readonly MetaDataTypeProvider[]; libraries?: readonly string[] } {
167
+ return {
168
+ ...(cfg?.providers !== undefined ? { providers: cfg.providers } : {}),
169
+ ...(cfg?.libraries !== undefined ? { libraries: cfg.libraries } : {}),
170
+ };
171
+ }
172
+
173
+ /**
174
+ * The directory whose `metaobjects.config.ts` governs a run started in `startDir` —
175
+ * the nearest ancestor carrying that file, `fallback` when there is none.
176
+ *
177
+ * This is a SECOND walk, deliberately separate from `resolveCollection`'s. The two
178
+ * files answer different questions and design §4.6 already says so: `.metaobjects/
179
+ * config.json` declares where metadata comes from — port-neutral, read by all five
180
+ * CLIs, and reasonably repo-global in a polyglot monorepo — while
181
+ * `metaobjects.config.ts` declares how THIS TypeScript package generates code. Reading
182
+ * the second from the directory that carried the first (#326) meant a Maven- or
183
+ * pip-rooted repo with a JS app underneath could not run `meta gen` at all: the
184
+ * collection resolved to the repo root, and the app's config, sitting in the very
185
+ * directory the command was invoked from, was never looked at.
186
+ *
187
+ * Nearest wins, which is what keeps the opposite arm — the divergence commit
188
+ * 0c8fd136e fixed — closed: a run from a subdirectory that declares NO config of its
189
+ * own walks up to the project root's, exactly as before, rather than silently
190
+ * defaulting `columnNamingStrategy` and emitting a migration that renames every
191
+ * column. When the two files sit together, as they do in every `meta init` project,
192
+ * this walk and the collection walk return the same directory by construction.
193
+ *
194
+ * The `fallback` is the collection's directory, so a project with no
195
+ * `metaobjects.config.ts` anywhere keeps today's diagnostics unchanged: this walk can
196
+ * only ever move the answer CLOSER to the invocation, never further away.
197
+ *
198
+ * Boundaries mirror `discoverCollectionRoot` (sdk `discovery.ts`): the marker is
199
+ * checked before `.git`, so a repo-root project sharing its directory with `.git` is
200
+ * still reachable from any subdirectory, and the walk stops there so a monorepo can
201
+ * never adopt a parent checkout's codegen config.
202
+ */
203
+ export function resolveGenConfigDir(startDir: string, fallback: string): string {
204
+ let dir = resolve(startDir);
205
+ for (;;) {
206
+ if (existsSync(join(dir, CONFIG_FILE))) return dir;
207
+ if (existsSync(join(dir, ".git"))) break;
208
+ const parent = dirname(dir);
209
+ if (parent === dir) break;
210
+ dir = parent;
211
+ }
212
+ return fallback;
213
+ }
214
+
152
215
  export async function loadMetaobjectsConfig(projectRoot: string): Promise<MetaobjectsGenConfig> {
153
216
  const fullPath = resolve(projectRoot, CONFIG_FILE);
154
217
  if (!existsSync(fullPath)) {
@@ -254,6 +317,25 @@ export async function loadMetaobjectsConfig(projectRoot: string): Promise<Metaob
254
317
  if (!cfg || typeof cfg !== "object" || !Array.isArray(cfg.generators)) {
255
318
  throw new Error(`metaobjects.config.ts at ${fullPath} did not export a valid MetaobjectsGenConfig (missing 'generators' array).`);
256
319
  }
320
+ // An unknown `libraries` name is a hard config error naming the valid ones, while
321
+ // `librarySources` keeps skipping one silently for a programmatic caller. The two
322
+ // are deliberately different: an API caller asking for a package this version does
323
+ // not ship should still be able to load its own metadata, but a name a human typed
324
+ // into a config file is a mistake worth failing on — skipped, it resurfaces later as
325
+ // ERR_UNRESOLVED_SUPER pointing at the adopter's own metadata, which is the wrong
326
+ // place to send someone looking. Python's `project_config` draws the same line in
327
+ // the same place, and the two ports agreeing here is the point.
328
+ if (cfg.libraries !== undefined && cfg.libraries.length > 0) {
329
+ const { knownLibraryPackages } = await import("@metaobjectsdev/metadata/library");
330
+ const available = knownLibraryPackages();
331
+ const unknown = cfg.libraries.filter((n) => !available.includes(n));
332
+ if (unknown.length > 0) {
333
+ throw new Error(
334
+ `metaobjects.config.ts at ${fullPath}: 'libraries' names unknown package(s) ` +
335
+ `${JSON.stringify(unknown)}; available: ${JSON.stringify(available)}.`,
336
+ );
337
+ }
338
+ }
257
339
  return cfg;
258
340
  } finally {
259
341
  // 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
+ }