@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/.turbo/turbo-build$colon$ci.log +7 -7
- package/.turbo/turbo-test$colon$ci.log +15 -15
- package/CHANGELOG.md +21 -0
- package/README.md +10 -1
- package/coverage/clover.xml +795 -573
- package/coverage/coverage-final.json +6 -6
- package/coverage/index.html +19 -19
- package/coverage/src/codegen-contract.ts.html +3 -3
- package/coverage/src/codegen-operation.ts.html +1 -1
- package/coverage/src/codegen-plain-types.ts.html +1 -1
- package/coverage/src/codegen-sdk.ts.html +989 -236
- package/coverage/src/index.html +39 -39
- package/coverage/src/index.ts.html +1386 -306
- package/coverage/src/path-utils.ts.html +51 -51
- package/coverage/src/ts-render.ts.html +4 -4
- package/coverage/tests/helpers.ts.html +15 -15
- package/coverage/tests/index.html +1 -1
- package/dist/codegen-sdk.d.ts +96 -2
- package/dist/codegen-sdk.d.ts.map +1 -1
- package/dist/index.d.ts +8 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +685 -137
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/codegen-sdk.ts +258 -7
- package/src/index.ts +556 -196
- package/tests/codegen-sdk.test.ts +188 -16
- package/tests/codegen-server.test.ts +101 -1
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 {
|
|
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}.
|
|
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.
|
|
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
|
-
// ───
|
|
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
|
|
255
|
+
function collectServerOutput(
|
|
108
256
|
config: ServerConfig,
|
|
109
257
|
rootDir: string,
|
|
110
258
|
inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
|
|
111
|
-
|
|
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
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
// ──
|
|
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
|
|
156
|
-
|
|
319
|
+
const refs = collectOpRootRefs(ast, modelMap);
|
|
320
|
+
const fingerprint = hashFingerprint({
|
|
321
|
+
kind: 'server-router',
|
|
322
|
+
v: TYPESCRIPT_CODEGEN_VERSION,
|
|
157
323
|
outPath,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
modelsWithOutput,
|
|
161
|
-
|
|
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
|
|
353
|
+
// ─── SDK sub-generator ─────────────────────────────────────────────────────
|
|
168
354
|
|
|
169
|
-
function
|
|
355
|
+
function collectSdkOutput(
|
|
170
356
|
config: SdkConfig,
|
|
171
357
|
rootDir: string,
|
|
172
358
|
inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
261
|
-
|
|
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
|
-
|
|
264
|
-
if (
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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
|
|
636
|
+
// ─── Zod sub-generator ─────────────────────────────────────────────────────
|
|
304
637
|
|
|
305
|
-
function
|
|
638
|
+
function collectZodOutput(
|
|
306
639
|
config: ZodConfig,
|
|
307
640
|
rootDir: string,
|
|
308
641
|
inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
|
|
309
|
-
|
|
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:
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
// ───
|
|
690
|
+
// ─── Plain types sub-generator ─────────────────────────────────────────────
|
|
342
691
|
|
|
343
|
-
function
|
|
692
|
+
function collectTypesOutput(
|
|
344
693
|
config: TypesConfig,
|
|
345
694
|
rootDir: string,
|
|
346
695
|
inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
|
|
347
|
-
|
|
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:
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
// ───
|
|
744
|
+
// ─── Manifest IO + cleanup ─────────────────────────────────────────────────
|
|
380
745
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
397
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
}
|