@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
@@ -48,14 +48,85 @@ async function probe(cwd: string): Promise<ProjectProbe> {
48
48
  };
49
49
  }
50
50
 
51
+ /**
52
+ * Spellings accepted for a stack value that is not the canonical one.
53
+ *
54
+ * `node` is here because the CLI's OWN `--help` gives it as an example of `--server`, and it
55
+ * is the more natural word for a Fastify backend run by the Node CLI. It used to be filtered
56
+ * out silently — see `assertKnownStackValues` for what that silence cost.
57
+ */
58
+ const SERVER_ALIASES: Readonly<Record<string, ServerLang>> = {
59
+ node: "typescript",
60
+ nodejs: "typescript",
61
+ ts: "typescript",
62
+ "c#": "csharp",
63
+ dotnet: "csharp",
64
+ py: "python",
65
+ };
66
+
67
+ const CLIENT_ALIASES: Readonly<Record<string, ClientFramework>> = {
68
+ reactjs: "react",
69
+ "react-query": "tanstack",
70
+ };
71
+
72
+ /** Canonicalize one value, or `undefined` when it names nothing we know. */
73
+ function canonical<T extends string>(
74
+ raw: string,
75
+ valid: readonly string[],
76
+ aliases: Readonly<Record<string, T>>,
77
+ ): T | undefined {
78
+ const v = raw.trim().toLowerCase();
79
+ if (valid.includes(v)) return v as T;
80
+ return aliases[v];
81
+ }
82
+
83
+ /**
84
+ * Refuse an unknown `--server` / `--client` value instead of dropping it.
85
+ *
86
+ * `--server node` — the CLI's own help example — used to exit 0 with no warning and record
87
+ * `"servers": []`. Worse than the missing four reference fragments: a non-empty override
88
+ * array suppresses BOTH the prior manifest's stack and detection, so the emitted context then
89
+ * declared "Stack: no server, no client" and told the agent that this project has no
90
+ * `metaobjects.config.ts` — about a project with a Fastify server, a React client, and the
91
+ * very config file the same CLI had just read. One silently-dropped flag value made three
92
+ * statements false in the artifact whose whole job is to orient an agent correctly.
93
+ *
94
+ * Either accept the value or say so; never neither.
95
+ */
96
+ export function assertKnownStackValues(overrides: { servers: string[]; clients: string[] }): void {
97
+ const bad: string[] = [];
98
+ for (const s of overrides.servers) {
99
+ if (canonical(s, SERVER_LANGS as readonly string[], SERVER_ALIASES) === undefined) {
100
+ bad.push(`--server ${s} (known: ${SERVER_LANGS.join(", ")})`);
101
+ }
102
+ }
103
+ for (const c of overrides.clients) {
104
+ if (canonical(c, CLIENT_FRAMEWORKS as readonly string[], CLIENT_ALIASES) === undefined) {
105
+ bad.push(`--client ${c} (known: ${CLIENT_FRAMEWORKS.join(", ")})`);
106
+ }
107
+ }
108
+ if (bad.length > 0) {
109
+ throw new Error(
110
+ `unknown stack value(s): ${bad.join("; ")}. A value that names nothing is refused rather ` +
111
+ "than dropped: an empty stack suppresses both the prior manifest and detection, and the " +
112
+ "generated agent context then states the project has no server, no client and no " +
113
+ "metaobjects.config.ts.",
114
+ );
115
+ }
116
+ }
117
+
51
118
  /** Resolve the stack: explicit --server/--client overrides take precedence; otherwise detect.
52
119
  * Concern tokens (e.g. requirements) are always OBSERVED from project state, independent of
53
120
  * any --server/--client override — a concern is not a stack axis. */
