@metaobjectsdev/codegen-ts 0.7.0-rc.9 → 0.8.0-rc.1

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 (108) hide show
  1. package/dist/generator.d.ts +9 -0
  2. package/dist/generator.d.ts.map +1 -1
  3. package/dist/generator.js.map +1 -1
  4. package/dist/generators/docs-data-builder.d.ts +16 -0
  5. package/dist/generators/docs-data-builder.d.ts.map +1 -0
  6. package/dist/generators/docs-data-builder.js +381 -0
  7. package/dist/generators/docs-data-builder.js.map +1 -0
  8. package/dist/generators/docs-data.d.ts +98 -0
  9. package/dist/generators/docs-data.d.ts.map +1 -0
  10. package/dist/generators/docs-data.js +43 -0
  11. package/dist/generators/docs-data.js.map +1 -0
  12. package/dist/generators/docs-file.d.ts +8 -0
  13. package/dist/generators/docs-file.d.ts.map +1 -0
  14. package/dist/generators/docs-file.js +77 -0
  15. package/dist/generators/docs-file.js.map +1 -0
  16. package/dist/generators/entity-file.d.ts.map +1 -1
  17. package/dist/generators/entity-file.js +7 -0
  18. package/dist/generators/entity-file.js.map +1 -1
  19. package/dist/generators/index.d.ts +5 -0
  20. package/dist/generators/index.d.ts.map +1 -1
  21. package/dist/generators/index.js +4 -0
  22. package/dist/generators/index.js.map +1 -1
  23. package/dist/generators/output-prompt-file.d.ts +9 -0
  24. package/dist/generators/output-prompt-file.d.ts.map +1 -0
  25. package/dist/generators/output-prompt-file.js +51 -0
  26. package/dist/generators/output-prompt-file.js.map +1 -0
  27. package/dist/generators/template-generator.d.ts +41 -0
  28. package/dist/generators/template-generator.d.ts.map +1 -0
  29. package/dist/generators/template-generator.js +62 -0
  30. package/dist/generators/template-generator.js.map +1 -0
  31. package/dist/index.d.ts +7 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +8 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/instance-artifacts.d.ts +29 -0
  36. package/dist/instance-artifacts.d.ts.map +1 -0
  37. package/dist/instance-artifacts.js +57 -0
  38. package/dist/instance-artifacts.js.map +1 -0
  39. package/dist/metaobjects-config.d.ts +10 -0
  40. package/dist/metaobjects-config.d.ts.map +1 -1
  41. package/dist/metaobjects-config.js +1 -0
  42. package/dist/metaobjects-config.js.map +1 -1
  43. package/dist/overwrite-policy.d.ts +39 -2
  44. package/dist/overwrite-policy.d.ts.map +1 -1
  45. package/dist/overwrite-policy.js +233 -13
  46. package/dist/overwrite-policy.js.map +1 -1
  47. package/dist/render-context.d.ts +4 -1
  48. package/dist/render-context.d.ts.map +1 -1
  49. package/dist/render-context.js +1 -0
  50. package/dist/render-context.js.map +1 -1
  51. package/dist/render-engine/framework-provider.d.ts +28 -0
  52. package/dist/render-engine/framework-provider.d.ts.map +1 -0
  53. package/dist/render-engine/framework-provider.js +104 -0
  54. package/dist/render-engine/framework-provider.js.map +1 -0
  55. package/dist/runner.d.ts +15 -1
  56. package/dist/runner.d.ts.map +1 -1
  57. package/dist/runner.js +45 -6
  58. package/dist/runner.js.map +1 -1
  59. package/dist/templates/docs-file.d.ts +17 -0
  60. package/dist/templates/docs-file.d.ts.map +1 -0
  61. package/dist/templates/docs-file.js +37 -0
  62. package/dist/templates/docs-file.js.map +1 -0
  63. package/dist/templates/entity-file.d.ts.map +1 -1
  64. package/dist/templates/entity-file.js +12 -0
  65. package/dist/templates/entity-file.js.map +1 -1
  66. package/dist/templates/fr010-field-mapping.d.ts +28 -0
  67. package/dist/templates/fr010-field-mapping.d.ts.map +1 -0
  68. package/dist/templates/fr010-field-mapping.js +170 -0
  69. package/dist/templates/fr010-field-mapping.js.map +1 -0
  70. package/dist/templates/output-format-spec-emitter.d.ts +4 -0
  71. package/dist/templates/output-format-spec-emitter.d.ts.map +1 -0
  72. package/dist/templates/output-format-spec-emitter.js +60 -0
  73. package/dist/templates/output-format-spec-emitter.js.map +1 -0
  74. package/dist/templates/output-parser.d.ts.map +1 -1
  75. package/dist/templates/output-parser.js +69 -4
  76. package/dist/templates/output-parser.js.map +1 -1
  77. package/dist/templates/output-prompt.d.ts +10 -0
  78. package/dist/templates/output-prompt.d.ts.map +1 -0
  79. package/dist/templates/output-prompt.js +75 -0
  80. package/dist/templates/output-prompt.js.map +1 -0
  81. package/dist/templates/recover-schema-emitter.d.ts +8 -0
  82. package/dist/templates/recover-schema-emitter.d.ts.map +1 -0
  83. package/dist/templates/recover-schema-emitter.js +64 -0
  84. package/dist/templates/recover-schema-emitter.js.map +1 -0
  85. package/package.json +5 -5
  86. package/src/generator.ts +9 -0
  87. package/src/generators/docs-data-builder.ts +470 -0
  88. package/src/generators/docs-data.ts +154 -0
  89. package/src/generators/docs-file.ts +87 -0
  90. package/src/generators/entity-file.ts +7 -0
  91. package/src/generators/index.ts +17 -0
  92. package/src/generators/output-prompt-file.ts +66 -0
  93. package/src/generators/template-generator.ts +106 -0
  94. package/src/index.ts +34 -2
  95. package/src/instance-artifacts.ts +61 -0
  96. package/src/metaobjects-config.ts +11 -0
  97. package/src/overwrite-policy.ts +325 -14
  98. package/src/render-context.ts +5 -1
  99. package/src/render-engine/framework-provider.ts +107 -0
  100. package/src/runner.ts +66 -6
  101. package/src/templates/docs-file.ts +51 -0
  102. package/src/templates/entity-file.ts +13 -0
  103. package/src/templates/fr010-field-mapping.ts +191 -0
  104. package/src/templates/output-format-spec-emitter.ts +97 -0
  105. package/src/templates/output-parser.ts +77 -2
  106. package/src/templates/output-prompt.ts +88 -0
  107. package/src/templates/recover-schema-emitter.ts +91 -0
  108. package/templates/docs/entity-page.md.mustache +54 -0
