@contractkit/plugin-csharp 0.0.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 (48) hide show
  1. package/.turbo/turbo-build$colon$ci.log +13 -0
  2. package/.turbo/turbo-build.log +12 -0
  3. package/.turbo/turbo-format.log +34 -0
  4. package/.turbo/turbo-test.log +17 -0
  5. package/CHANGELOG.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +173 -0
  8. package/dist/codegen-client.d.ts +35 -0
  9. package/dist/codegen-client.d.ts.map +1 -0
  10. package/dist/codegen-models.d.ts +75 -0
  11. package/dist/codegen-models.d.ts.map +1 -0
  12. package/dist/codegen-sdk.d.ts +13 -0
  13. package/dist/codegen-sdk.d.ts.map +1 -0
  14. package/dist/hoist.d.ts +53 -0
  15. package/dist/hoist.d.ts.map +1 -0
  16. package/dist/index.d.ts +30 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +2569 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/naming.d.ts +89 -0
  21. package/dist/naming.d.ts.map +1 -0
  22. package/dist/runtime-converters.d.ts +15 -0
  23. package/dist/runtime-converters.d.ts.map +1 -0
  24. package/dist/runtime.d.ts +10 -0
  25. package/dist/runtime.d.ts.map +1 -0
  26. package/dist/scaffold.d.ts +26 -0
  27. package/dist/scaffold.d.ts.map +1 -0
  28. package/eslint.config.js +6 -0
  29. package/package.json +48 -0
  30. package/src/codegen-client.ts +680 -0
  31. package/src/codegen-models.ts +909 -0
  32. package/src/codegen-sdk.ts +52 -0
  33. package/src/hoist.ts +402 -0
  34. package/src/index.ts +373 -0
  35. package/src/naming.ts +262 -0
  36. package/src/runtime-converters.ts +147 -0
  37. package/src/runtime.ts +381 -0
  38. package/src/scaffold.ts +41 -0
  39. package/tests/codegen-client.test.ts +275 -0
  40. package/tests/codegen-models.test.ts +410 -0
  41. package/tests/helpers.ts +202 -0
  42. package/tests/hoist.test.ts +92 -0
  43. package/tests/index.test.ts +124 -0
  44. package/tests/naming.test.ts +133 -0
  45. package/tests/runtime.test.ts +104 -0
  46. package/tests/scaffold.test.ts +28 -0
  47. package/tsconfig.json +9 -0
  48. package/vitest.config.ts +14 -0
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildModelIndex } from '@contractkit/core';
3
+ import type { ContractRootNode } from '@contractkit/core';
4
+ import { collectHoistedTypes } from '../src/hoist.js';
5
+ import { contractRoot, enumType, field, inlineObjectType, literalType, model, refType, scalarType, tupleType, unionType } from './helpers.js';
6
+
7
+ function hoist(roots: ContractRootNode[], modelsWithInput = new Set<string>(), warn?: (m: string, f: string) => void) {
8
+ return collectHoistedTypes(roots, { modelIndex: buildModelIndex(roots.flatMap(r => r.models)), modelsWithInput, warn });
9
+ }
10
+
11
+ describe('collectHoistedTypes', () => {
12
+ it('names an inline enum after the model and field that hold it', () => {
13
+ const result = hoist([contractRoot([model('M', [field('status', enumType('a', 'b'))])])]);
14
+ expect(result.byName.get('MStatus')?.kind).toBe('enum');
15
+ });
16
+
17
+ it('names an inline object as a record', () => {
18
+ const result = hoist([contractRoot([model('M', [field('nested', inlineObjectType([field('a', scalarType('string'))]))])])]);
19
+ expect(result.byName.get('MNested')?.kind).toBe('record');
20
+ });
21
+
22
+ it('hoists a 2-tuple, which the Kotlin plugin maps onto Pair instead', () => {
23
+ const result = hoist([contractRoot([model('M', [field('coords', tupleType(scalarType('int'), scalarType('int')))])])]);
24
+ expect(result.byName.get('MCoords')?.kind).toBe('tuple');
25
+ });
26
+
27
+ it('leaves a union of one non-null member alone, since that is just a nullable type', () => {
28
+ const result = hoist([contractRoot([model('M', [field('v', unionType(scalarType('string'), scalarType('null')))])])]);
29
+ expect(result.byName.has('MV')).toBe(false);
30
+ });
31
+
32
+ it('records a membership so a member record declares the union it belongs to', () => {
33
+ const result = hoist([
34
+ contractRoot([
35
+ model('Card', [field('kind', literalType('card'))]),
36
+ model('M', [field('m', { kind: 'discriminatedUnion', discriminator: 'kind', members: [refType('Card')] })]),
37
+ ]),
38
+ ]);
39
+ expect(result.memberships.get('Card')).toEqual(['MM']);
40
+ });
41
+
42
+ it('lets one contract belong to two unions, which a record base could not express', () => {
43
+ const result = hoist([
44
+ contractRoot([
45
+ model('Card', [field('kind', literalType('card'))]),
46
+ model('A', [field('m', { kind: 'discriminatedUnion', discriminator: 'kind', members: [refType('Card')] })]),
47
+ model('B', [field('m', { kind: 'discriminatedUnion', discriminator: 'kind', members: [refType('Card')] })]),
48
+ ]),
49
+ ]);
50
+ expect(result.memberships.get('Card')).toEqual(['AM', 'BM']);
51
+ });
52
+
53
+ it('suffixes a name already claimed by a model', () => {
54
+ const result = hoist([contractRoot([model('MStatus', [field('x', scalarType('string'))]), model('M', [field('status', enumType('a'))])])]);
55
+ expect(result.byName.has('MStatus2')).toBe(true);
56
+ });
57
+
58
+ it('warns and hoists nothing when a discriminator is not a literal', () => {
59
+ const warnings: string[] = [];
60
+ const result = hoist(
61
+ [
62
+ contractRoot([
63
+ model('Card', [field('kind', enumType('card'))]),
64
+ model('M', [field('m', { kind: 'discriminatedUnion', discriminator: 'kind', members: [refType('Card')] })]),
65
+ ]),
66
+ ],
67
+ new Set(),
68
+ m => warnings.push(m),
69
+ );
70
+ expect(result.byName.has('MM')).toBe(false);
71
+ expect(warnings.join('\n')).toMatch(/is not a literal/);
72
+ });
73
+
74
+ it('marks a hoisted shape as needing an Input twin when it reaches a split model', () => {
75
+ const roots = [
76
+ contractRoot([
77
+ model('P', [field('id', scalarType('uuid'), { visibility: 'readonly' })]),
78
+ model('M', [field('nested', inlineObjectType([field('p', refType('P'))]))]),
79
+ ]),
80
+ ];
81
+ const result = hoist(roots, new Set(['P']));
82
+ expect(result.byName.get('MNested')?.needsInput).toBe(true);
83
+ });
84
+
85
+ it('assigns a declaration to the file that owns it, so each models file emits its own', () => {
86
+ const a = contractRoot([model('A', [field('s', enumType('x'))])], 'a.ck');
87
+ const b = contractRoot([model('B', [field('s', enumType('y'))])], 'b.ck');
88
+ const result = hoist([a, b]);
89
+ expect(result.byFile.get('a.ck')?.map(d => d.name)).toEqual(['AS']);
90
+ expect(result.byFile.get('b.ck')?.map(d => d.name)).toEqual(['BS']);
91
+ });
92
+ });
@@ -0,0 +1,124 @@
1
+ import { mkdtempSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join, relative, sep } from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ import type { PluginContext } from '@contractkit/core';
6
+ import { assertValidConfig, createCSharpSdkPlugin } from '../src/index.js';
7
+ import { contractRoot, field, model, opOperation, opResponse, opRoot, opRoute, scalarType } from './helpers.js';
8
+
9
+ const ROOT_DIR = '/project';
10
+
11
+ /** A `PluginContext` that captures `emitFile` in memory, keyed by rootDir-relative POSIX path. */
12
+ export function makeCtx(): PluginContext & { emitted: Map<string, string>; ifAbsent: string[] } {
13
+ const emitted = new Map<string, string>();
14
+ const ifAbsent: string[] = [];
15
+ return {
16
+ rootDir: ROOT_DIR,
17
+ options: {},
18
+ cacheEnabled: false,
19
+ cacheDir: mkdtempSync(join(tmpdir(), 'ck-csharp-')),
20
+ emitFile: (outPath, content, opts) => {
21
+ const key = relative(ROOT_DIR, outPath).split(sep).join('/');
22
+ emitted.set(key, content);
23
+ if (opts?.ifAbsent) ifAbsent.push(key);
24
+ },
25
+ emitted,
26
+ ifAbsent,
27
+ };
28
+ }
29
+
30
+ export const INPUTS = {
31
+ contractRoots: [contractRoot([model('Payment', [field('id', scalarType('uuid'))])], 'contracts/billing.ck')],
32
+ opRoots: [
33
+ opRoot(
34
+ [opRoute('/payments', [opOperation('get', { sdk: 'listPayments', responses: [opResponse(200, 'Payment')] })])],
35
+ 'contracts/billing.ck',
36
+ ),
37
+ ],
38
+ modelsWithInput: new Set<string>(),
39
+ modelsWithOutput: new Set<string>(),
40
+ };
41
+
42
+ describe('assertValidConfig', () => {
43
+ it('accepts a valid config and an empty one', () => {
44
+ expect(() => assertValidConfig({})).not.toThrow();
45
+ expect(() => assertValidConfig({ namespace: 'Acme.Sdk', sdkName: 'AcmeSdk', scaffold: true })).not.toThrow();
46
+ });
47
+
48
+ it('rejects a namespace segment that is not an identifier', () => {
49
+ expect(() => assertValidConfig({ namespace: 'Acme.1Bad' })).toThrow(/not a valid C# namespace/);
50
+ expect(() => assertValidConfig({ namespace: 'Acme..Sdk' })).toThrow(/not a valid C# namespace/);
51
+ expect(() => assertValidConfig({ namespace: 'Acme/Sdk' })).toThrow(/not a valid C# namespace/);
52
+ });
53
+
54
+ it('rejects a namespace segment that is a C# keyword', () => {
55
+ expect(() => assertValidConfig({ namespace: 'Acme.event.Sdk' })).toThrow(/contains the C# keyword 'event'/);
56
+ });
57
+
58
+ it('rejects an sdkName that is not a class name', () => {
59
+ expect(() => assertValidConfig({ sdkName: '2Sdk' })).toThrow(/not a valid C# class name/);
60
+ expect(() => assertValidConfig({ sdkName: 'class' })).toThrow(/is a C# keyword/);
61
+ });
62
+
63
+ it('rejects non-boolean flags, which JSON config cannot be trusted to type', () => {
64
+ expect(() => assertValidConfig({ scaffold: 'yes' as never })).toThrow(/scaffold must be a boolean/);
65
+ expect(() => assertValidConfig({ includeInternal: 1 as never })).toThrow(/includeInternal must be a boolean/);
66
+ });
67
+ });
68
+
69
+ describe('generateTargets', () => {
70
+ it('emits the runtime and the aggregator at the configured output directory', async () => {
71
+ const ctx = makeCtx();
72
+ const plugin = createCSharpSdkPlugin({ baseDir: 'cssdk', namespace: 'Acme.Sdk', sdkName: 'AcmeSdk' }, ROOT_DIR);
73
+ await plugin.generateTargets!(INPUTS, ctx);
74
+
75
+ expect([...ctx.emitted.keys()].sort()).toEqual([
76
+ 'cssdk/AcmeSdk.cs',
77
+ 'cssdk/Clients/BillingClient.cs',
78
+ 'cssdk/Models/Billing.cs',
79
+ 'cssdk/Runtime/Converters.cs',
80
+ 'cssdk/Runtime/SdkRuntime.cs',
81
+ ]);
82
+ expect(ctx.emitted.get('cssdk/Runtime/SdkRuntime.cs')).toContain('namespace Acme.Sdk.Runtime;');
83
+ expect(ctx.emitted.get('cssdk/Models/Billing.cs')).toContain('public sealed record Payment');
84
+ expect(ctx.emitted.get('cssdk/Clients/BillingClient.cs')).toContain('public sealed class BillingClient(SdkHttp http)');
85
+ expect(ctx.emitted.get('cssdk/AcmeSdk.cs')).toContain('public sealed class AcmeSdk : IDisposable');
86
+ expect(ctx.emitted.get('cssdk/AcmeSdk.cs')).toContain('Billing = new BillingClient(Http);');
87
+ });
88
+
89
+ it('defaults the output directory, namespace, and aggregator name', async () => {
90
+ const ctx = makeCtx();
91
+ await createCSharpSdkPlugin({}, ROOT_DIR).generateTargets!(INPUTS, ctx);
92
+ expect(ctx.emitted.has('csharp-sdk/Sdk.cs')).toBe(true);
93
+ expect(ctx.emitted.get('csharp-sdk/Sdk.cs')).toContain('namespace ContractKit.Sdk;');
94
+ });
95
+
96
+ it('skips a client file whose operations are all internal, rather than emitting an empty class', async () => {
97
+ const ctx = makeCtx();
98
+ const internalOnly = {
99
+ ...INPUTS,
100
+ opRoots: [opRoot([opRoute('/x', [opOperation('get', { sdk: 'x' })], undefined, ['internal'])], 'contracts/admin.ck')],
101
+ };
102
+ await createCSharpSdkPlugin({ baseDir: 'cssdk', namespace: 'Acme.Sdk' }, ROOT_DIR).generateTargets!(internalOnly, ctx);
103
+ expect([...ctx.emitted.keys()].some(k => k.includes('AdminClient'))).toBe(false);
104
+ });
105
+
106
+ it('emits the project file as user-owned only when scaffolding is asked for', async () => {
107
+ const off = makeCtx();
108
+ await createCSharpSdkPlugin({ baseDir: 'cssdk', sdkName: 'AcmeSdk' }, ROOT_DIR).generateTargets!(INPUTS, off);
109
+ expect(off.emitted.has('cssdk/AcmeSdk.csproj')).toBe(false);
110
+
111
+ const on = makeCtx();
112
+ await createCSharpSdkPlugin({ baseDir: 'cssdk', sdkName: 'AcmeSdk', scaffold: true }, ROOT_DIR).generateTargets!(INPUTS, on);
113
+ expect(on.emitted.get('cssdk/AcmeSdk.csproj')).toContain('<AssemblyName>AcmeSdk</AssemblyName>');
114
+ // Write-once: the CLI must be told not to overwrite a file the user has since edited.
115
+ expect(on.ifAbsent).toEqual(['cssdk/AcmeSdk.csproj']);
116
+ });
117
+
118
+ it('surfaces an invalid namespace as a build error rather than emitting broken C#', async () => {
119
+ const ctx = makeCtx();
120
+ const plugin = createCSharpSdkPlugin({ namespace: 'Acme.class.Sdk' }, ROOT_DIR);
121
+ await expect(plugin.generateTargets!(INPUTS, ctx)).rejects.toThrow(/C# keyword 'class'/);
122
+ expect(ctx.emitted.size).toBe(0);
123
+ });
124
+ });
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CSHARP_KEYWORDS,
4
+ deriveCSharpFileBase,
5
+ escapeCSharpIdentifier,
6
+ escapeXml,
7
+ quoteCSharpString,
8
+ safeMemberName,
9
+ sanitizeCSharpTypeName,
10
+ toCSharpEnumMemberName,
11
+ toCSharpParameterName,
12
+ toCSharpPropertyName,
13
+ toCSharpTypeName,
14
+ xmlDocLines,
15
+ } from '../src/naming.js';
16
+
17
+ describe('escapeCSharpIdentifier', () => {
18
+ it('prefixes a reserved keyword with @', () => {
19
+ expect(escapeCSharpIdentifier('params')).toBe('@params');
20
+ expect(escapeCSharpIdentifier('event')).toBe('@event');
21
+ expect(escapeCSharpIdentifier('string')).toBe('@string');
22
+ });
23
+
24
+ it('leaves a contextual keyword alone, since it is a legal identifier', () => {
25
+ for (const name of ['record', 'required', 'init', 'value', 'var', 'async', 'await', 'yield', 'when']) {
26
+ expect(CSHARP_KEYWORDS.has(name)).toBe(false);
27
+ expect(escapeCSharpIdentifier(name)).toBe(name);
28
+ }
29
+ });
30
+ });
31
+
32
+ describe('toCSharpPropertyName', () => {
33
+ it('PascalCases across separators and camelCase boundaries', () => {
34
+ expect(toCSharpPropertyName('x-request-id')).toBe('XRequestId');
35
+ expect(toCSharpPropertyName('created_at')).toBe('CreatedAt');
36
+ expect(toCSharpPropertyName('createdAt')).toBe('CreatedAt');
37
+ expect(toCSharpPropertyName('myHTTPClient')).toBe('MyHttpClient');
38
+ });
39
+
40
+ it('prefixes a leading digit, which C# identifiers cannot start with', () => {
41
+ expect(toCSharpPropertyName('2fa')).toBe('_2fa');
42
+ });
43
+
44
+ it('never needs keyword escaping, because every C# keyword is lowercase', () => {
45
+ expect(toCSharpPropertyName('params')).toBe('Params');
46
+ expect(toCSharpPropertyName('class')).toBe('Class');
47
+ });
48
+ });
49
+
50
+ describe('toCSharpParameterName', () => {
51
+ it('camelCases a hyphenated path placeholder', () => {
52
+ expect(toCSharpParameterName('invoice-id')).toBe('invoiceId');
53
+ expect(toCSharpParameterName('paymentId')).toBe('paymentId');
54
+ });
55
+
56
+ it('escapes a name that lands on a keyword, which camelCase regularly does', () => {
57
+ expect(toCSharpParameterName('event')).toBe('@event');
58
+ expect(toCSharpParameterName('params')).toBe('@params');
59
+ });
60
+ });
61
+
62
+ describe('safeMemberName', () => {
63
+ it('renames a member that matches its enclosing type, which C# rejects', () => {
64
+ expect(safeMemberName('Invoice', 'Invoice')).toBe('InvoiceValue');
65
+ expect(safeMemberName('Id', 'Invoice')).toBe('Id');
66
+ });
67
+
68
+ it('renames a member a record already synthesizes', () => {
69
+ expect(safeMemberName('Equals', 'Payment')).toBe('EqualsValue');
70
+ expect(safeMemberName('ToString', 'Payment')).toBe('ToStringValue');
71
+ expect(safeMemberName('EqualityContract', 'Payment')).toBe('EqualityContractValue');
72
+ });
73
+ });
74
+
75
+ describe('toCSharpTypeName and sanitizeCSharpTypeName', () => {
76
+ it('PascalCases a source name', () => {
77
+ expect(toCSharpTypeName('payment-method')).toBe('PaymentMethod');
78
+ expect(toCSharpTypeName('billing')).toBe('Billing');
79
+ });
80
+
81
+ it('sanitizes an already-composed name without re-casing it', () => {
82
+ expect(sanitizeCSharpTypeName('MV')).toBe('MV');
83
+ expect(toCSharpTypeName('MV')).toBe('Mv');
84
+ expect(sanitizeCSharpTypeName('Get-Payment200')).toBe('GetPayment200');
85
+ });
86
+ });
87
+
88
+ describe('toCSharpEnumMemberName', () => {
89
+ it('PascalCases the wire value, which travels separately', () => {
90
+ expect(toCSharpEnumMemberName('in-progress')).toBe('InProgress');
91
+ expect(toCSharpEnumMemberName('pending')).toBe('Pending');
92
+ expect(toCSharpEnumMemberName('2fa')).toBe('_2fa');
93
+ });
94
+ });
95
+
96
+ describe('deriveCSharpFileBase', () => {
97
+ it('takes the PascalCase base of a .ck path', () => {
98
+ expect(deriveCSharpFileBase('contracts/ledger.categories.ck')).toBe('LedgerCategories');
99
+ expect(deriveCSharpFileBase('contracts/billing.op.ck')).toBe('Billing');
100
+ });
101
+ });
102
+
103
+ describe('xmlDocLines', () => {
104
+ it('renders a single line inline and multiple lines as a block', () => {
105
+ expect(xmlDocLines('A payment', ' ')).toEqual([' /// <summary>A payment</summary>']);
106
+ expect(xmlDocLines('One\nTwo', '')).toEqual(['/// <summary>', '/// One', '/// Two', '/// </summary>']);
107
+ });
108
+
109
+ it('escapes XML, since a malformed doc comment is a warning and the build treats it as an error', () => {
110
+ expect(xmlDocLines('a < b && c > d', '')).toEqual(['/// <summary>a &lt; b &amp;&amp; c &gt; d</summary>']);
111
+ expect(escapeXml('<T>')).toBe('&lt;T&gt;');
112
+ });
113
+
114
+ it('takes the tag name, so a thrown status can be documented as an exception', () => {
115
+ expect(xmlDocLines('On 404.', ' ', 'remarks')).toEqual([' /// <remarks>On 404.</remarks>']);
116
+ });
117
+
118
+ it('returns nothing for empty text, so callers can splat unconditionally', () => {
119
+ expect(xmlDocLines('', '')).toEqual([]);
120
+ });
121
+ });
122
+
123
+ describe('quoteCSharpString', () => {
124
+ it('escapes the characters that would end or break the literal', () => {
125
+ expect(quoteCSharpString('a"b')).toBe('"a\\"b"');
126
+ expect(quoteCSharpString('a\\b')).toBe('"a\\\\b"');
127
+ expect(quoteCSharpString('a\nb')).toBe('"a\\nb"');
128
+ });
129
+
130
+ it('leaves $ alone, since no generated literal built from contract text is interpolated', () => {
131
+ expect(quoteCSharpString('a${b}')).toBe('"a${b}"');
132
+ });
133
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateRuntimeCs } from '../src/runtime.js';
3
+ import { generateConvertersCs } from '../src/runtime-converters.js';
4
+ import { generateSdkCs } from '../src/codegen-sdk.js';
5
+
6
+ describe('generateRuntimeCs', () => {
7
+ const out = generateRuntimeCs('Acme.Sdk');
8
+
9
+ it('declares the runtime namespace and the generated-file header', () => {
10
+ expect(out).toContain('namespace Acme.Sdk.Runtime;');
11
+ expect(out.startsWith('// <auto-generated/>')).toBe(true);
12
+ // `<auto-generated/>` turns the nullable context off, so it has to be turned back on.
13
+ expect(out).toContain('#nullable enable');
14
+ });
15
+
16
+ it('exposes the surface generated clients are written against', () => {
17
+ expect(out).toContain('public sealed class SdkHttp : IDisposable');
18
+ expect(out).toContain('public async Task<SdkResponse> ExecuteAsync(');
19
+ expect(out).toContain('public T ReadJson<T>(SdkResponse response)');
20
+ expect(out).toContain('public string RequireHeader(SdkResponse response, string name)');
21
+ expect(out).toContain('public string Path(params string[] segments)');
22
+ expect(out).toContain('public string Segment<T>(T value)');
23
+ expect(out).toContain('public IEnumerable<KeyValuePair<string, string>> Params<T>(T value)');
24
+ });
25
+
26
+ it('offers a content factory per request body kind', () => {
27
+ expect(out).toContain('public HttpContent JsonContent<T>(T value, string mediaType)');
28
+ expect(out).toContain('public HttpContent FormContent<T>(T value)');
29
+ expect(out).toContain('public HttpContent MultipartContent(IEnumerable<SdkPart> parts)');
30
+ expect(out).toContain('public HttpContent TextContent(string value, string mediaType)');
31
+ expect(out).toContain('public HttpContent BinaryContent(byte[] value, string mediaType)');
32
+ });
33
+
34
+ it('derives the error from HttpRequestException, so callers of the base type still catch it', () => {
35
+ expect(out).toContain('public class SdkException : HttpRequestException');
36
+ expect(out).toContain('public T? TryReadBody<T>(JsonSerializerOptions? options = null)');
37
+ });
38
+
39
+ it('throws only for a status outside 2xx that the operation did not declare', () => {
40
+ expect(out).toContain('if ((status < 200 || status > 299) && (expectStatuses is null || !expectStatuses.Contains(status)))');
41
+ });
42
+
43
+ it('reads the body eagerly, which is what lets a method switch on status and then decode', () => {
44
+ expect(out).toContain('HttpCompletionOption.ResponseContentRead');
45
+ expect(out).toContain('ReadAsByteArrayAsync');
46
+ });
47
+
48
+ it('strips content-type parameters, so a charset does not defeat a mime comparison', () => {
49
+ expect(out).toContain('Message.Content.Headers.ContentType?.MediaType');
50
+ });
51
+
52
+ it('disposes a client it created and leaves a caller-supplied one alone', () => {
53
+ expect(out).toContain('_ownsClient = options.HttpClient is null;');
54
+ expect(out).toContain('if (_ownsClient) Client.Dispose();');
55
+ });
56
+ });
57
+
58
+ describe('generateConvertersCs', () => {
59
+ const out = generateConvertersCs('Acme.Sdk');
60
+
61
+ it('registers exactly the scalar converters an attribute cannot carry', () => {
62
+ expect(out).toContain('public static readonly JsonSerializerOptions Options = CreateOptions();');
63
+ expect(out).toContain('options.Converters.Add(new BigIntegerConverter());');
64
+ expect(out).toContain('options.Converters.Add(new DecimalStringConverter());');
65
+ expect(out).toContain('options.Converters.Add(new IsoTimeSpanConverter());');
66
+ });
67
+
68
+ it('writes a decimal as a quoted string and refuses to read an unquoted number', () => {
69
+ expect(out).toContain('public sealed class DecimalStringConverter : JsonConverter<decimal>');
70
+ expect(out).toContain('throw new JsonException($"Expected a quoted decimal string, got {reader.TokenType}.");');
71
+ expect(out).toContain('writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));');
72
+ });
73
+
74
+ it('reads the bigint forms every other ContractKit SDK writes', () => {
75
+ expect(out).toContain("BigInteger.Parse(text.TrimEnd('n'), NumberStyles.Integer, CultureInfo.InvariantCulture)");
76
+ expect(out).toContain('reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan');
77
+ });
78
+
79
+ it('carries a duration as ISO 8601 rather than the BCL default', () => {
80
+ expect(out).toContain('XmlConvert.ToTimeSpan(text)');
81
+ expect(out).toContain('writer.WriteStringValue(XmlConvert.ToString(value));');
82
+ });
83
+ });
84
+
85
+ describe('generateSdkCs', () => {
86
+ it('shares one SdkHttp across every client, so the SDK keeps one connection pool', () => {
87
+ const out = generateSdkCs('Acme.Sdk', 'AcmeSdk', [
88
+ { className: 'BillingClient', propertyName: 'Billing' },
89
+ { className: 'InvoicesClient', propertyName: 'Invoices' },
90
+ ]);
91
+ expect(out).toContain('public sealed class AcmeSdk : IDisposable');
92
+ expect(out).toContain('Http = new SdkHttp(options);');
93
+ expect(out).toContain('Billing = new BillingClient(Http);');
94
+ expect(out).toContain('Invoices = new InvoicesClient(Http);');
95
+ expect(out).toContain('public BillingClient Billing { get; }');
96
+ expect(out).toContain('using Acme.Sdk.Clients;');
97
+ });
98
+
99
+ it('compiles with no clients at all, and then imports no client namespace', () => {
100
+ const out = generateSdkCs('Acme.Sdk', 'Sdk', []);
101
+ expect(out).toContain('public sealed class Sdk : IDisposable');
102
+ expect(out).not.toContain('using Acme.Sdk.Clients;');
103
+ });
104
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { SCAFFOLD_VERSIONS, generateCsproj } from '../src/scaffold.js';
3
+
4
+ describe('generateCsproj', () => {
5
+ it('targets the pinned framework', () => {
6
+ expect(generateCsproj('Acme.Sdk', 'AcmeSdk')).toContain(`<TargetFramework>${SCAFFOLD_VERSIONS.targetFramework}</TargetFramework>`);
7
+ });
8
+
9
+ it('takes the namespace and assembly name from config', () => {
10
+ const out = generateCsproj('Acme.Sdk', 'AcmeSdk');
11
+ expect(out).toContain('<RootNamespace>Acme.Sdk</RootNamespace>');
12
+ expect(out).toContain('<AssemblyName>AcmeSdk</AssemblyName>');
13
+ });
14
+
15
+ it('enables nullable and disables implicit usings, which generated files declare for themselves', () => {
16
+ const out = generateCsproj('Acme.Sdk', 'AcmeSdk');
17
+ expect(out).toContain('<Nullable>enable</Nullable>');
18
+ expect(out).toContain('<ImplicitUsings>disable</ImplicitUsings>');
19
+ });
20
+
21
+ it('declares no package reference, so the SDK restores with no NuGet feed reachable', () => {
22
+ expect(generateCsproj('Acme.Sdk', 'AcmeSdk')).not.toContain('PackageReference');
23
+ });
24
+
25
+ it('says it is never regenerated, since the file is the user’s after the first run', () => {
26
+ expect(generateCsproj('Acme.Sdk', 'AcmeSdk')).toContain('never regenerated');
27
+ });
28
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@repo/config-typescript/base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }
@@ -0,0 +1,14 @@
1
+ import { defineProject } from 'vitest/config';
2
+ import swc from 'unplugin-swc';
3
+
4
+ export default defineProject({
5
+ test: {
6
+ globals: true,
7
+ include: ['./tests/**/*.test.ts'],
8
+ environment: 'node',
9
+ testTimeout: 50000,
10
+ hookTimeout: 30000,
11
+ fileParallelism: true,
12
+ },
13
+ plugins: [swc.vite()],
14
+ });