@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
@@ -24,8 +24,10 @@ import {
24
24
  TEMPLATE_ATTR_PAYLOAD_REF,
25
25
  TEMPLATE_ATTR_RESPONSE_REF,
26
26
  TEMPLATE_ATTR_FORMAT,
27
+ RESPONSE_FORMAT_XML,
27
28
  TEMPLATE_ATTR_TEXT_REF,
28
29
  } from "@metaobjectsdev/metadata";
30
+ import { responseFormatOf } from "../templates/find-inbound.js";
29
31
  import type { MetaObject } from "@metaobjectsdev/metadata";
30
32
  import {
31
33
  type EmittedFile,
@@ -113,13 +115,19 @@ export const traceHelperFile = function traceHelperFile(opts?: TraceHelperOpts):
113
115
  ? `{ ...input, callType: ${JSON.stringify(callTypeValue)}, status, errorDetail }`
114
116
  : `{ ...input, status, errorDetail }`;
115
117
 
116
- // Derive the parse format from the prompt's @format attr.
117
- // "xml" → Format.XML; absent or any other value → Format.JSON.
118
- // ADR-0039: resolvinga prompt may inherit @format via extends.
119
- const promptFormat = prompt.attr(TEMPLATE_ATTR_FORMAT);
120
- const formatLiteral = typeof promptFormat === "string" && promptFormat.toLowerCase() === "xml"
121
- ? "Format.XML"
122
- : "Format.JSON";
118
+ // ADR-0053: the REPLY's syntax is @responseFormat, not @format.
119
+ //
120
+ // This site used to read @format twice once as the reply's syntax and once
121
+ // as the prompt body's — under a comment calling them "two intentionally
122
+ // different shapes". They are two different FACTS, not two shapes of one:
123
+ // a plain-text prompt can elicit an XML reply, and the shipped docs-site
124
+ // fixture is exactly that. Reading @format here mis-parsed every prompt whose
125
+ // body format differed from its reply's.
126
+ //
127
+ // The same rule the output-parser / extractor / response-format-fragment
128
+ // generators use — all four now go through responseFormatOf().
129
+ const formatLiteral =
130
+ responseFormatOf(prompt) === RESPONSE_FORMAT_XML ? "Format.XML" : "Format.JSON";
123
131
 
124
132
  // Collect VO names for interface emission (dedupe via batch emitter).
125
133
  // Both refs are guaranteed strings by the guards above. ADR-0042: a bare
@@ -137,9 +145,11 @@ export const traceHelperFile = function traceHelperFile(opts?: TraceHelperOpts):
137
145
  // ADR-0039: resolving — a prompt may inherit @textRef via extends.
138
146
  const textRef = prompt.attr(TEMPLATE_ATTR_TEXT_REF);
139
147
  const renderable = typeof textRef === "string";
140
- // Same @format attr, two intentionally different shapes: extract() takes the
141
- // Format enum (formatLiteral, above → Format.XML/Format.JSON), render() takes the
142
- // raw format string (renderFormat, here e.g. "json"/"xml", default "text").
148
+ // render() takes the raw format string of the prompt BODY @format, which is
149
+ // genuinely this attribute's job. Distinct from formatLiteral above, which is
150
+ // the REPLY's syntax and comes from @responseFormat (ADR-0053).
151
+ // ADR-0039: resolving — a prompt may inherit @format via extends.
152
+ const promptFormat = prompt.attr(TEMPLATE_ATTR_FORMAT);
143
153
  const renderFormat = typeof promptFormat === "string" ? promptFormat : "text";
144
154
 
145
155
  // Build the import block — all imports MUST stay at the top of the emitted file.
package/src/index.ts CHANGED
@@ -37,7 +37,7 @@ export {
37
37
  } from "./generator-registry.js";
38
38
  export type { GeneratorRegistryEntry, GeneratorTier } from "./generator-registry.js";
39
39
 
40
- export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, DocsConfig, ResolvedDocsConfig, DocsSurface, ApiSurface, VerifyConfig } from "./metaobjects-config.js";
40
+ export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, DocsConfig, ResolvedDocsConfig, DocsSurface, ApiSurface } from "./metaobjects-config.js";
41
41
  export { defineConfig, normalizeConfig, resolveGenerators, resolveDocsConfig } from "./metaobjects-config.js";
