@jskit-ai/architecture-inspector 0.1.1 → 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.
@@ -1,243 +1,14 @@
1
- import { createHash } from "node:crypto";
2
- import { lstat, readFile } from "node:fs/promises";
3
1
  import path from "node:path";
4
- import { parse as parseJavaScript } from "@babel/parser";
5
- import { parse as parseSfc } from "@vue/compiler-sfc";
6
- import { createRoutesContext, resolveOptions } from "vue-router/unplugin";
7
- import { resolveScopedApiBasePath } from "@jskit-ai/kernel/shared/surface";
8
2
  import { cacheKeys, cacheLocation, readCallCache, writeCallCache } from "./calls/cache.js";
9
3
  import { inspectCalls } from "./calls/index.js";
4
+ import { SCRIPT_FILE_PATTERN, loadModule, projectHttpScope, readDeclaredSource, resolveImport, utf8Source, warn } from "./vue/modules.js";
5
+ import { discoverComponents, discoverPages } from "./vue/pages.js";
6
+ import { discoverEntryOperations } from "./vue/routes.js";
7
+ import { isCanonicalPath, sourceEvidence } from "./vue/syntax.js";
10
8
 
11
- const INPUT_SCHEMA = "vibe64.architecture-inspection-input.v1";
9
+ const INPUT_SCHEMA = "jskit.architecture-inspection-input.v1";
12
10
  const OUTPUT_SCHEMA = "genesis.architecture-observations.v1";
13
11
  const ADAPTER_ID = "jskit.vue-static.v1";
14
- const MAX_SOURCE_BYTES = 4 * 1024 * 1024;
15
- const MAX_RESOURCE_HOPS = 64;
16
- const SCRIPT_FILE_PATTERN = /\.(?:[cm]?js|jsx|tsx?|vue)$/u;
17
- const SERVER_FILE_PATTERN = /\.(?:js|mjs|cjs|ts)$/u;
18
- const FUNCTION_NODE_PATTERN = /Function/u;
19
- const IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/u;
20
- const RESOLVE_EXTENSIONS = Object.freeze([".vue", ".js", ".ts", "/index.js", "/index.ts"]);
21
- const UI_ADAPTERS = Object.freeze(["jskit", "vue-filesystem-router", "vue-registered-router"]);
22
- const FILESYSTEM_ROUTER_OPTIONS = Object.freeze(["routesFolder", "extensions", "exclude", "dts", "nestedChildren"]);
23
- const CRUD_RESOURCE_OPERATIONS = Object.freeze(["list", "view", "create", "patch", "delete"]);
24
- const CRUD_CONTROLLER_OPERATIONS = Object.freeze(["list", "view", "create", "update", "delete"]);
25
- const CRUD_SCREEN_TRANSPORT_KEYS = Object.freeze(["screen", "client", "surfaceId", "transport"]);
26
- const CRUD_SCREEN_CLIENT_KEYS = Object.freeze(["client", "surfaceId", "transport", "resource"]);
27
- const MAX_UNROLLED_ELEMENTS = 64;
28
-
29
- // Framework sources whose declarations this inspector reads statically.
30
- const SOURCES = Object.freeze({
31
- crudResource: "@jskit-ai/resource-crud-core/shared/crudResource",
32
- crudJsonApiFeature: "@jskit-ai/crud-core/server/defineCrudJsonApiFeature",
33
- kernelFeatures: "@jskit-ai/kernel/server/features",
34
- kernelHttp: "@jskit-ai/kernel/server/http",
35
- crudListScreen: "@jskit-ai/http-web/client/components/CrudListScreen",
36
- crudAddEditScreen: "@jskit-ai/http-web/client/components/CrudAddEditScreen",
37
- crudComposables: "@jskit-ai/http-web/client/composables",
38
- filesystemRouter: "vue-router/vite",
39
- registeredRouter: "vue-router"
40
- });
41
-
42
- function hashBytes(source) {
43
- return `sha256:${createHash("sha256").update(source).digest("hex")}`;
44
- }
45
-
46
- function isCanonicalPath(value) {
47
- if (typeof value !== "string" || value.length === 0 || value === ".") {
48
- return false;
49
- }
50
- if (/[\\\0]/u.test(value) || path.posix.isAbsolute(value)) {
51
- return false;
52
- }
53
- return path.posix.normalize(value) === value && !value.split("/").includes("..");
54
- }
55
-
56
- function propertyKey(entry) {
57
- return entry.key.name || entry.key.value;
58
- }
59
-
60
- function isStaticObjectProperty(entry) {
61
- return entry.type === "ObjectProperty" && !entry.computed;
62
- }
63
-
64
- function objectProperty(node, key) {
65
- if (node?.type !== "ObjectExpression") {
66
- return undefined;
67
- }
68
- return node.properties.find((entry) => isStaticObjectProperty(entry) && propertyKey(entry) === key)?.value;
69
- }
70
-
71
- function hasOnlyStaticProperties(node) {
72
- return node.properties.every(isStaticObjectProperty);
73
- }
74
-
75
- function isFunctionNode(node) {
76
- return FUNCTION_NODE_PATTERN.test(node.type);
77
- }
78
-
79
- function directoryPrefixes(filePath) {
80
- const parts = filePath.split("/");
81
- return parts.map((_, index) => parts.slice(0, index + 1).join("/"));
82
- }
83
-
84
- // Every AST node with its ancestor chain, depth first.
85
- // Node records link to their parent record. `ancestors` (outermost first) is
86
- // built on demand, so a module's records stay linear in its size.
87
- const NODE_RECORD = Object.freeze({
88
- get ancestors() {
89
- const chain = [];
90
- for (let current = this.parent; current; current = current.parent) {
91
- chain.push(current.node);
92
- }
93
- return chain.reverse();
94
- }
95
- });
96
-
97
- function nodeRecord(node, parent) {
98
- return Object.assign(Object.create(NODE_RECORD), { node, parent });
99
- }
100
-
101
- function collectNodes(root) {
102
- const result = [];
103
- const pending = [nodeRecord(root, null)];
104
- while (pending.length) {
105
- const current = pending.pop();
106
- const { node } = current;
107
- if (!node || typeof node.type !== "string") {
108
- continue;
109
- }
110
- result.push(current);
111
- for (const value of Object.values(node)) {
112
- const children = Array.isArray(value) ? value : value?.type ? [value] : [];
113
- for (const child of children) {
114
- if (child?.type) {
115
- pending.push(nodeRecord(child, current));
116
- }
117
- }
118
- }
119
- }
120
- return result;
121
- }
122
-
123
- // The literal value of an expression, following local bindings; undefined when
124
- // any part is dynamic.
125
- function staticValue(node, bindings, seen = new Set(), environment = null) {
126
- if (!node) {
127
- return undefined;
128
- }
129
- if (node.type === "Identifier" && environment?.has(node.name)) {
130
- return environment.get(node.name);
131
- }
132
- if (["StringLiteral", "BooleanLiteral", "NumericLiteral"].includes(node.type)) {
133
- return node.value;
134
- }
135
- if (node.type === "NullLiteral") {
136
- return null;
137
- }
138
- if (node.type === "Identifier" && !seen.has(node.name)) {
139
- return staticValue(bindings.get(node.name), bindings, new Set([...seen, node.name]));
140
- }
141
- if (node.type === "ArrayExpression") {
142
- const values = node.elements.map((entry) => staticValue(entry, bindings, seen, environment));
143
- return values.every((value) => value !== undefined) ? values : undefined;
144
- }
145
- if (node.type === "ObjectExpression") {
146
- return staticObjectValue(node, bindings, seen, environment);
147
- }
148
- if (node.type === "TemplateLiteral") {
149
- const values = node.expressions.map((entry) => staticValue(entry, bindings, seen, environment));
150
- if (values.some((value) => typeof value !== "string")) {
151
- return undefined;
152
- }
153
- return node.quasis.map((entry, index) => entry.value.cooked + (values[index] || "")).join("");
154
- }
155
- if (node.type === "BinaryExpression" && node.operator === "+") {
156
- const left = staticValue(node.left, bindings, seen, environment);
157
- const right = staticValue(node.right, bindings, seen, environment);
158
- return typeof left === "string" && typeof right === "string" ? left + right : undefined;
159
- }
160
- return undefined;
161
- }
162
-
163
- function staticObjectValue(node, bindings, seen, environment) {
164
- const result = {};
165
- for (const entry of node.properties) {
166
- if (!isStaticObjectProperty(entry)) {
167
- return undefined;
168
- }
169
- const value = staticValue(entry.value, bindings, seen, environment);
170
- if (value === undefined) {
171
- return undefined;
172
- }
173
- result[propertyKey(entry)] = value;
174
- }
175
- return result;
176
- }
177
-
178
- // The node an identifier is bound to locally, or the node itself.
179
- function bindingNode(node, bindings, seen = new Set()) {
180
- if (node?.type !== "Identifier" || seen.has(node.name) || !bindings.has(node.name)) {
181
- return node;
182
- }
183
- return bindingNode(bindings.get(node.name), bindings, new Set([...seen, node.name]));
184
- }
185
-
186
- function isShadowed({ ancestors }, name) {
187
- return ancestors.some((node) =>
188
- node.params?.some((parameter) =>
189
- collectNodes(parameter).some(({ node: binding }) => binding.type === "Identifier" && binding.name === name)
190
- )
191
- );
192
- }
193
-
194
- function isLocalCall(record, names) {
195
- const { node } = record;
196
- return node.type === "CallExpression" &&
197
- node.callee.type === "Identifier" &&
198
- names.has(node.callee.name) &&
199
- !isShadowed(record, node.callee.name);
200
- }
201
-
202
- function importedNames(module, source, imported) {
203
- const names = [...module.imports]
204
- .filter(([, entry]) => entry.source === source && entry.imported === imported)
205
- .map(([name]) => name);
206
- return new Set(names);
207
- }
208
-
209
- function findRecord(module, node) {
210
- return module.nodes.find((entry) => entry.node === node);
211
- }
212
-
213
- function kebabCase(name) {
214
- return name.replace(/[A-Z]/gu, (letter, index) => `${index ? "-" : ""}${letter.toLowerCase()}`);
215
- }
216
-
217
- // The imported local name a template tag refers to, by exact or kebab-case name.
218
- function templateTagImport(module, tag) {
219
- return [...module.imports.keys()].find((name) => name === tag || kebabCase(name) === tag);
220
- }
221
-
222
- function sourcePoint(module, index) {
223
- const lines = module.source.slice(0, index).split("\n");
224
- return { line: lines.length, column: lines.at(-1).length + 1 };
225
- }
226
-
227
- function sourceEvidence(module, node, offset = 0) {
228
- const sourceEnd = Math.max(1, module.source.length - (module.source.endsWith("\n") ? 1 : 0));
229
- return [{
230
- path: module.path,
231
- hash: module.hash,
232
- start: sourcePoint(module, offset + (node?.start || 0)),
233
- end: sourcePoint(module, offset + (node?.end || sourceEnd))
234
- }];
235
- }
236
-
237
- function pageTitle(file) {
238
- const name = path.posix.basename(file, path.posix.extname(file));
239
- return name === "index" ? path.posix.basename(path.posix.dirname(file)) : name;
240
- }
241
12
 