@@ -1,39 +1,350 @@
1
- // Overwrite policy: drives the per-file write decision based on the @generated header.
2
- // Per design §8 read-only generated files; refuse to clobber hand-written code.
1
+ // Overwrite policy: per-file write decision using three-way merge against a
2
+ // canonical snapshot kept under `.metaobjects/.gen-state/`.
3
+ //
4
+ // Replaces the rc.11 marker-based clobber-or-refuse policy (strategy (a) from
5
+ // spike 002) with strategy (b) — git-merge-file-driven three-way merge. The
6
+ // `@generated` marker becomes purely informational (templates may still emit
7
+ // it for human readers); the policy no longer consults it.
8
+ //
9
+ // Per-file flow:
10
+ //
11
+ // 1. Render fresh content to a tmpfile.
12
+ // 2. If the output doesn't exist → write + copy to .gen-state/.
13
+ // 3. If .gen-state/<relPath> exists → run `git merge-file --diff3` against
14
+ // (current, .gen-state snapshot, fresh tmpfile). Exit 0 → clean merge,
15
+ // advance .gen-state to fresh content. Exit > 0 → leave conflict markers
16
+ // in the output file, do NOT advance .gen-state; status "conflict".
17
+ // 4. If .gen-state/<relPath> is absent but the file exists ("first-time
18
+ // regen on an existing file") → write-if-different against the existing
19
+ // file as the baseline (no merge, no clobber). The `baseline: "fresh"`
20
+ // flag opts into "overwrite from fresh and re-baseline".
21
+ //
22
+ // Integrity (caveat 2 from the spike): we keep a sha-256 of each canonical
23
+ // snapshot at `.gen-state/.hashes.json`. On load, mismatch → fall back to
24
+ // first-time semantics and warn naming the file so the user can investigate.
3
25
 
4
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
5
- import { dirname } from "node:path";
6
- import { GENERATED_HEADER } from "./constants.js";
26
+ import {
27
+ existsSync,
28
+ readFileSync,
29
+ writeFileSync,
30
+ mkdirSync,
31
+ copyFileSync,
32
+ } from "node:fs";
33
+ import { dirname, join, isAbsolute, relative, resolve } from "node:path";
34
+ import { spawnSync } from "node:child_process";
35
+ import { tmpdir } from "node:os";
36
+ import { createHash, randomBytes } from "node:crypto";
7
37
 
8
- export type WriteStatus = "new" | "overwrite" | "refused" | "skipped";
38
+ export type WriteStatus =
39
+ | "new"
40
+ | "unchanged"
41
+ | "overwrite"
42
+ | "merged"
43
+ | "conflict"
44
+ | "refused"
45
+ | "skipped";
46
+
47
+ /**
48
+ * "overwrite" — default; three-way merge if .gen-state exists, else write-if-
49
+ * different / first-time-existing flow.
50
+ * "skip-existing" — never write over an existing file; status "skipped".
51
+ * Useful for `meta gen --dry-run` style flows.
52
+ */
9
53
  export type MergeStrategy = "overwrite" | "skip-existing";
