@contractkit/openapi-to-ck 0.10.2 → 0.12.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 +24 -20
  3. package/CHANGELOG.md +172 -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-U7V7LFBV.js} +219 -391
  8. package/dist/chunk-U7V7LFBV.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 +36 -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 +71 -7
  30. package/src/tag-splitter.ts +21 -16
  31. package/src/types.ts +36 -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/openapi-to-ck",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "description": "Convert OpenAPI specs (2.0/3.0/3.1) to Contract Kit .ck files",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -28,13 +28,12 @@
28
28
  "./plugin": "./dist/plugin.js"
29
29
  },
30
30
  "dependencies": {
31
- "@scalar/openapi-parser": "^0.26.1",
32
31
  "yaml": "^2.8.3",
33
- "@contractkit/core": "0.26.1"
32
+ "@contractkit/core": "0.28.0"
34
33
  },
35
34
  "devDependencies": {
36
- "@repo/config-typescript": "0.1.0",
37
- "@repo/config-eslint": "0.3.1"
35
+ "@repo/config-eslint": "0.3.1",
36
+ "@repo/config-typescript": "0.1.0"
38
37
  },
39
38
  "scripts": {
40
39
  "build": "tsup src/index.ts src/plugin.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration",
package/src/ast-to-ck.ts CHANGED
@@ -1,467 +1,43 @@
1
- import type {
2
- CkRootNode,
3
- ModelNode,
4
- FieldNode,
5
- ContractTypeNode,
6
- OpRouteNode,
7
- OpOperationNode,
8
- OpRequestNode,
9
- OpResponseNode,
10
- ParamSource,
11
- SecurityNode,
12
- SecurityFields,
13
- ObjectMode,
14
- RouteModifier,
15
- } from '@contractkit/core';
16
-
17
- const INDENT = ' '; // 4 spaces
18
-
19
- /** Matches a bare identifier that needs no quoting (mirrors serializeDefault). */
20
- const IDENT_RE = /^[a-zA-Z_$][a-zA-Z0-9_$\-.]*$/;
1
+ import type { CkRootNode } from '@contractkit/core';
2
+ import { printCk, printType } from '@contractkit/core';
21
3
 
22
4
  /**
23
- * Flatten a description to a single line so it is safe to embed in a trailing
24
- * `# ...` comment. OpenAPI descriptions routinely contain newlines; an embedded
25
- * newline would terminate the comment and dump the rest as raw `.ck` source.
5
+ * `.ck` serialization for the OpenAPI importer.
6
+ *
7
+ * This module used to carry its own printer. `.ck` had two of them — this one and the prettier
8
+ * plugin's — and only the prettier copy was covered by the round-trip tests that the
9
+ * `ck-grammar-change` checklist points at, so this one silently fell behind the grammar: it
10
+ * ignored `hasBlock` and the `(documented)` response modifier, could not emit `mcp:`,
11
+ * `plugins:`, `name:`, `override`, `format(output=)` or options-level header globals, and
12
+ * emitted unparseable source for a regex containing `/` or an enum value containing both quote
13
+ * styles.
14
+ *
15
+ * The printer now lives in `@contractkit/core` next to `parseCk`, and this module is a thin
16
+ * adapter over it. A grammar change has one printer to update.
26
17
  */
27
- function singleLineComment(text: string): string {
28
- return text.replace(/\s+/g, ' ').trim();
29
- }
30
18
 
31
19
  /**
32
- * Serialize an enum value as a `.ck` enum argument. Bare identifiers are emitted
33
- * as-is; anything else (spaces, punctuation) is wrapped in a string literal.
34
- * `.ck` string literals have no escape sequences, so when the value contains a
35
- * double quote we fall back to single quotes (and vice versa).
20
+ * Options controlling how a {@link CkRootNode} is rendered to `.ck` source.
21
+ *
22
+ * @deprecated `includeComments` is a no-op and is kept only so existing callers still compile.
23
+ * Comments are controlled upstream: `ConvertOptions.includeComments` gates every `description`
24
+ * assignment in `schema-to-ast.ts` and `paths-to-ast.ts`, so when it is off the descriptions are
25
+ * absent from the AST and there is nothing left for the printer to suppress.
36
26
  */
37
- function quoteEnumValue(value: string): string {
38
- if (IDENT_RE.test(value)) return value;
39
- if (!value.includes('"')) return `"${value}"`;
40
- if (!value.includes("'")) return `'${value}'`;
41
- // Value contains both quote styles; `.ck` cannot escape, so keep double
42
- // quotes and preserve the inner ones as best we can.
43
- return `"${value}"`;
44
- }
45
-
46
- // ─── Public API ───────────────────────────────────────────────────────────
47
-
48
- /** Options controlling how a {@link CkRootNode} is rendered to `.ck` source. */
49
27
  export interface SerializeOptions {
50
- /** Emit descriptions as inline # comments. Default: true. */
28
+ /** No-op. See the deprecation note on {@link SerializeOptions}. */
51
29
  includeComments?: boolean;
52
30
  }
53
31
 
54
32
  /**
55
- * Serialize a `.ck` AST back to formatted `.ck` source text. Emits the options
56
- * block first, then every model, then every route, separated by blank lines and
57
- * terminated with a trailing newline. Descriptions become trailing `# ...`
58
- * comments unless `options.includeComments` is `false`. The output is designed
59
- * to re-parse cleanly via `parseCk` (see round-trip tests).
33
+ * Serialize a `.ck` AST back to formatted `.ck` source text.
34
+ *
35
+ * Delegates to `printCk`, which prints from a `CkRootNode` alone no Ohm CST and no original
36
+ * source so programmatically built nodes print correctly.
60
37
  */
61
- export function astToCk(root: CkRootNode, options: SerializeOptions = {}): string {
62
- const { includeComments = true } = options;
63
- const ctx: Ctx = { includeComments };
64
- const parts: string[] = [];
65
-
66
- // Options block
67
- const optionsBlock = serializeOptions(root);
68
- if (optionsBlock) parts.push(optionsBlock);
69
-
70
- // Models
71
- for (const model of root.models) {
72
- parts.push(serializeModel(model, ctx));
73
- }
74
-
75
- // Routes
76
- for (const route of root.routes) {
77
- parts.push(serializeRoute(route, ctx));
78
- }
79
-
80
- return parts.join('\n\n') + '\n';
38
+ export function astToCk(root: CkRootNode, _options: SerializeOptions = {}): string {
39
+ return printCk(root);
81
40
  }
82
41
 
83
- // ─── Context ──────────────────────────────────────────────────────────────
84
-
85
- interface Ctx {
86
- includeComments: boolean;
87
- }
88
-
89
- // ─── Options block ────────────────────────────────────────────────────────
90
-
91
- function serializeOptions(root: CkRootNode): string | null {
92
- const hasKeys = Object.keys(root.meta).length > 0;
93
- const hasServices = root.services && Object.keys(root.services).length > 0;
94
- const hasSecurity = root.security !== undefined;
95
-
96
- if (!hasKeys && !hasServices && !hasSecurity) return null;
97
-
98
- const lines: string[] = ['options {'];
99
-
100
- if (hasKeys) {
101
- lines.push(`${INDENT}keys: {`);
102
- for (const [key, value] of Object.entries(root.meta)) {
103
- lines.push(`${INDENT}${INDENT}${key}: ${value}`);
104
- }
105
- lines.push(`${INDENT}}`);
106
- }
107
-
108
- if (hasServices) {
109
- lines.push(`${INDENT}services: {`);
110
- for (const [name, path] of Object.entries(root.services)) {
111
- lines.push(`${INDENT}${INDENT}${name}: "${path}"`);
112
- }
113
- lines.push(`${INDENT}}`);
114
- }
115
-
116
- if (hasSecurity) {
117
- lines.push(`${INDENT}security: {`);
118
- if (root.security === 'none') {
119
- lines.push(`${INDENT}${INDENT}none`);
120
- } else {
121
- const sec = root.security as SecurityFields;
122
- if (sec.policy !== undefined) {
123
- const value = sec.policy === false ? 'none' : sec.policy;
124
- lines.push(`${INDENT}${INDENT}policy: ${value}`);
125
- }
126
- }
127
- lines.push(`${INDENT}}`);
128
- }
129
-
130
- lines.push('}');
131
- return lines.join('\n');
132
- }
133
-
134
- // ─── Models ───────────────────────────────────────────────────────────────
135
-
136
- function serializeModel(model: ModelNode, ctx: Ctx): string {
137
- const parts: string[] = [];
138
-
139
- // Modifiers: format(input=snake) mode(loose) deprecated
140
- const prefixes: string[] = [];
141
- if (model.inputCase && model.inputCase !== 'camel') {
142
- prefixes.push(`format(input=${model.inputCase})`);
143
- }
144
- if (model.mode && model.mode !== 'strict') {
145
- prefixes.push(`mode(${model.mode})`);
146
- }
147
- if (model.deprecated) {
148
- prefixes.push('deprecated');
149
- }
150
-
151
- const prefix = prefixes.length > 0 ? prefixes.join(' ') + ' ' : '';
152
- const comment = ctx.includeComments && model.description ? ` # ${singleLineComment(model.description)}` : '';
153
-
154
- // Type alias: contract Name: typeExpression
155
- if (model.type) {
156
- parts.push(`contract ${prefix}${model.name}: ${serializeType(model.type)}${comment}`);
157
- return parts.join('');
158
- }
159
-
160
- // Inheritance: contract Name: Base1 & Base2 & { ... }
161
- if (model.bases && model.bases.length > 0) {
162
- parts.push(`contract ${prefix}${model.name}: ${model.bases.join(' & ')} & {${comment}`);
163
- } else {
164
- parts.push(`contract ${prefix}${model.name}: {${comment}`);
165
- }
166
-
167
- for (const field of model.fields) {
168
- parts.push(serializeField(field, 1, ctx));
169
- }
170
-
171
- parts.push('}');
172
- return parts.join('\n');
173
- }
174
-
175
- function serializeField(field: FieldNode, depth: number, ctx: Ctx): string {
176
- const indent = INDENT.repeat(depth);
177
- const optional = field.optional ? '?' : '';
178
- const visibility = field.visibility !== 'normal' ? `${field.visibility} ` : '';
179
- const deprecated = field.deprecated ? 'deprecated ' : '';
180
-
181
- let typeStr = serializeType(field.type);
182
-
183
- // Nullable fields: if the type doesn't already contain null, append `| null`
184
- if (field.nullable && !typeContainsNull(field.type)) {
185
- typeStr = `${typeStr} | null`;
186
- }
187
-
188
- const defaultVal = field.default !== undefined ? ` = ${serializeDefault(field.default)}` : '';
189
- const comment = ctx.includeComments && field.description ? ` # ${singleLineComment(field.description)}` : '';
190
-
191
- return `${indent}${field.name}${optional}: ${deprecated}${visibility}${typeStr}${defaultVal}${comment}`;
192
- }
193
-
194
- function typeContainsNull(type: ContractTypeNode): boolean {
195
- if (type.kind === 'scalar' && type.name === 'null') return true;
196
- if (type.kind === 'union') return type.members.some(typeContainsNull);
197
- return false;
198
- }
199
-
200
- function serializeDefault(value: string | number | boolean): string {
201
- if (typeof value === 'string') {
202
- // If it looks like an identifier (e.g. enum value), don't quote it
203
- if (/^[a-zA-Z_$][a-zA-Z0-9_$\-.]*$/.test(value)) return value;
204
- return `"${value}"`;
205
- }
206
- return String(value);
207
- }
208
-
209
- // ─── Types ────────────────────────────────────────────────────────────────
210
-
211
- /**
212
- * Serialize a single {@link ContractTypeNode} to its inline `.ck` type
213
- * expression (e.g. `array(User, min=1)`, `string | int`, `enum(asc, desc)`).
214
- * Recurses through composite types; enum values are quoted as needed via
215
- * {@link quoteEnumValue}.
216
- */
217
- export function serializeType(type: ContractTypeNode): string {
218
- switch (type.kind) {
219
- case 'scalar':
220
- return serializeScalar(type);
221
- case 'array':
222
- return serializeArray(type);
223
- case 'tuple':
224
- return `tuple(${type.items.map(serializeType).join(', ')})`;
225
- case 'record':
226
- return `record(${serializeType(type.key)}, ${serializeType(type.value)})`;
227
- case 'enum':
228
- return `enum(${type.values.map(quoteEnumValue).join(', ')})`;
229
- case 'literal':
230
- return serializeLiteral(type);
231
- case 'union':
232
- return type.members.map(serializeType).join(' | ');
233
- case 'discriminatedUnion':
234
- return `discriminated(by=${type.discriminator}, ${type.members.map(serializeType).join(' | ')})`;
235
- case 'intersection':
236
- return type.members.map(serializeType).join(' & ');
237
- case 'ref':
238
- return type.name;
239
- case 'inlineObject':
240
- return serializeInlineObject(type);
241
- case 'lazy':
242
- return `lazy(${serializeType(type.inner)})`;
243
- }
244
- }
245
-
246
- function serializeScalar(type: {
247
- name: string;
248
- min?: number | bigint | string;
249
- max?: number | bigint | string;
250
- len?: number;
251
- regex?: string;
252
- format?: string;
253
- }): string {
254
- const args: string[] = [];
255
- if (type.len !== undefined) args.push(`length=${type.len}`);
256
- if (type.min !== undefined) args.push(typeof type.min === 'string' ? `min="${type.min}"` : `min=${type.min}`);
257
- if (type.max !== undefined) args.push(typeof type.max === 'string' ? `max="${type.max}"` : `max=${type.max}`);
258
- if (type.regex !== undefined) args.push(`regex=${type.regex}`);
259
- if (type.format !== undefined) args.push(`format=${type.format}`);
260
-
261
- if (args.length === 0) return type.name;
262
- return `${type.name}(${args.join(', ')})`;
263
- }
264
-
265
- function serializeArray(type: { item: ContractTypeNode; min?: number; max?: number }): string {
266
- const args: string[] = [serializeType(type.item)];
267
- if (type.min !== undefined) args.push(`min=${type.min}`);
268
- if (type.max !== undefined) args.push(`max=${type.max}`);
269
- return `array(${args.join(', ')})`;
270
- }
271
-
272
- function serializeLiteral(type: { value: string | number | boolean }): string {
273
- if (typeof type.value === 'string') return `literal("${type.value}")`;
274
- return `literal(${type.value})`;
275
- }
276
-
277
- function serializeInlineObject(type: { fields: FieldNode[]; mode?: ObjectMode }): string {
278
- const modePrefix = type.mode ? `mode(${type.mode}) ` : '';
279
- if (type.fields.length === 0) return `${modePrefix}{}`;
280
-
281
- const lines: string[] = [`${modePrefix}{`];
282
- for (const field of type.fields) {
283
- lines.push(serializeField(field, 2, { includeComments: true }));
284
- }
285
- lines.push(`${INDENT}}`);
286
- return lines.join('\n');
287
- }
288
-
289
- // ─── Routes ───────────────────────────────────────────────────────────────
290
-
291
- function serializeRoute(route: OpRouteNode, ctx: Ctx): string {
292
- const lines: string[] = [];
293
-
294
- const modStr = serializeModifiers(route.modifiers);
295
- const comment = ctx.includeComments && route.description ? ` # ${singleLineComment(route.description)}` : '';
296
- lines.push(`operation${modStr} ${route.path}: {${comment}`);
297
-
298
- // Route-level params
299
- if (route.params) {
300
- serializeParamSource(lines, 'params', route.params, route.paramsMode, 1, ctx);
301
- }
302
-
303
- // Route-level security
304
- if (route.security !== undefined) {
305
- serializeSecurityBlock(lines, route.security, 1, ctx);
306
- }
307
-
308
- // Operations
309
- for (const op of route.operations) {
310
- serializeOperation(lines, op, 1, ctx);
311
- }
312
-
313
- lines.push('}');
314
- return lines.join('\n');
315
- }
316
-
317
- function serializeOperation(lines: string[], op: OpOperationNode, depth: number, ctx: Ctx): string[] {
318
- const indent = INDENT.repeat(depth);
319
- const modStr = serializeModifiers(op.modifiers);
320
- const comment = ctx.includeComments && op.description ? ` # ${singleLineComment(op.description)}` : '';
321
- lines.push(`${indent}${op.method}${modStr}: {${comment}`);
322
-
323
- const inner = INDENT.repeat(depth + 1);
324
-
325
- // Service
326
- if (op.service) {
327
- lines.push(`${inner}service: ${op.service}`);
328
- }
329
-
330
- // SDK
331
- if (op.sdk) {
332
- lines.push(`${inner}sdk: ${op.sdk}`);
333
- }
334
-
335
- // Signature
336
- if (op.signature) {
337
- const sigComment = ctx.includeComments && op.signatureDescription ? ` # ${singleLineComment(op.signatureDescription)}` : '';
338
- if (op.signaturePolicy) {
339
- lines.push(`${inner}signature: {`);
340
- lines.push(`${inner} options: ${op.signature}${sigComment}`);
341
- lines.push(`${inner} policy: ${op.signaturePolicy}`);
342
- lines.push(`${inner}}`);
343
- } else {
344
- lines.push(`${inner}signature: ${op.signature}${sigComment}`);
345
- }
346
- }
347
-
348
- // Security
349
- if (op.security !== undefined) {
350
- serializeSecurityBlock(lines, op.security, depth + 1, ctx);
351
- }
352
-
353
- // Query
354
- if (op.query) {
355
- serializeParamSource(lines, 'query', op.query, op.queryMode, depth + 1, ctx);
356
- }
357
-
358
- // Headers
359
- if (op.headers) {
360
- serializeParamSource(lines, 'headers', op.headers, op.headersMode, depth + 1, ctx);
361
- }
362
-
363
- // Request
364
- if (op.request) {
365
- serializeRequest(lines, op.request, depth + 1);
366
- }
367
-
368
- // Responses
369
- if (op.responses.length > 0) {
370
- serializeResponses(lines, op.responses, depth + 1);
371
- }
372
-
373
- lines.push(`${indent}}`);
374
- return lines;
375
- }
376
-
377
- function serializeModifiers(modifiers?: RouteModifier[]): string {
378
- if (!modifiers || modifiers.length === 0) return '';
379
- return `(${modifiers.join(', ')})`;
380
- }
381
-
382
- function serializeParamSource(lines: string[], keyword: string, source: ParamSource, mode: ObjectMode | undefined, depth: number, ctx: Ctx): void {
383
- const indent = INDENT.repeat(depth);
384
-
385
- // String reference: `query: TypeName`
386
- if (source.kind === 'ref') {
387
- lines.push(`${indent}${keyword}: ${source.name}`);
388
- return;
389
- }
390
-
391
- // ContractTypeNode reference
392
- if (source.kind === 'type') {
393
- lines.push(`${indent}${keyword}: ${serializeType(source.node)}`);
394
- return;
395
- }
396
-
397
- // Inline param declarations: `params: { name: type }`
398
- const modeStr = mode ? `mode(${mode}) ` : '';
399
- lines.push(`${indent}${keyword}: ${modeStr}{`);
400
- for (const param of source.nodes) {
401
- const optional = param.optional ? '?' : '';
402
- let typeStr = serializeType(param.type);
403
- if (param.nullable && !typeContainsNull(param.type)) {
404
- typeStr = `${typeStr} | null`;
405
- }
406
- const defaultVal = param.default !== undefined ? ` = ${serializeDefault(param.default)}` : '';
407
- const comment = ctx.includeComments && param.description ? ` # ${singleLineComment(param.description)}` : '';
408
- lines.push(`${INDENT.repeat(depth + 1)}${param.name}${optional}: ${typeStr}${defaultVal}${comment}`);
409
- }
410
- lines.push(`${indent}}`);
411
- }
412
-
413
- function serializeRequest(lines: string[], request: OpRequestNode, depth: number): void {
414
- const indent = INDENT.repeat(depth);
415
- lines.push(`${indent}request: {`);
416
- for (const body of request.bodies) {
417
- lines.push(`${INDENT.repeat(depth + 1)}${body.contentType}: ${serializeType(body.bodyType)}`);
418
- }
419
- lines.push(`${indent}}`);
420
- }
421
-
422
- function serializeResponses(lines: string[], responses: OpResponseNode[], depth: number): void {
423
- const indent = INDENT.repeat(depth);
424
- lines.push(`${indent}response: {`);
425
- for (const resp of responses) {
426
- const bodies = resp.bodies;
427
- const hasHeaders = resp.headers && resp.headers.length > 0;
428
- if (bodies.length > 0 || hasHeaders) {
429
- lines.push(`${INDENT.repeat(depth + 1)}${resp.statusCode}: {`);
430
- for (const body of bodies) {
431
- lines.push(`${INDENT.repeat(depth + 2)}${body.contentType}: ${serializeType(body.bodyType)}`);
432
- }
433
- if (hasHeaders) {
434
- lines.push(`${INDENT.repeat(depth + 2)}headers: {`);
435
- for (const h of resp.headers!) {
436
- const opt = h.optional ? '?' : '';
437
- const trail = h.description ? ` # ${singleLineComment(h.description)}` : '';
438
- lines.push(`${INDENT.repeat(depth + 3)}${h.name}${opt}: ${serializeType(h.type)}${trail}`);
439
- }
440
- lines.push(`${INDENT.repeat(depth + 2)}}`);
441
- }
442
- lines.push(`${INDENT.repeat(depth + 1)}}`);
443
- } else {
444
- lines.push(`${INDENT.repeat(depth + 1)}${resp.statusCode}:`);
445
- }
446
- }
447
- lines.push(`${indent}}`);
448
- }
449
-
450
- function serializeSecurityBlock(lines: string[], security: SecurityNode, depth: number, ctx: Ctx): void {
451
- const indent = INDENT.repeat(depth);
452
- if (security === 'none') {
453
- lines.push(`${indent}security: none`);
454
- return;
455
- }
456
-
457
- const sec = security as SecurityFields;
458
- if (sec.policy !== undefined) {
459
- const comment = ctx.includeComments && sec.policyDescription ? ` # ${singleLineComment(sec.policyDescription)}` : '';
460
- const value = sec.policy === false ? 'none' : sec.policy;
461
- lines.push(`${indent}security: {`);
462
- lines.push(`${INDENT.repeat(depth + 1)}policy: ${value}${comment}`);
463
- lines.push(`${indent}}`);
464
- } else {
465
- lines.push(`${indent}security: {}`);
466
- }
467
- }
42
+ /** Render a `ContractTypeNode` to its `.ck` source string. Re-exported from core. */
43
+ export const serializeType = printType;
package/src/convert.ts CHANGED
@@ -11,12 +11,13 @@ import { splitByTag, mergeIntoSingle } from './tag-splitter.js';
11
11
  import { astToCk } from './ast-to-ck.js';
12
12
  import type { NormalizedSchema } from './types.js';
13
13
  import type { ModelNode } from '@contractkit/core';
14
+ import { parseCk, decomposeCk, validateRefs, DiagnosticCollector } from '@contractkit/core';
14
15
 
15
16
  /**
16
17
  * Convert an OpenAPI spec (2.0, 3.0, or 3.1) to Contract Kit .ck source files.
17
18
  */
18
19
  export async function convertOpenApiToCk(options: ConvertOptions): Promise<ConvertResult> {
19
- const { split = 'by-tag', includeComments = true } = options;
20
+ const { split = 'by-tag', includeComments = true, errorResponses = 'documented' } = options;
20
21
  const warnings = new WarningCollector(options.onWarning);
21
22
 
22
23
  // Step 1: Parse the input into a document object
@@ -41,6 +42,7 @@ export async function convertOpenApiToCk(options: ConvertOptions): Promise<Conve
41
42
  namedSchemas: schemas,
42
43
  extractedModels,
43
44
  inlineCounter: 0,
45
+ insideModel: true,
44
46
  };
45
47
 
46
48
  const models = schemasToModels(schemas, schemaCtx);
@@ -53,24 +55,76 @@ export async function convertOpenApiToCk(options: ConvertOptions): Promise<Conve
53
55
  namedSchemas: schemas,
54
56
  extractedModels,
55
57
  globalSecurity: doc.security,
58
+ errorResponses,
56
59
  });
