@metaobjectsdev/codegen-ts 0.23.2 → 0.24.0

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 (153) 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/api-field-shape.js +1 -1
  5. package/dist/generators/api-field-shape.js.map +1 -1
  6. package/dist/generators/api-model.d.ts.map +1 -1
  7. package/dist/generators/api-model.js +71 -42
  8. package/dist/generators/api-model.js.map +1 -1
  9. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  10. package/dist/generators/docs-data-builder.js +36 -1
  11. package/dist/generators/docs-data-builder.js.map +1 -1
  12. package/dist/generators/docs-data.d.ts +14 -0
  13. package/dist/generators/docs-data.d.ts.map +1 -1
  14. package/dist/generators/docs-file.d.ts.map +1 -1
  15. package/dist/generators/docs-file.js +13 -4
  16. package/dist/generators/docs-file.js.map +1 -1
  17. package/dist/generators/extractor-file.d.ts.map +1 -1
  18. package/dist/generators/extractor-file.js +7 -11
  19. package/dist/generators/extractor-file.js.map +1 -1
  20. package/dist/generators/index.d.ts +4 -0
  21. package/dist/generators/index.d.ts.map +1 -1
  22. package/dist/generators/index.js +5 -0
  23. package/dist/generators/index.js.map +1 -1
  24. package/dist/generators/output-parser-file.d.ts.map +1 -1
  25. package/dist/generators/output-parser-file.js +12 -7
  26. package/dist/generators/output-parser-file.js.map +1 -1
  27. package/dist/generators/output-prompt-file.d.ts.map +1 -1
  28. package/dist/generators/output-prompt-file.js +14 -24
  29. package/dist/generators/output-prompt-file.js.map +1 -1
  30. package/dist/generators/requirement-tests.d.ts +44 -0
  31. package/dist/generators/requirement-tests.d.ts.map +1 -0
  32. package/dist/generators/requirement-tests.js +127 -0
  33. package/dist/generators/requirement-tests.js.map +1 -0
  34. package/dist/generators/requirements-file.d.ts +9 -0
  35. package/dist/generators/requirements-file.d.ts.map +1 -0
  36. package/dist/generators/requirements-file.js +53 -0
  37. package/dist/generators/requirements-file.js.map +1 -0
  38. package/dist/generators/requirements-markdown.d.ts +10 -0
  39. package/dist/generators/requirements-markdown.d.ts.map +1 -0
  40. package/dist/generators/requirements-markdown.js +71 -0
  41. package/dist/generators/requirements-markdown.js.map +1 -0
  42. package/dist/generators/requirements-toon.d.ts +3 -0
  43. package/dist/generators/requirements-toon.d.ts.map +1 -0
  44. package/dist/generators/requirements-toon.js +47 -0
  45. package/dist/generators/requirements-toon.js.map +1 -0
  46. package/dist/generators/requirements-view.d.ts +32 -0
  47. package/dist/generators/requirements-view.d.ts.map +1 -0
  48. package/dist/generators/requirements-view.js +64 -0
  49. package/dist/generators/requirements-view.js.map +1 -0
  50. package/dist/generators/trace-helper-file.d.ts.map +1 -1
  51. package/dist/generators/trace-helper-file.js +19 -11
  52. package/dist/generators/trace-helper-file.js.map +1 -1
  53. package/dist/index.d.ts +13 -1
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +18 -0
  56. package/dist/index.js.map +1 -1
  57. package/dist/metaobjects-config.d.ts +12 -20
  58. package/dist/metaobjects-config.d.ts.map +1 -1
  59. package/dist/metaobjects-config.js +5 -1
  60. package/dist/metaobjects-config.js.map +1 -1
  61. package/dist/orphan-sweep.d.ts +38 -0
  62. package/dist/orphan-sweep.d.ts.map +1 -0
  63. package/dist/orphan-sweep.js +129 -0
  64. package/dist/orphan-sweep.js.map +1 -0
  65. package/dist/overwrite-policy.d.ts +97 -3
  66. package/dist/overwrite-policy.d.ts.map +1 -1
  67. package/dist/overwrite-policy.js +293 -81
  68. package/dist/overwrite-policy.js.map +1 -1
  69. package/dist/projection/build-projection-views.d.ts +8 -0
  70. package/dist/projection/build-projection-views.d.ts.map +1 -1
  71. package/dist/projection/build-projection-views.js +2 -0
  72. package/dist/projection/build-projection-views.js.map +1 -1
  73. package/dist/reconcile-orphans.d.ts +80 -0
  74. package/dist/reconcile-orphans.d.ts.map +1 -0
  75. package/dist/reconcile-orphans.js +61 -0
  76. package/dist/reconcile-orphans.js.map +1 -0
  77. package/dist/render-engine/embedded-templates.generated.js +1 -1
  78. package/dist/render-engine/embedded-templates.generated.js.map +1 -1
  79. package/dist/requirement-walk.d.ts +57 -0
  80. package/dist/requirement-walk.d.ts.map +1 -0
  81. package/dist/requirement-walk.js +94 -0
  82. package/dist/requirement-walk.js.map +1 -0
  83. package/dist/runner.d.ts +35 -0
  84. package/dist/runner.d.ts.map +1 -1
  85. package/dist/runner.js +217 -21
  86. package/dist/runner.js.map +1 -1
  87. package/dist/templates/extractor.d.ts +4 -4
  88. package/dist/templates/extractor.d.ts.map +1 -1
  89. package/dist/templates/extractor.js +18 -22
  90. package/dist/templates/extractor.js.map +1 -1
  91. package/dist/templates/find-inbound.d.ts +44 -0
  92. package/dist/templates/find-inbound.d.ts.map +1 -0
  93. package/dist/templates/find-inbound.js +69 -0
  94. package/dist/templates/find-inbound.js.map +1 -0
  95. package/dist/templates/output-format-spec-emitter.d.ts.map +1 -1
  96. package/dist/templates/output-format-spec-emitter.js +7 -4
  97. package/dist/templates/output-format-spec-emitter.js.map +1 -1
  98. package/dist/templates/output-parser.d.ts +8 -3
  99. package/dist/templates/output-parser.d.ts.map +1 -1
  100. package/dist/templates/output-parser.js +71 -31
  101. package/dist/templates/output-parser.js.map +1 -1
  102. package/dist/templates/output-prompt.d.ts +6 -5
  103. package/dist/templates/output-prompt.d.ts.map +1 -1
  104. package/dist/templates/output-prompt.js +26 -37
  105. package/dist/templates/output-prompt.js.map +1 -1
  106. package/dist/templates/queries.js +1 -1
  107. package/dist/templates/queries.js.map +1 -1
  108. package/dist/templates/requirement-test.d.ts +12 -0
  109. package/dist/templates/requirement-test.d.ts.map +1 -0
  110. package/dist/templates/requirement-test.js +120 -0
  111. package/dist/templates/requirement-test.js.map +1 -0
  112. package/dist/templates/zod-validators.d.ts.map +1 -1
  113. package/dist/templates/zod-validators.js +32 -15
  114. package/dist/templates/zod-validators.js.map +1 -1
  115. package/package.json +7 -6
  116. package/src/generator.ts +9 -0
  117. package/src/generators/api-field-shape.ts +1 -1
  118. package/src/generators/api-model.ts +69 -44
  119. package/src/generators/docs-data-builder.ts +37 -0
  120. package/src/generators/docs-data.ts +15 -0
  121. package/src/generators/docs-file.ts +13 -4
  122. package/src/generators/extractor-file.ts +7 -11
  123. package/src/generators/index.ts +11 -0
  124. package/src/generators/output-parser-file.ts +12 -7
  125. package/src/generators/output-prompt-file.ts +14 -27
  126. package/src/generators/requirement-tests.ts +203 -0
  127. package/src/generators/requirements-file.ts +71 -0
  128. package/src/generators/requirements-markdown.ts +72 -0
  129. package/src/generators/requirements-toon.ts +64 -0
  130. package/src/generators/requirements-view.ts +93 -0
  131. package/src/generators/trace-helper-file.ts +20 -10
  132. package/src/index.ts +47 -1
  133. package/src/metaobjects-config.ts +17 -22
  134. package/src/orphan-sweep.ts +178 -0
  135. package/src/overwrite-policy.ts +362 -89
  136. package/src/projection/build-projection-views.ts +10 -0
  137. package/src/reconcile-orphans.ts +136 -0
  138. package/src/reference/barrel.ts +4 -0
  139. package/src/reference/entity.ts +4 -0
  140. package/src/reference/queries.ts +4 -0
  141. package/src/reference/routes.ts +4 -0
  142. package/src/render-engine/embedded-templates.generated.ts +1 -1
  143. package/src/requirement-walk.ts +124 -0
  144. package/src/runner.ts +266 -27
  145. package/src/templates/extractor.ts +19 -24
  146. package/src/templates/find-inbound.ts +96 -0
  147. package/src/templates/output-format-spec-emitter.ts +7 -4
  148. package/src/templates/output-parser.ts +76 -34
  149. package/src/templates/output-prompt.ts +29 -42
  150. package/src/templates/queries.ts +1 -1
  151. package/src/templates/requirement-test.ts +140 -0
  152. package/src/templates/zod-validators.ts +32 -15
  153. package/templates/docs/entity-page.md.mustache +8 -0