10
54
 
55
+ /** "default" — the standard three-way merge flow described in the file header.
56
+ * "fresh" — opt-in via `meta gen --baseline=fresh`. When .gen-state is absent
57
+ * but the file exists, OVERWRITE with fresh content and seed .gen-state from
58
+ * the fresh content (caveat 3 escape hatch). */
59
+ export type BaselineMode = "default" | "fresh";
60
+
61
+ export interface DecideAndWriteOpts {
62
+ strategy?: MergeStrategy;
63
+ /** Absolute path to the .gen-state/ root for this project. When undefined,
64
+ * we fall back to a process-isolated tmpdir — fine for tests but the CLI
65
+ * always supplies the real project value via runGen(). */
66
+ genStateDir?: string;
67
+ /** Path the snapshot is keyed by — usually the path relative to the
68
+ * project root, but ANY stable identifier works. When undefined, derived
69
+ * from `path` (so unit tests can call without supplying it). */
70
+ outputRelPath?: string;
71
+ /** First-time-existing-file behavior. */
72
+ baseline?: BaselineMode;
73
+ }
74
+
11
75
  export interface WriteResult {
12
76
  path: string;
13
77
  status: WriteStatus;
78
+ /** Present when status is "conflict" — human-readable hint identifying the
79
+ * file the user must resolve. */
80
+ conflictHint?: string;
81
+ }
82
+
83
+ const HASHES_FILE = ".hashes.json";
84
+
85
+ type HashesFile = Record<string, string>;
86
+
87
+ function sha256(content: string | Buffer): string {
88
+ return createHash("sha256").update(content).digest("hex");
89
+ }
90
+
91
+ function loadHashes(genStateDir: string): HashesFile {
92
+ const f = join(genStateDir, HASHES_FILE);
93
+ if (!existsSync(f)) return {};
94
+ try {
95
+ const txt = readFileSync(f, "utf-8");
96
+ const parsed: unknown = JSON.parse(txt);
97
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
98
+ return parsed as HashesFile;
99
+ }
100
+ return {};
101
+ } catch {
102
+ return {};
103
+ }
14
104
  }
15
105
 
