@contractkit/plugin-typescript 0.17.5 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,14 +1,37 @@
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,
8
26
  generateSdkAggregator,
9
27
  deriveClientClassName,
10
28
  deriveClientPropertyName,
29
+ deriveSubareaClientClassName,
30
+ deriveSubareaPropertyName,
31
+ getAreaSubarea,
11
32
  hasPublicOperations,
33
+ type SdkClientInfo,
34
+ type SdkAreaInfo,
12
35
  } from './codegen-sdk.js';
13
36
  import { generatePlainTypes } from './codegen-plain-types.js';
14
37
  import {
@@ -28,149 +51,313 @@ import {
28
51
  export interface ServerConfig {
29
52
  /** Directory (relative to rootDir) where server files are written. Default: rootDir. */
30
53
  baseDir?: string;
31
- /**
32
- * When true, `output.types` emits Zod schema files (via `generateContract`).
33
- * When false/omitted, `output.types` emits plain TypeScript interfaces.
34
- */
54
+ /** When true, `output.types` emits Zod schema files (via `generateContract`). When false/omitted, emits plain TypeScript. */
35
55
  zod?: boolean;
36
56
  output?: {
37
- /** 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}. */
38
58
  routes?: string;
39
- /**
40
- * Path template for type/schema files. Supports {filename}, {dir}, {area}.
41
- * Generates Zod schemas when `zod: true`, otherwise plain TypeScript interfaces.
42
- */
59
+ /** Path template for type/schema files. Supports {filename}, {dir}, {area}. */
43
60
  types?: string;
44
61
  };
45
- /** Import path template for service implementations. Supports {module}. */
62
+ /** Import path template for service implementations. */
46
63
  servicePathTemplate?: string;
47
- /**
48
- * Whether to emit handlers for operations marked `internal`. Defaults to `true` —
49
- * the server still needs routes for internal endpoints. Set to `false` to omit them.
50
- */
64
+ /** Whether to emit handlers for `internal` operations. Default true. */
51
65
  includeInternal?: boolean;
52
66
  }
53
67
 
54
68
  export interface SdkConfig {
55
- /** Directory (relative to rootDir) where SDK files are written. Default: rootDir. */
56
69
  baseDir?: string;
57
- /** Name used for the aggregator SDK class (e.g. "homegrown" → `HomegrownSdk`). */
58
70
  name?: string;
59
- /**
60
- * When true, `output.types` emits Zod schema files (via `generateContract`).
61
- * When false/omitted, `output.types` emits plain TypeScript interfaces.
62
- */
63
71
  zod?: boolean;
64
72
  output?: {
65
- /** Path template for the SDK aggregator file. Supports {name}. Default: `sdk.ts`. */
66
73
  sdk?: string;
67
- /** Path template for SDK type files. Supports {filename}, {dir}, {area}. */
68
74
  types?: string;
69
- /** Path template for client class files. Supports {filename}, {dir}, {area}. */
70
75
  clients?: string;
71
76
  };
72
- /**
73
- * Whether to emit SDK methods for operations marked `internal`. Defaults to `false` —
74
- * internal ops are omitted from the SDK so consumers don't pick them up. Set to `true`
75
- * for an internal-use SDK that should expose them.
76
- */
77
77
  includeInternal?: boolean;
78
78
  }
79
79
 
80
80
  export interface ZodConfig {
81
- /** Directory (relative to rootDir) where Zod schema files are written. Default: rootDir. */
82
81
  baseDir?: string;
83
- /** Output path template. Supports {filename}, {dir}. Default: `{filename}.schema.ts` alongside source. */
84
82
  output?: string;
85
83
  }
86
84
 
87
85
  export interface TypesConfig {
88
- /** Directory (relative to rootDir) where plain TypeScript type files are written. Default: rootDir. */
89
86
  baseDir?: string;
90
- /** Output path template. Supports {filename}, {dir}. Default: `{filename}.types.ts` alongside source. */
91
87
  output?: string;
92
88
  }
93
89
 
94
90
  export interface TypescriptPluginConfig {
95
- /** Generate Koa router files from `operation` declarations. */
96
91
  server?: ServerConfig;
97
- /** Generate TypeScript SDK client files from `operation` declarations. */
98
92
  sdk?: SdkConfig;
99
- /** Generate Zod schema files from `contract` declarations. */
100
93
  zod?: ZodConfig;
101
- /** Generate plain TypeScript interface/type files from `contract` declarations (no Zod runtime). */
102
94
  types?: TypesConfig;
103
95
  }
104
96
 
105
- // ─── 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
+ }
191
+
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 ──────────────────────────────────────────────────
106
254
 
107
- function runServerGeneration(
255
+ function collectServerOutput(
108
256
  config: ServerConfig,
109
257
  rootDir: string,
110
258
  inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
111
- emitFile: (outPath: string, content: string) => void,
259
+ units: IncrementalUnit[],
112
260
  ): void {
113
261
  const serverBase = resolve(rootDir, config.baseDir ?? '.');
114
262
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
115
263
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
264
+ const modelMap = buildModelMap(inputs.contractRoots);
116
265
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
117
266
  const commonRoot = commonDir(allFiles, rootDir);
267
+ const subConfigKey = stableSubConfig(config);
118
268
 
119
- // ── Types / Zod output ──
120
- // When output.types is configured we generate type files ourselves and build a
121
- // local modelOutPaths map so the router generator can resolve import paths.
122
- let serverModelOutPaths = new Map<string, string>();
123
-
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 }[] = [];
124
274
  if (config.output?.types) {
125
- serverModelOutPaths = new Map();
126
-
127
- // Pass 1: register all model → outPath entries before generating content,
128
- // so cross-file type refs resolve correctly.
129
- const typeEntries: { ast: (typeof inputs.contractRoots)[number]; typeOutPath: string }[] = [];
130
275
  for (const ast of inputs.contractRoots) {
131
276
  const typeOutPath = computeContractOutPath(ast.file, serverBase, config.output.types, '.ts', commonRoot, ast.meta);
132
277
  typeEntries.push({ ast, typeOutPath });
133
278
  for (const model of ast.models) {
134
279
  serverModelOutPaths.set(model.name, typeOutPath);
135
- if (modelsWithInput.has(model.name)) {
136
- serverModelOutPaths.set(`${model.name}Input`, typeOutPath);
137
- }
138
- if (modelsWithOutput.has(model.name)) {
139
- serverModelOutPaths.set(`${model.name}Output`, typeOutPath);
140
- }
280
+ if (modelsWithInput.has(model.name)) serverModelOutPaths.set(`${model.name}Input`, typeOutPath);
281
+ if (modelsWithOutput.has(model.name)) serverModelOutPaths.set(`${model.name}Output`, typeOutPath);
141
282
  }
142
283
  }
284
+ }
143
285
 
144
- // Pass 2: emit type files.
145
- for (const { ast, typeOutPath } of typeEntries) {
146
- const ctx = { modelOutPaths: serverModelOutPaths, currentOutPath: typeOutPath, modelsWithInput, modelsWithOutput };
147
- const content = config.zod ? generateContract(ast, ctx) : generatePlainTypes(ast, ctx);
148
- emitFile(typeOutPath, content);
149
- }
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
+ });
150
314
  }
