@jskit-ai/architecture-inspector 0.1.0 → 0.1.1

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