@metaobjectsdev/codegen-ts 0.23.1 → 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 (179) hide show
  1. package/dist/column-mapper.d.ts +32 -0
  2. package/dist/column-mapper.d.ts.map +1 -1
  3. package/dist/column-mapper.js +91 -8
  4. package/dist/column-mapper.js.map +1 -1
  5. package/dist/enum-meta.d.ts +20 -0
  6. package/dist/enum-meta.d.ts.map +1 -1
  7. package/dist/enum-meta.js +32 -1
  8. package/dist/enum-meta.js.map +1 -1
  9. package/dist/generator.d.ts +9 -0
  10. package/dist/generator.d.ts.map +1 -1
  11. package/dist/generator.js.map +1 -1
  12. package/dist/generators/api-field-shape.js +1 -1
  13. package/dist/generators/api-field-shape.js.map +1 -1
  14. package/dist/generators/api-model.d.ts.map +1 -1
  15. package/dist/generators/api-model.js +71 -42
  16. package/dist/generators/api-model.js.map +1 -1
  17. package/dist/generators/docs-data-builder.d.ts.map +1 -1
  18. package/dist/generators/docs-data-builder.js +36 -1
  19. package/dist/generators/docs-data-builder.js.map +1 -1
  20. package/dist/generators/docs-data.d.ts +14 -0
  21. package/dist/generators/docs-data.d.ts.map +1 -1
  22. package/dist/generators/docs-file.d.ts.map +1 -1
  23. package/dist/generators/docs-file.js +13 -4
  24. package/dist/generators/docs-file.js.map +1 -1
  25. package/dist/generators/extractor-file.d.ts.map +1 -1
  26. package/dist/generators/extractor-file.js +7 -11
  27. package/dist/generators/extractor-file.js.map +1 -1
  28. package/dist/generators/index.d.ts +4 -0
  29. package/dist/generators/index.d.ts.map +1 -1
  30. package/dist/generators/index.js +5 -0
  31. package/dist/generators/index.js.map +1 -1
  32. package/dist/generators/output-parser-file.d.ts.map +1 -1
  33. package/dist/generators/output-parser-file.js +12 -7
  34. package/dist/generators/output-parser-file.js.map +1 -1
  35. package/dist/generators/output-prompt-file.d.ts.map +1 -1
  36. package/dist/generators/output-prompt-file.js +14 -24
  37. package/dist/generators/output-prompt-file.js.map +1 -1
  38. package/dist/generators/requirement-tests.d.ts +44 -0
  39. package/dist/generators/requirement-tests.d.ts.map +1 -0
  40. package/dist/generators/requirement-tests.js +127 -0
  41. package/dist/generators/requirement-tests.js.map +1 -0
  42. package/dist/generators/requirements-file.d.ts +9 -0
  43. package/dist/generators/requirements-file.d.ts.map +1 -0
  44. package/dist/generators/requirements-file.js +53 -0
  45. package/dist/generators/requirements-file.js.map +1 -0
  46. package/dist/generators/requirements-markdown.d.ts +10 -0
  47. package/dist/generators/requirements-markdown.d.ts.map +1 -0
  48. package/dist/generators/requirements-markdown.js +71 -0
  49. package/dist/generators/requirements-markdown.js.map +1 -0
  50. package/dist/generators/requirements-toon.d.ts +3 -0
  51. package/dist/generators/requirements-toon.d.ts.map +1 -0
  52. package/dist/generators/requirements-toon.js +47 -0
  53. package/dist/generators/requirements-toon.js.map +1 -0
  54. package/dist/generators/requirements-view.d.ts +32 -0
  55. package/dist/generators/requirements-view.d.ts.map +1 -0
  56. package/dist/generators/requirements-view.js +64 -0
  57. package/dist/generators/requirements-view.js.map +1 -0
  58. package/dist/generators/trace-helper-file.d.ts.map +1 -1
  59. package/dist/generators/trace-helper-file.js +19 -11
  60. package/dist/generators/trace-helper-file.js.map +1 -1
  61. package/dist/index.d.ts +13 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +18 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/metaobjects-config.d.ts +12 -20
  66. package/dist/metaobjects-config.d.ts.map +1 -1
  67. package/dist/metaobjects-config.js +5 -1
  68. package/dist/metaobjects-config.js.map +1 -1
  69. package/dist/orphan-sweep.d.ts +38 -0
  70. package/dist/orphan-sweep.d.ts.map +1 -0
  71. package/dist/orphan-sweep.js +129 -0
  72. package/dist/orphan-sweep.js.map +1 -0
  73. package/dist/overwrite-policy.d.ts +97 -3
  74. package/dist/overwrite-policy.d.ts.map +1 -1
  75. package/dist/overwrite-policy.js +293 -81
  76. package/dist/overwrite-policy.js.map +1 -1
  77. package/dist/projection/build-projection-views.d.ts +8 -0
  78. package/dist/projection/build-projection-views.d.ts.map +1 -1
  79. package/dist/projection/build-projection-views.js +2 -0
  80. package/dist/projection/build-projection-views.js.map +1 -1
  81. package/dist/projection/extract-view-spec.d.ts.map +1 -1
  82. package/dist/projection/extract-view-spec.js +70 -6
  83. package/dist/projection/extract-view-spec.js.map +1 -1
  84. package/dist/reconcile-orphans.d.ts +80 -0
  85. package/dist/reconcile-orphans.d.ts.map +1 -0
  86. package/dist/reconcile-orphans.js +61 -0
  87. package/dist/reconcile-orphans.js.map +1 -0
  88. package/dist/render-engine/embedded-templates.generated.js +1 -1
  89. package/dist/render-engine/embedded-templates.generated.js.map +1 -1
  90. package/dist/requirement-walk.d.ts +57 -0
  91. package/dist/requirement-walk.d.ts.map +1 -0
  92. package/dist/requirement-walk.js +94 -0
  93. package/dist/requirement-walk.js.map +1 -0
  94. package/dist/runner.d.ts +35 -0
  95. package/dist/runner.d.ts.map +1 -1
  96. package/dist/runner.js +217 -21
  97. package/dist/runner.js.map +1 -1
  98. package/dist/templates/drizzle-schema.d.ts.map +1 -1
  99. package/dist/templates/drizzle-schema.js +67 -4
  100. package/dist/templates/drizzle-schema.js.map +1 -1
  101. package/dist/templates/extractor.d.ts +4 -4
  102. package/dist/templates/extractor.d.ts.map +1 -1
  103. package/dist/templates/extractor.js +18 -22
  104. package/dist/templates/extractor.js.map +1 -1
  105. package/dist/templates/filter-allowlist.d.ts.map +1 -1
  106. package/dist/templates/filter-allowlist.js +4 -2
  107. package/dist/templates/filter-allowlist.js.map +1 -1
  108. package/dist/templates/filter-type.d.ts.map +1 -1
  109. package/dist/templates/filter-type.js +6 -2
  110. package/dist/templates/filter-type.js.map +1 -1
  111. package/dist/templates/find-inbound.d.ts +44 -0
  112. package/dist/templates/find-inbound.d.ts.map +1 -0
  113. package/dist/templates/find-inbound.js +69 -0
  114. package/dist/templates/find-inbound.js.map +1 -0
  115. package/dist/templates/output-format-spec-emitter.d.ts.map +1 -1
  116. package/dist/templates/output-format-spec-emitter.js +7 -4
  117. package/dist/templates/output-format-spec-emitter.js.map +1 -1
  118. package/dist/templates/output-parser.d.ts +8 -3
  119. package/dist/templates/output-parser.d.ts.map +1 -1
  120. package/dist/templates/output-parser.js +71 -31
  121. package/dist/templates/output-parser.js.map +1 -1
  122. package/dist/templates/output-prompt.d.ts +6 -5
  123. package/dist/templates/output-prompt.d.ts.map +1 -1
  124. package/dist/templates/output-prompt.js +26 -37
  125. package/dist/templates/output-prompt.js.map +1 -1
  126. package/dist/templates/queries.js +1 -1
  127. package/dist/templates/queries.js.map +1 -1
  128. package/dist/templates/requirement-test.d.ts +12 -0
  129. package/dist/templates/requirement-test.d.ts.map +1 -0
  130. package/dist/templates/requirement-test.js +120 -0
  131. package/dist/templates/requirement-test.js.map +1 -0
  132. package/dist/templates/zod-validators.d.ts.map +1 -1
  133. package/dist/templates/zod-validators.js +32 -15
  134. package/dist/templates/zod-validators.js.map +1 -1
  135. package/package.json +7 -6
  136. package/src/column-mapper.ts +124 -8
  137. package/src/enum-meta.ts +37 -1
  138. package/src/generator.ts +9 -0
  139. package/src/generators/api-field-shape.ts +1 -1
  140. package/src/generators/api-model.ts +69 -44
  141. package/src/generators/docs-data-builder.ts +37 -0
  142. package/src/generators/docs-data.ts +15 -0
  143. package/src/generators/docs-file.ts +13 -4
  144. package/src/generators/extractor-file.ts +7 -11
  145. package/src/generators/index.ts +11 -0
  146. package/src/generators/output-parser-file.ts +12 -7
  147. package/src/generators/output-prompt-file.ts +14 -27
  148. package/src/generators/requirement-tests.ts +203 -0
  149. package/src/generators/requirements-file.ts +71 -0
  150. package/src/generators/requirements-markdown.ts +72 -0
  151. package/src/generators/requirements-toon.ts +64 -0
  152. package/src/generators/requirements-view.ts +93 -0
  153. package/src/generators/trace-helper-file.ts +20 -10
  154. package/src/index.ts +47 -1
  155. package/src/metaobjects-config.ts +17 -22
  156. package/src/orphan-sweep.ts +178 -0
  157. package/src/overwrite-policy.ts +362 -89
  158. package/src/projection/build-projection-views.ts +10 -0
  159. package/src/projection/extract-view-spec.ts +88 -4
  160. package/src/reconcile-orphans.ts +136 -0
  161. package/src/reference/barrel.ts +4 -0
  162. package/src/reference/entity.ts +4 -0
  163. package/src/reference/queries.ts +4 -0
  164. package/src/reference/routes.ts +4 -0
  165. package/src/render-engine/embedded-templates.generated.ts +1 -1
  166. package/src/requirement-walk.ts +124 -0
  167. package/src/runner.ts +266 -27
  168. package/src/templates/drizzle-schema.ts +70 -6
  169. package/src/templates/extractor.ts +19 -24
  170. package/src/templates/filter-allowlist.ts +4 -2
  171. package/src/templates/filter-type.ts +6 -2
  172. package/src/templates/find-inbound.ts +96 -0
  173. package/src/templates/output-format-spec-emitter.ts +7 -4
  174. package/src/templates/output-parser.ts +76 -34
  175. package/src/templates/output-prompt.ts +29 -42
  176. package/src/templates/queries.ts +1 -1
  177. package/src/templates/requirement-test.ts +140 -0
  178. package/src/templates/zod-validators.ts +32 -15
  179. package/templates/docs/entity-page.md.mustache +8 -0
