@nanobpm/urban 0.37.0 → 0.39.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.
@@ -139,10 +139,167 @@ test("required query params aren't undefined-widened; optional ones use the ? mo
139
139
  },
140
140
  };
141
141
  const out = emitApiBindings(qdoc);
142
- assert.match(out, /"q": string \| string\[\]/); // required → present, not optional
142
+ assert.match(out, /"q": string/); // required scalar string → present, coerced to its schema type
143
143
  assert.doesNotMatch(out, /"q"\?:/); // not optional
144
- assert.doesNotMatch(out, /"q": string \| string\[\] \| undefined/); // not undefined-widened
145
- assert.match(out, /"page"\?: string \| string\[\]/); // optional → ? modifier
144
+ assert.doesNotMatch(out, /"q": string \| undefined/); // not undefined-widened
145
+ assert.match(out, /"page"\?: string/); // optional → ? modifier
146
+ });
147
+
148
+ test("params are typed by their schema (coerced), not the raw wire string", () => {
149
+ const pdoc: OpenApiDoc = {
150
+ openapi: "3.0.0",
151
+ paths: {
152
+ "/things/{id}": {
153
+ get: {
154
+ operationId: "getThing",
155
+ parameters: [
156
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
157
+ { name: "verbose", in: "query", schema: { type: "boolean" } },
158
+ { name: "tags", in: "query", schema: { type: "array", items: { type: "string" } } },
159
+ { name: "raw", in: "query" }, // no schema → raw wire type
160
+ ],
161
+ responses: { "200": {} },
162
+ },
163
+ },
164
+ },
165
+ };
166
+ const out = emitApiBindings(pdoc);
167
+ assert.match(out, /"id": number/); // integer path param → number, not string
168
+ assert.match(out, /"verbose"\?: boolean/); // boolean query → boolean
169
+ assert.match(out, /"tags"\?: Array<string>/); // array query → Array<item>
170
+ assert.match(out, /"raw"\?: string \| string\[\]/); // schemaless query → raw wire type
171
+ });
172
+
173
+ test("the response type unions every documented JSON response body (success + error)", () => {
174
+ const rdoc: OpenApiDoc = {
175
+ openapi: "3.0.0",
176
+ components: {
177
+ schemas: {
178
+ Ok: { type: "object", properties: { ok: { type: "boolean" } }, additionalProperties: false },
179
+ Err: { type: "object", properties: { error: { type: "string" } }, additionalProperties: false },
180
+ },
181
+ },
182
+ paths: {
183
+ "/do": {
184
+ post: {
185
+ operationId: "doIt",
186
+ responses: {
187
+ "200": { content: { "application/json": { schema: { $ref: "#/components/schemas/Ok" } } } },
188
+ "400": { content: { "application/json": { schema: { $ref: "#/components/schemas/Err" } } } },
189
+ },
190
+ },
191
+ },
192
+ },
193
+ };
194
+ const out = emitApiBindings(rdoc);
195
+ assert.match(out, /export type DoItResponse = Ok \| Err;/); // both bodies in the union
196
+ });
197
+
198
+ test("an ejected operation keeps raw wire param/query types + an unknown body (runtime skips coercion)", () => {
199
+ const edoc: OpenApiDoc = {
200
+ openapi: "3.0.0",
201
+ paths: {
202
+ "/raw/{id}": {
203
+ post: {
204
+ operationId: "rawOp",
205
+ "x-urban-eject": true,
206
+ parameters: [
207
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
208
+ { name: "n", in: "query", schema: { type: "integer" } },
209
+ ],
210
+ requestBody: {
211
+ required: true,
212
+ content: { "application/json": { schema: { type: "object", properties: { a: { type: "string" } } } } },
213
+ },
214
+ responses: { "200": {} },
215
+ },
216
+ },
217
+ },
218
+ };
219
+ const out = emitApiBindings(edoc);
220
+ assert.match(out, /"id": string/); // ejected integer path param stays a raw wire string
221
+ assert.match(out, /"n"\?: string \| string\[\]/); // ejected query param stays raw wire
222
+ assert.match(out, /body: unknown;/); // ejected body is unvalidated → unknown
223
+ });
224
+
225
+ test("whole-surface eject (manifest api.eject) types every op as ejected (raw params, unknown body)", () => {
226
+ const sdoc: OpenApiDoc = {
227
+ openapi: "3.0.0",
228
+ paths: {
229
+ "/thing/{id}": {
230
+ post: {
231
+ operationId: "doThing",
232
+ parameters: [
233
+ { name: "id", in: "path", required: true, schema: { type: "integer" } },
234
+ { name: "q", in: "query", required: true, schema: { type: "integer" } },
235
+ ],
236
+ requestBody: {
237
+ required: true,
238
+ content: { "application/json": { schema: { type: "object", properties: { a: { type: "string" } } } } },
239
+ },
240
+ responses: { "200": {} },
241
+ },
242
+ },
243
+ },
244
+ };
245
+ // Without the surface flag the op is fully typed…
246
+ const typed = emitApiBindings(sdoc, false);
247
+ assert.match(typed, /"id": number/);
248
+ assert.match(typed, /"q": number/);
249
+ // …with whole-surface eject it degrades to raw wire + unknown body, matching the runtime.
250
+ const ejected = emitApiBindings(sdoc, true);
251
+ assert.match(ejected, /"id": string/); // raw wire path param
252
+ assert.match(ejected, /"q"\?: string \| string\[\]/); // required query param becomes optional + raw
253
+ assert.match(ejected, /body: unknown;/);
254
+ });
255
+
256
+ test("an ejected required query param is typed optional (runtime does not enforce presence)", () => {
257
+ const edoc: OpenApiDoc = {
258
+ openapi: "3.0.0",
259
+ paths: {
260
+ "/raw": {
261
+ get: {
262
+ operationId: "rawReq",
263
+ "x-urban-eject": true,
264
+ parameters: [{ name: "req", in: "query", required: true, schema: { type: "integer" } }],
265
+ responses: { "200": {} },
266
+ },
267
+ },
268
+ },
269
+ };
270
+ const out = emitApiBindings(edoc);
271
+ assert.match(out, /"req"\?: string \| string\[\]/); // required-but-ejected → optional raw wire
272
+ });
273
+
274
+ test("a component or operationId colliding with a fixed generated name fails the drift gate", () => {
275
+ const cdoc: OpenApiDoc = {
276
+ openapi: "3.0.0",
277
+ components: { schemas: { ApiOperations: { type: "string" } } },
278
+ paths: { "/z": { get: { operationId: "getZ", responses: { "200": {} } } } },
279
+ };
280
+ assert.throws(() => emitApiBindings(cdoc), /duplicate TypeScript type "ApiOperations"/);
281
+ });
282
+
283
+ test("a documented-but-bodyless status is excluded from the response union", () => {
284
+ const rdoc: OpenApiDoc = {
285
+ openapi: "3.1.0",
286
+ components: {
287
+ schemas: { Err: { type: "object", properties: { error: { type: "string" } }, additionalProperties: false } },
288
+ },
289
+ paths: {
290
+ "/x": {
291
+ get: {
292
+ operationId: "getX",
293
+ responses: {
294
+ "204": {}, // documented, no JSON body → not in the union
295
+ default: { content: { "application/json": { schema: { $ref: "#/components/schemas/Err" } } } },
296
+ },
297
+ },
298
+ },
299
+ },
300
+ };
301
+ const out = emitApiBindings(rdoc);
302
+ assert.match(out, /export type GetXResponse = Err;/); // only the schema'd default body
146
303
  });
