@jskit-ai/shell-web 0.1.251 → 0.1.252
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/fixtures/adaptive-shell/src/main.js +6 -3
- package/package.json +2 -2
- package/patterns/application-shell/example/src/pages/home/index.vue +15 -5
- package/patterns/application-shell/example/tests/e2e/adaptive-shell.spec.ts +1 -1
- package/src/architecture/inspectVueArchitecture.js +1092 -336
- package/src/client/asyncModuleRecovery/runtime.js +116 -0
- package/src/client/components/ShellLayout.vue +18 -288
- package/src/client/components/ShellMenuLinkItem.vue +3 -21
- package/src/client/components/ShellOutlet.vue +2 -24
- package/src/client/components/ShellRouteTransition.vue +16 -114
- package/src/client/components/ShellSurfaceAwareMenuLinkItem.vue +5 -100
- package/src/client/components/ShellTabLinkItem.vue +5 -101
- package/src/client/composables/useShellNavigationLink.js +85 -0
- package/src/client/composables/useShellPullToRefresh.js +260 -0
- package/src/client/error/index.js +1 -1
- package/src/client/error/inject.js +4 -1
- package/src/client/error/policy.js +4 -13
- package/src/client/error/presenters.js +4 -9
- package/src/client/error/store.js +1 -1
- package/src/client/navigation/linkResolver.js +5 -27
- package/src/client/navigation/usePaths.js +1 -21
- package/src/client/placement/index.js +2 -0
- package/src/client/placement/inject.js +4 -1
- package/src/client/placement/runtime.js +2 -4
- package/src/client/placement/surfaceContext.js +11 -8
- package/src/client/placement/validators.js +18 -65
- package/src/client/providers/ShellWebClientProvider.js +100 -356
- package/src/client/requestRecovery/runtime.js +29 -77
- package/src/client/runtime/bootstrapRuntime.js +5 -4
- package/src/client/runtime/refreshRuntime.js +122 -0
- package/src/client/support/gestureTargets.js +26 -0
- package/src/client/support/menuLinkTarget.js +35 -19
- package/src/client/support/routeParams.js +26 -0
- package/src/client/support/routeSwipeGesture.js +88 -0
- package/src/client/support/routeTransitionKey.js +1 -3
- package/src/client/support/shellLayoutClass.js +32 -0
- package/test/bootstrapRuntime.test.js +98 -0
- package/test/errorActions.test.js +2 -1
- package/test/errorRuntime.test.js +30 -0
- package/test/linkResolver.test.js +23 -1
- package/test/menuLinkTarget.test.js +77 -0
- package/test/routeSwipeGesture.test.js +49 -0
- package/test/settingsPlacementContract.test.js +16 -6
- package/test/shellGestureSupport.test.js +46 -0
- package/test/surfaceContext.test.js +53 -0
- package/test/useShellPullToRefresh.test.js +123 -0
|
@@ -5,396 +5,1152 @@ 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
7
|
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
8
|
+
const INPUT_SCHEMA = "vibe64.architecture-inspection-input.v1";
|
|
9
|
+
const OUTPUT_SCHEMA = "genesis.architecture-observations.v1";
|
|
10
|
+
const ADAPTER_ID = "jskit.vue-static.v1";
|
|
11
|
+
const MAX_SOURCE_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
const MAX_RESOURCE_HOPS = 64;
|
|
13
|
+
const SCRIPT_FILE_PATTERN = /\.(?:[cm]?js|jsx|tsx?|vue)$/u;
|
|
14
|
+
const SERVER_FILE_PATTERN = /\.(?:js|mjs|cjs|ts)$/u;
|
|
15
|
+
const FUNCTION_NODE_PATTERN = /Function/u;
|
|
16
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/u;
|
|
17
|
+
const RESOLVE_EXTENSIONS = Object.freeze([".vue", ".js", ".ts", "/index.js", "/index.ts"]);
|
|
18
|
+
const UI_ADAPTERS = Object.freeze(["jskit", "vue-filesystem-router", "vue-registered-router"]);
|
|
19
|
+
const FILESYSTEM_ROUTER_OPTIONS = Object.freeze(["routesFolder", "extensions", "exclude", "dts", "nestedChildren"]);
|
|
20
|
+
const CRUD_RESOURCE_OPERATIONS = Object.freeze(["list", "view", "create", "patch", "delete"]);
|
|
21
|
+
const CRUD_CONTROLLER_OPERATIONS = Object.freeze(["list", "view", "create", "update", "delete"]);
|
|
22
|
+
const CRUD_SCREEN_TRANSPORT_KEYS = Object.freeze(["screen", "client", "surfaceId", "transport"]);
|
|
23
|
+
const CRUD_SCREEN_CLIENT_KEYS = Object.freeze(["client", "surfaceId", "transport", "resource"]);
|
|
11
24
|
|
|
12
|
-
|
|
13
|
-
|
|
25
|
+
// Framework sources whose declarations this inspector reads statically.
|
|
26
|
+
const SOURCES = Object.freeze({
|
|
27
|
+
crudResource: "@jskit-ai/resource-crud-core/shared/crudResource",
|
|
28
|
+
crudJsonApiFeature: "@jskit-ai/crud-core/server/defineCrudJsonApiFeature",
|
|
29
|
+
kernelFeatures: "@jskit-ai/kernel/server/features",
|
|
30
|
+
crudListScreen: "@jskit-ai/http-web/client/components/CrudListScreen",
|
|
31
|
+
crudAddEditScreen: "@jskit-ai/http-web/client/components/CrudAddEditScreen",
|
|
32
|
+
crudComposables: "@jskit-ai/http-web/client/composables",
|
|
33
|
+
filesystemRouter: "vue-router/vite",
|
|
34
|
+
registeredRouter: "vue-router"
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function hashBytes(source) {
|
|
38
|
+
return `sha256:${createHash("sha256").update(source).digest("hex")}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isCanonicalPath(value) {
|
|
42
|
+
if (typeof value !== "string" || value.length === 0 || value === ".") {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (/[\\\0]/u.test(value) || path.posix.isAbsolute(value)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return path.posix.normalize(value) === value && !value.split("/").includes("..");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function propertyKey(entry) {
|
|
52
|
+
return entry.key.name || entry.key.value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isStaticObjectProperty(entry) {
|
|
56
|
+
return entry.type === "ObjectProperty" && !entry.computed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function objectProperty(node, key) {
|
|
60
|
+
if (node?.type !== "ObjectExpression") {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
return node.properties.find((entry) => isStaticObjectProperty(entry) && propertyKey(entry) === key)?.value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasOnlyStaticProperties(node) {
|
|
67
|
+
return node.properties.every(isStaticObjectProperty);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isFunctionNode(node) {
|
|
71
|
+
return FUNCTION_NODE_PATTERN.test(node.type);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function directoryPrefixes(filePath) {
|
|
75
|
+
const parts = filePath.split("/");
|
|
76
|
+
return parts.map((_, index) => parts.slice(0, index + 1).join("/"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Every AST node with its ancestor chain, depth first.
|
|
80
|
+
function collectNodes(root) {
|
|
81
|
+
const result = [];
|
|
82
|
+
const pending = [{ node: root, ancestors: [] }];
|
|
14
83
|
while (pending.length) {
|
|
15
|
-
const current = pending.pop();
|
|
16
|
-
|
|
84
|
+
const current = pending.pop();
|
|
85
|
+
const { node, ancestors } = current;
|
|
86
|
+
if (!node || typeof node.type !== "string") {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
17
89
|
result.push(current);
|
|
18
90
|
for (const value of Object.values(node)) {
|
|
19
91
|
const children = Array.isArray(value) ? value : value?.type ? [value] : [];
|
|
20
|
-
for (const child of children)
|
|
92
|
+
for (const child of children) {
|
|
93
|
+
if (child?.type) {
|
|
94
|
+
pending.push({ node: child, ancestors: [...ancestors, node] });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
21
97
|
}
|
|
22
98
|
}
|
|
23
99
|
return result;
|
|
24
100
|
}
|
|
25
101
|
|
|
102
|
+
// The literal value of an expression, following local bindings; undefined when
|
|
103
|
+
// any part is dynamic.
|
|
26
104
|
function staticValue(node, bindings, seen = new Set()) {
|
|
27
|
-
if (!node)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
105
|
+
if (!node) {
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
if (["StringLiteral", "BooleanLiteral", "NumericLiteral"].includes(node.type)) {
|
|
109
|
+
return node.value;
|
|
110
|
+
}
|
|
111
|
+
if (node.type === "NullLiteral") {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
if (node.type === "Identifier" && !seen.has(node.name)) {
|
|
115
|
+
return staticValue(bindings.get(node.name), bindings, new Set([...seen, node.name]));
|
|
116
|
+
}
|
|
31
117
|
if (node.type === "ArrayExpression") {
|
|
32
|
-
const values = node.elements.map(entry => staticValue(entry, bindings, seen));
|
|
33
|
-
return values.every(value => value !== undefined) ? values : undefined;
|
|
118
|
+
const values = node.elements.map((entry) => staticValue(entry, bindings, seen));
|
|
119
|
+
return values.every((value) => value !== undefined) ? values : undefined;
|
|
34
120
|
}
|
|
35
121
|
if (node.type === "ObjectExpression") {
|
|
36
|
-
|
|
37
|
-
for (const entry of node.properties) {
|
|
38
|
-
if (entry.type !== "ObjectProperty" || entry.computed) return undefined;
|
|
39
|
-
const key = entry.key.name || entry.key.value; const value = staticValue(entry.value, bindings, seen);
|
|
40
|
-
if (value === undefined) return undefined; result[key] = value;
|
|
41
|
-
}
|
|
42
|
-
return result;
|
|
122
|
+
return staticObjectValue(node, bindings, seen);
|
|
43
123
|
}
|
|
44
124
|
if (node.type === "TemplateLiteral") {
|
|
45
|
-
const values = node.expressions.map(entry => staticValue(entry, bindings, seen));
|
|
46
|
-
if (values.some(value => typeof value !== "string"))
|
|
125
|
+
const values = node.expressions.map((entry) => staticValue(entry, bindings, seen));
|
|
126
|
+
if (values.some((value) => typeof value !== "string")) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
47
129
|
return node.quasis.map((entry, index) => entry.value.cooked + (values[index] || "")).join("");
|
|
48
130
|
}
|
|
49
131
|
if (node.type === "BinaryExpression" && node.operator === "+") {
|
|
50
|
-
const left = staticValue(node.left, bindings, seen);
|
|
132
|
+
const left = staticValue(node.left, bindings, seen);
|
|
133
|
+
const right = staticValue(node.right, bindings, seen);
|
|
51
134
|
return typeof left === "string" && typeof right === "string" ? left + right : undefined;
|
|
52
135
|
}
|
|
53
136
|
return undefined;
|
|
54
137
|
}
|
|
55
138
|
|
|
139
|
+
function staticObjectValue(node, bindings, seen) {
|
|
140
|
+
const result = {};
|
|
141
|
+
for (const entry of node.properties) {
|
|
142
|
+
if (!isStaticObjectProperty(entry)) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
const value = staticValue(entry.value, bindings, seen);
|
|
146
|
+
if (value === undefined) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
result[propertyKey(entry)] = value;
|
|
150
|
+
}
|
|
151
|
+
return result;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The node an identifier is bound to locally, or the node itself.
|
|
56
155
|
function bindingNode(node, bindings, seen = new Set()) {
|
|
57
|
-
if (node?.type !== "Identifier" || seen.has(node.name) || !bindings.has(node.name))
|
|
156
|
+
if (node?.type !== "Identifier" || seen.has(node.name) || !bindings.has(node.name)) {
|
|
157
|
+
return node;
|
|
158
|
+
}
|
|
58
159
|
return bindingNode(bindings.get(node.name), bindings, new Set([...seen, node.name]));
|
|
59
160
|
}
|
|
60
161
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
162
|
+
function isShadowed({ ancestors }, name) {
|
|
163
|
+
return ancestors.some((node) =>
|
|
164
|
+
node.params?.some((parameter) =>
|
|
165
|
+
collectNodes(parameter).some(({ node: binding }) => binding.type === "Identifier" && binding.name === name)
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isLocalCall(record, names) {
|
|
171
|
+
const { node } = record;
|
|
172
|
+
return node.type === "CallExpression" &&
|
|
173
|
+
node.callee.type === "Identifier" &&
|
|
174
|
+
names.has(node.callee.name) &&
|
|
175
|
+
!isShadowed(record, node.callee.name);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function importedNames(module, source, imported) {
|
|
179
|
+
const names = [...module.imports]
|
|
180
|
+
.filter(([, entry]) => entry.source === source && entry.imported === imported)
|
|
181
|
+
.map(([name]) => name);
|
|
182
|
+
return new Set(names);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function findRecord(module, node) {
|
|
186
|
+
return module.nodes.find((entry) => entry.node === node);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function kebabCase(name) {
|
|
190
|
+
return name.replace(/[A-Z]/gu, (letter, index) => `${index ? "-" : ""}${letter.toLowerCase()}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The imported local name a template tag refers to, by exact or kebab-case name.
|
|
194
|
+
function templateTagImport(module, tag) {
|
|
195
|
+
return [...module.imports.keys()].find((name) => name === tag || kebabCase(name) === tag);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function sourcePoint(module, index) {
|
|
199
|
+
const lines = module.source.slice(0, index).split("\n");
|
|
200
|
+
return { line: lines.length, column: lines.at(-1).length + 1 };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function sourceEvidence(module, node, offset = 0) {
|
|
204
|
+
const sourceEnd = Math.max(1, module.source.length - (module.source.endsWith("\n") ? 1 : 0));
|
|
205
|
+
return [{
|
|
206
|
+
path: module.path,
|
|
207
|
+
hash: module.hash,
|
|
208
|
+
start: sourcePoint(module, offset + (node?.start || 0)),
|
|
209
|
+
end: sourcePoint(module, offset + (node?.end || sourceEnd))
|
|
210
|
+
}];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function pageTitle(file) {
|
|
214
|
+
const name = path.posix.basename(file, path.posix.extname(file));
|
|
215
|
+
return name === "index" ? path.posix.basename(path.posix.dirname(file)) : name;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function validateInput({ projectRoot, input, aliases }) {
|
|
219
|
+
if (!path.isAbsolute(projectRoot || "") || input?.schema !== INPUT_SCHEMA || !Array.isArray(input.files)) {
|
|
220
|
+
throw new TypeError("An absolute project root and architecture inspection input are required.");
|
|
221
|
+
}
|
|
65
222
|
const files = new Map();
|
|
66
223
|
for (const file of input.files) {
|
|
67
|
-
if (!
|
|
224
|
+
if (!isCanonicalPath(file.path) || typeof file.hash !== "string" || files.has(file.path)) {
|
|
225
|
+
throw new TypeError("Invalid indexed input file.");
|
|
226
|
+
}
|
|
68
227
|
files.set(file.path, file);
|
|
69
228
|
}
|
|
70
|
-
for (const value of Object.values(aliases))
|
|
71
|
-
|
|
72
|
-
|
|
229
|
+
for (const value of Object.values(aliases)) {
|
|
230
|
+
if (value !== "." && !isCanonicalPath(value)) {
|
|
231
|
+
throw new TypeError("Aliases must resolve within the project.");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
73
234
|
const packages = input.packages || [];
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
235
|
+
const validPackage = (entry) =>
|
|
236
|
+
typeof entry.id === "string" &&
|
|
237
|
+
typeof entry.name === "string" &&
|
|
238
|
+
isCanonicalPath(entry.manifest) &&
|
|
239
|
+
Array.isArray(entry.entryPoints);
|
|
240
|
+
if (!Array.isArray(packages) || !packages.every(validPackage)) {
|
|
241
|
+
throw new TypeError("Invalid package boundary input.");
|
|
242
|
+
}
|
|
243
|
+
return { files, packages };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function createOutput({ input, httpScope }) {
|
|
247
|
+
const httpCapability = httpScope ? "partial" : "unconfigured";
|
|
248
|
+
const output = {
|
|
249
|
+
schema: OUTPUT_SCHEMA,
|
|
250
|
+
adapter: ADAPTER_ID,
|
|
251
|
+
inputs: [],
|
|
252
|
+
capabilities: {
|
|
253
|
+
pages: "unconfigured",
|
|
254
|
+
components: "partial",
|
|
255
|
+
imports: "partial",
|
|
256
|
+
entryOperations: httpCapability,
|
|
257
|
+
operationUses: httpCapability
|
|
258
|
+
},
|
|
259
|
+
pages: [],
|
|
260
|
+
registrations: [],
|
|
261
|
+
components: [],
|
|
262
|
+
componentUses: [],
|
|
263
|
+
entryOperations: [],
|
|
264
|
+
operationUses: [],
|
|
265
|
+
imports: [],
|
|
266
|
+
diagnostics: []
|
|
267
|
+
};
|
|
268
|
+
if (input.packageIdentity) {
|
|
269
|
+
output.packageIdentity = input.packageIdentity;
|
|
270
|
+
}
|
|
271
|
+
return output;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function warn(inspection, code, message, source) {
|
|
275
|
+
inspection.output.diagnostics.push({ code, message, ...(source ? { path: source } : {}) });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Reads one indexed, hash-matching source file once and parses its scripts.
|
|
279
|
+
// Non-script files are recorded as inputs without parsing.
|
|
280
|
+
async function loadModule(inspection, filePath) {
|
|
281
|
+
const { files, modules, output, projectRoot } = inspection;
|
|
282
|
+
if (modules.has(filePath)) {
|
|
283
|
+
return modules.get(filePath);
|
|
284
|
+
}
|
|
285
|
+
if (!files.has(filePath)) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
for (const directory of directoryPrefixes(filePath)) {
|
|
289
|
+
if ((await lstat(path.join(projectRoot, directory))).isSymbolicLink()) {
|
|
290
|
+
throw new Error("Architecture inspection cannot follow symbolic links.");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const bytes = await readFile(path.join(projectRoot, filePath));
|
|
294
|
+
if (bytes.length > MAX_SOURCE_BYTES || hashBytes(bytes) !== files.get(filePath).hash) {
|
|
295
|
+
throw new Error(`Source changed or exceeded inspection bounds: ${filePath}`);
|
|
296
|
+
}
|
|
297
|
+
if (!SCRIPT_FILE_PATTERN.test(filePath)) {
|
|
298
|
+
modules.set(filePath, null);
|
|
299
|
+
output.inputs.push({ path: filePath, hash: hashBytes(bytes) });
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
const source = bytes.toString("utf8");
|
|
303
|
+
if (!Buffer.from(source).equals(bytes)) {
|
|
304
|
+
throw new Error(`Source is not UTF-8: ${filePath}`);
|
|
305
|
+
}
|
|
306
|
+
const module = {
|
|
307
|
+
path: filePath,
|
|
308
|
+
source,
|
|
309
|
+
hash: hashBytes(bytes),
|
|
310
|
+
bindings: new Map(),
|
|
311
|
+
imports: new Map(),
|
|
312
|
+
nodes: [],
|
|
313
|
+
exports: new Map(),
|
|
314
|
+
descriptor: null
|
|
315
|
+
};
|
|
316
|
+
modules.set(filePath, module);
|
|
317
|
+
output.inputs.push({ path: filePath, hash: module.hash });
|
|
318
|
+
for (const unit of scriptUnits(inspection, module)) {
|
|
319
|
+
indexScriptUnit(inspection, module, unit);
|
|
320
|
+
}
|
|
321
|
+
return module;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function scriptUnits(inspection, module) {
|
|
325
|
+
if (!module.path.endsWith(".vue")) {
|
|
326
|
+
return [{ source: module.source, offset: 0, setup: false }];
|
|
327
|
+
}
|
|
328
|
+
const parsed = parseSfc(module.source, { filename: module.path });
|
|
329
|
+
if (parsed.errors.length) {
|
|
330
|
+
warn(inspection, "VUE_PARSE_FAILED", "The component could not be parsed completely.", module.path);
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
module.descriptor = parsed.descriptor;
|
|
334
|
+
const units = [];
|
|
335
|
+
const scripts = [parsed.descriptor.script, parsed.descriptor.scriptSetup].filter(Boolean);
|
|
336
|
+
for (const script of scripts) {
|
|
337
|
+
if (script.src) {
|
|
338
|
+
warn(inspection, "VUE_EXTERNAL_SCRIPT", "External component scripts need another inspection adapter.", module.path);
|
|
339
|
+
continue;
|
|
88
340
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
341
|
+
units.push({ source: script.content, offset: script.loc.start.offset, setup: script === parsed.descriptor.scriptSetup });
|
|
342
|
+
}
|
|
343
|
+
return units;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function indexScriptUnit(inspection, module, unit) {
|
|
347
|
+
try {
|
|
348
|
+
const ast = parseJavaScript(unit.source, {
|
|
349
|
+
sourceType: "unambiguous",
|
|
350
|
+
plugins: ["typescript", "jsx"],
|
|
351
|
+
createImportExpressions: true
|
|
352
|
+
});
|
|
353
|
+
const records = collectNodes(ast);
|
|
354
|
+
module.nodes.push(...records.map((record) => ({ ...record, offset: unit.offset, setup: unit.setup })));
|
|
355
|
+
for (const { node } of records) {
|
|
356
|
+
indexDeclaration(module, node);
|
|
357
|
+
}
|
|
358
|
+
} catch {
|
|
359
|
+
warn(inspection, "JAVASCRIPT_PARSE_FAILED", "A script needs unsupported syntax or could not be parsed.", module.path);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function indexDeclaration(module, node) {
|
|
364
|
+
if (node.type === "VariableDeclarator" && node.id.type === "Identifier") {
|
|
365
|
+
// A name bound twice in one module is ambiguous, so it resolves to nothing.
|
|
366
|
+
module.bindings.set(node.id.name, module.bindings.has(node.id.name) ? undefined : node.init);
|
|
367
|
+
}
|
|
368
|
+
if (node.type === "ExportNamedDeclaration") {
|
|
369
|
+
for (const specifier of node.specifiers) {
|
|
370
|
+
if (specifier.type === "ExportSpecifier") {
|
|
371
|
+
const exported = specifier.exported.name || specifier.exported.value;
|
|
372
|
+
const local = specifier.local.name || specifier.local.value;
|
|
373
|
+
module.exports.set(exported, { local, source: node.source?.value });
|
|
101
374
|
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
module.nodes.push(...records.map(record => ({ ...record, offset: unit.offset, setup: unit.setup })));
|
|
108
|
-
for (const { node } of records) {
|
|
109
|
-
if (node.type === "VariableDeclarator" && node.id.type === "Identifier") module.bindings.set(node.id.name, module.bindings.has(node.id.name) ? undefined : node.init);
|
|
110
|
-
if (node.type === "ExportNamedDeclaration") {
|
|
111
|
-
for (const specifier of node.specifiers) if (specifier.type === "ExportSpecifier") module.exports.set(specifier.exported.name || specifier.exported.value, { local: specifier.local.name || specifier.local.value, source: node.source?.value });
|
|
112
|
-
const declaration = node.declaration;
|
|
113
|
-
if (declaration?.type === "VariableDeclaration") for (const entry of declaration.declarations) if (entry.id.type === "Identifier") module.exports.set(entry.id.name, { local: entry.id.name });
|
|
114
|
-
}
|
|
115
|
-
if (node.type === "ImportDeclaration") for (const specifier of node.specifiers) module.imports.set(specifier.local.name, { source: node.source.value, imported: specifier.imported?.name || "default", node });
|
|
375
|
+
}
|
|
376
|
+
if (node.declaration?.type === "VariableDeclaration") {
|
|
377
|
+
for (const entry of node.declaration.declarations) {
|
|
378
|
+
if (entry.id.type === "Identifier") {
|
|
379
|
+
module.exports.set(entry.id.name, { local: entry.id.name });
|
|
116
380
|
}
|
|
117
|
-
} catch { warn("JAVASCRIPT_PARSE_FAILED", "A script needs unsupported syntax or could not be parsed.", filePath); }
|
|
118
|
-
}
|
|
119
|
-
return module;
|
|
120
|
-
}
|
|
121
|
-
function resolve(from, specifier) {
|
|
122
|
-
if (typeof specifier !== "string") return null;
|
|
123
|
-
let base;
|
|
124
|
-
if (specifier.startsWith(".")) base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
|
|
125
|
-
else if (specifier.startsWith("/")) base = specifier.slice(1);
|
|
126
|
-
else {
|
|
127
|
-
const alias = Object.keys(aliases).sort((a, b) => b.length - a.length).find(key => specifier === key || specifier.startsWith(key + "/"));
|
|
128
|
-
if (alias) base = path.posix.join(aliases[alias], specifier.slice(alias.length));
|
|
129
|
-
}
|
|
130
|
-
if (!base) {
|
|
131
|
-
const dependency = packageImport(from, specifier);
|
|
132
|
-
if (dependency && !dependency.record.opaque) {
|
|
133
|
-
const candidates = [...new Set(dependency.record.entryPoints.filter(entry => entry.importPath === dependency.importPath && files.has(entry.file)).map(entry => entry.file))];
|
|
134
|
-
if (candidates.length === 1) return candidates[0];
|
|
135
381
|
}
|
|
136
382
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
383
|
+
}
|
|
384
|
+
if (node.type === "ImportDeclaration") {
|
|
385
|
+
for (const specifier of node.specifiers) {
|
|
386
|
+
module.imports.set(specifier.local.name, {
|
|
387
|
+
source: node.source.value,
|
|
388
|
+
imported: specifier.imported?.name || "default",
|
|
389
|
+
node
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Resolves an import specifier to an indexed project file through relative
|
|
396
|
+
// paths, project-root paths, declared aliases and declared package entry points.
|
|
397
|
+
function resolveImport(inspection, from, specifier) {
|
|
398
|
+
const { aliases, files } = inspection;
|
|
399
|
+
if (typeof specifier !== "string") {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
let base;
|
|
403
|
+
if (specifier.startsWith(".")) {
|
|
404
|
+
base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
|
|
405
|
+
} else if (specifier.startsWith("/")) {
|
|
406
|
+
base = specifier.slice(1);
|
|
407
|
+
} else {
|
|
408
|
+
const alias = Object.keys(aliases)
|
|
409
|
+
.sort((a, b) => b.length - a.length)
|
|
410
|
+
.find((key) => specifier === key || specifier.startsWith(key + "/"));
|
|
411
|
+
if (alias) {
|
|
412
|
+
base = path.posix.join(aliases[alias], specifier.slice(alias.length));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (!base) {
|
|
416
|
+
const packageEntry = resolvePackageEntryFile(inspection, from, specifier);
|
|
417
|
+
if (packageEntry) {
|
|
418
|
+
return packageEntry;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (!isCanonicalPath(base)) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
const candidates = [base, ...RESOLVE_EXTENSIONS.map((extension) => base + extension)];
|
|
425
|
+
return candidates.find((candidate) => files.has(candidate)) || null;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function resolvePackageEntryFile(inspection, from, specifier) {
|
|
429
|
+
const dependency = resolvePackageImport(inspection, from, specifier);
|
|
430
|
+
if (!dependency || dependency.record.opaque) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
const entryFiles = dependency.record.entryPoints
|
|
434
|
+
.filter((entry) => entry.importPath === dependency.importPath && inspection.files.has(entry.file))
|
|
435
|
+
.map((entry) => entry.file);
|
|
436
|
+
const candidates = [...new Set(entryFiles)];
|
|
437
|
+
return candidates.length === 1 ? candidates[0] : null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// The declared package an import names, chosen by the nearest manifest that
|
|
441
|
+
// contains the importing file.
|
|
442
|
+
function resolvePackageImport(inspection, from, specifier) {
|
|
443
|
+
if (typeof specifier !== "string" || specifier.startsWith(".") || specifier.startsWith("/")) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
const namesPackage = (entry) => specifier === entry.name || specifier.startsWith(`${entry.name}/`);
|
|
447
|
+
const containsFile = (declaration) => {
|
|
448
|
+
const parent = path.posix.dirname(declaration.manifest);
|
|
449
|
+
return parent === "." || from.startsWith(`${parent}/`);
|
|
450
|
+
};
|
|
451
|
+
const candidates = inspection.packages
|
|
452
|
+
.flatMap((entry) =>
|
|
453
|
+
(entry.declarations || [{ manifest: entry.manifest }])
|
|
454
|
+
.filter((declaration) => containsFile(declaration) && namesPackage(entry))
|
|
455
|
+
.map((declaration) => ({ record: entry, manifest: declaration.manifest }))
|
|
456
|
+
)
|
|
457
|
+
.sort((a, b) => b.manifest.length - a.manifest.length);
|
|
458
|
+
const record = candidates[0]?.record;
|
|
459
|
+
if (!record) {
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
return { record, importPath: specifier === record.name ? "." : `.${specifier.slice(record.name.length)}` };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Follows an expression through local bindings, imports and re-exports to a
|
|
466
|
+
// literal defineCrudResource declaration.
|
|
467
|
+
async function resolveCrudResource(inspection, module, node, seen = new Set()) {
|
|
468
|
+
if (!node || seen.size >= MAX_RESOURCE_HOPS) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
if (node.type === "Identifier") {
|
|
472
|
+
const identity = `${module.path}:${node.name}`;
|
|
473
|
+
if (seen.has(identity)) {
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
476
|
+
const next = new Set([...seen, identity]);
|
|
477
|
+
const imported = module.imports.get(node.name);
|
|
478
|
+
if (!imported) {
|
|
479
|
+
return resolveCrudResource(inspection, module, module.bindings.get(node.name), next);
|
|
480
|
+
}
|
|
481
|
+
const target = resolveImport(inspection, module.path, imported.source);
|
|
482
|
+
const owner = target && await loadModule(inspection, target);
|
|
483
|
+
return owner ? resolveExportedCrudResource(inspection, owner, imported.imported, next) : null;
|
|
484
|
+
}
|
|
485
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier") {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const factory = module.imports.get(node.callee.name);
|
|
489
|
+
const record = findRecord(module, node);
|
|
490
|
+
const isFactory = factory?.source === SOURCES.crudResource && factory.imported === "defineCrudResource";
|
|
491
|
+
if (!isFactory || !record || isShadowed(record, node.callee.name)) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
const config = bindingNode(node.arguments[0], module.bindings);
|
|
495
|
+
if (config?.type !== "ObjectExpression" || !hasOnlyStaticProperties(config)) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
const namespace = staticValue(objectProperty(config, "namespace"), module.bindings);
|
|
499
|
+
const declaredOperations = objectProperty(config, "crudOperations");
|
|
500
|
+
const operations = declaredOperations ? staticValue(declaredOperations, module.bindings) : [...CRUD_RESOURCE_OPERATIONS];
|
|
501
|
+
if (typeof namespace !== "string" || !namespace.trim()) {
|
|
502
|
+
return null;
|
|
503
|
+
}
|
|
504
|
+
if (!Array.isArray(operations) || !operations.length || !operations.every((name) => CRUD_RESOURCE_OPERATIONS.includes(name))) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
if (objectProperty(config, "operations")) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
return { namespace: namespace.trim(), operations, evidence: sourceEvidence(module, node, record.offset) };
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function resolveExportedCrudResource(inspection, module, name, seen) {
|
|
514
|
+
const entry = module.exports.get(name);
|
|
515
|
+
if (!entry) {
|
|
516
|
+
return null;
|
|
517
|
+
}
|
|
518
|
+
if (!entry.source) {
|
|
519
|
+
return resolveCrudResource(inspection, module, { type: "Identifier", name: entry.local }, seen);
|
|
520
|
+
}
|
|
521
|
+
const identity = `${module.path}:export:${name}`;
|
|
522
|
+
if (seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
const target = resolveImport(inspection, module.path, entry.source);
|
|
526
|
+
const owner = target && await loadModule(inspection, target);
|
|
527
|
+
return owner ? resolveExportedCrudResource(inspection, owner, entry.local, new Set([...seen, identity])) : null;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function crudOperationKey(inspection, surface, namespace, operation, apiSuffix) {
|
|
531
|
+
return {
|
|
532
|
+
protocol: "jskit.crud",
|
|
533
|
+
scope: JSON.stringify([inspection.httpScope, surface, apiSuffix]),
|
|
534
|
+
operation: `${namespace}:${operation === "update" ? "patch" : operation}`
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Records one page per surface and file, and one registration per route that
|
|
539
|
+
// renders it.
|
|
540
|
+
async function recordPage(inspection, { file, discovery, identity, registrationModule, registrationNode, parentId = null }) {
|
|
541
|
+
const { output, pages } = inspection;
|
|
542
|
+
const module = await loadModule(inspection, file);
|
|
543
|
+
if (!module) {
|
|
544
|
+
warn(inspection, "PAGE_SOURCE_UNAVAILABLE", "A registered page is outside the indexed project source.", file);
|
|
545
|
+
return null;
|
|
546
|
+
}
|
|
547
|
+
const id = `page:${discovery.surface}:${file}`;
|
|
548
|
+
if (!pages.has(id)) {
|
|
549
|
+
const record = {
|
|
550
|
+
id,
|
|
551
|
+
title: pageTitle(file),
|
|
552
|
+
kind: "page",
|
|
553
|
+
surface: discovery.surface,
|
|
554
|
+
file,
|
|
555
|
+
parentId,
|
|
556
|
+
resolution: "resolved",
|
|
557
|
+
evidence: sourceEvidence(module)
|
|
558
|
+
};
|
|
559
|
+
pages.set(id, record);
|
|
560
|
+
output.pages.push(record);
|
|
561
|
+
} else if (pages.get(id).parentId !== parentId) {
|
|
562
|
+
pages.get(id).parentId = null;
|
|
563
|
+
warn(inspection, "PAGE_MULTIPLE_PARENTS", "A shared page has multiple registration contexts; registrations retain their identities.", file);
|
|
564
|
+
}
|
|
565
|
+
output.registrations.push({
|
|
566
|
+
id: `registration:${discovery.id}:${output.registrations.length}`,
|
|
567
|
+
pageId: id,
|
|
568
|
+
surface: discovery.surface,
|
|
569
|
+
identity,
|
|
570
|
+
resolution: "resolved",
|
|
571
|
+
evidence: sourceEvidence(registrationModule, registrationNode)
|
|
572
|
+
});
|
|
573
|
+
return id;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function isSupportedFilesystemOptions(options) {
|
|
577
|
+
if (!options || Object.keys(options).some((key) => !FILESYSTEM_ROUTER_OPTIONS.includes(key))) {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
return typeof options.routesFolder === "string" || Array.isArray(options.routesFolder);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// Literal `VueRouter(...)` options scanned by Vue Router's own page scanner,
|
|
584
|
+
// with declarations and watchers disabled. Returns the supported call count.
|
|
585
|
+
async function discoverFilesystemPages(inspection, discovery, module, registrationPath) {
|
|
586
|
+
const routerFactories = importedNames(module, SOURCES.filesystemRouter, "default");
|
|
587
|
+
const calls = module.nodes.filter((record) => isLocalCall(record, routerFactories));
|
|
588
|
+
let supported = 0;
|
|
589
|
+
for (const { node } of calls) {
|
|
590
|
+
const options = staticValue(node.arguments[0], module.bindings);
|
|
591
|
+
if (!isSupportedFilesystemOptions(options)) {
|
|
592
|
+
warn(inspection, "ROUTER_OPTIONS_UNSUPPORTED", "Filesystem routing needs literal supported options; runtime route extensions are not executed.", registrationPath);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
if (![options.routesFolder].flat().every(isCanonicalPath)) {
|
|
596
|
+
warn(inspection, "ROUTER_ROOT_INVALID", "Router page roots must be inside the project.", registrationPath);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
let tree;
|
|
600
|
+
const context = createRoutesContext(resolveOptions({
|
|
601
|
+
...options,
|
|
602
|
+
root: inspection.projectRoot,
|
|
603
|
+
dts: false,
|
|
604
|
+
logs: false,
|
|
605
|
+
beforeWriteFiles: (root) => {
|
|
606
|
+
tree = root;
|
|
160
607
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
608
|
+
}));
|
|
609
|
+
try {
|
|
610
|
+
await context.scanPages(false);
|
|
611
|
+
await recordFilesystemRoutes(inspection, { tree, discovery, module, node });
|
|
612
|
+
supported++;
|
|
613
|
+
} finally {
|
|
614
|
+
context.stopWatcher();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return supported;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function recordFilesystemRoutes(inspection, { tree, discovery, module, node }) {
|
|
621
|
+
const pending = [{ route: tree, parentId: null }];
|
|
622
|
+
while (pending.length) {
|
|
623
|
+
const { route, parentId } = pending.pop();
|
|
624
|
+
let owner = parentId;
|
|
625
|
+
for (const [view, absolute] of route.components) {
|
|
626
|
+
const file = path.relative(inspection.projectRoot, absolute).split(path.sep).join("/");
|
|
627
|
+
const identity = `${route.fullPath}${view === "default" ? "" : ` [${view}]`}`;
|
|
628
|
+
const pageId = await recordPage(inspection, { file, discovery, identity, registrationModule: module, registrationNode: node, parentId });
|
|
629
|
+
if (view === "default" && pageId) {
|
|
630
|
+
owner = pageId;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
for (const child of route.children) {
|
|
634
|
+
pending.push({ route: child, parentId: owner });
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// Literal `createRouter({ routes })` declarations, including local route
|
|
640
|
+
// arrays and literal child routes. Returns the supported call count.
|
|
641
|
+
async function discoverRegisteredPages(inspection, discovery, module, registrationPath) {
|
|
642
|
+
const routerFactories = importedNames(module, SOURCES.registeredRouter, "createRouter");
|
|
643
|
+
const calls = module.nodes.filter((record) => isLocalCall(record, routerFactories));
|
|
644
|
+
let supported = 0;
|
|
645
|
+
for (const { node } of calls) {
|
|
646
|
+
const routes = bindingNode(objectProperty(node.arguments[0], "routes"), module.bindings);
|
|
647
|
+
if (routes?.type !== "ArrayExpression") {
|
|
648
|
+
warn(inspection, "ROUTES_DYNAMIC", "Registered routes need a literal route array or its local binding.", registrationPath);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
await recordRegisteredRoutes(inspection, { routes, discovery, module, registrationPath });
|
|
652
|
+
supported++;
|
|
653
|
+
}
|
|
654
|
+
return supported;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function routeComponentSpecifier(module, component) {
|
|
658
|
+
if (component?.type === "Identifier") {
|
|
659
|
+
return module.imports.get(component.name)?.source || null;
|
|
660
|
+
}
|
|
661
|
+
if (component?.type === "ArrowFunctionExpression" && component.body.type === "ImportExpression") {
|
|
662
|
+
return staticValue(component.body.source, module.bindings);
|
|
663
|
+
}
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function recordRegisteredRoutes(inspection, { routes, discovery, module, registrationPath }) {
|
|
668
|
+
const pending = routes.elements.slice().reverse().map((route) => ({ route, prefix: "", parentId: null }));
|
|
669
|
+
while (pending.length) {
|
|
670
|
+
const { route, prefix, parentId } = pending.pop();
|
|
671
|
+
const routePath = staticValue(objectProperty(route, "path"), module.bindings);
|
|
672
|
+
if (typeof routePath !== "string") {
|
|
673
|
+
warn(inspection, "ROUTE_DYNAMIC", "A route path or spread cannot be resolved statically.", registrationPath);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const identity = routePath.startsWith("/") ? routePath : `${prefix}/${routePath}`.replace(/\/{2,}/gu, "/");
|
|
677
|
+
const component = bindingNode(objectProperty(route, "component"), module.bindings);
|
|
678
|
+
const file = resolveImport(inspection, module.path, routeComponentSpecifier(module, component));
|
|
679
|
+
let owner = parentId;
|
|
680
|
+
if (file) {
|
|
681
|
+
owner = await recordPage(inspection, { file, discovery, identity, registrationModule: module, registrationNode: route, parentId });
|
|
682
|
+
} else if (component) {
|
|
683
|
+
warn(inspection, "PAGE_COMPONENT_UNRESOLVED", "A registered route component is dynamic or outside the indexed project.", registrationPath);
|
|
684
|
+
}
|
|
685
|
+
const children = bindingNode(objectProperty(route, "children"), module.bindings);
|
|
686
|
+
if (children?.type === "ArrayExpression") {
|
|
687
|
+
pending.push(...children.elements.slice().reverse().map((child) => ({ route: child, prefix: identity, parentId: owner })));
|
|
688
|
+
} else if (children) {
|
|
689
|
+
warn(inspection, "ROUTES_DYNAMIC", "Child route registration is dynamic.", registrationPath);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
async function discoverPages(inspection, discoveries) {
|
|
204
695
|
let supported = 0;
|
|
205
696
|
for (const discovery of discoveries) {
|
|
206
|
-
if (!
|
|
697
|
+
if (!UI_ADAPTERS.includes(discovery.adapter)) {
|
|
698
|
+
warn(inspection, "UI_ADAPTER_UNAVAILABLE", `No Vue inspector is available for ${discovery.adapter}.`);
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
207
701
|
for (const registrationPath of discovery.registrations || []) {
|
|
208
|
-
const module = await
|
|
702
|
+
const module = await loadModule(inspection, registrationPath);
|
|
703
|
+
if (!module) {
|
|
704
|
+
warn(inspection, "REGISTRATION_UNAVAILABLE", "A routing input is not in the source inventory.", registrationPath);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
209
707
|
if (discovery.mode === "filesystem") {
|
|
210
|
-
|
|
211
|
-
const calls = module.nodes.filter(record => record.node.type === "CallExpression" && record.node.callee.type === "Identifier" && imports.has(record.node.callee.name) && unshadowed(record, record.node.callee.name));
|
|
212
|
-
for (const { node } of calls) {
|
|
213
|
-
const options = staticValue(node.arguments[0], module.bindings);
|
|
214
|
-
if (!options || Object.keys(options).some(key => !["routesFolder", "extensions", "exclude", "dts", "nestedChildren"].includes(key)) || (typeof options.routesFolder !== "string" && !Array.isArray(options.routesFolder))) { warn("ROUTER_OPTIONS_UNSUPPORTED", "Filesystem routing needs literal supported options; runtime route extensions are not executed.", registrationPath); continue; }
|
|
215
|
-
const folders = [options.routesFolder].flat();
|
|
216
|
-
if (!folders.every(canonical)) { warn("ROUTER_ROOT_INVALID", "Router page roots must be inside the project.", registrationPath); continue; }
|
|
217
|
-
let tree;
|
|
218
|
-
const context = createRoutesContext(resolveOptions({ ...options, root: projectRoot, dts: false, logs: false, beforeWriteFiles: root => { tree = root; } }));
|
|
219
|
-
try {
|
|
220
|
-
await context.scanPages(false);
|
|
221
|
-
const pending = [{ route: tree, parentId: null }];
|
|
222
|
-
while (pending.length) {
|
|
223
|
-
const { route, parentId } = pending.pop(); let owner = parentId;
|
|
224
|
-
for (const [view, absolute] of route.components) {
|
|
225
|
-
const file = path.relative(projectRoot, absolute).split(path.sep).join("/");
|
|
226
|
-
const definition = await page(file, discovery, `${route.fullPath}${view === "default" ? "" : ` [${view}]`}`, module, node, parentId);
|
|
227
|
-
if (view === "default" && definition) owner = definition;
|
|
228
|
-
}
|
|
229
|
-
for (const child of route.children) pending.push({ route: child, parentId: owner });
|
|
230
|
-
}
|
|
231
|
-
supported++;
|
|
232
|
-
} finally { context.stopWatcher(); }
|
|
233
|
-
}
|
|
708
|
+
supported += await discoverFilesystemPages(inspection, discovery, module, registrationPath);
|
|
234
709
|
} else {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
710
|
+
supported += await discoverRegisteredPages(inspection, discovery, module, registrationPath);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
const { output } = inspection;
|
|
715
|
+
if (supported) {
|
|
716
|
+
output.capabilities.pages = output.diagnostics.length ? "partial" : "complete";
|
|
717
|
+
} else {
|
|
718
|
+
output.capabilities.pages = discoveries.length ? "unsupported" : "unconfigured";
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Element tags and simple event-handler names of one component template.
|
|
723
|
+
function readTemplate(module) {
|
|
724
|
+
const eventHandlers = new Set();
|
|
725
|
+
const tags = [];
|
|
726
|
+
const pending = [module.descriptor.template.ast];
|
|
727
|
+
while (pending.length) {
|
|
728
|
+
const node = pending.pop();
|
|
729
|
+
if (!node) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (node.type === 1) {
|
|
733
|
+
tags.push(node);
|
|
734
|
+
for (const attribute of node.props || []) {
|
|
735
|
+
const handler = attribute.exp?.content || "";
|
|
736
|
+
if (attribute.type === 7 && attribute.name === "on" && IDENTIFIER_PATTERN.test(handler)) {
|
|
737
|
+
eventHandlers.add(handler);
|
|
257
738
|
}
|
|
258
739
|
}
|
|
259
740
|
}
|
|
741
|
+
pending.push(...(node.children || []));
|
|
260
742
|
}
|
|
261
|
-
|
|
262
|
-
|
|
743
|
+
return { eventHandlers, tags };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Walks page templates into the project components they actually use.
|
|
747
|
+
async function discoverComponents(inspection) {
|
|
748
|
+
const pending = inspection.output.pages.map((record) => ({ file: record.file, parentId: record.id }));
|
|
263
749
|
const inspected = new Set();
|
|
264
|
-
while (
|
|
265
|
-
const { file, parentId } =
|
|
266
|
-
|
|
267
|
-
inspected.
|
|
268
|
-
|
|
269
|
-
const templateNodes = [module.descriptor.template.ast];
|
|
270
|
-
while (templateNodes.length) {
|
|
271
|
-
const node = templateNodes.pop(); if (!node) continue;
|
|
272
|
-
if (node.type === 1) {
|
|
273
|
-
tags.push(node);
|
|
274
|
-
for (const attribute of node.props || []) if (attribute.type === 7 && attribute.name === "on" && /^[A-Za-z_$][\w$]*$/u.test(attribute.exp?.content || "")) eventHandlers.add(attribute.exp.content);
|
|
275
|
-
}
|
|
276
|
-
templateNodes.push(...(node.children || []));
|
|
750
|
+
while (pending.length) {
|
|
751
|
+
const { file, parentId } = pending.pop();
|
|
752
|
+
const module = await loadModule(inspection, file);
|
|
753
|
+
if (!module?.descriptor?.template || inspected.has(parentId)) {
|
|
754
|
+
continue;
|
|
277
755
|
}
|
|
756
|
+
inspected.add(parentId);
|
|
757
|
+
const { eventHandlers, tags } = readTemplate(module);
|
|
278
758
|
for (const tag of tags) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (!targetModule) { warn("COMPONENT_SOURCE_UNSUPPORTED", "An imported asset needs its own component adapter.", target); continue; }
|
|
288
|
-
if (!components.has(id)) { definition = { id, title: path.posix.basename(target, path.posix.extname(target)), file: target, resolution: "resolved", evidence: evidence(targetModule) }; components.set(id, definition); output.components.push(definition); }
|
|
289
|
-
definition = components.get(id); pendingComponents.push({ file: target, parentId: id });
|
|
290
|
-
} else if (imported) {
|
|
291
|
-
const dependency = packageImport(file, imported.source);
|
|
292
|
-
if (dependency && input.packageIdentity) {
|
|
293
|
-
const id = `component:package:${JSON.stringify([dependency.record.id, dependency.importPath, imported.imported])}`;
|
|
294
|
-
if (!components.has(id)) {
|
|
295
|
-
definition = { id, title: imported.imported === 'default' ? tag.tag : imported.imported, file: null, origin: { kind: 'package', packageId: dependency.record.id, importPath: dependency.importPath, symbol: imported.imported }, resolution: 'resolved', evidence: evidence(module, imported.node, module.nodes.find(record => record.node === imported.node)?.offset || 0) };
|
|
296
|
-
components.set(id, definition); output.components.push(definition);
|
|
297
|
-
}
|
|
298
|
-
definition = components.get(id);
|
|
299
|
-
}
|
|
759
|
+
await recordComponentUse(inspection, { module, file, parentId, tag, pending });
|
|
760
|
+
}
|
|
761
|
+
if (!inspection.httpScope) {
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (inspection.pages.has(parentId)) {
|
|
765
|
+
for (const tag of tags) {
|
|
766
|
+
await recordCrudScreenUse(inspection, { module, file, parentId, tag });
|
|
300
767
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
768
|
+
}
|
|
769
|
+
recordFetchUses(inspection, { module, parentId, eventHandlers });
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function recordComponentUse(inspection, { module, file, parentId, tag, pending }) {
|
|
774
|
+
const { components, output } = inspection;
|
|
775
|
+
const local = templateTagImport(module, tag.tag);
|
|
776
|
+
if (!local && !/^[A-Z]|-/u.test(tag.tag) && tag.tag !== "component") {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
if (!local && tag.tag.startsWith("v-")) {
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
const imported = module.imports.get(local);
|
|
783
|
+
const target = resolveImport(inspection, file, imported?.source);
|
|
784
|
+
let definition;
|
|
785
|
+
if (target) {
|
|
786
|
+
const targetModule = await loadModule(inspection, target);
|
|
787
|
+
if (!targetModule) {
|
|
788
|
+
warn(inspection, "COMPONENT_SOURCE_UNSUPPORTED", "An imported asset needs its own component adapter.", target);
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
const id = `component:${target}`;
|
|
792
|
+
if (!components.has(id)) {
|
|
793
|
+
const record = {
|
|
794
|
+
id,
|
|
795
|
+
title: path.posix.basename(target, path.posix.extname(target)),
|
|
796
|
+
file: target,
|
|
797
|
+
resolution: "resolved",
|
|
798
|
+
evidence: sourceEvidence(targetModule)
|
|
799
|
+
};
|
|
800
|
+
components.set(id, record);
|
|
801
|
+
output.components.push(record);
|
|
802
|
+
}
|
|
803
|
+
definition = components.get(id);
|
|
804
|
+
pending.push({ file: target, parentId: id });
|
|
805
|
+
} else if (imported) {
|
|
806
|
+
definition = recordPackageComponent(inspection, { module, file, tag, imported });
|
|
807
|
+
}
|
|
808
|
+
const range = { start: tag.loc.start.offset, end: tag.loc.end.offset };
|
|
809
|
+
output.componentUses.push({
|
|
810
|
+
id: `component-use:${parentId}:${tag.loc.start.offset}`,
|
|
811
|
+
parentId,
|
|
812
|
+
...(definition ? { componentId: definition.id } : {}),
|
|
813
|
+
title: tag.tag,
|
|
814
|
+
resolution: definition ? "resolved" : "unresolved",
|
|
815
|
+
evidence: sourceEvidence(module, range)
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// A component imported from a declared package keeps its package boundary as
|
|
820
|
+
// owner; its implementation files are not read.
|
|
821
|
+
function recordPackageComponent(inspection, { module, file, tag, imported }) {
|
|
822
|
+
const { components, input, output } = inspection;
|
|
823
|
+
const dependency = resolvePackageImport(inspection, file, imported.source);
|
|
824
|
+
if (!dependency || !input.packageIdentity) {
|
|
825
|
+
return undefined;
|
|
826
|
+
}
|
|
827
|
+
const id = `component:package:${JSON.stringify([dependency.record.id, dependency.importPath, imported.imported])}`;
|
|
828
|
+
if (!components.has(id)) {
|
|
829
|
+
const importOffset = findRecord(module, imported.node)?.offset || 0;
|
|
830
|
+
const record = {
|
|
831
|
+
id,
|
|
832
|
+
title: imported.imported === "default" ? tag.tag : imported.imported,
|
|
833
|
+
file: null,
|
|
834
|
+
origin: { kind: "package", packageId: dependency.record.id, importPath: dependency.importPath, symbol: imported.imported },
|
|
835
|
+
resolution: "resolved",
|
|
836
|
+
evidence: sourceEvidence(module, imported.node, importOffset)
|
|
837
|
+
};
|
|
838
|
+
components.set(id, record);
|
|
839
|
+
output.components.push(record);
|
|
840
|
+
}
|
|
841
|
+
return components.get(id);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function crudScreenKind(component) {
|
|
845
|
+
if (component?.source === SOURCES.crudListScreen && ["default", "CrudListScreen"].includes(component.imported)) {
|
|
846
|
+
return "list";
|
|
847
|
+
}
|
|
848
|
+
if (component?.source === SOURCES.crudAddEditScreen && ["default", "CrudAddEditScreen"].includes(component.imported)) {
|
|
849
|
+
return "edit";
|
|
850
|
+
}
|
|
851
|
+
return "";
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// The setup-level `useCrudListScreen` / `useCrudAddEditScreen` call bound to a
|
|
855
|
+
// screen tag's `:screen`, or null.
|
|
856
|
+
function crudScreenCall(module, tag, list) {
|
|
857
|
+
const screen = tag.props?.find((entry) => entry.type === 7 && entry.name === "bind" && entry.arg?.content === "screen")?.exp?.content;
|
|
858
|
+
if (!IDENTIFIER_PATTERN.test(screen || "")) {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
const call = bindingNode(module.bindings.get(screen), module.bindings);
|
|
862
|
+
if (call?.type !== "CallExpression" || call.callee.type !== "Identifier") {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
const helper = module.imports.get(call.callee.name);
|
|
866
|
+
const expected = list ? "useCrudListScreen" : "useCrudAddEditScreen";
|
|
867
|
+
if (!helper || helper.source !== `${SOURCES.crudComposables}/${expected}` || helper.imported !== expected) {
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
const record = findRecord(module, call);
|
|
871
|
+
if (!record?.setup || record.ancestors.some(isFunctionNode) || isShadowed(record, call.callee.name)) {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
return { call, record };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function hasDynamicScreenTransport(module, config) {
|
|
878
|
+
const dynamicKey = CRUD_SCREEN_TRANSPORT_KEYS.some((key) =>
|
|
879
|
+
objectProperty(config, key) && staticValue(objectProperty(config, key), module.bindings) !== null
|
|
880
|
+
);
|
|
881
|
+
return dynamicKey || config?.properties.some((entry) => !isStaticObjectProperty(entry));
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function hasCustomScreenClient(options) {
|
|
885
|
+
if (!options) {
|
|
886
|
+
return false;
|
|
887
|
+
}
|
|
888
|
+
if (options.type !== "ObjectExpression") {
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
return options.properties.some((entry) =>
|
|
892
|
+
!isStaticObjectProperty(entry) || CRUD_SCREEN_CLIENT_KEYS.includes(propertyKey(entry))
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// A used JSKIT CRUD screen whose resource, operation and API suffix are
|
|
897
|
+
// literal becomes a `jskit.crud` operation use.
|
|
898
|
+
async function recordCrudScreenUse(inspection, { module, file, parentId, tag }) {
|
|
899
|
+
const kind = crudScreenKind(module.imports.get(templateTagImport(module, tag.tag)));
|
|
900
|
+
if (!kind) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const list = kind === "list";
|
|
904
|
+
const screenCall = crudScreenCall(module, tag, list);
|
|
905
|
+
if (!screenCall) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const { call, record } = screenCall;
|
|
909
|
+
const config = bindingNode(call.arguments[0], module.bindings);
|
|
910
|
+
const resource = await resolveCrudResource(inspection, module, objectProperty(config, "resource"));
|
|
911
|
+
const operation = list ? "list" : staticValue(objectProperty(config, "operationName"), module.bindings);
|
|
912
|
+
const options = bindingNode(objectProperty(config, list ? "listOptions" : "addEditOptions"), module.bindings);
|
|
913
|
+
const apiSuffix = staticValue(objectProperty(list ? config : options, "apiSuffix"), module.bindings);
|
|
914
|
+
const enabled = objectProperty(config, "readEnabled");
|
|
915
|
+
if (list && enabled && staticValue(enabled, module.bindings) === false) {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const literalSuffix = typeof apiSuffix === "string" && apiSuffix.startsWith("/") && !apiSuffix.includes("?");
|
|
919
|
+
const literalTransport = !hasDynamicScreenTransport(module, config) && !hasCustomScreenClient(options);
|
|
920
|
+
const literalEnabled = !enabled || staticValue(enabled, module.bindings) === true;
|
|
921
|
+
const resolved = Boolean(resource && resource.operations.includes(operation) && literalSuffix && literalTransport && literalEnabled);
|
|
922
|
+
if (!resolved) {
|
|
923
|
+
warn(inspection, "CRUD_SCREEN_UNRESOLVED", "A used CRUD screen has dynamic resource, operation or transport configuration.", file);
|
|
924
|
+
}
|
|
925
|
+
const surface = inspection.pages.get(parentId).surface;
|
|
926
|
+
inspection.output.operationUses.push({
|
|
927
|
+
id: `operation-use:${parentId}:crud:${tag.loc.start.offset}`,
|
|
928
|
+
parentId,
|
|
929
|
+
resolution: resolved ? "resolved" : "unresolved",
|
|
930
|
+
...(resolved ? { key: crudOperationKey(inspection, surface, resource.namespace, operation, apiSuffix) } : {}),
|
|
931
|
+
evidence: [...sourceEvidence(module, call, record.offset), ...(resource?.evidence || [])]
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function isGlobalFetchCall(module, { node, ancestors }) {
|
|
936
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "fetch") {
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
return !module.bindings.has("fetch") && !module.imports.has("fetch") && !isShadowed({ ancestors }, "fetch");
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Global `fetch` calls made during setup or from a template event handler.
|
|
943
|
+
function recordFetchUses(inspection, { module, parentId, eventHandlers }) {
|
|
944
|
+
for (const { node, ancestors, offset, setup } of module.nodes) {
|
|
945
|
+
if (!isGlobalFetchCall(module, { node, ancestors })) {
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const enclosingFunction = ancestors.slice().reverse().find(isFunctionNode);
|
|
949
|
+
const enclosingDeclarator = ancestors.slice().reverse().find((entry) => entry.type === "VariableDeclarator");
|
|
950
|
+
const declaration = enclosingFunction?.id?.name || enclosingDeclarator?.id?.name;
|
|
951
|
+
if (enclosingFunction ? !eventHandlers.has(declaration) : !setup) {
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
const operation = staticValue(node.arguments[0], module.bindings);
|
|
955
|
+
const method = staticValue(objectProperty(node.arguments[1], "method"), module.bindings) || "GET";
|
|
956
|
+
const resolved = typeof operation === "string" && operation.startsWith("/") && !operation.includes("?") && typeof method === "string";
|
|
957
|
+
inspection.output.operationUses.push({
|
|
958
|
+
id: `operation-use:${parentId}:${offset + node.start}`,
|
|
959
|
+
parentId,
|
|
960
|
+
resolution: resolved ? "resolved" : "unresolved",
|
|
961
|
+
...(resolved ? { key: { protocol: "http", scope: inspection.httpScope, method: method.toUpperCase(), operation } } : {}),
|
|
962
|
+
evidence: sourceEvidence(module, node, offset)
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// Inspects the actual low-level JSKIT HTTP capability in a defineFeature setup
|
|
968
|
+
// and exported CRUD controller declarations. App-owned wrappers and dispatchers
|
|
969
|
+
// need their own inspector rather than a guessed match on a method name.
|
|
970
|
+
async function discoverEntryOperations(inspection) {
|
|
971
|
+
for (const file of inspection.files.keys()) {
|
|
972
|
+
if (!SERVER_FILE_PATTERN.test(file)) {
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
const module = await loadModule(inspection, file);
|
|
348
976
|
for (const record of module.nodes) {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
977
|
+
await recordCrudController(inspection, { module, file, record });
|
|
978
|
+
}
|
|
979
|
+
const featureFactories = importedNames(module, SOURCES.kernelFeatures, "defineFeature");
|
|
980
|
+
if (!featureFactories.size) {
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
for (const record of module.nodes) {
|
|
984
|
+
recordFeatureRoute(inspection, { module, file, record, featureFactories });
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function crudControllerOperations(module, config, resource) {
|
|
990
|
+
const declared = objectProperty(config, "operations");
|
|
991
|
+
if (declared) {
|
|
992
|
+
return staticValue(declared, module.bindings);
|
|
993
|
+
}
|
|
994
|
+
return resource?.operations.map((name) => (name === "patch" ? "update" : name));
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async function recordCrudController(inspection, { module, file, record }) {
|
|
998
|
+
const { node, offset } = record;
|
|
999
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || isShadowed(record, node.callee.name)) {
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
const factory = module.imports.get(node.callee.name);
|
|
1003
|
+
if (factory?.source !== SOURCES.crudJsonApiFeature || factory.imported !== "defineCrudJsonApiFeature") {
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
if (record.ancestors.some(isFunctionNode)) {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
const config = bindingNode(node.arguments[0], module.bindings);
|
|
1010
|
+
if (config?.type !== "ObjectExpression" || !hasOnlyStaticProperties(config)) {
|
|
1011
|
+
warn(inspection, "CRUD_CONTROLLER_UNRESOLVED", "A CRUD controller declaration is dynamic.", file);
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const resource = await resolveCrudResource(inspection, module, objectProperty(config, "resource"));
|
|
1015
|
+
const surface = staticValue(objectProperty(config, "surface"), module.bindings);
|
|
1016
|
+
const routes = objectProperty(config, "routes");
|
|
1017
|
+
if (routes && staticValue(routes, module.bindings) === false) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const relativePath = objectProperty(config, "relativePath");
|
|
1021
|
+
const apiSuffix = relativePath ? staticValue(relativePath, module.bindings) : resource && `/${resource.namespace.replace(/_/gu, "-")}`;
|
|
1022
|
+
const operations = crudControllerOperations(module, config, resource);
|
|
1023
|
+
const literalSuffix = typeof apiSuffix === "string" && apiSuffix.startsWith("/");
|
|
1024
|
+
const literalSurface = typeof surface === "string" && Boolean(surface.trim());
|
|
1025
|
+
const routesEnabled = !routes || staticValue(routes, module.bindings) === true;
|
|
1026
|
+
const knownOperations = Array.isArray(operations) && operations.length > 0 && operations.every((name) => CRUD_CONTROLLER_OPERATIONS.includes(name));
|
|
1027
|
+
if (!resource || !literalSuffix || !literalSurface || !routesEnabled || !knownOperations) {
|
|
1028
|
+
warn(inspection, "CRUD_CONTROLLER_UNRESOLVED", "A CRUD controller needs literal resource, surface and enabled operations.", file);
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const declaration = record.ancestors.find((entry) => entry.type === "VariableDeclarator" && entry.id.type === "Identifier");
|
|
1032
|
+
if (!declaration || !module.exports.has(declaration.id.name)) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
for (const operation of new Set(operations)) {
|
|
1036
|
+
inspection.output.entryOperations.push({
|
|
1037
|
+
id: `entry:crud:${file}:${node.start}:${operation}`,
|
|
1038
|
+
key: crudOperationKey(inspection, surface.trim().toLowerCase(), resource.namespace, operation, apiSuffix),
|
|
1039
|
+
controllerFile: file,
|
|
1040
|
+
resolution: "resolved",
|
|
1041
|
+
evidence: [...sourceEvidence(module, node, offset), ...resource.evidence]
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// `http.router.register(method, path, options, handler)` inside a
|
|
1047
|
+
// `defineFeature` setup that receives `http`.
|
|
1048
|
+
function recordFeatureRoute(inspection, { module, file, record, featureFactories }) {
|
|
1049
|
+
const { node, ancestors, offset } = record;
|
|
1050
|
+
if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.property.name !== "register") {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const router = node.callee.object;
|
|
1054
|
+
if (router.type !== "MemberExpression" || router.computed || router.property.name !== "router" || router.object.type !== "Identifier") {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const feature = ancestors.find((entry) => entry.type === "CallExpression" && entry.callee.type === "Identifier" && featureFactories.has(entry.callee.name));
|
|
1058
|
+
const setup = ancestors.slice().reverse().find((entry) => entry.type === "ObjectMethod" && entry.key.name === "setup");
|
|
1059
|
+
const setupParameter = setup?.params[0];
|
|
1060
|
+
const dependency = setupParameter?.type === "ObjectPattern" &&
|
|
1061
|
+
setupParameter.properties.find((entry) => entry.type === "ObjectProperty" && entry.key.name === "http");
|
|
1062
|
+
if (!feature || dependency?.value?.name !== router.object.name) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const method = staticValue(node.arguments[0], module.bindings);
|
|
1066
|
+
const operation = staticValue(node.arguments[1], module.bindings);
|
|
1067
|
+
const handler = node.arguments[3];
|
|
1068
|
+
if (typeof method !== "string" || typeof operation !== "string" || !handler || !isFunctionNode(handler)) {
|
|
1069
|
+
warn(inspection, "CONTROLLER_UNRESOLVED", "A route's application handler or identity is dynamic.", file);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
inspection.output.entryOperations.push({
|
|
1073
|
+
id: `entry:${inspection.httpScope}:${file}:${node.start}`,
|
|
1074
|
+
key: { protocol: "http", scope: inspection.httpScope, method: method.toUpperCase(), operation: `${inspection.apiBasePath}${operation}` },
|
|
1075
|
+
controllerFile: file,
|
|
1076
|
+
resolution: "resolved",
|
|
1077
|
+
evidence: sourceEvidence(module, node, offset)
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function importSpecifierName(specifier) {
|
|
1082
|
+
if (specifier.type === "ImportNamespaceSpecifier") {
|
|
1083
|
+
return "*";
|
|
1084
|
+
}
|
|
1085
|
+
if (specifier.type === "ImportDefaultSpecifier") {
|
|
1086
|
+
return "default";
|
|
1087
|
+
}
|
|
1088
|
+
return specifier.imported.name || specifier.imported.value;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function isTypeOnlyImport(node) {
|
|
1092
|
+
if (node.importKind === "type") {
|
|
1093
|
+
return true;
|
|
1094
|
+
}
|
|
1095
|
+
return Boolean(node.specifiers.length && node.specifiers.every((specifier) => specifier.importKind === "type"));
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// Source imports resolved with the same resolver as UI discovery. These are
|
|
1099
|
+
// imported names, not proof that an operation executes. Opaque packages stay
|
|
1100
|
+
// with package discovery; no dependency tree is read.
|
|
1101
|
+
async function collectImports(inspection) {
|
|
1102
|
+
for (const file of inspection.files.keys()) {
|
|
1103
|
+
if (!SCRIPT_FILE_PATTERN.test(file)) {
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const module = await loadModule(inspection, file);
|
|
388
1107
|
for (const { node, offset } of module.nodes) {
|
|
389
|
-
if (node.type !==
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1108
|
+
if (node.type !== "ImportDeclaration") {
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
const target = resolveImport(inspection, file, node.source.value);
|
|
1112
|
+
if (!target) {
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
await loadModule(inspection, target);
|
|
1116
|
+
inspection.output.imports.push({
|
|
1117
|
+
id: `import:${file}:${offset + node.start}`,
|
|
1118
|
+
fromFile: file,
|
|
1119
|
+
targetFile: target,
|
|
1120
|
+
specifier: node.source.value,
|
|
1121
|
+
names: node.specifiers.map(importSpecifierName),
|
|
1122
|
+
typeOnly: isTypeOnlyImport(node),
|
|
1123
|
+
resolution: "resolved",
|
|
1124
|
+
evidence: sourceEvidence(module, node, offset)
|
|
1125
|
+
});
|
|
397
1126
|
}
|
|
398
1127
|
}
|
|
399
|
-
return output;
|
|
400
1128
|
}
|
|
1129
|
+
|
|
1130
|
+
// Standalone, finite static inspection. No application import, watcher, server,
|
|
1131
|
+
// Genesis dependency or runtime interpretation is needed to emit neutral JSON.
|
|
1132
|
+
async function inspectVueArchitecture({ projectRoot, input, aliases = {}, httpScope = "", apiBasePath = "" }) {
|
|
1133
|
+
const { files, packages } = validateInput({ projectRoot, input, aliases });
|
|
1134
|
+
const inspection = {
|
|
1135
|
+
projectRoot,
|
|
1136
|
+
input,
|
|
1137
|
+
files,
|
|
1138
|
+
packages,
|
|
1139
|
+
aliases,
|
|
1140
|
+
httpScope,
|
|
1141
|
+
apiBasePath,
|
|
1142
|
+
output: createOutput({ input, httpScope }),
|
|
1143
|
+
modules: new Map(),
|
|
1144
|
+
components: new Map(),
|
|
1145
|
+
pages: new Map()
|
|
1146
|
+
};
|
|
1147
|
+
await discoverPages(inspection, input.uiDiscovery?.discoveries || []);
|
|
1148
|
+
await discoverComponents(inspection);
|
|
1149
|
+
if (httpScope) {
|
|
1150
|
+
await discoverEntryOperations(inspection);
|
|
1151
|
+
}
|
|
1152
|
+
await collectImports(inspection);
|
|
1153
|
+
return inspection.output;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
export { inspectVueArchitecture };
|