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

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 (121) 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 +5 -0
  5. package/dist/src/commands/gen.d.ts.map +1 -1
  6. package/dist/src/commands/gen.js +12 -1
  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 +27 -3
  10. package/dist/src/commands/init.js.map +1 -1
  11. package/dist/src/commands/migrate.d.ts +5 -0
  12. package/dist/src/commands/migrate.d.ts.map +1 -1
  13. package/dist/src/commands/migrate.js +10 -4
  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 +62 -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 +69 -21
  26. package/dist/src/commands/verify.js.map +1 -1
  27. package/dist/src/index.d.ts.map +1 -1
  28. package/dist/src/index.js +37 -18
  29. package/dist/src/index.js.map +1 -1
  30. package/dist/src/lib/advisory.d.ts +6 -0
  31. package/dist/src/lib/advisory.d.ts.map +1 -1
  32. package/dist/src/lib/advisory.js +13 -0
  33. package/dist/src/lib/advisory.js.map +1 -1
  34. package/dist/src/lib/anti-patterns.d.ts.map +1 -1
  35. package/dist/src/lib/anti-patterns.js +8 -4
  36. package/dist/src/lib/anti-patterns.js.map +1 -1
  37. package/dist/src/lib/args.d.ts +23 -12
  38. package/dist/src/lib/args.d.ts.map +1 -1
  39. package/dist/src/lib/args.js +33 -22
  40. package/dist/src/lib/args.js.map +1 -1
  41. package/dist/src/lib/authored-source.d.ts +22 -0
  42. package/dist/src/lib/authored-source.d.ts.map +1 -0
  43. package/dist/src/lib/authored-source.js +46 -0
  44. package/dist/src/lib/authored-source.js.map +1 -0
  45. package/dist/src/lib/base-url-advisory.d.ts +37 -0
  46. package/dist/src/lib/base-url-advisory.d.ts.map +1 -0
  47. package/dist/src/lib/base-url-advisory.js +168 -0
  48. package/dist/src/lib/base-url-advisory.js.map +1 -0
  49. package/dist/src/lib/codegen-drift.d.ts +8 -0
  50. package/dist/src/lib/codegen-drift.d.ts.map +1 -1
  51. package/dist/src/lib/codegen-drift.js +43 -10
  52. package/dist/src/lib/codegen-drift.js.map +1 -1
  53. package/dist/src/lib/detect-stack.d.ts +17 -0
  54. package/dist/src/lib/detect-stack.d.ts.map +1 -1
  55. package/dist/src/lib/detect-stack.js +64 -2
  56. package/dist/src/lib/detect-stack.js.map +1 -1
  57. package/dist/src/lib/docs-drift.d.ts.map +1 -1
  58. package/dist/src/lib/docs-drift.js +16 -7
  59. package/dist/src/lib/docs-drift.js.map +1 -1
  60. package/dist/src/lib/git-ignore.d.ts +19 -0
  61. package/dist/src/lib/git-ignore.d.ts.map +1 -1
  62. package/dist/src/lib/git-ignore.js +46 -3
  63. package/dist/src/lib/git-ignore.js.map +1 -1
  64. package/dist/src/lib/ignored-scaffold-check.d.ts +12 -0
  65. package/dist/src/lib/ignored-scaffold-check.d.ts.map +1 -0
  66. package/dist/src/lib/ignored-scaffold-check.js +75 -0
  67. package/dist/src/lib/ignored-scaffold-check.js.map +1 -0
  68. package/dist/src/lib/load-error.d.ts +46 -0
  69. package/dist/src/lib/load-error.d.ts.map +1 -0
  70. package/dist/src/lib/load-error.js +51 -0
  71. package/dist/src/lib/load-error.js.map +1 -0
  72. package/dist/src/lib/load-metaobjects-config.d.ts +13 -0
  73. package/dist/src/lib/load-metaobjects-config.d.ts.map +1 -1
  74. package/dist/src/lib/load-metaobjects-config.js +54 -5
  75. package/dist/src/lib/load-metaobjects-config.js.map +1 -1
  76. package/dist/src/lib/manifest-ignored-check.d.ts.map +1 -1
  77. package/dist/src/lib/manifest-ignored-check.js +19 -6
  78. package/dist/src/lib/manifest-ignored-check.js.map +1 -1
  79. package/dist/src/lib/output.d.ts +13 -1
  80. package/dist/src/lib/output.d.ts.map +1 -1
  81. package/dist/src/lib/output.js +26 -8
  82. package/dist/src/lib/output.js.map +1 -1
  83. package/dist/src/lib/rel-posix.d.ts +5 -0
  84. package/dist/src/lib/rel-posix.d.ts.map +1 -0
  85. package/dist/src/lib/rel-posix.js +23 -0
  86. package/dist/src/lib/rel-posix.js.map +1 -0
  87. package/dist/src/lib/replay-remedy.d.ts +2 -0
  88. package/dist/src/lib/replay-remedy.d.ts.map +1 -0
  89. package/dist/src/lib/replay-remedy.js +69 -0
  90. package/dist/src/lib/replay-remedy.js.map +1 -0
  91. package/dist/src/lib/wrangler.d.ts +17 -2
  92. package/dist/src/lib/wrangler.d.ts.map +1 -1
  93. package/dist/src/lib/wrangler.js +51 -2
  94. package/dist/src/lib/wrangler.js.map +1 -1
  95. package/package.json +10 -10
  96. package/src/commands/docs.ts +51 -12
  97. package/src/commands/gen.ts +14 -1
  98. package/src/commands/init.ts +27 -3
  99. package/src/commands/migrate.ts +12 -4
  100. package/src/commands/prompt-snapshot.ts +2 -1
  101. package/src/commands/types.ts +12 -3
  102. package/src/commands/upgrade.ts +69 -4
  103. package/src/commands/verify.ts +74 -23
  104. package/src/index.ts +37 -18
  105. package/src/lib/advisory.ts +15 -0
  106. package/src/lib/anti-patterns.ts +7 -5
  107. package/src/lib/args.ts +60 -39
  108. package/src/lib/authored-source.ts +45 -0
  109. package/src/lib/base-url-advisory.ts +178 -0
  110. package/src/lib/codegen-drift.ts +52 -9
  111. package/src/lib/detect-stack.ts +73 -2
  112. package/src/lib/docs-drift.ts +17 -7
  113. package/src/lib/git-ignore.ts +47 -3
  114. package/src/lib/ignored-scaffold-check.ts +78 -0
  115. package/src/lib/load-error.ts +89 -0
  116. package/src/lib/load-metaobjects-config.ts +53 -5
  117. package/src/lib/manifest-ignored-check.ts +21 -6
  118. package/src/lib/output.ts +36 -8
  119. package/src/lib/rel-posix.ts +25 -0
  120. package/src/lib/replay-remedy.ts +78 -0
  121. package/src/lib/wrangler.ts +52 -4