151
315
 
152
- // ── Routes output ──
316
+ // ── Per-op-root router unit ──
153
317
  for (const ast of inputs.opRoots) {
154
318
  const outPath = computeOpOutPath(ast.file, serverBase, config.output?.routes, '.router.ts', commonRoot, ast.meta);
155
- const content = generateOp(ast, {
156
- servicePathTemplate: config.servicePathTemplate,
319
+ const refs = collectOpRootRefs(ast, modelMap);
320
+ const fingerprint = hashFingerprint({
321
+ kind: 'server-router',
322
+ v: TYPESCRIPT_CODEGEN_VERSION,
157
323
  outPath,
158
- modelOutPaths: serverModelOutPaths,
159
- modelsWithInput,
160
- modelsWithOutput,
161
- 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
+ ],
162
349
  });
163
- emitFile(outPath, content);
164
350
  }
165
351
  }
166
352
 
167
- // ─── SDK generation ────────────────────────────────────────────────────────
353
+ // ─── SDK sub-generator ─────────────────────────────────────────────────────
168
354
 
169
- function runSdkGeneration(
355
+ function collectSdkOutput(
170
356
  config: SdkConfig,
171
357
  rootDir: string,
172
358
  inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
173
- emitFile: (outPath: string, content: string) => void,
359
+ units: IncrementalUnit[],
360
+ globalFiles: IncrementalOutputFile[],
174
361
  ): void {
175
362
  const sdkBase = config.baseDir ? resolve(rootDir, config.baseDir) : rootDir;
176
363
  const sdkName = config.name;
@@ -179,22 +366,22 @@ function runSdkGeneration(
179
366
  ? join(sdkBase, TEMPLATE_VAR_RE.test(sdkOutput) ? resolveTemplate(sdkOutput, { name: sdkName ?? 'sdk' }) : sdkOutput)
180
367
  : join(sdkBase, 'sdk.ts');
181
368
  const sdkOptionsPath = join(dirname(sdkEntryPath), 'sdk-options.ts');
369
+ const subConfigKey = stableSubConfig(config);
182
370
 
183
371
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
184
372
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
373
+ const modelMap = buildModelMap(inputs.contractRoots);
185
374
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
186
375
  const ckCommonRoot = commonDir(allFiles, rootDir);
187
376
 
188
- let sdkModelOutPaths = new Map<string, string>();
377
+ const sdkModelOutPaths = new Map<string, string>();
189
378
  const sdkTypePaths: string[] = [];
190
379
  const sdkClientInfos: { outPath: string; className: string; propertyName: string }[] = [];
191
380
 
192
- // ── SDK types ──
381
+ // ── Pre-pass: SDK type files ──
382
+ const sdkContractEntries: { ast: ContractRootNode; typeOutPath: string }[] = [];
193
383
  if (config.output?.types) {
194
- sdkModelOutPaths = new Map<string, string>();
195
384
  const publicTypes = computePubliclyReachableTypes(inputs.opRoots, inputs.contractRoots, modelsWithInput, modelsWithOutput);
196
-
197
- const sdkContractEntries: { ast: (typeof inputs.contractRoots)[number]; typeOutPath: string }[] = [];
198
385
  for (const ast of inputs.contractRoots) {
199
386
  const typeOutPath = computeSdkTypeOutPath(ast.file, sdkBase, config.output.types, ckCommonRoot, ast.meta);
200
387
  if (!typeOutPath) continue;
@@ -207,86 +394,229 @@ function runSdkGeneration(
207
394
  if (modelsWithOutput.has(model.name)) sdkModelOutPaths.set(`${model.name}Output`, typeOutPath);
208
395
  }
209
396
  }
397
+ }
210
398
 
211
- for (const { ast, typeOutPath } of sdkContractEntries) {
212
- let content: string;
213
- if (config.zod) {
214
- content = generateContract(ast, {
215
- modelOutPaths: sdkModelOutPaths,
216
- currentOutPath: typeOutPath,
217
- modelsWithInput,
218
- modelsWithOutput,
219
- });
220
- } else {
221
- let rel = relative(dirname(typeOutPath), sdkOptionsPath).replace(/\.ts$/, '.js');
222
- if (!rel.startsWith('.')) rel = './' + rel;
223
- content = generatePlainTypes(ast, {
224
- modelOutPaths: sdkModelOutPaths,
225
- currentOutPath: typeOutPath,
226
- modelsWithInput,
227
- modelsWithOutput,
228
- jsonValueImportPath: rel,
229
- });
230
- }
231
- emitFile(typeOutPath, content);
232
- }
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
+ });
233
440
  }