147
304
 
148
305
  test("request body optionality follows requestBody.required (runtime passes undefined when absent)", () => {
@@ -1,7 +1,8 @@
1
1
  // Deriver: an OpenAPI document → `api-io.d.ts` (the endpoint type map) + `operations.ts` (the typed
2
2
  // `defineOperation` wrapper). ADR 0058: the contract-first endpoint surface. This is the authoring
3
3
  // half — per `operationId` it emits a `Request`/`Response` type from the operation's parameters,
4
- // requestBody, and first success response, plus an `ApiOperationId` union — mirroring how
4
+ // requestBody, and response (a union of every documented JSON response body), plus an
5
+ // `ApiOperationId` union — mirroring how
5
6
  // `worker-io.d.ts` types `defineWorker` from the model (ADR 0033 §3). The runtime
6
7
  // (`runtime/core/modules/api.ts`) is the matching half: it validates + routes from the SAME spec via
7
8
  // the shared `openapi/spec.ts`, so types and runtime never drift.
@@ -117,23 +118,34 @@ function objectToTs(schema: OpenApiSchema, depth: number): string {
117
118
  return "Record<string, never>";
118
119
  }
119
120
 
120
- /** The `params`/`query` object type for an operation, from its parameters (strings on the wire). */
121
- function paramsTs(op: OperationInfo, where: "path" | "query"): string {
121
+ /** The `params`/`query` object type for an operation, from its parameters. Values are coerced to
122
+ * their declared schema type before the delegate receives them (the runtime coerces + validates,
123
+ * then forwards the coerced value), so a param's type is its schema type — not the raw wire string.
124
+ * A parameter with no schema, or ANY parameter of an ejected operation (whose runtime skips
125
+ * coercion/validation and hands the delegate the raw request), keeps the raw wire type (`string`,
126
+ * or `string | string[]` for query). Ejected query params are also made optional: the runtime does
127
+ * not enforce presence for ejected ops, so a "required" query param may in fact be absent. */
128
+ function paramsTs(op: OperationInfo, where: "path" | "query", ejected: boolean): string {
122
129
  const ps = op.parameters.filter((p) => p.in === where);
123
130
  if (ps.length === 0) return "{}";
131
+ const rawWire = where === "query" ? "string | string[]" : "string";
124
132
  const fields = ps.map((p) => {
125
- const opt = p.required ? "" : "?";
126
- // Query values arrive as strings (or string[] for repeated keys); path params are single
127
- // strings. Optionality is carried by the `?` modifier — don't fold `undefined` into the
128
- // value type, or required query params would still accept undefined.
129
- const t = where === "query" ? "string | string[]" : "string";
133
+ // Coerced to the schema type (number/boolean/array/enum/…); optionality is carried by the `?`
134
+ // modifier — don't fold `undefined` into the value type, or required params would still accept
135
+ // undefined. Schemaless params, and every param of an ejected op, fall back to the raw wire type.
136
+ // A required query param of an ejected op is nonetheless optional here (runtime skips presence);
137
+ // path params stay as declared (the route only matches when every path segment is present).
138
+ const opt = ejected && where === "query" ? "?" : p.required ? "" : "?";
139
+ const t = ejected || !p.schema ? rawWire : schemaToTs(p.schema);
130
140
  return `${JSON.stringify(p.name)}${opt}: ${t}`;
131
141
  });
132
142
  return `{ ${fields.join("; ")} }`;
133
143
  }
134
144
 
135
- /** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union. */
136
- export function emitApiBindings(doc: OpenApiDoc): string {
145
+ /** Emit `api-io.d.ts`: named component types + per-operation Request/Response + the id union.
146
+ * `surfaceEject` reflects the manifest's whole-surface `api.eject` (which the deriver otherwise
147
+ * can't see): when set, EVERY operation is treated as ejected (raw params/query, `unknown` body). */
148
+ export function emitApiBindings(doc: OpenApiDoc, surfaceEject = false): string {
137
149
  const ops = collectOperations(doc);
138
150
  const components = doc.components?.schemas ?? {};
139
151
 
@@ -152,6 +164,11 @@ export function emitApiBindings(doc: OpenApiDoc): string {
152
164
  }
153
165
  emittedTypeNames.set(typeName, source);
154
166
  };
167
+ // Reserve the fixed generated declarations (the operation-id union + operations map) up front, so
168
+ // a component or operationId whose stem collapses onto one of them fails the drift gate with a
169
+ // clear rename message instead of silently emitting a duplicate, uncompilable declaration.
170
+ claimTypeName("ApiOperationId", "the generated operation-id union");
171
+ claimTypeName("ApiOperations", "the generated operations map");
155
172
  for (const name of Object.keys(components)) {
156
173
  claimTypeName(typeStem(name), `component schema "${name}"`);
157
174
  }
@@ -162,8 +179,9 @@ export function emitApiBindings(doc: OpenApiDoc): string {
162
179
 
163
180
  const header =
164
181
  "// AUTO-GENERATED by @nanobpm/urban from the app's OpenAPI spec (ADR 0058).\n" +
165
- "// The endpoint type map: each operationId maps to its typed request (path/query params +\n" +
166
- "// JSON body) and success response, derived from the spec. Do not edit — regenerated from\n" +
182
+ "// The endpoint type map: each operationId maps to its typed request (coerced path/query params\n" +
183
+ "// + JSON body) and response (a union of every documented JSON response body), derived from the\n" +
184
+ "// spec. Do not edit — regenerated from\n" +
167
185
  "// the spec; erased to plain JS at compile.\n" +
168
186
  "// eslint-disable\n\n";
169
187
 
@@ -175,22 +193,38 @@ export function emitApiBindings(doc: OpenApiDoc): string {
175
193
  const opDecls = ops
176
194
  .map((op) => {
177
195
  const stem = typeStem(op.operationId);
196
+ const ejected = surfaceEject || op.eject;
197
+ // An ejected operation (`x-urban-eject`, or the whole surface via manifest `api.eject`)
198
+ // bypasses the generated coercion/validation — its delegate reads the raw request — so its
199
+ // input is NOT schema-shaped at runtime: params/query stay raw wire types and the body is
200
+ // unvalidated (`unknown`). Typing it by the schema would claim guarantees the runtime doesn't
201
+ // make. (ADR 0059 documents the ejection exception.)
178
202
  // With a schema → the typed body. Required but no JSON schema → `unknown` (a body IS
179
203
  // required at runtime, just of an unknown shape). Otherwise (optional, absent) → `undefined`.
180
- const bodyType = op.requestBodySchema
181
- ? schemaToTs(op.requestBodySchema)
182
- : op.requestBodyRequired
183
- ? "unknown"
184
- : "undefined";
204
+ const bodyType = ejected
205
+ ? "unknown"
206
+ : op.requestBodySchema
207
+ ? schemaToTs(op.requestBodySchema)
208
+ : op.requestBodyRequired
209
+ ? "unknown"
210
+ : "undefined";
185
211
  // An optional (or absent) request body is passed to the delegate as `undefined` at runtime,
186
212
  // so mark it optional in the type rather than forcing handlers to treat it as always-present.
187
213
  const bodyOpt = op.requestBodyRequired ? "" : "?";
188
- const resp = op.responseSchema ? schemaToTs(op.responseSchema) : "unknown";
214
+ // Response type: a union of every documented JSON response body (success + errors), so a
215
+ // handler that returns a documented error body typechecks against its operation's response
216
+ // type. Bodyless documented statuses carry no schema and are skipped here. Deduped (identical
217
+ // schemas collapse), stable order. No documented JSON body → unknown.
218
+ const respSeen = new Set<string>();
219
+ const respParts = op.responseSchemas
220
+ .flatMap((e) => (e.schema ? [schemaToTs(e.schema)] : []))
221
+ .filter((t) => (respSeen.has(t) ? false : (respSeen.add(t), true)));
222
+ const resp = respParts.length > 0 ? respParts.join(" | ") : "unknown";
189
223
  const summary = op.summary ? `/** ${op.summary.replace(/\*\//g, "*\\/")} */\n` : "";
190
224
  return (
191
225
  `${summary}export interface ${stem}Request {\n` +
192
- ` params: ${paramsTs(op, "path")};\n` +
193
- ` query: ${paramsTs(op, "query")};\n` +
226
+ ` params: ${paramsTs(op, "path", ejected)};\n` +
227
+ ` query: ${paramsTs(op, "query", ejected)};\n` +
194
228
  ` body${bodyOpt}: ${bodyType};\n` +
195
229
  `}\n` +
196
230
  `export type ${stem}Response = ${resp};`
@@ -308,18 +342,24 @@ export function emitApiController(doc: OpenApiDoc, operationsDir: string = DEFAU
308
342
  }
309
343
 
310
344
  /** Derive the api artifacts from a parsed OpenAPI document. `operationsDir` is the manifest's
311
- * `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it. */
312
- export function deriveApi(doc: OpenApiDoc, operationsDir: string = DEFAULT_OPERATIONS_DIR): DerivedArtifact[] {
345
+ * `api.dir` (default `operations/`) — the controller's static delegate imports resolve into it.
346
+ * `surfaceEject` is the manifest's whole-surface `api.eject`: when set, every operation is typed as
347
+ * ejected (raw params/query, `unknown` body) so the generated types match the runtime. */
348
+ export function deriveApi(
349
+ doc: OpenApiDoc,
350
+ operationsDir: string = DEFAULT_OPERATIONS_DIR,
351
+ surfaceEject = false,
352
+ ): DerivedArtifact[] {
313
353
  return [
314
- { path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc) },
354
+ { path: `${GENERATED_DIR}/${API_BINDINGS_DTS}`, content: emitApiBindings(doc, surfaceEject) },
315
355
  { path: `${GENERATED_DIR}/${API_BINDINGS_TS}`, content: emitApiBindingsRuntime() },
316
356
  { path: `${GENERATED_DIR}/${API_CONTROLLER_TS}`, content: emitApiController(doc, operationsDir) },
317
357
  ];
318
358
  }
319
359
 
320
- export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string }> = {
360
+ export const apiDeriver: Deriver<{ doc: OpenApiDoc; operationsDir?: string; surfaceEject?: boolean }> = {
321
361
  id: "openapi->api-io",
322
362
  describe:
323
363
  "Derive api-io.d.ts (typed request/response per operationId) + the typed defineOperation + the delegate registry.",
324
- derive: ({ doc, operationsDir }) => deriveApi(doc, operationsDir),
364
+ derive: ({ doc, operationsDir, surfaceEject }) => deriveApi(doc, operationsDir, surfaceEject),
325
365
  };
@@ -103,6 +103,59 @@ test("collectArtifacts touches no writes", async () => {
103
103
  assert.equal(Object.keys(io.files).length, before);
104
104
  });
105
105
 
106
+ test("runGen sweeps stale files from nano-generated/ (write mode)", async () => {
107
+ const io = memIO(fixture());
108
+ // A stale artifact left by a prior gen (e.g. a renamed `data_sdk.ts` orphan) that this run does
109
+ // not re-emit. It must be removed so the app can't import a dead generated module.
110
+ io.files["/app/nano-generated/data_sdk.ts"] = "// stale, renamed away";
111
+ io.files["/app/nano-generated/old-worker-io.d.ts"] = "// stale";
112
+ const res = await runGen({ root: "/app", io });
113
+ assert.deepEqual(res.swept, [
114
+ "nano-generated/data_sdk.ts",
115
+ "nano-generated/old-worker-io.d.ts",
116
+ ]);
117
+ assert.ok(!("/app/nano-generated/data_sdk.ts" in io.files), "stale file removed");
118
+ assert.ok(!("/app/nano-generated/old-worker-io.d.ts" in io.files), "stale file removed");
119
+ // Current artifacts survive.
120
+ assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "fresh artifact kept");
121
+ });
122
+
123
+ test("runGen never sweeps runtime-materialized wrappers gen does not emit", async () => {
124
+ const io = memIO(fixture());
125
+ // The SDK shims + `urban data`/console dataops wrappers live in nano-generated/ but are not
126
+ // produced by `urban gen`; the app needs them at runtime, so the sweep must protect them.
127
+ for (const name of ["domain.ts", "workers.ts", "messages.ts", "domain.json", "data-sdk.ts", "worker-sdk.ts"]) {
128
+ io.files[`/app/nano-generated/${name}`] = `// runtime-materialized ${name}`;
129
+ }
130
+ const res = await runGen({ root: "/app", io });
131
+ assert.deepEqual(res.swept, [], "no runtime-materialized file swept");
132
+ for (const name of ["domain.ts", "workers.ts", "messages.ts", "domain.json", "data-sdk.ts", "worker-sdk.ts"]) {
133
+ assert.ok(`/app/nano-generated/${name}` in io.files, `${name} preserved`);
134
+ }
135
+ });
136
+
137
+ test("gen --check never sweeps (read-only)", async () => {
138
+ const io = memIO(fixture());
139
+ await runGen({ root: "/app", io });
140
+ io.files["/app/nano-generated/stale.ts"] = "// stale";
141
+ const res = await runGen({ root: "/app", io, check: true });
142
+ assert.deepEqual(res.swept, []);
143
+ assert.ok("/app/nano-generated/stale.ts" in io.files, "check mode leaves stale files untouched");
144
+ });
145
+
146
+ test("modelsOnly (urban derive) never sweeps nano-generated type-contract outputs", async () => {
147
+ const io = memIO(fixture());
148
+ // A prior `urban gen` wrote the type-contract outputs.
149
+ await runGen({ root: "/app", io });
150
+ assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "precondition: gen wrote outputs");
151
+ // `urban derive` (modelsOnly) derives only models — its artifacts carry no nano-generated/*, so
152
+ // the sweep must be a no-op here; otherwise it would wipe the type contracts above.
153
+ const res = await runGen({ root: "/app", io, modelsOnly: true });
154
+ assert.deepEqual(res.swept, []);
155
+ assert.ok("/app/nano-generated/worker-io.d.ts" in io.files, "derive preserved the type contracts");
156
+ assert.ok("/app/nano-generated/app.schema.sql" in io.files, "derive preserved the migrations");
157
+ });
158
+
106
159
  // --- code-first model derivation (urban gen / urban derive) ---
107
160
 
108
161
  const GreetIn = envelope("GreetIn", { who: "string" });
@@ -4,7 +4,7 @@
4
4
  // pure; all IO is confined here behind a tiny FS port so the same code runs on Node and Deno.
5
5
 
6
6
  import type { DerivedArtifact } from "./artifact.ts";
7
- import { isAbsolutePath, sortArtifacts } from "./artifact.ts";
7
+ import { GENERATED_DIR, isAbsolutePath, RUNTIME_MATERIALIZED_ARTIFACTS, sortArtifacts } from "./artifact.ts";
8
8
  import { deriveMigrations, type ToolkitManifest } from "./derivers/migrations.ts";
9
9
  import { deriveDomain } from "./derivers/domain.ts";
10
10
  import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts";
@@ -51,6 +51,8 @@ export interface GenResult {
51
51
  artifacts: DerivedArtifact[];
52
52
  /** Paths that differ from disk (only populated by `check`). */
53
53
  drift: string[];
54
+ /** App-relative paths of stale files removed from `nano-generated/` (only populated in write mode). */
55
+ swept: string[];
54
56
  /** True if any code-first workflow failed to import/derive. */
55
57
  incomplete: boolean;
56
58
  /** Per-file model-derivation errors (empty on success). */
@@ -122,7 +124,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
122
124
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
123
125
  const manifest: ToolkitManifest & {
124
126
  models?: { processes?: string[]; workflows?: string[] };
125
- api?: { spec?: string; dir?: string };
127
+ api?: { spec?: string; dir?: string; eject?: boolean };
126
128
  } = JSON.parse(await io.readText(manifestPath));
127
129
 
128
130
  const artifacts: DerivedArtifact[] = [];
@@ -162,7 +164,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
162
164
  const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
163
165
  ? manifest.api.dir.trim()
164
166
  : undefined;
165
- artifacts.push(...deriveApi(parseSpec(specText), opsDir));
167
+ artifacts.push(...deriveApi(parseSpec(specText), opsDir, manifest.api?.eject === true));
166
168
  }
167
169
 
168
170
  return { artifacts: sortArtifacts(artifacts), derived };
@@ -199,7 +201,30 @@ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<Ge
199
201
  }
200
202
  }
201
203
 
202
- return { artifacts, drift, incomplete: derived.incomplete, modelErrors: derived.errors };
204
+ // Stale-artifact sweep of `nano-generated/` (clean break, nano-bpm#: gen owns its output dir).
205
+ // The dir is gitignored and fully codegen-owned, so any file this run did not (re)write is stale
206
+ // from a prior gen — a renamed or removed artifact (e.g. an old `data_sdk.ts`) whose orphan the
207
+ // app might still import. Remove it. The one exception is the runtime-materialized set: the SDK
208
+ // shims + the `urban data`/console dataops wrappers gen does NOT emit but the app needs at runtime
209
+ // (RUNTIME_MATERIALIZED_ARTIFACTS). Protect those by name. Write mode only; skipped when io.remove
210
+ // is absent (the sweep is best-effort, mirroring the `.bpmn` sweep above). NOT on `modelsOnly`
211
+ // (`urban derive`): that path derives only models, so `artifacts` carries no `nano-generated/*` and
212
+ // sweeping would wrongly wipe the type-contract outputs a prior `urban gen` wrote.
213
+ const swept: string[] = [];
214
+ if (!opts.check && !opts.modelsOnly && !derived.incomplete && io.remove) {
215
+ const keep = new Set(
216
+ artifacts.map((a) => a.path).filter((p) => p.startsWith(`${GENERATED_DIR}/`)),
217
+ );
218
+ for (const name of RUNTIME_MATERIALIZED_ARTIFACTS) keep.add(`${GENERATED_DIR}/${name}`);
219
+ for (const name of await io.listDir(join(root, GENERATED_DIR))) {
220
+ const rel = `${GENERATED_DIR}/${name}`;
221
+ if (keep.has(rel)) continue;
222
+ await io.remove(join(root, rel));
223
+ swept.push(rel);
224
+ }
225
+ }
226
+
227
+ return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
203
228
  }
204
229
 
205
230
  export { dirOf, join as joinPath };