@contractkit/openapi-to-ck 0.10.2 → 0.11.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 (41) hide show
  1. package/.turbo/turbo-build$colon$ci.log +7 -7
  2. package/.turbo/turbo-test$colon$ci.log +30 -26
  3. package/CHANGELOG.md +109 -0
  4. package/README.md +30 -8
  5. package/dist/ast-to-ck.d.ts +32 -16
  6. package/dist/ast-to-ck.d.ts.map +1 -1
  7. package/dist/{chunk-JPI3AQ7V.js → chunk-Z53MK4FM.js} +196 -390
  8. package/dist/chunk-Z53MK4FM.js.map +1 -0
  9. package/dist/convert.d.ts.map +1 -1
  10. package/dist/index.js +1 -1
  11. package/dist/normalize.d.ts +6 -2
  12. package/dist/normalize.d.ts.map +1 -1
  13. package/dist/paths-to-ast.d.ts +2 -0
  14. package/dist/paths-to-ast.d.ts.map +1 -1
  15. package/dist/plugin.d.ts.map +1 -1
  16. package/dist/plugin.js +18 -3
  17. package/dist/plugin.js.map +1 -1
  18. package/dist/schema-to-ast.d.ts +13 -1
  19. package/dist/schema-to-ast.d.ts.map +1 -1
  20. package/dist/tag-splitter.d.ts.map +1 -1
  21. package/dist/types.d.ts +28 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +4 -5
  24. package/src/ast-to-ck.ts +29 -453
  25. package/src/convert.ts +57 -3
  26. package/src/normalize.ts +87 -11
  27. package/src/paths-to-ast.ts +92 -11
  28. package/src/plugin.ts +17 -2
  29. package/src/schema-to-ast.ts +51 -7
  30. package/src/tag-splitter.ts +21 -16
  31. package/src/types.ts +28 -0
  32. package/tests/__snapshots__/kitchen-sink.ck +102 -0
  33. package/tests/ast-to-ck.test.ts +34 -17
  34. package/tests/component-refs.test.ts +114 -0
  35. package/tests/coverage.test.ts +246 -0
  36. package/tests/error-responses.test.ts +94 -0
  37. package/tests/fixtures/kitchen-sink-3.1.json +100 -0
  38. package/tests/helpers.ts +40 -0
  39. package/tests/kitchen-sink.test.ts +116 -0
  40. package/tests/schema-to-ast.test.ts +11 -2
  41. package/dist/chunk-JPI3AQ7V.js.map +0 -1
package/src/normalize.ts CHANGED
@@ -6,20 +6,96 @@ import type { WarningCollector } from './warnings.js';
6
6
  * 3.1-like shape. Swagger 2.0 and OpenAPI 3.0 documents are transformed
7
7
  * so that downstream code only needs to handle one schema dialect.
8
8
  *
9
- * Uses @scalar/openapi-parser's `upgrade()` for the heavy lifting when
10
- * available, with manual fallbacks for edge cases.
9
+ * The transformation is hand-written. An earlier note here claimed `@scalar/openapi-parser`'s
10
+ * `upgrade()` did the heavy lifting; it never did, and that dependency has been dropped.
11
+ *
12
+ * After version normalization, `dereferenceComponents` inlines `$ref`s to reusable non-schema
13
+ * components, so the rest of the pipeline sees only inline parameter, response, request-body and
14
+ * header objects. Schema `$ref`s are deliberately left intact — they become `.ck` model refs.
11
15
  */
