@metaobjectsdev/cli 1.0.0-rc.4 → 1.0.0-rc.6

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 (116) hide show
  1. package/dist/src/commands/docs.d.ts.map +1 -1
  2. package/dist/src/commands/docs.js +41 -9
  3. package/dist/src/commands/docs.js.map +1 -1
  4. package/dist/src/commands/gen.d.ts +8 -0
  5. package/dist/src/commands/gen.d.ts.map +1 -1
  6. package/dist/src/commands/gen.js +17 -4
  7. package/dist/src/commands/gen.js.map +1 -1
  8. package/dist/src/commands/init.d.ts.map +1 -1
  9. package/dist/src/commands/init.js +181 -35
  10. package/dist/src/commands/init.js.map +1 -1
  11. package/dist/src/commands/migrate.d.ts +6 -0
  12. package/dist/src/commands/migrate.d.ts.map +1 -1
  13. package/dist/src/commands/migrate.js +11 -5
  14. package/dist/src/commands/migrate.js.map +1 -1
  15. package/dist/src/commands/prompt-snapshot.d.ts.map +1 -1
  16. package/dist/src/commands/prompt-snapshot.js +2 -1
  17. package/dist/src/commands/prompt-snapshot.js.map +1 -1
  18. package/dist/src/commands/types.d.ts.map +1 -1
  19. package/dist/src/commands/types.js +12 -3
  20. package/dist/src/commands/types.js.map +1 -1
  21. package/dist/src/commands/upgrade.d.ts.map +1 -1
  22. package/dist/src/commands/upgrade.js +38 -4
  23. package/dist/src/commands/upgrade.js.map +1 -1
  24. package/dist/src/commands/verify.d.ts.map +1 -1
  25. package/dist/src/commands/verify.js +108 -30
  26. package/dist/src/commands/verify.js.map +1 -1
  27. package/dist/src/index.d.ts +2 -0
  28. package/dist/src/index.d.ts.map +1 -1
  29. package/dist/src/index.js +55 -8
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/lib/advisory.d.ts +6 -0
  32. package/dist/src/lib/advisory.d.ts.map +1 -1
  33. package/dist/src/lib/advisory.js +13 -0
  34. package/dist/src/lib/advisory.js.map +1 -1
  35. package/dist/src/lib/anti-patterns.d.ts.map +1 -1
  36. package/dist/src/lib/anti-patterns.js +2 -4
  37. package/dist/src/lib/anti-patterns.js.map +1 -1
  38. package/dist/src/lib/args.d.ts +251 -12
  39. package/dist/src/lib/args.d.ts.map +1 -1
  40. package/dist/src/lib/args.js +119 -92
  41. package/dist/src/lib/args.js.map +1 -1
  42. package/dist/src/lib/base-url-advisory.d.ts +37 -0
  43. package/dist/src/lib/base-url-advisory.d.ts.map +1 -0
  44. package/dist/src/lib/base-url-advisory.js +162 -0
  45. package/dist/src/lib/base-url-advisory.js.map +1 -0
  46. package/dist/src/lib/codegen-drift.d.ts +8 -0
  47. package/dist/src/lib/codegen-drift.d.ts.map +1 -1
  48. package/dist/src/lib/codegen-drift.js +43 -10
  49. package/dist/src/lib/codegen-drift.js.map +1 -1
  50. package/dist/src/lib/detect-stack.d.ts +17 -0
  51. package/dist/src/lib/detect-stack.d.ts.map +1 -1
  52. package/dist/src/lib/detect-stack.js +64 -2
  53. package/dist/src/lib/detect-stack.js.map +1 -1
  54. package/dist/src/lib/docs-drift.d.ts +12 -1
  55. package/dist/src/lib/docs-drift.d.ts.map +1 -1
  56. package/dist/src/lib/docs-drift.js +85 -16
  57. package/dist/src/lib/docs-drift.js.map +1 -1
  58. package/dist/src/lib/git-ignore.d.ts +59 -0
  59. package/dist/src/lib/git-ignore.d.ts.map +1 -0
  60. package/dist/src/lib/git-ignore.js +120 -0
  61. package/dist/src/lib/git-ignore.js.map +1 -0
  62. package/dist/src/lib/ignored-scaffold-check.d.ts +12 -0
  63. package/dist/src/lib/ignored-scaffold-check.d.ts.map +1 -0
  64. package/dist/src/lib/ignored-scaffold-check.js +75 -0
  65. package/dist/src/lib/ignored-scaffold-check.js.map +1 -0
  66. package/dist/src/lib/load-error.d.ts +46 -0
  67. package/dist/src/lib/load-error.d.ts.map +1 -0
  68. package/dist/src/lib/load-error.js +51 -0
  69. package/dist/src/lib/load-error.js.map +1 -0
  70. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  71. package/dist/src/lib/load-metaobjects-config.js +65 -1
  72. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  73. package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -1
  74. package/dist/src/lib/manifest-ignored-check.js +23 -36
  75. package/dist/src/lib/manifest-ignored-check.js.map +1 -1
  76. package/dist/src/lib/output.d.ts +22 -1
  77. package/dist/src/lib/output.d.ts.map +1 -1
  78. package/dist/src/lib/output.js +39 -12
  79. package/dist/src/lib/output.js.map +1 -1
  80. package/dist/src/lib/rel-posix.d.ts +5 -0
  81. package/dist/src/lib/rel-posix.d.ts.map +1 -0
  82. package/dist/src/lib/rel-posix.js +23 -0
  83. package/dist/src/lib/rel-posix.js.map +1 -0
  84. package/dist/src/lib/replay-remedy.d.ts +2 -0
  85. package/dist/src/lib/replay-remedy.d.ts.map +1 -0
  86. package/dist/src/lib/replay-remedy.js +69 -0
  87. package/dist/src/lib/replay-remedy.js.map +1 -0
  88. package/dist/src/lib/requirement-lint.d.ts.map +1 -1
  89. package/dist/src/lib/requirement-lint.js +25 -5
  90. package/dist/src/lib/requirement-lint.js.map +1 -1
  91. package/package.json +10 -10
  92. package/src/commands/docs.ts +51 -12
  93. package/src/commands/gen.ts +19 -4
  94. package/src/commands/init.ts +188 -39
  95. package/src/commands/migrate.ts +13 -5
  96. package/src/commands/prompt-snapshot.ts +2 -1
  97. package/src/commands/types.ts +12 -3
  98. package/src/commands/upgrade.ts +44 -4
  99. package/src/commands/verify.ts +120 -32
  100. package/src/index.ts +55 -8
  101. package/src/lib/advisory.ts +15 -0
  102. package/src/lib/anti-patterns.ts +2 -5
  103. package/src/lib/args.ts +154 -109
  104. package/src/lib/base-url-advisory.ts +173 -0
  105. package/src/lib/codegen-drift.ts +52 -9
  106. package/src/lib/detect-stack.ts +73 -2
  107. package/src/lib/docs-drift.ts +107 -17
  108. package/src/lib/git-ignore.ts +159 -0
  109. package/src/lib/ignored-scaffold-check.ts +78 -0
  110. package/src/lib/load-error.ts +89 -0
  111. package/src/lib/load-metaobjects-config.ts +58 -1
  112. package/src/lib/manifest-ignored-check.ts +25 -33
  113. package/src/lib/output.ts +64 -11
  114. package/src/lib/rel-posix.ts +25 -0
  115. package/src/lib/replay-remedy.ts +78 -0
  116. package/src/lib/requirement-lint.ts +26 -5
