@jskit-ai/architecture-inspector 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -10
- package/package.json +4 -2
- package/src/calls/cache.js +96 -0
- package/src/calls/evaluate.js +469 -0
- package/src/calls/features.js +273 -0
- package/src/calls/functions.js +204 -0
- package/src/calls/index.js +300 -0
- package/src/calls/program.js +143 -0
- package/src/calls/resolve.js +719 -0
- package/src/calls/symbols.js +178 -0
- package/src/calls/transports.js +194 -0
- package/src/cli.js +93 -14
- package/src/inspectVueArchitecture.js +457 -43
- package/src/previewLocator.js +30 -0
|
@@ -4,6 +4,9 @@ import path from "node:path";
|
|
|
4
4
|
import { parse as parseJavaScript } from "@babel/parser";
|
|
5
5
|
import { parse as parseSfc } from "@vue/compiler-sfc";
|
|
6
6
|
import { createRoutesContext, resolveOptions } from "vue-router/unplugin";
|
|
7
|
+
import { resolveScopedApiBasePath } from "@jskit-ai/kernel/shared/surface";
|
|
8
|
+
import { cacheKeys, cacheLocation, readCallCache, writeCallCache } from "./calls/cache.js";
|
|
9
|
+
import { inspectCalls } from "./calls/index.js";
|
|
7
10
|
|
|
8
11
|
const INPUT_SCHEMA = "vibe64.architecture-inspection-input.v1";
|
|
9
12
|
const OUTPUT_SCHEMA = "genesis.architecture-observations.v1";
|
|
@@ -21,12 +24,14 @@ const CRUD_RESOURCE_OPERATIONS = Object.freeze(["list", "view", "create", "patch
|
|
|
21
24
|
const CRUD_CONTROLLER_OPERATIONS = Object.freeze(["list", "view", "create", "update", "delete"]);
|
|
22
25
|
const CRUD_SCREEN_TRANSPORT_KEYS = Object.freeze(["screen", "client", "surfaceId", "transport"]);
|
|
23
26
|
const CRUD_SCREEN_CLIENT_KEYS = Object.freeze(["client", "surfaceId", "transport", "resource"]);
|
|
27
|
+
const MAX_UNROLLED_ELEMENTS = 64;
|
|
24
28
|
|
|
25
29
|
// Framework sources whose declarations this inspector reads statically.
|
|
26
30
|
const SOURCES = Object.freeze({
|
|
27
31
|
crudResource: "@jskit-ai/resource-crud-core/shared/crudResource",
|
|
28
32
|
crudJsonApiFeature: "@jskit-ai/crud-core/server/defineCrudJsonApiFeature",
|
|
29
33
|
kernelFeatures: "@jskit-ai/kernel/server/features",
|
|
34
|
+
kernelHttp: "@jskit-ai/kernel/server/http",
|
|
30
35
|
crudListScreen: "@jskit-ai/http-web/client/components/CrudListScreen",
|
|
31
36
|
crudAddEditScreen: "@jskit-ai/http-web/client/components/CrudAddEditScreen",
|
|
32
37
|
crudComposables: "@jskit-ai/http-web/client/composables",
|
|
@@ -77,12 +82,28 @@ function directoryPrefixes(filePath) {
|
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
// Every AST node with its ancestor chain, depth first.
|
|
85
|
+
// Node records link to their parent record. `ancestors` (outermost first) is
|
|
86
|
+
// built on demand, so a module's records stay linear in its size.
|
|
87
|
+
const NODE_RECORD = Object.freeze({
|
|
88
|
+
get ancestors() {
|
|
89
|
+
const chain = [];
|
|
90
|
+
for (let current = this.parent; current; current = current.parent) {
|
|
91
|
+
chain.push(current.node);
|
|
92
|
+
}
|
|
93
|
+
return chain.reverse();
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function nodeRecord(node, parent) {
|
|
98
|
+
return Object.assign(Object.create(NODE_RECORD), { node, parent });
|
|
99
|
+
}
|
|
100
|
+
|
|
80
101
|
function collectNodes(root) {
|
|
81
102
|
const result = [];
|
|
82
|
-
const pending = [
|
|
103
|
+
const pending = [nodeRecord(root, null)];
|
|
83
104
|
while (pending.length) {
|
|
84
105
|
const current = pending.pop();
|
|
85
|
-
const { node
|
|
106
|
+
const { node } = current;
|
|
86
107
|
if (!node || typeof node.type !== "string") {
|
|
87
108
|
continue;
|
|
88
109
|
}
|
|
@@ -91,7 +112,7 @@ function collectNodes(root) {
|
|
|
91
112
|
const children = Array.isArray(value) ? value : value?.type ? [value] : [];
|
|
92
113
|
for (const child of children) {
|
|
93
114
|
if (child?.type) {
|
|
94
|
-
pending.push(
|
|
115
|
+
pending.push(nodeRecord(child, current));
|
|
95
116
|
}
|
|
96
117
|
}
|
|
97
118
|
}
|
|
@@ -101,10 +122,13 @@ function collectNodes(root) {
|
|
|
101
122
|
|
|
102
123
|
// The literal value of an expression, following local bindings; undefined when
|
|
103
124
|
// any part is dynamic.
|
|
104
|
-
function staticValue(node, bindings, seen = new Set()) {
|
|
125
|
+
function staticValue(node, bindings, seen = new Set(), environment = null) {
|
|
105
126
|
if (!node) {
|
|
106
127
|
return undefined;
|
|
107
128
|
}
|
|
129
|
+
if (node.type === "Identifier" && environment?.has(node.name)) {
|
|
130
|
+
return environment.get(node.name);
|
|
131
|
+
}
|
|
108
132
|
if (["StringLiteral", "BooleanLiteral", "NumericLiteral"].includes(node.type)) {
|
|
109
133
|
return node.value;
|
|
110
134
|
}
|
|
@@ -115,34 +139,34 @@ function staticValue(node, bindings, seen = new Set()) {
|
|
|
115
139
|
return staticValue(bindings.get(node.name), bindings, new Set([...seen, node.name]));
|
|
116
140
|
}
|
|
117
141
|
if (node.type === "ArrayExpression") {
|
|
118
|
-
const values = node.elements.map((entry) => staticValue(entry, bindings, seen));
|
|
142
|
+
const values = node.elements.map((entry) => staticValue(entry, bindings, seen, environment));
|
|
119
143
|
return values.every((value) => value !== undefined) ? values : undefined;
|
|
120
144
|
}
|
|
121
145
|
if (node.type === "ObjectExpression") {
|
|
122
|
-
return staticObjectValue(node, bindings, seen);
|
|
146
|
+
return staticObjectValue(node, bindings, seen, environment);
|
|
123
147
|
}
|
|
124
148
|
if (node.type === "TemplateLiteral") {
|
|
125
|
-
const values = node.expressions.map((entry) => staticValue(entry, bindings, seen));
|
|
149
|
+
const values = node.expressions.map((entry) => staticValue(entry, bindings, seen, environment));
|
|
126
150
|
if (values.some((value) => typeof value !== "string")) {
|
|
127
151
|
return undefined;
|
|
128
152
|
}
|
|
129
153
|
return node.quasis.map((entry, index) => entry.value.cooked + (values[index] || "")).join("");
|
|
130
154
|
}
|
|
131
155
|
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
132
|
-
const left = staticValue(node.left, bindings, seen);
|
|
133
|
-
const right = staticValue(node.right, bindings, seen);
|
|
156
|
+
const left = staticValue(node.left, bindings, seen, environment);
|
|
157
|
+
const right = staticValue(node.right, bindings, seen, environment);
|
|
134
158
|
return typeof left === "string" && typeof right === "string" ? left + right : undefined;
|
|
135
159
|
}
|
|
136
160
|
return undefined;
|
|
137
161
|
}
|
|
138
162
|
|
|
139
|
-
function staticObjectValue(node, bindings, seen) {
|
|
163
|
+
function staticObjectValue(node, bindings, seen, environment) {
|
|
140
164
|
const result = {};
|
|
141
165
|
for (const entry of node.properties) {
|
|
142
166
|
if (!isStaticObjectProperty(entry)) {
|
|
143
167
|
return undefined;
|
|
144
168
|
}
|
|
145
|
-
const value = staticValue(entry.value, bindings, seen);
|
|
169
|
+
const value = staticValue(entry.value, bindings, seen, environment);
|
|
146
170
|
if (value === undefined) {
|
|
147
171
|
return undefined;
|
|
148
172
|
}
|
|
@@ -215,7 +239,7 @@ function pageTitle(file) {
|
|
|
215
239
|
return name === "index" ? path.posix.basename(path.posix.dirname(file)) : name;
|
|
216
240
|
}
|
|
217
241
|
|
|
218
|
-
function validateInput({ projectRoot, input, aliases }) {
|
|
242
|
+
function validateInput({ projectRoot, input, aliases, apiPrefixAliases }) {
|
|
219
243
|
if (!path.isAbsolute(projectRoot || "") || input?.schema !== INPUT_SCHEMA || !Array.isArray(input.files)) {
|
|
220
244
|
throw new TypeError("An absolute project root and architecture inspection input are required.");
|
|
221
245
|
}
|
|
@@ -231,6 +255,11 @@ function validateInput({ projectRoot, input, aliases }) {
|
|
|
231
255
|
throw new TypeError("Aliases must resolve within the project.");
|
|
232
256
|
}
|
|
233
257
|
}
|
|
258
|
+
for (const { client, route } of apiPrefixAliases) {
|
|
259
|
+
if (!/^\/[^\s?#]*[^/\s?#]$/u.test(client) || !/^\/[^\s?#]*[^/\s?#]$/u.test(route)) {
|
|
260
|
+
throw new TypeError("API prefix aliases map one absolute path prefix to another, without a trailing slash.");
|
|
261
|
+
}
|
|
262
|
+
}
|
|
234
263
|
const packages = input.packages || [];
|
|
235
264
|
const validPackage = (entry) =>
|
|
236
265
|
typeof entry.id === "string" &&
|
|
@@ -243,8 +272,7 @@ function validateInput({ projectRoot, input, aliases }) {
|
|
|
243
272
|
return { files, packages };
|
|
244
273
|
}
|
|
245
274
|
|
|
246
|
-
function createOutput({ input,
|
|
247
|
-
const httpCapability = httpScope ? "partial" : "unconfigured";
|
|
275
|
+
function createOutput({ input, calls }) {
|
|
248
276
|
const output = {
|
|
249
277
|
schema: OUTPUT_SCHEMA,
|
|
250
278
|
adapter: ADAPTER_ID,
|
|
@@ -253,8 +281,8 @@ function createOutput({ input, httpScope }) {
|
|
|
253
281
|
pages: "unconfigured",
|
|
254
282
|
components: "partial",
|
|
255
283
|
imports: "partial",
|
|
256
|
-
entryOperations:
|
|
257
|
-
operationUses:
|
|
284
|
+
entryOperations: "unconfigured",
|
|
285
|
+
operationUses: "unconfigured"
|
|
258
286
|
},
|
|
259
287
|
pages: [],
|
|
260
288
|
registrations: [],
|
|
@@ -265,6 +293,9 @@ function createOutput({ input, httpScope }) {
|
|
|
265
293
|
imports: [],
|
|
266
294
|
diagnostics: []
|
|
267
295
|
};
|
|
296
|
+
if (calls) {
|
|
297
|
+
Object.assign(output, { functions: [], calls: [], entryPoints: [] });
|
|
298
|
+
}
|
|
268
299
|
if (input.packageIdentity) {
|
|
269
300
|
output.packageIdentity = input.packageIdentity;
|
|
270
301
|
}
|
|
@@ -275,46 +306,82 @@ function warn(inspection, code, message, source) {
|
|
|
275
306
|
inspection.output.diagnostics.push({ code, message, ...(source ? { path: source } : {}) });
|
|
276
307
|
}
|
|
277
308
|
|
|
278
|
-
// Reads one indexed, hash-matching source file once and
|
|
279
|
-
//
|
|
280
|
-
async function
|
|
281
|
-
const { files,
|
|
282
|
-
if (
|
|
283
|
-
return
|
|
284
|
-
}
|
|
285
|
-
if (!files.has(filePath)) {
|
|
286
|
-
return null;
|
|
309
|
+
// Reads one indexed, hash-matching source file once and records it as an
|
|
310
|
+
// input. Symbolic links, changed sources and oversized files are refused.
|
|
311
|
+
async function readDeclaredSource(inspection, filePath) {
|
|
312
|
+
const { files, output, projectRoot, sources } = inspection;
|
|
313
|
+
if (sources.has(filePath)) {
|
|
314
|
+
return sources.get(filePath);
|
|
287
315
|
}
|
|
288
316
|
for (const directory of directoryPrefixes(filePath)) {
|
|
317
|
+
if (inspection.checkedDirectories.has(directory)) {
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
289
320
|
if ((await lstat(path.join(projectRoot, directory))).isSymbolicLink()) {
|
|
290
321
|
throw new Error("Architecture inspection cannot follow symbolic links.");
|
|
291
322
|
}
|
|
323
|
+
inspection.checkedDirectories.add(directory);
|
|
292
324
|
}
|
|
293
325
|
const bytes = await readFile(path.join(projectRoot, filePath));
|
|
294
326
|
if (bytes.length > MAX_SOURCE_BYTES || hashBytes(bytes) !== files.get(filePath).hash) {
|
|
295
327
|
throw new Error(`Source changed or exceeded inspection bounds: ${filePath}`);
|
|
296
328
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
329
|
+
output.inputs.push({ path: filePath, hash: files.get(filePath).hash });
|
|
330
|
+
sources.set(filePath, bytes);
|
|
331
|
+
return bytes;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// A JSKIT application serves its own HTTP operations, so without an explicit
|
|
335
|
+
// scope the root manifest's package name identifies them. A manifest that is
|
|
336
|
+
// absent, unnamed or not JSON leaves HTTP observations unconfigured.
|
|
337
|
+
async function projectHttpScope(inspection) {
|
|
338
|
+
if (!inspection.files.has("package.json")) {
|
|
339
|
+
return "";
|
|
301
340
|
}
|
|
341
|
+
const bytes = await readDeclaredSource(inspection, "package.json");
|
|
342
|
+
try {
|
|
343
|
+
const { name } = JSON.parse(bytes.toString("utf8"));
|
|
344
|
+
return typeof name === "string" ? name.trim() : "";
|
|
345
|
+
} catch {
|
|
346
|
+
return "";
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function utf8Source(bytes, filePath) {
|
|
302
351
|
const source = bytes.toString("utf8");
|
|
303
352
|
if (!Buffer.from(source).equals(bytes)) {
|
|
304
353
|
throw new Error(`Source is not UTF-8: ${filePath}`);
|
|
305
354
|
}
|
|
355
|
+
return source;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Reads one indexed source file once and parses its scripts. Non-script files
|
|
359
|
+
// are recorded as inputs without parsing.
|
|
360
|
+
async function loadModule(inspection, filePath) {
|
|
361
|
+
const { files, modules } = inspection;
|
|
362
|
+
if (modules.has(filePath)) {
|
|
363
|
+
return modules.get(filePath);
|
|
364
|
+
}
|
|
365
|
+
if (!files.has(filePath)) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
const bytes = await readDeclaredSource(inspection, filePath);
|
|
369
|
+
if (!SCRIPT_FILE_PATTERN.test(filePath)) {
|
|
370
|
+
modules.set(filePath, null);
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
306
373
|
const module = {
|
|
307
374
|
path: filePath,
|
|
308
|
-
source,
|
|
309
|
-
hash:
|
|
375
|
+
source: utf8Source(bytes, filePath),
|
|
376
|
+
hash: files.get(filePath).hash,
|
|
310
377
|
bindings: new Map(),
|
|
378
|
+
constants: new Set(),
|
|
311
379
|
imports: new Map(),
|
|
312
380
|
nodes: [],
|
|
313
381
|
exports: new Map(),
|
|
314
382
|
descriptor: null
|
|
315
383
|
};
|
|
316
384
|
modules.set(filePath, module);
|
|
317
|
-
output.inputs.push({ path: filePath, hash: module.hash });
|
|
318
385
|
for (const unit of scriptUnits(inspection, module)) {
|
|
319
386
|
indexScriptUnit(inspection, module, unit);
|
|
320
387
|
}
|
|
@@ -325,7 +392,7 @@ function scriptUnits(inspection, module) {
|
|
|
325
392
|
if (!module.path.endsWith(".vue")) {
|
|
326
393
|
return [{ source: module.source, offset: 0, setup: false }];
|
|
327
394
|
}
|
|
328
|
-
const parsed = parseSfc(module.source, { filename: module.path });
|
|
395
|
+
const parsed = parseSfc(module.source, { filename: module.path, sourceMap: false });
|
|
329
396
|
if (parsed.errors.length) {
|
|
330
397
|
warn(inspection, "VUE_PARSE_FAILED", "The component could not be parsed completely.", module.path);
|
|
331
398
|
return [];
|
|
@@ -351,19 +418,23 @@ function indexScriptUnit(inspection, module, unit) {
|
|
|
351
418
|
createImportExpressions: true
|
|
352
419
|
});
|
|
353
420
|
const records = collectNodes(ast);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
421
|
+
for (const record of records) {
|
|
422
|
+
Object.assign(record, { offset: unit.offset, setup: unit.setup });
|
|
423
|
+
module.nodes.push(record);
|
|
424
|
+
indexDeclaration(module, record.node, record.parent?.node);
|
|
357
425
|
}
|
|
358
426
|
} catch {
|
|
359
427
|
warn(inspection, "JAVASCRIPT_PARSE_FAILED", "A script needs unsupported syntax or could not be parsed.", module.path);
|
|
360
428
|
}
|
|
361
429
|
}
|
|
362
430
|
|
|
363
|
-
function indexDeclaration(module, node) {
|
|
431
|
+
function indexDeclaration(module, node, parent) {
|
|
364
432
|
if (node.type === "VariableDeclarator" && node.id.type === "Identifier") {
|
|
365
433
|
// A name bound twice in one module is ambiguous, so it resolves to nothing.
|
|
366
434
|
module.bindings.set(node.id.name, module.bindings.has(node.id.name) ? undefined : node.init);
|
|
435
|
+
if (parent?.kind === "const") {
|
|
436
|
+
module.constants.add(node.id.name);
|
|
437
|
+
}
|
|
367
438
|
}
|
|
368
439
|
if (node.type === "ExportNamedDeclaration") {
|
|
369
440
|
for (const specifier of node.specifiers) {
|
|
@@ -772,7 +843,9 @@ async function discoverComponents(inspection) {
|
|
|
772
843
|
await recordCrudScreenUse(inspection, { module, file, parentId, tag });
|
|
773
844
|
}
|
|
774
845
|
}
|
|
775
|
-
|
|
846
|
+
if (!inspection.calls) {
|
|
847
|
+
recordFetchUses(inspection, { module, parentId, eventHandlers });
|
|
848
|
+
}
|
|
776
849
|
}
|
|
777
850
|
}
|
|
778
851
|
|
|
@@ -982,6 +1055,7 @@ async function discoverEntryOperations(inspection) {
|
|
|
982
1055
|
for (const record of module.nodes) {
|
|
983
1056
|
await recordCrudController(inspection, { module, file, record });
|
|
984
1057
|
}
|
|
1058
|
+
await recordActionRouteScopes(inspection, { module, file });
|
|
985
1059
|
const featureFactories = importedNames(module, SOURCES.kernelFeatures, "defineFeature");
|
|
986
1060
|
if (!featureFactories.size) {
|
|
987
1061
|
continue;
|
|
@@ -1075,12 +1149,231 @@ function recordFeatureRoute(inspection, { module, file, record, featureFactories
|
|
|
1075
1149
|
warn(inspection, "CONTROLLER_UNRESOLVED", "A route's application handler or identity is dynamic.", file);
|
|
1076
1150
|
return;
|
|
1077
1151
|
}
|
|
1078
|
-
|
|
1152
|
+
const entry = {
|
|
1079
1153
|
id: `entry:${inspection.httpScope}:${file}:${node.start}`,
|
|
1080
1154
|
key: { protocol: "http", scope: inspection.httpScope, method: method.toUpperCase(), operation: `${inspection.apiBasePath}${operation}` },
|
|
1081
1155
|
controllerFile: file,
|
|
1082
1156
|
resolution: "resolved",
|
|
1083
1157
|
evidence: sourceEvidence(module, node, offset)
|
|
1158
|
+
};
|
|
1159
|
+
inspection.output.entryOperations.push(entry);
|
|
1160
|
+
inspection.handlerRanges.set(entry, { file, start: offset + handler.start, end: offset + handler.end });
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// The literal value of a scope option: a string literal, or a local or
|
|
1164
|
+
// imported const string literal. Undefined otherwise.
|
|
1165
|
+
async function scopeOption(inspection, module, node, fallback) {
|
|
1166
|
+
if (!node) {
|
|
1167
|
+
return fallback;
|
|
1168
|
+
}
|
|
1169
|
+
if (node.type === "StringLiteral" || (node.type === "TemplateLiteral" && !node.expressions.length)) {
|
|
1170
|
+
return staticValue(node, module.bindings);
|
|
1171
|
+
}
|
|
1172
|
+
if (node.type !== "Identifier") {
|
|
1173
|
+
return undefined;
|
|
1174
|
+
}
|
|
1175
|
+
if (module.imports.has(node.name)) {
|
|
1176
|
+
return importedConstant(inspection, module, node.name, new Set());
|
|
1177
|
+
}
|
|
1178
|
+
const value = module.constants.has(node.name) ? staticValue(module.bindings.get(node.name), module.bindings) : undefined;
|
|
1179
|
+
return typeof value === "string" ? value : undefined;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
async function importedConstant(inspection, module, name, seen) {
|
|
1183
|
+
const imported = module.imports.get(name);
|
|
1184
|
+
const target = imported && resolveImport(inspection, module.path, imported.source);
|
|
1185
|
+
const owner = target && await loadModule(inspection, target);
|
|
1186
|
+
return owner ? exportedConstant(inspection, owner, imported.imported, seen) : undefined;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async function exportedConstant(inspection, module, name, seen) {
|
|
1190
|
+
const identity = `${module.path}:${name}`;
|
|
1191
|
+
const entry = module.exports.get(name);
|
|
1192
|
+
if (!entry || seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
|
|
1193
|
+
return undefined;
|
|
1194
|
+
}
|
|
1195
|
+
const next = new Set([...seen, identity]);
|
|
1196
|
+
if (entry.source) {
|
|
1197
|
+
const target = resolveImport(inspection, module.path, entry.source);
|
|
1198
|
+
const owner = target && await loadModule(inspection, target);
|
|
1199
|
+
return owner ? exportedConstant(inspection, owner, entry.local, next) : undefined;
|
|
1200
|
+
}
|
|
1201
|
+
if (module.imports.has(entry.local)) {
|
|
1202
|
+
return importedConstant(inspection, module, entry.local, next);
|
|
1203
|
+
}
|
|
1204
|
+
const value = module.constants.has(entry.local) ? staticValue(module.bindings.get(entry.local), module.bindings) : undefined;
|
|
1205
|
+
return typeof value === "string" ? value : undefined;
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
// The scope's API base path, computed exactly as createActionRouteScope does;
|
|
1209
|
+
// null when an option is not a literal.
|
|
1210
|
+
async function actionRouteScopeBase(inspection, module, node) {
|
|
1211
|
+
const options = bindingNode(node.arguments[1], module.bindings);
|
|
1212
|
+
if (options && (options.type !== "ObjectExpression" || options.properties.some((entry) => entry.type === "SpreadElement"))) {
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
1215
|
+
const routeBase = await scopeOption(inspection, module, objectProperty(options, "routeBase"), "/");
|
|
1216
|
+
const relativePath = await scopeOption(inspection, module, objectProperty(options, "relativePath"), "");
|
|
1217
|
+
const surface = await scopeOption(inspection, module, objectProperty(options, "surface"), "");
|
|
1218
|
+
const strictParams = staticValue(objectProperty(options, "strictParams"), module.bindings) ?? false;
|
|
1219
|
+
if (typeof routeBase !== "string" || typeof relativePath !== "string" || typeof surface !== "string" || typeof strictParams !== "boolean") {
|
|
1220
|
+
return null;
|
|
1221
|
+
}
|
|
1222
|
+
try {
|
|
1223
|
+
return resolveScopedApiBasePath({ routeBase, relativePath, strictParams });
|
|
1224
|
+
} catch {
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// The names a scope's route registrations are called through: the variable
|
|
1230
|
+
// holding the scope, or destructured actionRoute / serviceRoute functions.
|
|
1231
|
+
function scopeReceivers(record) {
|
|
1232
|
+
const declarator = record.ancestors.at(-1);
|
|
1233
|
+
if (declarator?.type !== "VariableDeclarator" || declarator.init !== record.node) {
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
const owner = record.ancestors.slice().reverse().find((node) => isFunctionNode(node) || node.type === "Program");
|
|
1237
|
+
if (declarator.id.type === "Identifier") {
|
|
1238
|
+
return { owner, object: declarator.id.name, functions: new Map() };
|
|
1239
|
+
}
|
|
1240
|
+
if (declarator.id.type !== "ObjectPattern") {
|
|
1241
|
+
return null;
|
|
1242
|
+
}
|
|
1243
|
+
const functions = new Map();
|
|
1244
|
+
for (const entry of declarator.id.properties) {
|
|
1245
|
+
if (entry.type === "ObjectProperty" && entry.value.type === "Identifier" && ["actionRoute", "serviceRoute"].includes(propertyKey(entry))) {
|
|
1246
|
+
functions.set(entry.value.name, propertyKey(entry));
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return { owner, object: null, functions };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function routeRegistrationKind({ node, ancestors }, receivers) {
|
|
1253
|
+
if (node.type !== "CallExpression" || !ancestors.includes(receivers.owner)) {
|
|
1254
|
+
return "";
|
|
1255
|
+
}
|
|
1256
|
+
const callee = node.callee;
|
|
1257
|
+
if (callee.type === "Identifier") {
|
|
1258
|
+
return receivers.functions.get(callee.name) || "";
|
|
1259
|
+
}
|
|
1260
|
+
const named = callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" &&
|
|
1261
|
+
callee.object.name === receivers.object;
|
|
1262
|
+
return named && ["actionRoute", "serviceRoute"].includes(callee.property.name) ? callee.property.name : "";
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
function bindPattern(pattern, value, environment) {
|
|
1266
|
+
if (!pattern) {
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (pattern.type === "Identifier") {
|
|
1270
|
+
environment.set(pattern.name, value);
|
|
1271
|
+
} else if (pattern.type === "ArrayPattern") {
|
|
1272
|
+
pattern.elements.forEach((element, index) => bindPattern(element, Array.isArray(value) ? value[index] : undefined, environment));
|
|
1273
|
+
} else if (pattern.type === "ObjectPattern") {
|
|
1274
|
+
for (const entry of pattern.properties) {
|
|
1275
|
+
if (entry.type === "ObjectProperty" && !entry.computed) {
|
|
1276
|
+
bindPattern(entry.value, value && typeof value === "object" ? value[propertyKey(entry)] : undefined, environment);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
} else if (pattern.type === "AssignmentPattern") {
|
|
1280
|
+
bindPattern(pattern.left, value, environment);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// One environment per element of each enclosing `for...of` or `.map` over a
|
|
1285
|
+
// literal array of at most 64 elements; null beyond that bound.
|
|
1286
|
+
function unrolledEnvironments(module, { node, ancestors }) {
|
|
1287
|
+
let environments = [new Map()];
|
|
1288
|
+
const chain = [...ancestors, node];
|
|
1289
|
+
chain.forEach((current, index) => {
|
|
1290
|
+
let pattern;
|
|
1291
|
+
let source;
|
|
1292
|
+
if (current.type === "ForOfStatement" && chain.includes(current.body) && current.left.type === "VariableDeclaration") {
|
|
1293
|
+
pattern = current.left.declarations[0]?.id;
|
|
1294
|
+
source = current.right;
|
|
1295
|
+
}
|
|
1296
|
+
const call = chain[index - 1];
|
|
1297
|
+
if (isFunctionNode(current) && call?.type === "CallExpression" && call.arguments[0] === current &&
|
|
1298
|
+
call.callee.type === "MemberExpression" && !call.callee.computed && call.callee.property.name === "map") {
|
|
1299
|
+
pattern = current.params[0];
|
|
1300
|
+
source = call.callee.object;
|
|
1301
|
+
}
|
|
1302
|
+
const elements = source && staticValue(source, module.bindings);
|
|
1303
|
+
if (!pattern || !Array.isArray(elements) || elements.length > MAX_UNROLLED_ELEMENTS) {
|
|
1304
|
+
return;
|
|
1305
|
+
}
|
|
1306
|
+
environments = environments.flatMap((environment) => elements.map((element) => {
|
|
1307
|
+
const next = new Map(environment);
|
|
1308
|
+
bindPattern(pattern, element, next);
|
|
1309
|
+
return next;
|
|
1310
|
+
}));
|
|
1311
|
+
});
|
|
1312
|
+
return environments.length <= MAX_UNROLLED_ELEMENTS ? environments : null;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Routes registered through the kernel's createActionRouteScope. Scope
|
|
1316
|
+
// options must be literals or const string literals; each route's method,
|
|
1317
|
+
// suffix and actionId literals, templates or literal-array elements.
|
|
1318
|
+
async function recordActionRouteScopes(inspection, { module, file }) {
|
|
1319
|
+
const factories = importedNames(module, SOURCES.kernelHttp, "createActionRouteScope");
|
|
1320
|
+
if (!factories.size) {
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
for (const record of module.nodes.filter((entry) => isLocalCall(entry, factories))) {
|
|
1324
|
+
const receivers = scopeReceivers(record);
|
|
1325
|
+
if (!receivers) {
|
|
1326
|
+
warn(inspection, "ACTION_ROUTE_SCOPE_UNBOUND", "An action route scope must be held in a local variable or destructured.", file);
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
const routeBase = await actionRouteScopeBase(inspection, module, record.node);
|
|
1330
|
+
for (const registration of module.nodes) {
|
|
1331
|
+
const kind = routeRegistrationKind(registration, receivers);
|
|
1332
|
+
if (!kind) {
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
if (routeBase === null) {
|
|
1336
|
+
warn(inspection, "ACTION_ROUTE_UNRESOLVED", "An action route scope needs literal routeBase, relativePath and surface options.", file);
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
recordScopedRoute(inspection, { module, file, routeBase, registration, kind });
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function recordScopedRoute(inspection, { module, file, routeBase, registration, kind }) {
|
|
1345
|
+
const { node, offset } = registration;
|
|
1346
|
+
const environments = unrolledEnvironments(module, registration);
|
|
1347
|
+
const [methodNode, suffixNode, optionsNode, handler] = node.arguments;
|
|
1348
|
+
const options = bindingNode(optionsNode, module.bindings);
|
|
1349
|
+
(environments || [null]).forEach((environment, index) => {
|
|
1350
|
+
const method = environment && staticValue(methodNode, module.bindings, new Set(), environment);
|
|
1351
|
+
const suffix = environment && staticValue(suffixNode, module.bindings, new Set(), environment);
|
|
1352
|
+
const actionId = kind === "actionRoute" && environment
|
|
1353
|
+
? staticValue(objectProperty(options, "actionId"), module.bindings, new Set(), environment)
|
|
1354
|
+
: "";
|
|
1355
|
+
const literalAction = kind !== "actionRoute" || (typeof actionId === "string" && Boolean(actionId.trim()));
|
|
1356
|
+
if (typeof method !== "string" || !/^[A-Za-z]+$/u.test(method) || typeof suffix !== "string" || !literalAction) {
|
|
1357
|
+
warn(inspection, "ACTION_ROUTE_UNRESOLVED", "A route's method, path suffix or actionId is not a literal or literal-array element.", file);
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
const entry = {
|
|
1361
|
+
id: `entry:${inspection.httpScope}:${file}:${offset + node.start}${environments.length > 1 ? `:${index}` : ""}`,
|
|
1362
|
+
key: {
|
|
1363
|
+
protocol: "http",
|
|
1364
|
+
scope: inspection.httpScope,
|
|
1365
|
+
method: method.toUpperCase(),
|
|
1366
|
+
operation: `${routeBase}${suffix}`.replace(/\/+/gu, "/").replace(/(.)\/$/u, "$1")
|
|
1367
|
+
},
|
|
1368
|
+
controllerFile: file,
|
|
1369
|
+
...(kind === "actionRoute" ? { actionId: actionId.trim() } : {}),
|
|
1370
|
+
resolution: "resolved",
|
|
1371
|
+
evidence: sourceEvidence(module, node, offset)
|
|
1372
|
+
};
|
|
1373
|
+
inspection.output.entryOperations.push(entry);
|
|
1374
|
+
if (handler && isFunctionNode(handler)) {
|
|
1375
|
+
inspection.handlerRanges.set(entry, { file, start: offset + handler.start, end: offset + handler.end });
|
|
1376
|
+
}
|
|
1084
1377
|
});
|
|
1085
1378
|
}
|
|
1086
1379
|
|
|
@@ -1133,10 +1426,116 @@ async function collectImports(inspection) {
|
|
|
1133
1426
|
}
|
|
1134
1427
|
}
|
|
1135
1428
|
|
|
1429
|
+
// The page or component record a file defines, for operation uses found in it.
|
|
1430
|
+
function parentIdForFile(inspection, file) {
|
|
1431
|
+
const { output } = inspection;
|
|
1432
|
+
return output.pages.find((page) => page.file === file)?.id ||
|
|
1433
|
+
output.components.find((component) => component.file === file)?.id ||
|
|
1434
|
+
null;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// Functions, calls, transport operation uses and action entry points from the
|
|
1438
|
+
// TypeScript checker over every declared script. With a usable cache
|
|
1439
|
+
// directory, an unchanged run reuses its earlier result, and otherwise each
|
|
1440
|
+
// file whose own and directly imported sources are unchanged keeps its calls.
|
|
1441
|
+
async function discoverCalls(inspection) {
|
|
1442
|
+
const { files, output, projectRoot } = inspection;
|
|
1443
|
+
const scripts = [...files.keys()].filter((file) => SCRIPT_FILE_PATTERN.test(file));
|
|
1444
|
+
const keys = cacheKeys({
|
|
1445
|
+
input: inspection.input,
|
|
1446
|
+
files,
|
|
1447
|
+
imports: output.imports,
|
|
1448
|
+
scripts,
|
|
1449
|
+
options: {
|
|
1450
|
+
aliases: inspection.aliases,
|
|
1451
|
+
httpScope: inspection.httpScope,
|
|
1452
|
+
apiBasePath: inspection.apiBasePath,
|
|
1453
|
+
apiPrefixAliases: inspection.apiPrefixAliases
|
|
1454
|
+
}
|
|
1455
|
+
});
|
|
1456
|
+
const location = await cacheLocation(projectRoot, inspection.input.cacheDirectory);
|
|
1457
|
+
const cache = location && await readCallCache(location, keys.key);
|
|
1458
|
+
const sources = new Map();
|
|
1459
|
+
const hashes = new Map();
|
|
1460
|
+
for (const file of scripts) {
|
|
1461
|
+
sources.set(file, utf8Source(await readDeclaredSource(inspection, file), file));
|
|
1462
|
+
hashes.set(file, files.get(file).hash);
|
|
1463
|
+
}
|
|
1464
|
+
const run = cache?.run?.key === keys.runKey ? cache.run : await inspectCallsAndCache(inspection, {
|
|
1465
|
+
sources,
|
|
1466
|
+
hashes,
|
|
1467
|
+
keys,
|
|
1468
|
+
location,
|
|
1469
|
+
reuse: new Map(scripts.filter((file) => cache?.files[file]?.key === keys.fileKeys.get(file)).map((file) => [file, cache.files[file]]))
|
|
1470
|
+
});
|
|
1471
|
+
for (const [code, message, file] of run.diagnostics) {
|
|
1472
|
+
warn(inspection, code, message, file);
|
|
1473
|
+
}
|
|
1474
|
+
const controllers = new Map(run.controllers);
|
|
1475
|
+
for (const entry of output.entryOperations) {
|
|
1476
|
+
if (controllers.has(entry.id)) {
|
|
1477
|
+
entry.controllerFunctionId = controllers.get(entry.id);
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
const filesInOrder = run.order.map((file) => run.files?.[file] || cache.files[file]);
|
|
1481
|
+
output.functions.push(...filesInOrder.flatMap((entry) => entry.functions));
|
|
1482
|
+
output.calls.push(...filesInOrder.flatMap((entry) => entry.calls));
|
|
1483
|
+
output.entryPoints.push(...run.entryPoints);
|
|
1484
|
+
output.operationUses.push(...run.operationUses);
|
|
1485
|
+
output.capabilities.functions = run.complete ? "complete" : "partial";
|
|
1486
|
+
output.capabilities.calls = run.complete && !run.truncated ? "complete" : "partial";
|
|
1487
|
+
output.capabilities.entryPoints = "partial";
|
|
1488
|
+
if (inspection.httpScope) {
|
|
1489
|
+
output.capabilities.operationUses = "partial";
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// One call pass, written to the cache when one is usable. The run's
|
|
1494
|
+
// diagnostics are kept so an unchanged rerun reports them again.
|
|
1495
|
+
async function inspectCallsAndCache(inspection, { sources, hashes, keys, location, reuse }) {
|
|
1496
|
+
const diagnostics = [];
|
|
1497
|
+
const result = inspectCalls({
|
|
1498
|
+
projectRoot: inspection.projectRoot,
|
|
1499
|
+
sources,
|
|
1500
|
+
hashes,
|
|
1501
|
+
resolveImport: (from, specifier) => resolveImport(inspection, from, specifier),
|
|
1502
|
+
entryOperations: inspection.output.entryOperations,
|
|
1503
|
+
handlerRanges: inspection.handlerRanges,
|
|
1504
|
+
transports: Boolean(inspection.httpScope),
|
|
1505
|
+
parentIdForFile: (file) => parentIdForFile(inspection, file),
|
|
1506
|
+
prefixAliases: inspection.apiPrefixAliases,
|
|
1507
|
+
diagnose: (code, message, file) => diagnostics.push([code, message, file]),
|
|
1508
|
+
reuse
|
|
1509
|
+
});
|
|
1510
|
+
const run = {
|
|
1511
|
+
key: keys.runKey,
|
|
1512
|
+
order: [...result.fileRecords.keys()],
|
|
1513
|
+
controllers: [...result.controllers].map(([entry, functionId]) => [entry.id, functionId]),
|
|
1514
|
+
entryPoints: result.entryPoints,
|
|
1515
|
+
operationUses: result.operationUses,
|
|
1516
|
+
complete: result.complete,
|
|
1517
|
+
truncated: result.truncated,
|
|
1518
|
+
diagnostics
|
|
1519
|
+
};
|
|
1520
|
+
const files = Object.fromEntries([...result.fileRecords].map(([file, records]) => [file, { key: keys.fileKeys.get(file), ...records }]));
|
|
1521
|
+
if (location) {
|
|
1522
|
+
await writeCallCache(location, { key: keys.key, run, files });
|
|
1523
|
+
}
|
|
1524
|
+
return { ...run, files };
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1136
1527
|
// Standalone, finite static inspection. No application import, watcher, server,
|
|
1137
1528
|
// Genesis dependency or runtime interpretation is needed to emit neutral JSON.
|
|
1138
|
-
async function inspectVueArchitecture({
|
|
1139
|
-
|
|
1529
|
+
async function inspectVueArchitecture({
|
|
1530
|
+
projectRoot,
|
|
1531
|
+
input,
|
|
1532
|
+
aliases = {},
|
|
1533
|
+
httpScope = "",
|
|
1534
|
+
apiBasePath = "",
|
|
1535
|
+
calls = false,
|
|
1536
|
+
apiPrefixAliases = []
|
|
1537
|
+
}) {
|
|
1538
|
+
const { files, packages } = validateInput({ projectRoot, input, aliases, apiPrefixAliases });
|
|
1140
1539
|
const inspection = {
|
|
1141
1540
|
projectRoot,
|
|
1142
1541
|
input,
|
|
@@ -1145,17 +1544,32 @@ async function inspectVueArchitecture({ projectRoot, input, aliases = {}, httpSc
|
|
|
1145
1544
|
aliases,
|
|
1146
1545
|
httpScope,
|
|
1147
1546
|
apiBasePath,
|
|
1148
|
-
|
|
1547
|
+
calls,
|
|
1548
|
+
apiPrefixAliases,
|
|
1549
|
+
output: createOutput({ input, calls }),
|
|
1149
1550
|
modules: new Map(),
|
|
1551
|
+
sources: new Map(),
|
|
1552
|
+
checkedDirectories: new Set(),
|
|
1553
|
+
handlerRanges: new Map(),
|
|
1150
1554
|
components: new Map(),
|
|
1151
1555
|
pages: new Map()
|
|
1152
1556
|
};
|
|
1557
|
+
inspection.httpScope ||= await projectHttpScope(inspection);
|
|
1558
|
+
if (inspection.httpScope) {
|
|
1559
|
+
Object.assign(inspection.output.capabilities, { entryOperations: "partial", operationUses: "partial" });
|
|
1560
|
+
}
|
|
1153
1561
|
await discoverPages(inspection, input.uiDiscovery?.discoveries || []);
|
|
1154
1562
|
await discoverComponents(inspection);
|
|
1155
|
-
if (httpScope) {
|
|
1563
|
+
if (inspection.httpScope) {
|
|
1156
1564
|
await discoverEntryOperations(inspection);
|
|
1157
1565
|
}
|
|
1158
1566
|
await collectImports(inspection);
|
|
1567
|
+
if (calls) {
|
|
1568
|
+
// The call pass has its own TypeScript syntax trees; the parsed modules
|
|
1569
|
+
// are released first so both never stay in memory together.
|
|
1570
|
+
inspection.modules.clear();
|
|
1571
|
+
await discoverCalls(inspection);
|
|
1572
|
+
}
|
|
1159
1573
|
return inspection.output;
|
|
1160
1574
|
}
|
|
1161
1575
|
|