@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/.turbo/turbo-build$colon$ci.log +21 -35
- package/.turbo/turbo-test$colon$ci.log +37 -81
- package/CHANGELOG.md +10 -0
- package/coverage/clover.xml +747 -732
- package/coverage/coverage-final.json +8 -8
- package/coverage/index.html +18 -18
- package/coverage/src/codegen-contract.ts.html +429 -279
- package/coverage/src/codegen-operation.ts.html +183 -183
- package/coverage/src/codegen-plain-types.ts.html +1 -1
- package/coverage/src/codegen-sdk.ts.html +1 -1
- package/coverage/src/index.html +18 -18
- package/coverage/src/index.ts.html +1 -1
- package/coverage/src/path-utils.ts.html +1 -1
- package/coverage/src/ts-render.ts.html +1 -1
- package/coverage/tests/helpers.ts.html +18 -18
- package/coverage/tests/index.html +1 -1
- package/dist/codegen-contract.d.ts +23 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/index.js +36 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/codegen-contract.ts +79 -29
- package/tests/codegen-contract.test.ts +38 -0
- package/tests/codegen-operation.test.ts +39 -0
- package/.turbo/turbo-build.log +0 -15
- package/.turbo/turbo-test.log +0 -19
package/package.json
CHANGED
package/src/codegen-contract.ts
CHANGED
|
@@ -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 &
|
|
635
|
-
// single
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
.
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
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', [
|
package/.turbo/turbo-build.log
DELETED
|
@@ -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
|
package/.turbo/turbo-test.log
DELETED
|
@@ -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
|
-
[1m[30m[46m RUN [49m[39m[22m [36mv4.1.5 [39m[90m/Users/robert/projects/contractkit/packages/plugin-typescript[39m
|
|
7
|
-
|
|
8
|
-
[32m✓[39m tests/codegen-contract.test.ts [2m([22m[2m120 tests[22m[2m)[22m[32m 10[2mms[22m[39m
|
|
9
|
-
[32m✓[39m tests/codegen-plain-types.test.ts [2m([22m[2m58 tests[22m[2m)[22m[32m 10[2mms[22m[39m
|
|
10
|
-
[32m✓[39m tests/codegen-sdk.test.ts [2m([22m[2m123 tests[22m[2m)[22m[32m 16[2mms[22m[39m
|
|
11
|
-
[32m✓[39m tests/codegen-operation.test.ts [2m([22m[2m86 tests[22m[2m)[22m[32m 9[2mms[22m[39m
|
|
12
|
-
[32m✓[39m tests/pipeline.test.ts [2m([22m[2m25 tests[22m[2m)[22m[32m 29[2mms[22m[39m
|
|
13
|
-
[32m✓[39m tests/codegen-server.test.ts [2m([22m[2m15 tests[22m[2m)[22m[32m 6[2mms[22m[39m
|
|
14
|
-
|
|
15
|
-
[2m Test Files [22m [1m[32m6 passed[39m[22m[90m (6)[39m
|
|
16
|
-
[2m Tests [22m [1m[32m427 passed[39m[22m[90m (427)[39m
|
|
17
|
-
[2m Start at [22m 08:51:34
|
|
18
|
-
[2m Duration [22m 472ms[2m (transform 591ms, setup 0ms, import 1.33s, tests 80ms, environment 0ms)[22m
|
|
19
|
-
|