@metaobjectsdev/cli 1.0.0-rc.3 → 1.0.0-rc.5

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 (56) hide show
  1. package/dist/src/commands/gen.d.ts +3 -0
  2. package/dist/src/commands/gen.d.ts.map +1 -1
  3. package/dist/src/commands/gen.js +5 -3
  4. package/dist/src/commands/gen.js.map +1 -1
  5. package/dist/src/commands/init.d.ts.map +1 -1
  6. package/dist/src/commands/init.js +237 -29
  7. package/dist/src/commands/init.js.map +1 -1
  8. package/dist/src/commands/migrate.d.ts +1 -0
  9. package/dist/src/commands/migrate.d.ts.map +1 -1
  10. package/dist/src/commands/migrate.js +1 -1
  11. package/dist/src/commands/migrate.js.map +1 -1
  12. package/dist/src/commands/verify.d.ts.map +1 -1
  13. package/dist/src/commands/verify.js +39 -9
  14. package/dist/src/commands/verify.js.map +1 -1
  15. package/dist/src/index.d.ts +2 -0
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +30 -2
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/lib/args.d.ts +228 -0
  20. package/dist/src/lib/args.d.ts.map +1 -1
  21. package/dist/src/lib/args.js +88 -72
  22. package/dist/src/lib/args.js.map +1 -1
  23. package/dist/src/lib/docs-drift.d.ts +12 -1
  24. package/dist/src/lib/docs-drift.d.ts.map +1 -1
  25. package/dist/src/lib/docs-drift.js +71 -11
  26. package/dist/src/lib/docs-drift.js.map +1 -1
  27. package/dist/src/lib/git-ignore.d.ts +40 -0
  28. package/dist/src/lib/git-ignore.d.ts.map +1 -0
  29. package/dist/src/lib/git-ignore.js +77 -0
  30. package/dist/src/lib/git-ignore.js.map +1 -0
  31. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  32. package/dist/src/lib/load-metaobjects-config.js +65 -1
  33. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  34. package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -1
  35. package/dist/src/lib/manifest-ignored-check.js +5 -31
  36. package/dist/src/lib/manifest-ignored-check.js.map +1 -1
  37. package/dist/src/lib/output.d.ts +10 -1
  38. package/dist/src/lib/output.d.ts.map +1 -1
  39. package/dist/src/lib/output.js +15 -6
  40. package/dist/src/lib/output.js.map +1 -1
  41. package/dist/src/lib/requirement-lint.d.ts.map +1 -1
  42. package/dist/src/lib/requirement-lint.js +25 -5
  43. package/dist/src/lib/requirement-lint.js.map +1 -1
  44. package/package.json +10 -10
  45. package/src/commands/gen.ts +5 -3
  46. package/src/commands/init.ts +247 -33
  47. package/src/commands/migrate.ts +1 -1
  48. package/src/commands/verify.ts +46 -9
  49. package/src/index.ts +30 -2
  50. package/src/lib/args.ts +96 -72
  51. package/src/lib/docs-drift.ts +92 -12
  52. package/src/lib/git-ignore.ts +115 -0
  53. package/src/lib/load-metaobjects-config.ts +58 -1
  54. package/src/lib/manifest-ignored-check.ts +5 -28
  55. package/src/lib/output.ts +30 -5
  56. package/src/lib/requirement-lint.ts +26 -5
package/src/index.ts CHANGED
@@ -55,6 +55,12 @@ GLOBAL OPTIONS:
55
55
 
56
56
  GEN FLAGS:
57
57
  --dry-run Compute and print, don't write
58
+ --list Print the generator registry (name, tier, options) and exit —
59
+ no config or metadata required
60
+ --baseline <default|fresh>
61
+ First-time-on-existing-file behavior. Default: write-if-different,
62
+ adopting existing content as the canonical baseline. "fresh"
63
+ overwrites and re-baselines.
58
64
  --no-antipatterns Suppress the advisory "hand-rolled what MetaObjects can model" pass