package/src/lib/args.ts CHANGED
@@ -19,22 +19,25 @@ export interface InitFlags {
19
19
  configOnly: boolean;
20
20
  }
21
21
 
22
+ /** The flag table `parseInitArgs` parses. Exported so the help text can be gated against it. */
23
+ export const INIT_OPTIONS = {
24
+ force: { type: "boolean", default: false },
25
+ quiet: { type: "boolean", default: false },
26
+ "print-only": { type: "boolean", default: false },
27
+ "refresh-docs": { type: "boolean", default: false },
28
+ d1: { type: "boolean", default: false },
29
+ server: { type: "string", multiple: true },
30
+ client: { type: "string", multiple: true },
31
+ "no-skills": { type: "boolean", default: false },
32
+ "no-wire-root": { type: "boolean", default: false },
33
+ "docs-only": { type: "boolean", default: false },
34
+ "config-only": { type: "boolean", default: false },
35
+ } as const;
36
+
22
37
  export function parseInitArgs(argv: string[]): InitFlags {
23
38
  const { values } = parseArgs({
24
39
  args: argv,
25
- options: {
26
- force: { type: "boolean", default: false },
27
- quiet: { type: "boolean", default: false },
28
- "print-only": { type: "boolean", default: false },
29
- "refresh-docs": { type: "boolean", default: false },
30
- d1: { type: "boolean", default: false },
31
- server: { type: "string", multiple: true },
32
- client: { type: "string", multiple: true },
33
- "no-skills": { type: "boolean", default: false },
34
- "no-wire-root": { type: "boolean", default: false },
35
- "docs-only": { type: "boolean", default: false },
36
- "config-only": { type: "boolean", default: false },
37
- },
40
+ options: INIT_OPTIONS,
38
41
  strict: true,
39
42
  allowPositionals: false,
40
43
  });
@@ -65,16 +68,19 @@ export interface AgentDocsFlags {
65
68
  wireRoot: boolean;
66
69
  }
67
70
 
71
+ /** The flag table `parseAgentDocsArgs` parses. Exported so the help text can be gated against it. */
72
+ export const AGENT_DOCS_OPTIONS = {
73
+ server: { type: "string", multiple: true },
74
+ client: { type: "string", multiple: true },
75
+ out: { type: "string" },
76
+ "no-skills": { type: "boolean", default: false },
77
+ "no-wire-root": { type: "boolean", default: false },
78
+ } as const;
79
+
68
80
  export function parseAgentDocsArgs(argv: string[]): AgentDocsFlags {
69
81
  const { values } = parseArgs({
70
82
  args: argv,
71
- options: {
72
- server: { type: "string", multiple: true },
73
- client: { type: "string", multiple: true },
74
- out: { type: "string" },
75
- "no-skills": { type: "boolean", default: false },
76
- "no-wire-root": { type: "boolean", default: false },
77
- },
83
+ options: AGENT_DOCS_OPTIONS,
78
84
  strict: true,
79
85
  allowPositionals: false,
80
86
  });
@@ -94,10 +100,10 @@ export function parseAgentDocsArgs(argv: string[]): AgentDocsFlags {
94
100
  export interface GenFlags {
95
101
  dryRun: boolean;
96
102
  entities: string[];
97
- /** First-time-on-existing-file behavior. Default: write-if-different
98
- * (existing content becomes the canonical baseline). "fresh" → overwrite
99
- * and re-baseline. */
100
- baseline: "default" | "fresh";
103
+ /** First-time-on-existing-file behavior. Default: refuse a file that cannot be
104
+ * proved to be ours. "adopt" → record the file as the merge base and write
105
+ * nothing. "fresh" → overwrite from fresh output and re-baseline. */
106
+ baseline: "default" | "fresh" | "adopt";
101
107
  /** ADR-0021 D3 — print the stable-name generator registry and exit without
102
108
  * running codegen. */
103
109
  list: boolean;
@@ -111,29 +117,37 @@ export interface GenFlags {
111
117
  limit: number;
112
118
  }
113
119
 
120
+ /** The flag table `parseGenArgs` parses. Exported so the help text can be gated against it. */
121
+ export const GEN_OPTIONS = {
122
+ "dry-run": { type: "boolean", default: false },
123
+ "baseline": { type: "string" },
124
+ "list": { type: "boolean", default: false },
125
+ "no-antipatterns": { type: "boolean", default: false },
126
+ "limit": { type: "string" },
127
+ } as const;
128
+
114
129
  export function parseGenArgs(argv: string[]): GenFlags {
115
130
  const { values, positionals } = parseArgs({
116
131
  args: argv,
117
- options: {
118
- "dry-run": { type: "boolean", default: false },
119
- "baseline": { type: "string" },
120
- "list": { type: "boolean", default: false },
121
- "no-antipatterns": { type: "boolean", default: false },
122
- "limit": { type: "string" },
123
- },
132
+ options: GEN_OPTIONS,
124
133
  strict: true,
125
134
  allowPositionals: true,
126
135
  });
127
136
  const baselineRaw = values.baseline as string | undefined;
128
- if (baselineRaw !== undefined && baselineRaw !== "default" && baselineRaw !== "fresh") {
137
+ if (
138
+ baselineRaw !== undefined &&
139
+ baselineRaw !== "default" &&
140
+ baselineRaw !== "fresh" &&
141
+ baselineRaw !== "adopt"
142
+ ) {
129
143
  throw new Error(
130
- `invalid --baseline '${baselineRaw}'; expected 'default' or 'fresh'`,
144
+ `invalid --baseline '${baselineRaw}'; expected 'default', 'adopt' or 'fresh'`,
131
145
  );
132
146
  }
133
147
  return {
134
148
  dryRun: !!values["dry-run"],
135
149
  entities: positionals,
136
- baseline: (baselineRaw as "default" | "fresh" | undefined) ?? "default",
150
+ baseline: (baselineRaw as "default" | "fresh" | "adopt" | undefined) ?? "default",
137
151
  list: !!values.list,
138
152
  noAntipatterns: !!values["no-antipatterns"],
139
153
  // Throws on a bad value; the command layer reports it and exits 2, exactly as
@@ -150,12 +164,15 @@ export interface ExportFlags {
150
164
  out: string | undefined;
151
165
  }
152
166
 
167
+ /** The flag table `parseExportArgs` parses. Exported so the help text can be gated against it. */
168
+ export const EXPORT_OPTIONS = {
169
+ out: { type: "string" },
170
+ } as const;
171
+
153
172
  export function parseExportArgs(argv: string[]): ExportFlags {
154
173
  const { values } = parseArgs({
155
174
  args: argv,
156
- options: {
157
- out: { type: "string" },
158
- },
175
+ options: EXPORT_OPTIONS,
159
176
  strict: true,
160
177
  allowPositionals: false,
161
178
  });
@@ -251,7 +268,7 @@ export interface VerifyFlags {
251
268
  // command layer applies the bare-verify default (= --templates) when none are.
252
269
  /** Run the template/prompt {{field}}↔payload drift gate. */
253
270
  templates: boolean;
254
- /** Run the codegen-drift gate (regenerate-to-temp and diff committed output). */
271
+ /** Run the codegen-drift gate: is the GENERATED contribution of each file current? */
255
272
  codegen: boolean;
256
273
  /**
257
274
  * Run the docs-drift gate: regenerate the `meta docs` surfaces into a temp dir and diff
@@ -260,8 +277,9 @@ export interface VerifyFlags {
260
277
  * A SEPARATE subverb rather than part of `--codegen`, because they check different
261
278
  * trees under different ownership rules: `--codegen` regenerates `outDir`/`targets` and
262
279
  * must respect the hand edits `meta gen` preserves, while the docs tree has no merge,
263
- * no manifest, and lives in a directory (`./docs` by default) full of hand-written
264
- * files it does not own. Folding them together would have to pick one of those rules
280
+ * no manifest, and lives in a directory the adopter chose (`./docs/generated` by
281
+ * default) that may hold hand-written files it does not own. Folding them together
282
+ * would have to pick one of those rules
265
283
  * for both.
266
284
  */
267
285
  docs: boolean;
@@ -297,36 +315,74 @@ export interface VerifyFlags {
297
315
  */
298
316
  lax: boolean;
299
317
  /**
300
- * How many advisory lines TEXT output prints per SECTION before truncating
301
- * (`DEFAULT_ADVISORY_LIMIT`, or Infinity for `--limit all`). Per-section, never
302
- * shared across them — the reason the two caps were separate in the first place
303
- * is that one shared budget lets the authoring lint push every gate warning off
304
- * the end. It never applies to a structured payload, which carries everything.
318
+ * How many advisory lines TEXT output prints per printed LIST before truncating
319
+ * (`DEFAULT_ADVISORY_LIMIT`, or Infinity for `--limit all`). Never shared across
320
+ * lists — one shared budget lets the biggest list push every other finding off the
321
+ * end. It never applies to a structured payload, which carries everything.
322
+ *
323
+ * Per LIST, not per section, and the distinction is real rather than pedantic: the
324
+ * anti-pattern tier prints TWO separately-headed lists (the hand-rolling findings and
325
+ * the F52 missing-`baseUrl` findings) while being ONE section everywhere else — one
326
+ * structured list keyed by `rule`, one `--no-antipatterns` muting both. So that
327
+ * section's text output can reach a MULTIPLE of the cap. Deliberate: a project with 200
328
+ * money-float sites would otherwise never see its `baseUrl` advisory, which is the one
329
+ * that silently drops the API prefix from every generated hook, and starving the small
330
+ * list is the worse failure. Pinned in advisory-structured-output.test.ts.
305
331
  */
306
332
  limit: number;
307
333
  }
308
334
 
335
+ /** The flag table `parseVerifyArgs` parses. Exported so the help text can be gated against it. */
336
+ export const VERIFY_OPTIONS = {
337
+ prompts: { type: "string" },
338
+ db: { type: "string" },
339
+ dialect: { type: "string" },
340
+ allow: { type: "string", multiple: true },
341
+ "skip-schema": { type: "boolean", default: false },
342
+ templates: { type: "boolean", default: false },
343
+ codegen: { type: "boolean", default: false },
344
+ docs: { type: "boolean", default: false },
345
+ replay: { type: "boolean", default: false },
346
+ "replay-snapshot": { type: "boolean", default: false },
347
+ "no-antipatterns": { type: "boolean", default: false },
348
+ "no-requirement-lint": { type: "boolean", default: false },
349
+ lax: { type: "boolean", default: false },
350
+ "d1": { type: "string" },
351
+ "remote": { type: "boolean", default: false },
352
+ "limit": { type: "string" },
353
+ } as const;
354
+
355
+ /**
356
+ * The `--allow` tokens for one invocation, from EVERY occurrence of the flag.
357
+ *
358
+ * `--allow` is comma-separated, and repeating it is the natural reading (it is what
359
+ * `--server` does). It used to take the LAST occurrence only, so
360
+ * `--allow drop-fk --allow drop-view` silently discarded `drop-fk` and then printed
361
+ * "re-run with --allow drop-fk to apply" seven times — an instruction the user had just
362
+ * followed. Both spellings, and any mixture of them, now mean the union.
363
+ *
364
+ * Duplicates collapse: asking for the same permission twice is one permission.
365
+ */
366
+ function parseAllowTokens(raw: string | string[] | undefined): AllowToken[] {
367
+ const occurrences = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw];
368
+ const seen = new Set<string>();
369
+ for (const occurrence of occurrences) {
370
+ for (const tok of occurrence.split(",").map((t) => t.trim()).filter((t) => t.length > 0)) {
371
+ if (!ALLOW_TOKENS.includes(tok as AllowToken)) {
372
+ throw new Error(
373
+ `invalid --allow token '${tok}'; expected one of: ${ALLOW_TOKENS.join(", ")}`,
374
+ );
375
+ }
376
+ seen.add(tok);
377
+ }
378
+ }
379
+ return [...seen] as AllowToken[];
380
+ }
381
+
309
382
  export function parseVerifyArgs(argv: string[]): VerifyFlags {
310
383
  const { values } = parseArgs({
311
384
  args: argv,
312
- options: {
313
- prompts: { type: "string" },
314
- db: { type: "string" },
315
- dialect: { type: "string" },
316
- allow: { type: "string" },
317
- "skip-schema": { type: "boolean", default: false },
318
- templates: { type: "boolean", default: false },
319
- codegen: { type: "boolean", default: false },
320
- docs: { type: "boolean", default: false },
321
- replay: { type: "boolean", default: false },
322
- "replay-snapshot": { type: "boolean", default: false },
323
- "no-antipatterns": { type: "boolean", default: false },
324
- "no-requirement-lint": { type: "boolean", default: false },
325
- lax: { type: "boolean", default: false },
326
- "d1": { type: "string" },
327
- "remote": { type: "boolean", default: false },
328
- "limit": { type: "string" },
329
- },
385
+ options: VERIFY_OPTIONS,
330
386
  strict: true,
331
387
  allowPositionals: false,
332
388
  });
@@ -336,17 +392,7 @@ export function parseVerifyArgs(argv: string[]): VerifyFlags {
336
392
  throw new Error(`invalid --dialect '${dialect}'; expected: ${DIALECTS.join(", ")}`);
337
393
  }
338
394
 
339
- const allowRaw = (values.allow as string | undefined) ?? "";
340
- const allowTokens = allowRaw.length === 0
341
- ? []
342
- : allowRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
343
- for (const tok of allowTokens) {
344
- if (!ALLOW_TOKENS.includes(tok as AllowToken)) {
345
- throw new Error(
346
- `invalid --allow token '${tok}'; expected one of: ${ALLOW_TOKENS.join(", ")}`,
347
- );
348
- }
349
- }
395
+ const allowTokens = parseAllowTokens(values.allow as string | string[] | undefined);
350
396
 
351
397
  const templates = !!values.templates;
352
398
  const codegen = !!values.codegen;
@@ -395,13 +441,16 @@ export interface PromptSnapshotFlags {
395
441
  prompts: string | undefined;
396
442
  }
397
443
 
444
+ /** The flag table `parsePromptSnapshotArgs` parses. Exported so the help text can be gated against it. */
445
+ export const PROMPT_SNAPSHOT_OPTIONS = {
446
+ check: { type: "boolean", default: false },
447
+ prompts: { type: "string" },
448
+ } as const;
449
+
398
450
  export function parsePromptSnapshotArgs(argv: string[]): PromptSnapshotFlags {
399
451
  const { values } = parseArgs({
400
452
  args: argv,
401
- options: {
402
- check: { type: "boolean", default: false },
403
- prompts: { type: "string" },
404
- },
453
+ options: PROMPT_SNAPSHOT_OPTIONS,
405
454
  strict: true,
406
455
  allowPositionals: false,
407
456
  });
@@ -447,25 +496,28 @@ export interface MigrateFlags {
447
496
  applyPending: boolean;
448
497
  }
449
498
 
499
+ /** The flag table `parseMigrateArgs` parses. Exported so the help text can be gated against it. */
500
+ export const MIGRATE_OPTIONS = {
501
+ "db": { type: "string" },
502
+ "dialect": { type: "string" },
503
+ "migration-format": { type: "string" },
504
+ "out-dir": { type: "string" },
505
+ "slug": { type: "string" },
506
+ "allow": { type: "string", multiple: true },
507
+ "on-ambiguous": { type: "string" },
508
+ "dry-run": { type: "boolean", default: false },
509
+ "from-db": { type: "boolean", default: false },
510
+ "d1": { type: "string" },
511
+ "remote": { type: "boolean", default: false },
512
+ "apply": { type: "boolean", default: false },
513
+ "rollback": { type: "string" },
514
+ "yes": { type: "boolean", default: false },
515
+ } as const;
516
+
450
517
  export function parseMigrateArgs(argv: string[]): MigrateFlags {
451
518
  const { values, positionals } = parseArgs({
452
519
  args: argv,
453
- options: {
454
- "db": { type: "string" },
455
- "dialect": { type: "string" },
456
- "migration-format": { type: "string" },
457
- "out-dir": { type: "string" },
458
- "slug": { type: "string" },
459
- "allow": { type: "string" },
460
- "on-ambiguous": { type: "string" },
461
- "dry-run": { type: "boolean", default: false },
462
- "from-db": { type: "boolean", default: false },
463
- "d1": { type: "string" },
464
- "remote": { type: "boolean", default: false },
465
- "apply": { type: "boolean", default: false },
466
- "rollback": { type: "string" },
467
- "yes": { type: "boolean", default: false },
468
- },
520
+ options: MIGRATE_OPTIONS,
469
521
  strict: true,
470
522
  allowPositionals: true,
471
523
  });
@@ -497,17 +549,7 @@ export function parseMigrateArgs(argv: string[]): MigrateFlags {
497
549
  throw new Error(`invalid --on-ambiguous '${onAmb}'; expected: ${ON_AMBIGUOUS.join(", ")}`);
498
550
  }
499
551
 
500
- const allowRaw = (values.allow as string | undefined) ?? "";
501
- const allowTokens = allowRaw.length === 0
502
- ? []
503
- : allowRaw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
504
- for (const tok of allowTokens) {
505
- if (!ALLOW_TOKENS.includes(tok as AllowToken)) {
506
- throw new Error(
507
- `invalid --allow token '${tok}'; expected one of: ${ALLOW_TOKENS.join(", ")}`,
508
- );
509
- }
510
- }
552
+ const allowTokens = parseAllowTokens(values.allow as string | string[] | undefined);
511
553
 
512
554
  return {
513
555
  db: values.db as string | undefined,
@@ -541,13 +583,16 @@ export interface EjectFlags {
541
583
  force: boolean;
542
584
  }
543
585
 
586
+ /** The flag table `parseEjectArgs` parses. Exported so the help text can be gated against it. */
587
+ export const EJECT_OPTIONS = {
588
+ "list": { type: "boolean", default: false },
589
+ "force": { type: "boolean", default: false },
590
+ } as const;
591
+
544
592
  export function parseEjectArgs(argv: string[]): EjectFlags {
545
593
  const { values, positionals } = parseArgs({
546
594
  args: argv,
547
- options: {
548
- "list": { type: "boolean", default: false },
549
- "force": { type: "boolean", default: false },
550
- },
595
+ options: EJECT_OPTIONS,
551
596
  strict: true,
552
597
  allowPositionals: true,
553
598
  });
@@ -0,0 +1,173 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { relPosix } from "./rel-posix.js";
4
+
5
+ /**
6
+ * F52 — a provider mounted with no `baseUrl` on a project whose `apiPrefix` is not empty.
7
+ *
8
+ * 1.0 moved `$apiPrefix` out of the entity descriptor: a browser's base URL is a
9
+ * DEPLOYMENT fact, so the provider supplies it at runtime. The migration note argues the
10
+ * rename is safe because `<EntityFetcherProvider value={…}>` no longer typechecks — and
11
+ * that is true for the adopter who leaves `value` alone. It says nothing about the
12
+ * adopter who reads the note, renames `value` → `fetcher`, and stops: `baseUrl` is
13
+ * OPTIONAL with default `""`, so that tree compiles clean and every generated hook has
14
+ * quietly lost its `/api` segment. Nothing at the type level can see it, and on the
15
+ * estate where this was found nothing else could either — `vite build` transpiles without
16
+ * typechecking, so `build`, 150 tests and every `meta verify` gate were green over a
17
+ * storefront and an admin app whose entire generated CRUD surface would have 404'd.
18
+ *
19
+ * So this is the only gate that can catch it, and it is a WARNING: `baseUrl` is genuinely
20
+ * optional, a project may serve its API at the origin root, and a prefix can legitimately
21
+ * come from somewhere this scan cannot read. It never touches an exit code.
22
+ *
23
+ * It fires ONLY when `apiPrefix` is non-empty — the `meta init` scaffold sets `""`, which
24
+ * is exactly why the trap is easy to ship: the default-configured project is the one the
25
+ * design was reasoned about.
26
+ */
27
+ export interface BaseUrlFinding {
28
+ file: string;
29
+ line: number;
30
+ /** The provider construct found without a base. */
31
+ construct: string;
32
+ message: string;
33
+ }
34
+
35
+ const IGNORE_SEGMENTS = new Set([
36
+ "node_modules", ".git", "dist", "build", ".next", ".turbo", ".output",
37
+ "coverage", ".metaobjects", ".wrangler",
38
+ ]);
39
+ const SCAN_EXT = /\.(?:ts|tsx|js|jsx|mjs)$/;
40
+ const SKIP_FILE = /(?:\.d\.ts$|\.test\.|\.spec\.)/;
41
+
42
+ /**
43
+ * The two ways a base is supplied, one per client tier.
44
+ *
45
+ * Matched across the WHOLE opening construct rather than a single line, because a JSX
46
+ * provider is nearly always written multi-line — a per-line rule (which is what the
47
+ * anti-pattern scan uses) would miss the formatting every real codebase has.
48
+ */
49
+ const PROVIDERS: readonly { construct: string; open: RegExp; kind: "jsx" | "call" }[] = [
50
+ { construct: "<EntityFetcherProvider>", open: /<EntityFetcherProvider\b/g, kind: "jsx" },
51
+ { construct: "provideEntityFetcher()", open: /provideEntityFetcher\s*\(/g, kind: "call" },
52
+ ];
53
+
54
+ /**
55
+ * The text of the opening construct that begins at `start`.
56
+ *
57
+ * JSX: up to the `>` that closes the opening TAG. Call this a one-file tokenizer rather
58
+ * than a bracket count, because a bracket count is what the first version was and it was
59
+ * wrong in both directions:
60
+ *
61
+ * <EntityFetcherProvider title="a > b" fetcher={f} baseUrl="/api">
62
+ * → the `>` inside the STRING ended the tag before `baseUrl`, warning about a
63
+ * correct provider.
64
+ * <EntityFetcherProvider fetcher={mk(")")}>
65
+ * → the unbalanced `)` inside a string meant depth never returned to 0, so the
66
+ * 4000-char cap swallowed an unrelated `baseUrl` later in the file and SUPPRESSED
67
+ * a real finding.
68
+ *
69
+ * So string literals (all three quote styles, with escapes), line comments and block
70
+ * comments are skipped rather than counted. A regex literal is deliberately NOT handled —
71
+ * telling `/` division from a regex needs real parsing, and a regex inside a JSX opening
72
+ * tag is vanishingly rare next to the cost of getting it wrong.
73
+ *
74
+ * Call: to the `)` balancing the argument list, so a nested object or arrow does not end
75
+ * it early.
76
+ *
77
+ * Both bail after a generous cap rather than scanning a whole minified file when a
78
+ * construct is unterminated.
79
+ */
80
+ function constructText(src: string, start: number, kind: "jsx" | "call"): string {
81
+ const limit = Math.min(src.length, start + 4000);
82
+ let depth = 0;
83
+ for (let i = start; i < limit; i++) {
84
+ const c = src[i]!;
85
+
86
+ // --- skip what is not code -------------------------------------------------
87
+ if (c === '"' || c === "'" || c === "`") {
88
+ i++;
89
+ while (i < limit && src[i] !== c) { if (src[i] === "\\") i++; i++; }
90
+ continue;
91
+ }
92
+ if (c === "/" && src[i + 1] === "/") {
93
+ while (i < limit && src[i] !== "\n") i++;
94
+ continue;
95
+ }
96
+ if (c === "/" && src[i + 1] === "*") {
97
+ i += 2;
98
+ while (i < limit && !(src[i] === "*" && src[i + 1] === "/")) i++;
99
+ i++;
100
+ continue;
101
+ }
102
+
103
+ // --- code ------------------------------------------------------------------
104
+ if (kind === "jsx" && c === ">" && depth === 0) return src.slice(start, i + 1);
105
+ if (c === "{" || c === "(" || c === "[") depth++;
106
+ else if (c === "}" || c === ")" || c === "]") {
107
+ if (kind === "call" && depth === 1 && c === ")") return src.slice(start, i + 1);
108
+ depth--;
109
+ }
110
+ }
111
+ return src.slice(start, limit);
112
+ }
113
+
114
+ function scanFile(rel: string, src: string, apiPrefix: string): BaseUrlFinding[] {
115
+ const out: BaseUrlFinding[] = [];
116
+ for (const { construct, open, kind } of PROVIDERS) {
117
+ open.lastIndex = 0;
118
+ let m: RegExpExecArray | null;
119
+ while ((m = open.exec(src)) !== null) {
120
+ const text = constructText(src, m.index, kind);
121
+ if (/\bbaseUrl\b/.test(text)) continue;
122
+ const line = src.slice(0, m.index).split("\n").length;
123
+ out.push({
124
+ file: rel, line, construct,
125
+ message:
126
+ `${rel}:${line} — ${construct} is mounted with no baseUrl, but this project's ` +
127
+ `apiPrefix is "${apiPrefix}". Every generated hook fetches a path RELATIVE to ` +
128
+ `baseUrl, which defaults to "", so those requests go to the origin root and miss ` +
129
+ `"${apiPrefix}". Pass baseUrl (a literal, or your build's env var). If the API ` +
130
+ `really is served at the root here, this is a false positive — ignore it.`,
131
+ });
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ function walk(dir: string, root: string, acc: BaseUrlFinding[], apiPrefix: string): void {
138
+ let entries;
139
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
140
+ for (const e of entries) {
141
+ if (IGNORE_SEGMENTS.has(e.name)) continue;
142
+ const abs = join(dir, e.name);
143
+ // Symlinked directories are NOT followed — same as the anti-pattern scan beside it.
144
+ //
145
+ // The first version did follow them, with a comment claiming it "never re-enters
146
+ // one". There was no visited set, so `src/loop -> <root>` produced 41 findings for a
147
+ // single file (`src/loop/src/loop/…`), terminating only because Linux's 40-symlink
148
+ // ELOOP limit made readdirSync throw into the catch below. It also meant a link
149
+ // pointing outside the project — a monorepo sibling, a linked package — got walked on
150
+ // every `meta verify`. A provider reachable only through a symlink is reachable
151
+ // through its real path too, so following them buys nothing and the duplicates
152
+ // inflate the count the header prints.
153
+ if (e.isDirectory()) { walk(abs, root, acc, apiPrefix); continue; }
154
+ if (!SCAN_EXT.test(e.name) || SKIP_FILE.test(e.name)) continue;
155
+ let src: string;
156
+ try { src = readFileSync(abs, "utf8"); } catch { continue; }
157
+ if (!src.includes("EntityFetcher")) continue;
158
+ acc.push(...scanFile(relPosix(root, abs), src, apiPrefix));
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Scan a project tree for providers mounted without a base.
164
+ *
165
+ * Returns [] when `apiPrefix` is empty — there is nothing to lose then, and warning would
166
+ * fire on every scaffolded project.
167
+ */
168
+ export function scanForMissingBaseUrl(root: string, apiPrefix: string): BaseUrlFinding[] {
169
+ if (apiPrefix === "") return [];
170
+ const acc: BaseUrlFinding[] = [];
171
+ walk(root, root, acc, apiPrefix);
172
+ return acc;
173
+ }
@@ -44,6 +44,7 @@ import {
44
44
  contentHash,
45
45
  readGeneratedHash,
46
46
  listGeneratedPaths,
47
+ NAMES_FILE_SUFFIX,
47
48
  } from "@metaobjectsdev/codegen-ts";
48
49
  import type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts";
49
50
  import type { MetaData } from "@metaobjectsdev/metadata";
@@ -59,6 +60,14 @@ export interface CodegenDriftResult {
59
60
  driftedFiles: string[];
60
61
  /** Human-readable, one-line-per-file drift summary. */
61
62
  lines: string[];
63
+ /**
64
+ * The subset of `driftedFiles` convicted for being a HAND-EDITED names artifact,
65
+ * sorted. Reported rather than left to the caller to re-derive, because the remedy
66
+ * differs in kind: `meta gen` MERGES a hand edit and exits 0, so prescribing it for
67
+ * one of these is an unbreakable loop — the same loop the hand-edit exemption exists
68
+ * to end. Two doors asking one question must not answer it twice.
69
+ */
70
+ handEditedNames: string[];
62
71
  /** Set when the gate could not run (e.g. no outDir to compare against). */
63
72
  error?: string;
64
73
  }
@@ -128,6 +137,7 @@ export async function computeCodegenDrift(
128
137
  clean: false,
129
138
  driftedFiles: [],
130
139
  lines: [],
140
+ handEditedNames: [],
131
141
  error:
132
142
  "verify --codegen: no outDir configured — cannot locate the committed " +
133
143
  "generated output to diff against. Set 'outDir' (and/or per-target " +
@@ -196,6 +206,7 @@ export async function computeCodegenDrift(
196
206
  // Diff each committed outDir against its temp mirror.
197
207
  const driftedFiles = new Set<string>();
198
208
  const lines: string[] = [];
209
+ const handEditedNames = new Set<string>();
199
210
 
200
211
  // Whether we have any record of what we wrote here. With records, the orphan
201
212
  // branch below can scope itself to our own output; with none, it cannot tell
@@ -232,21 +243,53 @@ export async function computeCodegenDrift(
232
243
  } else {
233
244
  const a = readFileSync(join(committed, rel), "utf8");
234
245
  const b = readFileSync(join(fresh, rel), "utf8");
235
- // Not `a !== b` alone: that convicts the hand edit `meta gen` preserved.
236
- // The question this gate can honestly answer is "is the GENERATED
237
- // contribution current?", and the recorded hash answers exactly it.
238
- // FAILS CLOSED, matching `isPristineGenerated`: with no recorded hash
239
- // nothing is proven, so the old byte verdict stands.
240
- if (a !== b && readGeneratedHash(projectGenStateDir, relKey) !== contentHash(b)) {
241
- driftedFiles.add(relKey);
242
- lines.push(`~ ${relKey} (committed content differs from a fresh regen)`);
246
+ if (a !== b) {
247
+ // A `<Entity>Names` artifact is the ONE generated file where a hand edit is
248
+ // itself the defect, so the exemption below does not reach it. RULED: if you
249
+ // want a constant of your own, put it in a file of your own.
250
+ //
251
+ // The exemption exists because `meta gen` MERGES a hand edit and reports
252
+ // "merged", so convicting it printed a remedy that could not work — gen
253
+ // merges again and the next verify fails identically. A names artifact is not
254
+ // that shape. It is a module of physical database names, every value a string:
255
+ // no compiler can see a wrong one, no test reads it, and the whole point of
256
+ // the artifact is that a name is spelled ONCE per run and referenced. So an
257
+ // edited one silently renames a column for every consumer of it, while the
258
+ // gate reports "generated output is in sync with the metadata" — the exact
259
+ // failure the artifact was added to prevent, committed inside the artifact.
260
+ //
261
+ // Keyed on the suffix the emitter and the reference template both spell from
262
+ // one constant. An adopter who owns the generator and RENAMES the artifact
263
+ // opts out of this — stated rather than hidden; the gate cannot recognise a
264
+ // file shape it was never told about.
265
+ if (rel.endsWith(NAMES_FILE_SUFFIX)) {
266
+ driftedFiles.add(relKey);
267
+ handEditedNames.add(relKey);
268
+ lines.push(
269
+ `~ ${relKey} (hand-edited; a names artifact must match a fresh regen exactly — ` +
270
+ `put constants of your own in a file of your own)`,
271
+ );
272
+ } else if (readGeneratedHash(projectGenStateDir, relKey) !== contentHash(b)) {
273
+ // Not `a !== b` alone: that convicts the hand edit `meta gen` preserved.
274
+ // The question this gate can honestly answer is "is the GENERATED
275
+ // contribution current?", and the recorded hash answers exactly it.
276
+ // FAILS CLOSED, matching `isPristineGenerated`: with no recorded hash
277
+ // nothing is proven, so the old byte verdict stands.
278
+ driftedFiles.add(relKey);
279
+ lines.push(`~ ${relKey} (committed content differs from a fresh regen)`);
280
+ }
243
281
  }
244
282
  }
245
283
  }
246
284
  }
247
285
 
248
286
  const sorted = [...driftedFiles].sort();
249
- return { clean: sorted.length === 0, driftedFiles: sorted, lines };
287
+ return {
288
+ clean: sorted.length === 0,
289
+ driftedFiles: sorted,
290
+ lines,
291
+ handEditedNames: [...handEditedNames].sort(),
292
+ };
250
293
  } finally {
251
294
  rmSync(tempRoot, { recursive: true, force: true });
252
295
  }