-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathvalidation.ts
More file actions
318 lines (285 loc) · 8.84 KB
/
validation.ts
File metadata and controls
318 lines (285 loc) · 8.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
import ts from "typescript";
export interface ValidationResult {
valid: boolean;
errors: string[];
}
/**
* Default whitelist of allowed package patterns.
* Packages matching these patterns are allowed to be imported.
*/
export const DEFAULT_ALLOWED_PACKAGES = [
"@datocms/*", // Scoped packages under @datocms
"datocms-*", // Packages starting with datocms-
"./schema",
];
/**
* Checks if an import path matches any of the allowed package patterns.
*/
function isImportAllowed(
importPath: string,
allowedPatterns: string[],
): boolean {
return allowedPatterns.some((pattern) => {
if (pattern.endsWith("/*")) {
// Scoped package pattern like "@datocms/*"
const prefix = pattern.slice(0, -2); // Remove "/*"
return importPath === prefix || importPath.startsWith(`${prefix}/`);
}
if (pattern.endsWith("*")) {
// Prefix pattern like "datocms-*"
const prefix = pattern.slice(0, -1); // Remove "*"
return importPath.startsWith(prefix);
}
// Exact match
return importPath === pattern;
});
}
/**
* Validates that a script follows the required structural format:
* 1. Only imports from whitelisted packages are allowed
* 2. Must export a default async function with signature: (client: Client) => Promise<void>
* or (client: ReturnType<typeof buildClient>) => Promise<void>
*/
export function validateScriptStructure(
content: string,
allowedPackages: string[] = DEFAULT_ALLOWED_PACKAGES,
): ValidationResult {
const errors: string[] = [];
// Parse the TypeScript code
const sourceFile = ts.createSourceFile(
"script.ts",
content,
ts.ScriptTarget.Latest,
true,
);
let hasDefaultExport = false;
let defaultExportIsValidFunction = false;
// Visit each node in the AST
function visit(node: ts.Node) {
// Check for explicit 'any' or 'unknown' types
if (node.kind === ts.SyntaxKind.AnyKeyword) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart(),
);
errors.push(
`Explicit 'any' type found at line ${line + 1}, column ${character + 1}. Please use a specific type instead. If you're trying to use client.items.* methods, remember to import "./schema" and use its ItemTypeDefinitions!`,
);
}
if (node.kind === ts.SyntaxKind.UnknownKeyword) {
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart(),
);
errors.push(
`Explicit 'unknown' type found at line ${line + 1}, column ${character + 1}. Please use a specific type instead. If you're trying to use client.items.* methods, remember to import "./schema" and use its ItemTypeDefinitions!`,
);
}
// Check for import declarations
if (ts.isImportDeclaration(node)) {
const moduleSpecifier = node.moduleSpecifier;
if (ts.isStringLiteral(moduleSpecifier)) {
const importPath = moduleSpecifier.text;
// Check if import matches any allowed pattern
if (!isImportAllowed(importPath, allowedPackages)) {
const allowedPatternsStr = allowedPackages.join(", ");
errors.push(
`Invalid import: "${importPath}". Only imports from these packages are allowed: ${allowedPatternsStr}`,
);
}
}
}
// Check for default export
if (ts.isExportAssignment(node)) {
hasDefaultExport = true;
// Check if it's a function
const expression = node.expression;
// Could be a direct function declaration, arrow function, or identifier
if (
ts.isFunctionExpression(expression) ||
ts.isArrowFunction(expression)
) {
defaultExportIsValidFunction = validateFunctionSignature(
expression,
errors,
);
} else if (ts.isIdentifier(expression)) {
// If it's an identifier, we need to find the function declaration
const functionDecl = findFunctionDeclaration(
sourceFile,
expression.text,
);
if (functionDecl) {
if (ts.isFunctionDeclaration(functionDecl)) {
defaultExportIsValidFunction = validateFunctionSignature(
functionDecl,
errors,
);
} else if (ts.isVariableDeclaration(functionDecl)) {
// Handle const/let/var declarations like: const run = async (client: Client) => {}
const init = functionDecl.initializer;
if (
init &&
(ts.isFunctionExpression(init) || ts.isArrowFunction(init))
) {
defaultExportIsValidFunction = validateFunctionSignature(
init,
errors,
);
} else {
errors.push(
"Default export must reference a function with signature: async (client: Client) => Promise<any>",
);
}
}
} else {
errors.push(
"Default export must reference a function with signature: async (client: Client) => Promise<any>",
);
}
} else {
errors.push(
"Default export must be a function with signature: async (client: Client) => Promise<any>",
);
}
}
// Check for "export default function" or "export default async function"
if (
ts.isFunctionDeclaration(node) &&
node.modifiers?.some(
(m) =>
m.kind === ts.SyntaxKind.ExportKeyword ||
m.kind === ts.SyntaxKind.DefaultKeyword,
)
) {
const hasExport = node.modifiers?.some(
(m) => m.kind === ts.SyntaxKind.ExportKeyword,
);
const hasDefault = node.modifiers?.some(
(m) => m.kind === ts.SyntaxKind.DefaultKeyword,
);
if (hasExport && hasDefault) {
hasDefaultExport = true;
defaultExportIsValidFunction = validateFunctionSignature(node, errors);
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (!hasDefaultExport) {
errors.push(
"Script must export a default function with signature: async (client: Client) => Promise<void>",
);
} else if (!defaultExportIsValidFunction) {
// Error already added by validateFunctionSignature
}
return {
valid: errors.length === 0,
errors,
};
}
/**
* Checks if a type node represents a valid parameter type:
* - Client
* - ReturnType<typeof buildClient>
*/
function isValidParameterType(typeNode: ts.TypeNode): boolean {
if (ts.isTypeReferenceNode(typeNode)) {
const typeName = typeNode.typeName;
// Check for "Client"
if (ts.isIdentifier(typeName) && typeName.text === "Client") {
return true;
}
// Check for "ReturnType<typeof buildClient>"
if (ts.isIdentifier(typeName) && typeName.text === "ReturnType") {
const typeArgs = typeNode.typeArguments;
if (typeArgs && typeArgs.length === 1) {
const typeArg = typeArgs[0];
// Check if it's "typeof buildClient"
if (typeArg && ts.isTypeQueryNode(typeArg)) {
const exprName = typeArg.exprName;
if (ts.isIdentifier(exprName) && exprName.text === "buildClient") {
return true;
}
}
}
}
}
return false;
}
function validateFunctionSignature(
func:
| ts.FunctionDeclaration
| ts.FunctionExpression
| ts.ArrowFunction
| ts.MethodDeclaration,
errors: string[],
): boolean {
// Check if function is async or returns a Promise
const hasAsyncModifier = func.modifiers?.some(
(m) => m.kind === ts.SyntaxKind.AsyncKeyword,
);
const returnType = func.type;
let returnsPromise = false;
if (returnType && ts.isTypeReferenceNode(returnType)) {
const typeName = returnType.typeName;
if (ts.isIdentifier(typeName) && typeName.text === "Promise") {
returnsPromise = true;
}
}
if (!hasAsyncModifier && !returnsPromise) {
errors.push(
"Default export function must be async or return a Promise<void>",
);
return false;
}
// Check parameters - must have exactly one parameter of type Client or ReturnType<typeof buildClient>
if (!func.parameters || func.parameters.length !== 1) {
errors.push(
"Default export function must have exactly one parameter of type Client or ReturnType<typeof buildClient>",
);
return false;
}
const param = func.parameters[0];
if (!param) {
errors.push(
"Default export function must have exactly one parameter of type Client or ReturnType<typeof buildClient>",
);
return false;
}
// Check parameter type - should be Client or ReturnType<typeof buildClient>
if (param.type) {
if (!isValidParameterType(param.type)) {
errors.push(
'Default export function parameter must be of type "Client" or "ReturnType<typeof buildClient>"',
);
return false;
}
} else {
errors.push(
"Default export function parameter must have type annotation: Client or ReturnType<typeof buildClient>",
);
return false;
}
return true;
}
function findFunctionDeclaration(
sourceFile: ts.SourceFile,
name: string,
): ts.FunctionDeclaration | ts.VariableDeclaration | undefined {
let result: ts.FunctionDeclaration | ts.VariableDeclaration | undefined;
function visit(node: ts.Node) {
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
result = node;
} else if (ts.isVariableStatement(node)) {
for (const decl of node.declarationList.declarations) {
if (ts.isIdentifier(decl.name) && decl.name.text === name) {
result = decl;
}
}
}
if (!result) {
ts.forEachChild(node, visit);
}
}
visit(sourceFile);
return result;
}