@nanobpm/urban 0.40.2 → 0.41.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.
package/dist/cli.js CHANGED
@@ -142,6 +142,13 @@ async function cmdGen(f) {
142
142
  console.error(` • ${e.path}: ${e.message}`);
143
143
  return 1;
144
144
  }
145
+ // Non-fatal spec-hygiene advisories (e.g. a requestBody schema shared by >1 operation). Printed
146
+ // in both gen and check; never change the exit code.
147
+ if (res.warnings.length > 0) {
148
+ console.warn(`⚠ ${res.warnings.length} spec warning(s):`);
149
+ for (const w of res.warnings)
150
+ console.warn(` • ${w}`);
151
+ }
145
152
  if (f.check) {
146
153
  if (res.drift.length === 0 && res.missingStubs.length === 0) {
147
154
  console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
@@ -152,6 +152,19 @@ export declare function collectOperations(doc: OpenApiDoc): OperationInfo[];
152
152
  * fails loudly on an incoherent spec instead of shipping a mis-dispatching registry.
153
153
  */
154
154
  export declare function assertUniqueOperationIds(doc: OpenApiDoc): void;
155
+ /** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
156
+ * keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
157
+ * request-body schema reused verbatim by multiple operations is the structural fingerprint of the
158
+ * "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
159
+ * operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
160
+ * `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
161
+ * tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
162
+ * split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
163
+ * only (pure, total) — the message is built by the caller. */
164
+ export declare function sharedRequestBodySchemas(doc: OpenApiDoc): {
165
+ ref: string;
166
+ operationIds: string[];
167
+ }[];
155
168
  /** "method path" pointers for every operation that declares no `operationId`. Such operations are
156
169
  * unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
157
170
  export declare function operationsWithoutId(doc: OpenApiDoc): string[];
@@ -200,6 +200,33 @@ export function assertUniqueOperationIds(doc) {
200
200
  "it names the delegate module and the generated controller registry key.");
201
201
  }
202
202
  }
203
+ /** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
204
+ * keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
205
+ * request-body schema reused verbatim by multiple operations is the structural fingerprint of the
206
+ * "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
207
+ * operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
208
+ * `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
209
+ * tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
210
+ * split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
211
+ * only (pure, total) — the message is built by the caller. */
212
+ export function sharedRequestBodySchemas(doc) {
213
+ const byRef = new Map();
214
+ for (const op of collectOperations(doc)) {
215
+ const ref = op.requestBodySchema?.$ref;
216
+ if (typeof ref !== "string" || ref.length === 0)
217
+ continue;
218
+ const ids = byRef.get(ref) ?? [];
219
+ ids.push(op.operationId);
220
+ byRef.set(ref, ids);
221
+ }
222
+ const out = [];
223
+ for (const [ref, ids] of byRef) {
224
+ if (ids.length > 1)
225
+ out.push({ ref, operationIds: [...ids].sort() });
226
+ }
227
+ out.sort((a, b) => a.ref.localeCompare(b.ref));
228
+ return out;
229
+ }
203
230
  /** "method path" pointers for every operation that declares no `operationId`. Such operations are
204
231
  * unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
205
232
  export function operationsWithoutId(doc) {
@@ -532,6 +559,36 @@ function matchesType(declared, value) {
532
559
  function matchesAnyType(declared, value) {
533
560
  return declared.some((t) => matchesType(t, value));
534
561
  }
562
+ /** A short, human-readable label for one `anyOf`/`oneOf` variant, so a composition failure can name
563
+ * the allowed shapes instead of an opaque "matched 0". Prefers the variant's discriminant — its
564
+ * `required` fields (e.g. `{pr}` vs `{url}`) — since that is exactly what a caller must choose
565
+ * between; falls back to the declared type(s), else "object". */
566
+ function variantLabel(doc, variant) {
567
+ const s = resolveSchema(doc, variant);
568
+ if (!s)
569
+ return "?";
570
+ const req = Array.isArray(s.required) ? s.required : [];
571
+ if (req.length > 0)
572
+ return `{${req.join(", ")}}`;
573
+ const types = schemaTypeList(s);
574
+ return types.length > 0 ? types.join("|") : "object";
575
+ }
576
+ /** The `(allowed: {pr} | {url})` suffix for a composition-failure summary message. */
577
+ function variantSummary(doc, variants) {
578
+ return ` (allowed: ${variants.map((v) => variantLabel(doc, v)).join(" | ")})`;
579
+ }
580
+ /** When a `oneOf`/`anyOf` matched no variant, surface actionable detail: the issues of the variant
581
+ * the value was CLOSEST to (fewest problems). For the discriminant case this is the shape the
582
+ * caller meant, so its issues read like `pr: is required` / `url: is not an allowed property`
583
+ * instead of a bare "matched 0". Ties resolve to the earliest variant (declaration order). */
584
+ function closestVariantIssues(perVariant) {
585
+ let best;
586
+ for (const iss of perVariant) {
587
+ if (best === undefined || iss.length < best.length)
588
+ best = iss;
589
+ }
590
+ return best ?? [];
591
+ }
535
592
  /** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
536
593
  * found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
537
594
  export function validateValue(doc, schemaIn, value, path = "") {
@@ -578,13 +635,48 @@ export function validateValue(doc, schemaIn, value, path = "") {
578
635
  for (const sub of schema.allOf)
579
636
  issues.push(...validateValue(doc, sub, value, path));
580
637
  }
581
- if (schema.anyOf && !schema.anyOf.some((sub) => validateValue(doc, sub, value, path).length === 0)) {
582
- issues.push({ path: at, message: "does not match any schema in anyOf" });
638
+ if (schema.anyOf) {
639
+ // `anyOf` needs only ONE match, so short-circuit on the first clean variant — `validateValue`
640
+ // runs on every request (params/query/body/response) and evaluating every variant for a large
641
+ // schema is needless work once we have a hit. Only when NO variant matches do we build the full
642
+ // per-variant issue list to name the allowed shapes and surface the closest variant's issues.
643
+ let anyMatched = false;
644
+ const perVariant = [];
645
+ for (const sub of schema.anyOf) {
646
+ const subIssues = validateValue(doc, sub, value, path);
647
+ if (subIssues.length === 0) {
648
+ anyMatched = true;
649
+ break;
650
+ }
651
+ perVariant.push(subIssues);
652
+ }
653
+ if (!anyMatched) {
654
+ issues.push({
655
+ path: at,
656
+ message: `does not match any of the ${schema.anyOf.length} allowed shapes${variantSummary(doc, schema.anyOf)}`,
657
+ });
658
+ issues.push(...closestVariantIssues(perVariant));
659
+ }
583
660
  }
584
661
  if (schema.oneOf) {
585
- const matched = schema.oneOf.filter((sub) => validateValue(doc, sub, value, path).length === 0).length;
586
- if (matched !== 1) {
587
- issues.push({ path: at, message: `must match exactly one schema in oneOf (matched ${matched})` });
662
+ const perVariant = schema.oneOf.map((sub) => validateValue(doc, sub, value, path));
663
+ const matched = perVariant.filter((v) => v.length === 0).length;
664
+ if (matched === 0) {
665
+ // No variant matched: name the allowed shapes AND surface the closest variant's issues, so a
666
+ // discriminated body (e.g. exactly one of pr|url) fails with actionable detail, not "matched 0".
667
+ issues.push({
668
+ path: at,
669
+ message: `does not match any of the ${schema.oneOf.length} allowed shapes${variantSummary(doc, schema.oneOf)}`,
670
+ });
671
+ issues.push(...closestVariantIssues(perVariant));
672
+ }
673
+ else if (matched > 1) {
674
+ // Matched more than one: the shapes are not mutually exclusive for this value (usually a body
675
+ // that supplies two discriminants, or under-constrained variants). Report the ambiguity.
676
+ issues.push({
677
+ path: at,
678
+ message: `must match exactly one of the ${schema.oneOf.length} allowed shapes, but matched ${matched}${variantSummary(doc, schema.oneOf)}`,
679
+ });
588
680
  }
589
681
  }
590
682
  // `value` is non-null here (the null branch returned above), so match against the declared value
@@ -26,9 +26,14 @@ import { html, json } from "../router.js";
26
26
  import { cancelInstanceReconciling } from "./cancel.js";
27
27
  import { apiDocsPath } from "./api.js";
28
28
  import { quoteIdent } from "./gateway.js";
29
- /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
30
- function javascript(body, status = 200) {
31
- return { status, headers: { "content-type": "text/javascript; charset=utf-8" }, body };
29
+ /** A JavaScript-body response (the renderer module served at the fingerprinted
30
+ * `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
31
+ function javascript(body, status = 200, extraHeaders) {
32
+ return {
33
+ status,
34
+ headers: { "content-type": "text/javascript; charset=utf-8", ...extraHeaders },
35
+ body,
36
+ };
32
37
  }
33
38
  /** A SQL identifier guard — a table name must be a bare identifier *and* a known table
34
39
  * (checked against `schema()`), so `/app/data/:table` can never inject SQL. */
@@ -75,14 +80,29 @@ export function createPagesRoutes(opts, deps) {
75
80
  };
76
81
  const routes = [];
77
82
  const shell = html(rendererShell(homePage, opts.apiDocsPath));
83
+ // The shell must never be pinned by the browser: it carries the *current* fingerprinted
84
+ // runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
85
+ // a revalidation on every load; the shell body is tiny, and the expensive module it references
86
+ // is what gets the long-lived immutable caching (via its content-hashed URL).
87
+ shell.headers = { ...shell.headers, "cache-control": "no-cache" };
78
88
  // ── the renderer shell + module ─────────────────────────────────────────
79
89
  routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
80
90
  routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
91
+ // The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
92
+ routes.push({
93
+ method: "GET",
94
+ path: RUNTIME_JS_PATH,
95
+ source: "surface:pages",
96
+ handler: () => javascript(RENDERER_JS, 200, { "cache-control": IMMUTABLE_CACHE }),
97
+ });
98
+ // Back-compat / defence in depth: the legacy unhashed URL keeps working, but is served
99
+ // `no-cache` so any client (or cached shell) that still requests it always revalidates and
100
+ // gets the current bytes instead of replaying a heuristically-cached older copy.
81
101
  routes.push({
82
102
  method: "GET",
83
103
  path: "/app/runtime.js",
84
104
  source: "surface:pages",
85
- handler: () => javascript(RENDERER_JS),
105
+ handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
86
106
  });
87
107
  // ── GET /app/pages (index of available pages) ───────────────────────────
88
108
  // Powers multi-page navigation: a `nav` node with `items: "auto"` lists every
@@ -377,7 +397,7 @@ function rendererShell(homePage, apiDocsPath) {
377
397
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
378
398
  // tab a handle back to this window (reverse-tabnabbing).
379
399
  //
380
- // The href must be DOCUMENT-relative, exactly like the `./app/runtime.js` script tag below:
400
+ // The href must be DOCUMENT-relative, exactly like the `./app/runtime.<hash>.js` script tag below:
381
401
  // the shell is served at the app's mount root (always a trailing "/"), which is the origin
382
402
  // root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
383
403
  // proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
@@ -400,7 +420,7 @@ function rendererShell(homePage, apiDocsPath) {
400
420
  </head>
401
421
  <body>${apiDocsBadge}
402
422
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
403
- <script type="module" src="./app/runtime.js"></script>
423
+ <script type="module" src=".${RUNTIME_JS_PATH}"></script>
404
424
  </body>
405
425
  </html>`;
406
426
  }
@@ -1280,3 +1300,23 @@ async function renderPage() {
1280
1300
  window.addEventListener("hashchange", renderPage);
1281
1301
  renderPage();
1282
1302
  `;
1303
+ /** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
1304
+ * module's URL so a content change busts the browser cache. Environment-agnostic (no
1305
+ * `crypto` import) so the runtime stays loadable under Node, Deno, and the embedded host. */
1306
+ function fnv1aHex(s) {
1307
+ let h = 0x811c9dc5;
1308
+ for (let i = 0; i < s.length; i++) {
1309
+ h ^= s.charCodeAt(i);
1310
+ h = Math.imul(h, 0x01000193);
1311
+ }
1312
+ return (h >>> 0).toString(16).padStart(8, "0");
1313
+ }
1314
+ // The renderer module is served at a *content-fingerprinted* URL. The shell references this exact
1315
+ // path, so whenever `RENDERER_JS` changes (e.g. an Urban upgrade in a marketplace/Studio update)
1316
+ // the URL changes with it and the browser fetches the new module instead of silently replaying a
1317
+ // heuristically-cached copy of the old one — the failure that made a "Cancel" button (and every
1318
+ // other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
1319
+ // per content, the response itself is served `immutable` with a one-year max-age.
1320
+ const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
1321
+ const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1322
+ const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
@@ -42,6 +42,9 @@ export interface GenResult {
42
42
  incomplete: boolean;
43
43
  /** Per-file model-derivation errors (empty on success). */
44
44
  modelErrors: ModelError[];
45
+ /** Non-fatal spec-hygiene warnings (e.g. a requestBody schema shared by >1 operation). Empty on
46
+ * a clean spec. Surfaced by the CLI but never fail gen/check — advisory only. */
47
+ warnings: string[];
45
48
  }
46
49
  declare function join(root: string, rel: string): string;
47
50
  declare function dirOf(p: string): string;
@@ -9,7 +9,7 @@ import { deriveWorkerBindings } from "./derivers/worker-io.js";
9
9
  import { deriveMeta } from "./derivers/meta.js";
10
10
  import { deriveMessageBindings } from "./derivers/messages.js";
11
11
  import { deriveApi } from "./derivers/api.js";
12
- import { parseSpec } from "../openapi/spec.js";
12
+ import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.js";
13
13
  import { deriveModels, MODEL_PROVENANCE } from "./models.js";
14
14
  function join(root, rel) {
15
15
  // Normalize Windows-style separators to forward slashes (GenIO uses forward slashes on all
@@ -68,6 +68,7 @@ async function collectAll(opts) {
68
68
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
69
69
  const manifest = JSON.parse(await io.readText(manifestPath));
70
70
  const artifacts = [];
71
+ const warnings = [];
71
72
  // 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
72
73
  if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
73
74
  artifacts.push(...deriveMigrations(manifest));
@@ -101,13 +102,21 @@ async function collectAll(opts) {
101
102
  const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
102
103
  ? manifest.api.dir.trim()
103
104
  : undefined;
104
- artifacts.push(...deriveApi(parseSpec(specText), opsDir, manifest.api?.eject === true));
105
+ const doc = parseSpec(specText);
106
+ artifacts.push(...deriveApi(doc, opsDir, manifest.api?.eject === true));
107
+ // Non-fatal spec-hygiene: a requestBody schema reused by >1 operation can't be tightened into
108
+ // per-operation discriminated variants — flag it so the author can split it (never fail gen).
109
+ for (const { ref, operationIds } of sharedRequestBodySchemas(doc)) {
110
+ warnings.push(`requestBody schema ${ref} is shared by ${operationIds.length} operations ` +
111
+ `(${operationIds.join(", ")}). A single schema can't model per-operation discriminants — ` +
112
+ "consider splitting it into named variants + oneOf (additionalProperties:false) per operation.");
113
+ }
105
114
  }
106
- return { artifacts: sortArtifacts(artifacts), derived };
115
+ return { artifacts: sortArtifacts(artifacts), derived, warnings };
107
116
  }
108
117
  /** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
109
118
  export async function runGen(opts) {
110
- const { artifacts, derived } = await collectAll(opts);
119
+ const { artifacts, derived, warnings } = await collectAll(opts);
111
120
  const { root, io } = opts;
112
121
  const emitModels = opts.emitModels ?? true;
113
122
  const drift = [];
@@ -160,7 +169,7 @@ export async function runGen(opts) {
160
169
  swept.push(rel);
161
170
  }
162
171
  }
163
- return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
172
+ return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, warnings };
164
173
  }
165
174
  export { dirOf, join as joinPath };
166
175
  /** Derive code-first models in-memory (no writes) — backs `urban derive --stdout`, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.40.2",
3
+ "version": "0.41.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -153,6 +153,12 @@ async function cmdGen(f: Flags): Promise<number> {
153
153
  for (const e of res.modelErrors) console.error(` • ${e.path}: ${e.message}`);
154
154
  return 1;
155
155
  }
156
+ // Non-fatal spec-hygiene advisories (e.g. a requestBody schema shared by >1 operation). Printed
157
+ // in both gen and check; never change the exit code.
158
+ if (res.warnings.length > 0) {
159
+ console.warn(`⚠ ${res.warnings.length} spec warning(s):`);
160
+ for (const w of res.warnings) console.warn(` • ${w}`);
161
+ }
156
162
  if (f.check) {
157
163
  if (res.drift.length === 0 && res.missingStubs.length === 0) {
158
164
  console.log(`✔ generated artifacts + handler stubs are up to date (${res.artifacts.length} checked)`);
@@ -12,6 +12,7 @@ import {
12
12
  parseSpec,
13
13
  resolveSchema,
14
14
  responseSchemaForStatus,
15
+ sharedRequestBodySchemas,
15
16
  toRouteMatcher,
16
17
  undeclaredPathParams,
17
18
  undeclaredSecuritySchemes,
@@ -333,6 +334,72 @@ test("validateValue enforces allOf (intersection), anyOf/oneOf (union) — match
333
334
  assert.ok(validateValue(doc, one, "x").length > 0); // matches NEITHER
334
335
  });
335
336
 
337
+ test("validateValue: a failed oneOf discriminant names the shapes and surfaces the closest variant", () => {
338
+ // The Camunda-style discriminated request body: exactly one of two mutually-exclusive variants
339
+ // (mutual exclusion enforced by additionalProperties:false + a distinct required discriminant).
340
+ const byPr: OpenApiSchema = {
341
+ type: "object",
342
+ additionalProperties: false,
343
+ required: ["pr"],
344
+ properties: { pr: { type: "string" }, maxRounds: { type: "integer" } },
345
+ };
346
+ const byUrl: OpenApiSchema = {
347
+ type: "object",
348
+ additionalProperties: false,
349
+ required: ["url"],
350
+ properties: { url: { type: "string" }, maxRounds: { type: "integer" } },
351
+ };
352
+ const start: OpenApiSchema = { oneOf: [byPr, byUrl] };
353
+
354
+ // Neither discriminant supplied → matched 0. The summary must NAME the allowed shapes, and the
355
+ // closest variant's own issue must be surfaced (not just the opaque "matched 0").
356
+ const none = validateValue(doc, start, { maxRounds: 3 }, "body");
357
+ assert.ok(none.length > 0, "an empty discriminant is rejected");
358
+ assert.ok(
359
+ none.some((i) => /allowed:.*\{pr\}.*\|.*\{url\}/.test(i.message)),
360
+ `summary names the allowed shapes: ${JSON.stringify(none)}`,
361
+ );
362
+ assert.ok(
363
+ none.some((i) => i.path === "body/pr" && /required/.test(i.message)),
364
+ `closest variant's field issue is surfaced: ${JSON.stringify(none)}`,
365
+ );
366
+
367
+ // Both discriminants supplied → with additionalProperties:false, EACH variant rejects the other's
368
+ // field, so this is also "matches none" (the desirable strictness — a body can't smuggle both).
369
+ const both = validateValue(doc, start, { pr: "a/b#1", url: "http://x" }, "body");
370
+ assert.ok(
371
+ both.some((i) => /does not match any of the 2 allowed shapes/.test(i.message)),
372
+ `supplying both discriminants is rejected by the strict variants: ${JSON.stringify(both)}`,
373
+ );
374
+
375
+ // Exactly one discriminant → valid.
376
+ assert.equal(validateValue(doc, start, { pr: "a/b#1" }, "body").length, 0);
377
+
378
+ // `anyOf` no-match reporting mirrors `oneOf`: name the allowed shapes AND surface the closest
379
+ // variant's issues, so the improved diagnostics are guarded for the union branch too. Here a value
380
+ // matches NEITHER discriminated variant, and `{pr}` is the closest (its own field is the issue).
381
+ const anyStart: OpenApiSchema = { anyOf: [byPr, byUrl] };
382
+ const anyNone = validateValue(doc, anyStart, { maxRounds: 3 }, "body");
383
+ assert.ok(anyNone.length > 0, "a value matching no anyOf variant is rejected");
384
+ assert.ok(
385
+ anyNone.some((i) => /allowed:.*\{pr\}.*\|.*\{url\}/.test(i.message)),
386
+ `anyOf summary names the allowed shapes: ${JSON.stringify(anyNone)}`,
387
+ );
388
+ assert.ok(
389
+ anyNone.some((i) => i.path === "body/pr" && /required/.test(i.message)),
390
+ `anyOf surfaces the closest variant's field issue: ${JSON.stringify(anyNone)}`,
391
+ );
392
+
393
+ // The "matched more than one" branch: genuinely overlapping (non-exclusive) variants. A value that
394
+ // satisfies two shapes is ambiguous and must be reported as such.
395
+ const overlapping: OpenApiSchema = { oneOf: [{ type: "integer" }, { type: "integer", minimum: 10 }] };
396
+ const ambiguous = validateValue(doc, overlapping, 20, "n");
397
+ assert.ok(
398
+ ambiguous.some((i) => /must match exactly one of the 2 allowed shapes, but matched 2/.test(i.message)),
399
+ `ambiguous (multi-match) is reported: ${JSON.stringify(ambiguous)}`,
400
+ );
401
+ });
402
+
336
403
  test("undeclaredPathParams flags path-template params with no declared parameter", () => {
337
404
  const drift: OpenApiDoc = {
338
405
  openapi: "3.0.0",
@@ -608,3 +675,30 @@ test("responseSchemaForStatus: a status-range ('2XX') covers concrete statuses i
608
675
  assert.equal(responseSchemaForStatus(entries, 200)?.type, "string"); // 2XX class → range schema
609
676
  assert.equal(responseSchemaForStatus(entries, 404)?.type, "object"); // outside 2XX → default
610
677
  });
678
+
679
+ test("sharedRequestBodySchemas: flags a requestBody $ref reused across operations, ignores single-use", () => {
680
+ const jsonRef = (ref: string) => ({ requestBody: { content: { "application/json": { schema: { $ref: ref } } } } });
681
+ const doc: OpenApiDoc = {
682
+ openapi: "3.1.0",
683
+ components: {
684
+ schemas: {
685
+ StartVariables: { type: "object" },
686
+ FeatureAnswer: { type: "object" },
687
+ },
688
+ },
689
+ paths: {
690
+ // Two operations share the SAME loose body schema — the defect fingerprint.
691
+ "/convergence": { post: { operationId: "startConvergenceLoop", ...jsonRef("#/components/schemas/StartVariables") } },
692
+ "/fanout": { post: { operationId: "startPlanFanout", ...jsonRef("#/components/schemas/StartVariables") } },
693
+ // A distinct, single-use body — must NOT be flagged.
694
+ "/feature": { post: { operationId: "answerFeature", ...jsonRef("#/components/schemas/FeatureAnswer") } },
695
+ // An inline (non-$ref) body — no shared identity to compare, must NOT be flagged.
696
+ "/inline": { post: { operationId: "inlineBody", requestBody: { content: { "application/json": { schema: { type: "object" } } } } } },
697
+ },
698
+ };
699
+
700
+ const shared = sharedRequestBodySchemas(doc);
701
+ assert.equal(shared.length, 1, `only the reused ref is flagged: ${JSON.stringify(shared)}`);
702
+ assert.equal(shared[0].ref, "#/components/schemas/StartVariables");
703
+ assert.deepEqual(shared[0].operationIds, ["startConvergenceLoop", "startPlanFanout"]); // sorted
704
+ });
@@ -338,6 +338,32 @@ export function assertUniqueOperationIds(doc: OpenApiDoc): void {
338
338
  }
339
339
  }
340
340
 
341
+ /** Groups of operationIds that name the SAME requestBody schema `$ref` as their JSON body,
342
+ * keyed by that ref (only refs shared by two or more operations are returned, sorted). A single
343
+ * request-body schema reused verbatim by multiple operations is the structural fingerprint of the
344
+ * "one permissive schema standing in for what should be mutually-exclusive variants" defect: two
345
+ * operations that genuinely want different shapes (e.g. `startConvergenceLoop` wants pr|url,
346
+ * `startPlanFanout` wants issue|url) are forced to share one loose object, so neither can be
347
+ * tightened without breaking the other. gen surfaces this as a non-fatal warning so an author can
348
+ * split it into per-operation named variants + `oneOf` (the Camunda REST v2 pattern). Structural
349
+ * only (pure, total) — the message is built by the caller. */
350
+ export function sharedRequestBodySchemas(doc: OpenApiDoc): { ref: string; operationIds: string[] }[] {
351
+ const byRef = new Map<string, string[]>();
352
+ for (const op of collectOperations(doc)) {
353
+ const ref = op.requestBodySchema?.$ref;
354
+ if (typeof ref !== "string" || ref.length === 0) continue;
355
+ const ids = byRef.get(ref) ?? [];
356
+ ids.push(op.operationId);
357
+ byRef.set(ref, ids);
358
+ }
359
+ const out: { ref: string; operationIds: string[] }[] = [];
360
+ for (const [ref, ids] of byRef) {
361
+ if (ids.length > 1) out.push({ ref, operationIds: [...ids].sort() });
362
+ }
363
+ out.sort((a, b) => a.ref.localeCompare(b.ref));
364
+ return out;
365
+ }
366
+
341
367
  /** "method path" pointers for every operation that declares no `operationId`. Such operations are
342
368
  * unroutable (no delegate to bind), so the runtime mount logs them at `warn` and skips them. */
343
369
  export function operationsWithoutId(doc: OpenApiDoc): string[] {
@@ -697,6 +723,36 @@ function matchesAnyType(declared: string[], value: unknown): boolean {
697
723
  return declared.some((t) => matchesType(t, value));
698
724
  }
699
725
 
726
+ /** A short, human-readable label for one `anyOf`/`oneOf` variant, so a composition failure can name
727
+ * the allowed shapes instead of an opaque "matched 0". Prefers the variant's discriminant — its
728
+ * `required` fields (e.g. `{pr}` vs `{url}`) — since that is exactly what a caller must choose
729
+ * between; falls back to the declared type(s), else "object". */
730
+ function variantLabel(doc: OpenApiDoc, variant: OpenApiSchema): string {
731
+ const s = resolveSchema(doc, variant);
732
+ if (!s) return "?";
733
+ const req = Array.isArray(s.required) ? s.required : [];
734
+ if (req.length > 0) return `{${req.join(", ")}}`;
735
+ const types = schemaTypeList(s);
736
+ return types.length > 0 ? types.join("|") : "object";
737
+ }
738
+
739
+ /** The `(allowed: {pr} | {url})` suffix for a composition-failure summary message. */
740
+ function variantSummary(doc: OpenApiDoc, variants: OpenApiSchema[]): string {
741
+ return ` (allowed: ${variants.map((v) => variantLabel(doc, v)).join(" | ")})`;
742
+ }
743
+
744
+ /** When a `oneOf`/`anyOf` matched no variant, surface actionable detail: the issues of the variant
745
+ * the value was CLOSEST to (fewest problems). For the discriminant case this is the shape the
746
+ * caller meant, so its issues read like `pr: is required` / `url: is not an allowed property`
747
+ * instead of a bare "matched 0". Ties resolve to the earliest variant (declaration order). */
748
+ function closestVariantIssues(perVariant: ValidationIssue[][]): ValidationIssue[] {
749
+ let best: ValidationIssue[] | undefined;
750
+ for (const iss of perVariant) {
751
+ if (best === undefined || iss.length < best.length) best = iss;
752
+ }
753
+ return best ?? [];
754
+ }
755
+
700
756
  /** Validate `value` against `schema` (resolving local $refs against `doc`), returning every issue
701
757
  * found (empty = valid). A pragmatic JSON-Schema subset; unknown keywords are ignored. */
702
758
  export function validateValue(
@@ -743,13 +799,47 @@ export function validateValue(
743
799
  if (schema.allOf) {
744
800
  for (const sub of schema.allOf) issues.push(...validateValue(doc, sub, value, path));
745
801
  }
746
- if (schema.anyOf && !schema.anyOf.some((sub) => validateValue(doc, sub, value, path).length === 0)) {
747
- issues.push({ path: at, message: "does not match any schema in anyOf" });
802
+ if (schema.anyOf) {
803
+ // `anyOf` needs only ONE match, so short-circuit on the first clean variant — `validateValue`
804
+ // runs on every request (params/query/body/response) and evaluating every variant for a large
805
+ // schema is needless work once we have a hit. Only when NO variant matches do we build the full
806
+ // per-variant issue list to name the allowed shapes and surface the closest variant's issues.
807
+ let anyMatched = false;
808
+ const perVariant: ValidationIssue[][] = [];
809
+ for (const sub of schema.anyOf) {
810
+ const subIssues = validateValue(doc, sub, value, path);
811
+ if (subIssues.length === 0) {
812
+ anyMatched = true;
813
+ break;
814
+ }
815
+ perVariant.push(subIssues);
816
+ }
817
+ if (!anyMatched) {
818
+ issues.push({
819
+ path: at,
820
+ message: `does not match any of the ${schema.anyOf.length} allowed shapes${variantSummary(doc, schema.anyOf)}`,
821
+ });
822
+ issues.push(...closestVariantIssues(perVariant));
823
+ }
748
824
  }
749
825
  if (schema.oneOf) {
750
- const matched = schema.oneOf.filter((sub) => validateValue(doc, sub, value, path).length === 0).length;
751
- if (matched !== 1) {
752
- issues.push({ path: at, message: `must match exactly one schema in oneOf (matched ${matched})` });
826
+ const perVariant = schema.oneOf.map((sub) => validateValue(doc, sub, value, path));
827
+ const matched = perVariant.filter((v) => v.length === 0).length;
828
+ if (matched === 0) {
829
+ // No variant matched: name the allowed shapes AND surface the closest variant's issues, so a
830
+ // discriminated body (e.g. exactly one of pr|url) fails with actionable detail, not "matched 0".
831
+ issues.push({
832
+ path: at,
833
+ message: `does not match any of the ${schema.oneOf.length} allowed shapes${variantSummary(doc, schema.oneOf)}`,
834
+ });
835
+ issues.push(...closestVariantIssues(perVariant));
836
+ } else if (matched > 1) {
837
+ // Matched more than one: the shapes are not mutually exclusive for this value (usually a body
838
+ // that supplies two discriminants, or under-constrained variants). Report the ambiguity.
839
+ issues.push({
840
+ path: at,
841
+ message: `must match exactly one of the ${schema.oneOf.length} allowed shapes, but matched ${matched}${variantSummary(doc, schema.oneOf)}`,
842
+ });
753
843
  }
754
844
  }
755
845
 
@@ -90,12 +90,15 @@ test("GET / serves the renderer shell with the home marker", async () => {
90
90
  assert.equal(res.status, 200);
91
91
  assert.match(res.headers?.["content-type"] ?? "", /text\/html/);
92
92
  assert.match(res.body ?? "", /data-home="home"/);
93
- // The runtime module is loaded by a *relative* src so it resolves against the
94
- // document's mount path — works both at the origin root and under the Nano
95
- // console's /console/app-view/<name>/ reverse proxy. A root-absolute
96
- // "/app/runtime.js" would 404 against the console origin and never hydrate.
97
- assert.match(res.body ?? "", /src="\.\/app\/runtime\.js"/);
98
- assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\.js"/);
93
+ // The runtime module is loaded by a *relative*, *content-fingerprinted* src so it resolves
94
+ // against the document's mount path — works both at the origin root and under the Nano
95
+ // console's /console/app-view/<name>/ reverse proxy. A root-absolute path would 404 against
96
+ // the console origin; a non-fingerprinted URL would let a stale browser cache replay an old
97
+ // module after an upgrade (silently breaking every page action).
98
+ assert.match(res.body ?? "", /src="\.\/app\/runtime\.[0-9a-f]{8}\.js"/);
99
+ assert.doesNotMatch(res.body ?? "", /src="\/app\/runtime\./);
100
+ // The shell itself must never be pinned by the browser (it carries the current module hash).
101
+ assert.match(res.headers?.["cache-control"] ?? "", /no-cache/);
99
102
  });
100
103
 
101
104
  test("the API docs badge links via a document-relative href (proxy-safe)", async () => {
@@ -145,6 +148,31 @@ test("GET /app/runtime.js serves the renderer module", async () => {
145
148
  assert.match(res.body ?? "", /function apiUrl\(u\)/);
146
149
  });
147
150
 
151
+ test("the fingerprinted runtime URL from the shell is served immutable; the shell references it", async () => {
152
+ // The shell's <script src> must point at a route the router actually serves, and that route
153
+ // must carry long-lived immutable caching (the URL is unique per content, so it can never go
154
+ // stale). This is the cache-bust that stops an upgraded app from replaying an old module.
155
+ const shell = await dispatch("GET", "/");
156
+ const m = (shell.body ?? "").match(/src="\.(\/app\/runtime\.[0-9a-f]{8}\.js)"/);
157
+ assert.ok(m, "shell must reference a fingerprinted runtime URL");
158
+ const res = await dispatch("GET", m![1]);
159
+ assert.equal(res.status, 200);
160
+ assert.match(res.headers?.["content-type"] ?? "", /javascript/);
161
+ assert.match(res.body ?? "", /pc:refresh/);
162
+ assert.match(res.headers?.["cache-control"] ?? "", /immutable/);
163
+ assert.match(res.headers?.["cache-control"] ?? "", /max-age=31536000/);
164
+ });
165
+
166
+ test("the legacy /app/runtime.js URL still serves the module, but no-cache", async () => {
167
+ // Back-compat + defence in depth: an old cached shell (or any client) hitting the unhashed URL
168
+ // still gets the current bytes, and `no-cache` forces revalidation instead of a stale replay.
169
+ const res = await dispatch("GET", "/app/runtime.js");
170
+ assert.equal(res.status, 200);
171
+ assert.match(res.headers?.["content-type"] ?? "", /javascript/);
172
+ assert.match(res.body ?? "", /pc:refresh/);
173
+ assert.match(res.headers?.["cache-control"] ?? "", /no-cache/);
174
+ });
175
+
148
176
  test("the shell renders an API-docs badge only when an apiDocsPath is provided", async () => {
149
177
  const routes = createPagesRoutes(
150
178
  { pagesDir: "pages", homePage: "home", sourceName: "app", apiDocsPath: "/app/api-docs" },
@@ -73,9 +73,18 @@ export interface PagesDeps {
73
73
  cancel?(processInstanceKey: string): Promise<CancelInstanceResult>;
74
74
  }
75
75
 
76
- /** A JavaScript-body response (the renderer module served at /app/runtime.js). */
77
- function javascript(body: string, status = 200): { status: number; headers: Record<string, string>; body: string } {
78
- return { status, headers: { "content-type": "text/javascript; charset=utf-8" }, body };
76
+ /** A JavaScript-body response (the renderer module served at the fingerprinted
77
+ * `/app/runtime.<hash>.js`, and its `/app/runtime.js` back-compat route). */
78
+ function javascript(
79
+ body: string,
80
+ status = 200,
81
+ extraHeaders?: Record<string, string>,
82
+ ): { status: number; headers: Record<string, string>; body: string } {
83
+ return {
84
+ status,
85
+ headers: { "content-type": "text/javascript; charset=utf-8", ...extraHeaders },
86
+ body,
87
+ };
79
88
  }
80
89
 
81
90
  /** A SQL identifier guard — a table name must be a bare identifier *and* a known table
@@ -130,15 +139,30 @@ export function createPagesRoutes(opts: PagesOptions, deps: PagesDeps): Route[]
130
139
 
131
140
  const routes: Route[] = [];
132
141
  const shell = html(rendererShell(homePage, opts.apiDocsPath));
142
+ // The shell must never be pinned by the browser: it carries the *current* fingerprinted
143
+ // runtime URL, so a stale shell would keep pointing at an old module hash. `no-cache` forces
144
+ // a revalidation on every load; the shell body is tiny, and the expensive module it references
145
+ // is what gets the long-lived immutable caching (via its content-hashed URL).
146
+ shell.headers = { ...shell.headers, "cache-control": "no-cache" };
133
147
 
134
148
  // ── the renderer shell + module ─────────────────────────────────────────
135
149
  routes.push({ method: "GET", path: "/", source: "surface:pages", handler: () => shell });
136
150
  routes.push({ method: "GET", path: "/index.html", source: "surface:pages", handler: () => shell });
151
+ // The fingerprinted module URL (referenced by the shell): unique per content, so cache it hard.
152
+ routes.push({
153
+ method: "GET",
154
+ path: RUNTIME_JS_PATH,
155
+ source: "surface:pages",
156
+ handler: () => javascript(RENDERER_JS, 200, { "cache-control": IMMUTABLE_CACHE }),
157
+ });
158
+ // Back-compat / defence in depth: the legacy unhashed URL keeps working, but is served
159
+ // `no-cache` so any client (or cached shell) that still requests it always revalidates and
160
+ // gets the current bytes instead of replaying a heuristically-cached older copy.
137
161
  routes.push({
138
162
  method: "GET",
139
163
  path: "/app/runtime.js",
140
164
  source: "surface:pages",
141
- handler: () => javascript(RENDERER_JS),
165
+ handler: () => javascript(RENDERER_JS, 200, { "cache-control": "no-cache" }),
142
166
  });
143
167
 
144
168
  // ── GET /app/pages (index of available pages) ───────────────────────────
@@ -422,7 +446,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
422
446
  // UI for free). `target="_blank"` + hardened `rel` so the docs open without giving the docs
423
447
  // tab a handle back to this window (reverse-tabnabbing).
424
448
  //
425
- // The href must be DOCUMENT-relative, exactly like the `./app/runtime.js` script tag below:
449
+ // The href must be DOCUMENT-relative, exactly like the `./app/runtime.<hash>.js` script tag below:
426
450
  // the shell is served at the app's mount root (always a trailing "/"), which is the origin
427
451
  // root for a direct run (CLI on :3000) but a path prefix under the Nano console's reverse
428
452
  // proxy (/console/app-view/<name>/). A root-absolute "/app/api-docs" would escape that prefix
@@ -446,7 +470,7 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
446
470
  </head>
447
471
  <body>${apiDocsBadge}
448
472
  <main id="page" data-home="${escapeAttr(homePage)}"><p class="pc-empty">Loading…</p></main>
449
- <script type="module" src="./app/runtime.js"></script>
473
+ <script type="module" src=".${RUNTIME_JS_PATH}"></script>
450
474
  </body>
451
475
  </html>`;
452
476
  }
@@ -1328,3 +1352,25 @@ async function renderPage() {
1328
1352
  window.addEventListener("hashchange", renderPage);
1329
1353
  renderPage();
1330
1354
  `;
1355
+
1356
+ /** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
1357
+ * module's URL so a content change busts the browser cache. Environment-agnostic (no
1358
+ * `crypto` import) so the runtime stays loadable under Node, Deno, and the embedded host. */
1359
+ function fnv1aHex(s: string): string {
1360
+ let h = 0x811c9dc5;
1361
+ for (let i = 0; i < s.length; i++) {
1362
+ h ^= s.charCodeAt(i);
1363
+ h = Math.imul(h, 0x01000193);
1364
+ }
1365
+ return (h >>> 0).toString(16).padStart(8, "0");
1366
+ }
1367
+
1368
+ // The renderer module is served at a *content-fingerprinted* URL. The shell references this exact
1369
+ // path, so whenever `RENDERER_JS` changes (e.g. an Urban upgrade in a marketplace/Studio update)
1370
+ // the URL changes with it and the browser fetches the new module instead of silently replaying a
1371
+ // heuristically-cached copy of the old one — the failure that made a "Cancel" button (and every
1372
+ // other page action) do nothing after an upgrade until a hard refresh. Because the URL is unique
1373
+ // per content, the response itself is served `immutable` with a one-year max-age.
1374
+ const RUNTIME_JS_HASH = fnv1aHex(RENDERER_JS);
1375
+ const RUNTIME_JS_PATH = `/app/runtime.${RUNTIME_JS_HASH}.js`;
1376
+ const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
@@ -257,3 +257,35 @@ test("collectArtifacts resolves an ABSOLUTE api.spec at its own path, not root-r
257
257
  const res = await collectArtifacts({ root: "/app", io });
258
258
  assert.ok(res.map((a) => a.path).includes("nano-generated/api-io.d.ts"));
259
259
  });
260
+
261
+ test("runGen warns when a requestBody schema is shared across operations, but never fails", async () => {
262
+ const manifest = JSON.stringify({ id: "demo", data: { default: "app" }, api: { spec: "openapi.json" } });
263
+ const openapi = JSON.stringify({
264
+ openapi: "3.1.0",
265
+ components: { schemas: { StartVariables: { type: "object", properties: { pr: { type: "string" }, url: { type: "string" }, issue: { type: "string" } } } } },
266
+ paths: {
267
+ "/convergence": { post: { operationId: "startConvergenceLoop", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/StartVariables" } } } }, responses: { "200": {} } } },
268
+ "/fanout": { post: { operationId: "startPlanFanout", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/StartVariables" } } } }, responses: { "200": {} } } },
269
+ },
270
+ });
271
+ const io = memIO({ "/app/nano.app.json": manifest, "/app/openapi.json": openapi });
272
+ const res = await runGen({ root: "/app", io });
273
+
274
+ assert.equal(res.incomplete, false); // advisory only — gen still succeeds
275
+ assert.equal(res.warnings.length, 1, `one shared-body warning: ${JSON.stringify(res.warnings)}`);
276
+ assert.match(res.warnings[0], /StartVariables/);
277
+ assert.match(res.warnings[0], /startConvergenceLoop, startPlanFanout/);
278
+ // A clean spec (distinct bodies) produces no warning.
279
+ const clean = memIO({
280
+ "/app/nano.app.json": manifest,
281
+ "/app/openapi.json": JSON.stringify({
282
+ openapi: "3.1.0",
283
+ components: { schemas: { A: { type: "object" }, B: { type: "object" } } },
284
+ paths: {
285
+ "/a": { post: { operationId: "opA", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/A" } } } }, responses: { "200": {} } } },
286
+ "/b": { post: { operationId: "opB", requestBody: { content: { "application/json": { schema: { $ref: "#/components/schemas/B" } } } }, responses: { "200": {} } } },
287
+ },
288
+ }),
289
+ });
290
+ assert.deepEqual((await runGen({ root: "/app", io: clean })).warnings, []);
291
+ });
@@ -11,7 +11,7 @@ import { deriveWorkerBindings, type ModelSource } from "./derivers/worker-io.ts"
11
11
  import { deriveMeta } from "./derivers/meta.ts";
12
12
  import { deriveMessageBindings } from "./derivers/messages.ts";
13
13
  import { deriveApi } from "./derivers/api.ts";
14
- import { parseSpec } from "../openapi/spec.ts";
14
+ import { parseSpec, sharedRequestBodySchemas } from "../openapi/spec.ts";
15
15
  import { deriveModels, type DerivedModels, type ModelError, MODEL_PROVENANCE } from "./models.ts";
16
16
 
17
17
  /** Minimal filesystem port. Node/Deno impls live in `fsio.ts`. */
@@ -57,6 +57,9 @@ export interface GenResult {
57
57
  incomplete: boolean;
58
58
  /** Per-file model-derivation errors (empty on success). */
59
59
  modelErrors: ModelError[];
60
+ /** Non-fatal spec-hygiene warnings (e.g. a requestBody schema shared by >1 operation). Empty on
61
+ * a clean spec. Surfaced by the CLI but never fail gen/check — advisory only. */
62
+ warnings: string[];
60
63
  }
61
64
 
62
65
  function join(root: string, rel: string): string {
@@ -118,7 +121,7 @@ export async function collectArtifacts(opts: GenOptions): Promise<DerivedArtifac
118
121
 
119
122
  /** Internal: collect artifacts AND the model-derivation details (needed by `runGen` for the
120
123
  * incomplete signal + the provenance-scoped stale sweep). */
121
- async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifact[]; derived: DerivedModels }> {
124
+ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifact[]; derived: DerivedModels; warnings: string[] }> {
122
125
  const { root, io } = opts;
123
126
  const emitModels = opts.emitModels ?? true;
124
127
  const manifestPath = join(root, opts.manifestFile ?? "nano.app.json");
@@ -128,6 +131,7 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
128
131
  } = JSON.parse(await io.readText(manifestPath));
129
132
 
130
133
  const artifacts: DerivedArtifact[] = [];
134
+ const warnings: string[] = [];
131
135
 
132
136
  // 1. types → migrations + domain row types (DomainTables spine + DomainTypes registry)
133
137
  if (!opts.modelsOnly && manifest.types && Object.keys(manifest.types).length > 0) {
@@ -164,15 +168,25 @@ async function collectAll(opts: GenOptions): Promise<{ artifacts: DerivedArtifac
164
168
  const opsDir = typeof manifest.api?.dir === "string" && manifest.api.dir.trim().length > 0
165
169
  ? manifest.api.dir.trim()
166
170
  : undefined;
167
- artifacts.push(...deriveApi(parseSpec(specText), opsDir, manifest.api?.eject === true));
171
+ const doc = parseSpec(specText);
172
+ artifacts.push(...deriveApi(doc, opsDir, manifest.api?.eject === true));
173
+ // Non-fatal spec-hygiene: a requestBody schema reused by >1 operation can't be tightened into
174
+ // per-operation discriminated variants — flag it so the author can split it (never fail gen).
175
+ for (const { ref, operationIds } of sharedRequestBodySchemas(doc)) {
176
+ warnings.push(
177
+ `requestBody schema ${ref} is shared by ${operationIds.length} operations ` +
178
+ `(${operationIds.join(", ")}). A single schema can't model per-operation discriminants — ` +
179
+ "consider splitting it into named variants + oneOf (additionalProperties:false) per operation.",
180
+ );
181
+ }
168
182
  }
169
183
 
170
- return { artifacts: sortArtifacts(artifacts), derived };
184
+ return { artifacts: sortArtifacts(artifacts), derived, warnings };
171
185
  }
172
186
 
173
187
  /** Run the derivers and write artifacts (or, with `check`, report drift without writing). */
174
188
  export async function runGen(opts: GenOptions & { check?: boolean }): Promise<GenResult> {
175
- const { artifacts, derived } = await collectAll(opts);
189
+ const { artifacts, derived, warnings } = await collectAll(opts);
176
190
  const { root, io } = opts;
177
191
  const emitModels = opts.emitModels ?? true;
178
192
  const drift: string[] = [];
@@ -224,7 +238,7 @@ export async function runGen(opts: GenOptions & { check?: boolean }): Promise<Ge
224
238
  }
225
239
  }
226
240
 
227
- return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors };
241
+ return { artifacts, drift, swept: swept.sort(), incomplete: derived.incomplete, modelErrors: derived.errors, warnings };
228
242
  }
229
243
 
230
244
  export { dirOf, join as joinPath };