lambder 13.0.1 → 14.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +1 -1
- package/dist/api/LambderApiPipeline.d.ts +22 -2
- package/dist/api/LambderApiPipeline.js +28 -3
- package/dist/api/LambderApiRefusals.d.ts +16 -6
- package/dist/api/LambderApiRefusals.js +17 -10
- package/dist/api/LambderApiSignature.d.ts +5 -5
- package/dist/api/LambderApiSignature.js +7 -5
- package/dist/build/checkApiRefusals.d.ts +54 -0
- package/dist/build/checkApiRefusals.js +415 -0
- package/dist/build/generatedTables.d.ts +1 -1
- package/dist/build/loadTypeScriptCompiler.d.ts +10 -0
- package/dist/build/loadTypeScriptCompiler.js +20 -0
- package/dist/build/writeApiContract.js +21 -15
- package/dist/build/writeApiGuardParams.d.ts +11 -10
- package/dist/build/writeApiGuardParams.js +135 -31
- package/dist/build.d.ts +5 -2
- package/dist/build.js +4 -2
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +6 -5
- package/dist/core/Lambder.js +10 -9
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +24 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +13 -0
- package/dist/shared/wire/LambderApiRefusal.d.ts +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { dirname, relative, resolve, sep } from "path";
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
import { loadTypeScriptCompiler } from "./loadTypeScriptCompiler.js";
|
|
4
|
+
/** The registrations whose handler Lambder types a refuse for, by the name they are called under, and where each one's handler is. */
|
|
5
|
+
const HANDLER_REGISTRATIONS = {
|
|
6
|
+
defineApi: "second",
|
|
7
|
+
guard: "optionsHandler",
|
|
8
|
+
lambderGuard: "optionsHandler",
|
|
9
|
+
api: "secondOrItsHandler",
|
|
10
|
+
override: "secondOrItsHandler",
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Checks every handler of a project against the codes it may send, and
|
|
14
|
+
* answers what it found.
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* import { checkApiRefusals } from "lambder/build";
|
|
18
|
+
*
|
|
19
|
+
* const result = await checkApiRefusals({ tsconfig: "server/tsconfig.json" });
|
|
20
|
+
* console.log(result.lines.join("\n"));
|
|
21
|
+
* process.exit(result.ok ? 0 : 1);
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Run it where the app runs its other build checks, or from a test: it
|
|
25
|
+
* compiles the project, so it takes as long as a type check does.
|
|
26
|
+
*/
|
|
27
|
+
export const checkApiRefusals = async (options) => {
|
|
28
|
+
const ts = await loadTypeScriptCompiler("checkApiRefusals reads the project");
|
|
29
|
+
const requireCodes = options.requireCodes ?? true;
|
|
30
|
+
const configPath = resolve(options.tsconfig);
|
|
31
|
+
const read = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
32
|
+
if (read.error)
|
|
33
|
+
return failed(`✗ ${options.tsconfig} could not be read: ${ts.flattenDiagnosticMessageText(read.error.messageText, " ")}`);
|
|
34
|
+
const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, dirname(configPath), undefined, configPath);
|
|
35
|
+
if (parsed.errors.length)
|
|
36
|
+
return failed(`✗ ${options.tsconfig} has errors: ${parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, " ")).join("; ")}`);
|
|
37
|
+
const rootNames = options.files?.map((file) => resolve(file)) ?? parsed.fileNames;
|
|
38
|
+
const program = ts.createProgram({ rootNames, options: { ...parsed.options, noEmit: true, composite: false, incremental: false, declaration: false } });
|
|
39
|
+
const checker = program.getTypeChecker();
|
|
40
|
+
const lambderRoot = realPathOf(ts, resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."));
|
|
41
|
+
const ownFiles = new Map();
|
|
42
|
+
/** A file of the project itself: not a dependency's, not a declaration file, and not Lambder's own (its tests excepted, which are apps). */
|
|
43
|
+
const isOwnFile = (sourceFile) => {
|
|
44
|
+
const known = ownFiles.get(sourceFile.fileName);
|
|
45
|
+
if (known !== undefined)
|
|
46
|
+
return known;
|
|
47
|
+
const path = realPathOf(ts, sourceFile.fileName);
|
|
48
|
+
const own = !sourceFile.isDeclarationFile
|
|
49
|
+
&& !path.split(sep).includes("node_modules")
|
|
50
|
+
&& (!isInside(path, lambderRoot) || isInside(path, resolve(lambderRoot, "tests")));
|
|
51
|
+
ownFiles.set(sourceFile.fileName, own);
|
|
52
|
+
return own;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* A declaration of Lambder's: in the declaration files of any installed
|
|
56
|
+
* copy (a workspace holds one per set of peer versions, and a package's
|
|
57
|
+
* imports resolve to its own), or in its sources when this runs over its
|
|
58
|
+
* own tests.
|
|
59
|
+
*/
|
|
60
|
+
const isLambderDeclaration = (node) => {
|
|
61
|
+
if (!node)
|
|
62
|
+
return false;
|
|
63
|
+
const path = realPathOf(ts, node.getSourceFile().fileName);
|
|
64
|
+
const parts = path.split(sep);
|
|
65
|
+
if (parts.some((part, index) => part === "node_modules" && parts[index + 1] === "lambder"))
|
|
66
|
+
return true;
|
|
67
|
+
return isInside(path, lambderRoot) && !isInside(path, resolve(lambderRoot, "tests")) && !parts.slice(lambderRoot.split(sep).length).includes("node_modules");
|
|
68
|
+
};
|
|
69
|
+
const where = (node) => {
|
|
70
|
+
const sourceFile = node.getSourceFile();
|
|
71
|
+
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
72
|
+
return `${relative(process.cwd(), sourceFile.fileName)}:${line + 1}`;
|
|
73
|
+
};
|
|
74
|
+
// ── Raise sites ──────────────────────────────────────────────────────
|
|
75
|
+
/** The string literal values of a type, or null when it is not made of string literals alone. */
|
|
76
|
+
const literalsOf = (type) => {
|
|
77
|
+
const parts = type.isUnion() ? type.types : [type];
|
|
78
|
+
const values = [];
|
|
79
|
+
for (const part of parts) {
|
|
80
|
+
if (part.isStringLiteral())
|
|
81
|
+
values.push(part.value);
|
|
82
|
+
else
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return values;
|
|
86
|
+
};
|
|
87
|
+
/** The code a refuse options argument names, read off its type. */
|
|
88
|
+
const codeOfOptions = (argument, property, at) => {
|
|
89
|
+
if (!argument)
|
|
90
|
+
return [{ at, uncoded: true }];
|
|
91
|
+
let optionsType = checker.getTypeAtLocation(argument);
|
|
92
|
+
if (property === "refusal") {
|
|
93
|
+
const refusal = checker.getPropertyOfType(optionsType, "refusal");
|
|
94
|
+
if (!refusal)
|
|
95
|
+
return [{ at, uncoded: true }];
|
|
96
|
+
optionsType = checker.getTypeOfSymbolAtLocation(refusal, argument);
|
|
97
|
+
}
|
|
98
|
+
const code = checker.getPropertyOfType(optionsType, "code");
|
|
99
|
+
if (!code)
|
|
100
|
+
return [{ at, uncoded: true }];
|
|
101
|
+
const codeType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(code, argument));
|
|
102
|
+
if (codeType.flags & ts.TypeFlags.Never)
|
|
103
|
+
return [{ at, uncoded: true }];
|
|
104
|
+
const values = literalsOf(codeType);
|
|
105
|
+
if (values === null)
|
|
106
|
+
return [{ at, unreadable: true }];
|
|
107
|
+
return values.map((value) => ({ at, code: value }));
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* The refusal a call or construction raises, when it is one of Lambder's
|
|
111
|
+
* refuse functions or its refusal class's constructor. The constructor is
|
|
112
|
+
* told by the signature the call resolves to rather than by the name
|
|
113
|
+
* written, so it counts constructed as itself, as a class of the app's
|
|
114
|
+
* own that declares no constructor and inherits it, or as the super() of
|
|
115
|
+
* one that declares its own, which is then raised wherever that class is
|
|
116
|
+
* constructed.
|
|
117
|
+
*/
|
|
118
|
+
const raiseSitesOf = (node) => {
|
|
119
|
+
const callee = node.expression;
|
|
120
|
+
const constructs = ts.isNewExpression(node) || callee.kind === ts.SyntaxKind.SuperKeyword;
|
|
121
|
+
if (!constructs) {
|
|
122
|
+
const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
123
|
+
if (name !== "refuse")
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
const declaration = checker.getResolvedSignature(node)?.declaration;
|
|
127
|
+
if (!isLambderDeclaration(declaration))
|
|
128
|
+
return [];
|
|
129
|
+
if (constructs && !(declaration && ts.isConstructorDeclaration(declaration) && declaration.parent.name?.text === "LambderApiRefusal"))
|
|
130
|
+
return [];
|
|
131
|
+
const args = node.arguments ?? ts.factory.createNodeArray();
|
|
132
|
+
return constructs ? codeOfOptions(args[1], "refusal", where(node)) : codeOfOptions(args[1], "code", where(node));
|
|
133
|
+
};
|
|
134
|
+
// ── The call graph ───────────────────────────────────────────────────
|
|
135
|
+
const unwrap = (expression) => {
|
|
136
|
+
let current = expression;
|
|
137
|
+
while (ts.isParenthesizedExpression(current) || ts.isAsExpression(current) || ts.isSatisfiesExpression(current) || ts.isTypeAssertionExpression(current) || ts.isNonNullExpression(current)) {
|
|
138
|
+
current = current.expression;
|
|
139
|
+
}
|
|
140
|
+
return current;
|
|
141
|
+
};
|
|
142
|
+
const isFunctionWithBody = (node) => (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node) || ts.isMethodDeclaration(node)
|
|
143
|
+
|| ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node) || ts.isConstructorDeclaration(node)) && node.body !== undefined;
|
|
144
|
+
/** The functions a declaration stands for: itself, the function it is initialized to, or a class's constructor where it is constructed. */
|
|
145
|
+
const functionsOfDeclaration = (declaration, reference) => {
|
|
146
|
+
if (isFunctionWithBody(declaration))
|
|
147
|
+
return [declaration];
|
|
148
|
+
if ((ts.isVariableDeclaration(declaration) || ts.isPropertyAssignment(declaration) || ts.isPropertyDeclaration(declaration)) && declaration.initializer) {
|
|
149
|
+
const initializer = unwrap(declaration.initializer);
|
|
150
|
+
return isFunctionWithBody(initializer) ? [initializer] : [];
|
|
151
|
+
}
|
|
152
|
+
if (ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration)) {
|
|
153
|
+
const constructed = ts.isNewExpression(reference.parent) && reference.parent.expression === reference;
|
|
154
|
+
return constructed ? declaration.members.filter((member) => ts.isConstructorDeclaration(member) && member.body !== undefined) : [];
|
|
155
|
+
}
|
|
156
|
+
return [];
|
|
157
|
+
};
|
|
158
|
+
/** The functions of the project an identifier refers to, followed through an import, a shorthand property or a destructured binding. */
|
|
159
|
+
const functionsReferredToBy = (reference) => {
|
|
160
|
+
let symbol = checker.getSymbolAtLocation(reference);
|
|
161
|
+
if (!symbol)
|
|
162
|
+
return [];
|
|
163
|
+
if (ts.isShorthandPropertyAssignment(reference.parent) && reference.parent.name === reference) {
|
|
164
|
+
symbol = checker.getShorthandAssignmentValueSymbol(reference.parent) ?? symbol;
|
|
165
|
+
}
|
|
166
|
+
if (symbol.flags & ts.SymbolFlags.Alias)
|
|
167
|
+
symbol = checker.getAliasedSymbol(symbol);
|
|
168
|
+
const found = [];
|
|
169
|
+
for (const declaration of symbol.declarations ?? []) {
|
|
170
|
+
if (!isOwnFile(declaration.getSourceFile()))
|
|
171
|
+
continue;
|
|
172
|
+
if (ts.isBindingElement(declaration)) {
|
|
173
|
+
// `const { stamp } = await import("./stamper.js")`: the binding names no
|
|
174
|
+
// function, and the type it is given does.
|
|
175
|
+
for (const signature of checker.getTypeOfSymbolAtLocation(symbol, reference).getCallSignatures()) {
|
|
176
|
+
if (signature.declaration && isFunctionWithBody(signature.declaration) && isOwnFile(signature.declaration.getSourceFile()))
|
|
177
|
+
found.push(signature.declaration);
|
|
178
|
+
}
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
found.push(...functionsOfDeclaration(declaration, reference));
|
|
182
|
+
}
|
|
183
|
+
return found;
|
|
184
|
+
};
|
|
185
|
+
const scanned = new Map();
|
|
186
|
+
/**
|
|
187
|
+
* A function's own raise sites and the functions it refers to, or an
|
|
188
|
+
* expression's (a handler written as a wrapper's call, say). A function
|
|
189
|
+
* written inside it is one it reaches, since it runs as part of it.
|
|
190
|
+
*/
|
|
191
|
+
const scan = (root) => {
|
|
192
|
+
const known = scanned.get(root);
|
|
193
|
+
if (known)
|
|
194
|
+
return known;
|
|
195
|
+
const result = { raises: [], reaches: [] };
|
|
196
|
+
const visit = (node) => {
|
|
197
|
+
if (node !== root && isFunctionWithBody(node)) {
|
|
198
|
+
result.reaches.push(node);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node))
|
|
202
|
+
result.raises.push(...raiseSitesOf(node));
|
|
203
|
+
if (ts.isIdentifier(node))
|
|
204
|
+
result.reaches.push(...functionsReferredToBy(node));
|
|
205
|
+
ts.forEachChild(node, visit);
|
|
206
|
+
};
|
|
207
|
+
visit(root);
|
|
208
|
+
scanned.set(root, result);
|
|
209
|
+
return result;
|
|
210
|
+
};
|
|
211
|
+
/** Every raise site a handler can reach, through everything it refers to. */
|
|
212
|
+
const reachOf = (handler) => {
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
const stack = [handler];
|
|
215
|
+
const raises = [];
|
|
216
|
+
let functions = 0;
|
|
217
|
+
while (stack.length) {
|
|
218
|
+
const node = stack.pop();
|
|
219
|
+
if (seen.has(node))
|
|
220
|
+
continue;
|
|
221
|
+
seen.add(node);
|
|
222
|
+
if (isFunctionWithBody(node))
|
|
223
|
+
functions += 1;
|
|
224
|
+
const { raises: own, reaches } = scan(node);
|
|
225
|
+
raises.push(...own);
|
|
226
|
+
stack.push(...reaches);
|
|
227
|
+
}
|
|
228
|
+
return { raises, functions };
|
|
229
|
+
};
|
|
230
|
+
// ── Handlers ─────────────────────────────────────────────────────────
|
|
231
|
+
/** The codes a handler's `ctx.refuse` takes, or null when its context carries no typed refuse. */
|
|
232
|
+
const allowedCodesOf = (handler) => {
|
|
233
|
+
const contextNode = handler;
|
|
234
|
+
// The type the registration expects the handler to have, which holds
|
|
235
|
+
// however the handler is written (a function, one held elsewhere, a
|
|
236
|
+
// wrapper's result) and whether or not it names its context. A
|
|
237
|
+
// registration that takes a handler or an entry holding one expects a
|
|
238
|
+
// union: the function member is the handler's. A method written in an
|
|
239
|
+
// options object is read off its own type, which the registration
|
|
240
|
+
// gave it.
|
|
241
|
+
const expected = ts.isMethodDeclaration(handler) ? checker.getTypeAtLocation(handler) : checker.getContextualType(handler);
|
|
242
|
+
const members = expected?.isUnion() ? expected.types : expected ? [expected] : [];
|
|
243
|
+
const parameter = members.flatMap((member) => member.getCallSignatures())[0]?.parameters[0];
|
|
244
|
+
const contextType = parameter && checker.getTypeOfSymbolAtLocation(parameter, contextNode);
|
|
245
|
+
const refuse = contextType && checker.getPropertyOfType(contextType, "refuse");
|
|
246
|
+
if (!refuse)
|
|
247
|
+
return null;
|
|
248
|
+
const codes = new Set();
|
|
249
|
+
for (const signature of checker.getTypeOfSymbolAtLocation(refuse, contextNode).getCallSignatures()) {
|
|
250
|
+
const optionsParameter = signature.parameters[1];
|
|
251
|
+
if (!optionsParameter)
|
|
252
|
+
return null;
|
|
253
|
+
const optionsType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(optionsParameter, contextNode));
|
|
254
|
+
for (const part of optionsType.isUnion() ? optionsType.types : [optionsType]) {
|
|
255
|
+
const code = checker.getPropertyOfType(part, "code");
|
|
256
|
+
if (!code)
|
|
257
|
+
continue;
|
|
258
|
+
const codeType = checker.getNonNullableType(checker.getTypeOfSymbolAtLocation(code, contextNode));
|
|
259
|
+
// A handler whose endpoint declares no code is handed a refuse that takes none.
|
|
260
|
+
if (codeType.flags & ts.TypeFlags.Never)
|
|
261
|
+
continue;
|
|
262
|
+
const values = literalsOf(codeType);
|
|
263
|
+
// A refuse whose code is any string is the untyped one: it says nothing of what the handler may send.
|
|
264
|
+
if (values === null)
|
|
265
|
+
return null;
|
|
266
|
+
for (const value of values)
|
|
267
|
+
codes.add(value);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return [...codes];
|
|
271
|
+
};
|
|
272
|
+
/** The codes an options object's `refusals` property names: one code or a list, each a literal or a constant typed as one. */
|
|
273
|
+
const ownRefusalsOf = (optionsNode) => {
|
|
274
|
+
const options = optionsNode && unwrap(optionsNode);
|
|
275
|
+
if (!options || !ts.isObjectLiteralExpression(options))
|
|
276
|
+
return [];
|
|
277
|
+
const property = options.properties.find((candidate) => ts.isPropertyAssignment(candidate) && ts.isIdentifier(candidate.name) && candidate.name.text === "refusals");
|
|
278
|
+
if (!property || !ts.isPropertyAssignment(property))
|
|
279
|
+
return [];
|
|
280
|
+
const value = unwrap(property.initializer);
|
|
281
|
+
const entries = ts.isArrayLiteralExpression(value) ? [...value.elements] : [value];
|
|
282
|
+
return entries.flatMap((entry) => ts.isStringLiteralLike(entry) ? [entry.text] : literalsOf(checker.getTypeAtLocation(entry)) ?? []);
|
|
283
|
+
};
|
|
284
|
+
/** How a registration names its handler: `group.action` for an endpoint in a group, the key or variable it is held under otherwise, the name a mock entry is given, or the registration's own name when it is held under none. */
|
|
285
|
+
const handlerNameOf = (call, registration) => {
|
|
286
|
+
if (registration === "api" || registration === "override") {
|
|
287
|
+
const [name] = call.arguments;
|
|
288
|
+
if (name && ts.isStringLiteralLike(name))
|
|
289
|
+
return name.text;
|
|
290
|
+
}
|
|
291
|
+
const holder = call.parent;
|
|
292
|
+
const key = ts.isPropertyAssignment(holder) ? holder.name.getText()
|
|
293
|
+
: ts.isVariableDeclaration(holder) ? holder.name.getText() : null;
|
|
294
|
+
if (registration === "defineApi" && key && ts.isPropertyAssignment(holder)) {
|
|
295
|
+
const group = holder.parent.parent;
|
|
296
|
+
if (ts.isCallExpression(group) && group.arguments[1] === holder.parent && group.arguments[0] && ts.isStringLiteralLike(group.arguments[0])) {
|
|
297
|
+
return `${group.arguments[0].text}.${key}`;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
// Unnamed (inside an app's own wrapper, say): the finding's `at` says where.
|
|
301
|
+
return key ?? registration;
|
|
302
|
+
};
|
|
303
|
+
const findings = [];
|
|
304
|
+
const unchecked = [];
|
|
305
|
+
let handlers = 0;
|
|
306
|
+
const checkRegistration = (call) => {
|
|
307
|
+
const callee = call.expression;
|
|
308
|
+
const registration = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
309
|
+
const form = registration ? HANDLER_REGISTRATIONS[registration] : undefined;
|
|
310
|
+
if (!registration || !form)
|
|
311
|
+
return;
|
|
312
|
+
if (!isLambderDeclaration(checker.getResolvedSignature(call)?.declaration))
|
|
313
|
+
return;
|
|
314
|
+
const [first, second] = call.arguments;
|
|
315
|
+
const handlerOf = (holder) => {
|
|
316
|
+
if (!holder || !ts.isObjectLiteralExpression(unwrap(holder)))
|
|
317
|
+
return undefined;
|
|
318
|
+
const property = unwrap(holder).properties.find((candidate) => candidate.name?.getText() === "handler");
|
|
319
|
+
if (!property)
|
|
320
|
+
return undefined;
|
|
321
|
+
if (ts.isPropertyAssignment(property))
|
|
322
|
+
return property.initializer;
|
|
323
|
+
if (ts.isMethodDeclaration(property))
|
|
324
|
+
return property;
|
|
325
|
+
if (ts.isShorthandPropertyAssignment(property))
|
|
326
|
+
return property.name;
|
|
327
|
+
return undefined;
|
|
328
|
+
};
|
|
329
|
+
const handler = form === "second" ? second
|
|
330
|
+
: form === "optionsHandler" ? handlerOf(first)
|
|
331
|
+
: second && isFunctionWithBody(unwrap(second)) ? second : handlerOf(second) ?? second;
|
|
332
|
+
const optionsNode = form === "second" ? first : form === "optionsHandler" ? first : second;
|
|
333
|
+
if (!handler)
|
|
334
|
+
return;
|
|
335
|
+
const name = handlerNameOf(call, registration);
|
|
336
|
+
const at = where(call);
|
|
337
|
+
const allowed = allowedCodesOf(handler) ?? (registration === "guard" || registration === "lambderGuard" ? ownRefusalsOfOrNull(optionsNode) : null);
|
|
338
|
+
if (allowed === null) {
|
|
339
|
+
unchecked.push(`${name} (${at})`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
handlers += 1;
|
|
343
|
+
const { raises, functions } = reachOf(ts.isMethodDeclaration(handler) ? handler : unwrap(handler));
|
|
344
|
+
if (functions === 0) {
|
|
345
|
+
findings.push({ handler: name, at, problem: "untraced" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const allowedSet = new Set(allowed);
|
|
349
|
+
const reached = new Set();
|
|
350
|
+
const reported = new Set();
|
|
351
|
+
for (const raise of raises) {
|
|
352
|
+
if ("code" in raise) {
|
|
353
|
+
if (raise.code.startsWith("lambder/"))
|
|
354
|
+
continue;
|
|
355
|
+
reached.add(raise.code);
|
|
356
|
+
if (!allowedSet.has(raise.code) && !reported.has(raise.code)) {
|
|
357
|
+
reported.add(raise.code);
|
|
358
|
+
findings.push({ handler: name, at, problem: "undeclared", code: raise.code, raisedAt: raise.at });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
else if ("uncoded" in raise) {
|
|
362
|
+
if (requireCodes)
|
|
363
|
+
findings.push({ handler: name, at, problem: "uncoded", raisedAt: raise.at });
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
findings.push({ handler: name, at, problem: "unreadable", raisedAt: raise.at });
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const code of ownRefusalsOf(optionsNode)) {
|
|
370
|
+
if (!reached.has(code))
|
|
371
|
+
findings.push({ handler: name, at, problem: "unused", code });
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
/** A guard's `refusals` option, or null when it has none: what a guard built without the vocabulary may send. */
|
|
375
|
+
const ownRefusalsOfOrNull = (optionsNode) => {
|
|
376
|
+
const own = ownRefusalsOf(optionsNode);
|
|
377
|
+
return own.length ? own : null;
|
|
378
|
+
};
|
|
379
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
380
|
+
if (!isOwnFile(sourceFile))
|
|
381
|
+
continue;
|
|
382
|
+
const visit = (node) => {
|
|
383
|
+
if (ts.isCallExpression(node))
|
|
384
|
+
checkRegistration(node);
|
|
385
|
+
ts.forEachChild(node, visit);
|
|
386
|
+
};
|
|
387
|
+
visit(sourceFile);
|
|
388
|
+
}
|
|
389
|
+
const uncheckedLine = unchecked.length ? [` ${unchecked.length} ${unchecked.length === 1 ? "handler has" : "handlers have"} no typed refuse to check against: ${unchecked.join(", ")}`] : [];
|
|
390
|
+
if (handlers === 0) {
|
|
391
|
+
return failed(`✗ ${options.tsconfig}: no handler Lambder hands a typed refuse was found, so nothing was checked. Do its APIs come from an init that declares its refusals (declareRefusals), and do its files resolve lambder?`, uncheckedLine);
|
|
392
|
+
}
|
|
393
|
+
// Duplicate uncoded or unreadable findings of one site through two paths say the same thing once.
|
|
394
|
+
const unique = [...new Map(findings.map((finding) => [JSON.stringify(finding), finding])).values()];
|
|
395
|
+
const lines = [
|
|
396
|
+
unique.length === 0
|
|
397
|
+
? `✓ ${handlers} handlers reach only the refusals they may send`
|
|
398
|
+
: `✗ ${unique.length} refusal ${unique.length === 1 ? "finding" : "findings"} in ${new Set(unique.map((finding) => finding.handler)).size} of ${handlers} handlers`,
|
|
399
|
+
...unique.map(describeFinding),
|
|
400
|
+
...uncheckedLine,
|
|
401
|
+
];
|
|
402
|
+
return { ok: unique.length === 0, handlers, findings: unique, lines };
|
|
403
|
+
};
|
|
404
|
+
const describeFinding = (finding) => {
|
|
405
|
+
switch (finding.problem) {
|
|
406
|
+
case "undeclared": return ` ${finding.handler} (${finding.at}) can refuse with "${finding.code}" (${finding.raisedAt}), which it may not send: name it in its refusals, or keep the handler from reaching it`;
|
|
407
|
+
case "unused": return ` ${finding.handler} (${finding.at}) declares "${finding.code}", which nothing it reaches raises`;
|
|
408
|
+
case "uncoded": return ` ${finding.handler} (${finding.at}) can reach a refusal with no code (${finding.raisedAt})`;
|
|
409
|
+
case "unreadable": return ` ${finding.handler} (${finding.at}) can reach a refusal whose code is not a string literal (${finding.raisedAt}), so which code it sends cannot be told`;
|
|
410
|
+
case "untraced": return ` ${finding.handler} (${finding.at}) is handed a typed refuse, but its function cannot be found (a parameter, a dependency's value), so nothing it reaches was checked`;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const failed = (line, details = []) => ({ ok: false, handlers: 0, findings: [], lines: [line, ...details] });
|
|
414
|
+
const isInside = (path, directory) => path === directory || path.startsWith(directory + sep);
|
|
415
|
+
const realPathOf = (ts, path) => ts.sys.realpath?.(path) ?? path;
|
|
@@ -52,7 +52,7 @@ export declare const settleGeneratedFile: (run: {
|
|
|
52
52
|
previousText: string | null;
|
|
53
53
|
/** Whether that text held the tables as written; false for a file rewritten by hand. */
|
|
54
54
|
readBack: boolean;
|
|
55
|
-
/** Each table the file holds, under its exported name: how many entries it has now, and which of them moved. */
|
|
55
|
+
/** Each table the file holds, under the name its lines print (a table's exported name): how many entries it has now, and which of them moved. */
|
|
56
56
|
tables: readonly {
|
|
57
57
|
name: string;
|
|
58
58
|
count: number;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** The typescript package as the build tools read it: its compiler API. */
|
|
2
|
+
export type TypeScriptModule = typeof import("typescript");
|
|
3
|
+
/**
|
|
4
|
+
* The compiler API a build tool reads a project through, from the typescript
|
|
5
|
+
* package installed beside lambder. TypeScript 7 ships none, so its package
|
|
6
|
+
* answers the import without one, and an app on TypeScript 7 installs a 6.x
|
|
7
|
+
* where its generator script runs. `reader` says who reads what, for the
|
|
8
|
+
* error: "writeApiContract reads the contract".
|
|
9
|
+
*/
|
|
10
|
+
export declare const loadTypeScriptCompiler: (reader: string) => Promise<TypeScriptModule>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compiler API a build tool reads a project through, from the typescript
|
|
3
|
+
* package installed beside lambder. TypeScript 7 ships none, so its package
|
|
4
|
+
* answers the import without one, and an app on TypeScript 7 installs a 6.x
|
|
5
|
+
* where its generator script runs. `reader` says who reads what, for the
|
|
6
|
+
* error: "writeApiContract reads the contract".
|
|
7
|
+
*/
|
|
8
|
+
export const loadTypeScriptCompiler = async (reader) => {
|
|
9
|
+
const requirement = `${reader} through the TypeScript compiler API (typescript 5.4 to 6.x): install one beside lambder, such as in the generator's own package when the app is on TypeScript 7`;
|
|
10
|
+
let ts;
|
|
11
|
+
try {
|
|
12
|
+
ts = (await import("typescript")).default;
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
throw new Error(requirement, { cause: err });
|
|
16
|
+
}
|
|
17
|
+
if (typeof ts?.createProgram !== "function")
|
|
18
|
+
throw new Error(`${requirement}; the typescript installed (${ts?.version ?? "unknown"}) has no compiler API`);
|
|
19
|
+
return ts;
|
|
20
|
+
};
|
|
@@ -2,7 +2,27 @@ import { existsSync, readFileSync } from "fs";
|
|
|
2
2
|
import { dirname, resolve } from "path";
|
|
3
3
|
import { ContractTypePrinter } from "./ContractTypePrinter.js";
|
|
4
4
|
import { modulePathOf } from "./moduleLocation.js";
|
|
5
|
+
import { loadTypeScriptCompiler } from "./loadTypeScriptCompiler.js";
|
|
5
6
|
import { writeFileAtomically } from "./writeFileAtomically.js";
|
|
7
|
+
/*
|
|
8
|
+
* The contract a client compiles against, written out once as plain types.
|
|
9
|
+
*
|
|
10
|
+
* A client that imports `typeof lambder.ApiContract` from the server's
|
|
11
|
+
* sources compiles the server to get it: every endpoint's schemas, the
|
|
12
|
+
* libraries they infer through, and whatever the server's entry imports
|
|
13
|
+
* besides, and it resolves every endpoint's entry out of the declarations
|
|
14
|
+
* registerApiGroups() mapped. In a large app that is most of the client's
|
|
15
|
+
* type-check time and memory, for one type. This reads the ApiContract
|
|
16
|
+
* property off the exported instance and writes it out as the structure it
|
|
17
|
+
* resolves to, one object type with plain members in a module that imports
|
|
18
|
+
* nothing, so the client compiles the contract alone.
|
|
19
|
+
*
|
|
20
|
+
* What can go wrong is the file: stale against the server, or printed as
|
|
21
|
+
* something other than the type it stands for. A check compares it with what
|
|
22
|
+
* the server's sources print now, and a write first proves the printed type
|
|
23
|
+
* the same as the server's, entry by entry, in both directions, before the
|
|
24
|
+
* file is touched.
|
|
25
|
+
*/
|
|
6
26
|
const DEFAULT_HEADER = [
|
|
7
27
|
"Generated by writeApiContract() from lambder/build. Do not edit.",
|
|
8
28
|
"",
|
|
@@ -54,7 +74,7 @@ const DIAGNOSTIC_LIMIT = 20;
|
|
|
54
74
|
* over the old one, so a build reading it meanwhile never sees half of it.
|
|
55
75
|
*/
|
|
56
76
|
export const writeApiContract = async (options) => {
|
|
57
|
-
const ts = await
|
|
77
|
+
const ts = await loadTypeScriptCompiler("writeApiContract reads the contract");
|
|
58
78
|
const file = resolve(options.file);
|
|
59
79
|
const modulePath = modulePathOf(options.module);
|
|
60
80
|
const exportName = options.exportName ?? "default";
|
|
@@ -89,20 +109,6 @@ export const writeApiContract = async (options) => {
|
|
|
89
109
|
writeFileAtomically(file, text, previous !== null);
|
|
90
110
|
return result(true, true, [`✓ Wrote ${options.file} (${count} APIs)`, ...(movedSummary.length ? movedSummary : [" no API's types changed; the header or layout did"])], moved);
|
|
91
111
|
};
|
|
92
|
-
/** The compiler API writeApiContract reads with. TypeScript 7 ships none, so its package answers the import without one. */
|
|
93
|
-
const loadTypeScript = async () => {
|
|
94
|
-
const requirement = "writeApiContract reads the contract through the TypeScript compiler API (typescript 5.4 to 6.x): install one beside lambder, such as in the generator's own package when the app is on TypeScript 7";
|
|
95
|
-
let ts;
|
|
96
|
-
try {
|
|
97
|
-
ts = (await import("typescript")).default;
|
|
98
|
-
}
|
|
99
|
-
catch (err) {
|
|
100
|
-
throw new Error(requirement, { cause: err });
|
|
101
|
-
}
|
|
102
|
-
if (typeof ts?.createProgram !== "function")
|
|
103
|
-
throw new Error(`${requirement}; the typescript installed (${ts?.version ?? "unknown"}) has no compiler API`);
|
|
104
|
-
return ts;
|
|
105
|
-
};
|
|
106
112
|
/** The server's compiler options, and the root files a program over the module needs: the module, and the project's own declaration files, which may declare globals the module relies on. */
|
|
107
113
|
const readProject = (ts, tsconfig, modulePath) => {
|
|
108
114
|
const configPath = tsconfig ? resolve(tsconfig) : ts.findConfigFile(dirname(modulePath), ts.sys.fileExists);
|
|
@@ -12,13 +12,13 @@ export type LambderApiGuardParamsFileOptions = {
|
|
|
12
12
|
exportName?: string;
|
|
13
13
|
/** The guard whose parameters the file holds, as the server declares it. A name the server declares no guard under throws. */
|
|
14
14
|
guard: string;
|
|
15
|
-
/** The TypeScript module to write,
|
|
15
|
+
/** The TypeScript module to write, one export per API that declares the guard. Relative to the working directory. */
|
|
16
16
|
file: string;
|
|
17
17
|
/** Write nothing, and answer whether the file on disk holds what the instance reports now. Default: false. */
|
|
18
18
|
check?: boolean;
|
|
19
19
|
/** The comment the file opens with, one `//` line per line. It should name what generates the file. Default: a note naming writeApiGuardParams() and the guard. */
|
|
20
20
|
header?: string;
|
|
21
|
-
/** End
|
|
21
|
+
/** End each generated statement with a semicolon. Default: true. The values are JSON, double quotes included, whatever the project's style: that is what lets a check read them back. */
|
|
22
22
|
semicolons?: boolean;
|
|
23
23
|
};
|
|
24
24
|
export type LambderApiGuardParamsFileResult = {
|
|
@@ -26,7 +26,7 @@ export type LambderApiGuardParamsFileResult = {
|
|
|
26
26
|
ok: boolean;
|
|
27
27
|
/** The absolute path. */
|
|
28
28
|
file: string;
|
|
29
|
-
/** True when the file was (re)written; a file that already holds
|
|
29
|
+
/** True when the file was (re)written; a file that already holds these parameters is left untouched, however it is formatted. */
|
|
30
30
|
written: boolean;
|
|
31
31
|
/** How many APIs declare the guard. */
|
|
32
32
|
count: number;
|
|
@@ -36,8 +36,8 @@ export type LambderApiGuardParamsFileResult = {
|
|
|
36
36
|
lines: string[];
|
|
37
37
|
};
|
|
38
38
|
/**
|
|
39
|
-
* Writes one guard's parameters as a module of
|
|
40
|
-
* on disk, and says which APIs moved.
|
|
39
|
+
* Writes one guard's parameters as a module of one export per API, or checks
|
|
40
|
+
* the one on disk, and says which APIs moved.
|
|
41
41
|
*
|
|
42
42
|
* ```ts
|
|
43
43
|
* import { writeApiGuardParams } from "lambder/build";
|
|
@@ -51,10 +51,11 @@ export type LambderApiGuardParamsFileResult = {
|
|
|
51
51
|
* });
|
|
52
52
|
* ```
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* that is not plain data
|
|
54
|
+
* API `orders.list` exports `ordersListGuardParam`: the literal it declared,
|
|
55
|
+
* `true` for a guard named without a parameter (the string and list forms),
|
|
56
|
+
* typed LambderApiGuardParam<"orders.list", "store", literal>. Two APIs whose
|
|
57
|
+
* names export under one identifier fail the write, and so does a parameter
|
|
58
|
+
* that is not plain data, as it does for writeApiOptions, or that is null,
|
|
59
|
+
* which no tag can carry.
|
|
59
60
|
*/
|
|
60
61
|
export declare const writeApiGuardParams: (options: LambderApiGuardParamsFileOptions) => Promise<LambderApiGuardParamsFileResult>;
|