@groma/scanner-react 0.1.5 → 0.2.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/README.md +78 -43
- package/dist/index.js +1003 -606
- package/package.json +23 -3
- package/validation.md +2 -2
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// plugins/scanners/react/src/index.ts
|
|
3
|
-
import
|
|
3
|
+
import ts7 from "./typescript.cjs";
|
|
4
4
|
|
|
5
5
|
// plugins/scanners/typescript-outline.ts
|
|
6
6
|
import { readFile } from "fs/promises";
|
|
@@ -71,7 +71,7 @@ function typeMember(scope, member) {
|
|
|
71
71
|
return name === undefined ? [] : [symbolAt(scope, name.text, name, memberVisibility(scope, member))];
|
|
72
72
|
}
|
|
73
73
|
function typeMembers(k, statement) {
|
|
74
|
-
if (is(statement, k.ClassDeclaration) || is(statement, k.InterfaceDeclaration))
|
|
74
|
+
if (is(statement, k.ClassDeclaration) || is(statement, k.ClassExpression) || is(statement, k.InterfaceDeclaration))
|
|
75
75
|
return statement.members;
|
|
76
76
|
return statement.kind === k.EnumDeclaration ? [] : undefined;
|
|
77
77
|
}
|
|
@@ -100,9 +100,12 @@ function declarationsIn(scope, statements) {
|
|
|
100
100
|
return [...functionDeclarations(scope, statement), ...typeDeclaration(scope, statement)];
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
+
function listedExportNames(ts, source) {
|
|
104
|
+
return new Set(source.statements.flatMap((statement) => listedExports(ts.SyntaxKind, statement)));
|
|
105
|
+
}
|
|
103
106
|
function outlineSource(ts, source, context) {
|
|
104
107
|
const k = ts.SyntaxKind;
|
|
105
|
-
const exported = new Set([...
|
|
108
|
+
const exported = new Set([...listedExportNames(ts, source), ...context.exported ?? []]);
|
|
106
109
|
const scope = { k, source, symbols: context.symbols, exported, topLevelPrivate: context.topLevelPrivate ?? false };
|
|
107
110
|
return declarationsIn(scope, source.statements);
|
|
108
111
|
}
|
|
@@ -121,311 +124,17 @@ async function readTypeScriptOutline(ts, repositoryRoot, references) {
|
|
|
121
124
|
return files;
|
|
122
125
|
}
|
|
123
126
|
|
|
124
|
-
// plugins/scanners/typescript
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// plugins/scanners/projects.ts
|
|
128
|
-
import { execFile } from "child_process";
|
|
129
|
-
import { existsSync } from "fs";
|
|
130
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
131
|
-
import path2 from "path";
|
|
132
|
-
import { promisify } from "util";
|
|
133
|
-
var execute = promisify(execFile);
|
|
134
|
-
var excluded = new Set([
|
|
135
|
-
".git",
|
|
136
|
-
"node_modules",
|
|
137
|
-
"vendor",
|
|
138
|
-
"target",
|
|
139
|
-
"dist",
|
|
140
|
-
"build",
|
|
141
|
-
"bin",
|
|
142
|
-
"obj",
|
|
143
|
-
".gradle",
|
|
144
|
-
".angular",
|
|
145
|
-
"coverage",
|
|
146
|
-
"generated",
|
|
147
|
-
"groma",
|
|
148
|
-
".groma"
|
|
149
|
-
]);
|
|
150
|
-
async function repositoryFiles(root, matches) {
|
|
151
|
-
const { stdout } = await execute("git", ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"], { maxBuffer: 64 * 1024 * 1024 });
|
|
152
|
-
return [...new Set(stdout.split("\x00").filter((file) => file && matches(file) && existsSync(path2.join(root, file))))].sort();
|
|
153
|
-
}
|
|
154
|
-
function projectFiles(root, matches) {
|
|
155
|
-
return repositoryFiles(root, (file) => matches(file) && !file.split("/").some((part) => excluded.has(part)));
|
|
156
|
-
}
|
|
157
|
-
function hasDependency(manifest, dependency) {
|
|
158
|
-
return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some((section) => typeof manifest[section]?.[dependency] === "string");
|
|
159
|
-
}
|
|
160
|
-
async function frameworkProjects(root, dependency, extensions) {
|
|
161
|
-
const files = await projectFiles(root, () => true);
|
|
162
|
-
const manifests = files.filter((file) => path2.posix.basename(file) === "package.json");
|
|
163
|
-
const directories = new Set(manifests.map((file) => path2.posix.dirname(file)));
|
|
164
|
-
const sources = new Set;
|
|
165
|
-
for (const file of files.filter((file) => extensions.some((extension) => file.endsWith(extension)) && !file.endsWith(".d.ts"))) {
|
|
166
|
-
let directory = path2.posix.dirname(file);
|
|
167
|
-
while (directory !== "." && !directories.has(directory))
|
|
168
|
-
directory = path2.posix.dirname(directory);
|
|
169
|
-
sources.add(directory);
|
|
170
|
-
}
|
|
171
|
-
const projects = [];
|
|
172
|
-
for (const file of manifests) {
|
|
173
|
-
const directory = path2.posix.dirname(file);
|
|
174
|
-
if (!sources.has(directory) || !files.includes(path2.posix.join(directory, "tsconfig.json")))
|
|
175
|
-
continue;
|
|
176
|
-
const manifest = JSON.parse(await readFile2(path2.join(root, file), "utf8"));
|
|
177
|
-
if (hasDependency(manifest, dependency))
|
|
178
|
-
projects.push(path2.dirname(path2.join(root, file)));
|
|
179
|
-
}
|
|
180
|
-
return projects;
|
|
127
|
+
// plugins/scanners/typescript/src/files.ts
|
|
128
|
+
function typeScriptSources(files) {
|
|
129
|
+
return files.filter((file) => /\.tsx?$/.test(file) && !file.endsWith(".d.ts"));
|
|
181
130
|
}
|
|
182
131
|
|
|
183
|
-
// plugins/scanners/
|
|
184
|
-
|
|
185
|
-
const { root, dependency, projects, sources, also } = options;
|
|
186
|
-
const directories = (await frameworkProjects(root, dependency, projects)).map((directory) => path3.relative(root, directory).split(path3.sep).join("/") || ".");
|
|
187
|
-
if (directories.length === 0)
|
|
188
|
-
return [];
|
|
189
|
-
const listed = [];
|
|
190
|
-
for (const file of await projectFiles(root, (candidate) => !candidate.endsWith(".d.ts"))) {
|
|
191
|
-
const project = directories.find((directory) => directory === "." || file.startsWith(`${directory}/`));
|
|
192
|
-
if (project === undefined)
|
|
193
|
-
continue;
|
|
194
|
-
const local = project === "." ? file : file.slice(project.length + 1);
|
|
195
|
-
if (sources.some((extension) => file.endsWith(extension)) || (also?.(local) ?? false))
|
|
196
|
-
listed.push(file);
|
|
197
|
-
}
|
|
198
|
-
return listed.sort();
|
|
199
|
-
}
|
|
132
|
+
// plugins/scanners/react/src/scan.ts
|
|
133
|
+
import path8 from "path";
|
|
200
134
|
|
|
201
|
-
// plugins/scanners/
|
|
202
|
-
import {
|
|
135
|
+
// plugins/scanners/entry-points/javascript.ts
|
|
136
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
203
137
|
import path4 from "path";
|
|
204
|
-
import ts2 from "./typescript.cjs";
|
|
205
|
-
|
|
206
|
-
// plugins/scanners/http-url.ts
|
|
207
|
-
var pathText = /^[A-Za-z0-9\-._~!$&'()*+,;=:@%]+$/;
|
|
208
|
-
var absolute = /^([A-Za-z][A-Za-z\d+.-]*:)?\/\//;
|
|
209
|
-
var HOLE = "\x00";
|
|
210
|
-
var computedPart = { kind: "hole", configured: false };
|
|
211
|
-
var configuredPart = { kind: "hole", configured: true };
|
|
212
|
-
var methodToken = /^[A-Z][A-Z-]*$/;
|
|
213
|
-
function methodText(text) {
|
|
214
|
-
const method = text?.toUpperCase();
|
|
215
|
-
return method !== undefined && methodToken.test(method) ? method : undefined;
|
|
216
|
-
}
|
|
217
|
-
function requestSegment(part) {
|
|
218
|
-
if (part === HOLE)
|
|
219
|
-
return { kind: "dynamic" };
|
|
220
|
-
if (part.includes(HOLE) || !pathText.test(part))
|
|
221
|
-
return { kind: "unknown" };
|
|
222
|
-
return { kind: "literal", value: part };
|
|
223
|
-
}
|
|
224
|
-
function segmentsOf(parts) {
|
|
225
|
-
const url = parts.map((part) => part.kind === "text" ? part.text : HOLE).join("");
|
|
226
|
-
const end = Math.min(...["?", "#"].map((mark) => url.includes(mark) ? url.indexOf(mark) : url.length));
|
|
227
|
-
return url.slice(0, end).split("/").filter((part) => part !== "").map(requestSegment);
|
|
228
|
-
}
|
|
229
|
-
function afterAuthority(text) {
|
|
230
|
-
const slash = text.indexOf("/", text.indexOf("//") + 2);
|
|
231
|
-
return slash < 0 ? "" : text.slice(slash);
|
|
232
|
-
}
|
|
233
|
-
function joinedText(input) {
|
|
234
|
-
const parts = [];
|
|
235
|
-
for (const part of input) {
|
|
236
|
-
const last = parts.at(-1);
|
|
237
|
-
if (part.kind === "text" && last?.kind === "text")
|
|
238
|
-
parts[parts.length - 1] = { kind: "text", text: last.text + part.text };
|
|
239
|
-
else if (part.kind === "hole" || part.text !== "")
|
|
240
|
-
parts.push(part);
|
|
241
|
-
}
|
|
242
|
-
return parts;
|
|
243
|
-
}
|
|
244
|
-
function requestUrl(input) {
|
|
245
|
-
const parts = joinedText(input);
|
|
246
|
-
const [first, ...rest] = parts;
|
|
247
|
-
if (first === undefined)
|
|
248
|
-
return { path: [{ kind: "unknown" }] };
|
|
249
|
-
if (first.kind === "hole") {
|
|
250
|
-
const path = segmentsOf(rest);
|
|
251
|
-
const next = rest[0];
|
|
252
|
-
const separated = next === undefined || next.kind === "text" && next.text.startsWith("/");
|
|
253
|
-
return first.configured && separated ? { configured: true, path } : { path: [{ kind: "unknown" }, ...path] };
|
|
254
|
-
}
|
|
255
|
-
if (absolute.test(first.text)) {
|
|
256
|
-
return { path: [{ kind: "unknown" }, ...segmentsOf([{ kind: "text", text: afterAuthority(first.text) }, ...rest])] };
|
|
257
|
-
}
|
|
258
|
-
if (!first.text.startsWith("/"))
|
|
259
|
-
return { path: [{ kind: "unknown" }, ...segmentsOf(parts)] };
|
|
260
|
-
return { path: segmentsOf(parts) };
|
|
261
|
-
}
|
|
262
|
-
function joinBase(base, url) {
|
|
263
|
-
const stated = joinedText(base);
|
|
264
|
-
const path = joinedText(url);
|
|
265
|
-
const [first] = path;
|
|
266
|
-
if (stated.length === 0)
|
|
267
|
-
return path;
|
|
268
|
-
if (first === undefined)
|
|
269
|
-
return stated;
|
|
270
|
-
if (first.kind === "hole")
|
|
271
|
-
return [computedPart, ...path.slice(1)];
|
|
272
|
-
return absolute.test(first.text) ? path : [...stated, { kind: "text", text: "/" }, ...path];
|
|
273
|
-
}
|
|
274
|
-
function withBase(base, url) {
|
|
275
|
-
const stated = joinedText(base);
|
|
276
|
-
const path = joinedText(url);
|
|
277
|
-
const [only] = stated;
|
|
278
|
-
const [first, ...rest] = path;
|
|
279
|
-
if (stated.length !== 1 || only?.kind !== "text" || first?.kind !== "text" || absolute.test(first.text)) {
|
|
280
|
-
return joinBase(stated, path);
|
|
281
|
-
}
|
|
282
|
-
const prefix = only.text.replace(/\/$/, "");
|
|
283
|
-
if (first.text.startsWith(prefix)) {
|
|
284
|
-
const next = first.text[prefix.length];
|
|
285
|
-
if (next === undefined)
|
|
286
|
-
return rest.length === 0 ? path : [computedPart, ...rest];
|
|
287
|
-
return next === "/" || next === "?" ? path : joinBase(stated, path);
|
|
288
|
-
}
|
|
289
|
-
return prefix.startsWith(first.text) && rest.length > 0 ? [computedPart, ...rest] : joinBase(stated, path);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
// plugins/scanners/react/src/functions.ts
|
|
293
|
-
import ts from "./typescript.cjs";
|
|
294
|
-
function executable(node) {
|
|
295
|
-
return ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) && !!node.body;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// plugins/scanners/react/src/routes.ts
|
|
299
|
-
var METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
300
|
-
var APP_ROUTE = /^(?:(.+)\/)?route\.tsx?$/;
|
|
301
|
-
var PAGES_API = /^(api\/.+)\.tsx?$/;
|
|
302
|
-
var OPTIONAL_CATCH_ALL = /^\[\[\.\.\.(.+)\]\]$/;
|
|
303
|
-
var CATCH_ALL = /^\[\.\.\.(.+)\]$/;
|
|
304
|
-
var PARAMETER = /^\[(.+)\]$/;
|
|
305
|
-
function nextRouters(projectRoot) {
|
|
306
|
-
const active = (name) => existsSync2(path4.join(projectRoot, name)) ? name : `src/${name}`;
|
|
307
|
-
return { app: active("app"), pages: active("pages") };
|
|
308
|
-
}
|
|
309
|
-
function routeLocation(file, routers) {
|
|
310
|
-
const within = (directory) => file.startsWith(`${directory}/`) ? file.slice(directory.length + 1) : undefined;
|
|
311
|
-
const app = APP_ROUTE.exec(within(routers.app) ?? "");
|
|
312
|
-
if (app)
|
|
313
|
-
return { router: "app", ...app[1] === undefined ? {} : { directory: app[1] } };
|
|
314
|
-
const pages = PAGES_API.exec(within(routers.pages) ?? "");
|
|
315
|
-
return pages ? { router: "pages", directory: pages[1] } : undefined;
|
|
316
|
-
}
|
|
317
|
-
function routeCandidate(file) {
|
|
318
|
-
return ["", "src/"].some((prefix) => routeLocation(file, { app: `${prefix}app`, pages: `${prefix}pages` }) !== undefined);
|
|
319
|
-
}
|
|
320
|
-
var GROUP = /^\((?!\.+\))[^)]+\)$/;
|
|
321
|
-
function routeSegment(part, last) {
|
|
322
|
-
const optional = OPTIONAL_CATCH_ALL.exec(part);
|
|
323
|
-
if (optional)
|
|
324
|
-
return last && pathText.test(optional[1]) ? { kind: "catch-all", name: optional[1], optional: true } : undefined;
|
|
325
|
-
const rest = CATCH_ALL.exec(part);
|
|
326
|
-
if (rest)
|
|
327
|
-
return last && pathText.test(rest[1]) ? { kind: "catch-all", name: rest[1] } : undefined;
|
|
328
|
-
const parameter = PARAMETER.exec(part);
|
|
329
|
-
if (parameter)
|
|
330
|
-
return pathText.test(parameter[1]) ? { kind: "parameter", name: parameter[1] } : undefined;
|
|
331
|
-
if (part.startsWith("(") || part.startsWith("@") || part.includes("[") || part.includes("]"))
|
|
332
|
-
return;
|
|
333
|
-
return pathText.test(part) ? { kind: "literal", value: part } : undefined;
|
|
334
|
-
}
|
|
335
|
-
function routeParts(directory, stripIndex) {
|
|
336
|
-
const parts = (directory ?? "").split("/").filter((part) => part !== "" && !GROUP.test(part));
|
|
337
|
-
return stripIndex && parts.at(-1) === "index" ? parts.slice(0, -1) : parts;
|
|
338
|
-
}
|
|
339
|
-
function routePath(directory, stripIndex) {
|
|
340
|
-
const parts = routeParts(directory, stripIndex);
|
|
341
|
-
const segments = [];
|
|
342
|
-
for (const [index, part] of parts.entries()) {
|
|
343
|
-
const segment = routeSegment(part, index === parts.length - 1);
|
|
344
|
-
if (segment === undefined)
|
|
345
|
-
return;
|
|
346
|
-
segments.push(segment);
|
|
347
|
-
}
|
|
348
|
-
return segments;
|
|
349
|
-
}
|
|
350
|
-
function functionValue(node) {
|
|
351
|
-
return node !== undefined && executable(node) ? node : undefined;
|
|
352
|
-
}
|
|
353
|
-
function hasModifier2(statement, kind) {
|
|
354
|
-
return ts2.canHaveModifiers(statement) && (ts2.getModifiers(statement) ?? []).some((modifier) => modifier.kind === kind);
|
|
355
|
-
}
|
|
356
|
-
function declaredHandler(statement) {
|
|
357
|
-
if (!ts2.isFunctionDeclaration(statement) || statement.name === undefined)
|
|
358
|
-
return;
|
|
359
|
-
const handler = functionValue(statement);
|
|
360
|
-
return handler !== undefined && METHODS.has(statement.name.text) ? [statement.name.text, handler] : undefined;
|
|
361
|
-
}
|
|
362
|
-
function boundHandlers(statement) {
|
|
363
|
-
if (!ts2.isVariableStatement(statement))
|
|
364
|
-
return [];
|
|
365
|
-
return statement.declarationList.declarations.flatMap((declaration) => {
|
|
366
|
-
const handler = functionValue(declaration.initializer);
|
|
367
|
-
if (handler === undefined || !ts2.isIdentifier(declaration.name) || !METHODS.has(declaration.name.text))
|
|
368
|
-
return [];
|
|
369
|
-
return [[declaration.name.text, handler]];
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
function appHandlers(source) {
|
|
373
|
-
const exported = source.statements.filter((statement) => hasModifier2(statement, ts2.SyntaxKind.ExportKeyword));
|
|
374
|
-
return new Map(exported.flatMap((statement) => {
|
|
375
|
-
const declared = declaredHandler(statement);
|
|
376
|
-
return declared === undefined ? boundHandlers(statement) : [declared];
|
|
377
|
-
}));
|
|
378
|
-
}
|
|
379
|
-
function localFunction(source, name) {
|
|
380
|
-
for (const statement of source.statements) {
|
|
381
|
-
if (ts2.isFunctionDeclaration(statement) && statement.name?.text === name)
|
|
382
|
-
return functionValue(statement);
|
|
383
|
-
if (!ts2.isVariableStatement(statement))
|
|
384
|
-
continue;
|
|
385
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
386
|
-
if (ts2.isIdentifier(declaration.name) && declaration.name.text === name)
|
|
387
|
-
return functionValue(declaration.initializer);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
function pagesHandler(source) {
|
|
393
|
-
for (const statement of source.statements) {
|
|
394
|
-
if (ts2.isFunctionDeclaration(statement) && hasModifier2(statement, ts2.SyntaxKind.DefaultKeyword))
|
|
395
|
-
return functionValue(statement);
|
|
396
|
-
if (!ts2.isExportAssignment(statement) || statement.isExportEquals === true)
|
|
397
|
-
continue;
|
|
398
|
-
const value = functionValue(statement.expression);
|
|
399
|
-
if (value !== undefined)
|
|
400
|
-
return value;
|
|
401
|
-
if (ts2.isIdentifier(statement.expression))
|
|
402
|
-
return localFunction(source, statement.expression.text);
|
|
403
|
-
}
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
function nextRouteEndpoints(sources, projectRoot, routers, operationId) {
|
|
407
|
-
const endpoints = [];
|
|
408
|
-
for (const source of sources) {
|
|
409
|
-
const location = routeLocation(path4.relative(projectRoot, source.fileName).split(path4.sep).join("/"), routers);
|
|
410
|
-
const segments = location === undefined ? undefined : routePath(location.directory, location.router === "pages");
|
|
411
|
-
if (location === undefined || segments === undefined)
|
|
412
|
-
continue;
|
|
413
|
-
if (location.router === "app") {
|
|
414
|
-
for (const [method, handler] of appHandlers(source)) {
|
|
415
|
-
endpoints.push({ operation: operationId(handler), method, path: segments });
|
|
416
|
-
}
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
419
|
-
const handler = pagesHandler(source);
|
|
420
|
-
if (handler !== undefined)
|
|
421
|
-
endpoints.push({ operation: operationId(handler), method: "*", path: segments });
|
|
422
|
-
}
|
|
423
|
-
return endpoints;
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
// plugins/scanners/react/src/scan.ts
|
|
427
|
-
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
428
|
-
import path5 from "path";
|
|
429
138
|
|
|
430
139
|
// packages/scanner/src/values.ts
|
|
431
140
|
function object(value, label) {
|
|
@@ -446,18 +155,18 @@ function array(value, label) {
|
|
|
446
155
|
}
|
|
447
156
|
|
|
448
157
|
// packages/scanner/src/http.ts
|
|
449
|
-
var
|
|
450
|
-
var
|
|
158
|
+
var pathText = /^[A-Za-z0-9\-._~!$&'()*+,;=:@%]+$/;
|
|
159
|
+
var methodToken = /^[A-Z][A-Z-]*$/;
|
|
451
160
|
function fail(message) {
|
|
452
161
|
throw new Error(message);
|
|
453
162
|
}
|
|
454
163
|
function text(value, label) {
|
|
455
|
-
if (typeof value !== "string" || !
|
|
164
|
+
if (typeof value !== "string" || !pathText.test(value))
|
|
456
165
|
fail(`${label} must be nonempty URL path characters`);
|
|
457
166
|
return value;
|
|
458
167
|
}
|
|
459
168
|
function method(value, label, anyMethod) {
|
|
460
|
-
if (typeof value !== "string" || !(
|
|
169
|
+
if (typeof value !== "string" || !(methodToken.test(value) || anyMethod && value === "*")) {
|
|
461
170
|
fail(`${label} must be an uppercase HTTP method${anyMethod ? " or *" : ""}`);
|
|
462
171
|
}
|
|
463
172
|
return value;
|
|
@@ -486,7 +195,7 @@ function endpointSegment(value, last) {
|
|
|
486
195
|
...segment.constrained ? { constrained: true } : {}
|
|
487
196
|
};
|
|
488
197
|
}
|
|
489
|
-
function
|
|
198
|
+
function requestSegment(value) {
|
|
490
199
|
const segment = object(value, "HTTP request segment");
|
|
491
200
|
if (segment.kind === "literal")
|
|
492
201
|
return { kind: "literal", value: text(segment.value, "HTTP literal segment") };
|
|
@@ -523,7 +232,7 @@ function request(value, operations) {
|
|
|
523
232
|
operation: operation(fact.operation, operations),
|
|
524
233
|
...fact.method === undefined ? {} : { method: method(fact.method, "HTTP request method", false) },
|
|
525
234
|
...fact.configured ? { configured: true } : {},
|
|
526
|
-
path: array(fact.path, "HTTP request path").map(
|
|
235
|
+
path: array(fact.path, "HTTP request path").map(requestSegment)
|
|
527
236
|
};
|
|
528
237
|
}
|
|
529
238
|
function httpEvidence(endpoints, requests, operations) {
|
|
@@ -543,13 +252,13 @@ function compare(...values) {
|
|
|
543
252
|
}
|
|
544
253
|
function uniqueBy(values, key, label) {
|
|
545
254
|
const seen = new Set;
|
|
546
|
-
|
|
547
|
-
|
|
255
|
+
const keyed = values.map((value) => ({ id: key(value), value }));
|
|
256
|
+
for (const { id } of keyed) {
|
|
548
257
|
if (seen.has(id))
|
|
549
258
|
throw new Error(`duplicate ${label}: ${id}`);
|
|
550
259
|
seen.add(id);
|
|
551
260
|
}
|
|
552
|
-
return
|
|
261
|
+
return keyed.sort((left, right) => left.id.localeCompare(right.id)).map((entry) => entry.value);
|
|
553
262
|
}
|
|
554
263
|
function createScanObservation(input) {
|
|
555
264
|
const roots = validateRoots(input.roots);
|
|
@@ -560,9 +269,7 @@ function createScanObservation(input) {
|
|
|
560
269
|
symbols: [...new Map(file.symbols.map((symbol) => [
|
|
561
270
|
compare(symbol.id, symbol.kind),
|
|
562
271
|
symbol
|
|
563
|
-
]))
|
|
564
|
-
return compare(left.id, left.kind).localeCompare(compare(right.id, right.kind));
|
|
565
|
-
})
|
|
272
|
+
]))].sort(([left], [right]) => left.localeCompare(right)).map(([, symbol]) => symbol)
|
|
566
273
|
})), (file) => file.file, "file path");
|
|
567
274
|
const filePaths = new Set(files.map((file) => file.file));
|
|
568
275
|
for (const file of files) {
|
|
@@ -573,24 +280,46 @@ function createScanObservation(input) {
|
|
|
573
280
|
throw new Error(`file references unknown root: ${root}`);
|
|
574
281
|
}
|
|
575
282
|
}
|
|
576
|
-
for (const diagnostic of input.diagnostics)
|
|
577
|
-
diagnosticLocation(diagnostic);
|
|
578
283
|
return {
|
|
579
284
|
schemaVersion: 1,
|
|
580
285
|
scanner: input.scanner,
|
|
581
286
|
roots,
|
|
582
287
|
files,
|
|
583
288
|
...sourceUnits(input.sourceUnits, filePaths),
|
|
289
|
+
...entryPoints(input.entryPoints, filePaths),
|
|
584
290
|
...operationEvidence(input, filePaths),
|
|
585
291
|
...httpEvidence(input.httpEndpoints, input.httpRequests, input.operations && new Set(input.operations.map((operation) => operation.id))),
|
|
586
|
-
diagnostics:
|
|
587
|
-
diagnosticKey(diagnostic),
|
|
588
|
-
diagnostic
|
|
589
|
-
])).values()].sort((left, right) => {
|
|
590
|
-
return diagnosticKey(left).localeCompare(diagnosticKey(right));
|
|
591
|
-
})
|
|
292
|
+
diagnostics: normalizeScanDiagnostics(input.diagnostics)
|
|
592
293
|
};
|
|
593
294
|
}
|
|
295
|
+
function normalizeScanDiagnostics(diagnostics) {
|
|
296
|
+
for (const diagnostic of diagnostics)
|
|
297
|
+
diagnosticLocation(diagnostic);
|
|
298
|
+
return [...new Map(diagnostics.map((diagnostic) => [diagnosticKey(diagnostic), diagnostic]))].sort(([left], [right]) => left.localeCompare(right)).map(([, diagnostic]) => diagnostic);
|
|
299
|
+
}
|
|
300
|
+
function entryPoints(input, paths) {
|
|
301
|
+
if (input === undefined)
|
|
302
|
+
return {};
|
|
303
|
+
const entries = array(input, "entryPoints").map((value) => {
|
|
304
|
+
const entry = object(value, "entry point");
|
|
305
|
+
const files = [...new Set(array(entry.files, "entry point.files").map((file) => string(file, "entry point.file")))].sort();
|
|
306
|
+
for (const file of files) {
|
|
307
|
+
if (!paths.has(file))
|
|
308
|
+
throw new Error(`entry point references unknown file: ${file}`);
|
|
309
|
+
}
|
|
310
|
+
const file = string(entry.file, "entry point.file");
|
|
311
|
+
if (!files.includes(file))
|
|
312
|
+
throw new Error(`entry point must include its own source file: ${file}`);
|
|
313
|
+
return {
|
|
314
|
+
file,
|
|
315
|
+
declaration: string(entry.declaration, "entry point.declaration"),
|
|
316
|
+
name: string(entry.name, "entry point.name"),
|
|
317
|
+
files
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
const key = (entry) => compare(entry.file, entry.declaration, entry.name, ...entry.files);
|
|
321
|
+
return { entryPoints: [...new Map(entries.map((entry) => [key(entry), entry])).values()].sort((a, b) => key(a).localeCompare(key(b))) };
|
|
322
|
+
}
|
|
594
323
|
function sourceUnits(input, paths) {
|
|
595
324
|
if (input === undefined)
|
|
596
325
|
return {};
|
|
@@ -676,10 +405,14 @@ function operationTokens(operation) {
|
|
|
676
405
|
const endLine = Number(operation.endLine);
|
|
677
406
|
if (endLine < startLine)
|
|
678
407
|
throw new Error("operation.endLine must be at or after startLine");
|
|
408
|
+
const tokens = array(operation.tokens, "operation.tokens");
|
|
409
|
+
tokens.forEach((token) => {
|
|
410
|
+
string(token, "operation.token");
|
|
411
|
+
});
|
|
679
412
|
return {
|
|
680
413
|
startLine,
|
|
681
414
|
endLine,
|
|
682
|
-
tokens
|
|
415
|
+
tokens
|
|
683
416
|
};
|
|
684
417
|
}
|
|
685
418
|
function validateOperationTokens(operation) {
|
|
@@ -708,52 +441,217 @@ function sourcePosition(position) {
|
|
|
708
441
|
return { position: Number(position) };
|
|
709
442
|
}
|
|
710
443
|
|
|
711
|
-
// plugins/scanners/
|
|
712
|
-
import
|
|
444
|
+
// plugins/scanners/workspace-packages.ts
|
|
445
|
+
import path3 from "path";
|
|
713
446
|
|
|
714
|
-
// plugins/scanners/
|
|
715
|
-
|
|
716
|
-
|
|
447
|
+
// plugins/scanners/typescript-project.ts
|
|
448
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
449
|
+
import path2 from "path";
|
|
450
|
+
async function packageManifest(root, file) {
|
|
451
|
+
try {
|
|
452
|
+
return JSON.parse(await readFile2(path2.join(root, file), "utf8"));
|
|
453
|
+
} catch {
|
|
717
454
|
return;
|
|
718
|
-
const roots = [];
|
|
719
|
-
const files = new Map;
|
|
720
|
-
const operations = [];
|
|
721
|
-
const invocations = [];
|
|
722
|
-
const diagnostics = [];
|
|
723
|
-
const sourceUnits = [];
|
|
724
|
-
const httpEndpoints = [];
|
|
725
|
-
const httpRequests = [];
|
|
726
|
-
for (const { key, observation } of parts) {
|
|
727
|
-
const id = (value) => JSON.stringify([key, value]);
|
|
728
|
-
roots.push(...observation.roots.map((root) => ({ ...root, id: id(root.id), ...root.parent ? { parent: id(root.parent) } : {} })));
|
|
729
|
-
for (const source of observation.files) {
|
|
730
|
-
const combined = files.get(source.file) ?? { file: source.file, roots: [], symbols: [] };
|
|
731
|
-
combined.roots.push(...source.roots.map(id));
|
|
732
|
-
combined.symbols.push(...source.symbols.map((symbol) => ({ ...symbol, id: id(symbol.id) })));
|
|
733
|
-
files.set(source.file, combined);
|
|
734
|
-
}
|
|
735
|
-
operations.push(...(observation.operations ?? []).map((operation) => ({ ...operation, id: id(operation.id) })));
|
|
736
|
-
invocations.push(...(observation.invocations ?? []).map((call) => ({ ...call, source: id(call.source), targets: call.targets.map(id) })));
|
|
737
|
-
httpEndpoints.push(...(observation.httpEndpoints ?? []).map((endpoint) => ({ ...endpoint, operation: id(endpoint.operation) })));
|
|
738
|
-
httpRequests.push(...(observation.httpRequests ?? []).map((request) => ({ ...request, operation: id(request.operation) })));
|
|
739
|
-
diagnostics.push(...observation.diagnostics);
|
|
740
|
-
sourceUnits.push(...observation.sourceUnits ?? []);
|
|
741
455
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
456
|
+
}
|
|
457
|
+
function hasDependency(manifest, dependency) {
|
|
458
|
+
return ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"].some((section) => typeof manifest[section]?.[dependency] === "string");
|
|
459
|
+
}
|
|
460
|
+
function hasProjectConfig(directory, configs, options) {
|
|
461
|
+
if (options.nestedConfig && [...configs].some((config) => directory === "." || config.startsWith(`${directory}/`)))
|
|
462
|
+
return true;
|
|
463
|
+
for (let current = directory;; current = path2.posix.dirname(current)) {
|
|
464
|
+
if (configs.has(current))
|
|
465
|
+
return true;
|
|
466
|
+
if (!options.inheritConfig || current === ".")
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async function frameworkProjects(root, files, dependency, extensions, options = {}) {
|
|
471
|
+
const manifests = files.filter((file) => path2.posix.basename(file) === "package.json");
|
|
472
|
+
const configs = new Set(files.filter((file) => path2.posix.basename(file) === "tsconfig.json").map((file) => path2.posix.dirname(file)));
|
|
473
|
+
const candidates = new Set;
|
|
474
|
+
for (const file of manifests) {
|
|
475
|
+
const directory = path2.posix.dirname(file);
|
|
476
|
+
if (!hasProjectConfig(directory, configs, options))
|
|
477
|
+
continue;
|
|
478
|
+
const manifest = await packageManifest(root, file);
|
|
479
|
+
if (manifest && hasDependency(manifest, dependency))
|
|
480
|
+
candidates.add(directory);
|
|
481
|
+
}
|
|
482
|
+
const sources = new Set;
|
|
483
|
+
for (const file of files.filter((file) => extensions.some((extension) => file.endsWith(extension)) && !file.endsWith(".d.ts"))) {
|
|
484
|
+
let directory = path2.posix.dirname(file);
|
|
485
|
+
while (directory !== "." && !candidates.has(directory))
|
|
486
|
+
directory = path2.posix.dirname(directory);
|
|
487
|
+
if (candidates.has(directory))
|
|
488
|
+
sources.add(directory);
|
|
489
|
+
}
|
|
490
|
+
return [...candidates].filter((directory) => sources.has(directory)).map((directory) => path2.resolve(root, directory));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// plugins/scanners/workspace-packages.ts
|
|
494
|
+
function inRootDir(file, outputs) {
|
|
495
|
+
const output = outputs.find(({ outDir }) => file.startsWith(`${outDir}/`));
|
|
496
|
+
return output === undefined ? undefined : path3.posix.join(output.rootDir, file.slice(output.outDir.length + 1));
|
|
497
|
+
}
|
|
498
|
+
function sourceOf(file, outputs, files) {
|
|
499
|
+
const stem = inRootDir(file, outputs)?.replace(/\.(?:d\.[cm]?ts|[cm]?jsx?)$/, "");
|
|
500
|
+
return stem === undefined ? undefined : [".ts", ".tsx"].map((extension) => `${stem}${extension}`).find((candidate) => files.has(candidate));
|
|
501
|
+
}
|
|
502
|
+
var conditions = new Set(["types", "import", "default", "require"]);
|
|
503
|
+
|
|
504
|
+
// plugins/scanners/entry-points/javascript.ts
|
|
505
|
+
function packageFor(file, packages) {
|
|
506
|
+
let directory = path4.posix.dirname(file);
|
|
507
|
+
while (directory !== "." && !packages.has(directory))
|
|
508
|
+
directory = path4.posix.dirname(directory);
|
|
509
|
+
return directory;
|
|
510
|
+
}
|
|
511
|
+
function declaredBins(declaration, manifest) {
|
|
512
|
+
const bins = typeof manifest.bin === "string" ? { [String(manifest.name ?? "main")]: manifest.bin } : manifest.bin;
|
|
513
|
+
if (!bins || typeof bins !== "object")
|
|
514
|
+
return [];
|
|
515
|
+
return Object.entries(bins).flatMap(([name, target]) => typeof target === "string" ? [{ declaration, name, file: path4.posix.join(path4.posix.dirname(declaration), target) }] : []);
|
|
516
|
+
}
|
|
517
|
+
var RUNTIMES = new Set(["node", "bun", "tsx", "ts-node"]);
|
|
518
|
+
var VALUE_OPTIONS = new Set(["-r", "--require", "--import", "--tsconfig"]);
|
|
519
|
+
var SCRIPT = /^[\w./-]+\.[cm]?[jt]sx?$/;
|
|
520
|
+
function runtimeAt(words) {
|
|
521
|
+
let index = words[0] === "cross-env" ? 1 : 0;
|
|
522
|
+
while (/^[A-Za-z_]\w*=/.test(words[index] ?? ""))
|
|
523
|
+
index += 1;
|
|
524
|
+
return index;
|
|
525
|
+
}
|
|
526
|
+
function commandScript(command) {
|
|
527
|
+
const words = command.trim().split(/\s+/);
|
|
528
|
+
let index = runtimeAt(words);
|
|
529
|
+
if (!RUNTIMES.has(words[index] ?? ""))
|
|
530
|
+
return;
|
|
531
|
+
let subcommand = true;
|
|
532
|
+
for (index += 1;index < words.length; index += 1) {
|
|
533
|
+
const word = words[index];
|
|
534
|
+
if (VALUE_OPTIONS.has(word))
|
|
535
|
+
index += 1;
|
|
536
|
+
else if (subcommand && (word === "run" || word === "watch"))
|
|
537
|
+
subcommand = false;
|
|
538
|
+
else if (!word.startsWith("-"))
|
|
539
|
+
return SCRIPT.test(word) ? word : undefined;
|
|
540
|
+
}
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
function declaredScripts(declaration, scripts) {
|
|
544
|
+
return Object.entries(scripts ?? {}).flatMap(([name, script]) => {
|
|
545
|
+
if (typeof script !== "string")
|
|
546
|
+
return [];
|
|
547
|
+
const commands = /["'`]/.test(script) ? [script] : script.split(/\s+&&\s+/);
|
|
548
|
+
return commands.flatMap((command) => {
|
|
549
|
+
const file = commandScript(command);
|
|
550
|
+
return file === undefined ? [] : [{ declaration, name, file: path4.posix.join(path4.posix.dirname(declaration), file) }];
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
var isString = (value) => typeof value === "string";
|
|
555
|
+
function buildEntry(declaration, workspace, name, project) {
|
|
556
|
+
const build = (project.architect ?? project.targets)?.build;
|
|
557
|
+
const main = build?.options?.browser ?? build?.options?.main;
|
|
558
|
+
if (!isString(main))
|
|
559
|
+
return [];
|
|
560
|
+
const replacements = Object.values(build?.configurations ?? {}).flatMap((configuration) => Array.isArray(configuration.fileReplacements) ? configuration.fileReplacements.map((replacement) => replacement?.with) : []);
|
|
561
|
+
const at = (file) => path4.posix.join(workspace, file);
|
|
562
|
+
return [{ declaration, name, file: at(main), compiled: [build?.options?.polyfills, ...replacements].flat().filter(isString).map(at) }];
|
|
563
|
+
}
|
|
564
|
+
function angularEntries(declaration, source) {
|
|
565
|
+
const projects = JSON.parse(source).projects ?? {};
|
|
566
|
+
return Object.entries(projects).flatMap(([name, project]) => buildEntry(declaration, path4.posix.dirname(declaration), name, project));
|
|
567
|
+
}
|
|
568
|
+
function nxEntries(declaration, source, workspaces) {
|
|
569
|
+
const workspace = packageFor(declaration, workspaces);
|
|
570
|
+
if (!workspaces.has(workspace))
|
|
571
|
+
return [];
|
|
572
|
+
const project = JSON.parse(source);
|
|
573
|
+
return buildEntry(declaration, workspace, project.name ?? path4.posix.basename(path4.posix.dirname(declaration)), project);
|
|
574
|
+
}
|
|
575
|
+
function browserEntries(declaration, source, packages) {
|
|
576
|
+
const scripts = [...source.matchAll(/<script\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi)];
|
|
577
|
+
const folder = path4.posix.dirname(declaration);
|
|
578
|
+
const page = path4.posix.basename(declaration, ".html");
|
|
579
|
+
const name = page === "index" && folder !== "." ? path4.posix.basename(folder) : page;
|
|
580
|
+
return scripts.flatMap((match) => {
|
|
581
|
+
const src = match[1];
|
|
582
|
+
if (/^(?:[a-z]+:|\/\/)/i.test(src))
|
|
583
|
+
return [];
|
|
584
|
+
const base = src.startsWith("/") ? packageFor(declaration, packages) : folder;
|
|
585
|
+
return [{ file: path4.posix.join(base, src.replace(/^\//, "")), declaration, name }];
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
function reachedFiles(entry, compiled, imports, packages, units) {
|
|
589
|
+
const owner = packageFor(entry, packages), found = new Set;
|
|
590
|
+
const pending = [entry, ...compiled];
|
|
591
|
+
while (pending.length) {
|
|
592
|
+
const file = pending.pop();
|
|
593
|
+
if (found.has(file) || packageFor(file, packages) !== owner)
|
|
594
|
+
continue;
|
|
595
|
+
found.add(file);
|
|
596
|
+
pending.push(...imports.get(file) ?? []);
|
|
597
|
+
}
|
|
598
|
+
for (const unit of units)
|
|
599
|
+
if (found.has(unit.primary))
|
|
600
|
+
for (const file of unit.files)
|
|
601
|
+
found.add(file);
|
|
602
|
+
return found;
|
|
603
|
+
}
|
|
604
|
+
async function declaredEntries(root, declarations, packages, workspaces) {
|
|
605
|
+
const entries = [];
|
|
606
|
+
for (const file of declarations) {
|
|
607
|
+
const text = await readFile3(path4.join(root, file), "utf8");
|
|
608
|
+
const name = path4.posix.basename(file);
|
|
609
|
+
if (name === "package.json") {
|
|
610
|
+
const manifest = JSON.parse(text);
|
|
611
|
+
entries.push(...declaredBins(file, manifest), ...declaredScripts(file, manifest.scripts));
|
|
612
|
+
} else if (name === "angular.json")
|
|
613
|
+
entries.push(...angularEntries(file, text));
|
|
614
|
+
else if (name === "project.json")
|
|
615
|
+
entries.push(...nxEntries(file, text, workspaces));
|
|
616
|
+
else if (file.endsWith(".html"))
|
|
617
|
+
entries.push(...browserEntries(file, text, packages));
|
|
618
|
+
}
|
|
619
|
+
return entries;
|
|
620
|
+
}
|
|
621
|
+
function sourced(entry, files, outputs = []) {
|
|
622
|
+
const file = sourceOf(entry.file, outputs, files);
|
|
623
|
+
return file === undefined ? entry : { ...entry, file };
|
|
624
|
+
}
|
|
625
|
+
async function withJavaScriptEntries(root, observation, inputs, files) {
|
|
626
|
+
if (!observation)
|
|
627
|
+
return;
|
|
628
|
+
const inventory = files.filter((file) => ["package.json", "angular.json", "project.json", "nx.json"].includes(path4.posix.basename(file)) || file.endsWith(".html"));
|
|
629
|
+
const workspaces = new Set(inventory.filter((file) => path4.posix.basename(file) === "nx.json").map((file) => path4.posix.dirname(file)));
|
|
630
|
+
const packages = new Set(inventory.filter((file) => path4.posix.basename(file) === "package.json").map((file) => path4.posix.dirname(file)));
|
|
631
|
+
const active = new Set(observation.files.map((file) => packageFor(file.file, packages)));
|
|
632
|
+
const declarations = inventory.filter((file) => path4.posix.basename(file) !== "nx.json" && active.has(packageFor(file, packages)));
|
|
633
|
+
const entries = [...inputs.entries, ...await declaredEntries(root, declarations, packages, workspaces)];
|
|
634
|
+
const inventoryFiles = [...observation.files];
|
|
635
|
+
const visible = new Set(inventoryFiles.map((file) => file.file));
|
|
636
|
+
const readable = new Set(files);
|
|
637
|
+
const entryPoints = entries.map((entry) => sourced(entry, readable, inputs.buildOutputs)).flatMap(({ compiled = [], ...entry }) => {
|
|
638
|
+
if (!inputs.imports.has(entry.file))
|
|
639
|
+
return [];
|
|
640
|
+
const reached = reachedFiles(entry.file, compiled, inputs.imports, packages, observation.sourceUnits ?? []);
|
|
641
|
+
const members = observation.files.filter((file) => reached.has(file.file));
|
|
642
|
+
if (!members.length)
|
|
643
|
+
return [];
|
|
644
|
+
if (!visible.has(entry.file)) {
|
|
645
|
+
inventoryFiles.push({ file: entry.file, symbols: [], roots: [...new Set(members.flatMap((file) => file.roots))] });
|
|
646
|
+
visible.add(entry.file);
|
|
647
|
+
}
|
|
648
|
+
return [{ ...entry, files: [...new Set([entry.file, ...members.map((file) => file.file)])] }];
|
|
752
649
|
});
|
|
650
|
+
return createScanObservation({ ...observation, files: inventoryFiles, entryPoints });
|
|
753
651
|
}
|
|
754
652
|
|
|
755
|
-
// plugins/scanners/
|
|
756
|
-
import
|
|
653
|
+
// plugins/scanners/entry-points/source.ts
|
|
654
|
+
import path5 from "path";
|
|
757
655
|
|
|
758
656
|
// plugins/scanners/http-syntax.ts
|
|
759
657
|
function wrapper(ts, node) {
|
|
@@ -775,8 +673,10 @@ function ownProperty(ts, object, name) {
|
|
|
775
673
|
let found = "absent";
|
|
776
674
|
for (const property of object.properties) {
|
|
777
675
|
const key = propertyKey(ts, property);
|
|
778
|
-
if (key === undefined || ts.isGetAccessorDeclaration(property) || ts.isSetAccessorDeclaration(property))
|
|
779
|
-
|
|
676
|
+
if (key === undefined || ts.isGetAccessorDeclaration(property) || ts.isSetAccessorDeclaration(property)) {
|
|
677
|
+
found = "unknown";
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
780
680
|
if (key === name)
|
|
781
681
|
found = ts.isPropertyAssignment(property) ? { node: property.initializer } : "unknown";
|
|
782
682
|
}
|
|
@@ -1154,6 +1054,92 @@ function bindingUses(ts, checker, sources, options) {
|
|
|
1154
1054
|
};
|
|
1155
1055
|
}
|
|
1156
1056
|
|
|
1057
|
+
// plugins/scanners/http-url.ts
|
|
1058
|
+
var pathText2 = /^[A-Za-z0-9\-._~!$&'()*+,;=:@%]+$/;
|
|
1059
|
+
var absolute = /^([A-Za-z][A-Za-z\d+.-]*:)?\/\//;
|
|
1060
|
+
var HOLE = "\x00";
|
|
1061
|
+
var computedPart = { kind: "hole", configured: false };
|
|
1062
|
+
var configuredPart = { kind: "hole", configured: true };
|
|
1063
|
+
var methodToken2 = /^[A-Z][A-Z-]*$/;
|
|
1064
|
+
function methodText(text) {
|
|
1065
|
+
const method = text?.toUpperCase();
|
|
1066
|
+
return method !== undefined && methodToken2.test(method) ? method : undefined;
|
|
1067
|
+
}
|
|
1068
|
+
function requestSegment2(part) {
|
|
1069
|
+
if (part === HOLE)
|
|
1070
|
+
return { kind: "dynamic" };
|
|
1071
|
+
if (part.includes(HOLE) || !pathText2.test(part))
|
|
1072
|
+
return { kind: "unknown" };
|
|
1073
|
+
return { kind: "literal", value: part };
|
|
1074
|
+
}
|
|
1075
|
+
function segmentsOf(parts) {
|
|
1076
|
+
const url = parts.map((part) => part.kind === "text" ? part.text : HOLE).join("");
|
|
1077
|
+
const end = Math.min(...["?", "#"].map((mark) => url.includes(mark) ? url.indexOf(mark) : url.length));
|
|
1078
|
+
return url.slice(0, end).split("/").filter((part) => part !== "").map(requestSegment2);
|
|
1079
|
+
}
|
|
1080
|
+
function afterAuthority(text) {
|
|
1081
|
+
const slash = text.indexOf("/", text.indexOf("//") + 2);
|
|
1082
|
+
return slash < 0 ? "" : text.slice(slash);
|
|
1083
|
+
}
|
|
1084
|
+
function joinedText(input) {
|
|
1085
|
+
const parts = [];
|
|
1086
|
+
for (const part of input) {
|
|
1087
|
+
const last = parts.at(-1);
|
|
1088
|
+
if (part.kind === "text" && last?.kind === "text")
|
|
1089
|
+
parts[parts.length - 1] = { kind: "text", text: last.text + part.text };
|
|
1090
|
+
else if (part.kind === "hole" || part.text !== "")
|
|
1091
|
+
parts.push(part);
|
|
1092
|
+
}
|
|
1093
|
+
return parts;
|
|
1094
|
+
}
|
|
1095
|
+
function requestUrl(input) {
|
|
1096
|
+
const parts = joinedText(input);
|
|
1097
|
+
const [first, ...rest] = parts;
|
|
1098
|
+
if (first === undefined)
|
|
1099
|
+
return { path: [{ kind: "unknown" }] };
|
|
1100
|
+
if (first.kind === "hole") {
|
|
1101
|
+
const path = segmentsOf(rest);
|
|
1102
|
+
const next = rest[0];
|
|
1103
|
+
const separated = next === undefined || next.kind === "text" && next.text.startsWith("/");
|
|
1104
|
+
return first.configured && separated ? { configured: true, path } : { path: [{ kind: "unknown" }, ...path] };
|
|
1105
|
+
}
|
|
1106
|
+
if (absolute.test(first.text)) {
|
|
1107
|
+
return { path: [{ kind: "unknown" }, ...segmentsOf([{ kind: "text", text: afterAuthority(first.text) }, ...rest])] };
|
|
1108
|
+
}
|
|
1109
|
+
if (!first.text.startsWith("/"))
|
|
1110
|
+
return { path: [{ kind: "unknown" }, ...segmentsOf(parts)] };
|
|
1111
|
+
return { path: segmentsOf(parts) };
|
|
1112
|
+
}
|
|
1113
|
+
function joinBase(base, url) {
|
|
1114
|
+
const stated = joinedText(base);
|
|
1115
|
+
const path = joinedText(url);
|
|
1116
|
+
const [first] = path;
|
|
1117
|
+
if (stated.length === 0)
|
|
1118
|
+
return path;
|
|
1119
|
+
if (first === undefined)
|
|
1120
|
+
return stated;
|
|
1121
|
+
if (first.kind === "hole")
|
|
1122
|
+
return [computedPart, ...path.slice(1)];
|
|
1123
|
+
return absolute.test(first.text) ? path : [...stated, { kind: "text", text: "/" }, ...path];
|
|
1124
|
+
}
|
|
1125
|
+
function withBase(base, url) {
|
|
1126
|
+
const stated = joinedText(base);
|
|
1127
|
+
const path = joinedText(url);
|
|
1128
|
+
const [only] = stated;
|
|
1129
|
+
const [first, ...rest] = path;
|
|
1130
|
+
if (stated.length !== 1 || only?.kind !== "text" || first?.kind !== "text" || absolute.test(first.text)) {
|
|
1131
|
+
return joinBase(stated, path);
|
|
1132
|
+
}
|
|
1133
|
+
const prefix = only.text.replace(/\/$/, "");
|
|
1134
|
+
if (first.text.startsWith(prefix)) {
|
|
1135
|
+
const next = first.text[prefix.length];
|
|
1136
|
+
if (next === undefined)
|
|
1137
|
+
return rest.length === 0 ? path : [computedPart, ...rest];
|
|
1138
|
+
return next === "/" || next === "?" ? path : joinBase(stated, path);
|
|
1139
|
+
}
|
|
1140
|
+
return prefix.startsWith(first.text) && rest.length > 0 ? [computedPart, ...rest] : joinBase(stated, path);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1157
1143
|
// plugins/scanners/http-values.ts
|
|
1158
1144
|
function urlContext(ts, checker, sources, fileAlone = false) {
|
|
1159
1145
|
return { ts, checker, bindings: bindingUses(ts, checker, sources, { fileAlone }) };
|
|
@@ -1225,6 +1211,10 @@ async function variableHeld(context, name, path, depth) {
|
|
|
1225
1211
|
return "unseen";
|
|
1226
1212
|
if (path.length === 0 && ts.isFunctionDeclaration(declaration))
|
|
1227
1213
|
return { node: declaration };
|
|
1214
|
+
if (path.length === 1 && ts.isEnumDeclaration(declaration)) {
|
|
1215
|
+
const member = declaration.members.find((entry) => ts.isIdentifier(entry.name) && entry.name.text === path[0]);
|
|
1216
|
+
return member?.initializer === undefined ? "unknown" : held(context, member.initializer, [], depth + 1);
|
|
1217
|
+
}
|
|
1228
1218
|
if (!ts.isVariableDeclaration(declaration) || declaration.initializer === undefined)
|
|
1229
1219
|
return "unknown";
|
|
1230
1220
|
if (!ts.isIdentifier(declaration.name) || !await unassigned(context, declaration))
|
|
@@ -1345,126 +1335,105 @@ async function literalText(context, node) {
|
|
|
1345
1335
|
async function methodName(context, node) {
|
|
1346
1336
|
return methodText(await literalText(context, node));
|
|
1347
1337
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
if (
|
|
1352
|
-
return fallback;
|
|
1353
|
-
const declared = await heldAt(context, options, "method");
|
|
1354
|
-
if (declared === "absent")
|
|
1355
|
-
return fallback;
|
|
1356
|
-
return typeof declared === "string" ? undefined : methodName(context, declared.node);
|
|
1357
|
-
}
|
|
1358
|
-
async function optionsBase(context, config, fallback) {
|
|
1359
|
-
if (config === undefined)
|
|
1360
|
-
return fallback;
|
|
1361
|
-
const base = await heldAt(context, config, "baseURL");
|
|
1362
|
-
return base === "absent" ? fallback : heldParts(context, base);
|
|
1363
|
-
}
|
|
1364
|
-
async function runtimeFetch(context, callee) {
|
|
1365
|
-
if (!context.ts.isIdentifier(callee) || callee.text !== "fetch")
|
|
1338
|
+
async function objectEntries(context, node) {
|
|
1339
|
+
const { ts } = context;
|
|
1340
|
+
const value = node === undefined ? undefined : await heldAt(context, node);
|
|
1341
|
+
if (typeof value !== "object" || !ts.isObjectLiteralExpression(value.node))
|
|
1366
1342
|
return;
|
|
1367
|
-
const
|
|
1368
|
-
|
|
1369
|
-
|
|
1343
|
+
const entries = new Map;
|
|
1344
|
+
for (const property of value.node.properties) {
|
|
1345
|
+
const key = propertyKey(ts, property);
|
|
1346
|
+
if (key === undefined || ts.isGetAccessorDeclaration(property) || ts.isSetAccessorDeclaration(property))
|
|
1347
|
+
return;
|
|
1348
|
+
if (ts.isPropertyAssignment(property))
|
|
1349
|
+
entries.set(key, property.initializer);
|
|
1350
|
+
else
|
|
1351
|
+
entries.delete(key);
|
|
1352
|
+
}
|
|
1353
|
+
return entries;
|
|
1370
1354
|
}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1355
|
+
|
|
1356
|
+
// plugins/scanners/entry-points/source.ts
|
|
1357
|
+
var relative = (root, file) => path5.relative(root, path5.resolve(root, file)).split(path5.sep).join("/");
|
|
1358
|
+
async function text2(context, node) {
|
|
1359
|
+
if (!node)
|
|
1375
1360
|
return;
|
|
1376
|
-
const
|
|
1377
|
-
|
|
1378
|
-
const parts = await urlParts(context, url);
|
|
1379
|
-
const target = kind === "ofetch" ? withBase(await optionsBase(context, options, []), parts) : parts;
|
|
1380
|
-
return { ...method === undefined ? {} : { method }, ...requestUrl(target) };
|
|
1381
|
-
}
|
|
1382
|
-
async function withDefaults(context, client, holders) {
|
|
1383
|
-
const found = await Promise.all(holders.map((holder) => context.bindings.settings(holder, ["defaults"], ["baseURL", "method"])));
|
|
1384
|
-
const bases = found.flatMap((settings) => settings.assigned.get("baseURL") ?? []);
|
|
1385
|
-
const methods = found.flatMap((settings) => settings.assigned.get("method") ?? []);
|
|
1386
|
-
if (found.some((settings) => settings.changed) || bases.length > 1 || methods.length > 1)
|
|
1387
|
-
return { base: [computedPart], method: undefined };
|
|
1388
|
-
return {
|
|
1389
|
-
base: bases[0] === undefined ? client.base : await urlParts(context, bases[0]),
|
|
1390
|
-
method: methods[0] === undefined ? client.method : methodText(await literalText(context, methods[0]))
|
|
1391
|
-
};
|
|
1392
|
-
}
|
|
1393
|
-
function defaultClient(context) {
|
|
1394
|
-
return withDefaults(context, { base: [], method: "GET" }, context.bindings.defaultImports("axios"));
|
|
1395
|
-
}
|
|
1396
|
-
async function sent(context, client, url, method, config) {
|
|
1397
|
-
const effective = method ?? await declaredMethod(context, config, client.method);
|
|
1398
|
-
const base = await optionsBase(context, config, client.base);
|
|
1399
|
-
return { ...effective === undefined ? {} : { method: effective }, ...requestUrl(joinBase(base, url)) };
|
|
1400
|
-
}
|
|
1401
|
-
async function configRequest(context, client, config) {
|
|
1402
|
-
const url = config === undefined ? "absent" : await heldAt(context, config, "url");
|
|
1403
|
-
return url === "absent" ? undefined : sent(context, client, await heldParts(context, url), undefined, config);
|
|
1404
|
-
}
|
|
1405
|
-
async function holdsObject(context, node) {
|
|
1406
|
-
const value = await heldAt(context, node);
|
|
1407
|
-
return typeof value === "object" && context.ts.isObjectLiteralExpression(value.node);
|
|
1408
|
-
}
|
|
1409
|
-
var SHORTHAND = new Map([
|
|
1410
|
-
["get", "GET"],
|
|
1411
|
-
["post", "POST"],
|
|
1412
|
-
["put", "PUT"],
|
|
1413
|
-
["patch", "PATCH"],
|
|
1414
|
-
["delete", "DELETE"],
|
|
1415
|
-
["head", "HEAD"],
|
|
1416
|
-
["options", "OPTIONS"]
|
|
1417
|
-
]);
|
|
1418
|
-
var WITH_BODY = new Set(["post", "put", "patch"]);
|
|
1419
|
-
async function isAxios(context, node) {
|
|
1420
|
-
const origin = await importOrigin(context, node);
|
|
1421
|
-
return origin?.module === "axios" && origin.name === "default";
|
|
1361
|
+
const parts = await urlParts(context, node);
|
|
1362
|
+
return parts.every((part) => part.kind === "text") ? parts.map((part) => part.kind === "text" ? part.text : "").join("") : undefined;
|
|
1422
1363
|
}
|
|
1423
|
-
async function
|
|
1364
|
+
async function entryPath(root, context, node) {
|
|
1424
1365
|
const { ts } = context;
|
|
1425
|
-
const
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
if (
|
|
1366
|
+
const direct = await text2(context, node);
|
|
1367
|
+
if (direct !== undefined)
|
|
1368
|
+
return relative(root, direct);
|
|
1369
|
+
if (!ts.isCallExpression(node))
|
|
1429
1370
|
return;
|
|
1430
|
-
|
|
1371
|
+
const origin = await importOrigin(context, node.expression);
|
|
1372
|
+
if (origin?.name !== "fileURLToPath" || !["node:url", "url"].includes(origin.module))
|
|
1431
1373
|
return;
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
const [config] = call.arguments;
|
|
1435
|
-
const client = { base: await optionsBase(context, config, inherited.base), method: await declaredMethod(context, config, inherited.method) };
|
|
1436
|
-
const holders = [await declarationOf(context, node), holderOf(ts, call)].filter((holder) => holder !== undefined);
|
|
1437
|
-
return withDefaults(context, client, [...new Set(holders)]);
|
|
1438
|
-
}
|
|
1439
|
-
async function axiosClient(context, node) {
|
|
1440
|
-
if (!context.ts.isIdentifier(node))
|
|
1374
|
+
const url = node.arguments[0];
|
|
1375
|
+
if (!url || !ts.isNewExpression(url) || !ts.isIdentifier(url.expression) || url.expression.text !== "URL" || !await runtimeGlobal(context, url.expression))
|
|
1441
1376
|
return;
|
|
1442
|
-
|
|
1377
|
+
const base = url.arguments?.[1];
|
|
1378
|
+
if (!base || !ts.isPropertyAccessExpression(base) || base.name.text !== "url" || !ts.isMetaProperty(base.expression))
|
|
1379
|
+
return;
|
|
1380
|
+
const value = await text2(context, url.arguments?.[0]);
|
|
1381
|
+
return value === undefined ? undefined : relative(root, path5.resolve(root, path5.dirname(node.getSourceFile().fileName), value));
|
|
1443
1382
|
}
|
|
1444
|
-
async function
|
|
1383
|
+
async function buildEntries(root, context, call) {
|
|
1445
1384
|
const { ts } = context;
|
|
1446
1385
|
const callee = call.expression;
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1386
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "build" || !ts.isIdentifier(callee.expression) || callee.expression.text !== "Bun" || !await runtimeGlobal(context, callee.expression))
|
|
1387
|
+
return [];
|
|
1388
|
+
const fields = await objectEntries(context, call.arguments[0]);
|
|
1389
|
+
if (!fields)
|
|
1390
|
+
return [];
|
|
1391
|
+
const compile = fields.get("compile"), entrypoints = fields.get("entrypoints");
|
|
1392
|
+
const executable = compile && await heldAt(context, compile);
|
|
1393
|
+
const native = typeof executable === "object" && (executable.node.kind === ts.SyntaxKind.TrueKeyword || ts.isObjectLiteralExpression(executable.node));
|
|
1394
|
+
if (!native && await literalText(context, fields.get("target")) !== "browser")
|
|
1395
|
+
return [];
|
|
1396
|
+
const array = entrypoints && await heldAt(context, entrypoints);
|
|
1397
|
+
if (typeof array !== "object" || !ts.isArrayLiteralExpression(array.node))
|
|
1398
|
+
return [];
|
|
1399
|
+
const declaration = relative(root, call.getSourceFile().fileName);
|
|
1400
|
+
const files = await Promise.all(array.node.elements.map((node) => entryPath(root, context, node)));
|
|
1401
|
+
return files.flatMap((file) => file === undefined ? [] : [{ file, declaration, name: path5.posix.basename(file).replace(/\.[^.]+$/, "") }]);
|
|
1402
|
+
}
|
|
1403
|
+
async function sourceBuildEntries(root, ts, checker, sources) {
|
|
1404
|
+
const context = urlContext(ts, checker, sources);
|
|
1405
|
+
const calls = [];
|
|
1406
|
+
const visit = (node) => {
|
|
1407
|
+
if (ts.isCallExpression(node))
|
|
1408
|
+
calls.push(node);
|
|
1409
|
+
ts.forEachChild(node, visit);
|
|
1410
|
+
};
|
|
1411
|
+
for (const source of sources)
|
|
1412
|
+
visit(source);
|
|
1413
|
+
return (await Promise.all(calls.map((call) => buildEntries(root, context, call)))).flat();
|
|
1414
|
+
}
|
|
1415
|
+
async function entrySourceInputs(root, ts, checker, sources) {
|
|
1416
|
+
const selected = new Set(sources.map((source) => relative(root, source.fileName)));
|
|
1417
|
+
const imports = new Map;
|
|
1418
|
+
for (const source of sources) {
|
|
1419
|
+
const literals = [];
|
|
1420
|
+
const visit = (node) => {
|
|
1421
|
+
if (ts.isStringLiteral(node))
|
|
1422
|
+
literals.push(node);
|
|
1423
|
+
ts.forEachChild(node, visit);
|
|
1424
|
+
};
|
|
1425
|
+
visit(source);
|
|
1426
|
+
const symbols = await checker.symbolsAt(literals);
|
|
1427
|
+
const declarations = await Promise.all(symbols.flatMap((symbol) => symbol && checker.module(symbol) ? [checker.declaration(symbol)] : []));
|
|
1428
|
+
imports.set(relative(root, source.fileName), [...new Set(declarations.flatMap((declaration) => {
|
|
1429
|
+
const file = declaration && relative(root, declaration.getSourceFile().fileName);
|
|
1430
|
+
return file && selected.has(file) ? [file] : [];
|
|
1431
|
+
}))]);
|
|
1432
|
+
}
|
|
1433
|
+
return { imports, entries: await sourceBuildEntries(root, ts, checker, sources) };
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// plugins/scanners/http-checker.ts
|
|
1468
1437
|
function primitive(ts, type) {
|
|
1469
1438
|
if (type.isUnion())
|
|
1470
1439
|
return (type.types ?? []).every((member) => primitive(ts, member));
|
|
@@ -1487,74 +1456,278 @@ function classicChecker(ts, checker) {
|
|
|
1487
1456
|
};
|
|
1488
1457
|
}
|
|
1489
1458
|
|
|
1490
|
-
// plugins/scanners/react/src/
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
const
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1459
|
+
// plugins/scanners/react/src/scan.ts
|
|
1460
|
+
import ts6 from "./typescript.cjs";
|
|
1461
|
+
|
|
1462
|
+
// plugins/scanners/observations.ts
|
|
1463
|
+
function combineObservations(parts) {
|
|
1464
|
+
if (!parts.length)
|
|
1465
|
+
return;
|
|
1466
|
+
const roots = [];
|
|
1467
|
+
const files = new Map;
|
|
1468
|
+
const operations = [];
|
|
1469
|
+
const invocations = [];
|
|
1470
|
+
const diagnostics = [];
|
|
1471
|
+
const sourceUnits = [];
|
|
1472
|
+
const entryPoints = [];
|
|
1473
|
+
const httpEndpoints = [];
|
|
1474
|
+
const httpRequests = [];
|
|
1475
|
+
for (const { key, observation } of parts) {
|
|
1476
|
+
const id = (value) => JSON.stringify([key, value]);
|
|
1477
|
+
roots.push(...observation.roots.map((root) => ({ ...root, id: id(root.id), ...root.parent ? { parent: id(root.parent) } : {} })));
|
|
1478
|
+
for (const source of observation.files) {
|
|
1479
|
+
const combined = files.get(source.file) ?? { file: source.file, roots: [], symbols: [] };
|
|
1480
|
+
combined.roots.push(...source.roots.map(id));
|
|
1481
|
+
combined.symbols.push(...source.symbols.map((symbol) => ({ ...symbol, id: id(symbol.id) })));
|
|
1482
|
+
files.set(source.file, combined);
|
|
1483
|
+
}
|
|
1484
|
+
operations.push(...(observation.operations ?? []).map((operation) => ({ ...operation, id: id(operation.id) })));
|
|
1485
|
+
invocations.push(...(observation.invocations ?? []).map((call) => ({ ...call, source: id(call.source), targets: call.targets.map(id) })));
|
|
1486
|
+
httpEndpoints.push(...(observation.httpEndpoints ?? []).map((endpoint) => ({ ...endpoint, operation: id(endpoint.operation) })));
|
|
1487
|
+
httpRequests.push(...(observation.httpRequests ?? []).map((request) => ({ ...request, operation: id(request.operation) })));
|
|
1488
|
+
diagnostics.push(...observation.diagnostics);
|
|
1489
|
+
sourceUnits.push(...observation.sourceUnits ?? []);
|
|
1490
|
+
entryPoints.push(...observation.entryPoints ?? []);
|
|
1507
1491
|
}
|
|
1508
|
-
return
|
|
1492
|
+
return createScanObservation({
|
|
1493
|
+
scanner: parts[0].observation.scanner,
|
|
1494
|
+
roots,
|
|
1495
|
+
files: [...files.values()],
|
|
1496
|
+
operations,
|
|
1497
|
+
invocations,
|
|
1498
|
+
diagnostics,
|
|
1499
|
+
...parts.some((part) => part.observation.sourceUnits !== undefined) ? { sourceUnits } : {},
|
|
1500
|
+
...parts.some((part) => part.observation.entryPoints !== undefined) ? { entryPoints } : {},
|
|
1501
|
+
...parts.some((part) => part.observation.httpEndpoints !== undefined) ? { httpEndpoints } : {},
|
|
1502
|
+
...parts.some((part) => part.observation.httpRequests !== undefined) ? { httpRequests } : {}
|
|
1503
|
+
});
|
|
1509
1504
|
}
|
|
1510
1505
|
|
|
1511
|
-
// plugins/scanners/react/src/
|
|
1512
|
-
|
|
1513
|
-
|
|
1506
|
+
// plugins/scanners/react/src/evidence.ts
|
|
1507
|
+
import ts4 from "./typescript.cjs";
|
|
1508
|
+
|
|
1509
|
+
// plugins/scanners/react/src/functions.ts
|
|
1510
|
+
import ts from "./typescript.cjs";
|
|
1511
|
+
function executable(node) {
|
|
1512
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node))
|
|
1513
|
+
return true;
|
|
1514
|
+
return (ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isConstructorDeclaration(node) || ts.isGetAccessorDeclaration(node) || ts.isSetAccessorDeclaration(node)) && node.body !== undefined;
|
|
1515
|
+
}
|
|
1516
|
+
function caller(node) {
|
|
1517
|
+
for (let parent = node.parent;parent; parent = parent.parent)
|
|
1518
|
+
if (executable(parent))
|
|
1519
|
+
return parent;
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
function operationName(node) {
|
|
1523
|
+
const name = node.name;
|
|
1524
|
+
if (name !== undefined && !ts.isComputedPropertyName(name))
|
|
1525
|
+
return name.text;
|
|
1526
|
+
return ts.isVariableDeclaration(node.parent) ? node.parent.name.getText() : "callback";
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// plugins/scanners/react/src/project.ts
|
|
1530
|
+
import { readFileSync } from "fs";
|
|
1531
|
+
import path7 from "path";
|
|
1532
|
+
import ts3 from "./typescript.cjs";
|
|
1533
|
+
|
|
1534
|
+
// plugins/scanners/projects.ts
|
|
1535
|
+
function isUnder(file, directory) {
|
|
1536
|
+
return directory === "" || file.startsWith(`${directory}/`);
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// plugins/scanners/react/src/routes.ts
|
|
1540
|
+
import { existsSync } from "fs";
|
|
1541
|
+
import path6 from "path";
|
|
1542
|
+
import ts2 from "./typescript.cjs";
|
|
1543
|
+
var METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
|
|
1544
|
+
var APP_ROUTE = /^(?:(.+)\/)?route\.tsx?$/;
|
|
1545
|
+
var PAGES_API = /^(api\/.+)\.tsx?$/;
|
|
1546
|
+
var OPTIONAL_CATCH_ALL = /^\[\[\.\.\.(.+)\]\]$/;
|
|
1547
|
+
var CATCH_ALL = /^\[\.\.\.(.+)\]$/;
|
|
1548
|
+
var PARAMETER = /^\[(.+)\]$/;
|
|
1549
|
+
function nextRouters(directory) {
|
|
1550
|
+
const active = (name) => existsSync(path6.join(directory, name)) ? name : `src/${name}`;
|
|
1551
|
+
return { app: active("app"), pages: active("pages") };
|
|
1552
|
+
}
|
|
1553
|
+
function routeLocation(file, routers) {
|
|
1554
|
+
const within = (directory) => file.startsWith(`${directory}/`) ? file.slice(directory.length + 1) : undefined;
|
|
1555
|
+
const app = APP_ROUTE.exec(within(routers.app) ?? "");
|
|
1556
|
+
if (app)
|
|
1557
|
+
return app[1]?.split("/").some((part) => part.startsWith("_")) ? undefined : { router: "app", ...app[1] === undefined ? {} : { directory: app[1] } };
|
|
1558
|
+
const pages = PAGES_API.exec(within(routers.pages) ?? "");
|
|
1559
|
+
return pages ? { router: "pages", directory: pages[1] } : undefined;
|
|
1560
|
+
}
|
|
1561
|
+
var GROUP = /^\((?!\.+\))[^)]+\)$/;
|
|
1562
|
+
function routeSegment(part, last) {
|
|
1563
|
+
const optional = OPTIONAL_CATCH_ALL.exec(part);
|
|
1564
|
+
if (optional)
|
|
1565
|
+
return last && pathText2.test(optional[1]) ? { kind: "catch-all", name: optional[1], optional: true } : undefined;
|
|
1566
|
+
const rest = CATCH_ALL.exec(part);
|
|
1567
|
+
if (rest)
|
|
1568
|
+
return last && pathText2.test(rest[1]) ? { kind: "catch-all", name: rest[1] } : undefined;
|
|
1569
|
+
const parameter = PARAMETER.exec(part);
|
|
1570
|
+
if (parameter)
|
|
1571
|
+
return pathText2.test(parameter[1]) ? { kind: "parameter", name: parameter[1] } : undefined;
|
|
1572
|
+
if (part.startsWith("(") || part.startsWith("@") || part.includes("[") || part.includes("]"))
|
|
1573
|
+
return;
|
|
1574
|
+
return pathText2.test(part) ? { kind: "literal", value: part } : undefined;
|
|
1575
|
+
}
|
|
1576
|
+
function routeParts(directory, stripIndex) {
|
|
1577
|
+
const parts = (directory ?? "").split("/").filter((part) => part !== "" && !GROUP.test(part));
|
|
1578
|
+
return stripIndex && parts.at(-1) === "index" ? parts.slice(0, -1) : parts;
|
|
1579
|
+
}
|
|
1580
|
+
function routePath(directory, stripIndex) {
|
|
1581
|
+
const parts = routeParts(directory, stripIndex);
|
|
1582
|
+
const segments = [];
|
|
1583
|
+
for (const [index, part] of parts.entries()) {
|
|
1584
|
+
const segment = routeSegment(part, index === parts.length - 1);
|
|
1585
|
+
if (segment === undefined)
|
|
1586
|
+
return;
|
|
1587
|
+
segments.push(segment);
|
|
1588
|
+
}
|
|
1589
|
+
return segments;
|
|
1590
|
+
}
|
|
1591
|
+
function hasModifier2(statement, kind) {
|
|
1592
|
+
return ts2.canHaveModifiers(statement) && (ts2.getModifiers(statement) ?? []).some((modifier) => modifier.kind === kind);
|
|
1593
|
+
}
|
|
1594
|
+
function literal(node) {
|
|
1595
|
+
return ts2.isObjectLiteralExpression(node) || ts2.isArrayLiteralExpression(node) || ts2.isLiteralExpression(node) || node.kind === ts2.SyntaxKind.TrueKeyword || node.kind === ts2.SyntaxKind.FalseKeyword || node.kind === ts2.SyntaxKind.NullKeyword;
|
|
1596
|
+
}
|
|
1597
|
+
function served(source, value, depth = 0) {
|
|
1598
|
+
if (value === undefined || literal(value))
|
|
1599
|
+
return;
|
|
1600
|
+
if (executable(value))
|
|
1601
|
+
return value;
|
|
1602
|
+
return ts2.isIdentifier(value) && depth < 8 ? servedName(source, value.text, depth + 1) : source;
|
|
1603
|
+
}
|
|
1604
|
+
function servedName(source, name, depth = 0) {
|
|
1605
|
+
for (const statement of source.statements) {
|
|
1606
|
+
if (ts2.isFunctionDeclaration(statement) && statement.name?.text === name)
|
|
1607
|
+
return served(source, statement, depth);
|
|
1608
|
+
if (!ts2.isVariableStatement(statement))
|
|
1609
|
+
continue;
|
|
1610
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
1611
|
+
if (ts2.isIdentifier(declaration.name) && declaration.name.text === name)
|
|
1612
|
+
return served(source, declaration.initializer, depth);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return source;
|
|
1616
|
+
}
|
|
1617
|
+
function statementExports(source, statement) {
|
|
1618
|
+
if (ts2.isExportDeclaration(statement)) {
|
|
1619
|
+
if (statement.isTypeOnly || statement.exportClause === undefined || !ts2.isNamedExports(statement.exportClause))
|
|
1620
|
+
return [];
|
|
1621
|
+
return statement.exportClause.elements.filter((element) => !element.isTypeOnly).map((element) => [
|
|
1622
|
+
element.name.text,
|
|
1623
|
+
statement.moduleSpecifier === undefined ? servedName(source, (element.propertyName ?? element.name).text) : source
|
|
1624
|
+
]);
|
|
1625
|
+
}
|
|
1626
|
+
if (!hasModifier2(statement, ts2.SyntaxKind.ExportKeyword))
|
|
1627
|
+
return [];
|
|
1628
|
+
const name = hasModifier2(statement, ts2.SyntaxKind.DefaultKeyword) ? "default" : undefined;
|
|
1629
|
+
if (ts2.isFunctionDeclaration(statement))
|
|
1630
|
+
return [[name ?? statement.name.text, served(source, statement)]];
|
|
1631
|
+
if (!ts2.isVariableStatement(statement))
|
|
1632
|
+
return [];
|
|
1633
|
+
return statement.declarationList.declarations.flatMap((declaration) => ts2.isIdentifier(declaration.name) ? [[declaration.name.text, served(source, declaration.initializer)]] : ts2.isObjectBindingPattern(declaration.name) ? declaration.name.elements.flatMap((element) => ts2.isIdentifier(element.name) ? [[element.name.text, source]] : []) : []);
|
|
1634
|
+
}
|
|
1635
|
+
function routeExports(source) {
|
|
1636
|
+
return new Map(source.statements.flatMap((statement) => ts2.isExportAssignment(statement) ? statement.isExportEquals === true ? [] : [["default", served(source, statement.expression)]] : statementExports(source, statement)));
|
|
1637
|
+
}
|
|
1638
|
+
function answeredMethod(router, name) {
|
|
1639
|
+
if (router === "app")
|
|
1640
|
+
return METHODS.has(name) ? name : undefined;
|
|
1641
|
+
return name === "default" ? "*" : undefined;
|
|
1642
|
+
}
|
|
1643
|
+
function nextRouteEndpoints(sources, directory, routers, operationId) {
|
|
1644
|
+
const endpoints = [];
|
|
1645
|
+
for (const source of sources) {
|
|
1646
|
+
const location = routeLocation(path6.relative(directory, source.fileName).split(path6.sep).join("/"), routers);
|
|
1647
|
+
const segments = location === undefined ? undefined : routePath(location.directory, location.router === "pages");
|
|
1648
|
+
if (location === undefined || segments === undefined)
|
|
1649
|
+
continue;
|
|
1650
|
+
for (const [name, handler] of routeExports(source)) {
|
|
1651
|
+
const method = answeredMethod(location.router, name);
|
|
1652
|
+
if (method !== undefined && handler !== undefined)
|
|
1653
|
+
endpoints.push({ operation: operationId(handler), method, path: segments });
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
return endpoints;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// plugins/scanners/react/src/project.ts
|
|
1660
|
+
var absentBase = new Set([5083, 6053]);
|
|
1661
|
+
function relative2(root, file) {
|
|
1662
|
+
return path7.relative(root, file).split(path7.sep).join("/");
|
|
1514
1663
|
}
|
|
1515
1664
|
function failDiagnostics(diagnostics) {
|
|
1516
|
-
const errors = diagnostics.filter((item) => item.category ===
|
|
1665
|
+
const errors = diagnostics.filter((item) => item.category === ts3.DiagnosticCategory.Error);
|
|
1517
1666
|
if (errors.length)
|
|
1518
|
-
throw new Error(errors.map((item) => `${item.file?.fileName ?? "React"}: ${
|
|
1667
|
+
throw new Error(errors.map((item) => `${item.file?.fileName ?? "React"}: ${ts3.flattenDiagnosticMessageText(item.messageText, `
|
|
1519
1668
|
`)}`).join(`
|
|
1520
1669
|
`));
|
|
1521
1670
|
}
|
|
1522
|
-
function
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
try {
|
|
1530
|
-
const configFile = path5.join(root, "tsconfig.json");
|
|
1531
|
-
const config = ts4.readConfigFile(configFile, ts4.sys.readFile);
|
|
1532
|
-
if (config.error)
|
|
1533
|
-
failDiagnostics([config.error]);
|
|
1534
|
-
const parsed = ts4.parseJsonConfigFileContent(config.config, ts4.sys, root, undefined, configFile);
|
|
1535
|
-
failDiagnostics(parsed.errors);
|
|
1536
|
-
const program = ts4.createProgram(parsed.fileNames, { ...parsed.options, noEmit: true });
|
|
1537
|
-
const owned = program.getSourceFiles().filter((source) => {
|
|
1538
|
-
const file = relative(repositoryRoot, source.fileName);
|
|
1539
|
-
return !source.isDeclarationFile && !file.startsWith("../") && !file.includes("node_modules/");
|
|
1540
|
-
});
|
|
1541
|
-
const sources = owned.filter((source) => source.fileName.endsWith(".tsx"));
|
|
1542
|
-
if (!sources.length)
|
|
1543
|
-
throw new Error("The project tsconfig.json must include React TSX source files.");
|
|
1544
|
-
const routers = nextRouters(root);
|
|
1545
|
-
const routes = hasDependency(manifest, "next") ? owned.filter((source) => routeLocation(relative(root, source.fileName), routers) !== undefined) : [];
|
|
1546
|
-
failDiagnostics(program.getSyntacticDiagnostics());
|
|
1547
|
-
return { manifest, program, owned, sources, routes, routers };
|
|
1548
|
-
} catch (error) {
|
|
1549
|
-
throw new Error(`REACT_SOURCE_INVALID: Check the project tsconfig.json and TSX syntax. ${error}`);
|
|
1671
|
+
function packageConfig(directory, repositoryRoot, configs) {
|
|
1672
|
+
for (let current = directory;; current = path7.dirname(current)) {
|
|
1673
|
+
const file = path7.join(current, "tsconfig.json");
|
|
1674
|
+
if (configs.has(relative2(repositoryRoot, file)))
|
|
1675
|
+
return file;
|
|
1676
|
+
if (current === repositoryRoot)
|
|
1677
|
+
throw new Error(`${directory}: no TypeScript config in the package or its repository ancestors`);
|
|
1550
1678
|
}
|
|
1551
1679
|
}
|
|
1552
|
-
function
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1680
|
+
function componentConfig(configFile, repositoryRoot, typescriptSources, diagnostics) {
|
|
1681
|
+
const config = ts3.readConfigFile(configFile, ts3.sys.readFile);
|
|
1682
|
+
if (config.error)
|
|
1683
|
+
failDiagnostics([config.error]);
|
|
1684
|
+
const parsed = ts3.parseJsonConfigFileContent(config.config, ts3.sys, path7.dirname(configFile), undefined, configFile);
|
|
1685
|
+
failDiagnostics(parsed.errors.filter((item) => !absentBase.has(item.code)));
|
|
1686
|
+
diagnostics.push(...parsed.errors.filter((item) => absentBase.has(item.code)).map((item) => ({
|
|
1687
|
+
severity: "warning",
|
|
1688
|
+
code: "react-missing-config-base",
|
|
1689
|
+
file: relative2(repositoryRoot, configFile),
|
|
1690
|
+
message: `The extended TypeScript config is absent; source uses the available settings. ${ts3.flattenDiagnosticMessageText(item.messageText, " ")}`
|
|
1691
|
+
})));
|
|
1692
|
+
if (parsed.fileNames.length || !parsed.projectReferences?.length)
|
|
1693
|
+
return parsed;
|
|
1694
|
+
for (const reference of parsed.projectReferences) {
|
|
1695
|
+
const referenced = componentConfig(ts3.resolveProjectReferencePath(reference), repositoryRoot, typescriptSources, diagnostics);
|
|
1696
|
+
if (referenced?.fileNames.some((file) => file.endsWith(".tsx") && typescriptSources.has(relative2(repositoryRoot, file))))
|
|
1697
|
+
return referenced;
|
|
1698
|
+
}
|
|
1556
1699
|
return;
|
|
1557
1700
|
}
|
|
1701
|
+
function reactProject(directory, repositoryRoot, typescriptSources, configs, packages) {
|
|
1702
|
+
const manifest = JSON.parse(readFileSync(path7.join(directory, "package.json"), "utf8"));
|
|
1703
|
+
const configFile = packageConfig(directory, repositoryRoot, configs);
|
|
1704
|
+
try {
|
|
1705
|
+
const project = relative2(repositoryRoot, directory);
|
|
1706
|
+
const owner = (file) => packages.find((item) => isUnder(file, item)) ?? project;
|
|
1707
|
+
const belongs = (file) => owner(file) === project;
|
|
1708
|
+
const diagnostics = [];
|
|
1709
|
+
const config = componentConfig(configFile, repositoryRoot, typescriptSources, diagnostics);
|
|
1710
|
+
if (config === undefined)
|
|
1711
|
+
return;
|
|
1712
|
+
const routers = nextRouters(directory);
|
|
1713
|
+
const routeFiles = hasDependency(manifest, "next") ? [...typescriptSources].filter((file) => isUnder(file, project) && belongs(file) && routeLocation(project === "" ? file : file.slice(project.length + 1), routers) !== undefined) : [];
|
|
1714
|
+
const roots = [...new Set([...config.fileNames, ...routeFiles.map((file) => path7.join(repositoryRoot, file))])];
|
|
1715
|
+
const program = ts3.createProgram(roots, { ...config.options, noEmit: true });
|
|
1716
|
+
const readable = program.getSourceFiles().filter((source) => typescriptSources.has(relative2(repositoryRoot, source.fileName)));
|
|
1717
|
+
const owned = readable.filter((source) => belongs(relative2(repositoryRoot, source.fileName)));
|
|
1718
|
+
for (const source of owned)
|
|
1719
|
+
failDiagnostics(program.getSyntacticDiagnostics(source));
|
|
1720
|
+
const components = owned.filter((source) => source.fileName.endsWith(".tsx"));
|
|
1721
|
+
if (!components.length)
|
|
1722
|
+
return;
|
|
1723
|
+
const routes = owned.filter((source) => routeFiles.includes(relative2(repositoryRoot, source.fileName)));
|
|
1724
|
+
return { manifest, program, routers, readable, owned, components, routes, diagnostics, owner };
|
|
1725
|
+
} catch (error) {
|
|
1726
|
+
throw new Error(`REACT_SOURCE_INVALID: Check ${relative2(repositoryRoot, configFile)} and its TSX syntax. ${error}`);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// plugins/scanners/react/src/evidence.ts
|
|
1558
1731
|
function writesSymbol(target, symbol, checker) {
|
|
1559
1732
|
if (ts4.isIdentifier(target))
|
|
1560
1733
|
return checker.getSymbolAtLocation(target) === symbol;
|
|
@@ -1574,6 +1747,14 @@ function writesSymbol(target, symbol, checker) {
|
|
|
1574
1747
|
}
|
|
1575
1748
|
return false;
|
|
1576
1749
|
}
|
|
1750
|
+
function assignment2(node) {
|
|
1751
|
+
return ts4.isBinaryExpression(node) && node.operatorToken.kind >= ts4.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts4.SyntaxKind.LastAssignment;
|
|
1752
|
+
}
|
|
1753
|
+
var passThrough = new Set(["useCallback", "memo", "forwardRef"]);
|
|
1754
|
+
function platform(file, suffixes) {
|
|
1755
|
+
const stem = file.replace(/\.[cm]?[jt]sx?$/, "");
|
|
1756
|
+
return suffixes.find((suffix) => stem.endsWith(suffix)) ?? "";
|
|
1757
|
+
}
|
|
1577
1758
|
|
|
1578
1759
|
class Evidence {
|
|
1579
1760
|
operations = new Map;
|
|
@@ -1582,20 +1763,43 @@ class Evidence {
|
|
|
1582
1763
|
root;
|
|
1583
1764
|
checker;
|
|
1584
1765
|
files;
|
|
1585
|
-
|
|
1766
|
+
platforms;
|
|
1767
|
+
constructor(root, program, readable) {
|
|
1586
1768
|
this.root = root;
|
|
1587
|
-
this.checker =
|
|
1588
|
-
this.files = new Set(
|
|
1769
|
+
this.checker = program.getTypeChecker();
|
|
1770
|
+
this.files = new Set(readable);
|
|
1771
|
+
const suffixes = (program.getCompilerOptions().moduleSuffixes ?? []).filter((suffix) => suffix !== "");
|
|
1772
|
+
this.platforms = suffixes.length ? [...suffixes, ".web"] : [];
|
|
1589
1773
|
}
|
|
1590
1774
|
operationId(node) {
|
|
1591
|
-
const file =
|
|
1775
|
+
const file = relative2(this.root, node.getSourceFile().fileName);
|
|
1592
1776
|
const position = node.getStart();
|
|
1593
|
-
const
|
|
1594
|
-
const
|
|
1595
|
-
this.operations.set(id, { id, file, name, position });
|
|
1777
|
+
const module = ts4.isSourceFile(node);
|
|
1778
|
+
const id = `${file}#${module ? "module" : position}`;
|
|
1779
|
+
this.operations.set(id, { id, file, name: module ? "(anonymous)" : operationName(node), position });
|
|
1596
1780
|
return id;
|
|
1597
1781
|
}
|
|
1598
|
-
|
|
1782
|
+
reactPassThrough(callee) {
|
|
1783
|
+
const member = ts4.isPropertyAccessExpression(callee) ? callee.name.text : undefined;
|
|
1784
|
+
const local = ts4.isPropertyAccessExpression(callee) ? callee.expression : callee;
|
|
1785
|
+
const declaration = ts4.isIdentifier(local) ? this.checker.getSymbolAtLocation(local)?.declarations?.[0] : undefined;
|
|
1786
|
+
const statement = declaration && ts4.findAncestor(declaration, ts4.isImportDeclaration);
|
|
1787
|
+
if (!statement || !ts4.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "react")
|
|
1788
|
+
return false;
|
|
1789
|
+
const imported = ts4.isImportSpecifier(declaration) ? (declaration.propertyName ?? declaration.name).text : undefined;
|
|
1790
|
+
return member === undefined ? imported !== undefined && passThrough.has(imported) : imported === undefined && passThrough.has(member);
|
|
1791
|
+
}
|
|
1792
|
+
unwrapped(node, depth) {
|
|
1793
|
+
if (executable(node))
|
|
1794
|
+
return node;
|
|
1795
|
+
if (!ts4.isCallExpression(node) || depth > 4 || !this.reactPassThrough(node.expression))
|
|
1796
|
+
return;
|
|
1797
|
+
const [value] = node.arguments;
|
|
1798
|
+
if (value === undefined)
|
|
1799
|
+
return;
|
|
1800
|
+
return ts4.isIdentifier(value) ? this.functionValue(value, depth + 1) : this.unwrapped(value, depth + 1);
|
|
1801
|
+
}
|
|
1802
|
+
functionValue(name, depth = 0) {
|
|
1599
1803
|
let symbol = this.checker.getSymbolAtLocation(name);
|
|
1600
1804
|
if (symbol && symbol.flags & ts4.SymbolFlags.Alias)
|
|
1601
1805
|
symbol = this.checker.getAliasedSymbol(symbol);
|
|
@@ -1604,56 +1808,77 @@ class Evidence {
|
|
|
1604
1808
|
return;
|
|
1605
1809
|
if (executable(declaration))
|
|
1606
1810
|
return declaration;
|
|
1607
|
-
if (ts4.isVariableDeclaration(declaration) && declaration.parent.flags & ts4.NodeFlags.Const && declaration.initializer
|
|
1608
|
-
return declaration.initializer;
|
|
1811
|
+
if (ts4.isVariableDeclaration(declaration) && declaration.parent.flags & ts4.NodeFlags.Const && declaration.initializer)
|
|
1812
|
+
return this.unwrapped(declaration.initializer, depth);
|
|
1609
1813
|
return;
|
|
1610
1814
|
}
|
|
1611
|
-
|
|
1815
|
+
sameBuild(user, used) {
|
|
1816
|
+
const own = platform(user.fileName, this.platforms);
|
|
1817
|
+
if (own === "")
|
|
1818
|
+
return true;
|
|
1819
|
+
const theirs = platform(used.fileName, this.platforms);
|
|
1820
|
+
if (theirs !== "")
|
|
1821
|
+
return theirs === own;
|
|
1822
|
+
return !ts4.sys.fileExists(used.fileName.replace(/(\.[cm]?[jt]sx?)$/, `${own}$1`));
|
|
1823
|
+
}
|
|
1824
|
+
unsupported(node, reason) {
|
|
1612
1825
|
const source = node.getSourceFile();
|
|
1613
1826
|
this.diagnostics.push({
|
|
1614
1827
|
severity: "info",
|
|
1615
1828
|
code: "unsupported-react-binding",
|
|
1616
|
-
file:
|
|
1829
|
+
file: relative2(this.root, source.fileName),
|
|
1617
1830
|
line: source.getLineAndCharacterOfPosition(node.getStart()).line + 1,
|
|
1618
|
-
message:
|
|
1831
|
+
message: `No supported callback binding: ${reason}.`
|
|
1619
1832
|
});
|
|
1620
1833
|
}
|
|
1621
|
-
|
|
1622
|
-
const parameter = component.parameters[0];
|
|
1623
|
-
if (!parameter || !ts4.isObjectBindingPattern(parameter.name))
|
|
1624
|
-
return [];
|
|
1625
|
-
const binding = parameter.name.elements.find((element) => !element.dotDotDotToken && !element.initializer && (element.propertyName ?? element.name).getText() === attribute.name.getText());
|
|
1626
|
-
if (!binding || !ts4.isIdentifier(binding.name))
|
|
1627
|
-
return [];
|
|
1628
|
-
const symbol = this.checker.getSymbolAtLocation(binding.name);
|
|
1629
|
-
if (!symbol)
|
|
1630
|
-
return [];
|
|
1834
|
+
calls(component, symbol, callee) {
|
|
1631
1835
|
const calls = [];
|
|
1632
1836
|
let reassigned = false;
|
|
1633
|
-
const checker = this.checker;
|
|
1634
1837
|
const visit = (node) => {
|
|
1635
|
-
if (
|
|
1838
|
+
if (assignment2(node) && (writesSymbol(node.left, symbol, this.checker) || callee(node.left)))
|
|
1636
1839
|
reassigned = true;
|
|
1637
|
-
if (ts4.isForOfStatement(node) && writesSymbol(node.initializer, symbol, checker))
|
|
1840
|
+
if (ts4.isForOfStatement(node) && writesSymbol(node.initializer, symbol, this.checker))
|
|
1638
1841
|
reassigned = true;
|
|
1639
|
-
if (ts4.isCallExpression(node) &&
|
|
1842
|
+
if (ts4.isCallExpression(node) && callee(node.expression))
|
|
1640
1843
|
calls.push(node);
|
|
1641
1844
|
ts4.forEachChild(node, visit);
|
|
1642
1845
|
};
|
|
1643
1846
|
visit(component);
|
|
1644
|
-
return reassigned ?
|
|
1847
|
+
return reassigned ? undefined : calls;
|
|
1848
|
+
}
|
|
1849
|
+
propCalls(component, prop) {
|
|
1850
|
+
const parameter = component.parameters[0];
|
|
1851
|
+
const named = (name) => prop === undefined || name === prop;
|
|
1852
|
+
if (parameter !== undefined && ts4.isIdentifier(parameter.name)) {
|
|
1853
|
+
const props = this.checker.getSymbolAtLocation(parameter.name);
|
|
1854
|
+
return props === undefined ? [] : this.calls(component, props, (node) => ts4.isPropertyAccessExpression(node) && named(node.name.text) && ts4.isIdentifier(node.expression) && this.checker.getSymbolAtLocation(node.expression) === props);
|
|
1855
|
+
}
|
|
1856
|
+
if (parameter === undefined || !ts4.isObjectBindingPattern(parameter.name))
|
|
1857
|
+
return [];
|
|
1858
|
+
const found = parameter.name.elements.filter((element) => !element.dotDotDotToken && named((element.propertyName ?? element.name).getText())).map((element) => {
|
|
1859
|
+
const symbol = ts4.isIdentifier(element.name) ? this.checker.getSymbolAtLocation(element.name) : undefined;
|
|
1860
|
+
return symbol === undefined ? [] : this.calls(component, symbol, (node) => ts4.isIdentifier(node) && this.checker.getSymbolAtLocation(node) === symbol);
|
|
1861
|
+
});
|
|
1862
|
+
if (found.includes(undefined))
|
|
1863
|
+
return;
|
|
1864
|
+
return found.flatMap((item) => item ?? []);
|
|
1645
1865
|
}
|
|
1646
1866
|
binding(component, attribute) {
|
|
1647
1867
|
const initializer = attribute.initializer;
|
|
1648
1868
|
if (!initializer || !ts4.isJsxExpression(initializer) || !initializer.expression)
|
|
1649
1869
|
return;
|
|
1650
|
-
const
|
|
1651
|
-
|
|
1870
|
+
const prop = attribute.name.getText();
|
|
1871
|
+
const calls = this.propCalls(component, prop);
|
|
1872
|
+
if (calls?.length === 0)
|
|
1652
1873
|
return;
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1874
|
+
if (calls === undefined) {
|
|
1875
|
+
this.unsupported(attribute, `the component writes ${prop}`);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
const expression = initializer.expression;
|
|
1879
|
+
const target = executable(expression) ? expression : ts4.isIdentifier(expression) ? this.functionValue(expression) : undefined;
|
|
1880
|
+
if (!target) {
|
|
1881
|
+
this.unsupported(attribute, `${prop} receives a value that is not a function the sources define`);
|
|
1657
1882
|
return;
|
|
1658
1883
|
}
|
|
1659
1884
|
const source = attribute.getSourceFile();
|
|
@@ -1665,11 +1890,11 @@ class Evidence {
|
|
|
1665
1890
|
source: this.operationId(operation),
|
|
1666
1891
|
targets: [this.operationId(target)],
|
|
1667
1892
|
unresolved: false,
|
|
1668
|
-
member:
|
|
1893
|
+
member: prop,
|
|
1669
1894
|
position: call.getStart(),
|
|
1670
1895
|
line: call.getSourceFile().getLineAndCharacterOfPosition(call.getStart()).line + 1,
|
|
1671
1896
|
binding: {
|
|
1672
|
-
file:
|
|
1897
|
+
file: relative2(this.root, source.fileName),
|
|
1673
1898
|
position: attribute.getStart(),
|
|
1674
1899
|
line: source.getLineAndCharacterOfPosition(attribute.getStart()).line + 1
|
|
1675
1900
|
}
|
|
@@ -1678,10 +1903,11 @@ class Evidence {
|
|
|
1678
1903
|
}
|
|
1679
1904
|
element(node) {
|
|
1680
1905
|
const component = this.functionValue(node.tagName);
|
|
1681
|
-
if (!component)
|
|
1906
|
+
if (!component || !this.sameBuild(node.getSourceFile(), component.getSourceFile()))
|
|
1682
1907
|
return;
|
|
1683
1908
|
if (node.attributes.properties.some(ts4.isJsxSpreadAttribute)) {
|
|
1684
|
-
this.
|
|
1909
|
+
if (this.propCalls(component)?.length !== 0)
|
|
1910
|
+
this.unsupported(node, "a spread may supply a prop the component calls");
|
|
1685
1911
|
return;
|
|
1686
1912
|
}
|
|
1687
1913
|
for (const attribute of node.attributes.properties)
|
|
@@ -1694,59 +1920,230 @@ class Evidence {
|
|
|
1694
1920
|
ts4.forEachChild(node, (child) => this.inspect(child));
|
|
1695
1921
|
}
|
|
1696
1922
|
}
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1923
|
+
|
|
1924
|
+
// plugins/scanners/react/src/http.ts
|
|
1925
|
+
import ts5 from "./typescript.cjs";
|
|
1926
|
+
|
|
1927
|
+
// plugins/scanners/http-clients.ts
|
|
1928
|
+
async function declaredMethod(context, options, fallback) {
|
|
1929
|
+
if (options === undefined)
|
|
1930
|
+
return fallback;
|
|
1931
|
+
const declared = await heldAt(context, options, "method");
|
|
1932
|
+
if (declared === "absent")
|
|
1933
|
+
return fallback;
|
|
1934
|
+
return typeof declared === "string" ? undefined : methodName(context, declared.node);
|
|
1701
1935
|
}
|
|
1702
|
-
async function
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1936
|
+
async function optionsBase(context, config, fallback) {
|
|
1937
|
+
if (config === undefined)
|
|
1938
|
+
return fallback;
|
|
1939
|
+
const base = await heldAt(context, config, "baseURL");
|
|
1940
|
+
return base === "absent" ? fallback : heldParts(context, base);
|
|
1941
|
+
}
|
|
1942
|
+
async function runtimeFetch(context, callee) {
|
|
1943
|
+
if (!context.ts.isIdentifier(callee))
|
|
1944
|
+
return;
|
|
1945
|
+
const origin = await importOrigin(context, callee);
|
|
1946
|
+
const fetch = origin === undefined ? callee.text === "fetch" && await runtimeGlobal(context, callee) : origin.module === "node-fetch" && origin.name === "default" || origin.module === "undici" && origin.name === "fetch";
|
|
1947
|
+
return fetch ? "fetch" : undefined;
|
|
1948
|
+
}
|
|
1949
|
+
async function urlWithoutMethod(context, url, input) {
|
|
1950
|
+
if (input === "unseen")
|
|
1951
|
+
return true;
|
|
1952
|
+
if (typeof input === "object") {
|
|
1953
|
+
const value = unwrapped(context.ts, input.node);
|
|
1954
|
+
if (urlText(context.ts, value))
|
|
1955
|
+
return true;
|
|
1956
|
+
const urlObject = context.ts.isNewExpression(value) && context.ts.isIdentifier(value.expression) && value.expression.text === "URL" && await runtimeGlobal(context, value.expression);
|
|
1957
|
+
if (urlObject)
|
|
1958
|
+
return true;
|
|
1708
1959
|
}
|
|
1709
|
-
return
|
|
1960
|
+
return context.checker.primitiveAt(url);
|
|
1710
1961
|
}
|
|
1711
|
-
async function
|
|
1712
|
-
const
|
|
1713
|
-
|
|
1962
|
+
async function fetchRequest(context, call, client) {
|
|
1963
|
+
const [url, options] = call.arguments;
|
|
1964
|
+
const kind = url === undefined ? undefined : await client(call.expression);
|
|
1965
|
+
if (url === undefined || kind === undefined)
|
|
1714
1966
|
return;
|
|
1715
|
-
const
|
|
1716
|
-
const
|
|
1967
|
+
const input = await heldAt(context, url);
|
|
1968
|
+
const method = await urlWithoutMethod(context, url, input) ? await declaredMethod(context, options, "GET") : undefined;
|
|
1969
|
+
const parts = await urlParts(context, url);
|
|
1970
|
+
const target = kind === "ofetch" ? withBase(await optionsBase(context, options, []), parts) : parts;
|
|
1971
|
+
return { ...method === undefined ? {} : { method }, ...requestUrl(target) };
|
|
1972
|
+
}
|
|
1973
|
+
async function withDefaults(context, client, holders) {
|
|
1974
|
+
const found = await Promise.all(holders.map((holder) => context.bindings.settings(holder, ["defaults"], ["baseURL", "method"])));
|
|
1975
|
+
const bases = found.flatMap((settings) => settings.assigned.get("baseURL") ?? []);
|
|
1976
|
+
const methods = found.flatMap((settings) => settings.assigned.get("method") ?? []);
|
|
1977
|
+
if (found.some((settings) => settings.changed) || bases.length > 1 || methods.length > 1)
|
|
1978
|
+
return { base: [computedPart], method: undefined };
|
|
1979
|
+
return {
|
|
1980
|
+
base: bases[0] === undefined ? client.base : await urlParts(context, bases[0]),
|
|
1981
|
+
method: methods[0] === undefined ? client.method : methodText(await literalText(context, methods[0]))
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
function defaultClient(context) {
|
|
1985
|
+
return withDefaults(context, { base: [], method: "GET" }, context.bindings.defaultImports("axios"));
|
|
1986
|
+
}
|
|
1987
|
+
async function sent(context, client, url, method, config) {
|
|
1988
|
+
const effective = method ?? await declaredMethod(context, config, client.method);
|
|
1989
|
+
const base = await optionsBase(context, config, client.base);
|
|
1990
|
+
return { ...effective === undefined ? {} : { method: effective }, ...requestUrl(joinBase(base, url)) };
|
|
1991
|
+
}
|
|
1992
|
+
async function configRequest(context, client, config) {
|
|
1993
|
+
const url = config === undefined ? "absent" : await heldAt(context, config, "url");
|
|
1994
|
+
return url === "absent" ? undefined : sent(context, client, await heldParts(context, url), undefined, config);
|
|
1995
|
+
}
|
|
1996
|
+
async function holdsObject(context, node) {
|
|
1997
|
+
const value = await heldAt(context, node);
|
|
1998
|
+
return typeof value === "object" && context.ts.isObjectLiteralExpression(value.node);
|
|
1999
|
+
}
|
|
2000
|
+
var SHORTHAND = new Map([
|
|
2001
|
+
["get", "GET"],
|
|
2002
|
+
["post", "POST"],
|
|
2003
|
+
["put", "PUT"],
|
|
2004
|
+
["patch", "PATCH"],
|
|
2005
|
+
["delete", "DELETE"],
|
|
2006
|
+
["head", "HEAD"],
|
|
2007
|
+
["options", "OPTIONS"],
|
|
2008
|
+
["postForm", "POST"],
|
|
2009
|
+
["putForm", "PUT"],
|
|
2010
|
+
["patchForm", "PATCH"]
|
|
2011
|
+
]);
|
|
2012
|
+
var WITH_BODY = new Set(["post", "put", "patch", "postForm", "putForm", "patchForm"]);
|
|
2013
|
+
async function isAxios(context, node) {
|
|
2014
|
+
const origin = await importOrigin(context, node);
|
|
2015
|
+
return origin?.module === "axios" && origin.name === "default";
|
|
2016
|
+
}
|
|
2017
|
+
async function createdClient(context, node) {
|
|
2018
|
+
const { ts } = context;
|
|
2019
|
+
const created = await heldAt(context, node);
|
|
2020
|
+
const call = typeof created === "object" && ts.isCallExpression(created.node) ? created.node : undefined;
|
|
2021
|
+
const callee = call?.expression;
|
|
2022
|
+
if (call === undefined || callee === undefined || !ts.isPropertyAccessExpression(callee) || !ts.isIdentifier(callee.name))
|
|
2023
|
+
return;
|
|
2024
|
+
if (callee.name.text !== "create" || !await isAxios(context, callee.expression))
|
|
2025
|
+
return;
|
|
2026
|
+
const parent = await defaultClient(context);
|
|
2027
|
+
const inherited = { base: parent.base.length === 0 ? [] : [computedPart], method: parent.method === "GET" ? "GET" : undefined };
|
|
2028
|
+
const [config] = call.arguments;
|
|
2029
|
+
const client = { base: await optionsBase(context, config, inherited.base), method: await declaredMethod(context, config, inherited.method) };
|
|
2030
|
+
const holders = [await declarationOf(context, node), holderOf(ts, call)].filter((holder) => holder !== undefined);
|
|
2031
|
+
return withDefaults(context, client, [...new Set(holders)]);
|
|
2032
|
+
}
|
|
2033
|
+
async function axiosClient(context, node) {
|
|
2034
|
+
if (!context.ts.isIdentifier(node))
|
|
2035
|
+
return;
|
|
2036
|
+
return await isAxios(context, node) ? defaultClient(context) : createdClient(context, node);
|
|
2037
|
+
}
|
|
2038
|
+
async function memberRequest(context, receiver, member, call) {
|
|
2039
|
+
const method = SHORTHAND.get(member);
|
|
2040
|
+
if (method === undefined && member !== "request")
|
|
2041
|
+
return;
|
|
2042
|
+
const client = await axiosClient(context, receiver);
|
|
2043
|
+
if (client === undefined)
|
|
2044
|
+
return;
|
|
2045
|
+
const [first, second, third] = call.arguments;
|
|
2046
|
+
if (method !== undefined && first !== undefined) {
|
|
2047
|
+
return sent(context, client, await urlParts(context, first), method, WITH_BODY.has(member) ? third : second);
|
|
2048
|
+
}
|
|
2049
|
+
return member === "request" ? configRequest(context, client, first) : undefined;
|
|
2050
|
+
}
|
|
2051
|
+
async function axiosRequest(context, call) {
|
|
2052
|
+
const { ts } = context;
|
|
2053
|
+
const callee = call.expression;
|
|
2054
|
+
const [first, second] = call.arguments;
|
|
2055
|
+
if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.name)) {
|
|
2056
|
+
return memberRequest(context, callee.expression, callee.name.text, call);
|
|
2057
|
+
}
|
|
2058
|
+
const client = await axiosClient(context, callee);
|
|
2059
|
+
if (client === undefined || first === undefined)
|
|
2060
|
+
return;
|
|
2061
|
+
if (await holdsObject(context, first))
|
|
2062
|
+
return configRequest(context, client, first);
|
|
2063
|
+
return sent(context, client, await urlParts(context, first), undefined, second);
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// plugins/scanners/react/src/http.ts
|
|
2067
|
+
async function reactHttpRequests(sources, analyzed, checker, callerOperation) {
|
|
2068
|
+
const context = urlContext(ts5, classicChecker(ts5, checker), analyzed);
|
|
2069
|
+
const calls = [];
|
|
2070
|
+
const visit = (node) => {
|
|
2071
|
+
if (ts5.isCallExpression(node))
|
|
2072
|
+
calls.push(node);
|
|
2073
|
+
ts5.forEachChild(node, visit);
|
|
2074
|
+
};
|
|
1717
2075
|
for (const source of sources)
|
|
1718
|
-
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
2076
|
+
visit(source);
|
|
2077
|
+
const requests = [];
|
|
2078
|
+
for (const call of calls) {
|
|
2079
|
+
const request = await fetchRequest(context, call, (callee) => runtimeFetch(context, callee)) ?? await axiosRequest(context, call);
|
|
2080
|
+
if (request !== undefined)
|
|
2081
|
+
requests.push({ operation: callerOperation(call), ...request });
|
|
2082
|
+
}
|
|
2083
|
+
return requests;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// plugins/scanners/react/src/scan.ts
|
|
2087
|
+
function reactProjects(root, files) {
|
|
2088
|
+
return frameworkProjects(root, files, "react", [".tsx"], { inheritConfig: true });
|
|
2089
|
+
}
|
|
2090
|
+
async function eachProject(root, files, visit) {
|
|
2091
|
+
const typescriptSources = new Set(typeScriptSources(files));
|
|
2092
|
+
const configs = new Set(files.filter((file) => path8.posix.basename(file) === "tsconfig.json"));
|
|
2093
|
+
const directories = await reactProjects(root, files);
|
|
2094
|
+
const packages = directories.map((directory) => relative2(root, directory)).sort((left, right) => right.length - left.length);
|
|
2095
|
+
for (const directory of directories) {
|
|
2096
|
+
const project = reactProject(directory, root, typescriptSources, configs, packages);
|
|
2097
|
+
if (project !== undefined)
|
|
2098
|
+
await visit(directory, project);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
async function checkReactReadiness(root, _settings, files) {
|
|
2102
|
+
await eachProject(root, files, async () => {});
|
|
2103
|
+
}
|
|
2104
|
+
async function scanReact(root, _settings, files) {
|
|
2105
|
+
const parts = [];
|
|
2106
|
+
await eachProject(root, files, async (directory, project) => {
|
|
2107
|
+
const observation = await scanProject(root, directory, project, files);
|
|
2108
|
+
if (observation)
|
|
2109
|
+
parts.push({ key: relative2(root, directory), observation });
|
|
1722
2110
|
});
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
2111
|
+
return combineObservations(parts);
|
|
2112
|
+
}
|
|
2113
|
+
async function scanProject(root, directory, { manifest, program, routers, readable, owned, components, routes, diagnostics, owner }, files) {
|
|
2114
|
+
const evidence = new Evidence(root, program, readable);
|
|
2115
|
+
for (const source of components)
|
|
2116
|
+
evidence.inspect(source);
|
|
2117
|
+
const httpRequests = await reactHttpRequests(components, readable, program.getTypeChecker(), (call) => evidence.operationId(caller(call) ?? call.getSourceFile()));
|
|
2118
|
+
const httpEndpoints = nextRouteEndpoints(routes, directory, routers, (handler) => evidence.operationId(handler));
|
|
2119
|
+
const reported = [...new Set([
|
|
2120
|
+
...[...components, ...routes].map((source) => relative2(root, source.fileName)),
|
|
2121
|
+
...[...evidence.operations.values()].map((operation) => operation.file)
|
|
2122
|
+
])];
|
|
2123
|
+
const own = relative2(root, directory);
|
|
2124
|
+
const packageRoot = (file) => owner(file) === own ? "react-project" : `package:${owner(file)}`;
|
|
2125
|
+
const others = [...new Set(reported.map(owner))].filter((item) => item !== own);
|
|
2126
|
+
return withJavaScriptEntries(root, createScanObservation({
|
|
2127
|
+
scanner: { id: "react", technology: "typescript/react", engine: "typescript-sdk", engineVersion: ts6.version },
|
|
2128
|
+
roots: [
|
|
2129
|
+
{ id: "react-project", kind: "package", name: manifest.name, file: relative2(root, path8.join(directory, "package.json")) },
|
|
2130
|
+
...others.map((item) => ({ id: `package:${item}`, kind: "package", name: item || ".", file: path8.posix.join(item, "package.json") }))
|
|
2131
|
+
],
|
|
2132
|
+
files: reported.map((file) => ({ file, symbols: [], roots: [packageRoot(file)] })),
|
|
1729
2133
|
operations: [...evidence.operations.values()],
|
|
1730
2134
|
invocations: evidence.invocations,
|
|
1731
|
-
diagnostics: evidence.diagnostics,
|
|
2135
|
+
diagnostics: [...diagnostics, ...evidence.diagnostics],
|
|
1732
2136
|
...httpEndpoints.length ? { httpEndpoints } : {},
|
|
1733
2137
|
...httpRequests.length ? { httpRequests } : {}
|
|
1734
|
-
});
|
|
2138
|
+
}), await entrySourceInputs(root, ts6, classicChecker(ts6, program.getTypeChecker()), owned), files);
|
|
1735
2139
|
}
|
|
1736
2140
|
|
|
1737
2141
|
// plugins/scanners/react/src/index.ts
|
|
1738
2142
|
var src_default = {
|
|
1739
2143
|
id: "react",
|
|
1740
|
-
watch: { include: ["**/*.tsx", "**/*.ts", "**/tsconfig*.json", "**/package.json"], exclude: [] },
|
|
1741
2144
|
checkReadiness: checkReactReadiness,
|
|
1742
|
-
listSourceFiles: (root) =>
|
|
1743
|
-
|
|
1744
|
-
dependency: "react",
|
|
1745
|
-
projects: [".tsx"],
|
|
1746
|
-
sources: [".tsx"],
|
|
1747
|
-
also: routeCandidate
|
|
1748
|
-
}),
|
|
1749
|
-
readCodeStructure: (root, references) => readTypeScriptOutline(ts5, root, references),
|
|
2145
|
+
listSourceFiles: async (root, _settings, candidates) => (await reactProjects(root, candidates)).length ? typeScriptSources(candidates) : [],
|
|
2146
|
+
readCodeStructure: (root, references) => readTypeScriptOutline(ts7, root, references),
|
|
1750
2147
|
scan: scanReact
|
|
1751
2148
|
};
|
|
1752
2149
|
export {
|