42
42
  export { apiLabel } from "./generators/api-label.js";
43
43
 
@@ -193,3 +193,49 @@ export type {
193
193
  export { buildEntityDocData } from "./generators/docs-data-builder.js";
194
194
  export type { TemplateDocData, TemplateOutputPart } from "./generators/template-doc-data.js";
195
195
  export { buildTemplateDocData } from "./generators/template-doc-builder.js";
196
+
197
+ // FR-038 — requirement-derived test stubs.
198
+ //
199
+ // Both the factory AND its primitives are exported deliberately. Scaffold-and-own
200
+ // means the application owns its generator file, but that is only a real escape
201
+ // hatch if it can compose one from parts — otherwise an app needing one different
202
+ // behaviour must reimplement the requirement walk, and reimplementing it badly is
203
+ // worse than the bug report this is meant to avoid.
204
+ export { requirementTests } from "./generators/requirement-tests.js";
205
+ export { requirementsFile } from "./generators/requirements-file.js";
206
+ export type { RequirementRow } from "./generators/requirements-view.js";
207
+ export type {
208
+ RequirementTestsOpts,
209
+ RequirementTestRenderer,
210
+ } from "./generators/requirement-tests.js";
211
+ export {
212
+ walkRequirements,
213
+ groupByConcern,
214
+ concernOf,
215
+ NO_CONCERN,
216
+ } from "./requirement-walk.js";
217
+ export type {
218
+ RequirementView,
219
+ ResolvedClaim,
220
+ WalkedRequirement,
221
+ } from "./requirement-walk.js";
222
+ export { renderRequirementTest } from "./templates/requirement-test.js";
223
+ export type { RequirementTestArgs } from "./templates/requirement-test.js";
224
+
225
+ // FR-038 §8 — deletion integrity. Pure decision logic: which no-longer-generated
226
+ // files may be removed, and which were hand-edited and must be refused instead.
227
+ // `OrphanPolicy` is the Generator field an app sets to opt in; `sweepOrphans` is
228
+ // the filesystem binding, exported so an app composing its own generator can
229
+ // reconcile the same way the runner does instead of hand-rolling the walk.
230
+ export { reconcileOrphans, refusedOrphanMessage } from "./reconcile-orphans.js";
231
+ export type {
232
+ OrphanDecision,
233
+ ReconcileOrphansArgs,
234
+ OrphanPolicy,
235
+ } from "./reconcile-orphans.js";
236
+ export { sweepOrphans } from "./orphan-sweep.js";
237
+ export type {
238
+ OrphanJob,
239
+ SweepOrphansArgs,
240
+ SweepOrphansResult,
241
+ } from "./orphan-sweep.js";
@@ -154,30 +154,21 @@ export interface MetaobjectsGenConfig extends Omit<ResolvedGenConfig, "dbImport"
154
154
  * loader. Composed AFTER the default core+forge bundle.
155
155
  */
156
156
  providers?: readonly MetaDataTypeProvider[];
157
- /** `meta verify` settings. Nothing here affects codegen. */
158
- verify?: VerifyConfig;
159
- }
160
-
161
- /** `meta verify` settings. */
162
- export interface VerifyConfig {
163
157
  /**
164
- * Glob patterns naming this project's test files, for the `@verifiedBy` check.
165
- *
166
- * **What counts as a test file is the project's call.** The built-in patterns cover
167
- * the conventions this repo ports to (jest/vitest/bun, JUnit, Maven Failsafe `*IT`,
168
- * xUnit/NUnit, pytest, Kotlin) and are a CONVENIENCE, not an authority — a list of
169
- * guesses about someone else's repository will always be incomplete, and when it is,
170
- * a requirement naming a real test reads as a broken claim. Anything declared here is
171
- * added to the built-ins.
158
+ * MetaObjects-shipped library packages this project loads alongside its own metadata
159
+ * `["ai"]` makes `extends: "metaobjects::ai::LlmCallBase"` resolve.
172
160
  *
173
- * Matched against forward-slash paths relative to the project root: `**` spans
174
- * separators, `*` does not.
161
+ * Sits beside `providers` because it answers the same shape of question: what does this
162
+ * project's model need in scope beyond the files it declares. Opt-in, because a library
163
+ * registers real top-level nodes and a project that never references one should not find
164
+ * them in its model, its generated output or its docs.
175
165
  *
176
- * ```ts
177
- * verify: { testFiles: ["**\/*IT.kt", "**\/*.feature"] }
178
- * ```
166
+ * Threaded to `loadMemory` by every CLI command that loads metadata. Before it existed,
167
+ * `librarySources` was reachable only from `MetaDataLoader.fromDirectory` which the
168
+ * CLI does not use — so a generator that consumes a library was registered FOR the CLI
169
+ * while its input was unreachable THROUGH it (#333).
179
170
  */
180
- testFiles?: string[];
171
+ libraries?: readonly string[];
181
172
  }