package/src/runner.ts CHANGED
@@ -14,13 +14,19 @@ import type { ResolvedTarget } from "./import-path.js";
14
14
  import { buildPkMap } from "./pk-resolver.js";
15
15
  import { buildRelationMap } from "./relation-resolver.js";
16
16
  import { makeRenderContext } from "./render-context.js";
17
+ import { sweepOrphans, type OrphanJob } from "./orphan-sweep.js";
18
+ import { refusedOrphanMessage } from "./reconcile-orphans.js";
17
19
  import {
18
20
  decideAndWrite,
21
+ previewWriteStatus,
22
+ hasHashManifest,
23
+ listGeneratedPaths,
19
24
  loadEngineVersion,
20
25
  saveEngineVersion,
21
26
  type WriteResult,
22
27
  type MergeStrategy,
23
28
  type BaselineMode,
29
+ type DecideAndWriteOpts,
24
30
  } from "./overwrite-policy.js";
25
31
 
26
32
  /** JS-identifier-shape only. Prevents filesystem traversal when metadata comes
@@ -60,6 +66,41 @@ export interface RunGenOpts {
60
66
  * `--dry-run`, and watching it reappear.
61
67
  */
62
68
  dryRun?: boolean;
69
+ /**
70
+ * Output scope — an object is generated only when this predicate returns true
71
+ * for its fully-qualified name (`obj.resolutionKey()`, `<package>::<name>`).
72
+ * Intersects with `entityFilter`: both must pass. Absent ⇒ every object is
73
+ * in scope (byte-identical to a project with no `scope` declared).
74
+ *
75
+ * The collection metadata always loads in FULL regardless of this predicate —
76
+ * scope filters OUTPUT, never input (design §4.3). So an in-scope object may
77
+ * reference an out-of-scope one (an FK target, a relationship `@objectRef`, a
78
+ * projection's base) and resolve perfectly at load time, while the code
79
+ * emitted FOR the in-scope object still imports/names a symbol that was never
80
+ * generated. This is left silent by design, not auto-widened: the adopter
81
+ * declared the scope precisely because something else (another consumer,
82
+ * another codegen run) owns those objects, and the reference is real. Warning
83
+ * on it correctly would require walking every reference kind (identity.reference,
84
+ * every relationship.* @objectRef, projection extends bases, field.object
85
+ * @objectRef) FQN-resolved against the SAME scope — genuinely new machinery,
86
+ * not a fit for the existing `warnings: string[]` channel at this seam. If an
87
+ * adopter hits it, the failure is a plain compiler error in the generated
88
+ * code (an unresolved import) — loud, at build time, not silent at runtime.
89
+ *
90
+ * Deliberately a PLAIN PREDICATE, not the `include`/`exclude` pattern strings
91
+ * `@metaobjectsdev/sdk`'s `scope.ts` compiles. `codegen-ts` must not depend on
92
+ * `@metaobjectsdev/sdk` — the dependency runs the other way (`cli` depends on
93
+ * both) — so it cannot import `matchesScope`/`CompiledScope` itself. The
94
+ * design's "package patterns, never a predicate function" rule (§4.3 of the
95
+ * metadata-source-resolution design doc) governs CONFIG SURFACES that must
96
+ * port identically to a `pom.xml` / `metaobjects.config.yaml` in every
97
+ * language port; it says nothing about internal plumbing between two
98
+ * TypeScript packages in this one repo. Do not "fix" this into a config
99
+ * shape — `cli`'s `gen`/`verify` commands are the only callers, and a
100
+ * `Collection` already exposes exactly this predicate as `inScope`, which
101
+ * they pass straight through.
102
+ */
103
+ scope?: (fqn: string) => boolean;
63
104
  }