234
441
 
235
- // ── SDK clients ──
442
+ // ── Bucket op roots by area/subarea ──
443
+ interface AreaBucket {
444
+ leaves: { ast: OpRootNode; outPath: string; subarea: string }[];
445
+ inlineRoots: OpRootNode[];
446
+ }
447
+ const areaBuckets = new Map<string, AreaBucket>();
448
+ const topLevelEntries: { ast: OpRootNode; outPath: string }[] = [];
449
+
236
450
  if (config.output?.clients) {
237
451
  for (const ast of inputs.opRoots) {
238
452
  const sdkOutPath = computeSdkOutPath(ast.file, sdkBase, config.output.clients, ckCommonRoot, ast.meta);
239
453
  if (!sdkOutPath || !hasPublicOperations(ast, config.includeInternal)) continue;
240
- sdkClientInfos.push({
241
- outPath: sdkOutPath,
242
- className: deriveClientClassName(ast.file),
243
- propertyName: deriveClientPropertyName(ast.file),
244
- });
245
- emitFile(
246
- sdkOutPath,
247
- generateSdk(ast, {
248
- typeImportPathTemplate: undefined,
249
- outPath: sdkOutPath,
250
- modelOutPaths: sdkModelOutPaths,
454
+ const { area, subarea } = getAreaSubarea(ast);
455
+ if (area && subarea) {
456
+ const bucket = areaBuckets.get(area) ?? { leaves: [], inlineRoots: [] };
457
+ bucket.leaves.push({ ast, outPath: sdkOutPath, subarea });
458
+ areaBuckets.set(area, bucket);
459
+ } else if (area) {
460
+ const bucket = areaBuckets.get(area) ?? { leaves: [], inlineRoots: [] };
461
+ bucket.inlineRoots.push(ast);
462
+ areaBuckets.set(area, bucket);
463
+ } else {
464
+ topLevelEntries.push({ ast, outPath: sdkOutPath });
465
+ }
466
+ }
467
+
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),
251
482
  sdkOptionsPath,
252
- modelsWithInput,
253
- modelsWithOutput,
254
- includeInternal: config.includeInternal,
255
- }),
256
- );
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
+ }
507
+ }
508
+
509
+ // ── Top-level (no area) client units ──
510
+ for (const { ast, outPath } of topLevelEntries) {
511
+ const className = deriveClientClassName(ast.file);
512
+ sdkClientInfos.push({ outPath, className, propertyName: deriveClientPropertyName(ast.file) });
513
+ const refs = collectOpRootRefs(ast, modelMap);
514
+ const fingerprint = hashFingerprint({
515
+ kind: 'sdk-top-client',
516
+ v: TYPESCRIPT_CODEGEN_VERSION,
517
+ outPath,
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
+ });
257
544
  }
