@contractkit/plugin-typescript 0.33.3 → 0.34.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 (40) hide show
  1. package/.turbo/turbo-build$colon$ci.log +4 -4
  2. package/.turbo/turbo-test$colon$ci.log +26 -25
  3. package/CHANGELOG.md +285 -0
  4. package/dist/codegen-contract.d.ts +22 -6
  5. package/dist/codegen-contract.d.ts.map +1 -1
  6. package/dist/codegen-mcp.d.ts.map +1 -1
  7. package/dist/codegen-operation.d.ts.map +1 -1
  8. package/dist/codegen-plain-types.d.ts.map +1 -1
  9. package/dist/codegen-revive.d.ts +38 -4
  10. package/dist/codegen-revive.d.ts.map +1 -1
  11. package/dist/codegen-sdk.d.ts +2 -0
  12. package/dist/codegen-sdk.d.ts.map +1 -1
  13. package/dist/index.d.ts +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +421 -202
  16. package/dist/index.js.map +1 -1
  17. package/dist/path-utils.d.ts +9 -0
  18. package/dist/path-utils.d.ts.map +1 -1
  19. package/dist/ts-render.d.ts +14 -0
  20. package/dist/ts-render.d.ts.map +1 -1
  21. package/llms.txt +14 -0
  22. package/package.json +3 -2
  23. package/src/codegen-contract.ts +84 -68
  24. package/src/codegen-mcp.ts +22 -11
  25. package/src/codegen-operation.ts +32 -19
  26. package/src/codegen-plain-types.ts +15 -8
  27. package/src/codegen-revive.ts +140 -20
  28. package/src/codegen-sdk.ts +322 -63
  29. package/src/index.ts +46 -4
  30. package/src/path-utils.ts +10 -0
  31. package/src/ts-render.ts +26 -0
  32. package/tests/codegen-contract.test.ts +87 -32
  33. package/tests/codegen-mcp.test.ts +23 -1
  34. package/tests/codegen-operation.test.ts +68 -8
  35. package/tests/codegen-plain-types.test.ts +13 -5
  36. package/tests/codegen-sdk.test.ts +265 -10
  37. package/tests/codegen-server.test.ts +43 -1
  38. package/tests/helpers.ts +7 -2
  39. package/tests/pipeline.test.ts +51 -4
  40. package/tests/ts-render.test.ts +29 -0
@@ -8,13 +8,23 @@ import type {
8
8
  OpResponseHeaderNode,
9
9
  ContractTypeNode,
10
10
  ModelNode,
11
+ OpParamNode,
11
12
  ParamSource,
13
+ ScalarTypeNode,
12
14
  } from '@contractkit/core';
13
- import { resolveModifiers, isJsonMime, classifyContentType, observableResponses, thrownResponses } from '@contractkit/core';
14
- import { renderInputTsType, renderOutputTsType, quoteKey, headerNameToProperty, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
15
+ import { resolveModifiers, isJsonMime, classifyContentType, observableResponses, thrownResponses, PATH_PARAM_RE_G, toIdentifier } from '@contractkit/core';
16
+ import {
17
+ renderInputTsType,
18
+ renderOutputTsType,
19
+ quoteKey,
20
+ headerNameToProperty,
21
+ escapeJsDocLines,
22
+ sourceLink,
23
+ JSON_VALUE_TYPE_DECL,
24
+ } from './ts-render.js';
15
25
  import { pascalToDotCase, typeNeedsScalar } from './codegen-contract.js';
16
26
  import { bodyTypesStructurallyEqual } from './codegen-operation.js';
17
- import { reviveFnName, renderInlineReviver, typeReachesDecimal, DECIMAL_COERCE_DECL } from './codegen-revive.js';
27
+ import { reviveFnName, renderInlineReviver, typeReachesDecimal, coerceDeclsFor, coerceLuxonImports } from './codegen-revive.js';
18
28
  import { DECIMAL_IMPORT, DECIMAL_CONFIG_LINE } from './decimal-runtime.js';
19
29
  import { basename, dirname, relative } from 'path';
20
30
 
@@ -86,6 +96,8 @@ export interface SdkCodegenOptions {
86
96
  modelsWithOutput?: Set<string>;
87
97
  /** Model names carrying a `decimal`, whose response bodies need rehydrating client-side. */
88
98
  modelsWithDecimal?: Set<string>;
99
+ /** Model names carrying a `bigint`, directly or transitively. Selects the JSON reviver. */
100
+ modelsWithBigInt?: Set<string>;
89
101
  /** Every model in scope, for resolving discriminated-union members inside an inline reviver. */
90
102
  modelMap?: Map<string, ModelNode>;
91
103
  /**
@@ -139,19 +151,31 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
139
151
  const mods = resolveModifiers(route, op);
140
152
  if (!includeInternal && mods.includes('internal')) continue;
141
153
  classBody.push('');
142
- if (mods.includes('deprecated')) classBody.push(' /** @deprecated */');
143
154
  classBody.push(...generateMethod(route, op, root.file, options, inlineRevivers));
144
155
  }
145
156
  }
