@contractkit/openapi-to-ck 0.7.1

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 (97) hide show
  1. package/.turbo/turbo-build$colon$ci.log +40 -0
  2. package/.turbo/turbo-build.log +20 -0
  3. package/.turbo/turbo-format.log +36 -0
  4. package/.turbo/turbo-test$colon$ci.log +77 -0
  5. package/.turbo/turbo-test.log +19 -0
  6. package/CHANGELOG.md +108 -0
  7. package/coverage/base.css +224 -0
  8. package/coverage/block-navigation.js +87 -0
  9. package/coverage/clover.xml +878 -0
  10. package/coverage/coverage-final.json +10 -0
  11. package/coverage/favicon.png +0 -0
  12. package/coverage/index.html +131 -0
  13. package/coverage/prettify.css +1 -0
  14. package/coverage/prettify.js +2 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/src/ast-to-ck.ts.html +1336 -0
  18. package/coverage/src/circular-refs.ts.html +298 -0
  19. package/coverage/src/convert.ts.html +535 -0
  20. package/coverage/src/index.html +221 -0
  21. package/coverage/src/normalize.ts.html +1162 -0
  22. package/coverage/src/paths-to-ast.ts.html +1165 -0
  23. package/coverage/src/schema-to-ast.ts.html +1378 -0
  24. package/coverage/src/tag-splitter.ts.html +685 -0
  25. package/coverage/src/warnings.ts.html +154 -0
  26. package/coverage/tests/helpers.ts.html +559 -0
  27. package/coverage/tests/index.html +116 -0
  28. package/dist/ast-to-ck.d.ts +8 -0
  29. package/dist/ast-to-ck.d.ts.map +1 -0
  30. package/dist/chunk-CQGFQKBJ.js +1681 -0
  31. package/dist/chunk-CQGFQKBJ.js.map +1 -0
  32. package/dist/chunk-FNUU2DWY.js +1779 -0
  33. package/dist/chunk-FNUU2DWY.js.map +1 -0
  34. package/dist/chunk-LD2HAFZG.js +1681 -0
  35. package/dist/chunk-LD2HAFZG.js.map +1 -0
  36. package/dist/chunk-LQ2B3EJG.js +1777 -0
  37. package/dist/chunk-LQ2B3EJG.js.map +1 -0
  38. package/dist/chunk-M6JA6WY2.js +1714 -0
  39. package/dist/chunk-M6JA6WY2.js.map +1 -0
  40. package/dist/chunk-MQTKLXTN.js +1681 -0
  41. package/dist/chunk-MQTKLXTN.js.map +1 -0
  42. package/dist/chunk-NV7RGUUS.js +1699 -0
  43. package/dist/chunk-NV7RGUUS.js.map +1 -0
  44. package/dist/chunk-REM25FDE.js +1779 -0
  45. package/dist/chunk-REM25FDE.js.map +1 -0
  46. package/dist/chunk-SNW7GJOM.js +1681 -0
  47. package/dist/chunk-SNW7GJOM.js.map +1 -0
  48. package/dist/chunk-UWOSCBRG.js +1681 -0
  49. package/dist/chunk-UWOSCBRG.js.map +1 -0
  50. package/dist/chunk-YP7FWA4H.js +1779 -0
  51. package/dist/chunk-YP7FWA4H.js.map +1 -0
  52. package/dist/circular-refs.d.ts +12 -0
  53. package/dist/circular-refs.d.ts.map +1 -0
  54. package/dist/convert.d.ts +6 -0
  55. package/dist/convert.d.ts.map +1 -0
  56. package/dist/index.d.ts +9 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.js +29 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/normalize.d.ts +12 -0
  61. package/dist/normalize.d.ts.map +1 -0
  62. package/dist/paths-to-ast.d.ts +22 -0
  63. package/dist/paths-to-ast.d.ts.map +1 -0
  64. package/dist/plugin.d.ts +4 -0
  65. package/dist/plugin.d.ts.map +1 -0
  66. package/dist/plugin.js +86 -0
  67. package/dist/plugin.js.map +1 -0
  68. package/dist/schema-to-ast.d.ts +39 -0
  69. package/dist/schema-to-ast.d.ts.map +1 -0
  70. package/dist/tag-splitter.d.ts +16 -0
  71. package/dist/tag-splitter.d.ts.map +1 -0
  72. package/dist/types.d.ts +137 -0
  73. package/dist/types.d.ts.map +1 -0
  74. package/dist/warnings.d.ts +10 -0
  75. package/dist/warnings.d.ts.map +1 -0
  76. package/eslint.config.js +6 -0
  77. package/package.json +46 -0
  78. package/src/ast-to-ck.ts +417 -0
  79. package/src/circular-refs.ts +71 -0
  80. package/src/convert.ts +150 -0
  81. package/src/index.ts +8 -0
  82. package/src/normalize.ts +359 -0
  83. package/src/paths-to-ast.ts +360 -0
  84. package/src/plugin.ts +89 -0
  85. package/src/schema-to-ast.ts +431 -0
  86. package/src/tag-splitter.ts +200 -0
  87. package/src/types.ts +133 -0
  88. package/src/warnings.ts +23 -0
  89. package/tests/ast-to-ck.test.ts +425 -0
  90. package/tests/circular-refs.test.ts +71 -0
  91. package/tests/convert.test.ts +330 -0
  92. package/tests/fixtures/petstore-3.1.json +187 -0
  93. package/tests/helpers.ts +158 -0
  94. package/tests/normalize.test.ts +114 -0
  95. package/tests/schema-to-ast.test.ts +341 -0
  96. package/tests/tag-splitter.test.ts +75 -0
  97. package/tsconfig.json +9 -0