258
545
  }
259
546
 
260
- // ── sdk-options.ts ──
261
- 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() });
262
552
 
263
- // ── sdk.ts aggregator ──
264
- if (sdkClientInfos.length > 0) {
553
+ const hasAnything = sdkClientInfos.length > 0 || areaBuckets.size > 0;
554
+ if (hasAnything) {
265
555
  const sdkEntryDir = dirname(sdkEntryPath);
266
- const clients = sdkClientInfos.map(c => {
267
- let rel = relative(sdkEntryDir, c.outPath).replace(/\.ts$/, '.js');
268
- if (!rel.startsWith('.')) rel = './' + rel;
269
- return { className: c.className, propertyName: c.propertyName, importPath: rel };
270
- });
271
556
  const sdkOptionsRel = relative(sdkEntryDir, sdkOptionsPath).replace(/\.ts$/, '.js');
557
+ const sdkOptionsImportPath = sdkOptionsRel.startsWith('.') ? sdkOptionsRel : './' + sdkOptionsRel;
272
558
  const sdkClassName = sdkName
273
559
  ? sdkName
274
560
  .split(/[-._\s]+/)
275
561
  .map(s => s.charAt(0).toUpperCase() + s.slice(1))
276
562
  .join('') + 'Sdk'
277
563
  : 'Sdk';
278
- emitFile(sdkEntryPath, generateSdkAggregator(clients, sdkOptionsRel.startsWith('.') ? sdkOptionsRel : './' + sdkOptionsRel, sdkClassName));
564
+
565
+ const toClientImport = (info: { outPath: string; className: string; propertyName: string }): SdkClientInfo => {
566
+ let rel = relative(sdkEntryDir, info.outPath).replace(/\.ts$/, '.js');
567
+ if (!rel.startsWith('.')) rel = './' + rel;
568
+ return { className: info.className, propertyName: info.propertyName, importPath: rel };
569
+ };
570
+
571
+ const topLevelClients: SdkClientInfo[] = topLevelEntries.map(e => ({
572
+ className: deriveClientClassName(e.ast.file),
573
+ propertyName: deriveClientPropertyName(e.ast.file),
574
+ importPath: (() => {
575
+ const rel = relative(sdkEntryDir, e.outPath).replace(/\.ts$/, '.js');
576
+ return rel.startsWith('.') ? rel : './' + rel;
577
+ })(),
578
+ }));
579
+
580
+ const areas: SdkAreaInfo[] = [...areaBuckets.entries()]
581
+ .sort(([a], [b]) => a.localeCompare(b))
582
+ .map(([area, bucket]) => ({
583
+ area,
584
+ inlineFiles: bucket.inlineRoots.map(root => ({
585
+ root,
586
+ codegenOptions: {
587
+ typeImportPathTemplate: undefined,
588
+ outPath: sdkEntryPath,
589
+ modelOutPaths: sdkModelOutPaths,
590
+ sdkOptionsPath,
591
+ modelsWithInput,
592
+ modelsWithOutput,
593
+ includeInternal: config.includeInternal,
594
+ },
595
+ })),
596
+ subareaClients: bucket.leaves
597
+ .sort((a, b) => a.subarea.localeCompare(b.subarea))
598
+ .map(l => ({
599
+ propertyName: deriveSubareaPropertyName(l.subarea),
600
+ client: toClientImport({
601
+ outPath: l.outPath,
602
+ className: deriveSubareaClientClassName(area, l.subarea),
603
+ propertyName: deriveSubareaPropertyName(l.subarea),
604
+ }),
605
+ })),
606
+ }));
607
+
608
+ globalFiles.push({
609
+ relativePath: sdkEntryPath,
610
+ content: generateSdkAggregator({ topLevelClients, areas, sdkOptionsImportPath, sdkClassName }),
611
+ });
279
612
  }
280
613
 
281
- // ── Barrel files ──
282
614
  const sdkSrcDir = dirname(sdkEntryPath);
283
615
  const sdkTypeBarrels = generateBarrelFiles(sdkTypePaths);
284
- for (const barrel of sdkTypeBarrels) emitFile(barrel.outPath, barrel.content);
616
+ for (const barrel of sdkTypeBarrels) globalFiles.push({ relativePath: barrel.outPath, content: barrel.content });
285
617
 
286
618
  const rootExports: string[] = [`export * from './${basename(sdkOptionsPath).replace(/\.ts$/, '.js')}';`];
287
- if (sdkClientInfos.length > 0) {
288
- rootExports.push(`export * from './${basename(sdkEntryPath).replace(/\.ts$/, '.js')}';`);
289
- }
619
+ if (hasAnything) rootExports.push(`export * from './${basename(sdkEntryPath).replace(/\.ts$/, '.js')}';`);
290
620
  for (const c of sdkClientInfos) {
291
621
  let rel = relative(sdkSrcDir, c.outPath).replace(/\.ts$/, '.js');
292
622
  if (!rel.startsWith('.')) rel = './' + rel;
@@ -297,26 +627,30 @@ function runSdkGeneration(
297
627
  if (!rel.startsWith('.')) rel = './' + rel;
298
628
  rootExports.push(`export * from '${rel}';`);
299
629
  }
300
- 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
+ });
301
634
  }