@@ -14,14 +14,23 @@
14
14
  // (current, .gen-state snapshot, fresh tmpfile). Exit 0 → clean merge,
15
15
  // advance .gen-state to fresh content. Exit > 0 → leave conflict markers
16
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".
17
+ // 4. If the .gen-state BODY is absent but the file exists, consult the
18
+ // committed hash manifest: identical fresh content "unchanged"; the file
19
+ // still hashes to what we recorded writing safe to overwrite; anything
20
+ // else (edited, or no record at all) "refused", naming the file. The
21
+ // `baseline: "fresh"` flag opts into "overwrite from fresh and re-baseline".
21
22
  //
22
23
  // 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.
24
+ // snapshot at `.gen-state/.hashes.json`.
25
+ //
26
+ // THE MANIFEST IS COMMITTED; THE BODIES ARE NOT. That split is what makes step 4
27
+ // possible. A hash per path is small and reviewable, where a second full copy of
28
+ // all generated output is neither — and a hash is already sufficient to tell
29
+ // "nobody touched this" from "somebody edited this", which is the only thing
30
+ // steps 4 and the orphan-delete path need to know. The cost, stated: without a
31
+ // body there is no base to merge against, so a diverged file on a fresh clone is
32
+ // REFUSED rather than merged. That is a smaller loss than it sounds, because the
33
+ // behaviour it replaces was a silent overwrite.
25
34
 