59
65
  --limit <n|all> How many advisory lines TEXT output prints before it truncates
60
66
  (default 20). Never applies to --format toon/json, which
@@ -127,7 +133,7 @@ ship in later sub-projects. See https://metaobjects.com for docs.
127
133
  `;
128
134
 
129
135
  /** Focused per-subcommand usage slices shown by `<cmd> --help`. */
130
- const COMMAND_HELP: Record<string, string> = {
136
+ export const COMMAND_HELP: Record<string, string> = {
131
137
  gen: `meta gen — codegen TS targets from your declared metadata
132
138
 
133
139
  USAGE:
@@ -135,6 +141,12 @@ USAGE:
135
141
 
136
142
  FLAGS:
137
143
  --dry-run Compute and print, don't write
144
+ --list Print the generator registry (name, tier, options) and exit —
145
+ no config or metadata required
146
+ --baseline <default|fresh>
147
+ First-time-on-existing-file behavior. Default: write-if-different,
148
+ adopting existing content as the canonical baseline. "fresh"
149
+ overwrites and re-baselines.
138
150
  --no-antipatterns Suppress the advisory "hand-rolled what MetaObjects can model" pass
139
151
  --limit <n|all> Advisory lines TEXT output prints before truncating (default 20)
140
152
  --format <toon|json|text> Output format (global flag; default toon off-TTY)
@@ -182,7 +194,11 @@ FLAGS:
182
194
  --codegen Codegen drift — regenerate to temp dir and diff committed output
183
195
  Needs metaobjects.config.ts; exit 2 if absent.
184
196
  --docs Docs drift — run 'meta docs' to a temp dir and diff docs.outDir
185
- Needs metaobjects.config.ts; exit 2 if absent.
197
+ Needs metaobjects.config.ts; exit 2 if absent. A page the
198
+ project GIT-IGNORES is exempt (docs.outDir is a directory, not
199
+ a namespace MetaObjects owns); the count of exempt pages is
200
+ reported either way, and a run where every page is ignored is
201
+ refused rather than reported clean.
186
202
  --db <url> Schema drift — live DB URL enables the schema-drift gate.
187
203
  Supports: file:, libsql:, postgres:, postgresql:
188
204
  D1 has no URL — use --dialect d1 / --d1 <binding> instead.
@@ -204,6 +220,10 @@ FLAGS:
204
220
  --d1 <binding> D1 binding name from wrangler.toml (only with --dialect d1)
205
221
  --remote Target remote D1 instead of local (only with --dialect d1) —
206
222
  the ONLY way to verify the actual deployed D1 database
223
+ --lax Load with the legacy OPEN attribute set instead of strict
224
+ (ADR-0023). Strict is the default: an undeclared or typo'd own
225
+ @attr fails verify with ERR_UNKNOWN_ATTR. Use only while
226
+ migrating a model onto a registered provider.
207
227
  --no-antipatterns Suppress the advisory "hand-rolled what MetaObjects can model" pass
208
228
  --no-requirement-lint Suppress the advisory requirement AUTHORING lint (not the gate)
209
229
  --limit <n|all> Advisory lines TEXT output prints PER SECTION before truncating
@@ -274,6 +294,14 @@ USAGE:
274
294
 
275
295
  FLAGS:
276
296
  --refresh-docs Refresh .metaobjects/AGENTS.md + CLAUDE.md after CLI upgrades
297
+ --docs-only Write only the agent-context files — no metadata sources, no
298
+ codegen scaffold, no config. Pair with --refresh-docs to update
299
+ an existing project after upgrading.
300
+ --server <lang> Declare a server language for the agent context (repeatable;
301
+ e.g. csharp, kotlin, python, node)
302
+ --client <fw> Declare a client framework for the agent context (repeatable;
303
+ e.g. react, vue)
304
+ --no-skills Skip the .claude/skills/ scaffold
277
305
  --force Overwrite existing files
278
306
  --quiet Suppress output
279
307
  --print-only Print what would be written, don't write
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
  });
