@jskit-ai/architecture-inspector 0.1.0 → 0.1.2
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 +135 -13
- package/package.json +4 -2
- package/src/calls/cache.js +97 -0
- package/src/calls/evaluate.js +474 -0
- package/src/calls/external.js +123 -0
- package/src/calls/features.js +371 -0
- package/src/calls/functions.js +277 -0
- package/src/calls/index.js +343 -0
- package/src/calls/program.js +143 -0
- package/src/calls/resolve.js +658 -0
- package/src/calls/symbols.js +201 -0
- package/src/calls/transports.js +194 -0
- package/src/calls/types.js +9 -0
- package/src/cli.js +93 -14
- package/src/inspectVueArchitecture.js +146 -1035
- package/src/previewLocator.js +30 -0
- package/src/vue/modules.js +293 -0
- package/src/vue/pages.js +551 -0
- package/src/vue/routes.js +589 -0
- package/src/vue/syntax.js +222 -0
package/src/vue/pages.js
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { createRoutesContext, resolveOptions } from "vue-router/unplugin";
|
|
3
|
+
import { MAX_RESOURCE_HOPS, SOURCES, loadModule, resolveImport, resolvePackageImport, warn } from "./modules.js";
|
|
4
|
+
import {
|
|
5
|
+
bindingNode,
|
|
6
|
+
findRecord,
|
|
7
|
+
hasOnlyStaticProperties,
|
|
8
|
+
importedNames,
|
|
9
|
+
isCanonicalPath,
|
|
10
|
+
isFunctionNode,
|
|
11
|
+
isLocalCall,
|
|
12
|
+
isShadowed,
|
|
13
|
+
isStaticObjectProperty,
|
|
14
|
+
objectProperty,
|
|
15
|
+
propertyKey,
|
|
16
|
+
sourceEvidence,
|
|
17
|
+
staticValue,
|
|
18
|
+
templateTagImport
|
|
19
|
+
} from "./syntax.js";
|
|
20
|
+
|
|
21
|
+
// Pages, components and the HTTP calls UI code makes: file-system and
|
|
22
|
+
// registered routes, component use, CRUD screens and direct fetch calls.
|
|
23
|
+
|
|
24
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/u;
|
|
25
|
+
const UI_ADAPTERS = Object.freeze(["jskit", "vue-filesystem-router", "vue-registered-router"]);
|
|
26
|
+
const FILESYSTEM_ROUTER_OPTIONS = Object.freeze(["routesFolder", "extensions", "exclude", "dts", "nestedChildren"]);
|
|
27
|
+
const CRUD_RESOURCE_OPERATIONS = Object.freeze(["list", "view", "create", "patch", "delete"]);
|
|
28
|
+
const CRUD_SCREEN_TRANSPORT_KEYS = Object.freeze(["screen", "client", "surfaceId", "transport"]);
|
|
29
|
+
const CRUD_SCREEN_CLIENT_KEYS = Object.freeze(["client", "surfaceId", "transport", "resource"]);
|
|
30
|
+
|
|
31
|
+
function pageTitle(file) {
|
|
32
|
+
const name = path.posix.basename(file, path.posix.extname(file));
|
|
33
|
+
return name === "index" ? path.posix.basename(path.posix.dirname(file)) : name;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Follows an expression through local bindings, imports and re-exports to a
|
|
37
|
+
// literal defineCrudResource declaration.
|
|
38
|
+
async function resolveCrudResource(inspection, module, node, seen = new Set()) {
|
|
39
|
+
if (!node || seen.size >= MAX_RESOURCE_HOPS) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (node.type === "Identifier") {
|
|
43
|
+
const identity = `${module.path}:${node.name}`;
|
|
44
|
+
if (seen.has(identity)) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const next = new Set([...seen, identity]);
|
|
48
|
+
const imported = module.imports.get(node.name);
|
|
49
|
+
if (!imported) {
|
|
50
|
+
return resolveCrudResource(inspection, module, module.bindings.get(node.name), next);
|
|
51
|
+
}
|
|
52
|
+
const target = resolveImport(inspection, module.path, imported.source);
|
|
53
|
+
const owner = target && await loadModule(inspection, target);
|
|
54
|
+
return owner ? resolveExportedCrudResource(inspection, owner, imported.imported, next) : null;
|
|
55
|
+
}
|
|
56
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier") {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
const factory = module.imports.get(node.callee.name);
|
|
60
|
+
const record = findRecord(module, node);
|
|
61
|
+
const isFactory = factory?.source === SOURCES.crudResource && factory.imported === "defineCrudResource";
|
|
62
|
+
if (!isFactory || !record || isShadowed(record, node.callee.name)) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const config = bindingNode(node.arguments[0], module.bindings);
|
|
66
|
+
if (config?.type !== "ObjectExpression" || !hasOnlyStaticProperties(config)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const namespace = staticValue(objectProperty(config, "namespace"), module.bindings);
|
|
70
|
+
const declaredOperations = objectProperty(config, "crudOperations");
|
|
71
|
+
const operations = declaredOperations ? staticValue(declaredOperations, module.bindings) : [...CRUD_RESOURCE_OPERATIONS];
|
|
72
|
+
if (typeof namespace !== "string" || !namespace.trim()) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(operations) || !operations.length || !operations.every((name) => CRUD_RESOURCE_OPERATIONS.includes(name))) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
if (objectProperty(config, "operations")) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
return { namespace: namespace.trim(), operations, evidence: sourceEvidence(module, node, record.offset) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function resolveExportedCrudResource(inspection, module, name, seen) {
|
|
85
|
+
const entry = module.exports.get(name);
|
|
86
|
+
if (!entry) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (!entry.source) {
|
|
90
|
+
return resolveCrudResource(inspection, module, { type: "Identifier", name: entry.local }, seen);
|
|
91
|
+
}
|
|
92
|
+
const identity = `${module.path}:export:${name}`;
|
|
93
|
+
if (seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const target = resolveImport(inspection, module.path, entry.source);
|
|
97
|
+
const owner = target && await loadModule(inspection, target);
|
|
98
|
+
return owner ? resolveExportedCrudResource(inspection, owner, entry.local, new Set([...seen, identity])) : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function crudOperationKey(inspection, surface, namespace, operation, apiSuffix) {
|
|
102
|
+
return {
|
|
103
|
+
protocol: "jskit.crud",
|
|
104
|
+
scope: JSON.stringify([inspection.httpScope, surface, apiSuffix]),
|
|
105
|
+
operation: `${namespace}:${operation === "update" ? "patch" : operation}`
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Records one page per surface and file, and one registration per route that
|
|
110
|
+
// renders it.
|
|
111
|
+
async function recordPage(inspection, { file, discovery, identity, registrationModule, registrationNode, parentId = null }) {
|
|
112
|
+
const { output, pages } = inspection;
|
|
113
|
+
const module = await loadModule(inspection, file);
|
|
114
|
+
if (!module) {
|
|
115
|
+
warn(inspection, "PAGE_SOURCE_UNAVAILABLE", "A registered page is outside the indexed project source.", file);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const id = `page:${discovery.surface}:${file}`;
|
|
119
|
+
if (!pages.has(id)) {
|
|
120
|
+
const record = {
|
|
121
|
+
id,
|
|
122
|
+
title: pageTitle(file),
|
|
123
|
+
kind: "page",
|
|
124
|
+
surface: discovery.surface,
|
|
125
|
+
file,
|
|
126
|
+
parentId,
|
|
127
|
+
resolution: "resolved",
|
|
128
|
+
evidence: sourceEvidence(module)
|
|
129
|
+
};
|
|
130
|
+
pages.set(id, record);
|
|
131
|
+
output.pages.push(record);
|
|
132
|
+
} else if (pages.get(id).parentId !== parentId) {
|
|
133
|
+
pages.get(id).parentId = null;
|
|
134
|
+
warn(inspection, "PAGE_MULTIPLE_PARENTS", "A shared page has multiple registration contexts; registrations retain their identities.", file);
|
|
135
|
+
}
|
|
136
|
+
output.registrations.push({
|
|
137
|
+
id: `registration:${discovery.id}:${output.registrations.length}`,
|
|
138
|
+
pageId: id,
|
|
139
|
+
surface: discovery.surface,
|
|
140
|
+
identity,
|
|
141
|
+
resolution: "resolved",
|
|
142
|
+
evidence: sourceEvidence(registrationModule, registrationNode)
|
|
143
|
+
});
|
|
144
|
+
return id;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isSupportedFilesystemOptions(options) {
|
|
148
|
+
if (!options || Object.keys(options).some((key) => !FILESYSTEM_ROUTER_OPTIONS.includes(key))) {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
return typeof options.routesFolder === "string" || Array.isArray(options.routesFolder);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Literal `VueRouter(...)` options scanned by Vue Router's own page scanner,
|
|
155
|
+
// with declarations and watchers disabled. Returns the supported call count.
|
|
156
|
+
async function discoverFilesystemPages(inspection, discovery, module, registrationPath) {
|
|
157
|
+
const routerFactories = importedNames(module, SOURCES.filesystemRouter, "default");
|
|
158
|
+
const calls = module.nodes.filter((record) => isLocalCall(record, routerFactories));
|
|
159
|
+
let supported = 0;
|
|
160
|
+
for (const { node } of calls) {
|
|
161
|
+
const options = staticValue(node.arguments[0], module.bindings);
|
|
162
|
+
if (!isSupportedFilesystemOptions(options)) {
|
|
163
|
+
warn(inspection, "ROUTER_OPTIONS_UNSUPPORTED", "Filesystem routing needs literal supported options; runtime route extensions are not executed.", registrationPath);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (![options.routesFolder].flat().every(isCanonicalPath)) {
|
|
167
|
+
warn(inspection, "ROUTER_ROOT_INVALID", "Router page roots must be inside the project.", registrationPath);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
let tree;
|
|
171
|
+
const context = createRoutesContext(resolveOptions({
|
|
172
|
+
...options,
|
|
173
|
+
root: inspection.projectRoot,
|
|
174
|
+
dts: false,
|
|
175
|
+
logs: false,
|
|
176
|
+
beforeWriteFiles: (root) => {
|
|
177
|
+
tree = root;
|
|
178
|
+
}
|
|
179
|
+
}));
|
|
180
|
+
try {
|
|
181
|
+
await context.scanPages(false);
|
|
182
|
+
await recordFilesystemRoutes(inspection, { tree, discovery, module, node });
|
|
183
|
+
supported++;
|
|
184
|
+
} finally {
|
|
185
|
+
context.stopWatcher();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return supported;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function byCodeUnits(left, right) {
|
|
192
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function recordFilesystemRoutes(inspection, { tree, discovery, module, node }) {
|
|
196
|
+
const pending = [{ route: tree, parentId: null }];
|
|
197
|
+
while (pending.length) {
|
|
198
|
+
const { route, parentId } = pending.pop();
|
|
199
|
+
let owner = parentId;
|
|
200
|
+
for (const [view, absolute] of [...route.components].sort(([left], [right]) => byCodeUnits(left, right))) {
|
|
201
|
+
const file = path.relative(inspection.projectRoot, absolute).split(path.sep).join("/");
|
|
202
|
+
const identity = `${route.fullPath}${view === "default" ? "" : ` [${view}]`}`;
|
|
203
|
+
const pageId = await recordPage(inspection, { file, discovery, identity, registrationModule: module, registrationNode: node, parentId });
|
|
204
|
+
if (view === "default" && pageId) {
|
|
205
|
+
owner = pageId;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Scan order follows the filesystem and registration ids are positional,
|
|
209
|
+
// so siblings are visited in fullPath order (pushed reversed for the stack).
|
|
210
|
+
for (const child of [...route.children].sort((left, right) => byCodeUnits(right.fullPath, left.fullPath))) {
|
|
211
|
+
pending.push({ route: child, parentId: owner });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Literal `createRouter({ routes })` declarations, including local route
|
|
217
|
+
// arrays and literal child routes. Returns the supported call count.
|
|
218
|
+
async function discoverRegisteredPages(inspection, discovery, module, registrationPath) {
|
|
219
|
+
const routerFactories = importedNames(module, SOURCES.registeredRouter, "createRouter");
|
|
220
|
+
const calls = module.nodes.filter((record) => isLocalCall(record, routerFactories));
|
|
221
|
+
let supported = 0;
|
|
222
|
+
for (const { node } of calls) {
|
|
223
|
+
const routes = bindingNode(objectProperty(node.arguments[0], "routes"), module.bindings);
|
|
224
|
+
if (routes?.type !== "ArrayExpression") {
|
|
225
|
+
warn(inspection, "ROUTES_DYNAMIC", "Registered routes need a literal route array or its local binding.", registrationPath);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
await recordRegisteredRoutes(inspection, { routes, discovery, module, registrationPath });
|
|
229
|
+
supported++;
|
|
230
|
+
}
|
|
231
|
+
return supported;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function routeComponentSpecifier(module, component) {
|
|
235
|
+
if (component?.type === "Identifier") {
|
|
236
|
+
return module.imports.get(component.name)?.source || null;
|
|
237
|
+
}
|
|
238
|
+
if (component?.type === "ArrowFunctionExpression" && component.body.type === "ImportExpression") {
|
|
239
|
+
return staticValue(component.body.source, module.bindings);
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function recordRegisteredRoutes(inspection, { routes, discovery, module, registrationPath }) {
|
|
245
|
+
const pending = routes.elements.slice().reverse().map((route) => ({ route, prefix: "", parentId: null }));
|
|
246
|
+
while (pending.length) {
|
|
247
|
+
const { route, prefix, parentId } = pending.pop();
|
|
248
|
+
const routePath = staticValue(objectProperty(route, "path"), module.bindings);
|
|
249
|
+
if (typeof routePath !== "string") {
|
|
250
|
+
warn(inspection, "ROUTE_DYNAMIC", "A route path or spread cannot be resolved statically.", registrationPath);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const identity = routePath.startsWith("/") ? routePath : `${prefix}/${routePath}`.replace(/\/{2,}/gu, "/");
|
|
254
|
+
const component = bindingNode(objectProperty(route, "component"), module.bindings);
|
|
255
|
+
const file = resolveImport(inspection, module.path, routeComponentSpecifier(module, component));
|
|
256
|
+
let owner = parentId;
|
|
257
|
+
if (file) {
|
|
258
|
+
owner = await recordPage(inspection, { file, discovery, identity, registrationModule: module, registrationNode: route, parentId });
|
|
259
|
+
} else if (component) {
|
|
260
|
+
warn(inspection, "PAGE_COMPONENT_UNRESOLVED", "A registered route component is dynamic or outside the indexed project.", registrationPath);
|
|
261
|
+
}
|
|
262
|
+
const children = bindingNode(objectProperty(route, "children"), module.bindings);
|
|
263
|
+
if (children?.type === "ArrayExpression") {
|
|
264
|
+
pending.push(...children.elements.slice().reverse().map((child) => ({ route: child, prefix: identity, parentId: owner })));
|
|
265
|
+
} else if (children) {
|
|
266
|
+
warn(inspection, "ROUTES_DYNAMIC", "Child route registration is dynamic.", registrationPath);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function discoverPages(inspection, discoveries) {
|
|
272
|
+
let supported = 0;
|
|
273
|
+
for (const discovery of discoveries) {
|
|
274
|
+
if (!UI_ADAPTERS.includes(discovery.adapter)) {
|
|
275
|
+
warn(inspection, "UI_ADAPTER_UNAVAILABLE", `No Vue inspector is available for ${discovery.adapter}.`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
for (const registrationPath of discovery.registrations || []) {
|
|
279
|
+
const module = await loadModule(inspection, registrationPath);
|
|
280
|
+
if (!module) {
|
|
281
|
+
warn(inspection, "REGISTRATION_UNAVAILABLE", "A routing input is not in the source inventory.", registrationPath);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (discovery.mode === "filesystem") {
|
|
285
|
+
supported += await discoverFilesystemPages(inspection, discovery, module, registrationPath);
|
|
286
|
+
} else {
|
|
287
|
+
supported += await discoverRegisteredPages(inspection, discovery, module, registrationPath);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const { output } = inspection;
|
|
292
|
+
if (supported) {
|
|
293
|
+
output.capabilities.pages = output.diagnostics.length ? "partial" : "complete";
|
|
294
|
+
} else {
|
|
295
|
+
output.capabilities.pages = discoveries.length ? "unsupported" : "unconfigured";
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Element tags and simple event-handler names of one component template.
|
|
300
|
+
function readTemplate(module) {
|
|
301
|
+
const eventHandlers = new Set();
|
|
302
|
+
const tags = [];
|
|
303
|
+
const pending = [module.descriptor.template.ast];
|
|
304
|
+
while (pending.length) {
|
|
305
|
+
const node = pending.pop();
|
|
306
|
+
if (!node) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (node.type === 1) {
|
|
310
|
+
tags.push(node);
|
|
311
|
+
for (const attribute of node.props || []) {
|
|
312
|
+
const handler = attribute.exp?.content || "";
|
|
313
|
+
if (attribute.type === 7 && attribute.name === "on" && IDENTIFIER_PATTERN.test(handler)) {
|
|
314
|
+
eventHandlers.add(handler);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
pending.push(...(node.children || []));
|
|
319
|
+
}
|
|
320
|
+
return { eventHandlers, tags };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Walks page templates into the project components they actually use.
|
|
324
|
+
async function discoverComponents(inspection) {
|
|
325
|
+
const pending = inspection.output.pages.map((record) => ({ file: record.file, parentId: record.id }));
|
|
326
|
+
const inspected = new Set();
|
|
327
|
+
while (pending.length) {
|
|
328
|
+
const { file, parentId } = pending.pop();
|
|
329
|
+
const module = await loadModule(inspection, file);
|
|
330
|
+
if (!module?.descriptor?.template || inspected.has(parentId)) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
inspected.add(parentId);
|
|
334
|
+
const { eventHandlers, tags } = readTemplate(module);
|
|
335
|
+
for (const tag of tags) {
|
|
336
|
+
await recordComponentUse(inspection, { module, file, parentId, tag, pending });
|
|
337
|
+
}
|
|
338
|
+
if (!inspection.httpScope) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (inspection.pages.has(parentId)) {
|
|
342
|
+
for (const tag of tags) {
|
|
343
|
+
await recordCrudScreenUse(inspection, { module, file, parentId, tag });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (!inspection.calls) {
|
|
347
|
+
recordFetchUses(inspection, { module, parentId, eventHandlers });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function recordComponentUse(inspection, { module, file, parentId, tag, pending }) {
|
|
353
|
+
const { components, output } = inspection;
|
|
354
|
+
const local = templateTagImport(module, tag.tag);
|
|
355
|
+
if (!local && !/^[A-Z]|-/u.test(tag.tag) && tag.tag !== "component") {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (!local && tag.tag.startsWith("v-")) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const imported = module.imports.get(local);
|
|
362
|
+
const target = resolveImport(inspection, file, imported?.source);
|
|
363
|
+
let definition;
|
|
364
|
+
if (target) {
|
|
365
|
+
const targetModule = await loadModule(inspection, target);
|
|
366
|
+
if (!targetModule) {
|
|
367
|
+
warn(inspection, "COMPONENT_SOURCE_UNSUPPORTED", "An imported asset needs its own component adapter.", target);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const id = `component:${target}`;
|
|
371
|
+
if (!components.has(id)) {
|
|
372
|
+
const record = {
|
|
373
|
+
id,
|
|
374
|
+
title: path.posix.basename(target, path.posix.extname(target)),
|
|
375
|
+
file: target,
|
|
376
|
+
resolution: "resolved",
|
|
377
|
+
evidence: sourceEvidence(targetModule)
|
|
378
|
+
};
|
|
379
|
+
components.set(id, record);
|
|
380
|
+
output.components.push(record);
|
|
381
|
+
}
|
|
382
|
+
definition = components.get(id);
|
|
383
|
+
pending.push({ file: target, parentId: id });
|
|
384
|
+
} else if (imported) {
|
|
385
|
+
definition = recordPackageComponent(inspection, { module, file, tag, imported });
|
|
386
|
+
}
|
|
387
|
+
const range = { start: tag.loc.start.offset, end: tag.loc.end.offset };
|
|
388
|
+
output.componentUses.push({
|
|
389
|
+
id: `component-use:${parentId}:${tag.loc.start.offset}`,
|
|
390
|
+
parentId,
|
|
391
|
+
...(definition ? { componentId: definition.id } : {}),
|
|
392
|
+
title: tag.tag,
|
|
393
|
+
resolution: definition ? "resolved" : "unresolved",
|
|
394
|
+
evidence: sourceEvidence(module, range)
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// A component imported from a declared package keeps its package boundary as
|
|
399
|
+
// owner; its implementation files are not read.
|
|
400
|
+
function recordPackageComponent(inspection, { module, file, tag, imported }) {
|
|
401
|
+
const { components, input, output } = inspection;
|
|
402
|
+
const dependency = resolvePackageImport(inspection, file, imported.source);
|
|
403
|
+
if (!dependency || !input.packageIdentity) {
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
const id = `component:package:${JSON.stringify([dependency.record.id, dependency.importPath, imported.imported])}`;
|
|
407
|
+
if (!components.has(id)) {
|
|
408
|
+
const importOffset = findRecord(module, imported.node)?.offset || 0;
|
|
409
|
+
const record = {
|
|
410
|
+
id,
|
|
411
|
+
title: imported.imported === "default" ? tag.tag : imported.imported,
|
|
412
|
+
file: null,
|
|
413
|
+
origin: { kind: "package", packageId: dependency.record.id, importPath: dependency.importPath, symbol: imported.imported },
|
|
414
|
+
resolution: "resolved",
|
|
415
|
+
evidence: sourceEvidence(module, imported.node, importOffset)
|
|
416
|
+
};
|
|
417
|
+
components.set(id, record);
|
|
418
|
+
output.components.push(record);
|
|
419
|
+
}
|
|
420
|
+
return components.get(id);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function crudScreenKind(component) {
|
|
424
|
+
if (component?.source === SOURCES.crudListScreen && ["default", "CrudListScreen"].includes(component.imported)) {
|
|
425
|
+
return "list";
|
|
426
|
+
}
|
|
427
|
+
if (component?.source === SOURCES.crudAddEditScreen && ["default", "CrudAddEditScreen"].includes(component.imported)) {
|
|
428
|
+
return "edit";
|
|
429
|
+
}
|
|
430
|
+
return "";
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// The setup-level `useCrudListScreen` / `useCrudAddEditScreen` call bound to a
|
|
434
|
+
// screen tag's `:screen`, or null.
|
|
435
|
+
function crudScreenCall(module, tag, list) {
|
|
436
|
+
const screen = tag.props?.find((entry) => entry.type === 7 && entry.name === "bind" && entry.arg?.content === "screen")?.exp?.content;
|
|
437
|
+
if (!IDENTIFIER_PATTERN.test(screen || "")) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const call = bindingNode(module.bindings.get(screen), module.bindings);
|
|
441
|
+
if (call?.type !== "CallExpression" || call.callee.type !== "Identifier") {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
const helper = module.imports.get(call.callee.name);
|
|
445
|
+
const expected = list ? "useCrudListScreen" : "useCrudAddEditScreen";
|
|
446
|
+
if (!helper || helper.source !== `${SOURCES.crudComposables}/${expected}` || helper.imported !== expected) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const record = findRecord(module, call);
|
|
450
|
+
if (!record?.setup || record.ancestors.some(isFunctionNode) || isShadowed(record, call.callee.name)) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
return { call, record };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function hasDynamicScreenTransport(module, config) {
|
|
457
|
+
const dynamicKey = CRUD_SCREEN_TRANSPORT_KEYS.some((key) =>
|
|
458
|
+
objectProperty(config, key) && staticValue(objectProperty(config, key), module.bindings) !== null
|
|
459
|
+
);
|
|
460
|
+
return dynamicKey || config?.properties.some((entry) => !isStaticObjectProperty(entry));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function hasCustomScreenClient(options) {
|
|
464
|
+
if (!options) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
if (options.type !== "ObjectExpression") {
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
return options.properties.some((entry) =>
|
|
471
|
+
!isStaticObjectProperty(entry) || CRUD_SCREEN_CLIENT_KEYS.includes(propertyKey(entry))
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// A used JSKIT CRUD screen whose resource, operation and API suffix are
|
|
476
|
+
// literal becomes a `jskit.crud` operation use.
|
|
477
|
+
async function recordCrudScreenUse(inspection, { module, file, parentId, tag }) {
|
|
478
|
+
const kind = crudScreenKind(module.imports.get(templateTagImport(module, tag.tag)));
|
|
479
|
+
if (!kind) {
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const list = kind === "list";
|
|
483
|
+
const screenCall = crudScreenCall(module, tag, list);
|
|
484
|
+
if (!screenCall) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const { call, record } = screenCall;
|
|
488
|
+
const config = bindingNode(call.arguments[0], module.bindings);
|
|
489
|
+
const resource = await resolveCrudResource(inspection, module, objectProperty(config, "resource"));
|
|
490
|
+
const operation = list ? "list" : staticValue(objectProperty(config, "operationName"), module.bindings);
|
|
491
|
+
const options = bindingNode(objectProperty(config, list ? "listOptions" : "addEditOptions"), module.bindings);
|
|
492
|
+
const apiSuffix = staticValue(objectProperty(list ? config : options, "apiSuffix"), module.bindings);
|
|
493
|
+
const enabled = objectProperty(config, "readEnabled");
|
|
494
|
+
if (list && enabled && staticValue(enabled, module.bindings) === false) {
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const literalSuffix = typeof apiSuffix === "string" && apiSuffix.startsWith("/") && !apiSuffix.includes("?");
|
|
498
|
+
const literalTransport = !hasDynamicScreenTransport(module, config) && !hasCustomScreenClient(options);
|
|
499
|
+
const literalEnabled = !enabled || staticValue(enabled, module.bindings) === true;
|
|
500
|
+
const resolved = Boolean(resource && resource.operations.includes(operation) && literalSuffix && literalTransport && literalEnabled);
|
|
501
|
+
if (!resolved) {
|
|
502
|
+
warn(inspection, "CRUD_SCREEN_UNRESOLVED", "A used CRUD screen has dynamic resource, operation or transport configuration.", file);
|
|
503
|
+
}
|
|
504
|
+
const surface = inspection.pages.get(parentId).surface;
|
|
505
|
+
inspection.output.operationUses.push({
|
|
506
|
+
id: `operation-use:${parentId}:crud:${tag.loc.start.offset}`,
|
|
507
|
+
parentId,
|
|
508
|
+
resolution: resolved ? "resolved" : "unresolved",
|
|
509
|
+
...(resolved ? { key: crudOperationKey(inspection, surface, resource.namespace, operation, apiSuffix) } : {}),
|
|
510
|
+
evidence: [...sourceEvidence(module, call, record.offset), ...(resource?.evidence || [])]
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isGlobalFetchCall(module, { node, ancestors }) {
|
|
515
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "fetch") {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
return !module.bindings.has("fetch") && !module.imports.has("fetch") && !isShadowed({ ancestors }, "fetch");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Global `fetch` calls made during setup or from a template event handler.
|
|
522
|
+
function recordFetchUses(inspection, { module, parentId, eventHandlers }) {
|
|
523
|
+
for (const { node, ancestors, offset, setup } of module.nodes) {
|
|
524
|
+
if (!isGlobalFetchCall(module, { node, ancestors })) {
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const enclosingFunction = ancestors.slice().reverse().find(isFunctionNode);
|
|
528
|
+
const enclosingDeclarator = ancestors.slice().reverse().find((entry) => entry.type === "VariableDeclarator");
|
|
529
|
+
const declaration = enclosingFunction?.id?.name || enclosingDeclarator?.id?.name;
|
|
530
|
+
if (enclosingFunction ? !eventHandlers.has(declaration) : !setup) {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const operation = staticValue(node.arguments[0], module.bindings);
|
|
534
|
+
const method = staticValue(objectProperty(node.arguments[1], "method"), module.bindings) || "GET";
|
|
535
|
+
const resolved = typeof operation === "string" && operation.startsWith("/") && !operation.includes("?") && typeof method === "string";
|
|
536
|
+
inspection.output.operationUses.push({
|
|
537
|
+
id: `operation-use:${parentId}:${offset + node.start}`,
|
|
538
|
+
parentId,
|
|
539
|
+
resolution: resolved ? "resolved" : "unresolved",
|
|
540
|
+
...(resolved ? { key: { protocol: "http", scope: inspection.httpScope, method: method.toUpperCase(), operation } } : {}),
|
|
541
|
+
evidence: sourceEvidence(module, node, offset)
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export {
|
|
547
|
+
crudOperationKey,
|
|
548
|
+
discoverComponents,
|
|
549
|
+
discoverPages,
|
|
550
|
+
resolveCrudResource
|
|
551
|
+
};
|