57
60
 
61
+ // Models extracted from inline request/response body schemas arrive during step 6, after
62
+ // `schemasToModels` has already read the array — without this they are referenced by the
63
+ // generated operations and never defined.
64
+ const known = new Set(models.map(m => m.name));
65
+ for (const extracted of extractedModels) {
66
+ if (known.has(extracted.name)) continue;
67
+ known.add(extracted.name);
68
+ models.push(extracted);
69
+ }
70
+
58
71
  // Step 7: Split or merge
59
72
  const files = new Map<string, string>();
60
73
 
61
74
  if (split === 'by-tag') {
62
75
  const ckRoots = splitByTag(models, routes, routeTags);
63
76
  for (const [filename, root] of ckRoots) {
64
- files.set(filename, astToCk(root, { includeComments }));
77
+ files.set(filename, astToCk(root));
65
78
  }
66
79
  } else {
67
80
  const root = mergeIntoSingle(models, routes);
68
- files.set('api.ck', astToCk(root, { includeComments }));
81
+ files.set('api.ck', astToCk(root));
69
82
  }
70
83
 
84
+ // Step 8: Check what we are about to hand back actually compiles
85
+ checkGeneratedFiles(files, warnings);
86
+
71
87
  return { files, warnings: warnings.warnings };
72
88
  }