54
121
  export async function resolveStack(cwd: string, overrides: { servers: string[]; clients: string[] }): Promise<Stack> {
55
122
  const validServers = SERVER_LANGS as readonly string[];
56
123
  const validClients = CLIENT_FRAMEWORKS as readonly string[];
57
- const oServers = overrides.servers.filter((s): s is ServerLang => validServers.includes(s));
58
- const oClients = overrides.clients.filter((c): c is ClientFramework => validClients.includes(c));
124
+ const oServers = overrides.servers
125
+ .map((s) => canonical<ServerLang>(s, validServers, SERVER_ALIASES))
126
+ .filter((s): s is ServerLang => s !== undefined);
127
+ const oClients = overrides.clients
128
+ .map((c) => canonical<ClientFramework>(c, validClients, CLIENT_ALIASES))
129
+ .filter((c): c is ClientFramework => c !== undefined);
59
130
  const p = await probe(cwd);
60
131
  const concerns = detectConcerns(p);
61
132
  if (oServers.length > 0 || oClients.length > 0) return makeStack(oServers, oClients, concerns);
@@ -19,9 +19,11 @@
19
19
  // `.gen-state/.hashes.json` to tell a preserved hand edit from stale output; here
20
20
  // there is no such offer to honour.
21
21
  //
22
- // 2. IT REPORTS A FILE AS EXTRA ONLY WHERE IT OWNS THE DIRECTORY. `docs.outDir`
23
- // defaults to `./docs`, which in a real repository is full of hand-written
24
- // documentation — this repository's own `docs/` holds a hundred such files. Its
22
+ // 2. IT REPORTS A FILE AS EXTRA ONLY WHERE IT OWNS THE DIRECTORY. `docs.outDir` is a
23
+ // directory an adopter chooses, and it may well be one full of hand-written
24
+ // documentation — this repository's own `docs/` holds a hundred such files. (The
25
+ // default moved to `./docs/generated` for exactly that reason: defaulting into the
26
+ // most-owned directory name in the ecosystem contradicted this paragraph.) Its
25
27
  // `api/` subtree is not ours either: on a multi-port project those pages are written
26
28
  // by the OTHER port's docs command (`mvn metaobjects:docs`, `metaobjects docs`,
27
29
  // `dotnet meta docs`), which this gate never runs, so a fresh run here legitimately
@@ -47,8 +49,10 @@
47
49
 
48
50
  import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, lstatSync } from "node:fs";
49
51
  import { tmpdir } from "node:os";
50
- import { join, relative, resolve, isAbsolute, sep } from "node:path";
52
+ import { join, relative, resolve, isAbsolute } from "node:path";
51
53
  import { docsCommand } from "../commands/docs.js";
54
+ import { gitIgnored } from "./git-ignore.js";
55
+ import { toPosix } from "./rel-posix.js";
52
56
 