106
+ function saveHashes(genStateDir: string, hashes: HashesFile): void {
107
+ mkdirSync(genStateDir, { recursive: true });
108
+ writeFileSync(join(genStateDir, HASHES_FILE), JSON.stringify(hashes, null, 2) + "\n");
109
+ }
110
+
111
+ function snapshotPath(genStateDir: string, relPath: string): string {
112
+ // `.hashes.json` is reserved at the top of .gen-state/; relPath must never
113
+ // collide with it. Output paths derived from entity names ("Post.ts" etc.)
114
+ // are JS-identifier-shape so this is a non-issue in practice.
115
+ return join(genStateDir, relPath);
116
+ }
117
+
118
+ /** Synchronously copy `src` content into the snapshot at `<genStateDir>/<relPath>`
119
+ * and refresh the hash for `relPath`. */
120
+ function advanceSnapshot(
121
+ genStateDir: string,
122
+ relPath: string,
123
+ content: string,
124
+ ): void {
125
+ const dest = snapshotPath(genStateDir, relPath);
126
+ mkdirSync(dirname(dest), { recursive: true });
127
+ writeFileSync(dest, content);
128
+ const hashes = loadHashes(genStateDir);
129
+ hashes[relPath] = sha256(content);
130
+ saveHashes(genStateDir, hashes);
131
+ }
132
+
133
+ /** Return the snapshot text iff present AND the hash matches; else undefined. */
134
+ function readSnapshotChecked(
135
+ genStateDir: string,
136
+ relPath: string,
137
+ ): { text: string } | undefined {
138
+ const path = snapshotPath(genStateDir, relPath);
139
+ if (!existsSync(path)) return undefined;
140
+ const text = readFileSync(path, "utf-8");
141
+ const hashes = loadHashes(genStateDir);
142
+ const expected = hashes[relPath];
143
+ if (expected !== undefined && expected !== sha256(text)) {
144
+ return undefined;
145
+ }
146
+ return { text };
147
+ }
148
+
149
+ /** Thrown when git is unavailable. Surfaces as a clear CLI error rather than
150
+ * a generic ENOENT halfway through a regen. */
151
+ export class GitMissingError extends Error {
152
+ constructor() {
153
+ super(
154
+ "meta gen: `git` binary not found on PATH. Three-way-merge regen " +
155
+ "requires git (any modern version). Install git and re-run.",
156
+ );
157
+ this.name = "GitMissingError";
158
+ }
159
+ }
160
+
161
+ interface MergeOutcome {
162
+ /** 0 → clean merge; > 0 → conflicts present (file contains diff3 markers). */
163
+ exitCode: number;
164
+ /** stderr text — surfaced on unexpected errors. */
165
+ stderr: string;
166
+ /** Resulting file contents on disk after the merge (always read; clean or
167
+ * conflicting). */
168
+ mergedContent: string;
169
+ }
170
+
171
+ /** Run `git merge-file --diff3 -L<...> <out> <base> <fresh>`. The output is
172
+ * written in-place into `outPath`. Returns the exit code (0 = clean, > 0 =
173
+ * conflicts) and the resulting file contents.
174
+ *
175
+ * The git binary is "git" by default; set `META_GEN_GIT` in the environment
176
+ * to a different path (useful for tests that simulate "git not installed"). */
177
+ function runGitMergeFile(
178
+ outPath: string,
179
+ basePath: string,
180
+ freshPath: string,
181
+ ): MergeOutcome {
182
+ const gitBin = process.env.META_GEN_GIT ?? "git";
183
+ let res;
184
+ try {
185
+ res = spawnSync(
186
+ gitBin,
187
+ [
188
+ "merge-file",
189
+ "--diff3",
190
+ "-L",
191
+ "your edits",
192
+ "-L",
193
+ "last generated",
194
+ "-L",
195
+ "fresh from meta gen",
196
+ outPath,
197
+ basePath,
198
+ freshPath,
199
+ ],
200
+ { encoding: "utf-8" },
201
+ );
202
+ } catch (err) {
203
+ const msg = (err as Error).message ?? "";
204
+ if (msg.includes("ENOENT")) throw new GitMissingError();
205
+ throw err;
206
+ }
207
+ if (res.error) {
208
+ const code = (res.error as NodeJS.ErrnoException).code;
209
+ if (code === "ENOENT") throw new GitMissingError();
210
+ throw res.error;
211
+ }
212
+ // `git merge-file` returns the conflict count (0 = clean, > 0 = conflicts,
213
+ // < 0 = error). Negative is unexpected — surface verbatim.
214
+ const exitCode = res.status ?? 0;
215
+ if (exitCode < 0) {
216
+ throw new Error(
217
+ `git merge-file failed for ${outPath}: ${res.stderr || res.stdout}`,
218
+ );
219
+ }
220
+ const mergedContent = readFileSync(outPath, "utf-8");
221
+ return { exitCode, stderr: res.stderr ?? "", mergedContent };
222
+ }
223
+
224
+ /** Write `content` to a freshly-named tmpfile under the OS tmpdir; return the
225
+ * path. Caller is responsible for cleanup (acceptable for codegen — tmpdir
226
+ * is process-scoped). */
227
+ function writeTmpfile(content: string): string {
228
+ const dir = join(tmpdir(), "meta-gen-merge");
229
+ mkdirSync(dir, { recursive: true });
230
+ const path = join(dir, `${Date.now()}-${randomBytes(6).toString("hex")}.tmp`);
231
+ writeFileSync(path, content);
232
+ return path;
233
+ }
234
+
235
+ /** Resolve the snapshot key for a given output path. Falls back to a stable
236
+ * hash-of-path so unit tests that pass arbitrary tmpdir outputs still get a
237
+ * consistent .gen-state key. */
238
+ function defaultOutputRelPath(outputPath: string): string {
239
+ // Use sha256 of the absolute path → hex; lets tests work without supplying
240
+ // an explicit relPath. NOT used by the runner — runGen always passes the
241
+ // real project-relative path.
242
+ return sha256(resolve(outputPath)).slice(0, 32);
243
+ }
244
+
245
+ /**
246
+ * The main entry point. Backward-compatible with the rc.11 signature: passing
247
+ * a `MergeStrategy` string as the third argument continues to work; passing
248
+ * an options object opts into three-way merge.
249
+ */
16
250
  export function decideAndWrite(
17
251
  path: string,
18
252
  content: string,
19
- strategy: MergeStrategy = "overwrite",
253
+ optsOrStrategy: DecideAndWriteOpts | MergeStrategy = {},
20
254
  ): WriteResult {
21
- // 'skip-existing' only skips overwrites, not new files.
255
+ const opts: DecideAndWriteOpts =
256
+ typeof optsOrStrategy === "string"
257
+ ? { strategy: optsOrStrategy }
258
+ : optsOrStrategy;
259
+ const strategy: MergeStrategy = opts.strategy ?? "overwrite";
260
+ const baseline: BaselineMode = opts.baseline ?? "default";
261
+ const genStateDir =
262
+ opts.genStateDir !== undefined
263
+ ? (isAbsolute(opts.genStateDir)
264
+ ? opts.genStateDir
265
+ : resolve(opts.genStateDir))
266
+ : join(tmpdir(), "meta-gen-state-fallback");
267
+ const relPath = opts.outputRelPath ?? defaultOutputRelPath(path);
268
+
269
+ // 1. First-time write — file doesn't exist.
22
270
  if (!existsSync(path)) {
23
271
  mkdirSync(dirname(path), { recursive: true });
24
272
  writeFileSync(path, content);
273
+ advanceSnapshot(genStateDir, relPath, content);
25
274
  return { path, status: "new" };
26
275
  }
27
276
 
277
+ if (strategy === "skip-existing") {
278
+ return { path, status: "skipped" };
279
+ }
280
+
281
+ // 2. File exists. Load the canonical snapshot if any.
282
+ const snapshot = readSnapshotChecked(genStateDir, relPath);
283
+
284
+ // 3. First-time regen on a pre-existing file (no snapshot).
285
+ if (snapshot === undefined) {
286
+ const current = readFileSync(path, "utf-8");
287
+
288
+ if (baseline === "fresh") {
289
+ // Opt-in escape hatch: overwrite and seed the snapshot from fresh.
290
+ if (current === content) {
291
+ advanceSnapshot(genStateDir, relPath, content);
292
+ return { path, status: "unchanged" };
293
+ }
294
+ writeFileSync(path, content);
295
+ advanceSnapshot(genStateDir, relPath, content);
296
+ return { path, status: "overwrite" };
297
+ }
298
+
299
+ // Default: write-if-different. The EXISTING file is treated as the
300
+ // canonical baseline so subsequent runs do real three-way merges. If
301
+ // fresh output is identical, just seed the snapshot. If different, write
302
+ // the fresh content + seed snapshot — there's no marker policy left to
303
+ // refuse on, but this is the contract documented in the runbook (no
304
+ // marker check; users with hand-written files should never have the
305
+ // codegen output path colliding with them).
306
+ if (current === content) {
307
+ advanceSnapshot(genStateDir, relPath, current);
308
+ return { path, status: "unchanged" };
309
+ }
310
+ writeFileSync(path, content);
311
+ advanceSnapshot(genStateDir, relPath, content);
312
+ return { path, status: "overwrite" };
313
+ }
314
+
315
+ // 4. Snapshot exists — real three-way merge.
28
316
  const current = readFileSync(path, "utf-8");
29
- if (!current.includes(GENERATED_HEADER)) {
30
- return { path, status: "refused" };
317
+
318
+ // Fast path: nothing changed.
319
+ if (current === content && snapshot.text === content) {
320
+ return { path, status: "unchanged" };
31
321
  }
32
322
 
33
- if (strategy === "skip-existing") {
34
- return { path, status: "skipped" };
323
+ const baseTmp = writeTmpfile(snapshot.text);
324
+ const freshTmp = writeTmpfile(content);
325
+
326
+ const outcome = runGitMergeFile(path, baseTmp, freshTmp);
327
+
328
+ if (outcome.exitCode === 0) {
329
+ // Clean merge — advance the canonical snapshot to fresh.
330
+ advanceSnapshot(genStateDir, relPath, content);
331
+ // Distinguish "user had no changes vs canonical" from "merge integrated
332
+ // edits". The fresh equals snapshot case happened above (fast path) — so
333
+ // if outcome.mergedContent equals fresh we report a plain overwrite,
334
+ // otherwise it's a merge that pulled in user edits.
335
+ if (outcome.mergedContent === content) {
336
+ return { path, status: "overwrite" };
337
+ }
338
+ return { path, status: "merged" };
35
339
  }
36
340
 
37
- writeFileSync(path, content);
38
- return { path, status: "overwrite" };
341
+ // Conflict — do NOT advance the snapshot. The output now contains diff3
342
+ // markers from git merge-file.
343
+ return {
344
+ path,
345
+ status: "conflict",
346
+ conflictHint:
347
+ "merge conflict — resolve `<<<<<<<` markers and re-run `meta gen` to " +
348
+ "advance the canonical state.",
349
+ };
39
350
  }
@@ -39,6 +39,8 @@ export interface RenderContext {
39
39
  columnNamingStrategy: ColumnNamingStrategy;
40
40
  /** Path prefix applied to generated route registrations + hook fetch URLs. Defaults to "". */
41
41
  apiPrefix: string;
42
+ /** Whether abstract entities emit their shape artifact (type-only interface / value-object file). Defaults to true. Instance/write artifacts are never emitted for abstract entities regardless. */
43
+ emitAbstractShapes: boolean;
42
44
  /** Output layout mode: "flat" (default) — all files in outDir; "package" — sub-paths from entity metadata package. */
43
45
  outputLayout: OutputLayout;
44
46
  /** The target THIS generator emits to (drives path layout + same-target imports). */
@@ -53,11 +55,12 @@ export interface RenderContext {
53
55
  }
54
56
 
55
57
  /** Optional shape — `extStyle`, `omImport`, `columnNamingStrategy`, `apiPrefix`, `outputLayout`, and `packageOf` default if omitted. `packageOf` defaults to an empty Map (correct for flat layout; `runGen` always provides the real map). */
56
- export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "apiPrefix" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget"> & {
58
+ export type RenderContextInput = Omit<RenderContext, "extStyle" | "omImport" | "columnNamingStrategy" | "apiPrefix" | "emitAbstractShapes" | "outputLayout" | "packageOf" | "selfTarget" | "entityModuleTarget"> & {
57
59
  extStyle?: ExtStyle;
58
60
  omImport?: string;
59
61
  columnNamingStrategy?: ColumnNamingStrategy;
60
62
  apiPrefix?: string;
63
+ emitAbstractShapes?: boolean;
61
64
  outputLayout?: OutputLayout;
62
65
  packageOf?: Map<string, string | undefined>;
63
66
  selfTarget?: ResolvedTarget;
@@ -85,6 +88,7 @@ export function makeRenderContext(opts: RenderContextInput): RenderContext {
85
88
  omImport: opts.omImport ?? "../index",
86
89
  columnNamingStrategy: opts.columnNamingStrategy ?? "snake_case",
87
90
  apiPrefix: opts.apiPrefix ?? "",
91
+ emitAbstractShapes: opts.emitAbstractShapes ?? true,
88
92
  outputLayout,
89
93
  packageOf: opts.packageOf ?? new Map(),
90
94
  selfTarget: defaultTarget,
@@ -0,0 +1,107 @@
1
+ // FrameworkTemplateProvider — resolves template refs (e.g. "docs/entity-page.md")
2
+ // against the codegen-ts package's `templates/` directory. Adopters who want
3
+ // to override a framework template create their own `templates/<ref>.mustache`
4
+ // file in their project; `ProviderChain` (below) consults the project first
5
+ // and falls back to the framework defaults.
6
+ //
7
+ // Decision D1 from the template-driven-codegen design — hybrid: framework
8
+ // ships defaults, adopters override by file-system convention.
9
+ //
10
+ // The framework Provider is filesystem-backed so it works identically whether
11
+ // codegen-ts runs from source (bun, dev) or from `dist/` (npm install). The
12
+ // `templates/` directory is included in the published tarball via
13
+ // package.json `files: ["dist", "src", "templates", ...]`.
14
+
15
+ import type { Provider } from "@metaobjectsdev/render";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { join, resolve, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ /** Canonical shipped template — used to verify a candidate framework
21
+ * templates directory actually contains our defaults. Without this check a
22
+ * hoisted-install layout (pnpm/bun workspaces) can walk up to a CONSUMER
23
+ * package.json and silently return a templates dir that doesn't exist. */
24
+ const CANONICAL_TEMPLATE_REL = "docs/entity-page.md.mustache";
25
+
26
+ /** Walk up from `start` until we find a `package.json` whose neighbour
27
+ * `templates/` directory contains our canonical shipped template (i.e., the
28
+ * codegen-ts package root). Works the same way from
29
+ * `src/render-engine/framework-provider.ts` (during dev) and
30
+ * `dist/render-engine/framework-provider.js` (after `npm install`). */
31
+ function findFrameworkTemplatesDir(start: string): string {
32
+ let dir = start;
33
+ while (true) {
34
+ const pkgJson = join(dir, "package.json");
35
+ if (existsSync(pkgJson)) {
36
+ const templatesDir = join(dir, "templates");
37
+ // Assert we landed at the codegen-ts package root, not a consumer's.
38
+ if (existsSync(join(templatesDir, CANONICAL_TEMPLATE_REL))) {
39
+ return templatesDir;
40
+ }
41
+ }
42
+ const parent = dirname(dir);
43
+ if (parent === dir) break;
44
+ dir = parent;
45
+ }
46
+ throw new Error(
47
+ `framework templates dir unresolved: walked up from ${start} without finding a package.json ` +
48
+ `whose templates/${CANONICAL_TEMPLATE_REL} exists. This usually means codegen-ts was installed ` +
49
+ `via a hoisted layout (pnpm/bun workspaces) into an unexpected location, or the published ` +
50
+ `tarball is missing the templates/ directory.`,
51
+ );
52
+ }
53
+
54
+ // In ESM (CLAUDE.md: "ESM only. No CommonJS."), `import.meta.url` is
55
+ // guaranteed to be a file: URL; no defensive try/catch needed.
56
+ const SELF_DIR = dirname(fileURLToPath(import.meta.url));
57
+
58
+ const FRAMEWORK_TEMPLATES_DIR = findFrameworkTemplatesDir(SELF_DIR);
59
+
60
+ /** Provider backed by an arbitrary on-disk template directory. References
61
+ * resolve as `<dir>/<ref>.mustache`. Used by both the framework default
62
+ * and adopter override paths. */
63
+ export class FileSystemProvider implements Provider {
64
+ constructor(private readonly root: string) {}
65
+ resolve(ref: string): string | undefined {
66
+ const path = join(this.root, `${ref}.mustache`);
67
+ if (!existsSync(path)) return undefined;
68
+ return readFileSync(path, "utf-8");
69
+ }
70
+ }
71
+
72
+ /** The framework defaults provider — resolves refs against codegen-ts's own
73
+ * `templates/` directory. */
74
+ export const frameworkTemplatesProvider: Provider = new FileSystemProvider(
75
+ FRAMEWORK_TEMPLATES_DIR,
76
+ );
77
+
78
+ /** Compose providers: first match wins. Adopters typically chain
79
+ * `[projectProvider, frameworkTemplatesProvider]` so their own templates
80
+ * override the framework defaults. */
81
+ export class ProviderChain implements Provider {
82
+ constructor(private readonly providers: readonly Provider[]) {}
83
+ resolve(ref: string): string | undefined {
84
+ for (const p of this.providers) {
85
+ const text = p.resolve(ref);
86
+ if (text !== undefined) return text;
87
+ }
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ /** Build a project-scoped Provider: layers an optional project `templates/`
93
+ * directory over the framework defaults. Returns just the framework provider
94
+ * when `projectRoot` is undefined / its `templates/` dir doesn't exist. */
95
+ export function projectProvider(projectRoot?: string): Provider {
96
+ if (projectRoot === undefined) return frameworkTemplatesProvider;
97
+ const projTemplates = resolve(projectRoot, "templates");
98
+ if (!existsSync(projTemplates)) return frameworkTemplatesProvider;
99
+ return new ProviderChain([
100
+ new FileSystemProvider(projTemplates),
101
+ frameworkTemplatesProvider,
102
+ ]);
103
+ }
104
+
105
+ /** Exposed for tests that want to inspect / clear the resolved framework
106
+ * templates directory (don't use outside tests). */
107
+ export const __frameworkTemplatesDirForTests = FRAMEWORK_TEMPLATES_DIR;
package/src/runner.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { join } from "node:path";
1
+ import { join, relative, resolve, isAbsolute } from "node:path";
2
+ import { tmpdir } from "node:os";
2
3
  import type { MetaData, MetaObject } from "@metaobjectsdev/metadata";
3
4
  import { MetaRoot } from "@metaobjectsdev/metadata";
4
5
  import type { Generator, GenContext, EmittedFile } from "./generator.js";
@@ -8,7 +9,12 @@ import type { ResolvedTarget } from "./import-path.js";
8
9
  import { buildPkMap } from "./pk-resolver.js";
9
10
  import { buildRelationMap } from "./relation-resolver.js";
10
11
  import { makeRenderContext } from "./render-context.js";
11
- import { decideAndWrite, type WriteResult, type MergeStrategy } from "./overwrite-policy.js";
12
+ import {
13
+ decideAndWrite,
14
+ type WriteResult,
15
+ type MergeStrategy,
16
+ type BaselineMode,
17
+ } from "./overwrite-policy.js";
12
18
 
13
19
  /** JS-identifier-shape only. Prevents filesystem traversal when metadata comes
14
20
  * from untrusted sources (e.g. MCP). Mirrors the guard in legacy generate.ts. */
@@ -21,16 +27,48 @@ export interface RunGenOpts {
21
27
  entityFilter?: string[];
22
28
  /** Overwrite strategy passed to decideAndWrite. Defaults to "overwrite". */
23
29
  mergeStrategy?: MergeStrategy;
30
+ /** Project root (used to derive the .gen-state/ snapshot directory and to
31
+ * key snapshots by project-relative output path). When omitted, falls back
32
+ * to process.cwd(). */
33
+ projectRoot?: string;
34
+ /** Override the snapshot directory location. Defaults to
35
+ * `<projectRoot>/.metaobjects/.gen-state/`. */
36
+ genStateDir?: string;
37
+ /** First-time-on-existing-file behavior. Defaults to "default" (write-if-
38
+ * different). "fresh" → overwrite and re-baseline (the `--baseline=fresh`
39
+ * CLI flag). */
40
+ baseline?: BaselineMode;
24
41
  }
25
42
 
26
43
  export interface RunGenResult {
27
44
  files: WriteResult[];
28
45
  warnings: string[];
46
+ /** Subset of `files` with status "conflict" — surfaced separately so the
47
+ * CLI can print the end-of-run summary. */
48
+ conflicts: WriteResult[];
29
49
  }
30
50
 
31
51
  export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
32
52
  const warnings: string[] = [];
33
53
  const strategy = opts.mergeStrategy ?? "overwrite";
54
+ const baseline = opts.baseline ?? "default";
55
+ // When projectRoot is not supplied we DON'T fall back to process.cwd() —
56
+ // that would leak .gen-state/ into whatever directory happens to be cwd
57
+ // (e.g. the package dir during a unit-test run). Instead, fall back to a
58
+ // process-isolated tmpdir, which gives the new-write semantics every call
59
+ // (the snapshot exists only for the current process). The CLI's
60
+ // `genCommand` always passes a real projectRoot, so this fallback only
61
+ // affects programmatic callers (tests, library embedding).
62
+ const projectRoot = opts.projectRoot !== undefined
63
+ ? (isAbsolute(opts.projectRoot) ? opts.projectRoot : resolve(opts.projectRoot))
64
+ : undefined;
65
+ const genStateDir = opts.genStateDir !== undefined
66
+ ? (isAbsolute(opts.genStateDir)
67
+ ? opts.genStateDir
68
+ : resolve(projectRoot ?? process.cwd(), opts.genStateDir))
69
+ : (projectRoot !== undefined
70
+ ? join(projectRoot, ".metaobjects", ".gen-state")
71
+ : join(tmpdir(), `meta-gen-state-${process.pid}`));
34
72
 
35
73
  // loadMemory now returns MetaRoot; guard here also covers callers that pass a
36
74
  // plain MetaData (e.g. test helpers that build trees programmatically).
@@ -50,7 +88,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
50
88
  ? "no object children match the provided entityFilter"
51
89
  : "root has no object children";
52
90
  warnings.push(`No entities to generate — ${reason}.`);
53
- return { files: [], warnings };
91
+ return { files: [], warnings, conflicts: [] };
54
92
  }
55
93
 
56
94
  const safeEntities: MetaObject[] = [];
@@ -64,7 +102,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
64
102
  safeEntities.push(entity);
65
103
  }
66
104
  if (safeEntities.length === 0) {
67
- return { files: [], warnings };
105
+ return { files: [], warnings, conflicts: [] };
68
106
  }
69
107
 
70
108
  // 2. Resolve targets + entity-module target.
@@ -117,6 +155,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
117
155
  extStyle: config.extStyle,
118
156
  columnNamingStrategy: config.columnNamingStrategy,
119
157
  apiPrefix: config.apiPrefix,
158
+ emitAbstractShapes: config.emitAbstractShapes,
120
159
  outputLayout: selfTarget.outputLayout,
121
160
  pkMap,
122
161
  relationMap,
@@ -136,6 +175,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
136
175
  outputLayout: selfTarget.outputLayout,
137
176
  },
138
177
  renderContext,
178
+ ...(projectRoot !== undefined && { projectRoot }),
139
179
  warn: (msg) => warnings.push(`[${generator.name}] ${msg}`),
140
180
  };
141
181
 
@@ -163,9 +203,29 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
163
203
 
164
204
  // 5. Write phase.
165
205
  const writes: WriteResult[] = [];
206
+ const conflicts: WriteResult[] = [];
166
207
  for (const file of emitted) {
167
- const result = decideAndWrite(file.fullPath, file.content, strategy);
208
+ // Key the snapshot by project-relative path so multi-target projects keep
209
+ // distinct entries (e.g. `database/Post.ts` vs `web/Post.queries.ts`).
210
+ // Without an explicit projectRoot we let decideAndWrite derive a stable
211
+ // hash-of-path key — fine for ephemeral test runs.
212
+ const policyOpts: import("./overwrite-policy.js").DecideAndWriteOpts = {
213
+ strategy,
214
+ genStateDir,
215
+ baseline,
216
+ };
217
+ if (projectRoot !== undefined) {
218
+ policyOpts.outputRelPath = relative(projectRoot, file.fullPath);
219
+ }
220
+ const result = decideAndWrite(file.fullPath, file.content, policyOpts);
168
221
  writes.push(result);
222
+ if (result.status === "conflict") {
223
+ conflicts.push(result);
224
+ warnings.push(
225
+ `Merge conflict in ${file.fullPath}: resolve diff3 markers and re-run ` +
226
+ `'meta gen' to advance the canonical state.`,
227
+ );
228
+ }
169
229
  if (result.status === "refused") {
170
230
  warnings.push(
171
231
  `Refused to overwrite ${file.fullPath}: file exists without @generated header. ` +
@@ -174,5 +234,5 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
174
234
  }
175
235
  }
176
236
 
177
- return { files: writes, warnings };
237
+ return { files: writes, warnings, conflicts };
178
238
  }