@ts-kizuna/cli 1.49.5
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/LICENSE.MD +9 -0
- package/README.md +21 -0
- package/dist/cli.cjs +61 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +62 -0
- package/dist/index.cjs +8 -0
- package/dist/index.d.cts +71 -0
- package/dist/index.d.mts +72 -0
- package/dist/index.mjs +2 -0
- package/dist/lint-deprecations-CmqMnr7K.cjs +735 -0
- package/dist/lint-deprecations-Dx9WRFQa.mjs +669 -0
- package/package.json +78 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import ts from "typescript";
|
|
4
|
+
import { AUTHORING_NAMES, CONTRACT_EXPORT_NAMES } from "@ts-kizuna/core/authoring-names";
|
|
5
|
+
import { contractFingerprint, serializeDeprecationMap } from "@ts-kizuna/core/generator";
|
|
6
|
+
import { createJiti } from "jiti";
|
|
7
|
+
//#region src/schema-exports.ts
|
|
8
|
+
/**
|
|
9
|
+
* Maps every exported schema reachable from `entryPath` to the verbatim JSDoc
|
|
10
|
+
* block on each of its fields, keyed by the exported const name (and, for
|
|
11
|
+
* aliased re-exports, by the original declared name too).
|
|
12
|
+
*
|
|
13
|
+
* Walks the whole reachable graph, both the entry's re-exports and every file
|
|
14
|
+
* it imports, so a schema defined in its own module and used via `import`
|
|
15
|
+
* (rather than re-exported from the contract entry) is still collected.
|
|
16
|
+
*
|
|
17
|
+
* Used to patch emitted `.d.ts` files, where a `z.ZodObject<{...}>` shape is
|
|
18
|
+
* keyed by the `declare const` name rather than a route or schema `meta.id`.
|
|
19
|
+
*/
|
|
20
|
+
const collectExportedSchemaDocs = (entryPath) => {
|
|
21
|
+
const { resolve, cache } = makeResolverWithCache(entryPath);
|
|
22
|
+
const result = /* @__PURE__ */ new Map();
|
|
23
|
+
const visited = /* @__PURE__ */ new Set();
|
|
24
|
+
const record = (name, expression) => {
|
|
25
|
+
const fields = /* @__PURE__ */ new Map();
|
|
26
|
+
collectFieldDocs(expression, "", fields, resolve);
|
|
27
|
+
if (fields.size > 0) result.set(name, fields);
|
|
28
|
+
};
|
|
29
|
+
const collectFromFile = (filePath) => {
|
|
30
|
+
if (visited.has(filePath)) return;
|
|
31
|
+
visited.add(filePath);
|
|
32
|
+
const sourceFile = ts.createSourceFile(filePath, fs.readFileSync(filePath, "utf8"), ts.ScriptTarget.Latest, true);
|
|
33
|
+
for (const statement of sourceFile.statements) {
|
|
34
|
+
if (ts.isVariableStatement(statement)) {
|
|
35
|
+
if (!(statement.modifiers ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
36
|
+
for (const declaration of statement.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.initializer) record(declaration.name.text, declaration.initializer);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!ts.isExportDeclaration(statement) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
40
|
+
const specifier = statement.moduleSpecifier.text;
|
|
41
|
+
if (!specifier.startsWith(".")) continue;
|
|
42
|
+
const target = resolveImportPath(path.dirname(filePath), specifier);
|
|
43
|
+
if (!target) continue;
|
|
44
|
+
const exportClause = statement.exportClause;
|
|
45
|
+
if (!exportClause) {
|
|
46
|
+
collectFromFile(target);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!ts.isNamedExports(exportClause)) continue;
|
|
50
|
+
const targetScope = buildFileScope(target, cache);
|
|
51
|
+
for (const element of exportClause.elements) {
|
|
52
|
+
const localName = (element.propertyName ?? element.name).text;
|
|
53
|
+
const exposedName = element.name.text;
|
|
54
|
+
const expression = targetScope.get(localName);
|
|
55
|
+
if (!expression) continue;
|
|
56
|
+
record(exposedName, expression);
|
|
57
|
+
if (localName !== exposedName) record(localName, expression);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
collectFromFile(entryPath);
|
|
62
|
+
for (const filePath of [...cache.keys()]) collectFromFile(filePath);
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region src/dts-jsdoc.ts
|
|
67
|
+
/**
|
|
68
|
+
* Zod wrapper types whose inner type is the first type argument. Navigating a
|
|
69
|
+
* field path unwraps these until it reaches a `ZodObject`. Mirrors the runtime
|
|
70
|
+
* wrapper set in `zod-internals.ts`.
|
|
71
|
+
*/
|
|
72
|
+
const ELEMENT_WRAPPERS = new Set([
|
|
73
|
+
"ZodArray",
|
|
74
|
+
"ZodOptional",
|
|
75
|
+
"ZodNullable",
|
|
76
|
+
"ZodDefault",
|
|
77
|
+
"ZodReadonly",
|
|
78
|
+
"ZodNonOptional",
|
|
79
|
+
"ZodCatch",
|
|
80
|
+
"ZodPrefault"
|
|
81
|
+
]);
|
|
82
|
+
const entityNameRight = (name) => ts.isQualifiedName(name) ? name.right.text : name.text;
|
|
83
|
+
/**
|
|
84
|
+
* The right-most type name, ignoring the qualifier, so `ZodObject`,
|
|
85
|
+
* `z.ZodObject`, and `import("zod").ZodObject` all read as `ZodObject`.
|
|
86
|
+
*/
|
|
87
|
+
const rightmostTypeName = (node) => {
|
|
88
|
+
if (ts.isTypeReferenceNode(node)) return entityNameRight(node.typeName);
|
|
89
|
+
if (ts.isImportTypeNode(node)) return node.qualifier ? entityNameRight(node.qualifier) : void 0;
|
|
90
|
+
};
|
|
91
|
+
const typeArgumentsOf = (node) => {
|
|
92
|
+
if (ts.isTypeReferenceNode(node)) return node.typeArguments ?? [];
|
|
93
|
+
if (ts.isImportTypeNode(node)) return node.typeArguments ?? [];
|
|
94
|
+
return [];
|
|
95
|
+
};
|
|
96
|
+
const propertySignatureName = (node) => {
|
|
97
|
+
if (ts.isIdentifier(node)) return node.text;
|
|
98
|
+
if (ts.isStringLiteral(node)) return node.text;
|
|
99
|
+
if (ts.isNumericLiteral(node)) return node.text;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* Walks a dotted field path through a Zod schema type to the leaf property
|
|
103
|
+
* signature. Unwraps element wrappers (`ZodArray`, `ZodOptional`, …) and reads
|
|
104
|
+
* each `ZodObject`'s shape from its first type argument. Returns undefined and
|
|
105
|
+
* never throws when the path can't be navigated (e.g. the shape was emitted as
|
|
106
|
+
* a named reference rather than a literal).
|
|
107
|
+
*/
|
|
108
|
+
const findPropertySignature = (typeNode, segments) => {
|
|
109
|
+
let current = typeNode;
|
|
110
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
111
|
+
let guard = 0;
|
|
112
|
+
let name = rightmostTypeName(current);
|
|
113
|
+
while (name !== void 0 && ELEMENT_WRAPPERS.has(name)) {
|
|
114
|
+
const args = typeArgumentsOf(current);
|
|
115
|
+
if (args.length === 0) return void 0;
|
|
116
|
+
current = args[0];
|
|
117
|
+
name = rightmostTypeName(current);
|
|
118
|
+
guard += 1;
|
|
119
|
+
if (guard > 32) return void 0;
|
|
120
|
+
}
|
|
121
|
+
if (name !== "ZodObject") return void 0;
|
|
122
|
+
const shape = typeArgumentsOf(current)[0];
|
|
123
|
+
if (!shape || !ts.isTypeLiteralNode(shape)) return void 0;
|
|
124
|
+
const member = shape.members.find((candidate) => ts.isPropertySignature(candidate) && propertySignatureName(candidate.name) === segments[index]);
|
|
125
|
+
if (!member) return void 0;
|
|
126
|
+
if (index === segments.length - 1) return member;
|
|
127
|
+
if (!member.type) return void 0;
|
|
128
|
+
current = member.type;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const hasLeadingJsDoc = (member, fullText) => {
|
|
132
|
+
return (ts.getLeadingCommentRanges(fullText, member.getFullStart()) ?? []).some((range) => fullText.slice(range.pos, range.pos + 3) === "/**");
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Re-indents a captured JSDoc block to `indent`: the opening `/**` keeps the
|
|
136
|
+
* property's own indentation (already present before the splice point), and each
|
|
137
|
+
* continuation line is re-prefixed so the `*` column lines up.
|
|
138
|
+
*/
|
|
139
|
+
const reindentJsDoc = (block, indent) => block.split("\n").map((line, index) => index === 0 ? line.trim() : `${indent} ${line.trim()}`).join("\n");
|
|
140
|
+
const indentAt = (text, offset) => {
|
|
141
|
+
const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
|
|
142
|
+
return text.slice(lineStart, offset).match(/^[ \t]*/)?.[0] ?? "";
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Maps each locally declared name to the names it's re-exported under in the
|
|
146
|
+
* same file, e.g. `export { i as UserSchema }` from a bundled chunk where the
|
|
147
|
+
* `declare const` is named `i`.
|
|
148
|
+
*/
|
|
149
|
+
const localExportAliases = (sourceFile) => {
|
|
150
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
151
|
+
for (const statement of sourceFile.statements) {
|
|
152
|
+
if (!ts.isExportDeclaration(statement) || statement.moduleSpecifier) continue;
|
|
153
|
+
if (!statement.exportClause || !ts.isNamedExports(statement.exportClause)) continue;
|
|
154
|
+
for (const element of statement.exportClause.elements) {
|
|
155
|
+
const localName = (element.propertyName ?? element.name).text;
|
|
156
|
+
const list = aliases.get(localName) ?? [];
|
|
157
|
+
list.push(element.name.text);
|
|
158
|
+
aliases.set(localName, list);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return aliases;
|
|
162
|
+
};
|
|
163
|
+
const patchFile = (filePath, exportFieldMap) => {
|
|
164
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
165
|
+
const sourceFile = ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true);
|
|
166
|
+
const aliases = localExportAliases(sourceFile);
|
|
167
|
+
const insertions = [];
|
|
168
|
+
for (const statement of sourceFile.statements) {
|
|
169
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
170
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
171
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.type) continue;
|
|
172
|
+
const fields = [declaration.name.text, ...aliases.get(declaration.name.text) ?? []].map((name) => exportFieldMap.get(name)).find(Boolean);
|
|
173
|
+
if (!fields) continue;
|
|
174
|
+
for (const [fieldPath, block] of fields) {
|
|
175
|
+
const member = findPropertySignature(declaration.type, fieldPath.split("."));
|
|
176
|
+
if (!member || hasLeadingJsDoc(member, text)) continue;
|
|
177
|
+
const offset = member.getStart(sourceFile);
|
|
178
|
+
const indent = indentAt(text, offset);
|
|
179
|
+
insertions.push({
|
|
180
|
+
offset,
|
|
181
|
+
text: `${reindentJsDoc(block, indent)}\n${indent}`
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (insertions.length === 0) return 0;
|
|
187
|
+
insertions.sort((left, right) => right.offset - left.offset);
|
|
188
|
+
let output = text;
|
|
189
|
+
for (const insertion of insertions) output = output.slice(0, insertion.offset) + insertion.text + output.slice(insertion.offset);
|
|
190
|
+
fs.writeFileSync(filePath, output, "utf8");
|
|
191
|
+
return insertions.length;
|
|
192
|
+
};
|
|
193
|
+
const DECLARATION_SUFFIXES = [
|
|
194
|
+
".d.ts",
|
|
195
|
+
".d.mts",
|
|
196
|
+
".d.cts"
|
|
197
|
+
];
|
|
198
|
+
const walkDeclarationFiles = (dir, into) => {
|
|
199
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
200
|
+
const full = path.join(dir, entry.name);
|
|
201
|
+
if (entry.isDirectory()) walkDeclarationFiles(full, into);
|
|
202
|
+
else if (entry.isFile() && DECLARATION_SUFFIXES.some((suffix) => entry.name.endsWith(suffix))) into.push(full);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* Re-injects the JSDoc blocks from {@link collectExportedSchemaDocs} onto Zod
|
|
207
|
+
* schema shape properties in the `.d.ts` files under `distDir`. Declaration emit
|
|
208
|
+
* drops the comments an author wrote on schema fields; this restores them, full
|
|
209
|
+
* descriptions, `@deprecated`, `@example`, etc., so they reach `z.infer`
|
|
210
|
+
* consumers in other repos. Skips properties that already have JSDoc. Idempotent.
|
|
211
|
+
*/
|
|
212
|
+
const patchDeclarationDocs = (distDir, exportFieldMap) => {
|
|
213
|
+
const files = [];
|
|
214
|
+
walkDeclarationFiles(distDir, files);
|
|
215
|
+
let filesChanged = 0;
|
|
216
|
+
let injections = 0;
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
const count = patchFile(file, exportFieldMap);
|
|
219
|
+
if (count > 0) {
|
|
220
|
+
filesChanged += 1;
|
|
221
|
+
injections += count;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
filesScanned: files.length,
|
|
226
|
+
filesChanged,
|
|
227
|
+
injections
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
//#endregion
|
|
231
|
+
//#region src/deprecation-parser.ts
|
|
232
|
+
const SCHEMA_KEYS = new Set([
|
|
233
|
+
"body",
|
|
234
|
+
"query",
|
|
235
|
+
"headers"
|
|
236
|
+
]);
|
|
237
|
+
const readDeprecatedMessage = (node) => {
|
|
238
|
+
const tag = ts.getJSDocTags(node).find((candidate) => candidate.tagName.text === "deprecated");
|
|
239
|
+
if (!tag) return void 0;
|
|
240
|
+
return ts.getTextOfJSDocComment(tag.comment) ?? "";
|
|
241
|
+
};
|
|
242
|
+
const propertyName = (node) => {
|
|
243
|
+
if (ts.isIdentifier(node)) return node.text;
|
|
244
|
+
if (ts.isStringLiteral(node)) return node.text;
|
|
245
|
+
if (ts.isNumericLiteral(node)) return node.text;
|
|
246
|
+
};
|
|
247
|
+
const resolveImportPath = (dir, specifier) => {
|
|
248
|
+
const base = specifier.replace(/\.js$/, "");
|
|
249
|
+
for (const candidate of [
|
|
250
|
+
`${base}.ts`,
|
|
251
|
+
`${base}/index.ts`,
|
|
252
|
+
base
|
|
253
|
+
]) {
|
|
254
|
+
const full = path.resolve(dir, candidate);
|
|
255
|
+
if (fs.existsSync(full)) return full;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
const buildFileScope = (filePath, cache) => {
|
|
259
|
+
if (cache.has(filePath)) return cache.get(filePath);
|
|
260
|
+
const scope = /* @__PURE__ */ new Map();
|
|
261
|
+
cache.set(filePath, scope);
|
|
262
|
+
const source = fs.readFileSync(filePath, "utf8");
|
|
263
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
|
264
|
+
for (const statement of sourceFile.statements) {
|
|
265
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
266
|
+
for (const decl of statement.declarationList.declarations) if (ts.isIdentifier(decl.name) && decl.initializer) scope.set(decl.name.text, decl.initializer);
|
|
267
|
+
}
|
|
268
|
+
for (const statement of sourceFile.statements) {
|
|
269
|
+
if (!ts.isImportDeclaration(statement)) continue;
|
|
270
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
271
|
+
const specifier = statement.moduleSpecifier.text;
|
|
272
|
+
if (!specifier.startsWith(".")) continue;
|
|
273
|
+
const importedPath = resolveImportPath(path.dirname(filePath), specifier);
|
|
274
|
+
if (!importedPath) continue;
|
|
275
|
+
const importedScope = buildFileScope(importedPath, cache);
|
|
276
|
+
const namedBindings = statement.importClause?.namedBindings;
|
|
277
|
+
if (!namedBindings || !ts.isNamedImports(namedBindings)) continue;
|
|
278
|
+
for (const element of namedBindings.elements) {
|
|
279
|
+
const exportedName = (element.propertyName ?? element.name).text;
|
|
280
|
+
const localName = element.name.text;
|
|
281
|
+
const expr = importedScope.get(exportedName);
|
|
282
|
+
if (expr) scope.set(localName, expr);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return scope;
|
|
286
|
+
};
|
|
287
|
+
const makeResolverWithCache = (contractPath) => {
|
|
288
|
+
const cache = /* @__PURE__ */ new Map();
|
|
289
|
+
buildFileScope(contractPath, cache);
|
|
290
|
+
const resolve = (node) => {
|
|
291
|
+
const filePath = node.getSourceFile().fileName;
|
|
292
|
+
return (cache.get(filePath) ?? buildFileScope(filePath, cache)).get(node.text);
|
|
293
|
+
};
|
|
294
|
+
return {
|
|
295
|
+
resolve,
|
|
296
|
+
cache
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
const readObjectStringProperty = (object, name) => {
|
|
300
|
+
for (const property of object.properties) {
|
|
301
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
302
|
+
if (!ts.isIdentifier(property.name) || property.name.text !== name) continue;
|
|
303
|
+
if (ts.isStringLiteral(property.initializer)) return property.initializer.text;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const readAstMetaId = (expr) => {
|
|
307
|
+
if (!ts.isCallExpression(expr)) return void 0;
|
|
308
|
+
const firstArg = expr.arguments[0];
|
|
309
|
+
if (isModelCall(expr)) {
|
|
310
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return void 0;
|
|
311
|
+
return readObjectStringProperty(firstArg, "title");
|
|
312
|
+
}
|
|
313
|
+
if (ts.isPropertyAccessExpression(expr.expression) && expr.expression.name.text === "meta") {
|
|
314
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return void 0;
|
|
315
|
+
return readObjectStringProperty(firstArg, "id") ?? readAstMetaId(expr.expression.expression);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const firstObjectLiteralIn = (node, resolve, visited) => {
|
|
319
|
+
if (ts.isObjectLiteralExpression(node)) return node;
|
|
320
|
+
if (ts.isIdentifier(node)) {
|
|
321
|
+
const name = node.text;
|
|
322
|
+
if (visited.has(name)) return void 0;
|
|
323
|
+
const referenced = resolve(node);
|
|
324
|
+
if (!referenced) return void 0;
|
|
325
|
+
if (ts.isArrowFunction(referenced) || ts.isFunctionExpression(referenced)) return void 0;
|
|
326
|
+
visited.add(name);
|
|
327
|
+
return firstObjectLiteralIn(referenced, resolve, visited);
|
|
328
|
+
}
|
|
329
|
+
if (ts.isCallExpression(node)) {
|
|
330
|
+
if (isContractChainCall(node) || isRoutesChainCall(node)) {
|
|
331
|
+
const routesArg = routesArgFrom(node);
|
|
332
|
+
if (routesArg) return firstObjectLiteralIn(routesArg, resolve, visited);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (isModelCall(node)) {
|
|
336
|
+
const modelSchema = extractCreateModelSchema(node);
|
|
337
|
+
return modelSchema ? firstObjectLiteralIn(modelSchema, resolve, visited) : void 0;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
let found;
|
|
341
|
+
ts.forEachChild(node, (child) => {
|
|
342
|
+
if (found) return;
|
|
343
|
+
found = firstObjectLiteralIn(child, resolve, visited);
|
|
344
|
+
});
|
|
345
|
+
return found;
|
|
346
|
+
};
|
|
347
|
+
const findExtendBase = (node) => {
|
|
348
|
+
if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return void 0;
|
|
349
|
+
if (node.expression.name.text === "extend") return node.expression.expression;
|
|
350
|
+
return findExtendBase(node.expression.expression);
|
|
351
|
+
};
|
|
352
|
+
const getFunctionBody = (func) => {
|
|
353
|
+
const { body } = func;
|
|
354
|
+
if (!ts.isBlock(body)) return body;
|
|
355
|
+
let result;
|
|
356
|
+
ts.forEachChild(body, (stmt) => {
|
|
357
|
+
if (!result && ts.isReturnStatement(stmt) && stmt.expression) result = stmt.expression;
|
|
358
|
+
});
|
|
359
|
+
return result ?? body;
|
|
360
|
+
};
|
|
361
|
+
const makeScopedResolver = (func, args, parent) => {
|
|
362
|
+
const substitutions = /* @__PURE__ */ new Map();
|
|
363
|
+
func.parameters.forEach((param, index) => {
|
|
364
|
+
const arg = args[index];
|
|
365
|
+
if (arg && ts.isIdentifier(param.name)) substitutions.set(param.name.text, arg);
|
|
366
|
+
});
|
|
367
|
+
return (node) => substitutions.get(node.text) ?? parent(node);
|
|
368
|
+
};
|
|
369
|
+
const extractCreateModelSchema = (node) => {
|
|
370
|
+
if (!ts.isCallExpression(node) || !isModelCall(node)) return void 0;
|
|
371
|
+
const firstArg = node.arguments[0];
|
|
372
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return void 0;
|
|
373
|
+
for (const property of firstArg.properties) {
|
|
374
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
375
|
+
if (propertyName(property.name) === "schema") return property.initializer;
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
/**
|
|
379
|
+
* Walks a schema expression's fields, calling `visit` with each field's dot-path
|
|
380
|
+
* and its property node. Resolves identifiers, `Kizuna.model`, generic wrapper
|
|
381
|
+
* functions (e.g. `Pagination(Item)`), and `.extend()`; recurses into nested
|
|
382
|
+
* objects.
|
|
383
|
+
*/
|
|
384
|
+
const walkSchemaFields = (schemaNode, prefix, resolve, visit) => {
|
|
385
|
+
const resolved = ts.isIdentifier(schemaNode) ? resolve(schemaNode) ?? schemaNode : schemaNode;
|
|
386
|
+
const modelSchema = extractCreateModelSchema(resolved);
|
|
387
|
+
if (modelSchema) {
|
|
388
|
+
walkSchemaFields(modelSchema, prefix, resolve, visit);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
if (ts.isCallExpression(resolved) && ts.isIdentifier(resolved.expression)) {
|
|
392
|
+
const funcExpr = resolve(resolved.expression);
|
|
393
|
+
if (funcExpr && (ts.isArrowFunction(funcExpr) || ts.isFunctionExpression(funcExpr))) {
|
|
394
|
+
walkSchemaFields(getFunctionBody(funcExpr), prefix, makeScopedResolver(funcExpr, resolved.arguments, resolve), visit);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const extendBase = findExtendBase(resolved);
|
|
399
|
+
if (extendBase) walkSchemaFields(extendBase, prefix, resolve, visit);
|
|
400
|
+
const objectLiteral = firstObjectLiteralIn(schemaNode, resolve, /* @__PURE__ */ new Set());
|
|
401
|
+
if (!objectLiteral) return;
|
|
402
|
+
for (const property of objectLiteral.properties) {
|
|
403
|
+
if (ts.isShorthandPropertyAssignment(property)) {
|
|
404
|
+
visit(prefix === "" ? property.name.text : `${prefix}.${property.name.text}`, property);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (!ts.isPropertyAssignment(property)) continue;
|
|
408
|
+
const fieldName = propertyName(property.name);
|
|
409
|
+
if (fieldName === void 0) continue;
|
|
410
|
+
const fieldPath = prefix === "" ? fieldName : `${prefix}.${fieldName}`;
|
|
411
|
+
visit(fieldPath, property);
|
|
412
|
+
walkSchemaFields(property.initializer, fieldPath, resolve, visit);
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const collectFieldDeprecations = (schemaNode, prefix, into, resolve) => {
|
|
416
|
+
walkSchemaFields(schemaNode, prefix, resolve, (fieldPath, property) => {
|
|
417
|
+
const message = readDeprecatedMessage(property);
|
|
418
|
+
if (message !== void 0) into.set(fieldPath, message);
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
/**
|
|
422
|
+
* Returns the verbatim leading JSDoc block (`/** … *\/`) on a node, or undefined
|
|
423
|
+
* when it has none.
|
|
424
|
+
*/
|
|
425
|
+
const readJsDocBlock = (node) => {
|
|
426
|
+
const sourceFile = node.getSourceFile();
|
|
427
|
+
const jsDoc = (ts.getLeadingCommentRanges(sourceFile.text, node.getFullStart()) ?? []).filter((range) => sourceFile.text.slice(range.pos, range.pos + 3) === "/**").at(-1);
|
|
428
|
+
return jsDoc ? sourceFile.text.slice(jsDoc.pos, jsDoc.end) : void 0;
|
|
429
|
+
};
|
|
430
|
+
const collectFieldDocs = (schemaNode, prefix, into, resolve) => {
|
|
431
|
+
walkSchemaFields(schemaNode, prefix, resolve, (fieldPath, property) => {
|
|
432
|
+
const block = readJsDocBlock(property);
|
|
433
|
+
if (block !== void 0) into.set(fieldPath, block);
|
|
434
|
+
});
|
|
435
|
+
};
|
|
436
|
+
const isRouteLike = (obj) => obj.properties.some((prop) => {
|
|
437
|
+
const name = ts.isPropertyAssignment(prop) ? propertyName(prop.name) : ts.isShorthandPropertyAssignment(prop) ? prop.name.text : void 0;
|
|
438
|
+
return name === "method" || name === "path" || name === "responses";
|
|
439
|
+
});
|
|
440
|
+
const routePropertyName = (prop) => {
|
|
441
|
+
if (ts.isPropertyAssignment(prop)) return propertyName(prop.name);
|
|
442
|
+
if (ts.isShorthandPropertyAssignment(prop)) return prop.name.text;
|
|
443
|
+
};
|
|
444
|
+
const routePropertyInitializer = (prop) => ts.isPropertyAssignment(prop) ? prop.initializer : prop.name;
|
|
445
|
+
const buildMapFromRoutesLiteral = (routesLiteral, resolve, prefix = "") => {
|
|
446
|
+
const routes = /* @__PURE__ */ new Map();
|
|
447
|
+
const fields = /* @__PURE__ */ new Map();
|
|
448
|
+
for (const routeProperty of routesLiteral.properties) {
|
|
449
|
+
if (!ts.isPropertyAssignment(routeProperty) && !ts.isShorthandPropertyAssignment(routeProperty)) continue;
|
|
450
|
+
const routeName = routePropertyName(routeProperty);
|
|
451
|
+
if (routeName === void 0) continue;
|
|
452
|
+
const fullKey = prefix === "" ? routeName : `${prefix}.${routeName}`;
|
|
453
|
+
const routeMessage = readDeprecatedMessage(routeProperty);
|
|
454
|
+
if (routeMessage !== void 0) routes.set(fullKey, routeMessage);
|
|
455
|
+
const initializer = routePropertyInitializer(routeProperty);
|
|
456
|
+
const resolvedLiteral = ts.isObjectLiteralExpression(initializer) ? initializer : firstObjectLiteralIn(initializer, resolve, /* @__PURE__ */ new Set());
|
|
457
|
+
if (!resolvedLiteral) continue;
|
|
458
|
+
if (!isRouteLike(resolvedLiteral)) {
|
|
459
|
+
const sub = buildMapFromRoutesLiteral(resolvedLiteral, resolve, fullKey);
|
|
460
|
+
for (const [key, value] of sub.routes) routes.set(key, value);
|
|
461
|
+
for (const [key, value] of sub.fields) fields.set(key, value);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const deprecated = /* @__PURE__ */ new Map();
|
|
465
|
+
for (const subProperty of resolvedLiteral.properties) {
|
|
466
|
+
if (!ts.isPropertyAssignment(subProperty)) continue;
|
|
467
|
+
const subKey = propertyName(subProperty.name);
|
|
468
|
+
if (subKey === void 0) continue;
|
|
469
|
+
if (SCHEMA_KEYS.has(subKey)) {
|
|
470
|
+
collectFieldDeprecations(subProperty.initializer, subKey, deprecated, resolve);
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (subKey === "responses" && ts.isObjectLiteralExpression(subProperty.initializer)) for (const responseEntry of subProperty.initializer.properties) {
|
|
474
|
+
if (!ts.isPropertyAssignment(responseEntry)) continue;
|
|
475
|
+
const status = propertyName(responseEntry.name);
|
|
476
|
+
if (status === void 0) continue;
|
|
477
|
+
collectFieldDeprecations(responseEntry.initializer, `responses.${status}`, deprecated, resolve);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (deprecated.size > 0) fields.set(fullKey, deprecated);
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
routes,
|
|
484
|
+
fields
|
|
485
|
+
};
|
|
486
|
+
};
|
|
487
|
+
const findRouterCallInNode = (node) => {
|
|
488
|
+
if (ts.isCallExpression(node)) {
|
|
489
|
+
if (isMemberCall(node, AUTHORING_NAMES.router)) return node;
|
|
490
|
+
if (isContractChainCall(node) || isRoutesChainCall(node)) return node;
|
|
491
|
+
}
|
|
492
|
+
let found;
|
|
493
|
+
ts.forEachChild(node, (child) => {
|
|
494
|
+
if (found) return;
|
|
495
|
+
found = findRouterCallInNode(child);
|
|
496
|
+
});
|
|
497
|
+
return found;
|
|
498
|
+
};
|
|
499
|
+
/**
|
|
500
|
+
* True when `node` calls `member` on any receiver, e.g. `k.routes(...)`.
|
|
501
|
+
*/
|
|
502
|
+
const isMemberCall = (node, member) => ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.name) && node.expression.name.text === member;
|
|
503
|
+
const isRoutesChainCall = (node) => isMemberCall(node, AUTHORING_NAMES.routes);
|
|
504
|
+
const isContractChainCall = (node) => isMemberCall(node, AUTHORING_NAMES.contract);
|
|
505
|
+
const isModelCall = (node) => isMemberCall(node, AUTHORING_NAMES.model);
|
|
506
|
+
const isContractExportName = (name) => CONTRACT_EXPORT_NAMES.includes(name);
|
|
507
|
+
const routesArgFrom = (call) => {
|
|
508
|
+
if (isRoutesChainCall(call)) return call.arguments[call.arguments.length - 1];
|
|
509
|
+
if (isContractChainCall(call)) {
|
|
510
|
+
const arg = call.arguments[0];
|
|
511
|
+
if (arg && ts.isObjectLiteralExpression(arg)) for (const prop of arg.properties) {
|
|
512
|
+
if (ts.isPropertyAssignment(prop) && propertyName(prop.name) === "routes") return prop.initializer;
|
|
513
|
+
if (ts.isShorthandPropertyAssignment(prop) && prop.name.text === "routes") return prop.name;
|
|
514
|
+
}
|
|
515
|
+
return arg;
|
|
516
|
+
}
|
|
517
|
+
const firstArg = call.arguments[0];
|
|
518
|
+
if (!firstArg) return void 0;
|
|
519
|
+
if (call.arguments.length === 2) return call.arguments[1];
|
|
520
|
+
return firstArg;
|
|
521
|
+
};
|
|
522
|
+
const collectExportedRoutesLiterals = (sourceFile, resolve, into) => {
|
|
523
|
+
for (const statement of sourceFile.statements) {
|
|
524
|
+
if (!ts.isVariableStatement(statement)) continue;
|
|
525
|
+
if (!(statement.modifiers ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
|
|
526
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
527
|
+
if (!ts.isIdentifier(declaration.name)) continue;
|
|
528
|
+
const exportName = declaration.name.text;
|
|
529
|
+
if (!isContractExportName(exportName)) continue;
|
|
530
|
+
if (!declaration.initializer) continue;
|
|
531
|
+
let initializer = declaration.initializer;
|
|
532
|
+
if (ts.isIdentifier(initializer)) {
|
|
533
|
+
const resolved = resolve(initializer);
|
|
534
|
+
if (resolved) initializer = resolved;
|
|
535
|
+
}
|
|
536
|
+
if (!ts.isCallExpression(initializer)) continue;
|
|
537
|
+
initializer.expression;
|
|
538
|
+
if (isContractChainCall(initializer)) {
|
|
539
|
+
const routesArg = routesArgFrom(initializer);
|
|
540
|
+
const lit = routesArg ? firstObjectLiteralIn(routesArg, resolve, /* @__PURE__ */ new Set()) : void 0;
|
|
541
|
+
if (lit) into.push(lit);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (const statement of sourceFile.statements) {
|
|
546
|
+
if (!ts.isExportDeclaration(statement)) continue;
|
|
547
|
+
if (!statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
548
|
+
const specifier = statement.moduleSpecifier.text;
|
|
549
|
+
if (!specifier.startsWith(".")) continue;
|
|
550
|
+
const exportClause = statement.exportClause;
|
|
551
|
+
if (!exportClause || !ts.isNamedExports(exportClause)) continue;
|
|
552
|
+
if (!exportClause.elements.some((element) => {
|
|
553
|
+
const exportedName = element.name.text;
|
|
554
|
+
return isContractExportName(exportedName);
|
|
555
|
+
})) continue;
|
|
556
|
+
const importedPath = resolveImportPath(path.dirname(sourceFile.fileName), specifier);
|
|
557
|
+
if (!importedPath) continue;
|
|
558
|
+
collectExportedRoutesLiterals(ts.createSourceFile(importedPath, fs.readFileSync(importedPath, "utf8"), ts.ScriptTarget.Latest, true), resolve, into);
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
const findAllRouteObjectLiterals = (sourceFile, resolve) => {
|
|
562
|
+
const results = [];
|
|
563
|
+
collectExportedRoutesLiterals(sourceFile, resolve, results);
|
|
564
|
+
if (results.length > 0) return results;
|
|
565
|
+
const routerCall = findRouterCallInNode(sourceFile);
|
|
566
|
+
if (!routerCall) return [];
|
|
567
|
+
const routesArg = routesArgFrom(routerCall);
|
|
568
|
+
const lit = routesArg ? firstObjectLiteralIn(routesArg, resolve, /* @__PURE__ */ new Set()) : void 0;
|
|
569
|
+
return lit ? [lit] : [];
|
|
570
|
+
};
|
|
571
|
+
const parseFromSource = (contractPath) => {
|
|
572
|
+
const source = fs.readFileSync(contractPath, "utf8");
|
|
573
|
+
const sourceFile = ts.createSourceFile(contractPath, source, ts.ScriptTarget.Latest, true);
|
|
574
|
+
const { resolve, cache } = makeResolverWithCache(contractPath);
|
|
575
|
+
const routeLiterals = findAllRouteObjectLiterals(sourceFile, resolve);
|
|
576
|
+
const routes = /* @__PURE__ */ new Map();
|
|
577
|
+
const fields = /* @__PURE__ */ new Map();
|
|
578
|
+
for (const literal of routeLiterals) {
|
|
579
|
+
const partial = buildMapFromRoutesLiteral(literal, resolve);
|
|
580
|
+
for (const [key, value] of partial.routes) routes.set(key, value);
|
|
581
|
+
for (const [key, value] of partial.fields) fields.set(key, value);
|
|
582
|
+
}
|
|
583
|
+
const schemas = /* @__PURE__ */ new Map();
|
|
584
|
+
for (const fileScope of cache.values()) for (const expr of fileScope.values()) {
|
|
585
|
+
const id = readAstMetaId(expr);
|
|
586
|
+
if (!id) continue;
|
|
587
|
+
const fieldDeprecations = /* @__PURE__ */ new Map();
|
|
588
|
+
collectFieldDeprecations(expr, "", fieldDeprecations, resolve);
|
|
589
|
+
if (fieldDeprecations.size > 0) schemas.set(id, fieldDeprecations);
|
|
590
|
+
}
|
|
591
|
+
return {
|
|
592
|
+
routes,
|
|
593
|
+
fields,
|
|
594
|
+
schemas
|
|
595
|
+
};
|
|
596
|
+
};
|
|
597
|
+
/**
|
|
598
|
+
* Parses a contract's `@deprecated` JSDoc tags into a {@link DeprecationMap}.
|
|
599
|
+
*/
|
|
600
|
+
const createDeprecationMap = (contractPath) => parseFromSource(contractPath);
|
|
601
|
+
/**
|
|
602
|
+
* Parses each contract's `@deprecated` tags and writes them to
|
|
603
|
+
* `<outDir>/deprecations.json`, keyed by contract fingerprint. Generators read
|
|
604
|
+
* the entry matching the contract they generate. Returns the written path.
|
|
605
|
+
*/
|
|
606
|
+
const writeKizunaDeprecations = (contracts, outDir) => {
|
|
607
|
+
const entries = {};
|
|
608
|
+
for (const { contract, contractPath } of contracts) entries[contractFingerprint(contract)] = serializeDeprecationMap(createDeprecationMap(contractPath));
|
|
609
|
+
const outputPath = path.join(outDir, "deprecations.json");
|
|
610
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
611
|
+
fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2), "utf8");
|
|
612
|
+
return outputPath;
|
|
613
|
+
};
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/load-contract.ts
|
|
616
|
+
/**
|
|
617
|
+
* Imports a contract module with jiti (so a `.ts` entry works without a build
|
|
618
|
+
* step) and returns the named export (default `contract`) or the default export.
|
|
619
|
+
* Returns undefined when neither is present.
|
|
620
|
+
*/
|
|
621
|
+
const loadContract = async (contractPath, exportName = "contract") => {
|
|
622
|
+
const loaded = await createJiti(import.meta.url, { interopDefault: true }).import(contractPath);
|
|
623
|
+
return loaded[exportName] ?? loaded.default;
|
|
624
|
+
};
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/lint-deprecations.ts
|
|
627
|
+
const editDistance = (a, b) => {
|
|
628
|
+
const rows = Array.from({ length: a.length + 1 }, (_, index) => [index, ...new Array(b.length).fill(0)]);
|
|
629
|
+
for (let column = 0; column <= b.length; column += 1) rows[0][column] = column;
|
|
630
|
+
for (let row = 1; row <= a.length; row += 1) for (let column = 1; column <= b.length; column += 1) {
|
|
631
|
+
const cost = a[row - 1] === b[column - 1] ? 0 : 1;
|
|
632
|
+
rows[row][column] = Math.min(rows[row - 1][column] + 1, rows[row][column - 1] + 1, rows[row - 1][column - 1] + cost);
|
|
633
|
+
}
|
|
634
|
+
return rows[a.length][b.length];
|
|
635
|
+
};
|
|
636
|
+
const lineOf = (text, index) => text.slice(0, index).split("\n").length;
|
|
637
|
+
/**
|
|
638
|
+
* Warns about JSDoc that would silently disable a deprecation, a misspelled
|
|
639
|
+
* `@deprecated` tag, or more than one on the same comment. Scans the contract
|
|
640
|
+
* source and the files it imports.
|
|
641
|
+
*/
|
|
642
|
+
const lintDeprecations = (entryPath) => {
|
|
643
|
+
const { cache } = makeResolverWithCache(entryPath);
|
|
644
|
+
const warnings = [];
|
|
645
|
+
for (const filePath of cache.keys()) {
|
|
646
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
647
|
+
for (const block of text.matchAll(/\/\*\*[\s\S]*?\*\//g)) {
|
|
648
|
+
const blockStart = block.index;
|
|
649
|
+
const tags = [...block[0].matchAll(/@(\w+)/g)];
|
|
650
|
+
const deprecatedTags = tags.filter((tag) => tag[1] === "deprecated");
|
|
651
|
+
if (deprecatedTags.length > 1) warnings.push({
|
|
652
|
+
file: filePath,
|
|
653
|
+
line: lineOf(text, blockStart + deprecatedTags[1].index),
|
|
654
|
+
message: "Duplicate `@deprecated` tag in one comment, only the first message is used."
|
|
655
|
+
});
|
|
656
|
+
for (const tag of tags) {
|
|
657
|
+
const name = tag[1].toLowerCase();
|
|
658
|
+
if (name !== "deprecated" && name.startsWith("dep") && editDistance(name, "deprecated") <= 2) warnings.push({
|
|
659
|
+
file: filePath,
|
|
660
|
+
line: lineOf(text, blockStart + tag.index),
|
|
661
|
+
message: `\`@${tag[1]}\` looks like a typo of \`@deprecated\`, it will be ignored.`
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return warnings;
|
|
667
|
+
};
|
|
668
|
+
//#endregion
|
|
669
|
+
export { patchDeclarationDocs as a, writeKizunaDeprecations as i, loadContract as n, collectExportedSchemaDocs as o, createDeprecationMap as r, lintDeprecations as t };
|