73
89
 
90
+ /**
91
+ * Re-parse the generated files and report anything that does not survive.
92
+ *
93
+ * The converter builds core AST nodes and prints them, so a bug in either half produces `.ck`
94
+ * that will not compile — and, because the output is written straight to disk, the first sign of
95
+ * it is an error in the user's own build. Spec content is more adversarial than anything a
96
+ * hand-written contract contains (patterns full of punctuation, descriptions full of newlines,
97
+ * schema names that are not identifiers), so it is worth the round trip to find out here.
98
+ *
99
+ * Reference validation runs across all the files together, because `by-tag` deliberately splits
100
+ * a model into one file and its users into another. Parsing alone is not enough: a reference to
101
+ * a contract that was never emitted is perfectly good syntax, which is exactly how the importer
102
+ * shipped operations pointing at inline body models it had dropped.
103
+ */
104
+ function checkGeneratedFiles(files: Map<string, string>, warnings: WarningCollector): void {
105
+ const diag = new DiagnosticCollector();
106
+ const roots = [];
107
+ for (const [filename, text] of files) {
108
+ const before = diag.getAll().length;
109
+ const root = parseCk(text, filename, diag);
110
+ if (diag.getAll().length === before) roots.push(root);
111
+ }
112
+
113
+ if (roots.length === files.size) {
114
+ const decomposed = roots.map(decomposeCk);
115
+ validateRefs(
116
+ decomposed.map(d => d.contract),
117
+ decomposed.map(d => d.op),
118
+ diag,
119
+ );
120
+ }
121
+
122
+ for (const d of diag.getAll()) {
123
+ if (d.severity !== 'error') continue;
124
+ warnings.warn(d.file, `generated .ck is not valid (line ${d.line}): ${d.message}`);
125
+ }
126
+ }
127
+
74
128
  // ─── Input Parsing ────────────────────────────────────────────────────────
75
129
 
76
130
  async function parseInput(input: string | Record<string, unknown>): Promise<Record<string, unknown>> {