@contractkit/plugin-typescript 0.16.1 → 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -22,6 +22,11 @@ import {
22
22
  collectExternalOutputRefs as ckCollectExternalOutputRefs,
23
23
  } from '@contractkit/core';
24
24
 
25
+ /**
26
+ * Maps a ContractKit object mode to its Zod constructor name.
27
+ *
28
+ * @returns `"z.strictObject"` | `"z.object"` | `"z.looseObject"`
29
+ */
25
30
  export function modeToWrapper(mode: ObjectMode): string {
26
31
  switch (mode) {
27
32
  case 'strict':
@@ -111,6 +116,16 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
111
116
  return lines;
112
117
  }
113
118
 
119
+ /**
120
+ * Generate a TypeScript module containing Zod schemas for every model in `root`.
121
+ *
122
+ * Emits up to three schemas per model when visibility modifiers are present:
123
+ * `ModelBase` (all fields), `Model` (read — no writeonly), `ModelInput` (write — no readonly).
124
+ *
125
+ * @param root - The parsed contract root node.
126
+ * @param context - Optional cross-file context for import resolution and Input/Output variant tracking.
127
+ * @returns The full TypeScript source as a string.
128
+ */
114
129
  export function generateContract(root: ContractRootNode, context?: ContractCodegenContext): string {
115
130
  const needsDateTime = rootNeedsDateTime(root);
116
131
  const needsDuration = rootNeedsScalar(root, 'duration');
@@ -464,6 +479,14 @@ function renderField(field: FieldNode, defaultMode?: ObjectMode): string[] {
464
479
 
465
480
  // ─── Type rendering ────────────────────────────────────────────────────────
466
481
 
482
+ /**
483
+ * Render a ContractKit AST type node as a Zod schema expression string.
484
+ *
485
+ * @param parseCaseTransform - When set, generates a `.transform()` that remaps incoming keys from
486
+ * the given casing (`'snake'` | `'pascal'`) to camelCase for `inlineObject` types.
487
+ * @param defaultMode - Fallback object mode (`'strict'` | `'strip'` | `'loose'`) when the node
488
+ * doesn't specify its own mode.
489
+ */
467
490
  export function renderType(type: ContractTypeNode, parseCaseTransform?: 'snake' | 'pascal', defaultMode?: ObjectMode): string {
468
491
  switch (type.kind) {
469
492
  case 'scalar':
@@ -631,25 +654,34 @@ function renderDiscriminatedUnion(u: DiscriminatedUnionTypeNode, parseCaseTransf
631
654
 
632
655
  function renderIntersection(i: IntersectionTypeNode, parseCaseTransform?: 'snake' | 'pascal', defaultMode?: ObjectMode): string {
633
656
  const [first, ...rest] = i.members;
634
- // When the pattern is ref & { inlineObject(s) }, use .extend() to produce a
635
- // single merged ZodObject. Using .and(z.strictObject) breaks because each
636
- // strict side rejects the other side's keys during intersection parsing.
637
- if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'inlineObject')) {
638
- const allFields = rest.flatMap(m => (m as InlineObjectTypeNode).fields);
639
- const fieldLines =
640
- parseCaseTransform === 'snake'
641
- ? renderFieldsAsSnakeCase(allFields, defaultMode)
642
- .map(l => ` ${l}`)
643
- .join('\n')
644
- : parseCaseTransform === 'pascal'
645
- ? renderFieldsAsPascalCase(allFields, defaultMode)
646
- .map(l => ` ${l}`)
647
- .join('\n')
648
- : allFields
649
- .flatMap(f => renderField(f, defaultMode))
650
- .map(l => ` ${l}`)
651
- .join('\n');
652
- return `${first.name}.extend({\n${fieldLines}\n})`;
657
+ // When the pattern is ref & (ref | inlineObject)*, use .extend() chains to
658
+ // produce a single ZodObject. .and() breaks strict objects — each strict side
659
+ // rejects the other side's keys during intersection parsing, and ZodIntersection
660
+ // has no .strict() method.
661
+ if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'ref' || m.kind === 'inlineObject')) {
662
+ let expr = first.name;
663
+ for (const member of rest) {
664
+ if (member.kind === 'ref') {
665
+ expr += `.extend(${member.name}.shape)`;
666
+ } else {
667
+ const m = member as InlineObjectTypeNode;
668
+ const fieldLines =
669
+ parseCaseTransform === 'snake'
670
+ ? renderFieldsAsSnakeCase(m.fields, defaultMode)
671
+ .map(l => ` ${l}`)
672
+ .join('\n')
673
+ : parseCaseTransform === 'pascal'
674
+ ? renderFieldsAsPascalCase(m.fields, defaultMode)
675
+ .map(l => ` ${l}`)
676
+ .join('\n')
677
+ : m.fields
678
+ .flatMap(f => renderField(f, defaultMode))
679
+ .map(l => ` ${l}`)
680
+ .join('\n');
681
+ expr += `.extend({\n${fieldLines}\n})`;
682
+ }
683
+ }
684
+ return expr;
653
685
  }
654
686
  let expr = renderType(first!, parseCaseTransform, defaultMode);
655
687
  for (const member of rest) {
@@ -730,11 +762,20 @@ export function renderInputType(type: ContractTypeNode, modelsWithInput?: Set<st
730
762
  return `z.discriminatedUnion("${escapeString(type.discriminator)}", [${type.members.map(m => renderInputType(m, modelsWithInput, defaultMode)).join(', ')}])`;
731
763
  case 'intersection': {
732
764
  const [first, ...rest] = type.members;
733
- if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'inlineObject')) {
734
- const base = modelsWithInput?.has(first.name) ? `${first.name}Input` : first.name;
735
- const allFields = rest.flatMap(m => (m as InlineObjectTypeNode).fields);
736
- const fieldLines = allFields.map(f => ` ${renderInputField(f, modelsWithInput ?? new Set(), defaultMode)}`).join('\n');
737
- return `${base}.extend({\n${fieldLines}\n})`;
765
+ if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'ref' || m.kind === 'inlineObject')) {
766
+ let expr = modelsWithInput?.has(first.name) ? `${first.name}Input` : first.name;
767
+ for (const member of rest) {
768
+ if (member.kind === 'ref') {
769
+ const name = modelsWithInput?.has(member.name) ? `${member.name}Input` : member.name;
770
+ expr += `.extend(${name}.shape)`;
771
+ } else {
772
+ const fieldLines = (member as InlineObjectTypeNode).fields
773
+ .map(f => ` ${renderInputField(f, modelsWithInput ?? new Set(), defaultMode)}`)
774
+ .join('\n');
775
+ expr += `.extend({\n${fieldLines}\n})`;
776
+ }
777
+ }
778
+ return expr;
738
779
  }
739
780
  let expr = renderInputType(first!, modelsWithInput, defaultMode);
740
781
  for (const member of rest) {
@@ -798,11 +839,20 @@ export function renderQueryType(type: ContractTypeNode, modelsWithInput?: Set<st
798
839
  }
799
840
  case 'intersection': {
800
841
  const [first, ...rest] = type.members;
801
- if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'inlineObject')) {
802
- const base = modelsWithInput?.has(first.name) ? `${first.name}Input` : first.name;
803
- const allFields = rest.flatMap(m => (m as InlineObjectTypeNode).fields);
804
- const fieldLines = allFields.map(f => ` ${renderQueryField(f, modelsWithInput, defaultMode)}`).join('\n');
805
- return `${base}.extend({\n${fieldLines}\n})`;
842
+ if (first && first.kind === 'ref' && rest.length > 0 && rest.every(m => m.kind === 'ref' || m.kind === 'inlineObject')) {
843
+ let expr = modelsWithInput?.has(first.name) ? `${first.name}Input` : first.name;
844
+ for (const member of rest) {
845
+ if (member.kind === 'ref') {
846
+ const name = modelsWithInput?.has(member.name) ? `${member.name}Input` : member.name;
847
+ expr += `.extend(${name}.shape)`;
848
+ } else {
849
+ const fieldLines = (member as InlineObjectTypeNode).fields
850
+ .map(f => ` ${renderQueryField(f, modelsWithInput, defaultMode)}`)
851
+ .join('\n');
852
+ expr += `.extend({\n${fieldLines}\n})`;
853
+ }
854
+ }
855
+ return expr;
806
856
  }
807
857
  let expr = renderQueryType(first!, modelsWithInput, defaultMode);
808
858
  for (const member of rest) {
@@ -713,6 +713,44 @@ describe('generateContract', () => {
713
713
  expect(output).toContain('export const UserId = z.uuid()');
714
714
  expect(output).not.toContain('UserIdInput');
715
715
  });
716
+
717
+ it('ref & ref type alias uses .extend(B.shape) instead of .and()', () => {
718
+ const root = contractRoot([
719
+ model('Pagination', [field('page', scalarType('int'))]),
720
+ model('Filter', [field('status', scalarType('string'), { optional: true })]),
721
+ model('ListQuery', [], {
722
+ type: {
723
+ kind: 'intersection',
724
+ members: [
725
+ { kind: 'ref', name: 'Pagination' },
726
+ { kind: 'ref', name: 'Filter' },
727
+ ],
728
+ },
729
+ }),
730
+ ]);
731
+ const output = generateContract(root);
732
+ expect(output).toContain('export const ListQuery = Pagination.extend(Filter.shape)');
733
+ expect(output).not.toContain('.and(');
734
+ });
735
+
736
+ it('ref & ref type alias substitutes Input variant when base has Input', () => {
737
+ const root = contractRoot([
738
+ model('Pagination', [field('page', scalarType('int')), field('total', scalarType('int'), { visibility: 'readonly' })]),
739
+ model('Filter', [field('status', scalarType('string'), { optional: true })]),
740
+ model('ListQuery', [], {
741
+ type: {
742
+ kind: 'intersection',
743
+ members: [
744
+ { kind: 'ref', name: 'Pagination' },
745
+ { kind: 'ref', name: 'Filter' },
746
+ ],
747
+ },
748
+ }),
749
+ ]);
750
+ const output = generateContract(root);
751
+ expect(output).toContain('export const ListQuery = Pagination.extend(Filter.shape)');
752
+ expect(output).toContain('export const ListQueryInput = PaginationInput.extend(Filter.shape)');
753
+ });
716
754
  });
717
755
 
718
756
  // ─── Description ──────────────────────────────────────────────
@@ -324,6 +324,45 @@ describe('generateOperation', () => {
324
324
  expect(output).toMatch(/import \{[^}]*\bPaginationInput\b[^}]*\} from /);
325
325
  });
326
326
 
327
+ it('ref & ref intersection query uses .extend(B.shape) not .and()', () => {
328
+ const root = opRoot([
329
+ opRoute('/persons', [
330
+ opOperation('get', {
331
+ query: {
332
+ kind: 'intersection',
333
+ members: [
334
+ { kind: 'ref', name: 'Pagination' },
335
+ { kind: 'ref', name: 'PersonQuery' },
336
+ ],
337
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
338
+ } as any,
339
+ }),
340
+ ]),
341
+ ]);
342
+ const output = generateOp(root);
343
+ expect(output).toContain('Pagination.extend(PersonQuery.shape)');
344
+ expect(output).not.toContain('.and(');
345
+ });
346
+
347
+ it('ref & ref intersection query substitutes Input variants', () => {
348
+ const root = opRoot([
349
+ opRoute('/persons', [
350
+ opOperation('get', {
351
+ query: {
352
+ kind: 'intersection',
353
+ members: [
354
+ { kind: 'ref', name: 'Pagination' },
355
+ { kind: 'ref', name: 'PersonQuery' },
356
+ ],
357
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
358
+ } as any,
359
+ }),
360
+ ]),
361
+ ]);
362
+ const output = generateOp(root, { modelsWithInput: new Set(['Pagination']) });
363
+ expect(output).toContain('PaginationInput.extend(PersonQuery.shape)');
364
+ });
365
+
327
366
  it('wraps ContractTypeNode intersection query with array fields using z.preprocess', () => {
328
367
  const root = opRoot([
329
368
  opRoute('/offers', [
@@ -1,15 +0,0 @@
1
-
2
- > @contractkit/plugin-typescript@0.16.0 build /Users/robert/projects/contractkit/packages/plugin-typescript
3
- > tsup src/index.ts --format esm --sourcemap --dts && tsc --emitDeclarationOnly --declaration
4
-
5
- CLI Building entry: src/index.ts
6
- CLI Using tsconfig: tsconfig.json
7
- CLI tsup v8.5.1
8
- CLI Target: esnext
9
- ESM Build start
10
- ESM dist/index.js 123.76 KB
11
- ESM dist/index.js.map 267.25 KB
12
- ESM ⚡️ Build success in 95ms
13
- DTS Build start
14
- DTS ⚡️ Build success in 744ms
15
- DTS dist/index.d.ts 3.44 KB
@@ -1,19 +0,0 @@
1
-
2
- > @contractkit/plugin-typescript@0.16.0 test /Users/robert/projects/contractkit/packages/plugin-typescript
3
- > vitest run
4
-
5
-
6
-  RUN  v4.1.5 /Users/robert/projects/contractkit/packages/plugin-typescript
7
-
8
- ✓ tests/codegen-contract.test.ts (120 tests) 10ms
9
- ✓ tests/codegen-plain-types.test.ts (58 tests) 10ms
10
- ✓ tests/codegen-sdk.test.ts (123 tests) 16ms
11
- ✓ tests/codegen-operation.test.ts (86 tests) 9ms
12
- ✓ tests/pipeline.test.ts (25 tests) 29ms
13
- ✓ tests/codegen-server.test.ts (15 tests) 6ms
14
-
15
-  Test Files  6 passed (6)
16
-  Tests  427 passed (427)
17
-  Start at  08:51:34
18
-  Duration  472ms (transform 591ms, setup 0ms, import 1.33s, tests 80ms, environment 0ms)
19
-