242
13
  function validateInput({ projectRoot, input, aliases, apiPrefixAliases }) {
243
14
  if (!path.isAbsolute(projectRoot || "") || input?.schema !== INPUT_SCHEMA || !Array.isArray(input.files)) {
@@ -302,1081 +73,6 @@ function createOutput({ input, calls }) {
302
73
  return output;
303
74
  }
304
75
 
305
- function warn(inspection, code, message, source) {
306
- inspection.output.diagnostics.push({ code, message, ...(source ? { path: source } : {}) });
307
- }
308
-
309
- // Reads one indexed, hash-matching source file once and records it as an
310
- // input. Symbolic links, changed sources and oversized files are refused.
311
- async function readDeclaredSource(inspection, filePath) {
312
- const { files, output, projectRoot, sources } = inspection;
313
- if (sources.has(filePath)) {
314
- return sources.get(filePath);
315
- }
316
- for (const directory of directoryPrefixes(filePath)) {
317
- if (inspection.checkedDirectories.has(directory)) {
318
- continue;
319
- }
320
- if ((await lstat(path.join(projectRoot, directory))).isSymbolicLink()) {
321
- throw new Error("Architecture inspection cannot follow symbolic links.");
322
- }
323
- inspection.checkedDirectories.add(directory);
324
- }
325
- const bytes = await readFile(path.join(projectRoot, filePath));
326
- if (bytes.length > MAX_SOURCE_BYTES || hashBytes(bytes) !== files.get(filePath).hash) {
327
- throw new Error(`Source changed or exceeded inspection bounds: ${filePath}`);
328
- }
329
- output.inputs.push({ path: filePath, hash: files.get(filePath).hash });
330
- sources.set(filePath, bytes);
331
- return bytes;
332
- }
333
-
334
- // A JSKIT application serves its own HTTP operations, so without an explicit
335
- // scope the root manifest's package name identifies them. A manifest that is
336
- // absent, unnamed or not JSON leaves HTTP observations unconfigured.
337
- async function projectHttpScope(inspection) {
338
- if (!inspection.files.has("package.json")) {
339
- return "";
340
- }
341
- const bytes = await readDeclaredSource(inspection, "package.json");
342
- try {
343
- const { name } = JSON.parse(bytes.toString("utf8"));
344
- return typeof name === "string" ? name.trim() : "";
345
- } catch {
346
- return "";
347
- }
348
- }
349
-
350
- function utf8Source(bytes, filePath) {
351
- const source = bytes.toString("utf8");
352
- if (!Buffer.from(source).equals(bytes)) {
353
- throw new Error(`Source is not UTF-8: ${filePath}`);
354
- }
355
- return source;
356
- }
357
-
358
- // Reads one indexed source file once and parses its scripts. Non-script files
359
- // are recorded as inputs without parsing.
360
- async function loadModule(inspection, filePath) {
361
- const { files, modules } = inspection;
362
- if (modules.has(filePath)) {
363
- return modules.get(filePath);
364
- }
365
- if (!files.has(filePath)) {
366
- return null;
367
- }
368
- const bytes = await readDeclaredSource(inspection, filePath);
369
- if (!SCRIPT_FILE_PATTERN.test(filePath)) {
370
- modules.set(filePath, null);
371
- return null;
372
- }
373
- const module = {
374
- path: filePath,
375
- source: utf8Source(bytes, filePath),
376
- hash: files.get(filePath).hash,
377
- bindings: new Map(),
378
- constants: new Set(),
379
- imports: new Map(),
380
- nodes: [],
381
- exports: new Map(),
382
- descriptor: null
383
- };
384
- modules.set(filePath, module);
385
- for (const unit of scriptUnits(inspection, module)) {
386
- indexScriptUnit(inspection, module, unit);
387
- }
388
- return module;
389
- }
390
-
391
- function scriptUnits(inspection, module) {
392
- if (!module.path.endsWith(".vue")) {
393
- return [{ source: module.source, offset: 0, setup: false }];
394
- }
395
- const parsed = parseSfc(module.source, { filename: module.path, sourceMap: false });
396
- if (parsed.errors.length) {
397
- warn(inspection, "VUE_PARSE_FAILED", "The component could not be parsed completely.", module.path);
398
- return [];
399
- }
400
- module.descriptor = parsed.descriptor;
401
- const units = [];
402
- const scripts = [parsed.descriptor.script, parsed.descriptor.scriptSetup].filter(Boolean);
403
- for (const script of scripts) {
404
- if (script.src) {
405
- warn(inspection, "VUE_EXTERNAL_SCRIPT", "External component scripts need another inspection adapter.", module.path);
406
- continue;
407
- }
408
- units.push({ source: script.content, offset: script.loc.start.offset, setup: script === parsed.descriptor.scriptSetup });
409
- }
410
- return units;
411
- }
412
-
413
- function indexScriptUnit(inspection, module, unit) {
414
- try {
415
- const ast = parseJavaScript(unit.source, {
416
- sourceType: "unambiguous",
417
- plugins: ["typescript", "jsx"],
418
- createImportExpressions: true
419
- });
420
- const records = collectNodes(ast);
421
- for (const record of records) {
422
- Object.assign(record, { offset: unit.offset, setup: unit.setup });
423
- module.nodes.push(record);
424
- indexDeclaration(module, record.node, record.parent?.node);
425
- }
426
- } catch {
427
- warn(inspection, "JAVASCRIPT_PARSE_FAILED", "A script needs unsupported syntax or could not be parsed.", module.path);
428
- }
429
- }
430
-
431
- function indexDeclaration(module, node, parent) {
432
- if (node.type === "VariableDeclarator" && node.id.type === "Identifier") {
433
- // A name bound twice in one module is ambiguous, so it resolves to nothing.
434
- module.bindings.set(node.id.name, module.bindings.has(node.id.name) ? undefined : node.init);
435
- if (parent?.kind === "const") {
436
- module.constants.add(node.id.name);
437
- }
438
- }
439
- if (node.type === "ExportNamedDeclaration") {
440
- for (const specifier of node.specifiers) {
441
- if (specifier.type === "ExportSpecifier") {
442
- const exported = specifier.exported.name || specifier.exported.value;
443
- const local = specifier.local.name || specifier.local.value;
444
- module.exports.set(exported, { local, source: node.source?.value });
445
- }
446
- }
447
- if (node.declaration?.type === "VariableDeclaration") {
448
- for (const entry of node.declaration.declarations) {
449
- if (entry.id.type === "Identifier") {
450
- module.exports.set(entry.id.name, { local: entry.id.name });
451
- }
452
- }
453
- }
454
- }
455
- if (node.type === "ImportDeclaration") {
456
- for (const specifier of node.specifiers) {
457
- module.imports.set(specifier.local.name, {
458
- source: node.source.value,
459
- imported: specifier.imported?.name || "default",
460
- node
461
- });
462
- }
463
- }
464
- }
465
-
466
- // Resolves an import specifier to an indexed project file through relative
467
- // paths, project-root paths, declared aliases and declared package entry points.
468
- function resolveImport(inspection, from, specifier) {
469
- const { aliases, files } = inspection;
470
- if (typeof specifier !== "string") {
471
- return null;
472
- }
473
- let base;
474
- if (specifier.startsWith(".")) {
475
- base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
476
- } else if (specifier.startsWith("/")) {
477
- base = specifier.slice(1);
478
- } else {
479
- const alias = Object.keys(aliases)
480
- .sort((a, b) => b.length - a.length)
481
- .find((key) => specifier === key || specifier.startsWith(key + "/"));
482
- if (alias) {
483
- base = path.posix.join(aliases[alias], specifier.slice(alias.length));
484
- }
485
- }
486
- if (!base) {
487
- const packageEntry = resolvePackageEntryFile(inspection, from, specifier);
488
- if (packageEntry) {
489
- return packageEntry;
490
- }
491
- }
492
- if (!isCanonicalPath(base)) {
493
- return null;
494
- }
495
- const candidates = [base, ...RESOLVE_EXTENSIONS.map((extension) => base + extension)];
496
- return candidates.find((candidate) => files.has(candidate)) || null;
497
- }
498
-
499
- function resolvePackageEntryFile(inspection, from, specifier) {
500
- const dependency = resolvePackageImport(inspection, from, specifier);
501
- if (!dependency || dependency.record.opaque) {
502
- return null;
503
- }
504
- const entryFiles = dependency.record.entryPoints
505
- .filter((entry) => entry.importPath === dependency.importPath && inspection.files.has(entry.file))
506
- .map((entry) => entry.file);
507
- const candidates = [...new Set(entryFiles)];
508
- return candidates.length === 1 ? candidates[0] : null;
509
- }
510
-
511
- // The declared package an import names, chosen by the nearest manifest that
512
- // contains the importing file.
513
- function resolvePackageImport(inspection, from, specifier) {
514
- if (typeof specifier !== "string" || specifier.startsWith(".") || specifier.startsWith("/")) {
515
- return null;
516
- }
517
- const namesPackage = (entry) => specifier === entry.name || specifier.startsWith(`${entry.name}/`);
518
- const containsFile = (declaration) => {
519
- const parent = path.posix.dirname(declaration.manifest);
520
- return parent === "." || from.startsWith(`${parent}/`);
521
- };
522
- const candidates = inspection.packages
523
- .flatMap((entry) =>
524
- (entry.declarations || [{ manifest: entry.manifest }])
525
- .filter((declaration) => containsFile(declaration) && namesPackage(entry))
526
- .map((declaration) => ({ record: entry, manifest: declaration.manifest }))
527
- )
528
- .sort((a, b) => b.manifest.length - a.manifest.length);
529
- const record = candidates[0]?.record;
530
- if (!record) {
531
- return null;
532
- }
533
- return { record, importPath: specifier === record.name ? "." : `.${specifier.slice(record.name.length)}` };
534
- }
535
-
536
- // Follows an expression through local bindings, imports and re-exports to a
537
- // literal defineCrudResource declaration.
538
- async function resolveCrudResource(inspection, module, node, seen = new Set()) {
539
- if (!node || seen.size >= MAX_RESOURCE_HOPS) {
540
- return null;
541
- }
542
- if (node.type === "Identifier") {
543
- const identity = `${module.path}:${node.name}`;
544
- if (seen.has(identity)) {
545
- return null;
546
- }
547
- const next = new Set([...seen, identity]);
548
- const imported = module.imports.get(node.name);
549
- if (!imported) {
550
- return resolveCrudResource(inspection, module, module.bindings.get(node.name), next);
551
- }
552
- const target = resolveImport(inspection, module.path, imported.source);
553
- const owner = target && await loadModule(inspection, target);
554
- return owner ? resolveExportedCrudResource(inspection, owner, imported.imported, next) : null;
555
- }
556
- if (node.type !== "CallExpression" || node.callee.type !== "Identifier") {
557
- return null;
558
- }
559
- const factory = module.imports.get(node.callee.name);
560
- const record = findRecord(module, node);
561
- const isFactory = factory?.source === SOURCES.crudResource && factory.imported === "defineCrudResource";
562
- if (!isFactory || !record || isShadowed(record, node.callee.name)) {
563
- return null;
564
- }
565
- const config = bindingNode(node.arguments[0], module.bindings);
566
- if (config?.type !== "ObjectExpression" || !hasOnlyStaticProperties(config)) {
567
- return null;
568
- }
569
- const namespace = staticValue(objectProperty(config, "namespace"), module.bindings);
570
- const declaredOperations = objectProperty(config, "crudOperations");
571
- const operations = declaredOperations ? staticValue(declaredOperations, module.bindings) : [...CRUD_RESOURCE_OPERATIONS];
572
- if (typeof namespace !== "string" || !namespace.trim()) {
573
- return null;
574
- }
575
- if (!Array.isArray(operations) || !operations.length || !operations.every((name) => CRUD_RESOURCE_OPERATIONS.includes(name))) {
576
- return null;
577
- }
578
- if (objectProperty(config, "operations")) {
579
- return null;
580
- }
581
- return { namespace: namespace.trim(), operations, evidence: sourceEvidence(module, node, record.offset) };
582
- }
583
-
584
- async function resolveExportedCrudResource(inspection, module, name, seen) {
585
- const entry = module.exports.get(name);
586
- if (!entry) {
587
- return null;
588
- }
589
- if (!entry.source) {
590
- return resolveCrudResource(inspection, module, { type: "Identifier", name: entry.local }, seen);
591
- }
592
- const identity = `${module.path}:export:${name}`;
593
- if (seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
594
- return null;
595
- }
596
- const target = resolveImport(inspection, module.path, entry.source);
597
- const owner = target && await loadModule(inspection, target);
598
- return owner ? resolveExportedCrudResource(inspection, owner, entry.local, new Set([...seen, identity])) : null;
599
- }
600
-
601
- function crudOperationKey(inspection, surface, namespace, operation, apiSuffix) {
602
- return {
603
- protocol: "jskit.crud",
604
- scope: JSON.stringify([inspection.httpScope, surface, apiSuffix]),
605
- operation: `${namespace}:${operation === "update" ? "patch" : operation}`
606
- };
607
- }
608
-
609
- // Records one page per surface and file, and one registration per route that
610
- // renders it.
611
- async function recordPage(inspection, { file, discovery, identity, registrationModule, registrationNode, parentId = null }) {
612
- const { output, pages } = inspection;
613
- const module = await loadModule(inspection, file);
614
- if (!module) {
615
- warn(inspection, "PAGE_SOURCE_UNAVAILABLE", "A registered page is outside the indexed project source.", file);
616
- return null;
617
- }
618
- const id = `page:${discovery.surface}:${file}`;
619
- if (!pages.has(id)) {
620
- const record = {
621
- id,
622
- title: pageTitle(file),
623
- kind: "page",
624
- surface: discovery.surface,
625
- file,
626
- parentId,
627
- resolution: "resolved",
628
- evidence: sourceEvidence(module)
629
- };
630
- pages.set(id, record);
631
- output.pages.push(record);
632
- } else if (pages.get(id).parentId !== parentId) {
633
- pages.get(id).parentId = null;
634
- warn(inspection, "PAGE_MULTIPLE_PARENTS", "A shared page has multiple registration contexts; registrations retain their identities.", file);
635
- }
636
- output.registrations.push({
637
- id: `registration:${discovery.id}:${output.registrations.length}`,
638
- pageId: id,
639
- surface: discovery.surface,
640
- identity,
641
- resolution: "resolved",
642
- evidence: sourceEvidence(registrationModule, registrationNode)
643
- });
644
- return id;
645
- }
646
-
647
- function isSupportedFilesystemOptions(options) {
648
- if (!options || Object.keys(options).some((key) => !FILESYSTEM_ROUTER_OPTIONS.includes(key))) {
649
- return false;
650
- }
651
- return typeof options.routesFolder === "string" || Array.isArray(options.routesFolder);
652
- }
653
-
654
- // Literal `VueRouter(...)` options scanned by Vue Router's own page scanner,
655
- // with declarations and watchers disabled. Returns the supported call count.
656
- async function discoverFilesystemPages(inspection, discovery, module, registrationPath) {
657
- const routerFactories = importedNames(module, SOURCES.filesystemRouter, "default");
658
- const calls = module.nodes.filter((record) => isLocalCall(record, routerFactories));
659
- let supported = 0;
660
- for (const { node } of calls) {
661
- const options = staticValue(node.arguments[0], module.bindings);
662
- if (!isSupportedFilesystemOptions(options)) {
663
- warn(inspection, "ROUTER_OPTIONS_UNSUPPORTED", "Filesystem routing needs literal supported options; runtime route extensions are not executed.", registrationPath);
664
- continue;
665
- }
666
- if (![options.routesFolder].flat().every(isCanonicalPath)) {
667
- warn(inspection, "ROUTER_ROOT_INVALID", "Router page roots must be inside the project.", registrationPath);
668
- continue;
669
- }
670
- let tree;
671
- const context = createRoutesContext(resolveOptions({
672
- ...options,
673
- root: inspection.projectRoot,
674
- dts: false,
675
- logs: false,
676
- beforeWriteFiles: (root) => {
677
- tree = root;
678
- }
679
- }));
680
- try {
681
- await context.scanPages(false);
682
- await recordFilesystemRoutes(inspection, { tree, discovery, module, node });
683
- supported++;
684
- } finally {
685
- context.stopWatcher();
686
- }
687
- }
688
- return supported;
689
- }
690
-
691
- function byCodeUnits(left, right) {
692
- return left < right ? -1 : left > right ? 1 : 0;
693
- }
694
-
695
- async function recordFilesystemRoutes(inspection, { tree, discovery, module, node }) {
696
- const pending = [{ route: tree, parentId: null }];
697
- while (pending.length) {
698
- const { route, parentId } = pending.pop();
699
- let owner = parentId;
700
- for (const [view, absolute] of [...route.components].sort(([left], [right]) => byCodeUnits(left, right))) {
701
- const file = path.relative(inspection.projectRoot, absolute).split(path.sep).join("/");
702
- const identity = `${route.fullPath}${view === "default" ? "" : ` [${view}]`}`;
703
- const pageId = await recordPage(inspection, { file, discovery, identity, registrationModule: module, registrationNode: node, parentId });
704
- if (view === "default" && pageId) {
705
- owner = pageId;
706
- }
707
- }
708
- // Scan order follows the filesystem and registration ids are positional,
709
- // so siblings are visited in fullPath order (pushed reversed for the stack).
710
- for (const child of [...route.children].sort((left, right) => byCodeUnits(right.fullPath, left.fullPath))) {
711
- pending.push({ route: child, parentId: owner });
712
- }
713
- }
714
- }
715
-
716
- // Literal `createRouter({ routes })` declarations, including local route
717
- // arrays and literal child routes. Returns the supported call count.
718
- async function discoverRegisteredPages(inspection, discovery, module, registrationPath) {
719
- const routerFactories = importedNames(module, SOURCES.registeredRouter, "createRouter");
720
- const calls = module.nodes.filter((record) => isLocalCall(record, routerFactories));
721
- let supported = 0;
722
- for (const { node } of calls) {
723
- const routes = bindingNode(objectProperty(node.arguments[0], "routes"), module.bindings);
724
- if (routes?.type !== "ArrayExpression") {
725
- warn(inspection, "ROUTES_DYNAMIC", "Registered routes need a literal route array or its local binding.", registrationPath);
726
- continue;
727
- }
728
- await recordRegisteredRoutes(inspection, { routes, discovery, module, registrationPath });
729
- supported++;
730
- }
731
- return supported;
732
- }
733
-
734
- function routeComponentSpecifier(module, component) {
735
- if (component?.type === "Identifier") {
736
- return module.imports.get(component.name)?.source || null;
737
- }
738
- if (component?.type === "ArrowFunctionExpression" && component.body.type === "ImportExpression") {
739
- return staticValue(component.body.source, module.bindings);
740
- }
741
- return null;
742
- }
743
-
744
- async function recordRegisteredRoutes(inspection, { routes, discovery, module, registrationPath }) {
745
- const pending = routes.elements.slice().reverse().map((route) => ({ route, prefix: "", parentId: null }));
746
- while (pending.length) {
747
- const { route, prefix, parentId } = pending.pop();
748
- const routePath = staticValue(objectProperty(route, "path"), module.bindings);
749
- if (typeof routePath !== "string") {
750
- warn(inspection, "ROUTE_DYNAMIC", "A route path or spread cannot be resolved statically.", registrationPath);
751
- continue;
752
- }
753
- const identity = routePath.startsWith("/") ? routePath : `${prefix}/${routePath}`.replace(/\/{2,}/gu, "/");
754
- const component = bindingNode(objectProperty(route, "component"), module.bindings);
755
- const file = resolveImport(inspection, module.path, routeComponentSpecifier(module, component));
756
- let owner = parentId;
757
- if (file) {
758
- owner = await recordPage(inspection, { file, discovery, identity, registrationModule: module, registrationNode: route, parentId });
759
- } else if (component) {
760
- warn(inspection, "PAGE_COMPONENT_UNRESOLVED", "A registered route component is dynamic or outside the indexed project.", registrationPath);
761
- }
762
- const children = bindingNode(objectProperty(route, "children"), module.bindings);
763
- if (children?.type === "ArrayExpression") {
764
- pending.push(...children.elements.slice().reverse().map((child) => ({ route: child, prefix: identity, parentId: owner })));
765
- } else if (children) {
766
- warn(inspection, "ROUTES_DYNAMIC", "Child route registration is dynamic.", registrationPath);
767
- }
768
- }
769
- }
770
-
771
- async function discoverPages(inspection, discoveries) {
772
- let supported = 0;
773
- for (const discovery of discoveries) {
774
- if (!UI_ADAPTERS.includes(discovery.adapter)) {
775
- warn(inspection, "UI_ADAPTER_UNAVAILABLE", `No Vue inspector is available for ${discovery.adapter}.`);
776
- continue;
777
- }
778
- for (const registrationPath of discovery.registrations || []) {
779
- const module = await loadModule(inspection, registrationPath);
780
- if (!module) {
781
- warn(inspection, "REGISTRATION_UNAVAILABLE", "A routing input is not in the source inventory.", registrationPath);
782
- continue;
783
- }
784
- if (discovery.mode === "filesystem") {
785
- supported += await discoverFilesystemPages(inspection, discovery, module, registrationPath);
786
- } else {
787
- supported += await discoverRegisteredPages(inspection, discovery, module, registrationPath);
788
- }
789
- }
790
- }
791
- const { output } = inspection;
792
- if (supported) {
793
- output.capabilities.pages = output.diagnostics.length ? "partial" : "complete";
794
- } else {
795
- output.capabilities.pages = discoveries.length ? "unsupported" : "unconfigured";
796
- }
797
- }
798
-
799
- // Element tags and simple event-handler names of one component template.
800
- function readTemplate(module) {
801
- const eventHandlers = new Set();
802
- const tags = [];
803
- const pending = [module.descriptor.template.ast];
804
- while (pending.length) {
805
- const node = pending.pop();
806
- if (!node) {
807
- continue;
808
- }
809
- if (node.type === 1) {
810
- tags.push(node);
811
- for (const attribute of node.props || []) {
812
- const handler = attribute.exp?.content || "";
813
- if (attribute.type === 7 && attribute.name === "on" && IDENTIFIER_PATTERN.test(handler)) {
814
- eventHandlers.add(handler);
815
- }
816
- }
817
- }
818
- pending.push(...(node.children || []));
819
- }
820
- return { eventHandlers, tags };
821
- }
822
-
823
- // Walks page templates into the project components they actually use.
824
- async function discoverComponents(inspection) {
825
- const pending = inspection.output.pages.map((record) => ({ file: record.file, parentId: record.id }));
826
- const inspected = new Set();
827
- while (pending.length) {
828
- const { file, parentId } = pending.pop();
829
- const module = await loadModule(inspection, file);
830
- if (!module?.descriptor?.template || inspected.has(parentId)) {
831
- continue;
832
- }
833
- inspected.add(parentId);
834
- const { eventHandlers, tags } = readTemplate(module);
835
- for (const tag of tags) {
836
- await recordComponentUse(inspection, { module, file, parentId, tag, pending });
837
- }
838
- if (!inspection.httpScope) {
839
- continue;
840
- }
841
- if (inspection.pages.has(parentId)) {
842
- for (const tag of tags) {
843
- await recordCrudScreenUse(inspection, { module, file, parentId, tag });
844
- }
845
- }
846
- if (!inspection.calls) {
847
- recordFetchUses(inspection, { module, parentId, eventHandlers });
848
- }
849
- }
850
- }
851
-
852
- async function recordComponentUse(inspection, { module, file, parentId, tag, pending }) {
853
- const { components, output } = inspection;
854
- const local = templateTagImport(module, tag.tag);
855
- if (!local && !/^[A-Z]|-/u.test(tag.tag) && tag.tag !== "component") {
856
- return;
857
- }
858
- if (!local && tag.tag.startsWith("v-")) {
859
- return;
860
- }
861
- const imported = module.imports.get(local);
862
- const target = resolveImport(inspection, file, imported?.source);
863
- let definition;
864
- if (target) {
865
- const targetModule = await loadModule(inspection, target);
866
- if (!targetModule) {
867
- warn(inspection, "COMPONENT_SOURCE_UNSUPPORTED", "An imported asset needs its own component adapter.", target);
868
- return;
869
- }
870
- const id = `component:${target}`;
871
- if (!components.has(id)) {
872
- const record = {
873
- id,
874
- title: path.posix.basename(target, path.posix.extname(target)),
875
- file: target,
876
- resolution: "resolved",
877
- evidence: sourceEvidence(targetModule)
878
- };
879
- components.set(id, record);
880
- output.components.push(record);
881
- }
882
- definition = components.get(id);
883
- pending.push({ file: target, parentId: id });
884
- } else if (imported) {
885
- definition = recordPackageComponent(inspection, { module, file, tag, imported });
886
- }
887
- const range = { start: tag.loc.start.offset, end: tag.loc.end.offset };
888
- output.componentUses.push({
889
- id: `component-use:${parentId}:${tag.loc.start.offset}`,
890
- parentId,
891
- ...(definition ? { componentId: definition.id } : {}),
892
- title: tag.tag,
893
- resolution: definition ? "resolved" : "unresolved",
894
- evidence: sourceEvidence(module, range)
895
- });
896
- }
897
-
898
- // A component imported from a declared package keeps its package boundary as
899
- // owner; its implementation files are not read.
900
- function recordPackageComponent(inspection, { module, file, tag, imported }) {
901
- const { components, input, output } = inspection;
902
- const dependency = resolvePackageImport(inspection, file, imported.source);
903
- if (!dependency || !input.packageIdentity) {
904
- return undefined;
905
- }
906
- const id = `component:package:${JSON.stringify([dependency.record.id, dependency.importPath, imported.imported])}`;
907
- if (!components.has(id)) {
908
- const importOffset = findRecord(module, imported.node)?.offset || 0;
909
- const record = {
910
- id,
911
- title: imported.imported === "default" ? tag.tag : imported.imported,
912
- file: null,
913
- origin: { kind: "package", packageId: dependency.record.id, importPath: dependency.importPath, symbol: imported.imported },
914
- resolution: "resolved",
915
- evidence: sourceEvidence(module, imported.node, importOffset)
916
- };
917
- components.set(id, record);
918
- output.components.push(record);
919
- }
920
- return components.get(id);
921
- }
922
-
923
- function crudScreenKind(component) {
924
- if (component?.source === SOURCES.crudListScreen && ["default", "CrudListScreen"].includes(component.imported)) {
925
- return "list";
926
- }
927
- if (component?.source === SOURCES.crudAddEditScreen && ["default", "CrudAddEditScreen"].includes(component.imported)) {
928
- return "edit";
929
- }
930
- return "";
931
- }
932
-
933
- // The setup-level `useCrudListScreen` / `useCrudAddEditScreen` call bound to a
934
- // screen tag's `:screen`, or null.
935
- function crudScreenCall(module, tag, list) {
936
- const screen = tag.props?.find((entry) => entry.type === 7 && entry.name === "bind" && entry.arg?.content === "screen")?.exp?.content;
937
- if (!IDENTIFIER_PATTERN.test(screen || "")) {
938
- return null;
939
- }
940
- const call = bindingNode(module.bindings.get(screen), module.bindings);
941
- if (call?.type !== "CallExpression" || call.callee.type !== "Identifier") {
942
- return null;
943
- }
944
- const helper = module.imports.get(call.callee.name);
945
- const expected = list ? "useCrudListScreen" : "useCrudAddEditScreen";
946
- if (!helper || helper.source !== `${SOURCES.crudComposables}/${expected}` || helper.imported !== expected) {
947
- return null;
948
- }
949
- const record = findRecord(module, call);
950
- if (!record?.setup || record.ancestors.some(isFunctionNode) || isShadowed(record, call.callee.name)) {
951
- return null;
952
- }
953
- return { call, record };
954
- }
955
-
956
- function hasDynamicScreenTransport(module, config) {
957
- const dynamicKey = CRUD_SCREEN_TRANSPORT_KEYS.some((key) =>
958
- objectProperty(config, key) && staticValue(objectProperty(config, key), module.bindings) !== null
959
- );
960
- return dynamicKey || config?.properties.some((entry) => !isStaticObjectProperty(entry));
961
- }
962
-
963
- function hasCustomScreenClient(options) {
964
- if (!options) {
965
- return false;
966
- }
967
- if (options.type !== "ObjectExpression") {
968
- return true;
969
- }
970
- return options.properties.some((entry) =>
971
- !isStaticObjectProperty(entry) || CRUD_SCREEN_CLIENT_KEYS.includes(propertyKey(entry))
972
- );
973
- }
974
-
975
- // A used JSKIT CRUD screen whose resource, operation and API suffix are
976
- // literal becomes a `jskit.crud` operation use.
977
- async function recordCrudScreenUse(inspection, { module, file, parentId, tag }) {
978
- const kind = crudScreenKind(module.imports.get(templateTagImport(module, tag.tag)));
979
- if (!kind) {
980
- return;
981
- }
982
- const list = kind === "list";
983
- const screenCall = crudScreenCall(module, tag, list);
984
- if (!screenCall) {
985
- return;
986
- }
987
- const { call, record } = screenCall;
988
- const config = bindingNode(call.arguments[0], module.bindings);
989
- const resource = await resolveCrudResource(inspection, module, objectProperty(config, "resource"));
990
- const operation = list ? "list" : staticValue(objectProperty(config, "operationName"), module.bindings);
991
- const options = bindingNode(objectProperty(config, list ? "listOptions" : "addEditOptions"), module.bindings);
992
- const apiSuffix = staticValue(objectProperty(list ? config : options, "apiSuffix"), module.bindings);
993
- const enabled = objectProperty(config, "readEnabled");
994
- if (list && enabled && staticValue(enabled, module.bindings) === false) {
995
- return;
996
- }
997
- const literalSuffix = typeof apiSuffix === "string" && apiSuffix.startsWith("/") && !apiSuffix.includes("?");
998
- const literalTransport = !hasDynamicScreenTransport(module, config) && !hasCustomScreenClient(options);
999
- const literalEnabled = !enabled || staticValue(enabled, module.bindings) === true;
1000
- const resolved = Boolean(resource && resource.operations.includes(operation) && literalSuffix && literalTransport && literalEnabled);
1001
- if (!resolved) {
1002
- warn(inspection, "CRUD_SCREEN_UNRESOLVED", "A used CRUD screen has dynamic resource, operation or transport configuration.", file);
1003
- }
1004
- const surface = inspection.pages.get(parentId).surface;
1005
- inspection.output.operationUses.push({
1006
- id: `operation-use:${parentId}:crud:${tag.loc.start.offset}`,
1007
- parentId,
1008
- resolution: resolved ? "resolved" : "unresolved",
1009
- ...(resolved ? { key: crudOperationKey(inspection, surface, resource.namespace, operation, apiSuffix) } : {}),
1010
- evidence: [...sourceEvidence(module, call, record.offset), ...(resource?.evidence || [])]
1011
- });
1012
- }
1013
-
1014
- function isGlobalFetchCall(module, { node, ancestors }) {
1015
- if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "fetch") {
1016
- return false;
1017
- }
1018
- return !module.bindings.has("fetch") && !module.imports.has("fetch") && !isShadowed({ ancestors }, "fetch");
1019
- }
1020
-
1021
- // Global `fetch` calls made during setup or from a template event handler.
1022
- function recordFetchUses(inspection, { module, parentId, eventHandlers }) {
1023
- for (const { node, ancestors, offset, setup } of module.nodes) {
1024
- if (!isGlobalFetchCall(module, { node, ancestors })) {
1025
- continue;
1026
- }
1027
- const enclosingFunction = ancestors.slice().reverse().find(isFunctionNode);
1028
- const enclosingDeclarator = ancestors.slice().reverse().find((entry) => entry.type === "VariableDeclarator");
1029
- const declaration = enclosingFunction?.id?.name || enclosingDeclarator?.id?.name;
1030
- if (enclosingFunction ? !eventHandlers.has(declaration) : !setup) {
1031
- continue;
1032
- }
1033
- const operation = staticValue(node.arguments[0], module.bindings);
1034
- const method = staticValue(objectProperty(node.arguments[1], "method"), module.bindings) || "GET";
1035
- const resolved = typeof operation === "string" && operation.startsWith("/") && !operation.includes("?") && typeof method === "string";
1036
- inspection.output.operationUses.push({
1037
- id: `operation-use:${parentId}:${offset + node.start}`,
1038
- parentId,
1039
- resolution: resolved ? "resolved" : "unresolved",
1040
- ...(resolved ? { key: { protocol: "http", scope: inspection.httpScope, method: method.toUpperCase(), operation } } : {}),
1041
- evidence: sourceEvidence(module, node, offset)
1042
- });
1043
- }
1044
- }
1045
-
1046
- // Inspects the actual low-level JSKIT HTTP capability in a defineFeature setup
1047
- // and exported CRUD controller declarations. App-owned wrappers and dispatchers
1048
- // need their own inspector rather than a guessed match on a method name.
1049
- async function discoverEntryOperations(inspection) {
1050
- for (const file of inspection.files.keys()) {
1051
- if (!SERVER_FILE_PATTERN.test(file)) {
1052
- continue;
1053
- }
1054
- const module = await loadModule(inspection, file);
1055
- for (const record of module.nodes) {
1056
- await recordCrudController(inspection, { module, file, record });
1057
- }
1058
- await recordActionRouteScopes(inspection, { module, file });
1059
- const featureFactories = importedNames(module, SOURCES.kernelFeatures, "defineFeature");
1060
- if (!featureFactories.size) {
1061
- continue;
1062
- }
1063
- for (const record of module.nodes) {
1064
- recordFeatureRoute(inspection, { module, file, record, featureFactories });
1065
- }
1066
- }
1067
- }
1068
-
1069
- function crudControllerOperations(module, config, resource) {
1070
- const declared = objectProperty(config, "operations");
1071
- if (declared) {
1072
- return staticValue(declared, module.bindings);
1073
- }
1074
- return resource?.operations.map((name) => (name === "patch" ? "update" : name));
1075
- }
1076
-
1077
- async function recordCrudController(inspection, { module, file, record }) {
1078
- const { node, offset } = record;
1079
- if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || isShadowed(record, node.callee.name)) {
1080
- return;
1081
- }
1082
- const factory = module.imports.get(node.callee.name);
1083
- if (factory?.source !== SOURCES.crudJsonApiFeature || factory.imported !== "defineCrudJsonApiFeature") {
1084
- return;
1085
- }
1086
- if (record.ancestors.some(isFunctionNode)) {
1087
- return;
1088
- }
1089
- const config = bindingNode(node.arguments[0], module.bindings);
1090
- if (config?.type !== "ObjectExpression" || !hasOnlyStaticProperties(config)) {
1091
- warn(inspection, "CRUD_CONTROLLER_UNRESOLVED", "A CRUD controller declaration is dynamic.", file);
1092
- return;
1093
- }
1094
- const resource = await resolveCrudResource(inspection, module, objectProperty(config, "resource"));
1095
- const surface = staticValue(objectProperty(config, "surface"), module.bindings);
1096
- const routes = objectProperty(config, "routes");
1097
- if (routes && staticValue(routes, module.bindings) === false) {
1098
- return;
1099
- }
1100
- const relativePath = objectProperty(config, "relativePath");
1101
- const apiSuffix = relativePath ? staticValue(relativePath, module.bindings) : resource && `/${resource.namespace.replace(/_/gu, "-")}`;
1102
- const operations = crudControllerOperations(module, config, resource);
1103
- const literalSuffix = typeof apiSuffix === "string" && apiSuffix.startsWith("/");
1104
- const literalSurface = typeof surface === "string" && Boolean(surface.trim());
1105
- const routesEnabled = !routes || staticValue(routes, module.bindings) === true;
1106
- const knownOperations = Array.isArray(operations) && operations.length > 0 && operations.every((name) => CRUD_CONTROLLER_OPERATIONS.includes(name));
1107
- if (!resource || !literalSuffix || !literalSurface || !routesEnabled || !knownOperations) {
1108
- warn(inspection, "CRUD_CONTROLLER_UNRESOLVED", "A CRUD controller needs literal resource, surface and enabled operations.", file);
1109
- return;
1110
- }
1111
- const declaration = record.ancestors.find((entry) => entry.type === "VariableDeclarator" && entry.id.type === "Identifier");
1112
- if (!declaration || !module.exports.has(declaration.id.name)) {
1113
- return;
1114
- }
1115
- for (const operation of new Set(operations)) {
1116
- inspection.output.entryOperations.push({
1117
- id: `entry:crud:${file}:${node.start}:${operation}`,
1118
- key: crudOperationKey(inspection, surface.trim().toLowerCase(), resource.namespace, operation, apiSuffix),
1119
- controllerFile: file,
1120
- resolution: "resolved",
1121
- evidence: [...sourceEvidence(module, node, offset), ...resource.evidence]
1122
- });
1123
- }
1124
- }
1125
-
1126
- // `http.router.register(method, path, options, handler)` inside a
1127
- // `defineFeature` setup that receives `http`.
1128
- function recordFeatureRoute(inspection, { module, file, record, featureFactories }) {
1129
- const { node, ancestors, offset } = record;
1130
- if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression" || node.callee.computed || node.callee.property.name !== "register") {
1131
- return;
1132
- }
1133
- const router = node.callee.object;
1134
- if (router.type !== "MemberExpression" || router.computed || router.property.name !== "router" || router.object.type !== "Identifier") {
1135
- return;
1136
- }
1137
- const feature = ancestors.find((entry) => entry.type === "CallExpression" && entry.callee.type === "Identifier" && featureFactories.has(entry.callee.name));
1138
- const setup = ancestors.slice().reverse().find((entry) => entry.type === "ObjectMethod" && entry.key.name === "setup");
1139
- const setupParameter = setup?.params[0];
1140
- const dependency = setupParameter?.type === "ObjectPattern" &&
1141
- setupParameter.properties.find((entry) => entry.type === "ObjectProperty" && entry.key.name === "http");
1142
- if (!feature || dependency?.value?.name !== router.object.name) {
1143
- return;
1144
- }
1145
- const method = staticValue(node.arguments[0], module.bindings);
1146
- const operation = staticValue(node.arguments[1], module.bindings);
1147
- const handler = node.arguments[3];
1148
- if (typeof method !== "string" || typeof operation !== "string" || !handler || !isFunctionNode(handler)) {
1149
- warn(inspection, "CONTROLLER_UNRESOLVED", "A route's application handler or identity is dynamic.", file);
1150
- return;
1151
- }
1152
- const entry = {
1153
- id: `entry:${inspection.httpScope}:${file}:${node.start}`,
1154
- key: { protocol: "http", scope: inspection.httpScope, method: method.toUpperCase(), operation: `${inspection.apiBasePath}${operation}` },
1155
- controllerFile: file,
1156
- resolution: "resolved",
1157
- evidence: sourceEvidence(module, node, offset)
1158
- };
1159
- inspection.output.entryOperations.push(entry);
1160
- inspection.handlerRanges.set(entry, { file, start: offset + handler.start, end: offset + handler.end });
1161
- }
1162
-
1163
- // The literal value of a scope option: a string literal, or a local or
1164
- // imported const string literal. Undefined otherwise.
1165
- async function scopeOption(inspection, module, node, fallback) {
1166
- if (!node) {
1167
- return fallback;
1168
- }
1169
- if (node.type === "StringLiteral" || (node.type === "TemplateLiteral" && !node.expressions.length)) {
1170
- return staticValue(node, module.bindings);
1171
- }
1172
- if (node.type !== "Identifier") {
1173
- return undefined;
1174
- }
1175
- if (module.imports.has(node.name)) {
1176
- return importedConstant(inspection, module, node.name, new Set());
1177
- }
1178
- const value = module.constants.has(node.name) ? staticValue(module.bindings.get(node.name), module.bindings) : undefined;
1179
- return typeof value === "string" ? value : undefined;
1180
- }
1181
-
1182
- async function importedConstant(inspection, module, name, seen) {
1183
- const imported = module.imports.get(name);
1184
- const target = imported && resolveImport(inspection, module.path, imported.source);
1185
- const owner = target && await loadModule(inspection, target);
1186
- return owner ? exportedConstant(inspection, owner, imported.imported, seen) : undefined;
1187
- }
1188
-
1189
- async function exportedConstant(inspection, module, name, seen) {
1190
- const identity = `${module.path}:${name}`;
1191
- const entry = module.exports.get(name);
1192
- if (!entry || seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
1193
- return undefined;
1194
- }
1195
- const next = new Set([...seen, identity]);
1196
- if (entry.source) {
1197
- const target = resolveImport(inspection, module.path, entry.source);
1198
- const owner = target && await loadModule(inspection, target);
1199
- return owner ? exportedConstant(inspection, owner, entry.local, next) : undefined;
1200
- }
1201
- if (module.imports.has(entry.local)) {
1202
- return importedConstant(inspection, module, entry.local, next);
1203
- }
1204
- const value = module.constants.has(entry.local) ? staticValue(module.bindings.get(entry.local), module.bindings) : undefined;
1205
- return typeof value === "string" ? value : undefined;
1206
- }
1207
-
1208
- // The scope's API base path, computed exactly as createActionRouteScope does;
1209
- // null when an option is not a literal.
1210
- async function actionRouteScopeBase(inspection, module, node) {
1211
- const options = bindingNode(node.arguments[1], module.bindings);
1212
- if (options && (options.type !== "ObjectExpression" || options.properties.some((entry) => entry.type === "SpreadElement"))) {
1213
- return null;
1214
- }
1215
- const routeBase = await scopeOption(inspection, module, objectProperty(options, "routeBase"), "/");
1216
- const relativePath = await scopeOption(inspection, module, objectProperty(options, "relativePath"), "");
1217
- const surface = await scopeOption(inspection, module, objectProperty(options, "surface"), "");
1218
- const strictParams = staticValue(objectProperty(options, "strictParams"), module.bindings) ?? false;
1219
- if (typeof routeBase !== "string" || typeof relativePath !== "string" || typeof surface !== "string" || typeof strictParams !== "boolean") {
1220
- return null;
1221
- }
1222
- try {
1223
- return resolveScopedApiBasePath({ routeBase, relativePath, strictParams });
1224
- } catch {
1225
- return null;
1226
- }
1227
- }
1228
-
1229
- // The names a scope's route registrations are called through: the variable
1230
- // holding the scope, or destructured actionRoute / serviceRoute functions.
1231
- function scopeReceivers(record) {
1232
- const declarator = record.ancestors.at(-1);
1233
- if (declarator?.type !== "VariableDeclarator" || declarator.init !== record.node) {
1234
- return null;
1235
- }
1236
- const owner = record.ancestors.slice().reverse().find((node) => isFunctionNode(node) || node.type === "Program");
1237
- if (declarator.id.type === "Identifier") {
1238
- return { owner, object: declarator.id.name, functions: new Map() };
1239
- }
1240
- if (declarator.id.type !== "ObjectPattern") {
1241
- return null;
1242
- }
1243
- const functions = new Map();
1244
- for (const entry of declarator.id.properties) {
1245
- if (entry.type === "ObjectProperty" && entry.value.type === "Identifier" && ["actionRoute", "serviceRoute"].includes(propertyKey(entry))) {
1246
- functions.set(entry.value.name, propertyKey(entry));
1247
- }
1248
- }
1249
- return { owner, object: null, functions };
1250
- }
1251
-
1252
- function routeRegistrationKind({ node, ancestors }, receivers) {
1253
- if (node.type !== "CallExpression" || !ancestors.includes(receivers.owner)) {
1254
- return "";
1255
- }
1256
- const callee = node.callee;
1257
- if (callee.type === "Identifier") {
1258
- return receivers.functions.get(callee.name) || "";
1259
- }
1260
- const named = callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" &&
1261
- callee.object.name === receivers.object;
1262
- return named && ["actionRoute", "serviceRoute"].includes(callee.property.name) ? callee.property.name : "";
1263
- }
1264
-
1265
- function bindPattern(pattern, value, environment) {
1266
- if (!pattern) {
1267
- return;
1268
- }
1269
- if (pattern.type === "Identifier") {
1270
- environment.set(pattern.name, value);
1271
- } else if (pattern.type === "ArrayPattern") {
1272
- pattern.elements.forEach((element, index) => bindPattern(element, Array.isArray(value) ? value[index] : undefined, environment));
1273
- } else if (pattern.type === "ObjectPattern") {
1274
- for (const entry of pattern.properties) {
1275
- if (entry.type === "ObjectProperty" && !entry.computed) {
1276
- bindPattern(entry.value, value && typeof value === "object" ? value[propertyKey(entry)] : undefined, environment);
1277
- }
1278
- }
1279
- } else if (pattern.type === "AssignmentPattern") {
1280
- bindPattern(pattern.left, value, environment);
1281
- }
1282
- }
1283
-
1284
- // One environment per element of each enclosing `for...of` or `.map` over a
1285
- // literal array of at most 64 elements; null beyond that bound.
1286
- function unrolledEnvironments(module, { node, ancestors }) {
1287
- let environments = [new Map()];
1288
- const chain = [...ancestors, node];
1289
- chain.forEach((current, index) => {
1290
- let pattern;
1291
- let source;
1292
- if (current.type === "ForOfStatement" && chain.includes(current.body) && current.left.type === "VariableDeclaration") {
1293
- pattern = current.left.declarations[0]?.id;
1294
- source = current.right;
1295
- }
1296
- const call = chain[index - 1];
1297
- if (isFunctionNode(current) && call?.type === "CallExpression" && call.arguments[0] === current &&
1298
- call.callee.type === "MemberExpression" && !call.callee.computed && call.callee.property.name === "map") {
1299
- pattern = current.params[0];
1300
- source = call.callee.object;
1301
- }
1302
- const elements = source && staticValue(source, module.bindings);
1303
- if (!pattern || !Array.isArray(elements) || elements.length > MAX_UNROLLED_ELEMENTS) {
1304
- return;
1305
- }
1306
- environments = environments.flatMap((environment) => elements.map((element) => {
1307
- const next = new Map(environment);
1308
- bindPattern(pattern, element, next);
1309
- return next;
1310
- }));
1311
- });
1312
- return environments.length <= MAX_UNROLLED_ELEMENTS ? environments : null;
1313
- }
1314
-
1315
- // Routes registered through the kernel's createActionRouteScope. Scope
1316
- // options must be literals or const string literals; each route's method,
1317
- // suffix and actionId literals, templates or literal-array elements.
1318
- async function recordActionRouteScopes(inspection, { module, file }) {
1319
- const factories = importedNames(module, SOURCES.kernelHttp, "createActionRouteScope");
1320
- if (!factories.size) {
1321
- return;
1322
- }
1323
- for (const record of module.nodes.filter((entry) => isLocalCall(entry, factories))) {
1324
- const receivers = scopeReceivers(record);
1325
- if (!receivers) {
1326
- warn(inspection, "ACTION_ROUTE_SCOPE_UNBOUND", "An action route scope must be held in a local variable or destructured.", file);
1327
- continue;
1328
- }
1329
- const routeBase = await actionRouteScopeBase(inspection, module, record.node);
1330
- for (const registration of module.nodes) {
1331
- const kind = routeRegistrationKind(registration, receivers);
1332
- if (!kind) {
1333
- continue;
1334
- }
1335
- if (routeBase === null) {
1336
- warn(inspection, "ACTION_ROUTE_UNRESOLVED", "An action route scope needs literal routeBase, relativePath and surface options.", file);
1337
- continue;
1338
- }
1339
- recordScopedRoute(inspection, { module, file, routeBase, registration, kind });
1340
- }
1341
- }
1342
- }
1343
-
1344
- function recordScopedRoute(inspection, { module, file, routeBase, registration, kind }) {
1345
- const { node, offset } = registration;
1346
- const environments = unrolledEnvironments(module, registration);
1347
- const [methodNode, suffixNode, optionsNode, handler] = node.arguments;
1348
- const options = bindingNode(optionsNode, module.bindings);
1349
- (environments || [null]).forEach((environment, index) => {
1350
- const method = environment && staticValue(methodNode, module.bindings, new Set(), environment);
1351
- const suffix = environment && staticValue(suffixNode, module.bindings, new Set(), environment);
1352
- const actionId = kind === "actionRoute" && environment
1353
- ? staticValue(objectProperty(options, "actionId"), module.bindings, new Set(), environment)
1354
- : "";
1355
- const literalAction = kind !== "actionRoute" || (typeof actionId === "string" && Boolean(actionId.trim()));
1356
- if (typeof method !== "string" || !/^[A-Za-z]+$/u.test(method) || typeof suffix !== "string" || !literalAction) {
1357
- warn(inspection, "ACTION_ROUTE_UNRESOLVED", "A route's method, path suffix or actionId is not a literal or literal-array element.", file);
1358
- return;
1359
- }
1360
- const entry = {
1361
- id: `entry:${inspection.httpScope}:${file}:${offset + node.start}${environments.length > 1 ? `:${index}` : ""}`,
1362
- key: {
1363
- protocol: "http",
1364
- scope: inspection.httpScope,
1365
- method: method.toUpperCase(),
1366
- operation: `${routeBase}${suffix}`.replace(/\/+/gu, "/").replace(/(.)\/$/u, "$1")
1367
- },
1368
- controllerFile: file,
1369
- ...(kind === "actionRoute" ? { actionId: actionId.trim() } : {}),
1370
- resolution: "resolved",
1371
- evidence: sourceEvidence(module, node, offset)
1372
- };
1373
- inspection.output.entryOperations.push(entry);
1374
- if (handler && isFunctionNode(handler)) {
1375
- inspection.handlerRanges.set(entry, { file, start: offset + handler.start, end: offset + handler.end });
1376
- }
1377
- });
1378
- }
1379
-
1380
76
  function importSpecifierName(specifier) {
1381
77
  if (specifier.type === "ImportNamespaceSpecifier") {
1382
78
  return "*";
@@ -1551,6 +247,7 @@ async function inspectVueArchitecture({
1551
247
  sources: new Map(),
1552
248
  checkedDirectories: new Set(),
1553
249
  handlerRanges: new Map(),
250
+ routeRegistrations: new Set(),
1554
251
  components: new Map(),
1555
252
  pages: new Map()
1556
253
  };