64
105
 
65
106
  export interface RunGenResult {
@@ -112,6 +153,12 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
112
153
  // since the last gen, note it (generated output may legitimately differ). Purely
113
154
  // informational — the version file is separate from `.hashes.json` and never
114
155
  // affects the merge. Only fires when a prior stamp exists AND differs.
156
+ // Captured BEFORE any write, because the first write creates the manifest — read
157
+ // it afterwards and every project looks migrated.
158
+ const hadHashManifest = hasHashManifest(genStateDir);
159
+ const relativeForDisplay = (p: string): string =>
160
+ projectRoot !== undefined ? relative(projectRoot, p) : p;
161
+
115
162
  const hasPersistentGenState = opts.projectRoot !== undefined || opts.genStateDir !== undefined;
116
163
  const installedEngine = hasPersistentGenState ? engineVersion() : undefined;
117
164
  const recordedEngine = hasPersistentGenState ? loadEngineVersion(genStateDir) : undefined;
@@ -136,16 +183,46 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
136
183
  }
137
184
  const root = opts.metadata;
138
185
 
139
- // 1. Resolve entities (filter + safety check).
186
+ // 1. Resolve entities (entityFilter + scope + safety check). This is the
187
+ // single choke point for entity selection — scope INTERSECTS entityFilter
188
+ // (an object must pass both), matched against the object's
189
+ // fully-qualified name (resolutionKey(), never the bare name — two
190
+ // packages may declare the same short name).
140
191
  const allObjects = root.objects();
141
192
  const entityFilter = opts.entityFilter;
142
- const filtered = entityFilter
193
+ const afterEntityFilter = entityFilter
143
194
  ? allObjects.filter((o) => entityFilter.includes(o.name))
144
195
  : allObjects;
196
+ const scope = opts.scope;
197
+ const filtered = scope
198
+ ? afterEntityFilter.filter((o) => scope(o.resolutionKey()))
199
+ : afterEntityFilter;
145
200
  if (filtered.length === 0) {
146
- const reason = opts.entityFilter
147
- ? "no object children match the provided entityFilter"
148
- : "root has no object children";
201
+ // Name the REAL cause. When `scope` is absent, this is byte-identical to
202
+ // the pre-scope two-way branch (kept as its own arm, rather than folded
203
+ // into the scope-aware logic below, so an unscoped project's warning text
204
+ // — including its quirky edge case: an empty root with entityFilter set
205
+ // still blames entityFilter — is untouched). Only when `scope` is
206
+ // present does a THIRD reason become reachable: "root has no object
207
+ // children" for a scoped-out model, or "...entityFilter" for a scope
208
+ // that admitted everything entityFilter then excluded, are both false
209
+ // statements that send the reader to the wrong file.
210
+ let reason: string;
211
+ if (scope === undefined) {
212
+ // Byte-identical to the pre-scope branch, quirk included: an EMPTY root
213
+ // with an entityFilter set still blames the filter. Wrong, and untouched
214
+ // — changing what an unscoped project reads is a behaviour change, and
215
+ // this is a shape change.
216
+ reason = entityFilter
217
+ ? "no object children match the provided entityFilter"
218
+ : "root has no object children";
219
+ } else if (allObjects.length === 0) {
220
+ reason = "root has no object children";
221
+ } else if (afterEntityFilter.length === 0) {
222
+ reason = "no object children match the provided entityFilter";
223
+ } else {
224
+ reason = "no object children match the configured scope";
225
+ }
149
226
  warnings.push(`No entities to generate — ${reason}.`);
150
227
  return { files: [], warnings, conflicts: [] };
151
228
  }