12
16
  export function normalize(doc: Record<string, unknown>, warnings: WarningCollector): NormalizedDocument {
13
17
  const version = detectVersion(doc);
14
18
 
15
- if (version === '2.0') {
16
- return normalizeSwagger2(doc, warnings);
17
- }
18
- if (version === '3.0') {
19
- return normalizeOas30(doc as unknown as NormalizedDocument, warnings);
19
+ const normalized =
20
+ version === '2.0'
21
+ ? normalizeSwagger2(doc, warnings)
22
+ : version === '3.0'
23
+ ? normalizeOas30(doc as unknown as NormalizedDocument, warnings)
24
+ : // 3.1+ — already in target shape
25
+ (doc as unknown as NormalizedDocument);
26
+
27
+ dereferenceComponents(normalized, warnings);
28
+ return normalized;
29
+ }
30
+
31
+ // ─── Component $ref inlining ──────────────────────────────────────────────
32
+
33
+ /** The component sections whose `$ref`s are inlined. `schemas` is deliberately absent. */
34
+ const DEREF_SECTIONS = ['parameters', 'requestBodies', 'responses', 'headers'] as const;
35
+
36
+ /** Guards against a `$ref` chain that loops back on itself. */
37
+ const MAX_REF_DEPTH = 10;
38
+
39
+ /**
40
+ * Inline `$ref`s to reusable non-schema components.
41
+ *
42
+ * Only `#/components/schemas/*` refs survive conversion — they become `.ck` model references.
43
+ * Everything else (`parameters`, `requestBodies`, `responses`, `headers`) has no `.ck`
44
+ * counterpart, and nothing downstream resolves it: a `$ref`'d parameter used to reach
45
+ * `parameterToNode` with no `name` and print as `undefined: string`, which *parses*, so the
46
+ * corruption was silent. Resolving here means the rest of the pipeline only ever sees inline
47
+ * objects.
48
+ *
49
+ * Sibling keys are kept and win over the target's, matching how OpenAPI 3.1 treats a `$ref`
50
+ * alongside other properties.
51
+ */
52
+ function dereferenceComponents(doc: NormalizedDocument, warnings: WarningCollector): void {
53
+ const components = doc.components as Record<string, Record<string, unknown>> | undefined;
54
+
55
+ const resolve = (node: unknown, path: string, depth = 0): unknown => {
56
+ if (!node || typeof node !== 'object') return node;
57
+ if (Array.isArray(node)) return node.map(item => resolve(item, path, depth));
58
+
59
+ const obj = node as Record<string, unknown>;
60
+ const ref = obj.$ref;
61
+ if (typeof ref === 'string') {
62
+ const match = /^#\/components\/([^/]+)\/(.+)$/.exec(ref);
63
+ const section = match?.[1];
64
+ // Schema refs are the ones that survive into `.ck`; leave them for `extractRefName`.
65
+ if (section && section !== 'schemas' && (DEREF_SECTIONS as readonly string[]).includes(section)) {
66
+ if (depth >= MAX_REF_DEPTH) {
67
+ warnings.warn(path, `$ref chain too deep to resolve: ${ref}`);
68
+ return obj;
69
+ }
70
+ const target = components?.[section]?.[decodeRefToken(match[2]!)];
71
+ if (target === undefined) {
72
+ warnings.warn(path, `unresolved $ref '${ref}' — the component is not defined`);
73
+ return obj;
74
+ }
75
+ const siblings = { ...obj };
76
+ delete siblings.$ref;
77
+ const resolved = resolve(target, path, depth + 1);
78
+ return { ...(resolved as Record<string, unknown>), ...siblings };
79
+ }
80
+ return obj;
81
+ }
82
+
83
+ const out: Record<string, unknown> = {};
84
+ for (const [key, value] of Object.entries(obj)) {
85
+ // A schema subtree can hold `$ref`s of its own, all of them schema refs.
86
+ out[key] = key === 'schema' || key === 'schemas' ? value : resolve(value, `${path}/${key}`, depth);
87
+ }
88
+ return out;
89
+ };
90
+
91
+ for (const [path, pathItem] of Object.entries(doc.paths ?? {})) {
92
+ doc.paths![path] = resolve(pathItem, `#/paths/${path}`) as typeof pathItem;
20
93
  }
21
- // 3.1+ — already in target shape
22
- return doc as unknown as NormalizedDocument;
94
+ }
95
+
96
+ /** Undo the `~1`/`~0` escaping a JSON pointer uses for `/` and `~`. */
97
+ function decodeRefToken(token: string): string {
98
+ return decodeURIComponent(token).replace(/~1/g, '/').replace(/~0/g, '~');
23
99
  }
24
100
 
25
101
  function detectVersion(doc: Record<string, unknown>): '2.0' | '3.0' | '3.1' {
@@ -88,7 +164,7 @@ function normalizePathItem2(
88
164
  globalProduces: string[],
89
165
  warnings: WarningCollector,
90
166
  ): Record<string, unknown> {
91
- const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
167
+ const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
92
168
  const normalized: Record<string, unknown> = {};
93
169
 
94
170
  // Path-level parameters
@@ -277,7 +353,7 @@ function normalizeOas30(doc: NormalizedDocument, _warnings: WarningCollector): N
277
353
  }
278
354
 
279
355
  function normalizePathItemSchemas(pathItem: Record<string, unknown>): void {
280
- const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
356
+ const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'];
281
357
  for (const method of methods) {
282
358
  const op = pathItem[method] as Record<string, unknown> | undefined;
283
359
  if (!op) continue;
@@ -24,8 +24,28 @@ import type { SchemaContext } from './schema-to-ast.js';
24
24
  import type { WarningCollector } from './warnings.js';
25
25
 
26
26
  const LOC: SourceLocation = { file: '', line: 0 };
27
+
28
+ /** A plain `type/subtype`, which is all `mimeType` in the grammar accepts. */
29
+ const MIME_RE = /^[a-z0-9][a-z0-9.+_-]*\/[a-z0-9][a-z0-9.+_-]*$/i;
30
+
31
+ /**
32
+ * Reduce a summary to something `nameText` can hold.
33
+ *
34
+ * `nameText = (~("\n" | "}" | " #" | "\t#") any)+` — the value runs to end of line and stops at
35
+ * a closing brace or a whitespace-preceded `#`. An OpenAPI summary respects none of that, and an
36
+ * unsanitized one would mis-parse the rest of the operation.
37
+ */
38
+ function toNameText(summary: string): string {
39
+ return summary
40
+ .replace(/[}#]/g, ' ')
41
+ .replace(/\s+/g, ' ')
42
+ .trim();
43
+ }
27
44
  const HTTP_METHODS: HttpMethod[] = ['get', 'post', 'put', 'patch', 'delete'];
28
45
 
46
+ /** Methods a spec may declare that `httpMethod` in the grammar has no keyword for. */
47
+ const UNSUPPORTED_METHODS = ['head', 'options', 'trace'] as const;
48
+
29
49
  // ─── Public API ───────────────────────────────────────────────────────────
30
50
 
31
51
  export interface PathsContext {
@@ -37,6 +57,8 @@ export interface PathsContext {
37
57
  extractedModels: ModelNode[];
38
58
  /** Global security from the spec (for detecting explicit overrides). */
39
59
  globalSecurity?: Record<string, string[]>[];
60
+ /** How bodied 4xx/5xx responses are imported. See `ConvertOptions.errorResponses`. */
61
+ errorResponses: 'documented' | 'emitted';
40
62
  }
41
63
 
42
64
  /**
@@ -69,6 +91,13 @@ function pathItemToRoute(path: string, pathItem: NormalizedPathItem, ctx: PathsC
69
91
  // Collect path-level parameters
70
92
  const pathParams = (pathItem.parameters ?? []).filter(p => p.in === 'path');
71
93
 
94
+ for (const method of UNSUPPORTED_METHODS) {
95
+ // A grammar limitation, not a converter one — `.ck` has no keyword for these verbs.
96
+ if ((pathItem as Record<string, unknown>)[method]) {
97
+ ctx.warnings.warn(`#/paths/${encodePathSegment(path)}/${method}`, `\`${method}\` operations have no .ck equivalent; dropped`);
98
+ }
99
+ }
100
+
72
101
  for (const method of HTTP_METHODS) {
73
102
  const op = pathItem[method];
74
103
  if (!op) continue;
@@ -121,6 +150,14 @@ function operationToNode(method: HttpMethod, op: NormalizedOperation, path: stri
121
150
  node.sdk = op.operationId;
122
151
  }
123
152
 
153
+ // summary → `name:`, the human-readable label the key exists for. `description` stays the
154
+ // doc comment; when only a summary is given it becomes the name and is not doubled as prose.
155
+ if (op.summary) {
156
+ const name = toNameText(op.summary);
157
+ if (name) node.name = name;
158
+ else ctx.warnings.warn(`${pathPrefix}/summary`, 'summary has no content `.ck` can carry as a name; dropped');
159
+ }
160
+
124
161
  // Description
125
162
  if (op.description && ctx.includeComments) {
126
163
  node.description = op.description;
@@ -136,10 +173,19 @@ function operationToNode(method: HttpMethod, op: NormalizedOperation, path: stri
136
173
  const headerParams: OpParamNode[] = [];
137
174
 
138
175
  for (const param of op.parameters ?? []) {
176
+ // `dereferenceComponents` inlines `#/components/parameters/*` before this runs, so a
177
+ // parameter with no name is one nothing could resolve. Emitting it would print
178
+ // `undefined: string`, which parses — silent corruption is worse than a dropped param.
179
+ if (!param?.name) {
180
+ ctx.warnings.warn(`${pathPrefix}/parameters`, 'skipped a parameter with no name (an unresolved $ref?)');
181
+ continue;
182
+ }
139
183
  if (param.in === 'query') {
140
184
  queryParams.push(parameterToNode(param, schemaCtx));
141
185
  } else if (param.in === 'header') {
142
186
  headerParams.push(parameterToNode(param, schemaCtx));
187
+ } else if (param.in === 'cookie') {
188
+ ctx.warnings.warn(`${pathPrefix}/parameters/${param.name}`, 'cookie parameters have no `.ck` equivalent; dropped');
143
189
  }
144
190
  }
145
191
 
@@ -158,15 +204,22 @@ function operationToNode(method: HttpMethod, op: NormalizedOperation, path: stri
158
204
  // Responses
159
205
  const responses = op.responses ?? {};
160
206
  for (const [code, resp] of Object.entries(responses)) {
207
+ // Strictly numeric: `parseInt('4XX')` is 4, which would silently invent a status code.
208
+ if (!/^\d{3}$/.test(code)) {
209
+ // `default`, `2XX`, `4XX` — the response block is keyed by a numeric status.
210
+ ctx.warnings.warn(`${pathPrefix}/responses/${code}`, `response key '${code}' is not a numeric status code; dropped`);
211
+ continue;
212
+ }
161
213
  const statusCode = parseInt(code, 10);
162
- if (isNaN(statusCode)) continue;
163
214
  const respNode = responseToNode(statusCode, resp, op.operationId ?? `${method}${toPascalCase(path)}`, schemaCtx, ctx);
164
215
  node.responses.push(respNode);
165
216
  }
166
217
 
167
- // Security
168
- if (op.security !== undefined) {
169
- node.security = convertSecurity(op.security);
218
+ // Security. A spec-level `security` applies to every operation that does not override it;
219
+ // it used to be collected and never read, so a globally-secured spec imported as unsecured.
220
+ const security = op.security ?? ctx.globalSecurity;
221
+ if (security !== undefined) {
222
+ node.security = convertSecurity(security);
170
223
  }
171
224
 
172
225
  return node;
@@ -238,19 +291,22 @@ function requestBodyToNode(
238
291
  const content = reqBody.content;
239
292
  if (!content) return undefined;
240
293
 
241
- const supported = new Set<string>(['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data']);
242
294
  const bodies: OpRequestNode['bodies'] = [];
243
295
 
244
296
  for (const [contentType, mediaType] of Object.entries(content)) {
245
- if (!supported.has(contentType) || !mediaType?.schema) continue;
297
+ // `.ck` accepts any RFC 6838 `type/subtype`, so there is no reason to narrow a spec to
298
+ // the three content types this used to allow. What it cannot carry is a parameterised
299
+ // (`; charset=`) or wildcard mime, since `mimeType` is two `mimeChar+` runs.
300
+ if (!MIME_RE.test(contentType)) {
301
+ ctx.warnings.warn(`${schemaCtx.path}/requestBody/content`, `content type '${contentType}' is not a plain type/subtype; skipped`);
302
+ continue;
303
+ }
304
+ if (!mediaType?.schema) continue;
246
305
  const { typeNode, model } = extractInlineModel(mediaType.schema, `${toPascalCase(operationName)}Request`, schemaCtx);
247
306
  if (model) {
248
307
  ctx.extractedModels.push(model);
249
308
  }
250
- bodies.push({
251
- contentType: contentType as OpRequestNode['bodies'][number]['contentType'],
252
- bodyType: typeNode,
253
- });
309
+ bodies.push({ contentType, bodyType: typeNode });
254
310
  }
255
311
 
256
312
  if (bodies.length === 0) return undefined;
@@ -259,6 +315,23 @@ function requestBodyToNode(
259
315
 
260
316
  // ─── Responses ────────────────────────────────────────────────────────────
261
317
 
318
+ /**
319
+ * Whether an imported status should be marked `(documented)` rather than service-produced.
320
+ *
321
+ * Only applies to a status that carries a block, because that is the only case where the
322
+ * modifier does anything: `isEmitted` in core treats a block as "the service produces this", and
323
+ * `isRedundantDocumented` warns when `(documented)` is put on a bare bodyless non-2xx, where the
324
+ * status is already not emitted. 3xx is left alone — `observableResponses` already covers
325
+ * everything below 400, so the marker would change nothing a client sees, and a spec'd redirect
326
+ * body is plausibly service-produced.
327
+ */
328
+ function shouldDocument(statusCode: number, braced: boolean, resp: NormalizedResponse, ctx: PathsContext): boolean {
329
+ if (!braced) return false;
330
+ // A spec this project emitted says so outright; prefer it over guessing from the status.
331
+ if (resp['x-contractkit-emit'] === 'documented') return true;
332
+ return statusCode >= 400 && ctx.errorResponses === 'documented';
333
+ }
334
+
262
335
  function responseToNode(
263
336
  statusCode: number,
264
337
  resp: NormalizedResponse,
@@ -267,7 +340,12 @@ function responseToNode(
267
340
  ctx: PathsContext,
268
341
  ): OpResponseNode {
269
342
  const headers = convertResponseHeaders(resp.headers, schemaCtx);
270
- const empty = (): OpResponseNode => ({ statusCode, bodies: [], ...(headers ? { headers, hasBlock: true } : {}) });
343
+ const documented = (braced: boolean) => (shouldDocument(statusCode, braced, resp, ctx) ? { emit: 'documented' as const } : {});
344
+ const empty = (): OpResponseNode => ({
345
+ statusCode,
346
+ bodies: [],
347
+ ...(headers ? { headers, hasBlock: true, ...documented(true) } : {}),
348
+ });
271
349
 
272
350
  if (!resp.content) return empty();
273
351
 
@@ -291,6 +369,7 @@ function responseToNode(
291
369
  bodies,
292
370
  hasBlock: true,
293
371
  ...(headers ? { headers } : {}),
372
+ ...documented(true),
294
373
  };
295
374
  }
296
375
 
@@ -334,6 +413,8 @@ function makeSchemaCtx(ctx: PathsContext, path: string): SchemaContext {
334
413
  namedSchemas: ctx.namedSchemas as Record<string, never>,
335
414
  extractedModels: ctx.extractedModels,
336
415
  inlineCounter: 0,
416
+ // A response body, request body, param or header names an already-imported model.
417
+ insideModel: false,
337
418
  };
338
419
  }
339
420
 
package/src/plugin.ts CHANGED
@@ -8,12 +8,16 @@ interface ImportArgs {
8
8
  specPath: string;
9
9
  output: string;
10
10
  split: 'single' | 'by-tag';
11
+ includeComments: boolean;
12
+ errorResponses: 'documented' | 'emitted';
11
13
  }
12
14
 
13
15
  function parseImportArgs(argv: string[]): ImportArgs {
14
16
  let specPath = '';
15
17
  let output = '.';
16
18
  let split: 'single' | 'by-tag' = 'by-tag';
19
+ let includeComments = true;
20
+ let errorResponses: 'documented' | 'emitted' = 'documented';
17
21
 
18
22
  for (let i = 0; i < argv.length; i++) {
19
23
  const arg = argv[i]!;
@@ -22,12 +26,17 @@ function parseImportArgs(argv: string[]): ImportArgs {
22
26
  } else if (arg === '--split') {
23
27
  const val = argv[++i];
24
28
  if (val === 'single' || val === 'by-tag') split = val;
29
+ } else if (arg === '--no-comments') {
30
+ includeComments = false;
31
+ } else if (arg === '--error-responses') {
32
+ const val = argv[++i];
33
+ if (val === 'documented' || val === 'emitted') errorResponses = val;
25
34
  } else if (!arg.startsWith('-')) {
26
35
  specPath = arg;
27
36
  }
28
37
  }
29
38
 
30
- return { specPath, output, split };
39
+ return { specPath, output, split, includeComments, errorResponses };
31
40
  }
32
41
 
33
42
  const USAGE = `Usage: contractkit import-openapi <spec-path> [options]
@@ -40,6 +49,11 @@ Arguments:
40
49
  Options:
41
50
  -o, --output <dir> Output directory for .ck files (default: current directory)
42
51
  --split <mode> How to split output: "by-tag" (one file per tag) or "single" (default: by-tag)
52
+ --no-comments Skip OpenAPI descriptions instead of emitting them as # comments
53
+ --error-responses <mode>
54
+ How to import a 4xx/5xx that declares a body: "documented" (default)
55
+ marks it \`404(documented):\`, so the SDK throws it and the generated
56
+ router does not write it; "emitted" imports it as service-produced
43
57
  -h, --help Show this help message`;
44
58
 
45
59
  const plugin: ContractKitPlugin = {
@@ -61,7 +75,8 @@ const plugin: ContractKitPlugin = {
61
75
  const result = await convertOpenApiToCk({
62
76
  input: resolve(parsed.specPath),
63
77
  split: parsed.split,
64
- includeComments: true,
78
+ includeComments: parsed.includeComments,
79
+ errorResponses: parsed.errorResponses,
65
80
  onWarning: (w: Warning) => {
66
81
  const prefix = w.severity === 'warn' ? '⚠' : 'ℹ';
67
82
  console.warn(` ${prefix} ${w.path}: ${w.message}`);
@@ -6,8 +6,20 @@ import type { WarningCollector } from './warnings.js';
6
6
  // ─── Conversion Context ───────────────────────────────────────────────────
7
7
 
8
8
  export interface SchemaContext {
9
- /** Schema names involved in circular references — use lazy() for these. */
9
+ /** Schema names involved in circular references — see {@link insideModel}. */
10
10
  circularRefs: Set<string>;
11
+ /**
12
+ * Whether the type being built sits inside a `contract` body. Default `true`.
13
+ *
14
+ * `lazy()` exists to break a definition cycle: `topoSortModels` in the TypeScript plugin
15
+ * emits dependencies before dependents and can only fall back to source order for a cycle,
16
+ * so a reference from one cycle member to another has to be deferred. A reference from an
17
+ * operation — a response body, request body, param, or response header — is not part of any
18
+ * such cycle: it names a model the generated module has already imported and fully
19
+ * evaluated. Wrapping it achieves nothing and makes every contract importing a
20
+ * self-referential schema noisier than it needs to be.
21
+ */
22
+ insideModel: boolean;
11
23
  /** Warning collector for unsupported features. */
12
24
  warnings: WarningCollector;
13
25
  /** Current JSON pointer path (for warnings). */
@@ -28,12 +40,17 @@ const LOC: SourceLocation = { file: '', line: 0 };
28
40
 
29
41
  const FORMAT_TO_SCALAR: Record<string, ScalarTypeNode['name']> = {
30
42
  email: 'email',
43
+ 'idn-email': 'email',
31
44
  uri: 'url',
45
+ 'uri-reference': 'url',
46
+ iri: 'url',
47
+ 'iri-reference': 'url',
32
48
  url: 'url',
33
49
  uuid: 'uuid',
34
50
  date: 'date',
35
51
  'date-time': 'datetime',
36
52
  time: 'time',
53
+ duration: 'duration',
37
54
  binary: 'binary',
38
55
  int64: 'bigint',
39
56
  };
@@ -95,6 +112,9 @@ function schemaToModel(name: string, schema: NormalizedSchema, ctx: SchemaContex
95
112
  kind: 'model',
96
113
  name,
97
114
  fields,
115
+ // `additionalProperties: true` says unknown keys are allowed, which is `mode(loose)`.
116
+ // `false` and absent both match `.ck`'s `strict` default, so neither needs a mode.
117
+ ...(schema.additionalProperties === true ? { mode: 'loose' as const } : {}),
98
118
  description,
99
119
  loc: LOC,
100
120
  };
@@ -116,11 +136,13 @@ function schemaToModel(name: string, schema: NormalizedSchema, ctx: SchemaContex
116
136
  * Convert an OpenAPI schema to a ContractTypeNode.
117
137
  */
118
138
  export function schemaToTypeNode(schema: NormalizedSchema, ctx: SchemaContext): ContractTypeNode {
139
+ warnUnrepresentableConstraints(schema, ctx);
140
+
119
141
  // $ref
120
142
  if (schema.$ref) {
121
143
  const refName = extractRefName(schema.$ref);
122
144
  if (refName) {
123
- if (ctx.circularRefs.has(refName)) {
145
+ if (ctx.insideModel && ctx.circularRefs.has(refName)) {
124
146
  return { kind: 'lazy', inner: { kind: 'ref', name: refName } };
125
147
  }
126
148
  return { kind: 'ref', name: refName };
@@ -231,7 +253,7 @@ function stringSchemaToType(schema: NormalizedSchema): ContractTypeNode {
231
253
  if (schema.minLength !== undefined) mods.min = schema.minLength;
232
254
  if (schema.maxLength !== undefined) mods.max = schema.maxLength;
233
255
  }
234
- if (schema.pattern) mods.regex = `/${schema.pattern}/`;
256
+ if (schema.pattern) mods.regex = schema.pattern;
235
257
  if (schema.format && !FORMAT_TO_SCALAR[schema.format]) mods.format = schema.format;
236
258
 
237
259
  return { kind: 'scalar', name: 'string', ...mods };
@@ -372,12 +394,28 @@ function toDiscriminatedUnion(schemas: NormalizedSchema[], discriminator: string
372
394
  return { kind: 'discriminatedUnion', discriminator, members };
373
395
  }
374
396
 
397
+ /** JSON Schema keywords with no `.ck` counterpart, warned about rather than silently dropped. */
398
+ const UNREPRESENTABLE_KEYWORDS = ['exclusiveMinimum', 'exclusiveMaximum', 'multipleOf', 'uniqueItems'] as const;
399
+
375
400
  function warnUnsupported(schema: NormalizedSchema, ctx: SchemaContext): void {
376
401
  if (schema.xml) ctx.warnings.warn(ctx.path, 'xml metadata is not supported, skipping');
377
402
  if (schema.externalDocs) ctx.warnings.info(ctx.path, 'externalDocs is not supported, skipping');
378
403
  if (schema.not) ctx.warnings.warn(ctx.path, 'not keyword is not supported, skipping');
379
404
  }
380
405
 
406
+ /**
407
+ * Report constraints `.ck` has no vocabulary for.
408
+ *
409
+ * Its scalar and array arguments are `min`, `max`, `len`, `regex` and `format`. Folding
410
+ * `exclusiveMinimum` into `min=` would be an off-by-one lie and `multipleOf` has no analogue at
411
+ * all, so the constraint is reported as lost rather than approximated.
412
+ */
413
+ function warnUnrepresentableConstraints(schema: NormalizedSchema, ctx: SchemaContext): void {
414
+ for (const keyword of UNREPRESENTABLE_KEYWORDS) {
415
+ if (schema[keyword] !== undefined) ctx.warnings.warn(ctx.path, `${keyword} has no .ck equivalent, dropping the constraint`);
416
+ }
417
+ }
418
+
381
419
  /**
382
420
  * Extract a named model from an inline object schema (used for request/response bodies).
383
421
  */
@@ -393,7 +431,9 @@ export function extractInlineModel(
393
431
 
394
432
  // If it's an object with properties, extract as a named model
395
433
  if (schema.properties || (schema.type === 'object' && schema.additionalProperties === undefined)) {
396
- const fields = schemaPropertiesToFields(schema, ctx);
434
+ // The extracted model is a `contract` body like any other, so references inside it are
435
+ // subject to the same ordering problem an authored one would be.
436
+ const fields = schemaPropertiesToFields(schema, { ...ctx, insideModel: true });
397
437
  const model: ModelNode = {
398
438
  kind: 'model',
399
439
  name: suggestedName,
@@ -423,9 +463,13 @@ export function sanitizeName(name: string, warnings: WarningCollector): string {
423
463
  .map(part => part.charAt(0).toUpperCase() + part.slice(1))
424
464
  .join('');
425
465
 
426
- if (cleaned !== name) {
427
- warnings.info(`#/components/schemas/${name}`, `Schema name sanitized: "${name}" "${cleaned}"`);
466
+ // `identStart` excludes digits, so a schema named "3DModel" would sanitize to something the
467
+ // parser rejects. Prefix rather than drop the digit, which would collide names.
468
+ const safe = /^[0-9]/.test(cleaned) ? `_${cleaned}` : cleaned;
469
+
470
+ if (safe !== name) {
471
+ warnings.info(`#/components/schemas/${name}`, `Schema name sanitized: "${name}" → "${safe}"`);
428
472
  }
429
473
 
430
- return cleaned || 'UnnamedSchema';
474
+ return safe || 'UnnamedSchema';
431
475
  }
@@ -1,4 +1,4 @@
1
- import type { CkRootNode, ModelNode, OpRouteNode, ContractTypeNode } from '@contractkit/core';
1
+ import type { CkRootNode, ModelNode, OpRouteNode, ContractTypeNode, ParamSource } from '@contractkit/core';
2
2
 
3
3
  /**
4
4
  * Split models and routes into per-tag CkRootNode instances.
@@ -132,21 +132,26 @@ function collectRouteRefs(route: OpRouteNode, refs: Set<string>): void {
132
132
  }
133
133
  }
134
134
 
135
- function collectParamSourceRefs(source: unknown, refs: Set<string>): void {
136
- if (typeof source === 'string') {
137
- refs.add(source);
138
- return;
139
- }
140
- if (Array.isArray(source)) {
141
- for (const param of source) {
142
- if (param && typeof param === 'object' && 'type' in param) {
143
- collectTypeRefs(param.type as ContractTypeNode, refs);
144
- }
145
- }
146
- return;
147
- }
148
- if (source && typeof source === 'object' && 'kind' in source) {
149
- collectTypeRefs(source as ContractTypeNode, refs);
135
+ /**
136
+ * Collect the model names a `params`/`query`/`headers` source refers to.
137
+ *
138
+ * `ParamSource` is a tagged union, and this was written against the shape it had before that:
139
+ * a bare string, an array of params, or a type node. Only `kind: 'ref'` still worked, and only
140
+ * by coincidence — it happens to look like a `ModelRefTypeNode`. `'params'` and `'type'` fell
141
+ * through to `collectTypeRefs`, whose switch has neither case, so a model reached only from a
142
+ * query or header block collected no tags at all and was filed under `shared.ck`.
143
+ */
144
+ function collectParamSourceRefs(source: ParamSource, refs: Set<string>): void {
145
+ switch (source.kind) {
146
+ case 'ref':
147
+ refs.add(source.name);
148
+ return;
149
+ case 'params':
150
+ for (const param of source.nodes) collectTypeRefs(param.type, refs);
151
+ return;
152
+ case 'type':
153
+ collectTypeRefs(source.node, refs);
154
+ return;
150
155
  }
151
156
  }
152
157
 
package/src/types.ts CHANGED
@@ -7,6 +7,20 @@ export interface ConvertOptions {
7
7
  split?: 'single' | 'by-tag';
8
8
  /** Emit OpenAPI descriptions as # comments. Default: true. */
9
9
  includeComments?: boolean;
10
+ /**
11
+ * How a 4xx/5xx response that declares a body is imported. Default: `'documented'`.
12
+ *
13
+ * OpenAPI cannot say whether the handler *returns* a status or merely documents it, but
14
+ * `.ck` distinguishes the two and every generator downstream depends on the answer. Writing
15
+ * `404: { … }` means the service produces it: the generated router writes it and the SDKs
16
+ * hand it back as a value. `404(documented): { … }` means the body is the error contract —
17
+ * the SDK throws it as an `SdkError` and the service is not responsible for returning it,
18
+ * which is what an error response almost always is.
19
+ *
20
+ * `'emitted'` reproduces the pre-existing behaviour, where every declared status was imported
21
+ * as service-produced.
22
+ */
23
+ errorResponses?: 'documented' | 'emitted';
10
24
  /** Called for each warning during conversion. */
11
25
  onWarning?: (warning: Warning) => void;
12
26
  }
@@ -57,6 +71,10 @@ export interface NormalizedSchema {
57
71
  maximum?: number;
58
72
  minItems?: number;
59
73
  maxItems?: number;
74
+ exclusiveMinimum?: number | boolean;
75
+ exclusiveMaximum?: number | boolean;
76
+ multipleOf?: number;
77
+ uniqueItems?: boolean;
60
78
  discriminator?: { propertyName?: string; mapping?: Record<string, string> };
61
79
  xml?: unknown;
62
80
  externalDocs?: unknown;
@@ -71,6 +89,11 @@ export interface NormalizedDocument {
71
89
  components?: {
72
90
  schemas?: Record<string, NormalizedSchema>;
73
91
  securitySchemes?: Record<string, unknown>;
92
+ /** Reusable component objects, inlined by `dereferenceComponents` before conversion. */
93
+ parameters?: Record<string, NormalizedParameter>;
94
+ requestBodies?: Record<string, NormalizedRequestBody>;
95
+ responses?: Record<string, NormalizedResponse>;
96
+ headers?: Record<string, NormalizedHeader>;
74
97
  };
75
98
  security?: Record<string, string[]>[];
76
99
  servers?: { url: string; description?: string }[];
@@ -120,6 +143,11 @@ export interface NormalizedRequestBody {
120
143
 
121
144
  export interface NormalizedResponse {
122
145
  description?: string;
146
+ /**
147
+ * Set by `@contractkit/plugin-openapi` to carry the emitted-vs-documented distinction, which
148
+ * OpenAPI itself cannot express. Honoured ahead of the status-code heuristic on import.
149
+ */
150
+ 'x-contractkit-emit'?: 'documented';
123
151
  content?: Record<string, { schema?: NormalizedSchema }>;
124
152
  /** OpenAPI Header Objects keyed by header name (case-insensitive on the wire). */
125
153
  headers?: Record<string, NormalizedHeader>;