53
57
  export interface DocsDriftResult {
54
58
  /** True when every page a fresh `meta docs` would write is committed and identical. */
@@ -59,8 +63,19 @@ export interface DocsDriftResult {
59
63
  /** Human-readable, one line per file. */
60
64
  lines: string[];
61
65
  /** 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. */
66
+ * run produced, plus any committed page under `agent/` it no longer emits, MINUS the
67
+ * git-ignored ones. A pure function of (fresh set, repository ignore rules) — never of
68
+ * which files happen to be on this machine's disk. */
63
69
  checked: number;
70
+ /** How many pages a fresh run emits that the project git-ignores, so did not check.
71
+ * Reported on BOTH the passing and the failing line: a gate that quietly checked two
72
+ * of 589 pages and said "no drift" would be indistinguishable from one that checked
73
+ * everything. */
74
+ ignored: number;
75
+ /** Why the ignore rules could not be consulted, when they could not be. Undefined means
76
+ * they were. The gate then falls back to checking EVERY page a fresh run emits — the
77
+ * pre-existing behaviour — and says so rather than degrading silently. */
78
+ ignoreReason?: string | undefined;
64
79
  /** Set when the gate could not run at all. */
65
80
  error?: string;
66
81
  }
@@ -111,7 +126,7 @@ const GENERATED_MARKER = "@generated";
111
126
  * hand-written note inside `agent/` out of it.
112
127
  */
113
128
  function isOurs(docsDir: string, rel: string): boolean {
114
- const normalized = rel.split(sep).join("/");
129
+ const normalized = toPosix(rel);
115
130
  if (!OWNED_PREFIXES.some((p) => normalized.startsWith(p))) return false;
116
131
  try {
117
132
  return readFileSync(join(docsDir, rel), "utf8")
@@ -163,6 +178,7 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
163
178
  driftedFiles: [],
164
179
  lines: [],
165
180
  checked: 0,
181
+ ignored: 0,
166
182
  error:
167
183
  `verify --docs: 'meta docs' exited ${exit}, so the committed pages could not be ` +
168
184
  `compared against a fresh run. Fix that first — the error is above.`,
@@ -176,19 +192,96 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
176
192
  driftedFiles: [],
177
193
  lines: [],
178
194
  checked: 0,
195
+ ignored: 0,
179
196
  error:
180
197
  "verify --docs: a fresh 'meta docs' produced no pages, so there is nothing to " +
181
198
  "compare. Check that this project declares metadata the docs surfaces cover.",
182
199
  };
183
200
  }
184
201
 
202
+ // Committed-but-not-regenerated, inside the directories this command owns.
203
+ const freshSet = new Set(fresh);
204
+ const ownedOrphans = listFiles(docsDir)
205
+ .filter((rel) => !freshSet.has(rel) && isOurs(docsDir, rel))
206
+ .sort();
207
+
208
+ // A page the project deliberately does not commit is not drift.
209
+ //
210
+ // `docs.outDir` is a directory, not a namespace MetaObjects owns — the same
211
+ // jurisdiction ruling 0.24.3 made for `verify --codegen`. But `--codegen` could key
212
+ // on `.gen-state/.hashes.json`, a record of what the generator WROTE, and `meta
213
+ // docs` keeps no such manifest. What it can ask instead is what the PROJECT says:
214
+ // a page under `docs.outDir` that git reports ignored is one the project has
215
+ // declared it does not commit.
216
+ //
217
+ // This is not a guess about intent, it is the project's own statement of it. And it
218
+ // cannot be used to hide a file the project actually tracks: git never reports a
219
+ // TRACKED path as ignored, whatever pattern matches it, so a committed page is
220
+ // always compared.
221
+ //
222
+ // The exemption is computed over the FRESH set by NAME, present on disk or not.
223
+ // That is what makes the verdict machine-independent: a developer box that has run
224
+ // `meta docs` locally has all 589 pages on disk and a CI runner has two, and both
225
+ // must report the same denominator. Deciding per-file-existence would have made
226
+ // `checked` a property of the machine.
227
+ //
228
+ // Without it: an estate generating three surfaces and committing 2 files of 589 —
229
+ // gitignoring the rest with a comment explaining they are a derived view of
230
+ // metadata that is already the source of truth — was told it had 588 drifted pages,
231
+ // of which exactly ONE was real.
232
+ const ignoreCandidates = [...fresh, ...ownedOrphans];
233
+ // honourGlobalExcludes: false — this verdict must be a property of the repository.
234
+ // A gate that passes for the author and fails in CI because of a personal ignore
235
+ // file is worse than no gate.
236
+ const ignoreLookup = gitIgnored(docsDir, ignoreCandidates, {
237
+ honourGlobalExcludes: false,
238
+ });
239
+ const ignored: ReadonlySet<string> =
240
+ "ignored" in ignoreLookup ? ignoreLookup.ignored : new Set<string>();
241
+ const ignoreReason: string | undefined =
242
+ "unavailable" in ignoreLookup ? ignoreLookup.unavailable : undefined;
243
+ const isIgnored = (rel: string): boolean => ignored.has(toPosix(rel));
244
+
245
+ const toCheck = fresh.filter((rel) => !isIgnored(rel));
246
+ const orphans = ownedOrphans.filter((rel) => !isIgnored(rel));
247
+ // BOTH halves. `ignoreCandidates` above tests fresh pages AND owned orphans, so an
248
+ // ignored orphan is dropped from the check exactly like an ignored fresh page — but
249
+ // counting only the fresh half reported a number SMALLER than the work actually
250
+ // skipped. That is the `verify --templates` denominator mistake in miniature (a line
251
+ // describing work the command did not do), which is the very thing the orphan loop
252
+ // below is written to avoid; the two must be consistent about the same set.
253
+ const ignoredCount =
254
+ fresh.length - toCheck.length + (ownedOrphans.length - orphans.length);
255
+
256
+ // A gate asked to check pages that could check NONE must not answer "no drift".
257
+ if (toCheck.length === 0 && orphans.length === 0) {
258
+ return {
259
+ clean: false,
260
+ driftedFiles: [],
261
+ lines: [],
262
+ checked: 0,
263
+ ignored: ignoredCount,
264
+ ignoreReason,
265
+ error:
266
+ `verify --docs: a fresh 'meta docs' produced ${fresh.length} page(s) and every ` +
267
+ `one of them is git-ignored, so there is nothing to compare. Commit the pages ` +
268
+ `you want checked, or run without --docs.`,
269
+ };
270
+ }
271
+
185
272
  const driftedFiles: string[] = [];
186
273
  const lines: string[] = [];
187
- for (const rel of fresh) {
274
+ for (const rel of toCheck) {
188
275
  const committedPath = join(docsDir, rel);
189
276
  if (!existsSync(committedPath)) {
190
277
  driftedFiles.push(rel);
191
- lines.push(`+ ${rel} (a fresh 'meta docs' emits it; not committed)`);
278
+ // When the rules could not be consulted the last clause would be a claim we
279
+ // have not checked, so it is dropped rather than asserted.
280
+ lines.push(
281
+ ignoreReason === undefined
282
+ ? `+ ${rel} (a fresh 'meta docs' emits it; not committed, not git-ignored)`
283
+ : `+ ${rel} (a fresh 'meta docs' emits it; not committed)`,
284
+ );
192
285
  continue;
193
286
  }
194
287
  const a = readFileSync(committedPath, "utf8");
@@ -198,14 +291,9 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
198
291
  lines.push(`~ ${rel} (committed content differs from a fresh 'meta docs')`);
199
292
  }
200
293
  }
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();
294
+ // Counted into `checked` as well as `driftedFiles` so the failing line and the
295
+ // passing line keep dividing by the same set — the `verify --templates` mistake,
296
+ // where a red run and a green run reported different denominators for one project.
209
297
  for (const rel of orphans) {
210
298
  driftedFiles.push(rel);
211
299
  lines.push(`- ${rel} (committed; a fresh 'meta docs' no longer emits it)`);
@@ -214,7 +302,9 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
214
302
  clean: driftedFiles.length === 0,
215
303
  driftedFiles: driftedFiles.sort(),
216
304
  lines,
217
- checked: fresh.length + orphans.length,
305
+ checked: toCheck.length + orphans.length,
306
+ ignored: ignoredCount,
307
+ ignoreReason,
218
308
  };
219
309
  } finally {
220
310
  rmSync(tempRoot, { recursive: true, force: true });
@@ -0,0 +1,159 @@
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 { toPosix } from "./rel-posix.js";
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(toPosix).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(toPosix(rel));
115
+ }
116
+
117
+ /**
118
+ * WHICH rule ignores `rel` — the `.gitignore` file, its line, and the pattern.
119
+ *
120
+ * Advisories that tell an adopter to fix an ignore rule have to name the rule that is
121
+ * actually in force, not a conventional location. The exclusion can live in ANY
122
+ * `.gitignore` up the tree, in `.git/info/exclude`, or in a per-user excludes file, and an
123
+ * advisory that assumes one of them sends the reader to edit a file that does not hold the
124
+ * rule — after which the same advisory prints again, unchanged. `git check-ignore -v`
125
+ * answers it exactly, so nothing has to be assumed.
126
+ *
127
+ * `undefined` when git cannot say or nothing matches. The verdict itself still comes from
128
+ * `isGitIgnored`; this is only for the message, so a failure here degrades the wording
129
+ * rather than the check.
130
+ */
131
+ export function gitIgnoreSource(
132
+ dir: string,
133
+ rel: string,
134
+ opts: GitIgnoreOptions,
135
+ ): { file: string; line: string; pattern: string } | undefined {
136
+ const gitBin = process.env.META_GEN_GIT ?? "git";
137
+ const args: string[] = [];
138
+ if (!opts.honourGlobalExcludes) {
139
+ args.push("-c", `core.excludesFile=${NO_SUCH_EXCLUDES_FILE}`);
140
+ }
141
+ args.push("-C", dir, "check-ignore", "-v", "--", toPosix(rel));
142
+
143
+ let res;
144
+ try {
145
+ res = spawnSync(gitBin, args, { encoding: "utf-8" });
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ if (res.error !== undefined || res.status !== 0) return undefined;
150
+ // `<source>:<line>:<pattern>\t<path>` — the source may itself contain a colon on
151
+ // Windows, so split from the RIGHT of the first tab-delimited field.
152
+ const first = (res.stdout ?? "").split("\n")[0];
153
+ if (first === undefined || first === "") return undefined;
154
+ const lhs = first.split("\t")[0];
155
+ if (lhs === undefined) return undefined;
156
+ const m = /^(.*):(\d+):(.*)$/.exec(lhs);
157
+ if (m === null) return undefined;
158
+ return { file: m[1]!, line: m[2]!, pattern: m[3]! };
159
+ }
@@ -0,0 +1,78 @@
1
+ // Advisory: did the scaffold land in a directory this repository ignores?
2
+ //
3
+ // `meta init` reports "(11 files)". Eight of them go under `.claude/skills/`, and many
4
+ // projects git-ignore `.claude/` — commonly under a comment about credentials, which is a
5
+ // defensible convention, not a mistake. Ignored, those files exist only on the machine that
6
+ // ran the command: never committed, absent from CI and from a fresh clone, invisible to a
7
+ // teammate — while `.metaobjects/.agent-context.json`, which IS committed, records every one
8
+ // of them. The count says eleven; the repository gets three.
9
+ //
10
+ // The failure is INVISIBLE, which is why it is worth a check rather than a doc: nothing
11
+ // errors, nothing looks wrong, and the whole downstream agent-context design is simply void
12
+ // for that repo.
13
+ //
14
+ // Deliberately ADVISORY. Ignoring `.claude/` may be exactly what the project wants — it is a
15
+ // per-repo policy call, and a scaffolder has no standing to fail a build over it.
16
+
17
+ import { isAbsolute, relative } from "node:path";
18
+ import { log } from "./log.js";
19
+ import { gitIgnored } from "./git-ignore.js";
20
+ import { toPosix } from "./rel-posix.js";
21
+
22
+ /**
23
+ * `meta init` records what it created as paths RELATIVE TO THE PROJECT, and one of them
24
+ * carries a display decoration ("CLAUDE.md (created with MetaObjects @import)") because the
25
+ * same array is printed back to the user. Normalize both into a plain project-relative,
26
+ * POSIX-separated path, and drop anything outside the project.
27
+ *
28
+ * The relative case is why this exists at all: `relative(cwd, p)` resolves BOTH arguments
29
+ * against the ambient `process.cwd()`, so handing it an already-relative path under `--cwd`
30
+ * produced a "../"-prefixed result for every entry, the filter dropped them all, and the
31
+ * advisory returned before consulting git. It fired only when the process happened to be
32
+ * running in the project directory — silent on the path the check exists for.
33
+ */
34
+ function toProjectRelative(cwd: string, p: string): string | undefined {
35
+ // Strip a trailing display decoration; handed to git verbatim it is not a path, so a
36
+ // repository ignoring that file was never told about it.
37
+ const bare = p.replace(/\s+\(.*\)$/, "");
38
+ const rel = toPosix(isAbsolute(bare) ? relative(cwd, bare) : bare);
39
+ if (rel.length === 0 || rel === ".." || rel.startsWith("../")) return undefined;
40
+ return rel;
41
+ }
42
+
43
+ /**
44
+ * Warn when some of the scaffolded paths are git-ignored, naming how many and where.
45
+ *
46
+ * Silent when: nothing is ignored, the project is not a git repository, or git cannot be
47
+ * consulted — "cannot say" must never be reported as "there is a problem".
48
+ *
49
+ * `honourGlobalExcludes: false` for the same reason the docs-drift gate uses it: this is a
50
+ * property of the REPOSITORY, and a personal `~/.gitignore` is not something a teammate
51
+ * shares. A warning that fires for one developer and not another is worse than none.
52
+ */
53
+ export function reportIgnoredScaffold(cwd: string, created: readonly string[]): void {
54
+ const rels = created
55
+ .map((p) => toProjectRelative(cwd, p))
56
+ .filter((p): p is string => p !== undefined);
57
+ if (rels.length === 0) return;
58
+
59
+ const res = gitIgnored(cwd, rels, { honourGlobalExcludes: false });
60
+ if ("unavailable" in res) return; // not a git repo, or git could not answer
61
+ const ignored = rels.filter((p) => res.ignored.has(p));
62
+ if (ignored.length === 0) return;
63
+
64
+ // Report the DIRECTORIES rather than every path: an adopter fixes this by editing one
65
+ // `.gitignore` line, and a wall of forty filenames buries that.
66
+ const dirs = [...new Set(ignored.map((p) => {
67
+ const i = p.indexOf("/");
68
+ return i < 0 ? p : p.slice(0, i) + "/";
69
+ }))].sort();
70
+
71
+ log.warn(
72
+ `${ignored.length} of ${rels.length} scaffolded file(s) are git-ignored (${dirs.join(", ")}), ` +
73
+ "so they exist only on this machine — not committed, absent from CI and from a fresh " +
74
+ "clone, invisible to a teammate — while .metaobjects/.agent-context.json is committed " +
75
+ "and records them. If the agent context is meant to be shared, un-ignore those paths; " +
76
+ "if it is deliberately local, nothing to do.",
77
+ );
78
+ }
@@ -0,0 +1,89 @@
1
+ import type { LoaderError } from "@metaobjectsdev/metadata";
2
+
3
+ /**
4
+ * One rendering of a metadata LOAD failure, for every command that can hit one.
5
+ *
6
+ * The loader builds a full ADR-0009 envelope — a stable `code`, the `files` the node came
7
+ * from, the `jsonPath` inside the document, and often `suggestions[]` naming the next step.
8
+ * Five commands then printed `` `failed to load metadata: ${err.message}` `` and threw all
9
+ * of it away. So `ERR_ABSTRACT_SUBTYPE_AUTHORED` reached an adopter as a paragraph with no
10
+ * file, no line, no node name and no code — and `--format json` carried the same bare
11
+ * string, so a CI job could not key on the code ADR-0009 promises and the docs name. Five
12
+ * metadata files made that a `grep`; two hundred would not.
13
+ *
14
+ * This does not invent provenance. It reports exactly what the error carries and stays
15
+ * silent about what it does not, so a caller can never read more precision into the line
16
+ * than the loader actually had.
17
+ */
18
+ export interface LoadErrorReport {
19
+ /** The message plus whatever provenance the envelope carried, for a human. */
20
+ readonly text: string;
21
+ /** ADR-0009 stable code, when the loader attached one. */
22
+ readonly code?: string;
23
+ /** The file(s) the failing node was read from. */
24
+ readonly files?: readonly string[];
25
+ /** JSON path of the failing node within its document. */
26
+ readonly jsonPath?: string;
27
+ /** The loader's own next steps. Printed verbatim; never paraphrased. */
28
+ readonly suggestions?: readonly string[];
29
+ }
30
+
31
+ /** True for a thrown value carrying the loader's ADR-0009 envelope. */
32
+ function isLoaderError(err: unknown): err is LoaderError {
33
+ return (
34
+ typeof err === "object" && err !== null
35
+ && typeof (err as { code?: unknown }).code === "string"
36
+ && typeof (err as { source?: unknown }).source === "object"
37
+ );
38
+ }
39
+
40
+ export function describeLoadError(err: unknown): LoadErrorReport {
41
+ const message = err instanceof Error ? err.message : String(err);
42
+ if (!isLoaderError(err)) return { text: message };
43
+
44
+ const source = err.source as { files?: readonly string[]; jsonPath?: string };
45
+ const files = source.files?.filter((f) => f.length > 0);
46
+ const jsonPath = source.jsonPath;
47
+ const suggestions = err.suggestions?.filter((s) => s.length > 0);
48
+
49
+ // `code` first: it is the one part a machine keys on, and a human scanning a terminal
50
+ // finds it fastest at the front of the line. Then WHERE, which is the question the
51
+ // message itself can never answer.
52
+ const where = [
53
+ files !== undefined && files.length > 0 ? files.join(", ") : undefined,
54
+ jsonPath !== undefined && jsonPath.length > 0 ? `at ${jsonPath}` : undefined,
55
+ ].filter((p): p is string => p !== undefined).join(" ");
56
+
57
+ const head = `${err.code}: ${message}`;
58
+ return {
59
+ text: where.length > 0 ? `${head}\n in ${where}` : head,
60
+ code: err.code,
61
+ ...(files !== undefined && files.length > 0 ? { files } : {}),
62
+ ...(jsonPath !== undefined && jsonPath.length > 0 ? { jsonPath } : {}),
63
+ ...(suggestions !== undefined && suggestions.length > 0 ? { suggestions } : {}),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Print a load failure the one way every command prints it: the described line, then the
69
+ * loader's own `suggestions[]` indented beneath it, verbatim.
70
+ *
71
+ * This lived as a byte-identical private copy in `docs.ts`, `gen.ts` and `migrate.ts`. It
72
+ * belongs beside `describeLoadError` for the same reason that function exists: the rendering
73
+ * of a load failure is ONE decision, and three copies of it are three places for the next
74
+ * improvement to reach two of. `verify.ts`'s variant is deliberately NOT this — it chooses
75
+ * between the loader's suggestions and its own strict-attr hint (ADR-0023), which is a
76
+ * different decision, not a different spelling of this one.
77
+ *
78
+ * `log` is a parameter rather than an import so a test can capture what was printed without
79
+ * reaching into module state.
80
+ */
81
+ export function reportLoadError(
82
+ log: { error: (msg: string) => void },
83
+ prefix: string,
84
+ err: unknown,
85
+ ): void {
86
+ const report = describeLoadError(err);
87
+ log.error(`${prefix}: ${report.text}`);
88
+ for (const s of report.suggestions ?? []) log.error(` ${s}`);
89
+ }