@@ -111,16 +117,19 @@ 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
  });
@@ -150,12 +159,15 @@ export interface ExportFlags {
150
159
  out: string | undefined;
151
160
  }
152
161
 
162
+ /** The flag table `parseExportArgs` parses. Exported so the help text can be gated against it. */
163
+ export const EXPORT_OPTIONS = {
164
+ out: { type: "string" },
165
+ } as const;
166
+
153
167
  export function parseExportArgs(argv: string[]): ExportFlags {
154
168
  const { values } = parseArgs({
155
169
  args: argv,
156
- options: {
157
- out: { type: "string" },
158
- },
170
+ options: EXPORT_OPTIONS,
159
171
  strict: true,
160
172
  allowPositionals: false,
161
173
  });
@@ -306,27 +318,30 @@ export interface VerifyFlags {
306
318
  limit: number;
307
319
  }
308
320
 
321
+ /** The flag table `parseVerifyArgs` parses. Exported so the help text can be gated against it. */
322
+ export const VERIFY_OPTIONS = {
323
+ prompts: { type: "string" },
324
+ db: { type: "string" },
325
+ dialect: { type: "string" },
326
+ allow: { type: "string" },
327
+ "skip-schema": { type: "boolean", default: false },
328
+ templates: { type: "boolean", default: false },
329
+ codegen: { type: "boolean", default: false },
330
+ docs: { type: "boolean", default: false },
331
+ replay: { type: "boolean", default: false },
332
+ "replay-snapshot": { type: "boolean", default: false },
333
+ "no-antipatterns": { type: "boolean", default: false },
334
+ "no-requirement-lint": { type: "boolean", default: false },
335
+ lax: { type: "boolean", default: false },
336
+ "d1": { type: "string" },
337
+ "remote": { type: "boolean", default: false },
338
+ "limit": { type: "string" },
339
+ } as const;
340
+
309
341
  export function parseVerifyArgs(argv: string[]): VerifyFlags {
310
342
  const { values } = parseArgs({
311
343
  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
- },
344
+ options: VERIFY_OPTIONS,
330
345
  strict: true,
331
346
  allowPositionals: false,
332
347
  });
@@ -395,13 +410,16 @@ export interface PromptSnapshotFlags {
395
410
  prompts: string | undefined;
396
411
  }
397
412
 
413
+ /** The flag table `parsePromptSnapshotArgs` parses. Exported so the help text can be gated against it. */
414
+ export const PROMPT_SNAPSHOT_OPTIONS = {
415
+ check: { type: "boolean", default: false },
416
+ prompts: { type: "string" },
417
+ } as const;
418
+
398
419
  export function parsePromptSnapshotArgs(argv: string[]): PromptSnapshotFlags {
399
420
  const { values } = parseArgs({
400
421
  args: argv,
401
- options: {
402
- check: { type: "boolean", default: false },
403
- prompts: { type: "string" },
404
- },
422
+ options: PROMPT_SNAPSHOT_OPTIONS,
405
423
  strict: true,
406
424
  allowPositionals: false,
407
425
  });
@@ -447,25 +465,28 @@ export interface MigrateFlags {
447
465
  applyPending: boolean;
448
466
  }
449
467
 
468
+ /** The flag table `parseMigrateArgs` parses. Exported so the help text can be gated against it. */
469
+ export const MIGRATE_OPTIONS = {
470
+ "db": { type: "string" },
471
+ "dialect": { type: "string" },
472
+ "migration-format": { type: "string" },
473
+ "out-dir": { type: "string" },
474
+ "slug": { type: "string" },
475
+ "allow": { type: "string" },
476
+ "on-ambiguous": { type: "string" },
477
+ "dry-run": { type: "boolean", default: false },
478
+ "from-db": { type: "boolean", default: false },
479
+ "d1": { type: "string" },
480
+ "remote": { type: "boolean", default: false },
481
+ "apply": { type: "boolean", default: false },
482
+ "rollback": { type: "string" },
483
+ "yes": { type: "boolean", default: false },
484
+ } as const;
485
+
450
486
  export function parseMigrateArgs(argv: string[]): MigrateFlags {
451
487
  const { values, positionals } = parseArgs({
452
488
  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
- },
489
+ options: MIGRATE_OPTIONS,
469
490
  strict: true,
470
491
  allowPositionals: true,
471
492
  });
@@ -541,13 +562,16 @@ export interface EjectFlags {
541
562
  force: boolean;
542
563
  }
543
564
 
565
+ /** The flag table `parseEjectArgs` parses. Exported so the help text can be gated against it. */
566
+ export const EJECT_OPTIONS = {
567
+ "list": { type: "boolean", default: false },
568
+ "force": { type: "boolean", default: false },
569
+ } as const;
570
+
544
571
  export function parseEjectArgs(argv: string[]): EjectFlags {
545
572
  const { values, positionals } = parseArgs({
546
573
  args: argv,
547
- options: {
548
- "list": { type: "boolean", default: false },
549
- "force": { type: "boolean", default: false },
550
- },
574
+ options: EJECT_OPTIONS,
551
575
  strict: true,
552
576
  allowPositionals: true,
553
577
  });
@@ -49,6 +49,7 @@ import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, lstatSync }
49
49
  import { tmpdir } from "node:os";
50
50
  import { join, relative, resolve, isAbsolute, sep } from "node:path";
51
51
  import { docsCommand } from "../commands/docs.js";
52
+ import { gitIgnored } from "./git-ignore.js";
52
53
 
53
54
  export interface DocsDriftResult {
54
55
  /** True when every page a fresh `meta docs` would write is committed and identical. */
@@ -59,8 +60,19 @@ export interface DocsDriftResult {
59
60
  /** Human-readable, one line per file. */
60
61
  lines: string[];
61
62
  /** The denominator both the passing and the failing report divide by: the pages a fresh
62
- * run produced, plus any committed page under `agent/` it no longer emits. */
63
+ * run produced, plus any committed page under `agent/` it no longer emits, MINUS the
64
+ * git-ignored ones. A pure function of (fresh set, repository ignore rules) — never of
65
+ * which files happen to be on this machine's disk. */
63
66
  checked: number;
67
+ /** How many pages a fresh run emits that the project git-ignores, so did not check.
68
+ * Reported on BOTH the passing and the failing line: a gate that quietly checked two
69
+ * of 589 pages and said "no drift" would be indistinguishable from one that checked
70
+ * everything. */
71
+ ignored: number;
72
+ /** Why the ignore rules could not be consulted, when they could not be. Undefined means
73
+ * they were. The gate then falls back to checking EVERY page a fresh run emits — the
74
+ * pre-existing behaviour — and says so rather than degrading silently. */
75
+ ignoreReason?: string | undefined;
64
76
  /** Set when the gate could not run at all. */
65
77
  error?: string;
66
78
  }
@@ -163,6 +175,7 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
163
175
  driftedFiles: [],
164
176
  lines: [],
165
177
  checked: 0,
178
+ ignored: 0,
166
179
  error:
167
180
  `verify --docs: 'meta docs' exited ${exit}, so the committed pages could not be ` +
168
181
  `compared against a fresh run. Fix that first — the error is above.`,
@@ -176,19 +189,89 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
176
189
  driftedFiles: [],
177
190
  lines: [],
178
191
  checked: 0,
192
+ ignored: 0,
179
193
  error:
180
194
  "verify --docs: a fresh 'meta docs' produced no pages, so there is nothing to " +
181
195
  "compare. Check that this project declares metadata the docs surfaces cover.",
182
196
  };
183
197
  }
184
198
 
199
+ // Committed-but-not-regenerated, inside the directories this command owns.
200
+ const freshSet = new Set(fresh);
201
+ const ownedOrphans = listFiles(docsDir)
202
+ .filter((rel) => !freshSet.has(rel) && isOurs(docsDir, rel))
203
+ .sort();
204
+
205
+ // A page the project deliberately does not commit is not drift.
206
+ //
207
+ // `docs.outDir` is a directory, not a namespace MetaObjects owns — the same
208
+ // jurisdiction ruling 0.24.3 made for `verify --codegen`. But `--codegen` could key
209
+ // on `.gen-state/.hashes.json`, a record of what the generator WROTE, and `meta
210
+ // docs` keeps no such manifest. What it can ask instead is what the PROJECT says:
211
+ // a page under `docs.outDir` that git reports ignored is one the project has
212
+ // declared it does not commit.
213
+ //
214
+ // This is not a guess about intent, it is the project's own statement of it. And it
215
+ // cannot be used to hide a file the project actually tracks: git never reports a
216
+ // TRACKED path as ignored, whatever pattern matches it, so a committed page is
217
+ // always compared.
218
+ //
219
+ // The exemption is computed over the FRESH set by NAME, present on disk or not.
220
+ // That is what makes the verdict machine-independent: a developer box that has run
221
+ // `meta docs` locally has all 589 pages on disk and a CI runner has two, and both
222
+ // must report the same denominator. Deciding per-file-existence would have made
223
+ // `checked` a property of the machine.
224
+ //
225
+ // Without it: an estate generating three surfaces and committing 2 files of 589 —
226
+ // gitignoring the rest with a comment explaining they are a derived view of
227
+ // metadata that is already the source of truth — was told it had 588 drifted pages,
228
+ // of which exactly ONE was real.
229
+ const ignoreCandidates = [...fresh, ...ownedOrphans];
230
+ // honourGlobalExcludes: false — this verdict must be a property of the repository.
231
+ // A gate that passes for the author and fails in CI because of a personal ignore
232
+ // file is worse than no gate.
233
+ const ignoreLookup = gitIgnored(docsDir, ignoreCandidates, {
234
+ honourGlobalExcludes: false,
235
+ });
236
+ const ignored: ReadonlySet<string> =
237
+ "ignored" in ignoreLookup ? ignoreLookup.ignored : new Set<string>();
238
+ const ignoreReason: string | undefined =
239
+ "unavailable" in ignoreLookup ? ignoreLookup.unavailable : undefined;
240
+ const isIgnored = (rel: string): boolean => ignored.has(rel.split(sep).join("/"));
241
+
242
+ const toCheck = fresh.filter((rel) => !isIgnored(rel));
243
+ const orphans = ownedOrphans.filter((rel) => !isIgnored(rel));
244
+ const ignoredCount = fresh.length - toCheck.length;
245
+
246
+ // A gate asked to check pages that could check NONE must not answer "no drift".
247
+ if (toCheck.length === 0 && orphans.length === 0) {
248
+ return {
249
+ clean: false,
250
+ driftedFiles: [],
251
+ lines: [],
252
+ checked: 0,
253
+ ignored: ignoredCount,
254
+ ignoreReason,
255
+ error:
256
+ `verify --docs: a fresh 'meta docs' produced ${fresh.length} page(s) and every ` +
257
+ `one of them is git-ignored, so there is nothing to compare. Commit the pages ` +
258
+ `you want checked, or run without --docs.`,
259
+ };
260
+ }
261
+
185
262
  const driftedFiles: string[] = [];
186
263
  const lines: string[] = [];
187
- for (const rel of fresh) {
264
+ for (const rel of toCheck) {
188
265
  const committedPath = join(docsDir, rel);
189
266
  if (!existsSync(committedPath)) {
190
267
  driftedFiles.push(rel);
191
- lines.push(`+ ${rel} (a fresh 'meta docs' emits it; not committed)`);
268
+ // When the rules could not be consulted the last clause would be a claim we
269
+ // have not checked, so it is dropped rather than asserted.
270
+ lines.push(
271
+ ignoreReason === undefined
272
+ ? `+ ${rel} (a fresh 'meta docs' emits it; not committed, not git-ignored)`
273
+ : `+ ${rel} (a fresh 'meta docs' emits it; not committed)`,
274
+ );
192
275
  continue;
193
276
  }
194
277
  const a = readFileSync(committedPath, "utf8");
@@ -198,14 +281,9 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
198
281
  lines.push(`~ ${rel} (committed content differs from a fresh 'meta docs')`);
199
282
  }
200
283
  }
201
- // Committed-but-not-regenerated, inside the directories this command owns. Counted
202
- // into `checked` as well as `driftedFiles` so the failing line and the passing line
203
- // keep dividing by the same set — the `verify --templates` mistake, where a red run
204
- // and a green run reported different denominators for the same project.
205
- const freshSet = new Set(fresh);
206
- const orphans = listFiles(docsDir)
207
- .filter((rel) => !freshSet.has(rel) && isOurs(docsDir, rel))
208
- .sort();
284
+ // Counted into `checked` as well as `driftedFiles` so the failing line and the
285
+ // passing line keep dividing by the same set — the `verify --templates` mistake,
286
+ // where a red run and a green run reported different denominators for one project.
209
287
  for (const rel of orphans) {
210
288
  driftedFiles.push(rel);
211
289
  lines.push(`- ${rel} (committed; a fresh 'meta docs' no longer emits it)`);
@@ -214,7 +292,9 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
214
292
  clean: driftedFiles.length === 0,
215
293
  driftedFiles: driftedFiles.sort(),
216
294
  lines,
217
- checked: fresh.length + orphans.length,
295
+ checked: toCheck.length + orphans.length,
296
+ ignored: ignoredCount,
297
+ ignoreReason,
218
298
  };
219
299
  } finally {
220
300
  rmSync(tempRoot, { recursive: true, force: true });
@@ -0,0 +1,115 @@
1
+ // "Does this project deliberately not commit these files?" — asked of git, once.
2
+ //
3
+ // `check-ignore` is the only reliable answer. Parsing `.gitignore` by hand would have
4
+ // to reimplement precedence, negation and nested ignore files, and the traps are
5
+ // exactly the rules a hand-rolled parser gets wrong: a directory pattern (`docs/`)
6
+ // makes a `!` negation inside it unreachable, and a TRACKED file is never reported
7
+ // ignored no matter what pattern matches it.
8
+ //
9
+ // That last property is what makes this usable as an exemption: a page the project
10
+ // has committed is always compared, whatever its `.gitignore` says. So a rule built
11
+ // on this cannot be used to hide a file the project actually tracks.
12
+ //
13
+ // Paths are evaluated BY NAME — a path need not exist on disk for git to answer. That
14
+ // is deliberate and load-bearing for the docs gate: the exemption is computed over
15
+ // the set a fresh run WOULD emit, so the verdict is identical on a developer box
16
+ // where those files are present and on a CI runner where they are not.
17
+
18
+ import { spawnSync } from "node:child_process";
19
+ import { sep } from "node:path";
20
+
21
+ /** Ignored paths, or the reason the question could not be asked. */
22
+ export type GitIgnoreResult =
23
+ | { readonly ignored: ReadonlySet<string> }
24
+ | { readonly unavailable: string };
25
+
26
+ export interface GitIgnoreOptions {
27
+ /**
28
+ * Whether a per-user ignore file (`core.excludesFile`) may contribute to the
29
+ * verdict.
30
+ *
31
+ * `false` for anything whose answer must be a property of the REPOSITORY — a gate
32
+ * that passes on the author's machine and fails in CI because of a personal ignore
33
+ * file is worse than no gate. Implemented by pointing `core.excludesFile` at a path
34
+ * that does not exist, which is the only way to neutralise it.
35
+ *
36
+ * `true` where the question is genuinely about this machine — "will this file reach
37
+ * anyone else?" is answered by every rule that applies here, personal ones included.
38
+ *
39
+ * Note `.git/info/exclude` is per-clone and CANNOT be neutralised; it is reported
40
+ * like any other rule, and the caller's count line is what exposes it.
41
+ */
42
+ readonly honourGlobalExcludes: boolean;
43
+ }
44
+
45
+ /** A path that cannot exist, used to neutralise a per-user excludes file. */
46
+ const NO_SUCH_EXCLUDES_FILE = "/dev/null/metaobjects-no-global-excludes";
47
+
48
+ /**
49
+ * Ask git which of `rels` are ignored, relative to `dir`.
50
+ *
51
+ * Returns `unavailable` — never throws, never guesses — when git is not on PATH or
52
+ * `dir` is not inside a repository. A caller must decide what that means for it;
53
+ * silently treating "cannot say" as "nothing is ignored" is a fallback that lies, and
54
+ * the callers here say so out loud instead.
55
+ */
56
+ export function gitIgnored(
57
+ dir: string,
58
+ rels: readonly string[],
59
+ opts: GitIgnoreOptions,
60
+ ): GitIgnoreResult {
61
+ if (rels.length === 0) return { ignored: new Set() };
62
+
63
+ const gitBin = process.env.META_GEN_GIT ?? "git";
64
+ const args: string[] = [];
65
+ if (!opts.honourGlobalExcludes) {
66
+ args.push("-c", `core.excludesFile=${NO_SUCH_EXCLUDES_FILE}`);
67
+ }
68
+ args.push("-C", dir, "check-ignore", "--stdin", "-z");
69
+
70
+ // Forward slashes: git speaks them on every platform, and `listFiles` hands us
71
+ // platform separators.
72
+ const input = rels.map((r) => r.split(sep).join("/")).join("\0");
73
+
74
+ let res;
75
+ try {
76
+ res = spawnSync(gitBin, args, { encoding: "utf-8", input });
77
+ } catch (err) {
78
+ return { unavailable: `git could not be run (${(err as Error).message})` };
79
+ }
80
+ if (res.error !== undefined) {
81
+ return { unavailable: "git is not on PATH" };
82
+ }
83
+ // 0 = at least one ignored, 1 = none ignored, anything else = not a repository
84
+ // (or a git error), which is a different fact from "nothing is ignored".
85
+ if (res.status === 1) return { ignored: new Set() };
86
+ if (res.status !== 0) {
87
+ const stderr = (res.stderr ?? "").split("\n")[0]?.trim();
88
+ return {
89
+ unavailable:
90
+ stderr !== undefined && stderr !== ""
91
+ ? stderr
92
+ : `'${dir}' is not inside a git repository`,
93
+ };
94
+ }
95
+
96
+ const ignored = new Set(
97
+ (res.stdout ?? "").split("\0").map((p) => p.trim()).filter((p) => p !== ""),
98
+ );
99
+ return { ignored };
100
+ }
101
+
102
+ /**
103
+ * Single-path convenience: true / false / undefined when git cannot say.
104
+ *
105
+ * `honourGlobalExcludes` is the caller's decision — see the option's doc.
106
+ */
107
+ export function isGitIgnored(
108
+ dir: string,
109
+ rel: string,
110
+ opts: GitIgnoreOptions,
111
+ ): boolean | undefined {
112
+ const res = gitIgnored(dir, [rel], opts);
113
+ if ("unavailable" in res) return undefined;
114
+ return res.ignored.has(rel.split(sep).join("/"));
115
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, unlinkSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { readFile, writeFile } from "node:fs/promises";
3
3
  import { createRequire } from "node:module";
4
4
  import { dirname, join, resolve } from "node:path";
@@ -351,6 +351,63 @@ export async function loadMetaobjectsConfig(projectRoot: string): Promise<Metaob
351
351
  } catch { /* no ts-poet adjacent to codegen-ts — leave project resolution in place */ }
352
352
  }