@@ -218,16 +295,25 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
218
295
  const entityGen = config.generators.find((g) => g.emitsEntityModule);
219
296
  const entityModuleTarget = entityGen ? targetOf(entityGen) : targets[DEFAULT_TARGET_NAME]!;
220
297
 
221
- const needsCrossTarget = config.generators.some(
222
- (g) => (g.target ?? DEFAULT_TARGET_NAME) !== entityModuleTarget.name,
223
- );
224
- if (needsCrossTarget && entityModuleTarget.importBase === undefined) {
225
- throw new Error(
226
- `Target "${entityModuleTarget.name}" holds the entity modules that other ` +
227
- `targets import, but has no importBase. Set importBase on it (e.g. ` +
228
- `"@your-pkg/database/generated").`,
229
- );
230
- }
298
+ // NO eager importBase check here, deliberately.
299
+ //
300
+ // There used to be one: "any generator on a non-entity target ⇒ the entity
301
+ // target must have importBase". It asked the wrong question — target placement,
302
+ // not whether anything actually imports across targets — so it convicted every
303
+ // multi-target project whose second target imports nothing, and the only way
304
+ // out was to set a value that is provably inert. An adopter hit this with a
305
+ // requirement-test target that imports no entity modules at all.
306
+ //
307
+ // `crossTargetEntityPath` (import-path.ts) is the SOLE consumer of importBase
308
+ // and already throws when it is missing, naming the resolution that needed it —
309
+ // and every cross-target entry point (`entityModuleSpecifier`,
310
+ // `barrelModuleSpecifier`) routes through it. That throw happens inside phase 4,
311
+ // before the write phase, and the runner tags it with the generator name, so
312
+ // the diagnostic is strictly better than the one removed here.
313
+ //
314
+ // The trade, stated: a generator whose cross-target import is CONDITIONAL now
315
+ // fails when a model change first makes it real, rather than at config time.
316
+ // That is the correct moment — it is also the only moment the answer is known.
231
317
 
232
318
  // 3. Build shared render state once.
233
319
  const pkMap = buildPkMap(root);
@@ -272,6 +358,10 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
272
358
 
273
359
  // 4. Run each generator with a per-target render context; collect with full path.
274
360
  const emitted: { fullPath: string; content: string; generatedBy: string }[] = [];