@@ -0,0 +1,178 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { relPosix } from "./rel-posix.js";
4
+ import { looksBundled } from "./authored-source.js";
5
+
6
+ /**
7
+ * F52 — a provider mounted with no `baseUrl` on a project whose `apiPrefix` is not empty.
8
+ *
9
+ * 1.0 moved `$apiPrefix` out of the entity descriptor: a browser's base URL is a
10
+ * DEPLOYMENT fact, so the provider supplies it at runtime. The migration note argues the
11
+ * rename is safe because `<EntityFetcherProvider value={…}>` no longer typechecks — and
12
+ * that is true for the adopter who leaves `value` alone. It says nothing about the
13
+ * adopter who reads the note, renames `value` → `fetcher`, and stops: `baseUrl` is
14
+ * OPTIONAL with default `""`, so that tree compiles clean and every generated hook has
15
+ * quietly lost its `/api` segment. Nothing at the type level can see it, and on the
16
+ * estate where this was found nothing else could either — `vite build` transpiles without
17
+ * typechecking, so `build`, 150 tests and every `meta verify` gate were green over a
18
+ * storefront and an admin app whose entire generated CRUD surface would have 404'd.
19
+ *
20
+ * So this is the only gate that can catch it, and it is a WARNING: `baseUrl` is genuinely
21
+ * optional, a project may serve its API at the origin root, and a prefix can legitimately
22
+ * come from somewhere this scan cannot read. It never touches an exit code.
23
+ *
24
+ * It fires ONLY when `apiPrefix` is non-empty — the `meta init` scaffold sets `""`, which
25
+ * is exactly why the trap is easy to ship: the default-configured project is the one the
26
+ * design was reasoned about.
27
+ */
28
+ export interface BaseUrlFinding {
29
+ file: string;
30
+ line: number;
31
+ /** The provider construct found without a base. */
32
+ construct: string;
33
+ message: string;
34
+ }
35
+
36
+ const IGNORE_SEGMENTS = new Set([
37
+ "node_modules", ".git", "dist", "build", ".next", ".turbo", ".output",
38
+ "coverage", ".metaobjects", ".wrangler",
39
+ ]);
40
+ const SCAN_EXT = /\.(?:ts|tsx|js|jsx|mjs)$/;
41
+ const SKIP_FILE = /(?:\.d\.ts$|\.test\.|\.spec\.)/;
42
+
43
+ /**
44
+ * The two ways a base is supplied, one per client tier.
45
+ *
46
+ * Matched across the WHOLE opening construct rather than a single line, because a JSX
47
+ * provider is nearly always written multi-line — a per-line rule (which is what the
48
+ * anti-pattern scan uses) would miss the formatting every real codebase has.
49
+ */
50
+ const PROVIDERS: readonly { construct: string; open: RegExp; kind: "jsx" | "call" }[] = [
51
+ { construct: "<EntityFetcherProvider>", open: /<EntityFetcherProvider\b/g, kind: "jsx" },
52
+ { construct: "provideEntityFetcher()", open: /provideEntityFetcher\s*\(/g, kind: "call" },
53
+ ];
54
+
55
+ /**
56
+ * The text of the opening construct that begins at `start`.
57
+ *
58
+ * JSX: up to the `>` that closes the opening TAG. Call this a one-file tokenizer rather
59
+ * than a bracket count, because a bracket count is what the first version was and it was
60
+ * wrong in both directions:
61
+ *
62
+ * <EntityFetcherProvider title="a > b" fetcher={f} baseUrl="/api">
63
+ * → the `>` inside the STRING ended the tag before `baseUrl`, warning about a
64
+ * correct provider.
65
+ * <EntityFetcherProvider fetcher={mk(")")}>
66
+ * → the unbalanced `)` inside a string meant depth never returned to 0, so the
67
+ * 4000-char cap swallowed an unrelated `baseUrl` later in the file and SUPPRESSED
68
+ * a real finding.
69
+ *
70
+ * So string literals (all three quote styles, with escapes), line comments and block
71
+ * comments are skipped rather than counted. A regex literal is deliberately NOT handled —
72
+ * telling `/` division from a regex needs real parsing, and a regex inside a JSX opening
73
+ * tag is vanishingly rare next to the cost of getting it wrong.
74
+ *
75
+ * Call: to the `)` balancing the argument list, so a nested object or arrow does not end
76
+ * it early.
77
+ *
78
+ * Both bail after a generous cap rather than scanning a whole minified file when a
79
+ * construct is unterminated.
80
+ */
81
+ function constructText(src: string, start: number, kind: "jsx" | "call"): string {
82
+ const limit = Math.min(src.length, start + 4000);
83
+ let depth = 0;
84
+ for (let i = start; i < limit; i++) {
85
+ const c = src[i]!;
86
+
87
+ // --- skip what is not code -------------------------------------------------
88
+ if (c === '"' || c === "'" || c === "`") {
89
+ i++;
90
+ while (i < limit && src[i] !== c) { if (src[i] === "\\") i++; i++; }
91
+ continue;
92
+ }
93
+ if (c === "/" && src[i + 1] === "/") {
94
+ while (i < limit && src[i] !== "\n") i++;
95
+ continue;
96
+ }
97
+ if (c === "/" && src[i + 1] === "*") {
98
+ i += 2;
99
+ while (i < limit && !(src[i] === "*" && src[i + 1] === "/")) i++;
100
+ i++;
101
+ continue;
102
+ }
103
+
104
+ // --- code ------------------------------------------------------------------
105
+ if (kind === "jsx" && c === ">" && depth === 0) return src.slice(start, i + 1);
106
+ if (c === "{" || c === "(" || c === "[") depth++;
107
+ else if (c === "}" || c === ")" || c === "]") {
108
+ if (kind === "call" && depth === 1 && c === ")") return src.slice(start, i + 1);
109
+ depth--;
110
+ }
111
+ }
112
+ return src.slice(start, limit);
113
+ }
114
+
115
+ function scanFile(rel: string, src: string, apiPrefix: string): BaseUrlFinding[] {
116
+ const out: BaseUrlFinding[] = [];
117
+ for (const { construct, open, kind } of PROVIDERS) {
118
+ open.lastIndex = 0;
119
+ let m: RegExpExecArray | null;
120
+ while ((m = open.exec(src)) !== null) {
121
+ const text = constructText(src, m.index, kind);
122
+ if (/\bbaseUrl\b/.test(text)) continue;
123
+ const line = src.slice(0, m.index).split("\n").length;
124
+ out.push({
125
+ file: rel, line, construct,
126
+ message:
127
+ `${rel}:${line} — ${construct} is mounted with no baseUrl, but this project's ` +
128
+ `apiPrefix is "${apiPrefix}". Every generated hook fetches a path RELATIVE to ` +
129
+ `baseUrl, which defaults to "", so those requests go to the origin root and miss ` +
130
+ `"${apiPrefix}". Pass baseUrl (a literal, or your build's env var). If the API ` +
131
+ `really is served at the root here, this is a false positive — ignore it.`,
132
+ });
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+
138
+ function walk(dir: string, root: string, acc: BaseUrlFinding[], apiPrefix: string): void {
139
+ let entries;
140
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
141
+ for (const e of entries) {
142
+ if (IGNORE_SEGMENTS.has(e.name)) continue;
143
+ const abs = join(dir, e.name);
144
+ // Symlinked directories are NOT followed — same as the anti-pattern scan beside it.
145
+ //
146
+ // The first version did follow them, with a comment claiming it "never re-enters
147
+ // one". There was no visited set, so `src/loop -> <root>` produced 41 findings for a
148
+ // single file (`src/loop/src/loop/…`), terminating only because Linux's 40-symlink
149
+ // ELOOP limit made readdirSync throw into the catch below. It also meant a link
150
+ // pointing outside the project — a monorepo sibling, a linked package — got walked on
151
+ // every `meta verify`. A provider reachable only through a symlink is reachable
152
+ // through its real path too, so following them buys nothing and the duplicates
153
+ // inflate the count the header prints.
154
+ if (e.isDirectory()) { walk(abs, root, acc, apiPrefix); continue; }
155
+ if (!SCAN_EXT.test(e.name) || SKIP_FILE.test(e.name)) continue;
156
+ let src: string;
157
+ try { src = readFileSync(abs, "utf8"); } catch { continue; }
158
+ if (!src.includes("EntityFetcher")) continue;
159
+ // A bundle is not authored source: the fix belongs in the file it was built FROM, and
160
+ // an estate whose output directory is `public/` proved that no ignore list guesses the
161
+ // name. See lib/authored-source.ts.
162
+ if (looksBundled(src)) continue;
163
+ acc.push(...scanFile(relPosix(root, abs), src, apiPrefix));
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Scan a project tree for providers mounted without a base.
169
+ *
170
+ * Returns [] when `apiPrefix` is empty — there is nothing to lose then, and warning would
171
+ * fire on every scaffolded project.
172
+ */
173
+ export function scanForMissingBaseUrl(root: string, apiPrefix: string): BaseUrlFinding[] {
174
+ if (apiPrefix === "") return [];
175
+ const acc: BaseUrlFinding[] = [];
176
+ walk(root, root, acc, apiPrefix);
177
+ return acc;
178
+ }
@@ -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
  }
@@ -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,9 +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";
52
54
  import { gitIgnored } from "./git-ignore.js";
55
+ import { toPosix } from "./rel-posix.js";
53
56
 
54
57
  export interface DocsDriftResult {
55
58
  /** True when every page a fresh `meta docs` would write is committed and identical. */
@@ -123,7 +126,7 @@ const GENERATED_MARKER = "@generated";
123
126
  * hand-written note inside `agent/` out of it.
124
127
  */
125
128
  function isOurs(docsDir: string, rel: string): boolean {
126
- const normalized = rel.split(sep).join("/");
129
+ const normalized = toPosix(rel);
127
130
  if (!OWNED_PREFIXES.some((p) => normalized.startsWith(p))) return false;
128
131
  try {
129
132
  return readFileSync(join(docsDir, rel), "utf8")
@@ -237,11 +240,18 @@ export async function computeDocsDrift(args: ComputeDocsDriftArgs): Promise<Docs
237
240
  "ignored" in ignoreLookup ? ignoreLookup.ignored : new Set<string>();
238
241
  const ignoreReason: string | undefined =
239
242
  "unavailable" in ignoreLookup ? ignoreLookup.unavailable : undefined;
240
- const isIgnored = (rel: string): boolean => ignored.has(rel.split(sep).join("/"));
243
+ const isIgnored = (rel: string): boolean => ignored.has(toPosix(rel));
241
244
 
242
245
  const toCheck = fresh.filter((rel) => !isIgnored(rel));
243
246
  const orphans = ownedOrphans.filter((rel) => !isIgnored(rel));
244
- const ignoredCount = fresh.length - toCheck.length;
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);
245
255
 
246
256
  // A gate asked to check pages that could check NONE must not answer "no drift".
247
257
  if (toCheck.length === 0 && orphans.length === 0) {
@@ -16,7 +16,7 @@
16
16
  // where those files are present and on a CI runner where they are not.
17
17
 
18
18
  import { spawnSync } from "node:child_process";
19
- import { sep } from "node:path";
19
+ import { toPosix } from "./rel-posix.js";
20
20
 
21
21
  /** Ignored paths, or the reason the question could not be asked. */
22
22
  export type GitIgnoreResult =
@@ -69,7 +69,7 @@ export function gitIgnored(
69
69
 
70
70
  // Forward slashes: git speaks them on every platform, and `listFiles` hands us
71
71
  // platform separators.
72
- const input = rels.map((r) => r.split(sep).join("/")).join("\0");
72
+ const input = rels.map(toPosix).join("\0");
73
73
 
74
74
  let res;
75
75
  try {
@@ -111,5 +111,49 @@ export function isGitIgnored(
111
111
  ): boolean | undefined {
112
112
  const res = gitIgnored(dir, [rel], opts);
113
113
  if ("unavailable" in res) return undefined;
114
- return res.ignored.has(rel.split(sep).join("/"));
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]! };
115
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
+ }