353
353
 
354
+ // EVERY DECLARED SUBPATH EXPORT NEEDS ITS OWN ENTRY, because jiti's `alias` matches by
355
+ // PREFIX and then concatenates the remainder onto the mapped value. The mapped value is
356
+ // a FILE, so `@metaobjectsdev/codegen-ts/templates/inferred-types` became
357
+ // `…/codegen-ts/dist/index.js/templates/inferred-types` — a path that has never existed,
358
+ // named in an error message that mentions neither jiti nor the alias map:
359
+ //
360
+ // Cannot find module '…/@metaobjectsdev/codegen-ts/dist/index.js/templates/inferred-types'
361
+ //
362
+ // So every subpath the package deliberately exports was unloadable from an owned
363
+ // generator, and the documented escape (a dynamic `import()`, which this file's own
364
+ // comment offers) failed identically, because it falls back to the same broken matcher.
365
+ // A cold adoption probe hit this retargeting the entity tier off Drizzle and got past it
366
+ // only by reading this file's compiled output — the subpath holds `fieldTsTypeString`,
367
+ // the one neutral MetaField→TypeScript mapper, which anyone replacing `renderDrizzleSchema`
368
+ // needs. `rewriteImportSpecifiers` above was always correct here (it anchors on the
369
+ // closing quote, so a subpath is left alone); only the alias fallback was wrong, and the
370
+ // fallback is what an OWNED GENERATOR gets, since only the config file is pre-processed.
371
+ //
372
+ // DERIVED from each package's own `exports` map rather than listed. A hand-kept list of
373
+ // subpaths is the same defect one level up: the package adds an export, nobody edits this
374
+ // file, and it is unloadable again with the same unrecognisable error. Resolution goes
375
+ // through `createRequire().resolve`, so the exports map — including its conditions — is
376
+ // the authority, and a subpath that does not resolve is skipped rather than guessed at.
377
+ for (const [specifier, resolvedPath] of [...Object.entries(aliasMap)]) {
378
+ if (!specifier.startsWith("@metaobjectsdev/")) continue;
379
+ // The package root is the specifier itself; a specifier that is already a subpath
380
+ // (e.g. ".../generators") contributes nothing further.
381
+ if (specifier.slice("@metaobjectsdev/".length).includes("/")) continue;
382
+ // Read the manifest off the FILE SYSTEM by walking up from the resolved entry, not by
383
+ // requiring `<specifier>/package.json` — these packages do not list `./package.json` in
384
+ // their own exports map, so requiring it throws ERR_PACKAGE_PATH_NOT_EXPORTED and the
385
+ // derivation silently produces nothing. (Which it did, on the first cut of this fix:
386
+ // the gate in test/owned-generator-subpath-import.test.ts stayed red and named the
387
+ // same impossible path, which is exactly what that assertion is for.)
388
+ let manifest: { exports?: Record<string, unknown> } | undefined;
389
+ for (let dir = dirname(resolvedPath); ; dir = dirname(dir)) {
390
+ try {
391
+ const parsed = JSON.parse(readFileSync(resolve(dir, "package.json"), "utf8")) as {
392
+ name?: string;
393
+ exports?: Record<string, unknown>;
394
+ };
395
+ if (parsed.name === specifier) { manifest = parsed; break; }
396
+ } catch { /* not this directory — keep walking */ }
397
+ const parent = dirname(dir);
398
+ if (parent === dir) break; // filesystem root
399
+ }
400
+ if (manifest === undefined) continue; // no readable manifest — leave normal resolution
401
+ for (const key of Object.keys(manifest?.exports ?? {})) {
402
+ if (!key.startsWith("./") || key.includes("*")) continue;
403
+ const sub = `${specifier}/${key.slice(2)}`;
404
+ if (aliasMap[sub] !== undefined) continue; // an explicit CLI_PKG_PATHS entry wins
405
+ try {
406
+ aliasMap[sub] = createRequire(resolvedPath).resolve(sub);
407
+ } catch { /* not resolvable under these conditions — skip rather than invent a path */ }
408
+ }
409
+ }
410
+
354
411
  // Pre-process the config file content to rewrite @metaobjectsdev/* import
355
412
  // specifiers to resolved absolute paths. This is the primary mechanism that
356
413
  // ensures the CLI's own copies are used: jiti's `alias` map is kept below as