146
157
 
147
158
  const inlineReviverDecls = [...inlineRevivers.values()].flat();
148
159
  const decimalPrelude = decimalPreludeFor(inlineReviverDecls);
160
+ // Computed here rather than at its splice point below, so the import filter can see the
161
+ // aliases: an error-body alias is a genuine reference to a model type.
162
+ const errorAliases = generateErrorBodyAliases(root, options);
149
163
 
150
164
  // Type-only imports, plus the model revivers the methods actually call.
151
- if (types.length > 0) {
152
- lines.push(...generateTypeImports(types, root.file, options, usedRevivers(classBody)));
165
+ //
166
+ // `collectTypes` walks the AST and so reports every model a request body names, including one
167
+ // carried by a `multipart/form-data` body — which `buildMethodParams` types as `FormData`, so
168
+ // the model is never mentioned in the emitted code and its import is unused. Rather than
169
+ // special-casing multipart in `collectTypes`, which validating multipart bodies would later
170
+ // have to undo, keep only the types the emitted text actually names. This is the same
171
+ // text-derived idiom the reviver imports already use just below.
172
+ const referenced = referencedTypes(types, [...classBody, ...errorAliases, ...inlineReviverDecls]);
173
+ if (referenced.length > 0) {
174
+ lines.push(...generateTypeImports(referenced, root.file, options, usedRevivers(classBody)));
153
175
  }
154
176
  lines.push(...decimalPrelude.imports);
177
+ const headerLuxon = headerLuxonImport(classBody, decimalPrelude.imports);
178
+ if (headerLuxon) lines.push(headerLuxon);
155
179
 
156
180
  // SdkOptions import (from shared file) or inline fallback
157
181
  if (options.sdkOptionsPath && options.outPath) {
@@ -162,7 +186,11 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
162
186
  lines.push(`import type { SdkFetch${jsonImport} } from '${rel}';`);
163
187
  const valueImports: string[] = [];
164
188
  if (sdkNeedsBigIntReplacer(root, includeInternal)) valueImports.push('bigIntReplacer');
165
- if (sdkNeedsBigIntReviver(root, includeInternal)) valueImports.push('parseJson');
189
+ // Aliased rather than emitted at each call site: the method bodies are identical either
190
+ // way, and the import line is where the choice is legible.
191
+ if (sdkParsesJsonResponse(root, includeInternal)) {
192
+ valueImports.push(sdkResponsesUseBigInt(root, options, includeInternal) ? 'parseJsonWithBigInt as parseJson' : 'parseJson');
193
+ }
166
194
  if (sdkNeedsQueryString(root, includeInternal)) valueImports.push('buildQueryString');
167
195
  if (sdkNeedsReadContentType(root, includeInternal)) valueImports.push('readContentType');
168
196
  if (valueImports.length > 0) {
@@ -239,7 +267,11 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
239
267
  lines.push('}');
240
268
  lines.push('');
241
269
  lines.push('export async function parseJson<T>(res: Response): Promise<T> {');
242
- lines.push(' return JSON.parse(await res.text(), bigIntReviver) as T;');
270
+ lines.push(
271
+ sdkResponsesUseBigInt(root, options, includeInternal)
272
+ ? ' return JSON.parse(await res.text(), bigIntReviver) as T;'
273
+ : ' return JSON.parse(await res.text()) as T;',
274
+ );
243
275
  lines.push('}');
244
276
  }
245
277
 
@@ -249,7 +281,6 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
249
281
 
250
282
  lines.push('');
251
283
 
252
- const errorAliases = generateErrorBodyAliases(root, options);
253
284
  if (errorAliases.length > 0) {
254
285
  lines.push(...errorAliases);
255
286
  lines.push('');
@@ -268,8 +299,7 @@ export function generateSdk(root: OpRootNode, options: SdkCodegenOptions = {}):
268
299
 
269
300
  // Client class
270
301
  lines.push('/**');
271
- const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
272
- lines.push(` * generated from [${basename(root.file)}](file://./${relFile})`);
302
+ lines.push(` * generated from ${sourceLink(basename(root.file), options.outPath, root.file)}`);
273
303
  lines.push(' */');
274
304
  lines.push(`export class ${clientClassName} {`);
275
305
  lines.push(' constructor(private fetch: SdkFetch) {}');
@@ -307,7 +337,6 @@ export function generateClientMethods(
307
337
  const mods = resolveModifiers(route, op);
308
338
  if (!includeInternal && mods.includes('internal')) continue;
309
339
  lines.push('');
310
- if (mods.includes('deprecated')) lines.push(' /** @deprecated */');
311
340
  lines.push(...generateMethod(route, op, root.file, options, inlineRevivers));
312
341
  methodNames.push(deriveMethodName(op, route));
313
342
  }
@@ -328,11 +357,51 @@ export function generateClientMethods(
328
357
  * config, since nothing else in the file necessarily pulls them in.
329
358
  */
330
359
  function decimalPreludeFor(declLines: string[]): { imports: string[]; decls: string[] } {
331
- if (!declLines.some(l => l.includes('__dec('))) return { imports: [], decls: [] };
332
- return { imports: [DECIMAL_IMPORT], decls: [DECIMAL_CONFIG_LINE, '', ...DECIMAL_COERCE_DECL] };
360
+ const decls = coerceDeclsFor(declLines);
361
+ if (decls.length === 0) return { imports: [], decls: [] };
362
+
363
+ const imports: string[] = [];
364
+ const preamble: string[] = [];
365
+ if (declLines.some(l => l.includes('__dec('))) {
366
+ imports.push(DECIMAL_IMPORT);
367
+ // The global config keeps decimals out of exponential notation; only decimal.js needs it.
368
+ preamble.push(DECIMAL_CONFIG_LINE, '');
369
+ }
370
+ const luxon = coerceLuxonImports(declLines);
371
+ if (luxon.length > 0) imports.push(`import { ${luxon.join(', ')} } from 'luxon';`);
372
+
373
+ return { imports, decls: [...preamble, ...decls] };
374
+ }
375
+
376
+ /**
377
+ * The `luxon` import a client needs for its response-header coercions, or undefined.
378
+ *
379
+ * Header conversions call `DateTime.fromISO` and friends directly rather than through a reviver,
380
+ * so they are invisible to `decimalPreludeFor`. Decided from the emitted method bodies, like every
381
+ * other import in these files, and skipped when the reviver prelude already brought luxon in.
382
+ */
383
+ function headerLuxonImport(methodLines: string[], existingImports: string[]): string | undefined {
384
+ if (existingImports.some(l => l.includes("from 'luxon'"))) return undefined;
385
+ const needed = ['DateTime', 'Duration'].filter(c => methodLines.some(l => l.includes(`${c}.from`)));
386
+ return needed.length > 0 ? `import { ${needed.join(', ')} } from 'luxon';` : undefined;
333
387
  }
334
388
 
335
389
  /** Model reviver names referenced by generated method bodies. `__revive…` wrappers are local. */
390
+ /**
391
+ * Narrow a collected type list to the names the emitted code actually mentions.
392
+ *
393
+ * Boundaries are spelled out rather than using `\b`, so a model name containing `-`, `.` or `$`
394
+ * is matched correctly. A name appearing only inside a doc string counts as a reference and the
395
+ * import is kept: an unnecessary import is untidy, a missing one does not compile.
396
+ */
397
+ function referencedTypes(types: string[], emitted: string[]): string[] {
398
+ const haystack = emitted.join('\n');
399
+ return types.filter(t => {
400
+ const escaped = t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
401
+ return new RegExp(`(?<![A-Za-z0-9_$])${escaped}(?![A-Za-z0-9_$])`).test(haystack);
402
+ });
403
+ }
404
+
336
405
  function usedRevivers(lines: string[]): string[] {
337
406
  const found = new Set<string>();
338
407
  for (const m of lines.join('\n').matchAll(/\brevive[A-Z]\w*/g)) found.add(m[0]);
@@ -360,6 +429,8 @@ function generateMethod(
360
429
  : undefined;
361
430
  const lines: string[] = [];
362
431
  const methodName = deriveMethodName(op, route);
432
+ /** Identifies the operation in a codegen rejection, which the CLI scopes to this plugin. */
433
+ const where = `${op.method.toUpperCase()} ${route.path}`;
363
434
  const mRevive = hint(revive, `${methodName.charAt(0).toUpperCase()}${methodName.slice(1)}`);
364
435
  const httpMethod = op.method.toUpperCase();
365
436
  const { modelsWithInput, modelsWithOutput } = options;
@@ -408,11 +479,16 @@ function generateMethod(
408
479
  // JSDoc
409
480
  const desc = op.description ?? route.description;
410
481
  const errorBodyName = thrown.some(r => r.bodies.length > 0) ? errorBodyTypeName(route, op) : undefined;
411
- if (op.name || desc || errorBodyName) {
482
+ // `@deprecated` belongs in this block rather than in one of its own: TypeScript honours only
483
+ // the JSDoc comment adjacent to the declaration, so a separate `/** @deprecated */` above a
484
+ // description block is dropped by editors entirely. Tag order mirrors the router's.
485
+ const deprecated = resolveModifiers(route, op).includes('deprecated');
486
+ if (op.name || desc || errorBodyName || deprecated) {
412
487
  const tags: string[] = [];
413
488
  if (op.name) tags.push(`@name ${op.name}`);
414
489
  if (desc) tags.push(`@description ${desc}`);
415
490
  if (errorBodyName) tags.push(`@throws {SdkError<${errorBodyName}>} on ${thrown.map(r => r.statusCode).join(', ')}`);
491
+ if (deprecated) tags.push('@deprecated');
416
492
  const contentLines = tags.flatMap(t => escapeJsDocLines(t));
417
493
  if (contentLines.length === 1) {
418
494
  lines.push(` /** ${contentLines[0]} */`);
@@ -530,15 +606,15 @@ function generateMethod(
530
606
  lines.push(` switch (result.status) {`);
531
607
  for (const resp of rest) {
532
608
  lines.push(` case ${resp.statusCode}:`);
533
- lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true, mRevive));
609
+ lines.push(...sdkReturnLines(resp, modelsWithOutput, ' ', true, mRevive, where));
534
610
  }
535
611
  lines.push(` default:`);
536
- lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true, mRevive));
612
+ lines.push(...sdkReturnLines(fallback!, modelsWithOutput, ' ', true, mRevive, where));
537
613
  lines.push(` }`);
538
614
  } else if (primaryBodies.length > 1) {
539
- lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false, mRevive));
615
+ lines.push(...sdkReturnLines(primaryResponse!, modelsWithOutput, ' ', false, mRevive, where));
540
616
  } else if (hasRespHeaders) {
541
- const headerEntries = sdkHeaderEntries(respHeaders);
617
+ const headerEntries = sdkHeaderEntries(respHeaders, where);
542
618
  if (isVoid) {
543
619
  lines.push(` return { headers: { ${headerEntries} } };`);
544
620
  } else {
@@ -599,6 +675,8 @@ function hint(revive: ReviveContext | undefined, segment: string | number): Revi
599
675
  /** What `sdkReadExpr` needs to decide whether a body has to be revived, and with which function. */
600
676
  interface ReviveContext {
601
677
  modelsWithDecimal: Set<string>;
678
+ /** Which scalars need rehydrating; see `ReviveCodegenOptions.revivableScalars`. */
679
+ revivableScalars?: ReadonlySet<ScalarTypeNode['name']>;
602
680
  modelsWithOutput?: Set<string>;
603
681
  /** Inline reviver declarations accumulated for the current file, keyed by function name. */
604
682
  inlineRevivers: Map<string, string[]>;
@@ -607,10 +685,15 @@ interface ReviveContext {
607
685
  nameHint: string;
608
686
  }
609
687
 
610
- /** The reviver to apply to a response body, or `null` when the body holds no decimal. */
688
+ /** The reviver to apply to a response body, or `null` when the body holds nothing to rehydrate. */
611
689
  function reviveExprFor(bodyType: ContractTypeNode, ctx: ReviveContext | undefined): { name: string; kind: 'value' | 'array' } | null {
612
690
  if (!ctx || ctx.modelsWithDecimal.size === 0) return null;
613
- const opts = { modelsWithDecimal: ctx.modelsWithDecimal, modelsWithOutput: ctx.modelsWithOutput, modelMap: ctx.modelMap };
691
+ const opts = {
692
+ modelsWithDecimal: ctx.modelsWithDecimal,
693
+ revivableScalars: ctx.revivableScalars,
694
+ modelsWithOutput: ctx.modelsWithOutput,
695
+ modelMap: ctx.modelMap,
696
+ };
614
697
  if (!typeReachesDecimal(bodyType, opts)) return null;
615
698
 
616
699
  const refName = (t: ContractTypeNode): string | null => (t.kind === 'ref' ? t.name : t.kind === 'lazy' ? refName(t.inner) : null);
@@ -642,8 +725,79 @@ function renderSdkHeadersShape(headers: OpResponseHeaderNode[], modelsWithOutput
642
725
  return `{ ${fields.join('; ')} }`;
643
726
  }
644
727
 
645
- function sdkHeaderEntries(headers: OpResponseHeaderNode[]): string {
646
- return headers.map(h => `${quoteKey(headerNameToProperty(h.name))}: result.headers.get('${h.name}') ?? undefined`).join(', ');
728
+ /**
729
+ * The value expression for one response header, matching the type `renderSdkHeadersShape` gives it.
730
+ *
731
+ * Header values always arrive as strings, but the shape is typed from the contract — so a header
732
+ * declared `int` was typed `number` and assigned `string | undefined`, and a required one was
733
+ * typed `T` and assigned `T | undefined`. Both are `TS2322`, in opposite directions.
734
+ *
735
+ * `null` is what `Headers.get` returns for an absent header. A required header is asserted rather
736
+ * than defaulted, because the contract says the service always sends it; an optional one maps the
737
+ * absence onto `undefined`, which is what its `?` in the shape means.
738
+ *
739
+ * Anything that is not one of the scalars below cannot be read from a header at all, and is
740
+ * rejected here rather than emitted as code that does not compile. The list is shared with the
741
+ * Python SDK, except that temporals need coercion there — `renderPyType` maps them to `datetime`
742
+ * objects, while `renderOutputTsType` maps them to `string`.
743
+ */
744
+ function sdkHeaderEntry(h: OpResponseHeaderNode, where: string): string {
745
+ const raw = `result.headers.get('${h.name}')`;
746
+ const key = quoteKey(headerNameToProperty(h.name));
747
+ const scalar = h.type.kind === 'scalar' ? h.type.name : undefined;
748
+
749
+ /**
750
+ * Wrap a conversion so an optional header maps an absent value onto `undefined`.
751
+ *
752
+ * The value is asserted non-null in both branches. TypeScript does not carry a narrowing from
753
+ * `get(x) === null` across a *second* `get(x)` call, and the conversions below take a `string`
754
+ * — so without the assertion the optional form is a TS2345 even though the ternary has
755
+ * already excluded null.
756
+ */
757
+ const convert = (expr: (v: string) => string) =>
758
+ `${key}: ${h.optional ? `${raw} === null ? undefined : ${expr(`${raw}!`)}` : expr(`${raw}!`)}`;
759
+
760
+ switch (scalar) {
761
+ case 'string':
762
+ case 'email':
763
+ case 'url':
764
+ case 'uuid':
765
+ case 'interval':
766
+ case 'unknown':
767
+ return `${key}: ${raw}${h.optional ? ' ?? undefined' : '!'}`;
768
+ // Temporals are Luxon objects since the SDK started reviving them, so the shape
769
+ // `renderOutputTsType` produces says `DateTime` and the raw string no longer satisfies it.
770
+ // The format for date and time comes from the contract, as it does in the reviver.
771
+ case 'datetime':
772
+ return convert(v => `DateTime.fromISO(${v})`);
773
+ case 'duration':
774
+ return convert(v => `Duration.fromISO(${v})`);
775
+ case 'date':
776
+ return convert(v => `DateTime.fromFormat(${v}, '${(h.type.kind === 'scalar' && h.type.format) || 'yyyy-MM-dd'}')`);
777
+ case 'time':
778
+ return convert(v => `DateTime.fromFormat(${v}, '${(h.type.kind === 'scalar' && h.type.format) || 'HH:mm:ss'}')`);
779
+ case 'number':
780
+ case 'int':
781
+ return `${key}: ${h.optional ? `${raw} === null ? undefined : Number(${raw})` : `Number(${raw})`}`;
782
+ case 'boolean':
783
+ return `${key}: ${h.optional ? `${raw} === null ? undefined : ${raw} === 'true'` : `${raw} === 'true'`}`;
784
+ case 'bigint':
785
+ return convert(v => `BigInt(${v})`);
786
+ default:
787
+ throw new Error(
788
+ `Response header '${h.name}' on ${where} is declared as ${describeHeaderType(h.type)}, which cannot be read from an HTTP header. ` +
789
+ `Header values arrive as strings — declare it as string, email, url, uuid, a date/time type, int, number, boolean or bigint.`,
790
+ );
791
+ }
792
+ }
793
+
794
+ /** A short, contract-facing description of a header type, for the rejection message above. */
795
+ function describeHeaderType(type: ContractTypeNode): string {
796
+ return type.kind === 'scalar' ? `the '${type.name}' scalar` : type.kind === 'ref' ? `the model '${type.name}'` : `${type.kind === 'array' ? 'an' : 'a'} ${type.kind}`;
797
+ }
798
+
799
+ function sdkHeaderEntries(headers: OpResponseHeaderNode[], where: string): string {
800
+ return headers.map(h => sdkHeaderEntry(h, where)).join(', ');
647
801
  }
648
802
 
649
803
  /**
@@ -677,12 +831,13 @@ function sdkReturnLines(
677
831
  modelsWithOutput: Set<string> | undefined,
678
832
  indent: string,
679
833
  includeStatus: boolean,
680
- revive?: ReviveContext,
834
+ revive: ReviveContext | undefined,
835
+ where: string,
681
836
  ): string[] {
682
837
  const bodies = resp.bodies;
683
838
  const headers = resp.headers ?? [];
684
839
  const leading = includeStatus ? [`status: ${resp.statusCode}`] : [];
685
- const trailing = headers.length > 0 ? [`headers: { ${sdkHeaderEntries(headers)} }`] : [];
840
+ const trailing = headers.length > 0 ? [`headers: { ${sdkHeaderEntries(headers, where)} }`] : [];
686
841
 
687
842
  if (bodies.length === 0) {
688
843
  return [`${indent}return { ${[...leading, ...trailing].join(', ')} };`];
@@ -769,10 +924,26 @@ export function generateErrorBodyAliases(root: OpRootNode, options: SdkCodegenOp
769
924
 
770
925
  // ─── URL building ─────────────────────────────────────────────────────────
771
926
 
772
- function buildUrlExpression(path: string, _?: ParamSource): string {
773
- // Replace {paramName} with ${encodeURIComponent(paramName)}
774
- return path.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_match, name) => {
775
- return `\${encodeURIComponent(${name})}`;
927
+ /**
928
+ * Render a route path as the template-literal body that produces the request URL.
929
+ *
930
+ * `params` says where each value lives. `buildMethodParams` spreads a `params {}` block across
931
+ * the signature, so those interpolate by bare name; but for `params: SomeModel` it emits a single
932
+ * argument called `params`, and interpolating the bare name then refers to nothing that exists.
933
+ *
934
+ * The placeholder pattern matches what the `.ck` grammar allows rather than just
935
+ * `[a-zA-Z_]\w*`, so a hyphenated `{payment-id}` is interpolated instead of being left in the URL
936
+ * verbatim. Such a name is not a valid property accessor either, hence the bracket form.
937
+ */
938
+ function buildUrlExpression(path: string, params?: ParamSource): string {
939
+ return path.replace(PATH_PARAM_RE_G, (_m, name: string) => {
940
+ // Spread across the signature: interpolate the identifier `buildMethodParams` bound.
941
+ if (!params || params.kind === 'params') return `\${encodeURIComponent(${toIdentifier(name)})}`;
942
+ // Behind one `params` argument: read the model's field, which keeps its declared spelling
943
+ // and so may need bracket access. `String(...)` because that field may be typed something
944
+ // `encodeURIComponent` does not accept.
945
+ const access = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? `params.${name}` : `params[${JSON.stringify(name)}]`;
946
+ return `\${encodeURIComponent(String(${access}))}`;
776
947
  });
777
948
  }
778
949
 
@@ -791,7 +962,7 @@ function buildMethodParams(route: OpRouteNode, op: OpOperationNode, modelsWithIn
791
962
  if (route.params) {
792
963
  if (route.params.kind === 'params') {
793
964
  for (const p of route.params.nodes) {
794
- params.push({ name: p.name, type: renderInputTsType(p.type, modelsWithInput), optional: false });
965
+ params.push({ name: toIdentifier(p.name), type: renderInputTsType(p.type, modelsWithInput), optional: false });
795
966
  }
796
967
  } else if (route.params.kind === 'ref') {
797
968
  const typeName = modelsWithInput?.has(route.params.name) ? `${route.params.name}Input` : route.params.name;
@@ -835,33 +1006,55 @@ function buildMethodParams(route: OpRouteNode, op: OpOperationNode, modelsWithIn
835
1006
  params.push({ name: 'options', type: `{ contentType: ${ctUnion} }`, optional: false });
836
1007
  }
837
1008
 
838
- // Query (request-side — use Input variants)
839
- if (op.query) {
840
- if (op.query.kind === 'params') {
841
- const fields = op.query.nodes.map(p => `${quoteKey(p.name)}?: ${renderInputTsType(p.type, modelsWithInput)}`).join('; ');
842
- params.push({ name: 'query', type: `{ ${fields} }`, optional: true });
843
- } else if (op.query.kind === 'ref') {
844
- const typeName = modelsWithInput?.has(op.query.name) ? `${op.query.name}Input` : op.query.name;
845
- params.push({ name: 'query', type: typeName, optional: true });
846
- } else {
847
- params.push({ name: 'query', type: renderInputTsType(op.query.node, modelsWithInput), optional: true });
848
- }
849
- }
1009
+ // Query and custom headers (request-side — use Input variants)
1010
+ if (op.query) params.push(inlineArgParam('query', op.query, modelsWithInput));
1011
+ if (op.headers) params.push(inlineArgParam('customHeaders', op.headers, modelsWithInput));
850
1012
 
851
- // Headers (request-side — use Input variants)
852
- if (op.headers) {
853
- if (op.headers.kind === 'params') {
854
- const fields = op.headers.nodes.map(p => `${quoteKey(p.name)}?: ${renderInputTsType(p.type, modelsWithInput)}`).join('; ');
855
- params.push({ name: 'customHeaders', type: `{ ${fields} }`, optional: true });
856
- } else if (op.headers.kind === 'ref') {
857
- const typeName = modelsWithInput?.has(op.headers.name) ? `${op.headers.name}Input` : op.headers.name;
858
- params.push({ name: 'customHeaders', type: typeName, optional: true });
859
- } else {
860
- params.push({ name: 'customHeaders', type: renderInputTsType(op.headers.node, modelsWithInput), optional: true });
861
- }
1013
+ return normaliseOptionalOrder(params);
1014
+ }
1015
+
1016
+ /**
1017
+ * One whole-object argument built from a `query:` or `headers:` source.
1018
+ *
1019
+ * A field is optional only when the contract says so — with `?`, or by carrying a default, which
1020
+ * the caller may equally omit. The argument itself is optional only when every field is, since a
1021
+ * caller cannot omit an object that must supply something. Both were previously hardcoded
1022
+ * optional, which was wrong in both directions at once: it let a caller omit a value the router
1023
+ * demanded, and typed a required field as one you could leave out.
1024
+ *
1025
+ * A `ref` or a whole type node stays optional. Deciding needs the model's own fields, which
1026
+ * `buildMethodParams` has no access to.
1027
+ */
1028
+ function inlineArgParam(name: string, source: ParamSource, modelsWithInput?: Set<string>): MethodParam {
1029
+ if (source.kind === 'params') {
1030
+ const isOptional = (p: OpParamNode) => Boolean(p.optional) || p.default !== undefined;
1031
+ const fields = source.nodes
1032
+ .map(p => `${quoteKey(p.name)}${isOptional(p) ? '?' : ''}: ${renderInputTsType(p.type, modelsWithInput)}`)
1033
+ .join('; ');
1034
+ return { name, type: `{ ${fields} }`, optional: source.nodes.every(isOptional) };
1035
+ }
1036
+ if (source.kind === 'ref') {
1037
+ const typeName = modelsWithInput?.has(source.name) ? `${source.name}Input` : source.name;
1038
+ return { name, type: typeName, optional: true };
862
1039
  }
1040
+ return { name, type: renderInputTsType(source.node, modelsWithInput), optional: true };
1041
+ }
863
1042
 
864
- return params;
1043
+ /**
1044
+ * Clear `optional` on every parameter before the last required one.
1045
+ *
1046
+ * Parameter order is path, then body, then query, then customHeaders. An optional parameter
1047
+ * cannot precede a required one in TypeScript (`TS1016`), so an operation with an all-optional
1048
+ * `query:` and an all-required `headers:` would emit `async m(query?: Q, customHeaders: H)` and
1049
+ * fail to compile. Widening the earlier argument to required is the only option that keeps the
1050
+ * positional order the call sites depend on.
1051
+ *
1052
+ * This could not arise while every query and header argument was hardcoded optional; it becomes
1053
+ * reachable the moment optionality is read from the contract.
1054
+ */
1055
+ function normaliseOptionalOrder(params: MethodParam[]): MethodParam[] {
1056
+ const lastRequired = params.reduce((last, p, i) => (p.optional ? last : i), -1);
1057
+ return params.map((p, i) => (i < lastRequired ? { ...p, optional: false } : p));
865
1058
  }
866
1059
 
867
1060
  // ─── Method name inference ────────────────────────────────────────────────
@@ -1118,8 +1311,8 @@ function sdkNeedsBigIntReplacer(root: OpRootNode, includeInternal = false): bool
1118
1311
  return false;
1119
1312
  }
1120
1313
 
1121
- /** True if any public operation parses a JSON response body (uses bigIntReviver). */
1122
- function sdkNeedsBigIntReviver(root: OpRootNode, includeInternal = false): boolean {
1314
+ /** True if any public operation parses a JSON response body, and so calls `parseJson`. */
1315
+ function sdkParsesJsonResponse(root: OpRootNode, includeInternal = false): boolean {
1123
1316
  for (const route of root.routes) {
1124
1317
  for (const op of route.operations) {
1125
1318
  if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
@@ -1132,6 +1325,55 @@ function sdkNeedsBigIntReviver(root: OpRootNode, includeInternal = false): boole
1132
1325
  return false;
1133
1326
  }
1134
1327
 
1328
+ /**
1329
+ * True if a public operation's JSON *response* carries a `bigint`, so the client needs the
1330
+ * `123n` reviver when parsing one.
1331
+ *
1332
+ * Response bodies only: the request side is handled by `bigIntReplacer` on the way out. This is
1333
+ * the test the reviver always needed and never made — it was applied to every client that read
1334
+ * any JSON at all, so a contract with no bigint anywhere still had a legitimate string like
1335
+ * "123n" silently converted.
1336
+ */
1337
+ function sdkResponsesUseBigInt(root: OpRootNode, options: SdkCodegenOptions, includeInternal = false): boolean {
1338
+ const tainted = options.modelsWithBigInt;
1339
+ const reachesBigInt = (type: ContractTypeNode): boolean => {
1340
+ // `typeNeedsScalar` stops at a `ref` leaf, so the transitive answer has to come from the
1341
+ // precomputed set — a bigint two models down still arrives on the wire as `123n`.
1342
+ switch (type.kind) {
1343
+ case 'ref':
1344
+ return tainted?.has(type.name) ?? false;
1345
+ case 'array':
1346
+ return reachesBigInt(type.item);
1347
+ case 'lazy':
1348
+ return reachesBigInt(type.inner);
1349
+ case 'tuple':
1350
+ return type.items.some(reachesBigInt);
1351
+ case 'record':
1352
+ return reachesBigInt(type.value);
1353
+ case 'union':
1354
+ case 'discriminatedUnion':
1355
+ case 'intersection':
1356
+ return type.members.some(reachesBigInt);
1357
+ case 'inlineObject':
1358
+ return type.fields.some(f => reachesBigInt(f.type));
1359
+ default:
1360
+ return typeNeedsScalar(type, 'bigint');
1361
+ }
1362
+ };
1363
+
1364
+ for (const route of root.routes) {
1365
+ for (const op of route.operations) {
1366
+ if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
1367
+ for (const resp of op.responses) {
1368
+ for (const body of resp.bodies) {
1369
+ if (classifyContentType(body.contentType) === 'json' && reachesBigInt(body.bodyType)) return true;
1370
+ }
1371
+ }
1372
+ }
1373
+ }
1374
+ return false;
1375
+ }
1376
+
1135
1377
  function sdkNeedsJson(root: OpRootNode, includeInternal = false): boolean {
1136
1378
  for (const route of root.routes) {
1137
1379
  for (const op of route.operations) {
@@ -1345,7 +1587,20 @@ export function generateSdkOptions(): string {
1345
1587
  " return qs ? `?${qs}` : '';",
1346
1588
  '}',
1347
1589
  '',
1590
+ '/**',
1591
+ ' * Read a JSON response body.',
1592
+ ' *',
1593
+ ' * No reviver: `bigIntReviver` matches any string of the form `123n` anywhere in the',
1594
+ ' * document, so a contract with no bigint field would still have a legitimate string like',
1595
+ ' * "123n" silently turned into a BigInt. Clients whose contracts do use bigint import',
1596
+ ' * `parseJsonWithBigInt` under this name instead.',
1597
+ ' */',
1348
1598
  'export async function parseJson<T>(res: Response): Promise<T> {',
1599
+ ' return JSON.parse(await res.text()) as T;',
1600
+ '}',
1601
+ '',
1602
+ '/** `parseJson` for contracts that declare a bigint, applying the `123n` reviver. */',
1603
+ 'export async function parseJsonWithBigInt<T>(res: Response): Promise<T> {',
1349
1604
  ' return JSON.parse(await res.text(), bigIntReviver) as T;',
1350
1605
  '}',
1351
1606
  '',
@@ -1524,6 +1779,7 @@ export function generateAreaClient(input: AreaClientInput): string {
1524
1779
  const unresolvedTypes = new Set<string>();
1525
1780
  let needsJson = false;
1526
1781
  let needsBigIntReplacer = false;
1782
+ let needsParseJson = false;
1527
1783
  let needsBigIntReviver = false;
1528
1784
  let needsQueryString = false;
1529
1785
  let needsReadContentType = false;
@@ -1545,7 +1801,8 @@ export function generateAreaClient(input: AreaClientInput): string {
1545
1801
  for (const alias of generateErrorBodyAliases(inline.root, inline.codegenOptions)) collectedErrorAliases.add(alias);
1546
1802
  if (sdkNeedsJson(inline.root, includeInternal)) needsJson = true;
1547
1803
  if (sdkNeedsBigIntReplacer(inline.root, includeInternal)) needsBigIntReplacer = true;
1548
- if (sdkNeedsBigIntReviver(inline.root, includeInternal)) needsBigIntReviver = true;
1804
+ if (sdkParsesJsonResponse(inline.root, includeInternal)) needsParseJson = true;
1805
+ if (sdkResponsesUseBigInt(inline.root, inline.codegenOptions, includeInternal)) needsBigIntReviver = true;
1549
1806
  if (sdkNeedsQueryString(inline.root, includeInternal)) needsQueryString = true;
1550
1807
  if (sdkNeedsReadContentType(inline.root, includeInternal)) needsReadContentType = true;
1551
1808
 
@@ -1565,11 +1822,11 @@ export function generateAreaClient(input: AreaClientInput): string {
1565
1822
  // Resolve each file's type refs against THIS file's modelOutPaths, but
1566
1823
  // produce import paths relative to the area client's outPath (not the
1567
1824
  // contributing file's outPath, which pointed at the now-defunct sdk.ts).
1568
- const typesForFile = collectTypes(
1569
- inline.root,
1570
- inline.codegenOptions.modelsWithInput,
1571
- inline.codegenOptions.modelsWithOutput,
1572
- includeInternal,
1825
+ // Filtered against this file's own emitted text for the same reason `generateSdk` filters
1826
+ // its own — a multipart body's model is collected but never named in the output.
1827
+ const typesForFile = referencedTypes(
1828
+ collectTypes(inline.root, inline.codegenOptions.modelsWithInput, inline.codegenOptions.modelsWithOutput, includeInternal),
1829
+ [...methodLines, ...generateErrorBodyAliases(inline.root, inline.codegenOptions), ...preludeLines],
1573
1830
  );
1574
1831
  const { modelOutPaths } = inline.codegenOptions;
1575
1832
  if (modelOutPaths) {
@@ -1598,7 +1855,7 @@ export function generateAreaClient(input: AreaClientInput): string {
1598
1855
  lines.push(`import type { SdkFetch${jsonImport} } from '${sdkOptionsRel}';`);
1599
1856
  const valueImports: string[] = [];
1600
1857
  if (needsBigIntReplacer) valueImports.push('bigIntReplacer');
1601
- if (needsBigIntReviver) valueImports.push('parseJson');
1858
+ if (needsParseJson) valueImports.push(needsBigIntReviver ? 'parseJsonWithBigInt as parseJson' : 'parseJson');
1602
1859
  if (needsQueryString) valueImports.push('buildQueryString');
1603
1860
  if (needsReadContentType) valueImports.push('readContentType');
1604
1861
  if (valueImports.length > 0) {
@@ -1616,6 +1873,8 @@ export function generateAreaClient(input: AreaClientInput): string {
1616
1873
  }
1617
1874
 
1618
1875
  if (areaNeedsDecimalImport) lines.push(DECIMAL_IMPORT);
1876
+ const areaHeaderLuxon = headerLuxonImport(collectedMethodLines, collectedRevivePrelude);
1877
+ if (areaHeaderLuxon) lines.push(areaHeaderLuxon);
1619
1878
 
1620
1879
  // Leaf client imports (subareas only — top-level clients live next to sdk.ts).
1621
1880
  const importedClients = new Set<string>();