302
635
 
303
- // ─── Zod generation ────────────────────────────────────────────────────────
636
+ // ─── Zod sub-generator ─────────────────────────────────────────────────────
304
637
 
305
- function runZodGeneration(
638
+ function collectZodOutput(
306
639
  config: ZodConfig,
307
640
  rootDir: string,
308
641
  inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
309
- emitFile: (outPath: string, content: string) => void,
642
+ units: IncrementalUnit[],
310
643
  ): void {
311
644
  const zodBase = resolve(rootDir, config.baseDir ?? '.');
312
645
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
313
646
  const commonRoot = commonDir(allFiles, rootDir);
314
647
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
315
648
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
649
+ const modelMap = buildModelMap(inputs.contractRoots);
650
+ const subConfigKey = stableSubConfig(config);
316
651
 
317
- // Pre-pass: register all model → outPath before generating, so cross-file imports resolve.
318
652
  const modelOutPaths = new Map<string, string>();
319
- const entries: { ast: (typeof inputs.contractRoots)[number]; outPath: string }[] = [];
653
+ const entries: { ast: ContractRootNode; outPath: string }[] = [];
320
654
  for (const ast of inputs.contractRoots) {
321
655
  const outPath = computeContractOutPath(ast.file, zodBase, config.output, '.schema.ts', commonRoot, ast.meta);
322
656
  entries.push({ ast, outPath });
@@ -328,33 +662,49 @@ function runZodGeneration(
328
662
  }
329
663
 
330
664
  for (const { ast, outPath } of entries) {
331
- const content = generateContract(ast, {
332
- modelOutPaths,
333
- currentOutPath: outPath,
334
- modelsWithInput,
335
- 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
+ ],
336
686
  });
337
- emitFile(outPath, content);
338
687
  }
339
688
  }
340
689
 
341
- // ─── Types generation ──────────────────────────────────────────────────────
690
+ // ─── Plain types sub-generator ─────────────────────────────────────────────
342
691
 
343
- function runTypesGeneration(
692
+ function collectTypesOutput(
344
693
  config: TypesConfig,
345
694
  rootDir: string,
346
695
  inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
347
- emitFile: (outPath: string, content: string) => void,
696
+ units: IncrementalUnit[],
348
697
  ): void {
349
698
  const typesBase = resolve(rootDir, config.baseDir ?? '.');
350
699
  const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
351
700
  const commonRoot = commonDir(allFiles, rootDir);
352
701
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
353
702
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
703
+ const modelMap = buildModelMap(inputs.contractRoots);
704
+ const subConfigKey = stableSubConfig(config);
354
705
 
355
- // Pre-pass: register all model → outPath before generating.
356
706
  const modelOutPaths = new Map<string, string>();
357
- const entries: { ast: (typeof inputs.contractRoots)[number]; outPath: string }[] = [];
707
+ const entries: { ast: ContractRootNode; outPath: string }[] = [];
358
708
  for (const ast of inputs.contractRoots) {
359
709
  const outPath = computeContractOutPath(ast.file, typesBase, config.output, '.types.ts', commonRoot, ast.meta);
360
710
  entries.push({ ast, outPath });
@@ -366,60 +716,70 @@ function runTypesGeneration(
366
716
  }
367
717
 
368
718
  for (const { ast, outPath } of entries) {
369
- const content = generatePlainTypes(ast, {
370
- modelOutPaths,
371
- currentOutPath: outPath,
372
- modelsWithInput,
373
- 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
+ ],
374
740
  });
375
- emitFile(outPath, content);
376
741
  }
377
742
  }
378
743
 
379
- // ─── Combined plugin ────────────────────────────────────────────────────────
744
+ // ─── Manifest IO + cleanup ─────────────────────────────────────────────────
380
745
 
381
- const plugin: ContractKitPlugin = {
382
- name: 'typescript',
383
- cacheKey: 'typescript',
384
- async generateTargets(inputs, ctx) {
385
- 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
+ }
386
754
 
387
- if (config.server) {
388
- runServerGeneration(config.server, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
389
- }
390
- if (config.sdk) {
391
- runSdkGeneration(config.sdk, ctx.rootDir, inputs, ctx.emitFile.bind(ctx));
392
- }
393
- if (config.zod) {
394
- runZodGeneration(config.zod, 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));
395
762
  }
396
- if (config.types) {
397
- 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
+ }
398
778
  }
399
- },
400
- };
401
-
402
- export default plugin;
403
-
404
- // ─── Factory: for programmatic use with explicit config ────────────────────
779
+ }
780
+ }
405
781
 
406
- export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir: string): ContractKitPlugin {
407
- return {
408
- name: 'typescript',
409
- cacheKey: `typescript:${JSON.stringify(config)}`,
410
- async generateTargets(inputs, ctx) {
411
- if (config.server) {
412
- runServerGeneration(config.server, rootDir, inputs, ctx.emitFile.bind(ctx));
413
- }
414
- if (config.sdk) {
415
- runSdkGeneration(config.sdk, rootDir, inputs, ctx.emitFile.bind(ctx));
416
- }
417
- if (config.zod) {
418
- runZodGeneration(config.zod, rootDir, inputs, ctx.emitFile.bind(ctx));
419
- }
420
- if (config.types) {
421
- runTypesGeneration(config.types, rootDir, inputs, ctx.emitFile.bind(ctx));
422
- }
423
- },
424
- };
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);
425
785
  }