26
35
  import {
27
36
  existsSync,
@@ -29,6 +38,7 @@ import {
29
38
  writeFileSync,
30
39
  mkdirSync,
31
40
  copyFileSync,
41
+ rmSync,
32
42
  } from "node:fs";
33
43
  import { dirname, join, isAbsolute, relative, resolve } from "node:path";
34
44
  import { spawnSync } from "node:child_process";
@@ -42,7 +52,13 @@ export type WriteStatus =
42
52
  | "merged"
43
53
  | "conflict"
44
54
  | "refused"
45
- | "skipped";
55
+ | "skipped"
56
+ /** FR-038 §8 — deleted because it was generated by a previous run, is no longer
57
+ * generated, and was never edited by hand. Reported as a file outcome rather
58
+ * than a warning because a deletion is exactly as consequential as a write,
59
+ * and a run summary that lists writes but hides deletions is how a silent
60
+ * deletion happens. */
61
+ | "removed";
46
62
 
47
63
  /**
48
64
  * "overwrite" — default; three-way merge if .gen-state exists, else write-if-
@@ -75,8 +91,9 @@ export interface DecideAndWriteOpts {
75
91
  export interface WriteResult {
76
92
  path: string;
77
93
  status: WriteStatus;
78
- /** Present when status is "conflict" — human-readable hint identifying the
79
- * file the user must resolve. */
94
+ /** Present when status is "conflict" or "refused" — human-readable reason,
95
+ * naming what the user must do. A refusal nobody can act on gets the file
96
+ * deleted by hand, which is the outcome refusing exists to prevent. */
80
97
  conflictHint?: string;
81
98
  }
82
99
 
