@contractkit/plugin-typescript 0.18.0 → 0.19.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.
package/src/index.ts CHANGED
@@ -1,7 +1,25 @@
1
1
  import { resolve, join, relative, dirname, basename } from 'node:path';
2
+ import { existsSync, readFileSync, rmSync, readdirSync, rmdirSync } from 'node:fs';
2
3
  import { generateContract } from './codegen-contract.js';
3
4
  import { generateOp } from './codegen-operation.js';
4
- import type { ContractKitPlugin } from '@contractkit/core';
5
+ import type {
6
+ ContractKitPlugin,
7
+ PluginContext,
8
+ ContractRootNode,
9
+ OpRootNode,
10
+ ModelNode,
11
+ IncrementalManifest,
12
+ IncrementalUnit,
13
+ IncrementalOutputFile,
14
+ } from '@contractkit/core';
15
+ import {
16
+ runIncrementalCodegen,
17
+ parseIncrementalManifest,
18
+ emptyIncrementalManifest,
19
+ hashFingerprint,
20
+ collectTransitiveModelRefs,
21
+ collectTypeRefs,
22
+ } from '@contractkit/core';
5
23
  import {
6
24
  generateSdk,
7
25
  generateSdkOptions,
@@ -33,149 +51,313 @@ import {
33
51
  export interface ServerConfig {
34
52
  /** Directory (relative to rootDir) where server files are written. Default: rootDir. */
35
53
  baseDir?: string;
36
- /**
37
- * When true, `output.types` emits Zod schema files (via `generateContract`).
38
- * When false/omitted, `output.types` emits plain TypeScript interfaces.
39
- */
54
+ /** When true, `output.types` emits Zod schema files (via `generateContract`). When false/omitted, emits plain TypeScript. */
40
55
  zod?: boolean;
41
56
  output?: {
42
- /** Path template for Koa router files. Supports {filename}, {dir}, {area}. Default: `{filename}.router.ts`. */
57
+ /** Path template for Koa router files. Supports {filename}, {dir}, {area}. */
43
58
  routes?: string;
44
- /**
45
- * Path template for type/schema files. Supports {filename}, {dir}, {area}.
46
- * Generates Zod schemas when `zod: true`, otherwise plain TypeScript interfaces.
47
- */
59
+ /** Path template for type/schema files. Supports {filename}, {dir}, {area}. */
48
60
  types?: string;
49
61
  };
50
- /** Import path template for service implementations. Supports {module}. */
62
+ /** Import path template for service implementations. */
51
63
  servicePathTemplate?: string;
52
- /**
53
- * Whether to emit handlers for operations marked `internal`. Defaults to `true` —
54
- * the server still needs routes for internal endpoints. Set to `false` to omit them.
55
- */
64
+ /** Whether to emit handlers for `internal` operations. Default true. */
56
65
  includeInternal?: boolean;
57
66
  }
58
67
 
59
68
  export interface SdkConfig {
60
- /** Directory (relative to rootDir) where SDK files are written. Default: rootDir. */
61
69
  baseDir?: string;
62
- /** Name used for the aggregator SDK class (e.g. "homegrown" → `HomegrownSdk`). */
63
70
  name?: string;
64
- /**
65
- * When true, `output.types` emits Zod schema files (via `generateContract`).
66
- * When false/omitted, `output.types` emits plain TypeScript interfaces.
67
- */
68
71
  zod?: boolean;
69
72
  output?: {
70
- /** Path template for the SDK aggregator file. Supports {name}. Default: `sdk.ts`. */
71
73
  sdk?: string;
72
- /** Path template for SDK type files. Supports {filename}, {dir}, {area}, {subarea}. */
73
74
  types?: string;
74
- /** Path template for client class files. Supports {filename}, {dir}, {area}, {subarea}. */
75
75
  clients?: string;
76
76
  };
77
- /**
78
- * Whether to emit SDK methods for operations marked `internal`. Defaults to `false` —
79
- * internal ops are omitted from the SDK so consumers don't pick them up. Set to `true`
80
- * for an internal-use SDK that should expose them.
81
- */
82
77
  includeInternal?: boolean;
83
78
  }
84
79
 
85
80
  export interface ZodConfig {
86
- /** Directory (relative to rootDir) where Zod schema files are written. Default: rootDir. */
87
81
  baseDir?: string;
88
- /** Output path template. Supports {filename}, {dir}. Default: `{filename}.schema.ts` alongside source. */
89
82
  output?: string;
90
83
  }
91
84
 
92
85
  export interface TypesConfig {
93
- /** Directory (relative to rootDir) where plain TypeScript type files are written. Default: rootDir. */
94
86
  baseDir?: string;
95
- /** Output path template. Supports {filename}, {dir}. Default: `{filename}.types.ts` alongside source. */
96
87
  output?: string;
97
88
  }
98
89
 
99
90
  export interface TypescriptPluginConfig {
100
- /** Generate Koa router files from `operation` declarations. */
101
91
  server?: ServerConfig;
102
- /** Generate TypeScript SDK client files from `operation` declarations. */
103
92
  sdk?: SdkConfig;
104
- /** Generate Zod schema files from `contract` declarations. */
105
93
  zod?: ZodConfig;
106
- /** Generate plain TypeScript interface/type files from `contract` declarations (no Zod runtime). */
107
94
  types?: TypesConfig;
108
95
  }
109
96
 
110
- // ─── Server generation ─────────────────────────────────────────────────────
97
+ // ─── Caching constants ─────────────────────────────────────────────────────
98
+
99
+ /** Bumped when the codegen output shape changes in a way that should bust every per-file fingerprint. */
100
+ export const TYPESCRIPT_CODEGEN_VERSION = '1';
101
+
102
+ const MANIFEST_FILENAME = '.contractkit-typescript-manifest.json';
103
+
104
+ // ─── Plugin entry points ──────────────────────────────────────────────────
105
+
106
+ const plugin: ContractKitPlugin = {
107
+ name: 'typescript',
108
+ async generateTargets(inputs, ctx) {
109
+ const config = ctx.options as TypescriptPluginConfig;
110
+ await runTypescriptCodegen(inputs, ctx, config, ctx.rootDir);
111
+ },
112
+ };
113
+
114
+ export default plugin;
115
+
116
+ /** Build a `@contractkit/plugin-typescript` instance with explicit configuration, for programmatic use. */
117
+ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir: string): ContractKitPlugin {
118
+ return {
119
+ name: 'typescript',
120
+ async generateTargets(inputs, ctx) {
121
+ await runTypescriptCodegen(inputs, ctx, config, rootDir);
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Shared orchestration. Each sub-generator (server / sdk / zod / types) contributes a
128
+ * set of cacheable units (per-file fingerprints) plus a set of always-regenerated global
129
+ * files (aggregators, barrels, sdk-options). Units share a single manifest so the cache
130
+ * survives cross-cutting reads — the manifest lives at `<rootDir>/.contractkit-typescript-manifest.json`.
131
+ *
132
+ * Honors `ctx.cacheEnabled` — `--force` bypasses the manifest entirely.
133
+ */
134
+ async function runTypescriptCodegen(
135
+ inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
136
+ ctx: PluginContext,
137
+ config: TypescriptPluginConfig,
138
+ rootDir: string,
139
+ ): Promise<void> {
140
+ const manifestPath = resolve(rootDir, MANIFEST_FILENAME);
141
+ const prevManifest: IncrementalManifest = ctx.cacheEnabled ? readManifest(manifestPath) : emptyIncrementalManifest(TYPESCRIPT_CODEGEN_VERSION);
142
+
143
+ const units: IncrementalUnit[] = [];
144
+ const globalFiles: IncrementalOutputFile[] = [];
145
+
146
+ if (config.server) collectServerOutput(config.server, rootDir, inputs, units);
147
+ if (config.sdk) collectSdkOutput(config.sdk, rootDir, inputs, units, globalFiles);
148
+ if (config.zod) collectZodOutput(config.zod, rootDir, inputs, units);
149
+ if (config.types) collectTypesOutput(config.types, rootDir, inputs, units);
150
+
151
+ const result = runIncrementalCodegen({
152
+ codegenVersion: TYPESCRIPT_CODEGEN_VERSION,
153
+ manifestFilename: manifestPath,
154
+ prevManifest,
155
+ globalFiles,
156
+ units,
157
+ // Paths are absolute, so existsSync works directly.
158
+ fileExists: existsSync,
159
+ });
160
+
161
+ deleteStalePaths(result.deletedPaths);
162
+
163
+ for (const { relativePath, content } of result.filesToWrite) {
164
+ ctx.emitFile(relativePath, content);
165
+ }
166
+ }
167
+
168
+ // ─── Cross-file dependency analysis ────────────────────────────────────────
169
+
170
+ /** Build a quick lookup from model name → its definition. */
171
+ function buildModelMap(contractRoots: readonly ContractRootNode[]): Map<string, ModelNode> {
172
+ const map = new Map<string, ModelNode>();
173
+ for (const root of contractRoots) {
174
+ for (const model of root.models) map.set(model.name, model);
175
+ }
176
+ return map;
177
+ }
178
+
179
+ /** Collect every model referenced by this contract root (own models' fields + bases). Used to slice cross-file fingerprint inputs to just what this file actually depends on. */
180
+ function collectContractRootRefs(root: ContractRootNode, modelMap: Map<string, ModelNode>): Set<string> {
181
+ const seeds: Parameters<typeof collectTypeRefs>[0][] = [];
182
+ for (const m of root.models) {
183
+ if (m.type) seeds.push(m.type);
184
+ for (const f of m.fields) seeds.push(f.type);
185
+ if (m.bases) {
186
+ for (const b of m.bases) seeds.push({ kind: 'ref', name: b } as Parameters<typeof collectTypeRefs>[0]);
187
+ }
188
+ }
189
+ return collectTransitiveModelRefs(seeds, modelMap);
190
+ }
111
191
 
112
- function runServerGeneration(
192
+ /** Collect every model referenced by an op root's routes/operations (transitive). */
193
+ function collectOpRootRefs(root: OpRootNode, modelMap: Map<string, ModelNode>): Set<string> {
194
+ const seeds: Parameters<typeof collectTypeRefs>[0][] = [];
195
+ for (const route of root.routes) {
196
+ if (route.params) seeds.push(...paramSourceTypes(route.params));
197
+ for (const op of route.operations) {
198
+ if (op.query) seeds.push(...paramSourceTypes(op.query));
199
+ if (op.headers) seeds.push(...paramSourceTypes(op.headers));
200
+ if (op.request) {
201
+ for (const body of op.request.bodies) seeds.push(body.bodyType);
202
+ }
203
+ for (const resp of op.responses) {
204
+ if (resp.bodyType) seeds.push(resp.bodyType);
205
+ if (resp.headers) {
206
+ for (const h of resp.headers) seeds.push(h.type);
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return collectTransitiveModelRefs(seeds, modelMap);
212
+ }
213
+
214
+ function paramSourceTypes(src: NonNullable<OpRootNode['routes'][number]['params']>): Parameters<typeof collectTypeRefs>[0][] {
215
+ const out: Parameters<typeof collectTypeRefs>[0][] = [];
216
+ if (src.kind === 'params') {
217
+ for (const n of src.nodes) out.push(n.type);
218
+ } else if (src.kind === 'ref') {
219
+ out.push({ kind: 'ref', name: src.name } as Parameters<typeof collectTypeRefs>[0]);
220
+ } else if (src.kind === 'type') {
221
+ out.push(src.node);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ /** Build a sorted, JSON-stable record of (modelName -> outPath) for refs this unit depends on. */
227
+ function sliceOutPathMap(refs: Set<string>, modelOutPaths: Map<string, string>, modelsWithInput: Set<string>, modelsWithOutput: Set<string>): Record<string, string> {
228
+ const slice: Record<string, string> = {};
229
+ for (const ref of [...refs].sort()) {
230
+ const p = modelOutPaths.get(ref);
231
+ if (p) slice[ref] = p;
232
+ if (modelsWithInput.has(ref)) {
233
+ const ip = modelOutPaths.get(`${ref}Input`);
234
+ if (ip) slice[`${ref}Input`] = ip;
235
+ }
236
+ if (modelsWithOutput.has(ref)) {
237
+ const op = modelOutPaths.get(`${ref}Output`);
238
+ if (op) slice[`${ref}Output`] = op;
239
+ }
240
+ }
241
+ return slice;
242
+ }
243
+
244
+ /** Slice modelsWithInput/Output to only the names relevant to this unit. */
245
+ function sliceModelSet(refs: Set<string>, ownNames: Set<string>, set: Set<string>): string[] {
246
+ const result: string[] = [];
247
+ for (const name of set) {
248
+ if (refs.has(name) || ownNames.has(name)) result.push(name);
249
+ }
250
+ return result.sort();
251
+ }
252
+
253
+ // ─── Server sub-generator ──────────────────────────────────────────────────
254
+
255
+ function collectServerOutput(
113
256
  config: ServerConfig,
114
257
  rootDir: string,
115
258
  inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
116
- emitFile: (outPath: string, content: string) => void,
259
+ units: IncrementalUnit[],
117
260
  ): void {
118
261
  const serverBase = resolve(rootDir, config.baseDir ?? '.');
119
262
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
120
263
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
264
+ const modelMap = buildModelMap(inputs.contractRoots);
121
265
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
122
266
  const commonRoot = commonDir(allFiles, rootDir);
267
+ const subConfigKey = stableSubConfig(config);
123
268
 
124
- // ── Types / Zod output ──
125
- // When output.types is configured we generate type files ourselves and build a
126
- // local modelOutPaths map so the router generator can resolve import paths.
127
- let serverModelOutPaths = new Map<string, string>();
128
-
269
+ // Pre-pass: register all model outPath. Cross-file refs need to resolve correctly,
270
+ // which means we need the COMPLETE map (not a slice) even though each unit's fingerprint
271
+ // only includes its own slice.
272
+ const serverModelOutPaths = new Map<string, string>();
273
+ const typeEntries: { ast: ContractRootNode; typeOutPath: string }[] = [];
129
274
  if (config.output?.types) {
130
- serverModelOutPaths = new Map();
131
-
132
- // Pass 1: register all model → outPath entries before generating content,
133
- // so cross-file type refs resolve correctly.
134
- const typeEntries: { ast: (typeof inputs.contractRoots)[number]; typeOutPath: string }[] = [];
135
275
  for (const ast of inputs.contractRoots) {
136
276
  const typeOutPath = computeContractOutPath(ast.file, serverBase, config.output.types, '.ts', commonRoot, ast.meta);
137
277
  typeEntries.push({ ast, typeOutPath });
138
278
  for (const model of ast.models) {
139
279
  serverModelOutPaths.set(model.name, typeOutPath);
140
- if (modelsWithInput.has(model.name)) {
141
- serverModelOutPaths.set(`${model.name}Input`, typeOutPath);
142
- }
143
- if (modelsWithOutput.has(model.name)) {
144
- serverModelOutPaths.set(`${model.name}Output`, typeOutPath);
145
- }
280
+ if (modelsWithInput.has(model.name)) serverModelOutPaths.set(`${model.name}Input`, typeOutPath);
281
+ if (modelsWithOutput.has(model.name)) serverModelOutPaths.set(`${model.name}Output`, typeOutPath);
146
282
  }
147
283
  }
284
+ }
148
285
 
149
- // Pass 2: emit type files.
150
- for (const { ast, typeOutPath } of typeEntries) {
151
- const ctx = { modelOutPaths: serverModelOutPaths, currentOutPath: typeOutPath, modelsWithInput, modelsWithOutput };
152
- const content = config.zod ? generateContract(ast, ctx) : generatePlainTypes(ast, ctx);
153
- emitFile(typeOutPath, content);
154
- }
286
+ // ── Per-contract-root types unit ──
287
+ for (const { ast, typeOutPath } of typeEntries) {
288
+ const refs = collectContractRootRefs(ast, modelMap);
289
+ const ownNames = new Set(ast.models.map(m => m.name));
290
+ const fingerprint = hashFingerprint({
291
+ kind: 'server-types',
292
+ v: TYPESCRIPT_CODEGEN_VERSION,
293
+ outPath: typeOutPath,
294
+ root: ast,
295
+ outPathSlice: sliceOutPathMap(refs, serverModelOutPaths, modelsWithInput, modelsWithOutput),
296
+ modelsWithInput: sliceModelSet(refs, ownNames, modelsWithInput),
297
+ modelsWithOutput: sliceModelSet(refs, ownNames, modelsWithOutput),
298
+ sub: subConfigKey,
299
+ });
300
+ units.push({
301
+ key: `server-types::${typeOutPath}`,
302
+ fingerprint,
303
+ render: () => {
304
+ const renderCtx = {
305
+ modelOutPaths: serverModelOutPaths,
306
+ currentOutPath: typeOutPath,
307
+ modelsWithInput,
308
+ modelsWithOutput,
309
+ };
310
+ const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
311
+ return [{ relativePath: typeOutPath, content }];
312
+ },
313
+ });
155
314
  }
156
315
 
157
- // ── Routes output ──
316
+ // ── Per-op-root router unit ──
158
317
  for (const ast of inputs.opRoots) {
159
318
  const outPath = computeOpOutPath(ast.file, serverBase, config.output?.routes, '.router.ts', commonRoot, ast.meta);
160
- const content = generateOp(ast, {
161
- servicePathTemplate: config.servicePathTemplate,
319
+ const refs = collectOpRootRefs(ast, modelMap);
320
+ const fingerprint = hashFingerprint({
321
+ kind: 'server-router',
322
+ v: TYPESCRIPT_CODEGEN_VERSION,
162
323
  outPath,
163
- modelOutPaths: serverModelOutPaths,
164
- modelsWithInput,
165
- modelsWithOutput,
166
- includeInternal: config.includeInternal,
324
+ root: ast,
325
+ // The router imports types from each contract root's type file; the slice covers exactly that.
326
+ outPathSlice: sliceOutPathMap(refs, serverModelOutPaths, modelsWithInput, modelsWithOutput),
327
+ modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
328
+ modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
329
+ servicePathTemplate: config.servicePathTemplate ?? null,
330
+ includeInternal: config.includeInternal ?? true,
331
+ sub: subConfigKey,
332
+ });
333
+ units.push({
334
+ key: `server-router::${outPath}`,
335
+ fingerprint,
336
+ render: () => [
337
+ {
338
+ relativePath: outPath,
339
+ content: generateOp(ast, {
340
+ servicePathTemplate: config.servicePathTemplate,
341
+ outPath,
342
+ modelOutPaths: serverModelOutPaths,
343
+ modelsWithInput,
344
+ modelsWithOutput,
345
+ includeInternal: config.includeInternal,
346
+ }),
347
+ },
348
+ ],
167
349
  });
168
- emitFile(outPath, content);
169
350
  }
170
351
  }
171
352
 
172
- // ─── SDK generation ────────────────────────────────────────────────────────
353
+ // ─── SDK sub-generator ─────────────────────────────────────────────────────
173
354
 
174
- function runSdkGeneration(
355
+ function collectSdkOutput(
175
356
  config: SdkConfig,
176
357
  rootDir: string,
177
358
  inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
178
- emitFile: (outPath: string, content: string) => void,
359
+ units: IncrementalUnit[],
360
+ globalFiles: IncrementalOutputFile[],
179
361
  ): void {
180
362
  const sdkBase = config.baseDir ? resolve(rootDir, config.baseDir) : rootDir;
181
363
  const sdkName = config.name;
@@ -184,22 +366,22 @@ function runSdkGeneration(
184
366
  ? join(sdkBase, TEMPLATE_VAR_RE.test(sdkOutput) ? resolveTemplate(sdkOutput, { name: sdkName ?? 'sdk' }) : sdkOutput)
185
367
  : join(sdkBase, 'sdk.ts');
186
368
  const sdkOptionsPath = join(dirname(sdkEntryPath), 'sdk-options.ts');
369
+ const subConfigKey = stableSubConfig(config);
187
370
 
188
371
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
189
372
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
373
+ const modelMap = buildModelMap(inputs.contractRoots);
190
374
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
191
375
  const ckCommonRoot = commonDir(allFiles, rootDir);
192
376
 
193
- let sdkModelOutPaths = new Map<string, string>();
377
+ const sdkModelOutPaths = new Map<string, string>();
194
378
  const sdkTypePaths: string[] = [];
195
379
  const sdkClientInfos: { outPath: string; className: string; propertyName: string }[] = [];
196
380
 
197
- // ── SDK types ──
381
+ // ── Pre-pass: SDK type files ──
382
+ const sdkContractEntries: { ast: ContractRootNode; typeOutPath: string }[] = [];
198
383
  if (config.output?.types) {
199
- sdkModelOutPaths = new Map<string, string>();
200
384
  const publicTypes = computePubliclyReachableTypes(inputs.opRoots, inputs.contractRoots, modelsWithInput, modelsWithOutput);
201
-
202
- const sdkContractEntries: { ast: (typeof inputs.contractRoots)[number]; typeOutPath: string }[] = [];
203
385
  for (const ast of inputs.contractRoots) {
204
386
  const typeOutPath = computeSdkTypeOutPath(ast.file, sdkBase, config.output.types, ckCommonRoot, ast.meta);
205
387
  if (!typeOutPath) continue;
@@ -212,48 +394,63 @@ function runSdkGeneration(
212
394
  if (modelsWithOutput.has(model.name)) sdkModelOutPaths.set(`${model.name}Output`, typeOutPath);
213
395
  }
214
396
  }
397
+ }
215
398
 
216
- for (const { ast, typeOutPath } of sdkContractEntries) {
217
- let content: string;
218
- if (config.zod) {
219
- content = generateContract(ast, {
220
- modelOutPaths: sdkModelOutPaths,
221
- currentOutPath: typeOutPath,
222
- modelsWithInput,
223
- modelsWithOutput,
224
- });
225
- } else {
226
- let rel = relative(dirname(typeOutPath), sdkOptionsPath).replace(/\.ts$/, '.js');
227
- if (!rel.startsWith('.')) rel = './' + rel;
228
- content = generatePlainTypes(ast, {
229
- modelOutPaths: sdkModelOutPaths,
230
- currentOutPath: typeOutPath,
231
- modelsWithInput,
232
- modelsWithOutput,
233
- jsonValueImportPath: rel,
234
- });
235
- }
236
- emitFile(typeOutPath, content);
237
- }
399
+ // ── SDK type units ──
400
+ for (const { ast, typeOutPath } of sdkContractEntries) {
401
+ const refs = collectContractRootRefs(ast, modelMap);
402
+ const ownNames = new Set(ast.models.map(m => m.name));
403
+ const fingerprint = hashFingerprint({
404
+ kind: 'sdk-types',
405
+ v: TYPESCRIPT_CODEGEN_VERSION,
406
+ outPath: typeOutPath,
407
+ root: ast,
408
+ outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
409
+ modelsWithInput: sliceModelSet(refs, ownNames, modelsWithInput),
410
+ modelsWithOutput: sliceModelSet(refs, ownNames, modelsWithOutput),
411
+ sdkOptionsPath,
412
+ sub: subConfigKey,
413
+ });
414
+ units.push({
415
+ key: `sdk-types::${typeOutPath}`,
416
+ fingerprint,
417
+ render: () => {
418
+ let content: string;
419
+ if (config.zod) {
420
+ content = generateContract(ast, {
421
+ modelOutPaths: sdkModelOutPaths,
422
+ currentOutPath: typeOutPath,
423
+ modelsWithInput,
424
+ modelsWithOutput,
425
+ });
426
+ } else {
427
+ let rel = relative(dirname(typeOutPath), sdkOptionsPath).replace(/\.ts$/, '.js');
428
+ if (!rel.startsWith('.')) rel = './' + rel;
429
+ content = generatePlainTypes(ast, {
430
+ modelOutPaths: sdkModelOutPaths,
431
+ currentOutPath: typeOutPath,
432
+ modelsWithInput,
433
+ modelsWithOutput,
434
+ jsonValueImportPath: rel,
435
+ });
436
+ }
437
+ return [{ relativePath: typeOutPath, content }];
438
+ },
439
+ });
238
440
  }
239
441
 
240
- // ── SDK clients ──
241
- // Group opRoots by (area, subarea):
242
- // - area + subarea → leaf client emitted as <Area><Subarea>Client in its own file
243
- // - area only → no standalone file; methods inlined into <Area>Client in sdk.ts
244
- // - neither → flat top-level client (legacy behavior)
442
+ // ── Bucket op roots by area/subarea ──
245
443
  interface AreaBucket {
246
- leaves: { ast: typeof inputs.opRoots[number]; outPath: string; subarea: string }[];
247
- inlineRoots: typeof inputs.opRoots[number][];
444
+ leaves: { ast: OpRootNode; outPath: string; subarea: string }[];
445
+ inlineRoots: OpRootNode[];
248
446
  }
249
447
  const areaBuckets = new Map<string, AreaBucket>();
250
- const topLevelEntries: { ast: typeof inputs.opRoots[number]; outPath: string }[] = [];
448
+ const topLevelEntries: { ast: OpRootNode; outPath: string }[] = [];
251
449
 
252
450
  if (config.output?.clients) {
253
451
  for (const ast of inputs.opRoots) {
254
452
  const sdkOutPath = computeSdkOutPath(ast.file, sdkBase, config.output.clients, ckCommonRoot, ast.meta);
255
453
  if (!sdkOutPath || !hasPublicOperations(ast, config.includeInternal)) continue;
256
-
257
454
  const { area, subarea } = getAreaSubarea(ast);
258
455
  if (area && subarea) {
259
456
  const bucket = areaBuckets.get(area) ?? { leaves: [], inlineRoots: [] };
@@ -268,46 +465,91 @@ function runSdkGeneration(
268
465
  }
269
466
  }
270
467
 
271
- // Emit per-file clients for leaves (subarea) and top-level (no area). Area-only files are inlined later.
272
- for (const { ast, outPath, subarea } of [...areaBuckets.entries()].flatMap(([area, b]) => b.leaves.map(l => ({ ...l, area })))) {
273
- const className = deriveSubareaClientClassName((ast.meta?.area as string) ?? '', subarea);
274
- sdkClientInfos.push({ outPath, className, propertyName: deriveSubareaPropertyName(subarea) });
275
- emitFile(
276
- outPath,
277
- generateSdk(ast, {
278
- typeImportPathTemplate: undefined,
279
- outPath,
280
- modelOutPaths: sdkModelOutPaths,
468
+ // ── Per-leaf-client (area+subarea) units ──
469
+ for (const [area, bucket] of areaBuckets.entries()) {
470
+ for (const leaf of bucket.leaves) {
471
+ const className = deriveSubareaClientClassName(area, leaf.subarea);
472
+ sdkClientInfos.push({ outPath: leaf.outPath, className, propertyName: deriveSubareaPropertyName(leaf.subarea) });
473
+ const refs = collectOpRootRefs(leaf.ast, modelMap);
474
+ const fingerprint = hashFingerprint({
475
+ kind: 'sdk-leaf-client',
476
+ v: TYPESCRIPT_CODEGEN_VERSION,
477
+ outPath: leaf.outPath,
478
+ root: leaf.ast,
479
+ outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
480
+ modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
481
+ modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
281
482
  sdkOptionsPath,
282
- modelsWithInput,
283
- modelsWithOutput,
284
- includeInternal: config.includeInternal,
285
- clientClassName: className,
286
- }),
287
- );
483
+ className,
484
+ includeInternal: config.includeInternal ?? false,
485
+ sub: subConfigKey,
486
+ });
487
+ units.push({
488
+ key: `sdk-leaf-client::${leaf.outPath}`,
489
+ fingerprint,
490
+ render: () => [
491
+ {
492
+ relativePath: leaf.outPath,
493
+ content: generateSdk(leaf.ast, {
494
+ typeImportPathTemplate: undefined,
495
+ outPath: leaf.outPath,
496
+ modelOutPaths: sdkModelOutPaths,
497
+ sdkOptionsPath,
498
+ modelsWithInput,
499
+ modelsWithOutput,
500
+ includeInternal: config.includeInternal,
501
+ clientClassName: className,
502
+ }),
503
+ },
504
+ ],
505
+ });
506
+ }
288
507
  }
508
+
509
+ // ── Top-level (no area) client units ──
289
510
  for (const { ast, outPath } of topLevelEntries) {
290
511
  const className = deriveClientClassName(ast.file);
291
512
  sdkClientInfos.push({ outPath, className, propertyName: deriveClientPropertyName(ast.file) });
292
- emitFile(
513
+ const refs = collectOpRootRefs(ast, modelMap);
514
+ const fingerprint = hashFingerprint({
515
+ kind: 'sdk-top-client',
516
+ v: TYPESCRIPT_CODEGEN_VERSION,
293
517
  outPath,
294
- generateSdk(ast, {
295
- typeImportPathTemplate: undefined,
296
- outPath,
297
- modelOutPaths: sdkModelOutPaths,
298
- sdkOptionsPath,
299
- modelsWithInput,
300
- modelsWithOutput,
301
- includeInternal: config.includeInternal,
302
- }),
303
- );
518
+ root: ast,
519
+ outPathSlice: sliceOutPathMap(refs, sdkModelOutPaths, modelsWithInput, modelsWithOutput),
520
+ modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
521
+ modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
522
+ sdkOptionsPath,
523
+ includeInternal: config.includeInternal ?? false,
524
+ sub: subConfigKey,
525
+ });
526
+ units.push({
527
+ key: `sdk-top-client::${outPath}`,
528
+ fingerprint,
529
+ render: () => [
530
+ {
531
+ relativePath: outPath,
532
+ content: generateSdk(ast, {
533
+ typeImportPathTemplate: undefined,
534
+ outPath,
535
+ modelOutPaths: sdkModelOutPaths,
536
+ sdkOptionsPath,
537
+ modelsWithInput,
538
+ modelsWithOutput,
539
+ includeInternal: config.includeInternal,
540
+ }),
541
+ },
542
+ ],
543
+ });
304
544
  }
305
545
  }
306
546
 
307
- // ── sdk-options.ts ──
308
- emitFile(sdkOptionsPath, generateSdkOptions());
547
+ // ── Global files: sdk-options, aggregator, barrels, root index ──
548
+ // The aggregator inlines area-only op roots, so its content depends on each inline root's
549
+ // full AST. Caching it gains little — the codegen is fast and any inline-root change rebuilds
550
+ // the file anyway. Same for barrels (one line per imported file). Always regenerate.
551
+ globalFiles.push({ relativePath: sdkOptionsPath, content: generateSdkOptions() });
309
552
 
310
- // ── sdk.ts aggregator ──
311
553
  const hasAnything = sdkClientInfos.length > 0 || areaBuckets.size > 0;
312
554
  if (hasAnything) {
313
555
  const sdkEntryDir = dirname(sdkEntryPath);
@@ -363,26 +605,18 @@ function runSdkGeneration(
363
605
  })),
364
606
  }));
365
607
 
366
- emitFile(
367
- sdkEntryPath,
368
- generateSdkAggregator({
369
- topLevelClients,
370
- areas,
371
- sdkOptionsImportPath,
372
- sdkClassName,
373
- }),
374
- );
608
+ globalFiles.push({
609
+ relativePath: sdkEntryPath,
610
+ content: generateSdkAggregator({ topLevelClients, areas, sdkOptionsImportPath, sdkClassName }),
611
+ });
375
612
  }
376
613
 
377
- // ── Barrel files ──
378
614
  const sdkSrcDir = dirname(sdkEntryPath);
379
615
  const sdkTypeBarrels = generateBarrelFiles(sdkTypePaths);
380
- for (const barrel of sdkTypeBarrels) emitFile(barrel.outPath, barrel.content);
616
+ for (const barrel of sdkTypeBarrels) globalFiles.push({ relativePath: barrel.outPath, content: barrel.content });
381
617
 
382
618
  const rootExports: string[] = [`export * from './${basename(sdkOptionsPath).replace(/\.ts$/, '.js')}';`];
383
- if (hasAnything) {
384
- rootExports.push(`export * from './${basename(sdkEntryPath).replace(/\.ts$/, '.js')}';`);
385
- }
619
+ if (hasAnything) rootExports.push(`export * from './${basename(sdkEntryPath).replace(/\.ts$/, '.js')}';`);
386
620
  for (const c of sdkClientInfos) {
387
621
  let rel = relative(sdkSrcDir, c.outPath).replace(/\.ts$/, '.js');
388
622
  if (!rel.startsWith('.')) rel = './' + rel;
@@ -393,26 +627,30 @@ function runSdkGeneration(
393
627
  if (!rel.startsWith('.')) rel = './' + rel;
394
628
  rootExports.push(`export * from '${rel}';`);
395
629
  }
396
- emitFile(join(sdkSrcDir, 'index.ts'), `// Auto-generated barrel file\n${rootExports.sort().join('\n')}\n`);
630
+ globalFiles.push({
631
+ relativePath: join(sdkSrcDir, 'index.ts'),
632
+ content: `// Auto-generated barrel file\n${rootExports.sort().join('\n')}\n`,
633
+ });
397
634
  }
398
635
 
399
- // ─── Zod generation ────────────────────────────────────────────────────────
636
+ // ─── Zod sub-generator ─────────────────────────────────────────────────────
400
637
 
401
- function runZodGeneration(
638
+ function collectZodOutput(
402
639
  config: ZodConfig,
403
640
  rootDir: string,
404
641
  inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
405
- emitFile: (outPath: string, content: string) => void,
642
+ units: IncrementalUnit[],
406
643
  ): void {
407
644
  const zodBase = resolve(rootDir, config.baseDir ?? '.');
408
645
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
409
646
  const commonRoot = commonDir(allFiles, rootDir);
410
647
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
411
648
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
649
+ const modelMap = buildModelMap(inputs.contractRoots);
650
+ const subConfigKey = stableSubConfig(config);
412
651
 
413
- // Pre-pass: register all model → outPath before generating, so cross-file imports resolve.
414
652
  const modelOutPaths = new Map<string, string>();
415
- const entries: { ast: (typeof inputs.contractRoots)[number]; outPath: string }[] = [];
653
+ const entries: { ast: ContractRootNode; outPath: string }[] = [];
416
654
  for (const ast of inputs.contractRoots) {
417
655
  const outPath = computeContractOutPath(ast.file, zodBase, config.output, '.schema.ts', commonRoot, ast.meta);
418
656
  entries.push({ ast, outPath });
@@ -424,33 +662,49 @@ function runZodGeneration(
424
662
  }
425
663
 
426
664
  for (const { ast, outPath } of entries) {
427
- const content = generateContract(ast, {
428
- modelOutPaths,
429
- currentOutPath: outPath,
430
- modelsWithInput,
431
- modelsWithOutput,
665
+ const refs = collectContractRootRefs(ast, modelMap);
666
+ const ownNames = new Set(ast.models.map(m => m.name));
667
+ const fingerprint = hashFingerprint({
668
+ kind: 'zod',
669
+ v: TYPESCRIPT_CODEGEN_VERSION,
670
+ outPath,
671
+ root: ast,
672
+ outPathSlice: sliceOutPathMap(refs, modelOutPaths, modelsWithInput, modelsWithOutput),
673
+ modelsWithInput: sliceModelSet(refs, ownNames, modelsWithInput),
674
+ modelsWithOutput: sliceModelSet(refs, ownNames, modelsWithOutput),
675
+ sub: subConfigKey,
676
+ });
677
+ units.push({
678
+ key: `zod::${outPath}`,
679
+ fingerprint,
680
+ render: () => [
681
+ {
682
+ relativePath: outPath,
683
+ content: generateContract(ast, { modelOutPaths, currentOutPath: outPath, modelsWithInput, modelsWithOutput }),
684
+ },
685
+ ],
432
686
  });
433
- emitFile(outPath, content);
434
687
  }
435
688
  }
436
689
 
437
- // ─── Types generation ──────────────────────────────────────────────────────
690
+ // ─── Plain types sub-generator ─────────────────────────────────────────────
438
691
 
439
- function runTypesGeneration(
692
+ function collectTypesOutput(
440
693
  config: TypesConfig,
441
694
  rootDir: string,
442
695
  inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
443
- emitFile: (outPath: string, content: string) => void,
696
+ units: IncrementalUnit[],
444
697
  ): void {
445
698
  const typesBase = resolve(rootDir, config.baseDir ?? '.');
446
699
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
447
700
  const commonRoot = commonDir(allFiles, rootDir);
448
701
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
449
702
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
703
+ const modelMap = buildModelMap(inputs.contractRoots);
704
+ const subConfigKey = stableSubConfig(config);
450
705
 
451
- // Pre-pass: register all model → outPath before generating.
452
706
  const modelOutPaths = new Map<string, string>();
453
- const entries: { ast: (typeof inputs.contractRoots)[number]; outPath: string }[] = [];
707
+ const entries: { ast: ContractRootNode; outPath: string }[] = [];
454
708
  for (const ast of inputs.contractRoots) {
455
709
  const outPath = computeContractOutPath(ast.file, typesBase, config.output, '.types.ts', commonRoot, ast.meta);
456
710
  entries.push({ ast, outPath });
@@ -462,65 +716,70 @@ function runTypesGeneration(
462
716
  }
463
717
 
464
718
  for (const { ast, outPath } of entries) {
465
- const content = generatePlainTypes(ast, {
466
- modelOutPaths,
467
- currentOutPath: outPath,
468
- modelsWithInput,
469
- modelsWithOutput,
719
+ const refs = collectContractRootRefs(ast, modelMap);
720
+ const ownNames = new Set(ast.models.map(m => m.name));
721
+ const fingerprint = hashFingerprint({
722
+ kind: 'plain-types',
723
+ v: TYPESCRIPT_CODEGEN_VERSION,
724
+ outPath,
725
+ root: ast,
726
+ outPathSlice: sliceOutPathMap(refs, modelOutPaths, modelsWithInput, modelsWithOutput),
727
+ modelsWithInput: sliceModelSet(refs, ownNames, modelsWithInput),
728
+ modelsWithOutput: sliceModelSet(refs, ownNames, modelsWithOutput),
729
+ sub: subConfigKey,
730
+ });
731
+ units.push({
732
+ key: `plain-types::${outPath}`,
733
+ fingerprint,
734
+ render: () => [
735
+ {
736
+ relativePath: outPath,
737
+ content: generatePlainTypes(ast, { modelOutPaths, currentOutPath: outPath, modelsWithInput, modelsWithOutput }),
738
+ },
739
+ ],
470
740
  });
471
- emitFile(outPath, content);
472
741
  }
473
742
  }
474
743
 
475
- // ─── Combined plugin ────────────────────────────────────────────────────────
744
+ // ─── Manifest IO + cleanup ─────────────────────────────────────────────────
476
745
 
477
- const plugin: ContractKitPlugin = {
478
- name: 'typescript',
479
- cacheKey: 'typescript',
480
- async generateTargets(inputs, ctx) {
481
- const config = ctx.options as TypescriptPluginConfig;
746
+ function readManifest(manifestPath: string): IncrementalManifest {
747
+ if (!existsSync(manifestPath)) return emptyIncrementalManifest(TYPESCRIPT_CODEGEN_VERSION);
748
+ try {
749
+ return parseIncrementalManifest(readFileSync(manifestPath, 'utf-8'));
750
+ } catch {
751
+ return emptyIncrementalManifest(TYPESCRIPT_CODEGEN_VERSION);
752
+ }
753
+ }
482
754
 
483
- if (config.server) {
484
- runServerGeneration(config.server, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
485
- }
486
- if (config.sdk) {
487
- runSdkGeneration(config.sdk, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
755
+ function deleteStalePaths(absPaths: string[]): void {
756
+ if (absPaths.length === 0) return;
757
+ const removedDirs = new Set<string>();
758
+ for (const abs of absPaths) {
759
+ if (existsSync(abs)) {
760
+ rmSync(abs, { force: true });
761
+ removedDirs.add(dirname(abs));
488
762
  }
489
- if (config.zod) {
490
- runZodGeneration(config.zod, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
491
- }
492
- if (config.types) {
493
- runTypesGeneration(config.types, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
763
+ }
764
+ // Walk up affected dirs and remove if empty. Bounded — stops at filesystem root or first non-empty dir.
765
+ for (const dir of removedDirs) {
766
+ let current = dir;
767
+ while (current.length > 1) {
768
+ try {
769
+ if (readdirSync(current).length === 0) {
770
+ rmdirSync(current);
771
+ current = dirname(current);
772
+ } else {
773
+ break;
774
+ }
775
+ } catch {
776
+ break;
777
+ }
494
778
  }
495
- },
496
- };
497
-
498
- export default plugin;
499
-
500
- // ─── Factory: for programmatic use with explicit config ────────────────────
779
+ }
780
+ }
501
781
 
502
- /**
503
- * Build a `@contractkit/plugin-typescript` instance with explicit configuration, for
504
- * programmatic use (tests, custom build scripts). Prefer the default export when loading
505
- * via `contractkit.config.json`.
506
- */
507
- export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir: string): ContractKitPlugin {
508
- return {
509
- name: 'typescript',
510
- cacheKey: `typescript:${JSON.stringify(config)}`,
511
- async generateTargets(inputs, ctx) {
512
- if (config.server) {
513
- runServerGeneration(config.server, rootDir, inputs, ctx.emitFile.bind(ctx));
514
- }
515
- if (config.sdk) {
516
- runSdkGeneration(config.sdk, rootDir, inputs, ctx.emitFile.bind(ctx));
517
- }
518
- if (config.zod) {
519
- runZodGeneration(config.zod, rootDir, inputs, ctx.emitFile.bind(ctx));
520
- }
521
- if (config.types) {
522
- runTypesGeneration(config.types, rootDir, inputs, ctx.emitFile.bind(ctx));
523
- }
524
- },
525
- };
782
+ /** Stringify a sub-config so it can participate in fingerprints. JSON.stringify gives stable output for typical config shapes. */
783
+ function stableSubConfig(config: unknown): string {
784
+ return JSON.stringify(config ?? null);
526
785
  }