182
173
 
183
174
  /** MetaobjectsGenConfig after applying defaults. All fields required.
@@ -201,7 +192,7 @@ export interface NormalizedMetaobjectsGenConfig
201
192
  targets: Record<string, ResolvedTarget>;
202
193
  }
203
194
 
204
- export type DocsSurface = "model" | "api";
195
+ export type DocsSurface = "model" | "api" | "requirements";
205
196
 
206
197
  export interface ApiSurface {
207
198
  lang: string;
@@ -240,7 +231,11 @@ export function resolveDocsConfig(
240
231
  outDir: cli.outDir ?? block?.outDir ?? "./docs",
241
232
  layout: cli.layout ?? block?.layout ?? fallbackLayout,
242
233
  baseUrl: cli.baseUrl ?? block?.baseUrl ?? "",
243
- surfaces: cli.surfaces ?? block?.surfaces ?? ["model", "api"],
234
+ // `requirements` defaults ON. Safe ONLY because requirementsFile() emits ZERO
235
+ // files for a project declaring no `requirement.*` node — not an empty page. A
236
+ // project without a ledger sees byte-identical output to before the surface
237
+ // existed; see requirements-file.ts.
238
+ surfaces: cli.surfaces ?? block?.surfaces ?? ["model", "api", "requirements"],
244
239
  apiSurfaces: cli.apiSurfaces ?? block?.apiSurfaces ?? [{ lang: "ts", subDir: "api" }],
245
240
  };
246
241
  }
@@ -0,0 +1,178 @@
1
+ // FR-038 §8 — bind the orphan decision to the filesystem.
2
+ //
3
+ // reconcile-orphans.ts is deliberately pure: it answers "remove, refuse, or
4
+ // already gone?" from readers the caller supplies. This module supplies those
5
+ // readers, applies the answer, and keeps `.gen-state` honest afterwards. Keeping
6
+ // the two apart is what lets the RULE be tested without a filesystem and the
7
+ // PLUMBING be tested against real files, rather than only through a full runGen.
8
+ //
9
+ // Three properties this file is responsible for, none of which the pure decision
10
+ // can enforce on its own:
11
+ //
12
+ // 1. NAMESPACE SCOPE. A generator's `owns` predicate speaks in paths relative
13
+ // to its own output directory; gen-state speaks in project-relative paths.
14
+ // Translating between them is where a boundary is won or lost, so a path
15
+ // that resolves outside the generator's directory is never even offered to
16
+ // the predicate.
17
+ // 2. RECORD HYGIENE. A removed or already-gone path must be forgotten, or the
18
+ // next run re-decides a settled orphan forever. A REFUSED path must be
19
+ // kept, or a refusal degrades into permanent silence on the run after.
20
+ // 3. DRY-RUN HONESTY. `--dry-run` must report a pending deletion and perform
21
+ // none, because a preview that hides a deletion is worse than no preview.
22
+
23
+ import { existsSync, readFileSync, rmSync } from "node:fs";
24
+ import { isAbsolute, relative, resolve, sep } from "node:path";
25
+ import {
26
+ listGeneratedPaths,
27
+ isPristineGenerated,
28
+ forgetGeneratedPaths,
29
+ } from "./overwrite-policy.js";
30
+ import { reconcileOrphans, type OrphanPolicy } from "./reconcile-orphans.js";
31
+
32
+ /** One opt-in generator's stake in the sweep. */
33
+ export interface OrphanJob {
34
+ /** For diagnostics — which generator's namespace this is. */
35
+ readonly generatorName: string;
36
+ /** Absolute directory this generator wrote to, i.e. the base its policy's
37
+ * relative paths are measured from. */
38
+ readonly writeOutDir: string;
39
+ readonly policy: OrphanPolicy;
40
+ }
41
+
42
+ export interface SweepOrphansArgs {
43
+ readonly genStateDir: string;
44
+ /** Absolute project root — the base gen-state keys are relative to. */
45
+ readonly projectRoot: string;
46
+ /** Project-relative paths written on THIS run, by ANY generator. A path some
47
+ * other generator now produces is not an orphan, so the whole run's output is
48
+ * the right exclusion set, not just the opting-in generator's. */
49
+ readonly emittedRelPaths: readonly string[];
50
+ readonly jobs: readonly OrphanJob[];
51
+ /** Decide and report, touch nothing. */
52
+ readonly dryRun: boolean;
53
+ }
54
+
55
+ export interface SweepOrphansResult {
56
+ /** Untouched orphans deleted (or, under dryRun, that would be). */
57
+ readonly removed: string[];
58
+ /** Hand-edited orphans left alone and reported. */
59
+ readonly refused: string[];
60
+ /** Which generator's namespace each refusal came from, so the caller can name it
61
+ * instead of assuming one. `orphanPolicy` is a generic `Generator` field and an app
62
+ * is encouraged to compose its own generator, so a message hardcoding one
63
+ * generator's name would be wrong for exactly the users the seam exists for. */
64
+ readonly refusedBy: ReadonlyMap<string, string>;
65
+ /** Hand-edited orphans deleted because their policy set `force`. Separate from
66
+ * `removed` so the caller can say the louder thing about them. */
67
+ readonly forced: string[];
68
+ }
69
+
70
+ /** gen-state keys carry the platform separator; an `owns` predicate is written
71
+ * against generated output, which is always `/`-separated. Normalize once so a
72
+ * predicate authored the obvious way is not silently wrong off Linux. */
73
+ function toPosix(p: string): string {
74
+ return sep === "/" ? p : p.split(sep).join("/");
75
+ }
76
+
77
+ export function sweepOrphans(args: SweepOrphansArgs): SweepOrphansResult {
78
+ if (args.jobs.length === 0) {
79
+ return { removed: [], refused: [], forced: [], refusedBy: new Map() };
80
+ }
81
+
82
+ const previouslyGenerated = listGeneratedPaths(args.genStateDir);
83
+ if (previouslyGenerated.length === 0) {
84
+ return { removed: [], refused: [], forced: [], refusedBy: new Map() };
85
+ }
86
+
87
+ // NOT normalized: both sides of this comparison are produced by
88
+ // `relative(projectRoot, …)` — gen-state's keys and the runner's emitted paths
89
+ // alike — so they already share whatever separator the platform uses.
90
+ // Normalizing one side is what would break them apart. `toPosix` is used only
91
+ // where a HUMAN-authored string is involved (the `owns` predicate below).
92
+ const emitted = args.emittedRelPaths;
93
+ // A path can fall inside two jobs' namespaces (an app may register several
94
+ // requirementTests() instances). Sets keep the second visit from re-reporting
95
+ // a file the first already dealt with.
96
+ const removed = new Set<string>();
97
+ const refused = new Set<string>();
98
+ const forced = new Set<string>();
99
+ const forget = new Set<string>();
100
+ const refusedBy = new Map<string, string>();
101
+
102
+ for (const job of args.jobs) {
103
+ const decision = reconcileOrphans({
104
+ previouslyGenerated,
105
+ emitted,
106
+ owns: (relPath) => {
107
+ const inTarget = relative(job.writeOutDir, resolve(args.projectRoot, relPath));
108
+ // "" is the directory itself; a `..` prefix or an absolute result means
109
+ // the path is outside this generator's output entirely. Neither is
110
+ // something the predicate should get a say in.
111
+ if (inTarget === "" || inTarget.startsWith("..") || isAbsolute(inTarget)) {
112
+ return false;
113
+ }
114
+ return job.policy.owns(toPosix(inTarget));
115
+ },
116
+ exists: (relPath) => existsSync(resolve(args.projectRoot, relPath)),
117
+ // The committed hash manifest, NOT the snapshot body — the bodies are
118
+ // gitignored, so on a fresh clone a body-based check could never prove a
119
+ // file untouched and orphan cleanup would refuse everything forever.
120
+ isUntouched: (relPath) => {
121
+ const full = resolve(args.projectRoot, relPath);
122
+ if (!existsSync(full)) return false;
123
+ return isPristineGenerated(
124
+ args.genStateDir,
125
+ relPath,
126
+ readFileSync(full, "utf-8"),
127
+ );
128
+ },
129
+ });
130
+
131
+ for (const relPath of decision.remove) {
132
+ if (refused.has(relPath) || forced.has(relPath)) continue;
133
+ removed.add(relPath);
134
+ forget.add(relPath);
135
+ }
136
+ for (const relPath of decision.refused) {
137
+ // `refused` belongs in this guard too. Without it, a path already refused by a
138
+ // non-forcing job could be picked up by a later FORCING job whose namespace
139
+ // overlaps (the file anticipates several requirementTests() instances), landing
140
+ // in `refused` and `forced` at once — deleted from disk, while
141
+ // `refusedOrphanMessage` tells the user it "was NOT deleted".
142
+ //
143
+ // First decision wins: a refusal is the conservative answer, and letting a
144
+ // later job escalate it to a deletion would make the outcome depend on job
145
+ // ORDER, which is exactly the ambiguity the runner's duplicate-path guard
146
+ // exists to reject elsewhere.
147
+ if (removed.has(relPath) || forced.has(relPath) || refused.has(relPath)) continue;
148
+ if (job.policy.force === true) {
149
+ forced.add(relPath);
150
+ forget.add(relPath);
151
+ } else {
152
+ // Deliberately NOT forgotten: the record is what makes the refusal
153
+ // repeat until someone resolves it.
154
+ refused.add(relPath);
155
+ refusedBy.set(relPath, job.generatorName);
156
+ }
157
+ }
158
+ // Nothing on disk to report — the file is already gone. Clear the stale
159
+ // record so this stops being reconsidered on every future run.
160
+ for (const relPath of decision.vanished) forget.add(relPath);
161
+ }
162
+
163
+ if (!args.dryRun) {
164
+ for (const relPath of [...removed, ...forced]) {
165
+ rmSync(resolve(args.projectRoot, relPath), { force: true });
166
+ }
167
+ // Batched: the per-path form rewrites the whole manifest each call, so clearing
168
+ // k orphans rewrote it k times for an identical result.
169
+ forgetGeneratedPaths(args.genStateDir, forget);
170
+ }
171
+
172
+ return {
173
+ removed: [...removed].sort(),
174
+ refused: [...refused].sort(),
175
+ forced: [...forced].sort(),
176
+ refusedBy,
177
+ };
178
+ }