361
+ // FR-038 §8 — generators that opted into orphan reconciliation, paired with the
362
+ // directory their policy's relative paths are measured from. Collected here
363
+ // because `writeOutDir` is resolved per generator inside this loop.
364
+ const orphanJobs: OrphanJob[] = [];
275
365
  for (const generator of config.generators) {
276
366
  // ADR-0025: `meta docs` is the single docs door. A `meta gen` config that
277
367
  // still lists a deprecated doc generator is warned + skipped, not run — the
@@ -294,6 +384,13 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
294
384
  const writeOutDir = projectRoot !== undefined && !isAbsolute(selfTarget.outDir)
295
385
  ? resolve(projectRoot, selfTarget.outDir)
296
386
  : selfTarget.outDir;
387
+ if (generator.orphanPolicy !== undefined) {
388
+ orphanJobs.push({
389
+ generatorName: generator.name,
390
+ writeOutDir,
391
+ policy: generator.orphanPolicy,
392
+ });
393
+ }
297
394
  const renderContext = makeRenderContext({
298
395
  dialect: config.dialect,
299
396
  loadedRoot: root,
@@ -367,17 +464,156 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
367
464
  const writes: WriteResult[] = [];
368
465
  const conflicts: WriteResult[] = [];
369
466
 
467
+ // FR-038 §8 — reconcile files a previous run generated that this run does not.
468
+ //
469
+ // Gated on a real `projectRoot` for a load-bearing reason, not caution: without
470
+ // one, `decideAndWrite` keys each snapshot by a hash of its absolute path
471
+ // instead of a project-relative path, so gen-state holds no path to resolve and
472
+ // reconciliation has nothing to reason about. That gate is also what keeps
473
+ // `verify --codegen` inert — it runs against a throwaway root whose gen-state is
474
+ // empty — and what keeps programmatic/test callers from ever deleting a file.
475
+ // Refusals are reported ONE of two ways, and which one matters more than the
476
+ // wording. A project with no manifest at all predates the manifest being
477
+ // committed: every refusal in it has the SAME single cause and the same one-line
478
+ // fix, so N per-file warnings would be a wall that buries the instruction — the
479
+ // hostile-first-contact outcome that gets a tool switched off. A project that DOES
480
+ // have a manifest is refusing because specific files were edited, and there the
481
+ // per-file naming is the actionable part.
482
+ //
483
+ // Self-extinguishing: once the manifest is committed, the aggregate never fires
484
+ // again.
485
+ const MAX_NAMED = 5;
486
+ const reportRefusals = (): void => {
487
+ const refused = writes.filter((w) => w.status === "refused");
488
+ if (refused.length === 0) return;
489
+
490
+ if (!hadHashManifest) {
491
+ const names = refused.slice(0, MAX_NAMED).map((w) => relativeForDisplay(w.path));
492
+ const more = refused.length > MAX_NAMED ? `, and ${refused.length - MAX_NAMED} more` : "";
493
+ warnings.push(
494
+ `Refused to overwrite ${refused.length} existing file(s), and this project has ` +
495
+ `no codegen hash manifest — so 'meta gen' cannot tell your edits from its own ` +
496
+ `stale output, and it will not guess. This is the expected first run for a ` +
497
+ `project created before the manifest was committed. ` +
498
+ `ONE-TIME FIX: commit '.metaobjects/.gen-state/.hashes.json' (un-ignore it in ` +
499
+ `.metaobjects/.gitignore with '.gen-state/*' + '!.gen-state/.hashes.json'), ` +
500
+ `then re-run. To adopt fresh output and DISCARD any hand edits in these files ` +
501
+ `instead, re-run with --baseline=fresh. Files: ${names.join(", ")}${more}.`,
502
+ );
503
+ return;
504
+ }
505
+
506
+ for (const w of refused) {
507
+ warnings.push(
508
+ `Refused to overwrite ${w.path}: ${w.conflictHint ?? "content differs and could not be verified as generated."}`,
509
+ );
510
+ }
511
+ };
512
+
513
+ const sweep = (dryRun: boolean): void => {
514
+ if (projectRoot === undefined || orphanJobs.length === 0) return;
515
+
516
+ // NEVER reconcile a PARTIAL run. `meta gen <entity>` narrows the object set, so
517
+ // `emitted` is a subset of the full output BY CONSTRUCTION — every path belonging
518
+ // to an unselected entity looks exactly like an orphan, and the untouched ones get
519
+ // deleted. The shipped `requirementTests()` escapes only by accident (it walks
520
+ // `ctx.loadedRoot` and ignores `ctx.matches`); any app generator that honours
521
+ // `ctx.matches` — the documented, encouraged composition — would wipe every
522
+ // non-selected entity's output on a routine filtered run.
523
+ //
524
+ // The runner is the ONLY layer that knows the run was partial: a generator sees a
525
+ // narrowed entity list and cannot tell it from a model that genuinely has one
526
+ // entity. So the guard has to live here.
527
+ if (opts.entityFilter !== undefined && opts.entityFilter.length > 0) {
528
+ // Say it only when something was ACTUALLY withheld. With no previously-generated
529
+ // path outside this run's own output there are no orphan candidates at all, so
530
+ // the sweep provably no-ops and there is nothing to report. `meta gen <entity>`
531
+ // is a routine command; warning on every one of them teaches the reader to skim
532
+ // the message, which is how the real one gets skimmed too.
533
+ //
534
+ // Note the manifest is NOT empty here even on a first run — the writes above have
535
+ // already recorded this run's own paths in it — so the emitted set has to come
536
+ // out before counting. Computing the candidate set is safe on a filtered run;
537
+ // what must never happen is ACTING on it.
538
+ //
539
+ // A non-empty candidate set is the FLOOR, not proof that a specific file would
540
+ // have been removed: whether one falls inside an opting-in generator's namespace
541
+ // is the reconcile's answer, and running that is the thing being skipped.
542
+ const emittedRel = new Set(emitted.map((f) => relative(projectRoot, f.fullPath)));
543
+ const withheld = listGeneratedPaths(genStateDir).filter((p) => !emittedRel.has(p));
544
+ if (withheld.length > 0) {
545
+ warnings.push(
546
+ `Skipped orphan cleanup: this run generated only ${opts.entityFilter.join(", ")}, ` +
547
+ `so it cannot tell a file belonging to an unselected entity from one that is no ` +
548
+ `longer generated. Run 'meta gen' with no entity filter to reconcile deletions.`,
549
+ );
550
+ }
551
+ return;
552
+ }
553
+ const result = sweepOrphans({
554
+ genStateDir,
555
+ projectRoot,
556
+ emittedRelPaths: emitted.map((f) => relative(projectRoot, f.fullPath)),
557
+ jobs: orphanJobs,
558
+ dryRun,
559
+ });
560
+ // Both kinds are the same file outcome — gone. What differs is what it cost,
561
+ // which is what the warning below is for.
562
+ for (const relPath of [...result.removed, ...result.forced]) {
563
+ writes.push({ path: join(projectRoot, relPath), status: "removed" });
564
+ }
565
+ if (result.refused.length > 0) {
566
+ // Grouped by the generator that refused, so each message names a generator the
567
+ // project actually registered. `orphanPolicy` is generic and apps are encouraged
568
+ // to compose their own, so one blanket message naming `requirement-tests` would
569
+ // be wrong for precisely the users the seam exists for.
570
+ const byGenerator = new Map<string, string[]>();
571
+ for (const relPath of result.refused) {
572
+ const owner = result.refusedBy.get(relPath) ?? "orphan cleanup";
573
+ const list = byGenerator.get(owner);
574
+ if (list === undefined) byGenerator.set(owner, [relPath]);
575
+ else list.push(relPath);
576
+ }
577
+ for (const [owner, paths] of byGenerator) {
578
+ warnings.push(refusedOrphanMessage(paths, owner));
579
+ }
580
+ }
581
+ if (result.forced.length > 0) {
582
+ warnings.push(
583
+ `Deleted ${result.forced.length} hand-edited generated file(s) because a ` +
584
+ `generator's orphanPolicy sets force: ${result.forced.join(", ")}. ` +
585
+ `Hand-written content in them is gone — recover from version control.`,
586
+ );
587
+ }
588
+ };
589
+
370
590
  // --dry-run: report what WOULD be written and touch nothing — no output files
371
591
  // and no .gen-state/ snapshot (writing the snapshot would silently re-baseline
372
592
  // the merge base, so a later real run could skip a genuinely-needed write).
373
593
  if (opts.dryRun === true) {
374
594
  for (const file of emitted) {
375
- // Report the outcome faithfully rather than a placeholder: a path that does
376
- // not exist yet would be created ("new"), one that does would be rewritten.
377
- // Merge/conflict outcomes can't be known without doing the merge, so this
378
- // deliberately reports the coarser truth instead of guessing.
379
- writes.push({ path: file.fullPath, status: existsSync(file.fullPath) ? "overwrite" : "new" });
595
+ // Ask the same policy the real run asks, in a read-only mode. This used to be
596
+ // `existsSync(...) ? "overwrite" : "new"`, which previewed a hand-edited file
597
+ // as "overwrite" while the real run refused it — the one case the preview most
598
+ // needs to be right about. A merge outcome is still coarse (see
599
+ // previewWriteStatus), because clean-vs-conflicted is unknowable without merging.
600
+ const policyOpts: DecideAndWriteOpts = {
601
+ strategy,
602
+ genStateDir,
603
+ baseline,
604
+ };
605
+ if (projectRoot !== undefined) {
606
+ policyOpts.outputRelPath = relative(projectRoot, file.fullPath);
607
+ }
608
+ writes.push({
609
+ path: file.fullPath,
610
+ status: previewWriteStatus(file.fullPath, file.content, policyOpts),
611
+ });
380
612
  }
613
+ reportRefusals();
614
+ // A preview that hides a pending deletion is worse than no preview at all, so
615
+ // the sweep still runs — in decide-and-report mode, touching nothing.
616
+ sweep(true);
381
617
  return { files: writes, warnings, conflicts };
382
618
  }
383
619
 
@@ -386,7 +622,7 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
386
622
  // distinct entries (e.g. `database/Post.ts` vs `web/Post.queries.ts`).
387
623
  // Without an explicit projectRoot we let decideAndWrite derive a stable
388
624
  // hash-of-path key — fine for ephemeral test runs.
389
- const policyOpts: import("./overwrite-policy.js").DecideAndWriteOpts = {
625
+ const policyOpts: DecideAndWriteOpts = {
390
626
  strategy,
391
627
  genStateDir,
392
628
  baseline,
@@ -403,14 +639,17 @@ export async function runGen(opts: RunGenOpts): Promise<RunGenResult> {
403
639
  `'meta gen' to advance the canonical state.`,
404
640
  );
405
641
  }
406
- if (result.status === "refused") {
407
- warnings.push(
408
- `Refused to overwrite ${file.fullPath}: file exists without @generated header. ` +
409
- `Move to a different outDir, delete the file, or add the header to opt in.`,
410
- );
411
- }
642
+ // Refusals are reported together after the loop (see reportRefusals) so a
643
+ // whole-project cause can be stated once instead of once per file.
412
644
  }
413
645
 
646
+ reportRefusals();
647
+
648
+ // Sweep AFTER the writes: writing is the primary job, and a deletion that runs
649
+ // first would be unrecoverable if a later write threw. Ordering cannot change
650
+ // the decision — a path this run emitted is never an orphan either way.
651
+ sweep(false);
652
+
414
653
  // #232 — stamp the engine version that produced this snapshot, so the NEXT gen can
415
654
  // detect an engine change. Written after a successful run only.
416
655
  if (installedEngine !== undefined) saveEngineVersion(genStateDir, installedEngine);
@@ -13,7 +13,7 @@ import {
13
13
  } from "@metaobjectsdev/metadata";
14
14
  import { fieldDeclaringPackage, type RenderContext } from "../render-context.js";
15
15
  import { crossEntitySpecifier, valueObjectModuleSpecifier } from "../import-path.js";
16
- import { mapColumnType, type ColumnSpec } from "../column-mapper.js";
16
+ import { mapColumnType, type ColumnSpec, type EnumIntCustomType } from "../column-mapper.js";
17
17
  import { tableNameFromEntity, columnNameFromField } from "../naming.js";
18
18
  import { renderRelationsBlock } from "./relations-block.js";
19
19
  import { renderDocsFor } from "./jsdoc.js";
@@ -66,6 +66,9 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
66
66
  const columnLines: Code[] = [];
67
67
  // Collect CHECK constraints for enum columns; emitted as table-level check() callbacks.
68
68
  const checkConstraints: Array<{ name: string; expr: string }> = [];
69
+ // Int-backed field.enum customType helpers, emitted ahead of the table. Keyed by
70
+ // const name so a shared enum used by two fields of the SAME entity emits once.
71
+ const enumIntTypes = new Map<string, EnumIntCustomType>();
69
72
  for (const child of obj.fields()) {
70
73
  // #213 — a derived (origin-bearing) field is read-only, materialized on the
71
74
  // read (view) side, NOT a column on the entity's write table (FR-024 §7).
@@ -78,6 +81,9 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
78
81
  // Compute the column spec once per field and reuse it for both the column
79
82
  // line and the CHECK collection.
80
83
  const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode);
84
+ if (spec.enumIntCustomType !== undefined) {
85
+ enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType);
86
+ }
81
87
  const fieldDocs = renderDocsFor(child);
82
88
  const columnLine = renderColumn(spec, child, ctx, isPk, pkGeneration, fkInfo, isComposite, isUnique, obj.package, obj.name);
83
89
  columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine);
@@ -103,6 +109,9 @@ export function renderDrizzleSchema(obj: MetaObject, ctx: RenderContext): Code {
103
109
  // #213 — a TPH subtype's derived field is read-only too; never a table column.
104
110
  if (child.isDerived()) continue;
105
111
  const spec = mapColumnType(child, ctx.dialect, ctx.columnNamingStrategy, ctx.timestampMode);
112
+ if (spec.enumIntCustomType !== undefined) {
113
+ enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType);
114
+ }
106
115
  const fieldDocs = renderDocsFor(child);
107
116
  const columnLine = renderColumn(
108
117
  spec, child, ctx, false, undefined, fkMap.get(child.name), isComposite, false, obj.package, obj.name, true,
@@ -177,11 +186,60 @@ ${joinCode(columnLines, { on: ",\n", trim: false })}
177
186
  // Emit the relations() block (returns null if no relations).
178
187
  const relationsBlock = renderRelationsBlock(obj, ctx);
179
188
 
180
- if (relationsBlock === null) {
181
- return tableBlock;
182
- }
189
+ // Int-backed enum codecs are declared BEFORE the table that references them.
190
+ // Sorted by const name so output is deterministic regardless of field order.
191
+ const enumIntBlocks = [...enumIntTypes.values()]
192
+ .sort((a, b) => a.fnConstName.localeCompare(b.fnConstName))
193
+ .map((t) => renderEnumIntCustomType(t, importModule));
183
194
 
184
- return joinCode([tableBlock, relationsBlock], { on: "\n" });
195
+ const blocks: Code[] = [...enumIntBlocks, tableBlock];
196
+ if (relationsBlock !== null) blocks.push(relationsBlock);
197
+ return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" });
198
+ }
199
+
200
+ /**
201
+ * Render an int-backed `field.enum`'s Drizzle `customType` helper plus its two
202
+ * lookup maps.
203
+ *
204
+ * The codec lives HERE, in the column definition, so nothing downstream needs to
205
+ * know about it: `db.insert().values()` encodes on bind, a selected row decodes on
206
+ * read, and a filter comparison encodes because Drizzle binds through the column
207
+ * type. That is why this shape was chosen over a Zod write-transform plus a
208
+ * generated read-decode — TS's generated queries return raw Drizzle rows and have
209
+ * no decode seam, so the query-layer approach meant inventing one and wrapping
210
+ * every generated read. It is also the direct analogue of what the other four
211
+ * ports already do (EF Core `HasConversion`, OMDB `JdbcFieldCodec`, Exposed
212
+ * `customEnumeration`, Python `ObjectManager` coercion).
213
+ *
214
+ * `fromDriver` throws on an unmapped integer rather than returning undefined: a
215
+ * value outside the map means the DB holds data the model says is impossible
216
+ * (a hand-written INSERT, or a member removed without a migration), and silently
217
+ * yielding `undefined` for a non-nullable field would surface far from the cause.
218
+ */
219
+ function renderEnumIntCustomType(t: EnumIntCustomType, importModule: string): Code {
220
+ const customTypeSym = imp(`customType@${importModule}`);
221
+ const union = t.members.map((m) => JSON.stringify(m)).join(" | ");
222
+ const toEntries = t.members
223
+ .map((m) => `${JSON.stringify(m)}: ${t.intByMember[m]}`)
224
+ .join(", ");
225
+ const fromEntries = t.members
226
+ .map((m) => `${t.intByMember[m]}: ${JSON.stringify(m)}`)
227
+ .join(", ");
228
+ return code`
229
+ const ${t.toIntConstName} = { ${toEntries} } as const satisfies Record<${union}, number>;
230
+ const ${t.fromIntConstName}: Record<number, ${union}> = { ${fromEntries} };
231
+ const ${t.fnConstName} = ${customTypeSym}<{ data: ${union}; driverData: number }>({
232
+ dataType: () => ${JSON.stringify(t.dataType)},
233
+ toDriver: (value) => ${t.toIntConstName}[value],
234
+ fromDriver: (value) => {
235
+ const member = ${t.fromIntConstName}[value];
236
+ if (member === undefined) {
237
+ throw new Error(\`unmapped ${t.fnConstName} value: \${value}\`);
238
+ }
239
+ return member;
240
+ },
241
+ });
242
+ `;
185
243
  }
186
244
 
187
245
  interface FkInfo {
@@ -266,7 +324,13 @@ function renderColumn(
266
324
  // and suppress any DB default (other-subtype rows must stay NULL here).
267
325
  forceNullable: boolean = false,
268
326
  ): Code {
269
- const fnSym = imp(`${spec.fnName}@${spec.importModule}`);
327
+ // An int-backed field.enum's column function is a LOCAL generated const (the
328
+ // customType helper emitted into this same file), so it must not be imported
329
+ // from drizzle-orm/*-core like a built-in column type would be.
330
+ const fnSym =
331
+ spec.enumIntCustomType !== undefined
332
+ ? spec.enumIntCustomType.fnConstName
333
+ : imp(`${spec.fnName}@${spec.importModule}`);
270
334
 
271
335
  const dbNameLit = JSON.stringify(spec.dbName);
272
336
  let baseCall: Code;
@@ -24,15 +24,15 @@ import {
24
24
  type MetaData,
25
25
  type MetaField,
26
26
  TYPE_TEMPLATE,
27
- TEMPLATE_SUBTYPE_OUTPUT,
27
+ TEMPLATE_SUBTYPE_PROMPT,
28
28
  FIELD_SUBTYPE_OBJECT,
29
29
  FIELD_SUBTYPE_ENUM,
30
30
  FIELD_ATTR_OBJECT_REF,
31
31
  FIELD_ATTR_REQUIRED,
32
- TEMPLATE_ATTR_PAYLOAD_REF,
33
- TEMPLATE_ATTR_FORMAT,
32
+ TEMPLATE_ATTR_RESPONSE_REF,
34
33
  resolveObjectRef,
35
34
  } from "@metaobjectsdev/metadata";
35
+ import { responseShape } from "./find-inbound.js";
36
36
  import { fields, isArray } from "./fr010-field-mapping.js";
37
37
  import { mirrorName } from "./extract-delegate-emitter.js";
38
38
  import { enumUnionAliasName } from "./inferred-types.js";
@@ -288,36 +288,31 @@ function reachableMirrorTypes(vo: MetaData, root: MetaData, rootMirror: string,
288
288
  }
289
289
 
290
290
  /**
291
- * Render the full `<TemplateName>.extractor.ts` for one `template.output` node.
292
- * Throws if the template isn't found / isn't a template.output / its @payloadRef doesn't resolve,
293
- * or if the target format is not json/xml (the extract tier requires the extract<Name> API, which
294
- * only the json/xml output-parsers emit).
291
+ * Render the full `<PromptName>.extractor.ts` for one responding `template.prompt`.
292
+ * Throws if the template isn't found / isn't a template.prompt / its @responseRef
293
+ * doesn't resolve. ADR-0052: no format gate remains @responseFormat is a closed
294
+ * json|xml set, so every responding prompt has an extract<Name> API to sit over.
295
295
  */
296
296
  export function renderExtractor(root: MetaData, templateName: string, ctx?: RenderContext): string {
297
297
  const tmpl = findTemplate(root, templateName);
298
298
  if (!tmpl) {
299
299
  throw new Error(`template "${templateName}" not found in metadata root`);
300
300
  }
301
- if (tmpl.subType !== TEMPLATE_SUBTYPE_OUTPUT) {
302
- throw new Error(`template "${templateName}" is not a template.output (got subtype "${tmpl.subType}")`);
301
+ if (tmpl.subType !== TEMPLATE_SUBTYPE_PROMPT) {
302
+ throw new Error(`template "${templateName}" is not a template.prompt (got subtype "${tmpl.subType}")`);
303
303
  }
304
- // ADR-0039: resolving a template may inherit its @* refs/format/kind via extends.
305
- const payloadRef = tmpl.attr(TEMPLATE_ATTR_PAYLOAD_REF);
306
- if (typeof payloadRef !== "string") {
307
- throw new Error(`template "${templateName}" missing @payloadRef`);
308
- }
309
- // ADR-0042: a bare @payloadRef resolves in the template's package.
310
- const vo = findObject(root, payloadRef, tmpl.package ?? tmpl.fileDefaultPackage ?? "");
311
- if (!vo) {
312
- throw new Error(`template "${templateName}" @payloadRef "${payloadRef}" not found in metadata root`);
313
- }
314
- // ADR-0039: resolving — a template may inherit its @* refs/format/kind via extends.
315
- const format = ((tmpl.attr(TEMPLATE_ATTR_FORMAT) as string | undefined) ?? "text").toLowerCase();
316
- if (format !== "json" && format !== "xml") {
304
+ // ADR-0052: the extract tier reads a REPLY, so it binds @responseRef.
305
+ const shape = responseShape(root, tmpl);
306
+ if (!shape) {
307
+ // ADR-0039: resolving @responseRef may be inherited via extends.
308
+ const declared = tmpl.attr(TEMPLATE_ATTR_RESPONSE_REF);
317
309
  throw new Error(
318
- `template "${templateName}" @format "${format}" has no extract API to extract over (json/xml only)`,
310
+ typeof declared === "string"
311
+ ? `template "${templateName}" @responseRef "${declared}" not found in metadata root`
312
+ : `template "${templateName}" missing @responseRef`,
319
313
  );
320
314
  }
315
+ const { vo } = shape;
321
316
 
322
317
  // ADR-0044/#228: the strict payload TYPE name is the entity-domain EMITTED name (Task 3's
323
318
  // `valueObjectEmittedName`) — the SAME name entityFile() declared the interface under, so a
@@ -349,7 +344,7 @@ export function renderExtractor(root: MetaData, templateName: string, ctx?: Rend
349
344
  `// arrays-of-objects populated) in one call, by delegating to the nested-capable extract and\n` +
350
345
  `// mapping the all-nullable mirror onto the strict payload. No registry / binding / factory.\n` +
351
346
  `\n` +
352
- `import {\n ${extractLenientWithName},\n type ${mirrorTypes.join(",\n type ")},\n} from "./${templateName}.output.js";\n` +
347
+ `import {\n ${extractLenientWithName},\n type ${mirrorTypes.join(",\n type ")},\n} from "./${templateName}.response.js";\n` +
353
348
  `${payloadImports}\n` +
354
349
  `import type { MetaRoot } from "@metaobjectsdev/metadata";\n` +
355
350
  `import type { ExtractionResult } from "@metaobjectsdev/render";\n` +
@@ -13,7 +13,7 @@ import {
13
13
  FIELD_SUBTYPE_TIME,
14
14
  FIELD_SUBTYPE_TIMESTAMP,
15
15
  FIELD_SUBTYPE_CURRENCY,
16
- opsForSubType,
16
+ opsForField,
17
17
  } from "@metaobjectsdev/metadata";
18
18
  import { sortableFields } from "./filter-shared.js";
19
19
  import type { RenderContext } from "../render-context.js";
@@ -72,7 +72,9 @@ export const ${entity.name}FilterAllowlist = {} as const satisfies FilterAllowli
72
72
  }
73
73
  const rows = fields
74
74
  .map((f) => {
75
- const ops = opsForSubType(f.subType).map((o) => JSON.stringify(o)).join(", ");
75
+ // opsForField, not opsForSubType an int-backed field.enum (@intValueMap)
76
+ // stores as an integer, so `like` (a substring match) is not in its band.
77
+ const ops = opsForField(f).map((o) => JSON.stringify(o)).join(", ");
76
78
  const sub = filterSubTypeFor(f.subType);
77
79
  // Only field.timestamp is governed by timestampMode — Drizzle types
78
80
  // field.date / field.time as strings under every dialect.
@@ -11,7 +11,7 @@ import {
11
11
  FIELD_SUBTYPE_LONG,
12
12
  FIELD_SUBTYPE_DOUBLE,
13
13
  FIELD_SUBTYPE_FLOAT,
14
- opsForSubType,
14
+ opsForField,
15
15
  } from "@metaobjectsdev/metadata";
16
16
  import { isSortableField } from "./filter-shared.js";
17
17
 
@@ -34,7 +34,11 @@ function tsNameFor(fieldSubType: string): string {
34
34
  }
35
35
 
36
36
  function renderFieldUnion(field: MetaField): string {
37
- const ops = opsForSubType(field.subType);
37
+ // opsForField, not opsForSubType — an int-backed field.enum (@intValueMap) stores
38
+ // as an integer, so `like` is not in its band. The client type and the server
39
+ // allowlist MUST agree: offering `like` here that the allowlist 400s is a
40
+ // client/server mismatch of exactly the kind filter-shared.ts exists to prevent.
41
+ const ops = opsForField(field);
38
42
  const tsName = tsNameFor(field.subType);
39
43
  const opEntries = ops.map((op) => {
40
44
  if (op === "in") return `in?: ${tsName}[]`;