@@ -0,0 +1,431 @@
1
+ import type { ContractTypeNode, ScalarTypeNode, ModelNode, FieldNode, SourceLocation } from '@contractkit/core';
2
+ import type { NormalizedSchema } from './types.js';
3
+ import { extractRefName } from './circular-refs.js';
4
+ import type { WarningCollector } from './warnings.js';
5
+
6
+ // ─── Conversion Context ───────────────────────────────────────────────────
7
+
8
+ export interface SchemaContext {
9
+ /** Schema names involved in circular references — use lazy() for these. */
10
+ circularRefs: Set<string>;
11
+ /** Warning collector for unsupported features. */
12
+ warnings: WarningCollector;
13
+ /** Current JSON pointer path (for warnings). */
14
+ path: string;
15
+ /** Whether to include descriptions. */
16
+ includeComments: boolean;
17
+ /** All named schemas (for inline extraction). */
18
+ namedSchemas: Record<string, NormalizedSchema>;
19
+ /** Extracted inline models (accumulated during conversion). */
20
+ extractedModels: ModelNode[];
21
+ /** Counter for generating unique names for inline models. */
22
+ inlineCounter: number;
23
+ }
24
+
25
+ const LOC: SourceLocation = { file: '', line: 0 };
26
+
27
+ // ─── Format → Scalar Name Mapping ────────────────────────────────────────
28
+
29
+ const FORMAT_TO_SCALAR: Record<string, ScalarTypeNode['name']> = {
30
+ email: 'email',
31
+ uri: 'url',
32
+ url: 'url',
33
+ uuid: 'uuid',
34
+ date: 'date',
35
+ 'date-time': 'datetime',
36
+ time: 'time',
37
+ binary: 'binary',
38
+ int64: 'bigint',
39
+ };
40
+
41
+ // ─── Public API ───────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Convert all named schemas in components/schemas to ModelNodes.
45
+ */
46
+ export function schemasToModels(schemas: Record<string, NormalizedSchema>, ctx: SchemaContext): ModelNode[] {
47
+ const models: ModelNode[] = [];
48
+
49
+ for (const [name, schema] of Object.entries(schemas)) {
50
+ const modelCtx = { ...ctx, path: `#/components/schemas/${name}` };
51
+ const model = schemaToModel(name, schema, modelCtx);
52
+ if (model) models.push(model);
53
+ }
54
+
55
+ // Append any inline models extracted during conversion
56
+ models.push(...ctx.extractedModels);
57
+
58
+ return models;
59
+ }
60
+
61
+ /**
62
+ * Convert a named schema to a ModelNode.
63
+ */
64
+ function schemaToModel(name: string, schema: NormalizedSchema, ctx: SchemaContext): ModelNode | null {
65
+ warnUnsupported(schema, ctx);
66
+
67
+ const description = ctx.includeComments ? schema.description : undefined;
68
+
69
+ // allOf with exactly 2 members, one being a $ref → inheritance
70
+ if (schema.allOf && schema.allOf.length === 2) {
71
+ const [first, second] = schema.allOf;
72
+ const refMember = first?.$ref ? first : second?.$ref ? second : null;
73
+ const objectMember = first?.$ref ? second : first;
74
+
75
+ if (refMember?.$ref && objectMember?.properties) {
76
+ const baseName = extractRefName(refMember.$ref);
77
+ if (baseName) {
78
+ const fields = schemaPropertiesToFields(objectMember, ctx);
79
+ return {
80
+ kind: 'model',
81
+ name,
82
+ bases: [baseName],
83
+ fields,
84
+ description,
85
+ loc: LOC,
86
+ };
87
+ }
88
+ }
89
+ }
90
+
91
+ // Object with properties → struct
92
+ if (schema.properties || (schema.type === 'object' && !schema.additionalProperties)) {
93
+ const fields = schemaPropertiesToFields(schema, ctx);
94
+ return {
95
+ kind: 'model',
96
+ name,
97
+ fields,
98
+ description,
99
+ loc: LOC,
100
+ };
101
+ }
102
+
103
+ // Otherwise → type alias
104
+ const typeNode = schemaToTypeNode(schema, ctx);
105
+ return {
106
+ kind: 'model',
107
+ name,
108
+ fields: [],
109
+ type: typeNode,
110
+ description,
111
+ loc: LOC,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Convert an OpenAPI schema to a ContractTypeNode.
117
+ */
118
+ export function schemaToTypeNode(schema: NormalizedSchema, ctx: SchemaContext): ContractTypeNode {
119
+ // $ref
120
+ if (schema.$ref) {
121
+ const refName = extractRefName(schema.$ref);
122
+ if (refName) {
123
+ if (ctx.circularRefs.has(refName)) {
124
+ return { kind: 'lazy', inner: { kind: 'ref', name: refName } };
125
+ }
126
+ return { kind: 'ref', name: refName };
127
+ }
128
+ ctx.warnings.warn(ctx.path, `Unresolvable $ref: ${schema.$ref}`);
129
+ return { kind: 'scalar', name: 'unknown' };
130
+ }
131
+
132
+ // const
133
+ if (schema.const !== undefined) {
134
+ return { kind: 'literal', value: schema.const as string | number | boolean };
135
+ }
136
+
137
+ // enum
138
+ if (schema.enum) {
139
+ return { kind: 'enum', values: schema.enum.map(String) };
140
+ }
141
+
142
+ // oneOf / anyOf → union (or discriminatedUnion when discriminator.propertyName is set)
143
+ if (schema.oneOf && schema.oneOf.length > 0) {
144
+ if (schema.discriminator?.propertyName) {
145
+ return toDiscriminatedUnion(schema.oneOf, schema.discriminator.propertyName, ctx);
146
+ }
147
+ return toUnion(schema.oneOf, ctx);
148
+ }
149
+ if (schema.anyOf && schema.anyOf.length > 0) {
150
+ if (schema.discriminator?.propertyName) {
151
+ return toDiscriminatedUnion(schema.anyOf, schema.discriminator.propertyName, ctx);
152
+ }
153
+ return toUnion(schema.anyOf, ctx);
154
+ }
155
+
156
+ // allOf → intersection
157
+ if (schema.allOf && schema.allOf.length > 0) {
158
+ if (schema.allOf.length === 1) {
159
+ return schemaToTypeNode(schema.allOf[0]!, ctx);
160
+ }
161
+ return {
162
+ kind: 'intersection',
163
+ members: schema.allOf.map(s => schemaToTypeNode(s, ctx)),
164
+ };
165
+ }
166
+
167
+ // Handle nullable type arrays: [string, null] → nullable
168
+ const types = normalizeTypeField(schema);
169
+
170
+ if (types === null) {
171
+ // No type information at all
172
+ if (schema.properties) {
173
+ return schemaToInlineObject(schema, ctx);
174
+ }
175
+ return { kind: 'scalar', name: 'unknown' };
176
+ }
177
+
178
+ const { baseType, nullable } = types;
179
+
180
+ let typeNode: ContractTypeNode;
181
+
182
+ switch (baseType) {
183
+ case 'string':
184
+ typeNode = stringSchemaToType(schema);
185
+ break;
186
+ case 'integer':
187
+ typeNode = integerSchemaToType(schema);
188
+ break;
189
+ case 'number':
190
+ typeNode = numberSchemaToType(schema);
191
+ break;
192
+ case 'boolean':
193
+ typeNode = { kind: 'scalar', name: 'boolean' };
194
+ break;
195
+ case 'null':
196
+ typeNode = { kind: 'scalar', name: 'null' };
197
+ break;
198
+ case 'array':
199
+ typeNode = arraySchemaToType(schema, ctx);
200
+ break;
201
+ case 'object':
202
+ typeNode = objectSchemaToType(schema, ctx);
203
+ break;
204
+ default:
205
+ ctx.warnings.warn(ctx.path, `Unknown type: ${baseType}`);
206
+ typeNode = { kind: 'scalar', name: 'unknown' };
207
+ }
208
+
209
+ if (nullable) {
210
+ return { kind: 'union', members: [typeNode, { kind: 'scalar', name: 'null' }] };
211
+ }
212
+
213
+ return typeNode;
214
+ }
215
+
216
+ // ─── Type-Specific Converters ─────────────────────────────────────────────
217
+
218
+ function stringSchemaToType(schema: NormalizedSchema): ContractTypeNode {
219
+ // Check format first
220
+ if (schema.format) {
221
+ const scalarName = FORMAT_TO_SCALAR[schema.format];
222
+ if (scalarName) {
223
+ return { kind: 'scalar', name: scalarName };
224
+ }
225
+ }
226
+
227
+ const mods: Partial<ScalarTypeNode> = {};
228
+ if (schema.minLength !== undefined && schema.maxLength !== undefined && schema.minLength === schema.maxLength) {
229
+ mods.len = schema.minLength;
230
+ } else {
231
+ if (schema.minLength !== undefined) mods.min = schema.minLength;
232
+ if (schema.maxLength !== undefined) mods.max = schema.maxLength;
233
+ }
234
+ if (schema.pattern) mods.regex = `/${schema.pattern}/`;
235
+ if (schema.format && !FORMAT_TO_SCALAR[schema.format]) mods.format = schema.format;
236
+
237
+ return { kind: 'scalar', name: 'string', ...mods };
238
+ }
239
+
240
+ function integerSchemaToType(schema: NormalizedSchema): ContractTypeNode {
241
+ const name: ScalarTypeNode['name'] = schema.format === 'int64' ? 'bigint' : 'int';
242
+ const mods: Partial<ScalarTypeNode> = {};
243
+ if (schema.minimum !== undefined) mods.min = schema.minimum;
244
+ if (schema.maximum !== undefined) mods.max = schema.maximum;
245
+ return { kind: 'scalar', name, ...mods };
246
+ }
247
+
248
+ function numberSchemaToType(schema: NormalizedSchema): ContractTypeNode {
249
+ const mods: Partial<ScalarTypeNode> = {};
250
+ if (schema.minimum !== undefined) mods.min = schema.minimum;
251
+ if (schema.maximum !== undefined) mods.max = schema.maximum;
252
+ return { kind: 'scalar', name: 'number', ...mods };
253
+ }
254
+
255
+ function arraySchemaToType(schema: NormalizedSchema, ctx: SchemaContext): ContractTypeNode {
256
+ // Tuple: prefixItems (OAS 3.1)
257
+ if (schema.prefixItems && schema.prefixItems.length > 0) {
258
+ return {
259
+ kind: 'tuple',
260
+ items: schema.prefixItems.map(s => schemaToTypeNode(s, ctx)),
261
+ };
262
+ }
263
+
264
+ const item = schema.items ? schemaToTypeNode(schema.items, ctx) : { kind: 'scalar' as const, name: 'unknown' as const };
265
+ const mods: { min?: number; max?: number } = {};
266
+ if (schema.minItems !== undefined) mods.min = schema.minItems;
267
+ if (schema.maxItems !== undefined) mods.max = schema.maxItems;
268
+
269
+ return { kind: 'array', item, ...mods };
270
+ }
271
+
272
+ function objectSchemaToType(schema: NormalizedSchema, ctx: SchemaContext): ContractTypeNode {
273
+ // Record type: additionalProperties with no named properties
274
+ if (schema.additionalProperties && typeof schema.additionalProperties === 'object' && !schema.properties) {
275
+ return {
276
+ kind: 'record',
277
+ key: { kind: 'scalar', name: 'string' },
278
+ value: schemaToTypeNode(schema.additionalProperties, ctx),
279
+ };
280
+ }
281
+
282
+ // Object with properties → inline object or extracted model
283
+ if (schema.properties) {
284
+ return schemaToInlineObject(schema, ctx);
285
+ }
286
+
287
+ // Empty object
288
+ return { kind: 'scalar', name: 'object' };
289
+ }
290
+
291
+ function schemaToInlineObject(schema: NormalizedSchema, ctx: SchemaContext): ContractTypeNode {
292
+ const fields = schemaPropertiesToFields(schema, ctx);
293
+ return { kind: 'inlineObject', fields };
294
+ }
295
+
296
+ // ─── Field Conversion ─────────────────────────────────────────────────────
297
+
298
+ function schemaPropertiesToFields(schema: NormalizedSchema, ctx: SchemaContext): FieldNode[] {
299
+ const properties = schema.properties ?? {};
300
+ const required = new Set(schema.required ?? []);
301
+ const fields: FieldNode[] = [];
302
+
303
+ for (const [name, propSchema] of Object.entries(properties)) {
304
+ const propCtx = { ...ctx, path: `${ctx.path}/properties/${name}` };
305
+ const fieldType = schemaToTypeNode(propSchema, propCtx);
306
+
307
+ // Determine nullable from the type (if it's a union with null)
308
+ let nullable = false;
309
+ let effectiveType = fieldType;
310
+ if (fieldType.kind === 'union') {
311
+ const nonNull = fieldType.members.filter(m => !(m.kind === 'scalar' && m.name === 'null'));
312
+ if (nonNull.length < fieldType.members.length) {
313
+ nullable = true;
314
+ effectiveType = nonNull.length === 1 ? nonNull[0]! : { kind: 'union', members: nonNull };
315
+ }
316
+ }
317
+
318
+ const visibility = propSchema.readOnly ? ('readonly' as const) : propSchema.writeOnly ? ('writeonly' as const) : ('normal' as const);
319
+
320
+ fields.push({
321
+ name,
322
+ optional: !required.has(name),
323
+ nullable,
324
+ visibility,
325
+ type: effectiveType,
326
+ default: propSchema.default as string | number | boolean | undefined,
327
+ deprecated: propSchema.deprecated,
328
+ description: ctx.includeComments ? propSchema.description : undefined,
329
+ loc: LOC,
330
+ });
331
+ }
332
+
333
+ return fields;
334
+ }
335
+
336
+ // ─── Helpers ──────────────────────────────────────────────────────────────
337
+
338
+ function normalizeTypeField(schema: NormalizedSchema): { baseType: string; nullable: boolean } | null {
339
+ if (!schema.type) return null;
340
+
341
+ if (typeof schema.type === 'string') {
342
+ return { baseType: schema.type, nullable: false };
343
+ }
344
+
345
+ if (Array.isArray(schema.type)) {
346
+ const types = schema.type as string[];
347
+ const nonNull = types.filter(t => t !== 'null');
348
+ const nullable = types.includes('null');
349
+ if (nonNull.length === 1) {
350
+ return { baseType: nonNull[0]!, nullable };
351
+ }
352
+ // Multiple non-null types — unusual but handle gracefully
353
+ if (nonNull.length === 0) {
354
+ return { baseType: 'null', nullable: false };
355
+ }
356
+ // Multiple types → treat as unknown
357
+ return { baseType: nonNull[0]!, nullable };
358
+ }
359
+
360
+ return null;
361
+ }
362
+
363
+ function toUnion(schemas: NormalizedSchema[], ctx: SchemaContext): ContractTypeNode {
364
+ const members = schemas.map(s => schemaToTypeNode(s, ctx));
365
+ if (members.length === 1) return members[0]!;
366
+ return { kind: 'union', members };
367
+ }
368
+
369
+ function toDiscriminatedUnion(schemas: NormalizedSchema[], discriminator: string, ctx: SchemaContext): ContractTypeNode {
370
+ const members = schemas.map(s => schemaToTypeNode(s, ctx));
371
+ if (members.length === 1) return members[0]!;
372
+ return { kind: 'discriminatedUnion', discriminator, members };
373
+ }
374
+
375
+ function warnUnsupported(schema: NormalizedSchema, ctx: SchemaContext): void {
376
+ if (schema.xml) ctx.warnings.warn(ctx.path, 'xml metadata is not supported, skipping');
377
+ if (schema.externalDocs) ctx.warnings.info(ctx.path, 'externalDocs is not supported, skipping');
378
+ if (schema.not) ctx.warnings.warn(ctx.path, 'not keyword is not supported, skipping');
379
+ }
380
+
381
+ /**
382
+ * Extract a named model from an inline object schema (used for request/response bodies).
383
+ */
384
+ export function extractInlineModel(
385
+ schema: NormalizedSchema,
386
+ suggestedName: string,
387
+ ctx: SchemaContext,
388
+ ): { typeNode: ContractTypeNode; model?: ModelNode } {
389
+ // If it's a $ref, just use the ref
390
+ if (schema.$ref) {
391
+ return { typeNode: schemaToTypeNode(schema, ctx) };
392
+ }
393
+
394
+ // If it's an object with properties, extract as a named model
395
+ if (schema.properties || (schema.type === 'object' && schema.additionalProperties === undefined)) {
396
+ const fields = schemaPropertiesToFields(schema, ctx);
397
+ const model: ModelNode = {
398
+ kind: 'model',
399
+ name: suggestedName,
400
+ fields,
401
+ description: ctx.includeComments ? schema.description : undefined,
402
+ loc: LOC,
403
+ };
404
+ return {
405
+ typeNode: { kind: 'ref', name: suggestedName },
406
+ model,
407
+ };
408
+ }
409
+
410
+ // Otherwise return the type node directly
411
+ return { typeNode: schemaToTypeNode(schema, ctx) };
412
+ }
413
+
414
+ /**
415
+ * Sanitize an OpenAPI schema name to a valid .ck identifier (PascalCase).
416
+ */
417
+ export function sanitizeName(name: string, warnings: WarningCollector): string {
418
+ // Replace dots, hyphens, spaces, and other invalid chars with word boundaries
419
+ const cleaned = name
420
+ .replace(/[^a-zA-Z0-9_$]/g, ' ')
421
+ .split(/\s+/)
422
+ .filter(Boolean)
423
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
424
+ .join('');
425
+
426
+ if (cleaned !== name) {
427
+ warnings.info(`#/components/schemas/${name}`, `Schema name sanitized: "${name}" → "${cleaned}"`);
428
+ }
429
+
430
+ return cleaned || 'UnnamedSchema';
431
+ }
@@ -0,0 +1,200 @@
1
+ import type { CkRootNode, ModelNode, OpRouteNode, ContractTypeNode } from '@contractkit/core';
2
+
3
+ /**
4
+ * Split models and routes into per-tag CkRootNode instances.
5
+ *
6
+ * Algorithm:
7
+ * 1. Routes are assigned to the file of their first tag (untagged → 'default')
8
+ * 2. Models referenced by exactly one tag go into that tag's file
9
+ * 3. Models referenced by 2+ tags go into 'shared'
10
+ * 4. Orphan models (not referenced by any route) go into 'shared'
11
+ */
12
+ export function splitByTag(models: ModelNode[], routes: OpRouteNode[], routeTags: Map<OpRouteNode, string>): Map<string, CkRootNode> {
13
+ // Step 1: Group routes by tag
14
+ const routesByTag = new Map<string, OpRouteNode[]>();
15
+ for (const route of routes) {
16
+ const tag = routeTags.get(route) ?? 'default';
17
+ const group = routesByTag.get(tag) ?? [];
18
+ group.push(route);
19
+ routesByTag.set(tag, group);
20
+ }
21
+
22
+ // Step 2: For each tag, collect which model names are referenced
23
+ const modelsByTag = new Map<string, Set<string>>();
24
+ for (const [tag, tagRoutes] of routesByTag) {
25
+ const refs = new Set<string>();
26
+ for (const route of tagRoutes) {
27
+ collectRouteRefs(route, refs);
28
+ }
29
+ modelsByTag.set(tag, refs);
30
+ }
31
+
32
+ // Step 3: Determine which tag each model belongs to
33
+ const modelNameToModel = new Map(models.map(m => [m.name, m]));
34
+ const modelAssignment = new Map<string, string>(); // modelName → tag or 'shared'
35
+
36
+ for (const model of models) {
37
+ const tags: string[] = [];
38
+ for (const [tag, refs] of modelsByTag) {
39
+ if (refs.has(model.name)) {
40
+ tags.push(tag);
41
+ }
42
+ }
43
+
44
+ if (tags.length === 0) {
45
+ // Orphan model → shared
46
+ modelAssignment.set(model.name, 'shared');
47
+ } else if (tags.length === 1) {
48
+ // Single tag reference → that tag's file
49
+ modelAssignment.set(model.name, tags[0]!);
50
+ } else {
51
+ // Multi-tag reference → shared
52
+ modelAssignment.set(model.name, 'shared');
53
+ }
54
+ }
55
+
56
+ // Also check transitive: models referenced by shared models should also be shared
57
+ // (simple one-pass — could iterate to fixed point for deep chains)
58
+ for (const model of models) {
59
+ if (modelAssignment.get(model.name) === 'shared') {
60
+ const refs = new Set<string>();
61
+ collectModelRefs(model, refs);
62
+ for (const ref of refs) {
63
+ if (modelNameToModel.has(ref)) {
64
+ const currentTag = modelAssignment.get(ref);
65
+ // Only promote to shared if it was assigned to a specific tag
66
+ // (don't override if already shared)
67
+ if (currentTag && currentTag !== 'shared') {
68
+ // Check if another tag also references this model
69
+ const otherTags = [...modelsByTag.entries()].filter(([t, r]) => t !== currentTag && r.has(ref)).map(([t]) => t);
70
+ if (otherTags.length > 0) {
71
+ modelAssignment.set(ref, 'shared');
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+ }
78
+
79
+ // Step 4: Build CkRootNode per tag
80
+ const result = new Map<string, CkRootNode>();
81
+ const allTags = new Set([...routesByTag.keys(), ...new Set(modelAssignment.values())]);
82
+
83
+ for (const tag of allTags) {
84
+ const tagModels = models.filter(m => modelAssignment.get(m.name) === tag);
85
+ const tagRoutes = routesByTag.get(tag) ?? [];
86
+
87
+ if (tagModels.length === 0 && tagRoutes.length === 0) continue;
88
+
89
+ const filename = sanitizeFilename(tag);
90
+ result.set(`${filename}.ck`, {
91
+ kind: 'ckRoot',
92
+ meta: tag !== 'shared' ? { area: tag } : {},
93
+ services: {},
94
+ models: tagModels,
95
+ routes: tagRoutes,
96
+ file: `${filename}.ck`,
97
+ });
98
+ }
99
+
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * Create a single CkRootNode with all models and routes.
105
+ */
106
+ export function mergeIntoSingle(models: ModelNode[], routes: OpRouteNode[], filename: string = 'api'): CkRootNode {
107
+ return {
108
+ kind: 'ckRoot',
109
+ meta: {},
110
+ services: {},
111
+ models,
112
+ routes,
113
+ file: `${filename}.ck`,
114
+ };
115
+ }
116
+
117
+ // ─── Reference Collection ─────────────────────────────────────────────────
118
+
119
+ function collectRouteRefs(route: OpRouteNode, refs: Set<string>): void {
120
+ if (route.params) {
121
+ collectParamSourceRefs(route.params, refs);
122
+ }
123
+ for (const op of route.operations) {
124
+ if (op.query) collectParamSourceRefs(op.query, refs);
125
+ if (op.headers) collectParamSourceRefs(op.headers, refs);
126
+ if (op.request) {
127
+ for (const body of op.request.bodies) collectTypeRefs(body.bodyType, refs);
128
+ }
129
+ for (const resp of op.responses) {
130
+ if (resp.bodyType) collectTypeRefs(resp.bodyType, refs);
131
+ }
132
+ }
133
+ }
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);
150
+ }
151
+ }
152
+
153
+ function collectTypeRefs(type: ContractTypeNode, refs: Set<string>): void {
154
+ switch (type.kind) {
155
+ case 'ref':
156
+ refs.add(type.name);
157
+ break;
158
+ case 'array':
159
+ collectTypeRefs(type.item, refs);
160
+ break;
161
+ case 'tuple':
162
+ for (const item of type.items) collectTypeRefs(item, refs);
163
+ break;
164
+ case 'record':
165
+ collectTypeRefs(type.key, refs);
166
+ collectTypeRefs(type.value, refs);
167
+ break;
168
+ case 'union':
169
+ case 'discriminatedUnion':
170
+ case 'intersection':
171
+ for (const member of type.members) collectTypeRefs(member, refs);
172
+ break;
173
+ case 'inlineObject':
174
+ for (const field of type.fields) collectTypeRefs(field.type, refs);
175
+ break;
176
+ case 'lazy':
177
+ collectTypeRefs(type.inner, refs);
178
+ break;
179
+ }
180
+ }
181
+
182
+ function collectModelRefs(model: ModelNode, refs: Set<string>): void {
183
+ if (model.bases) for (const b of model.bases) refs.add(b);
184
+ if (model.type) collectTypeRefs(model.type, refs);
185
+ for (const field of model.fields) {
186
+ collectTypeRefs(field.type, refs);
187
+ }
188
+ }
189
+
190
+ // ─── Helpers ──────────────────────────────────────────────────────────────
191
+
192
+ function sanitizeFilename(tag: string): string {
193
+ return (
194
+ tag
195
+ .toLowerCase()
196
+ .replace(/[^a-z0-9-]/g, '-')
197
+ .replace(/-+/g, '-')
198
+ .replace(/^-|-$/g, '') || 'default'
199
+ );
200
+ }