@@ -105,7 +122,128 @@ function loadHashes(genStateDir: string): HashesFile {
105
122
 
106
123
  function saveHashes(genStateDir: string, hashes: HashesFile): void {
107
124
  mkdirSync(genStateDir, { recursive: true });
108
- writeFileSync(join(genStateDir, HASHES_FILE), JSON.stringify(hashes, null, 2) + "\n");
125
+ // Keys SORTED, because this file is committed. Insertion order would make the
126
+ // diff — and any merge conflict between two people who both regenerated —
127
+ // depend on which entity happened to generate first, which is noise nobody can
128
+ // review.
129
+ const sorted: HashesFile = {};
130
+ for (const key of Object.keys(hashes).sort()) sorted[key] = hashes[key]!;
131
+ writeFileSync(join(genStateDir, HASHES_FILE), JSON.stringify(sorted, null, 2) + "\n");
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // FR-038 §8 — readers for orphan reconciliation.
136
+ //
137
+ // `.hashes.json`'s key set is the ONLY record of what a previous run wrote, so
138
+ // it is also the only way to notice that a file we used to generate is no longer
139
+ // generated. These three functions expose that record without widening the write
140
+ // path: nothing here decides anything (see reconcile-orphans.ts) and nothing here
141
+ // touches an output file (see orphan-sweep.ts).
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /**
145
+ * Every snapshot key recorded under `genStateDir` — in a runner-driven project,
146
+ * the project-relative path of every file some previous `meta gen` wrote.
147
+ *
148
+ * Empty for a gen-state directory that does not exist yet, which is what makes a
149
+ * first run, an ephemeral test run and `verify --codegen`'s throwaway root all
150
+ * reconcile nothing.
151
+ */
152
+ export function listGeneratedPaths(genStateDir: string): string[] {
153
+ return Object.keys(loadHashes(genStateDir));
154
+ }
155
+
156
+ /**
157
+ * sha-256 of `content`, hex — the same function that produces `.hashes.json`.
158
+ *
159
+ * Exported so a caller can ask the one question the manifest exists to answer
160
+ * ("is this file byte-for-byte what we recorded writing?") without needing the
161
+ * snapshot body, which is what makes the answer available on a fresh clone.
162
+ */
163
+ export function contentHash(content: string): string {
164
+ return sha256(content);
165
+ }
166
+
167
+ /**
168
+ * The hash we recorded when we last wrote `relPath`, or undefined if we have no
169
+ * record of ever writing it.
170
+ *
171
+ * This is the COMMITTED half of `.gen-state`. The snapshot bodies stay ignored —
172
+ * they are a second full copy of all generated output — but a hash per path is
173
+ * small enough to commit and review, and it is sufficient to distinguish "nobody
174
+ * touched this" from "somebody edited this", which is the only distinction the
175
+ * overwrite and delete decisions actually need.
176
+ */
177
+ export function readGeneratedHash(
178
+ genStateDir: string,
179
+ relPath: string,
180
+ ): string | undefined {
181
+ return loadHashes(genStateDir)[relPath];
182
+ }
183
+
184
+ /**
185
+ * True when the file at `relPath` is byte-for-byte what we recorded writing.
186
+ *
187
+ * FAILS CLOSED: with no recorded hash we cannot prove anything, so the answer is
188
+ * false. Both the write path and the orphan-delete path ask this one question of
189
+ * this one piece of evidence — before this existed they answered the same
190
+ * uncertainty in opposite directions inside a single feature, refusing to DELETE
191
+ * a hand-edited file while silently OVERWRITING one.
192
+ */
193
+ export function isPristineGenerated(
194
+ genStateDir: string,
195
+ relPath: string,
196
+ current: string,
197
+ ): boolean {
198
+ const recorded = readGeneratedHash(genStateDir, relPath);
199
+ return recorded !== undefined && recorded === sha256(current);
200
+ }
201
+
202
+ /**
203
+ * The snapshot of what we last wrote to `relPath`, or undefined when there is no
204
+ * trustworthy one.
205
+ *
206
+ * HASH-CHECKED on purpose. A caller comparing this against the file on disk is
207
+ * asking "is the output still exactly what I wrote?" — and a snapshot that fails
208
+ * its own hash cannot answer that. Returning the stale text would let a
209
+ * reconciling caller conclude "untouched" and delete a file it cannot vouch for,
210
+ * so a tampered snapshot reads as absent and the caller fails closed.
211
+ */
212
+ export function readGeneratedSnapshot(
213
+ genStateDir: string,
214
+ relPath: string,
215
+ ): string | undefined {
216
+ return readSnapshotChecked(genStateDir, relPath)?.text;
217
+ }
218
+
219
+ /**
220
+ * Drop both halves of the record for paths this run no longer generates — each
221
+ * snapshot file and its `.hashes.json` entry.
222
+ *
223
+ * BOTH halves, or the next run sees the path again in `listGeneratedPaths` and
224
+ * re-decides an orphan that has already been dealt with. A no-op for a path that was
225
+ * never generated.
226
+ *
227
+ * Batched deliberately: one manifest read and one write for the whole set. A per-path
228
+ * variant re-reads, re-sorts and rewrites the entire manifest every call, so clearing k
229
+ * orphans rewrote it k times — on a project with hundreds of generated files that makes
230
+ * bookkeeping the dominant cost of `meta gen`, for an identical result.
231
+ */
232
+ export function forgetGeneratedPaths(
233
+ genStateDir: string,
234
+ relPaths: Iterable<string>,
235
+ ): void {
236
+ const hashes = loadHashes(genStateDir);
237
+ let changed = false;
238
+ for (const relPath of relPaths) {
239
+ const snapshot = snapshotPath(genStateDir, relPath);
240
+ if (existsSync(snapshot)) rmSync(snapshot, { force: true });
241
+ if (relPath in hashes) {
242
+ delete hashes[relPath];
243
+ changed = true;
244
+ }
245
+ }
246
+ if (changed) saveHashes(genStateDir, hashes);
109
247
  }
110
248
 
111
249
  const ENGINE_FILE = ".engine.json";
@@ -274,6 +412,159 @@ function defaultOutputRelPath(outputPath: string): string {
274
412
  return sha256(resolve(outputPath)).slice(0, 32);
275
413
  }
276
414
 
415
+ /** Where this call's gen-state lives. Shared by the write path and the preview so
416
+ * the two cannot resolve it differently — a preview that consults a different
417
+ * manifest than the write would be the same disagreement-between-two-answers bug
418
+ * this whole change exists to remove. */
419
+ function resolveGenStateDir(opts: DecideAndWriteOpts): string {
420
+ if (opts.genStateDir === undefined) return join(tmpdir(), "meta-gen-state-fallback");
421
+ return isAbsolute(opts.genStateDir) ? opts.genStateDir : resolve(opts.genStateDir);
422
+ }
423
+
424
+ /** True when this project has a hash manifest at all — as opposed to a manifest
425
+ * that simply has no entry for some path.
426
+ *
427
+ * The distinction drives the upgrade message: a project with NO manifest predates
428
+ * the manifest being committed, so its refusals are one fixable configuration
429
+ * problem rather than N independent hand edits, and it deserves one instruction
430
+ * instead of a wall of per-file warnings. */
431
+ export function hasHashManifest(genStateDir: string): boolean {
432
+ return existsSync(join(genStateDir, HASHES_FILE));
433
+ }
434
+
435
+ /** Normalize the legacy `MergeStrategy` string shorthand into an options object. */
436
+ function normalizeOpts(
437
+ optsOrStrategy: DecideAndWriteOpts | MergeStrategy,
438
+ ): DecideAndWriteOpts {
439
+ return typeof optsOrStrategy === "string" ? { strategy: optsOrStrategy } : optsOrStrategy;
440
+ }
441
+
442
+ /**
443
+ * The single decision tree `decideAndWrite` and `previewWriteStatus` both classify
444
+ * through — the fix for the risk named at the top of this file. Two functions that
445
+ * each re-derive "which case applies" from scratch can drift out of step in their
446
+ * branch ORDER, and when they do, the preview lies. Every branch that decides WHICH
447
+ * case an input falls into lives here, exactly once. `decideAndWrite` executes the
448
+ * case (the write, the merge, the snapshot advance — see its own comments for the
449
+ * "how"); `previewWriteStatus` maps the case straight to a `WriteStatus` with no
450
+ * side effects. Neither function repeats the ordering, so neither can disagree with
451
+ * the other about it.
452
+ */
453
+ type WriteCase =
454
+ | { kind: "new" }
455
+ | { kind: "skip" }
456
+ | { kind: "no-snapshot-fresh-unchanged" }
457
+ | { kind: "no-snapshot-fresh-overwrite" }
458
+ | { kind: "no-snapshot-unchanged" }
459
+ | { kind: "no-snapshot-pristine-overwrite" }
460
+ | { kind: "no-snapshot-refused"; hasRecord: boolean }
461
+ | { kind: "snapshot-unchanged" }
462
+ | { kind: "snapshot-merge-required"; snapshotText: string };
463
+
464
+ function classifyWrite(
465
+ path: string,
466
+ content: string,
467
+ opts: DecideAndWriteOpts,
468
+ ): WriteCase {
469
+ // First-time write — file doesn't exist.
470
+ if (!existsSync(path)) return { kind: "new" };
471
+
472
+ if ((opts.strategy ?? "overwrite") === "skip-existing") return { kind: "skip" };
473
+
474
+ // File exists. Load the canonical snapshot if any.
475
+ const genStateDir = resolveGenStateDir(opts);
476
+ const relPath = opts.outputRelPath ?? defaultOutputRelPath(path);
477
+ const snapshot = readSnapshotChecked(genStateDir, relPath);
478
+ const current = readFileSync(path, "utf-8");
479
+
480
+ // First-time regen on a pre-existing file (no snapshot) — caveat 3. `baseline`
481
+ // only ever applies here: BaselineMode's own doc comment says so ("When
482
+ // .gen-state is absent but the file exists"), and a snapshot body — handled
483
+ // below — always gets the real three-way merge regardless of baseline.
484
+ if (snapshot === undefined) {
485
+ if ((opts.baseline ?? "default") === "fresh") {
486
+ // Opt-in escape hatch: overwrite and seed the snapshot from fresh.
487
+ return current === content
488
+ ? { kind: "no-snapshot-fresh-unchanged" }
489
+ : { kind: "no-snapshot-fresh-overwrite" };
490
+ }
491
+
492
+ // No snapshot BODY. That is the normal state, not an edge case: the bodies
493
+ // are gitignored, so every fresh clone and every CI runner arrives here.
494
+ //
495
+ // Previously this branch wrote the fresh content unconditionally, which meant
496
+ // the documented promise that hand edits survive regeneration was false in
497
+ // precisely the situation adopters spend most of their time in — and the CLI
498
+ // labelled the replacement `NEW`.
499
+ //
500
+ // The committed hash manifest answers the only question that matters. Note
501
+ // this is the SAME question, asked of the same evidence, as the orphan-delete
502
+ // path: see `isPristineGenerated`.
503
+ if (current === content) return { kind: "no-snapshot-unchanged" };
504
+
505
+ if (isPristineGenerated(genStateDir, relPath, current)) {
506
+ // Byte-for-byte what we last wrote, so replacing it loses nothing. This is
507
+ // the common fresh-clone case (a formatter or engine bump moved the output)
508
+ // and it must not refuse, or a clean checkout would stall on every file.
509
+ return { kind: "no-snapshot-pristine-overwrite" };
510
+ }
511
+
512
+ // Either somebody edited it (hash mismatch) or we have no record of writing
513
+ // it at all (no hash). Both are unprovable, so fail closed.
514
+ return {
515
+ kind: "no-snapshot-refused",
516
+ hasRecord: readGeneratedHash(genStateDir, relPath) !== undefined,
517
+ };
518
+ }
519
+
520
+ // Snapshot exists — a real three-way merge decides this one.
521
+ // Fast path: nothing changed.
522
+ if (current === content && snapshot.text === content) {
523
+ return { kind: "snapshot-unchanged" };
524
+ }
525
+ return { kind: "snapshot-merge-required", snapshotText: snapshot.text };
526
+ }
527
+
528
+ /**
529
+ * What `decideAndWrite` WOULD do, touching nothing. Backs `meta gen --dry-run`.
530
+ *
531
+ * Exact for every outcome the hash manifest decides, because those are pure
532
+ * comparisons — `classifyWrite` above is the single source for which one applies.
533
+ * Deliberately COARSE in one place: with a snapshot body present the result depends
534
+ * on whether `git merge-file` comes back clean or conflicted, which cannot be known
535
+ * without performing the merge — so that case reports `overwrite`, meaning "this
536
+ * file will be rewritten", which is true either way.
537
+ *
538
+ * The reason this exists as its own function rather than a flag on
539
+ * `decideAndWrite`: a preview must be incapable of writing, and the cheapest way to
540
+ * guarantee that is to give it no write in its body at all — mapping a `WriteCase`
541
+ * to a status touches no file and no manifest.
542
+ */
543
+ export function previewWriteStatus(
544
+ path: string,
545
+ content: string,
546
+ optsOrStrategy: DecideAndWriteOpts | MergeStrategy = {},
547
+ ): WriteStatus {
548
+ const kase = classifyWrite(path, content, normalizeOpts(optsOrStrategy));
549
+ switch (kase.kind) {
550
+ case "new":
551
+ return "new";
552
+ case "skip":
553
+ return "skipped";
554
+ case "no-snapshot-fresh-unchanged":
555
+ case "no-snapshot-unchanged":
556
+ case "snapshot-unchanged":
557
+ return "unchanged";
558
+ case "no-snapshot-fresh-overwrite":
559
+ case "no-snapshot-pristine-overwrite":
560
+ return "overwrite";
561
+ case "no-snapshot-refused":
562
+ return "refused";
563
+ case "snapshot-merge-required":
564
+ return "overwrite";
565
+ }
566
+ }
567
+
277
568
  /**
278
569
  * The main entry point. Backward-compatible with the rc.11 signature: passing
279
570
  * a `MergeStrategy` string as the third argument continues to work; passing
@@ -284,99 +575,81 @@ export function decideAndWrite(
284
575
  content: string,
285
576
  optsOrStrategy: DecideAndWriteOpts | MergeStrategy = {},
286
577
  ): WriteResult {
287
- const opts: DecideAndWriteOpts =
288
- typeof optsOrStrategy === "string"
289
- ? { strategy: optsOrStrategy }
290
- : optsOrStrategy;
291
- const strategy: MergeStrategy = opts.strategy ?? "overwrite";
292
- const baseline: BaselineMode = opts.baseline ?? "default";
293
- const genStateDir =
294
- opts.genStateDir !== undefined
295
- ? (isAbsolute(opts.genStateDir)
296
- ? opts.genStateDir
297
- : resolve(opts.genStateDir))
298
- : join(tmpdir(), "meta-gen-state-fallback");
578
+ const opts = normalizeOpts(optsOrStrategy);
579
+ const genStateDir = resolveGenStateDir(opts);
299
580
  const relPath = opts.outputRelPath ?? defaultOutputRelPath(path);
581
+ const kase = classifyWrite(path, content, opts);
300
582
 
301
- // 1. First-time write — file doesn't exist.
302
- if (!existsSync(path)) {
303
- mkdirSync(dirname(path), { recursive: true });
304
- writeFileSync(path, content);
305
- advanceSnapshot(genStateDir, relPath, content);
306
- return { path, status: "new" };
307
- }
308
-
309
- if (strategy === "skip-existing") {
310
- return { path, status: "skipped" };
311
- }
583
+ switch (kase.kind) {
584
+ case "new":
585
+ mkdirSync(dirname(path), { recursive: true });
586
+ writeFileSync(path, content);
587
+ advanceSnapshot(genStateDir, relPath, content);
588
+ return { path, status: "new" };
312
589
 
313
- // 2. File exists. Load the canonical snapshot if any.
314
- const snapshot = readSnapshotChecked(genStateDir, relPath);
590
+ case "skip":
591
+ return { path, status: "skipped" };
315
592
 
316
- // 3. First-time regen on a pre-existing file (no snapshot).
317
- if (snapshot === undefined) {
318
- const current = readFileSync(path, "utf-8");
593
+ case "no-snapshot-fresh-unchanged":
594
+ case "no-snapshot-unchanged":
595
+ // Identical content needs no write, in either mode — just seed/advance the
596
+ // snapshot so the file is recognisable as ours next time.
597
+ advanceSnapshot(genStateDir, relPath, content);
598
+ return { path, status: "unchanged" };
319
599
 
320
- if (baseline === "fresh") {
321
- // Opt-in escape hatch: overwrite and seed the snapshot from fresh.
322
- if (current === content) {
323
- advanceSnapshot(genStateDir, relPath, content);
324
- return { path, status: "unchanged" };
325
- }
600
+ case "no-snapshot-fresh-overwrite":
601
+ case "no-snapshot-pristine-overwrite":
326
602
  writeFileSync(path, content);
327
603
  advanceSnapshot(genStateDir, relPath, content);
328
604
  return { path, status: "overwrite" };
329
- }
330
605
 
331
- // Default: write-if-different. The EXISTING file is treated as the
332
- // canonical baseline so subsequent runs do real three-way merges. If
333
- // fresh output is identical, just seed the snapshot. If different, write
334
- // the fresh content + seed snapshot there's no marker policy left to
335
- // refuse on, but this is the contract documented in the runbook (no
336
- // marker check; users with hand-written files should never have the
337
- // codegen output path colliding with them).
338
- if (current === content) {
339
- advanceSnapshot(genStateDir, relPath, current);
606
+ case "no-snapshot-refused":
607
+ // Deliberately does NOT advance the snapshot or the hash: a refusal that
608
+ // records the current content would make the file look pristine next run and
609
+ // turn this into a silent overwrite one run later.
610
+ return {
611
+ path,
612
+ status: "refused",
613
+ conflictHint: kase.hasRecord
614
+ ? "this file has been edited since it was generated — it was NOT " +
615
+ "overwritten. Move your edits into a non-generated file, or re-run " +
616
+ "with --baseline=fresh to discard them and adopt fresh output."
617
+ : "no record of generating this file, and its content differs from fresh " +
618
+ "output — it was NOT overwritten. Move it aside, or re-run with " +
619
+ "--baseline=fresh to overwrite it and adopt fresh output as the baseline.",
620
+ };
621
+
622
+ case "snapshot-unchanged":
340
623
  return { path, status: "unchanged" };
341
- }
342
- writeFileSync(path, content);
343
- advanceSnapshot(genStateDir, relPath, content);
344
- return { path, status: "overwrite" };
345
- }
346
624
 
347
- // 4. Snapshot exists — real three-way merge.
348
- const current = readFileSync(path, "utf-8");
625
+ case "snapshot-merge-required": {
626
+ const baseTmp = writeTmpfile(kase.snapshotText);
627
+ const freshTmp = writeTmpfile(content);
349
628
 
350
- // Fast path: nothing changed.
351
- if (current === content && snapshot.text === content) {
352
- return { path, status: "unchanged" };
353
- }
354
-
355
- const baseTmp = writeTmpfile(snapshot.text);
356
- const freshTmp = writeTmpfile(content);
629
+ const outcome = runGitMergeFile(path, baseTmp, freshTmp);
357
630
 
358
- const outcome = runGitMergeFile(path, baseTmp, freshTmp);
631
+ if (outcome.exitCode === 0) {
632
+ // Clean merge — advance the canonical snapshot to fresh.
633
+ advanceSnapshot(genStateDir, relPath, content);
634
+ // Distinguish "user had no changes vs canonical" from "merge integrated
635
+ // edits". The fresh-equals-snapshot case is `snapshot-unchanged` above —
636
+ // so if the merged result equals fresh we report a plain overwrite,
637
+ // otherwise it's a merge that pulled in user edits.
638
+ return {
639
+ path,
640
+ status: outcome.mergedContent === content ? "overwrite" : "merged",
641
+ };
642
+ }
359
643
 
360
- if (outcome.exitCode === 0) {
361
- // Clean merge advance the canonical snapshot to fresh.
362
- advanceSnapshot(genStateDir, relPath, content);
363
- // Distinguish "user had no changes vs canonical" from "merge integrated
364
- // edits". The fresh equals snapshot case happened above (fast path) — so
365
- // if outcome.mergedContent equals fresh we report a plain overwrite,
366
- // otherwise it's a merge that pulled in user edits.
367
- if (outcome.mergedContent === content) {
368
- return { path, status: "overwrite" };
644
+ // Conflict — do NOT advance the snapshot. The output now contains diff3
645
+ // markers from git merge-file.
646
+ return {
647
+ path,
648
+ status: "conflict",
649
+ conflictHint:
650
+ "merge conflict resolve `<<<<<<<` markers and re-run `meta gen` to " +
651
+ "advance the canonical state.",
652
+ };
369
653
  }
370
- return { path, status: "merged" };
371
654
  }
372
-
373
- // Conflict — do NOT advance the snapshot. The output now contains diff3
374
- // markers from git merge-file.
375
- return {
376
- path,
377
- status: "conflict",
378
- conflictHint:
379
- "merge conflict — resolve `<<<<<<<` markers and re-run `meta gen` to " +
380
- "advance the canonical state.",
381
- };
382
655
  }
@@ -53,6 +53,14 @@ export interface ExpectedView {
53
53
  name: string;
54
54
  schema?: string;
55
55
  sql: string;
56
+ /**
57
+ * `resolutionKey()` of the object that declared this view — the projection, or the
58
+ * write-through entity hosting its own read view. migrate-ts records it as
59
+ * PROVENANCE (never onto the view descriptor, never into the committed snapshot) so
60
+ * a per-command `migrate.scope` can decide ownership on the declaring FQN rather
61
+ * than on the physical view name, which no naming strategy can reverse.
62
+ */
63
+ fqn: string;
56
64
  /**
57
65
  * Physical tables this view reads (base + every joined table). The migrate-ts
58
66
  * diff uses this to recreate the view when one of its source tables undergoes a
@@ -199,6 +207,7 @@ function emitViewFor(
199
207
  sql: body,
200
208
  dependsOn,
201
209
  columns,
210
+ fqn: host.resolutionKey(),
202
211
  ...(schema !== undefined ? { schema } : {}),
203
212
  });
204
213
  }
@@ -240,6 +249,7 @@ function emitSqlView(
240
249
  name: source.physicalName, // FR-016 four-step physical name
241
250
  sql: source.sqlBody!, // verbatim — never parsed, never re-wrapped
242
251
  dependsOn,
252
+ fqn: host.resolutionKey(),
243
253
  // columns OMITTED → "unknown" → gated drop+create fail-safe.
244
254
  ...(schema !== undefined ? { schema } : {}),
245
255
  });