@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.
@@ -0,0 +1,589 @@
1
+ import { resolveScopedApiBasePath } from "@jskit-ai/kernel/shared/surface";
2
+ import { MAX_RESOURCE_HOPS, SOURCES, loadModule, resolveImport, warn } from "./modules.js";
3
+ import { crudOperationKey, resolveCrudResource } from "./pages.js";
4
+ import {
5
+ bindingNode,
6
+ collectNodes,
7
+ hasOnlyStaticProperties,
8
+ importedNames,
9
+ isFunctionNode,
10
+ isLocalCall,
11
+ isShadowed,
12
+ isStaticObjectProperty,
13
+ objectProperty,
14
+ propertyKey,
15
+ sourceEvidence,
16
+ staticValue
17
+ } from "./syntax.js";
18
+
19
+ // Server entry operations: CRUD controllers and actions, feature routes,
20
+ // router routes and the API scope each action route is served under.
21
+
22
+ const CRUD_CONTROLLER_OPERATIONS = Object.freeze(["list", "view", "create", "update", "delete"]);
23
+ const SERVER_FILE_PATTERN = /\.(?:js|mjs|cjs|ts)$/u;
24
+ const MAX_UNROLLED_ELEMENTS = 64;
25
+ const MAX_ROUTER_HOPS = 4;
26
+
27
+ // Inspects the actual low-level JSKIT HTTP capability in a defineFeature setup
28
+ // and exported CRUD controller declarations. App-owned wrappers and dispatchers
29
+ // need their own inspector rather than a guessed match on a method name.
30
+ async function discoverEntryOperations(inspection) {
31
+ for (const file of inspection.files.keys()) {
32
+ if (!SERVER_FILE_PATTERN.test(file)) {
33
+ continue;
34
+ }
35
+ const module = await loadModule(inspection, file);
36
+ for (const record of module.nodes) {
37
+ await recordCrudController(inspection, { module, file, record });
38
+ }
39
+ await recordActionRouteScopes(inspection, { module, file });
40
+ const featureFactories = importedNames(module, SOURCES.kernelFeatures, "defineFeature");
41
+ for (const record of module.nodes.filter((entry) => isLocalCall(entry, featureFactories))) {
42
+ await recordFeatureRoutes(inspection, { module, record });
43
+ }
44
+ }
45
+ }
46
+
47
+ function crudControllerOperations(module, config, resource) {
48
+ const declared = objectProperty(config, "operations");
49
+ if (declared) {
50
+ return staticValue(declared, module.bindings);
51
+ }
52
+ return resource?.operations.map((name) => (name === "patch" ? "update" : name));
53
+ }
54
+
55
+ async function recordCrudController(inspection, { module, file, record }) {
56
+ const { node, offset } = record;
57
+ if (node.type !== "CallExpression" || node.callee.type !== "Identifier" || isShadowed(record, node.callee.name)) {
58
+ return;
59
+ }
60
+ const factory = module.imports.get(node.callee.name);
61
+ if (factory?.source !== SOURCES.crudJsonApiFeature || factory.imported !== "defineCrudJsonApiFeature") {
62
+ return;
63
+ }
64
+ if (record.ancestors.some(isFunctionNode)) {
65
+ return;
66
+ }
67
+ const config = bindingNode(node.arguments[0], module.bindings);
68
+ if (config?.type !== "ObjectExpression" || !hasOnlyStaticProperties(config)) {
69
+ warn(inspection, "CRUD_CONTROLLER_UNRESOLVED", "A CRUD controller declaration is dynamic.", file);
70
+ return;
71
+ }
72
+ const resource = await resolveCrudResource(inspection, module, objectProperty(config, "resource"));
73
+ const surface = staticValue(objectProperty(config, "surface"), module.bindings);
74
+ const routes = objectProperty(config, "routes");
75
+ if (routes && staticValue(routes, module.bindings) === false) {
76
+ return;
77
+ }
78
+ const relativePath = objectProperty(config, "relativePath");
79
+ const apiSuffix = relativePath ? staticValue(relativePath, module.bindings) : resource && `/${resource.namespace.replace(/_/gu, "-")}`;
80
+ const operations = crudControllerOperations(module, config, resource);
81
+ const literalSuffix = typeof apiSuffix === "string" && apiSuffix.startsWith("/");
82
+ const literalSurface = typeof surface === "string" && Boolean(surface.trim());
83
+ const routesEnabled = !routes || staticValue(routes, module.bindings) === true;
84
+ const knownOperations = Array.isArray(operations) && operations.length > 0 && operations.every((name) => CRUD_CONTROLLER_OPERATIONS.includes(name));
85
+ if (!resource || !literalSuffix || !literalSurface || !routesEnabled || !knownOperations) {
86
+ warn(inspection, "CRUD_CONTROLLER_UNRESOLVED", "A CRUD controller needs literal resource, surface and enabled operations.", file);
87
+ return;
88
+ }
89
+ const declaration = record.ancestors.find((entry) => entry.type === "VariableDeclarator" && entry.id.type === "Identifier");
90
+ if (!declaration || !module.exports.has(declaration.id.name)) {
91
+ return;
92
+ }
93
+ // Each route runs its operation's action, named as crud-core names it.
94
+ for (const operation of new Set(operations)) {
95
+ inspection.output.entryOperations.push({
96
+ id: `entry:crud:${file}:${node.start}:${operation}`,
97
+ key: crudOperationKey(inspection, surface.trim().toLowerCase(), resource.namespace, operation, apiSuffix),
98
+ controllerFile: file,
99
+ actionId: `crud.${resource.namespace}.${operation}`,
100
+ resolution: "resolved",
101
+ evidence: [...sourceEvidence(module, node, offset), ...resource.evidence]
102
+ });
103
+ }
104
+ await recordCrudActionRoutes(inspection, { module, record, config, namespace: resource.namespace, apiSuffix });
105
+ }
106
+
107
+ // The routes of a CRUD declaration's custom actions: each `actions` entry with
108
+ // a `route`, at the declaration's API base path plus the route's path, running
109
+ // the entry's action. The actions object may be bound locally or imported
110
+ // through named re-exports.
111
+ async function recordCrudActionRoutes(inspection, { module, record, config, namespace, apiSuffix }) {
112
+ const actions = objectProperty(config, "actions");
113
+ if (!actions) {
114
+ return;
115
+ }
116
+ const scope = bindingNode(objectProperty(config, "scope"), module.bindings);
117
+ const routeBase = scope ? staticValue(objectProperty(scope, "routeBase"), module.bindings) ?? "/" : "/";
118
+ const owner = await objectDeclaration(inspection, module, actions);
119
+ if (!owner || typeof routeBase !== "string") {
120
+ warn(inspection, "CRUD_ACTION_ROUTE_UNRESOLVED", "A CRUD declaration's actions need a literal object and scope routeBase.", module.path);
121
+ return;
122
+ }
123
+ const basePath = resolveScopedApiBasePath({ routeBase: routeBase.trim() || "/", relativePath: apiSuffix, strictParams: false });
124
+ const { bindings } = owner.module;
125
+ for (const property of owner.node.properties) {
126
+ const definition = isStaticObjectProperty(property) ? bindingNode(property.value, bindings) : null;
127
+ const route = bindingNode(objectProperty(definition, "route"), bindings);
128
+ if (definition?.type === "ObjectExpression" && !route) {
129
+ continue;
130
+ }
131
+ const name = definition && propertyKey(property);
132
+ const method = staticValue(objectProperty(route, "method"), bindings) ?? "POST";
133
+ const routePath = staticValue(objectProperty(route, "path"), bindings);
134
+ const declaredId = objectProperty(definition, "id") ? staticValue(objectProperty(definition, "id"), bindings) : "";
135
+ const id = typeof declaredId === "string" ? declaredId.trim() || `crud.${namespace}.${name}` : null;
136
+ if (route?.type !== "ObjectExpression" || typeof method !== "string" || !/^\s*[A-Za-z]+\s*$/u.test(method) ||
137
+ typeof routePath !== "string" || !routePath.trim().startsWith("/") || !id) {
138
+ warn(inspection, "CRUD_ACTION_ROUTE_UNRESOLVED", "A CRUD custom action route needs a literal method, path and action id.", owner.module.path);
139
+ continue;
140
+ }
141
+ inspection.output.entryOperations.push({
142
+ id: `entry:crud:${module.path}:${record.node.start}:action:${name}`,
143
+ key: { protocol: "http", scope: inspection.httpScope, method: method.trim().toUpperCase(), operation: `${basePath}${routePath.trim()}` },
144
+ controllerFile: owner.module.path,
145
+ actionId: id,
146
+ resolution: "resolved",
147
+ evidence: [...sourceEvidence(module, record.node, record.offset), ...sourceEvidence(owner.module, definition)]
148
+ });
149
+ }
150
+ }
151
+
152
+ // The object literal an expression names, through local bindings, imports and
153
+ // named re-exports: { module, node }, or null.
154
+ async function objectDeclaration(inspection, module, node, seen = new Set()) {
155
+ const value = bindingNode(node, module.bindings);
156
+ if (value?.type === "ObjectExpression") {
157
+ return hasOnlyStaticProperties(value) ? { module, node: value } : null;
158
+ }
159
+ const imported = value?.type === "Identifier" && module.imports.get(value.name);
160
+ return imported ? exportedObjectDeclaration(inspection, module, imported.source, imported.imported, seen) : null;
161
+ }
162
+
163
+ async function exportedObjectDeclaration(inspection, module, source, name, seen) {
164
+ const target = resolveImport(inspection, module.path, source);
165
+ const owner = target && await loadModule(inspection, target);
166
+ const entry = owner?.exports.get(name);
167
+ const identity = `${owner?.path}:export:${name}`;
168
+ if (!entry || seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
169
+ return null;
170
+ }
171
+ const next = new Set([...seen, identity]);
172
+ return entry.source
173
+ ? exportedObjectDeclaration(inspection, owner, entry.source, entry.local, next)
174
+ : objectDeclaration(inspection, owner, { type: "Identifier", name: entry.local }, next);
175
+ }
176
+
177
+ // Routes registered with `router.register(method, path, options, handler)` on
178
+ // the HTTP router a `defineFeature` setup receives: `http.router` of its
179
+ // destructured `http` dependency, or `dependencies.http.router`. The setup
180
+ // may register there or pass the router to project functions (local, or
181
+ // imported through named imports and re-exports, at most four calls deep)
182
+ // that register on the parameter receiving it.
183
+ async function recordFeatureRoutes(inspection, { module, record }) {
184
+ const definition = bindingNode(record.node.arguments[0], module.bindings);
185
+ const setup = definition?.type === "ObjectExpression" ? featureSetup(module, definition) : null;
186
+ const router = setup && setupRouter(setup);
187
+ if (router) {
188
+ await recordRouterRoutes(inspection, { module, fn: setup, router, depth: 0, seen: new Set() });
189
+ }
190
+ }
191
+
192
+ function featureSetup(module, definition) {
193
+ const member = definition.properties.find((entry) =>
194
+ ["ObjectMethod", "ObjectProperty"].includes(entry.type) && !entry.computed && propertyKey(entry) === "setup");
195
+ if (member?.type === "ObjectMethod") {
196
+ return member;
197
+ }
198
+ const value = member?.value.type === "Identifier" ? localFunction(module, member.value.name) : member?.value;
199
+ return value && isFunctionNode(value) ? value : null;
200
+ }
201
+
202
+ function isNamedMember(node, name) {
203
+ return node?.type === "MemberExpression" && !node.computed && node.property.type === "Identifier" && node.property.name === name;
204
+ }
205
+
206
+ // How a setup names its HTTP router, from its first parameter.
207
+ function setupRouter(setup) {
208
+ const parameter = setup.params[0];
209
+ if (parameter?.type === "Identifier") {
210
+ const { name } = parameter;
211
+ return {
212
+ name,
213
+ matches: (node) => isNamedMember(node, "router") && isNamedMember(node.object, "http") &&
214
+ node.object.object.type === "Identifier" && node.object.object.name === name
215
+ };
216
+ }
217
+ const dependency = parameter?.type === "ObjectPattern" &&
218
+ parameter.properties.find((entry) => entry.type === "ObjectProperty" && !entry.computed && propertyKey(entry) === "http");
219
+ if (dependency?.value?.type !== "Identifier") {
220
+ return null;
221
+ }
222
+ const { name } = dependency.value;
223
+ return {
224
+ name,
225
+ matches: (node) => isNamedMember(node, "router") && node.object.type === "Identifier" && node.object.name === name
226
+ };
227
+ }
228
+
229
+ function parameterRouter(parameter) {
230
+ const name = parameter?.type === "AssignmentPattern" ? parameter.left.name : parameter?.name;
231
+ return parameter && [parameter.type, parameter.left?.type].includes("Identifier") && name
232
+ ? { name, matches: (node) => node.type === "Identifier" && node.name === name }
233
+ : null;
234
+ }
235
+
236
+ // Whether a node lies inside `fn`, with no function in between binding `name`.
237
+ function bindsWithin(record, fn, name) {
238
+ for (let current = record.parent; current; current = current.parent) {
239
+ if (current.node === fn) {
240
+ return true;
241
+ }
242
+ if (current.node.params?.some((parameter) => collectNodes(parameter).some(({ node }) => node.type === "Identifier" && node.name === name))) {
243
+ return false;
244
+ }
245
+ }
246
+ return false;
247
+ }
248
+
249
+ async function recordRouterRoutes(inspection, { module, fn, router, depth, seen }) {
250
+ const identity = `${module.path}:${fn.start}:${router.name}`;
251
+ if (seen.has(identity)) {
252
+ return;
253
+ }
254
+ seen.add(identity);
255
+ const calls = module.nodes
256
+ .filter((record) => record.node.type === "CallExpression" && bindsWithin(record, fn, router.name))
257
+ .sort((left, right) => left.node.start - right.node.start);
258
+ for (const record of calls) {
259
+ const { callee, arguments: args } = record.node;
260
+ if (isNamedMember(callee, "register") && router.matches(callee.object)) {
261
+ recordRouterRoute(inspection, module, record);
262
+ continue;
263
+ }
264
+ const position = args.findIndex((argument) => router.matches(argument));
265
+ const target = position >= 0 && depth < MAX_ROUTER_HOPS && callee.type === "Identifier"
266
+ ? await projectFunction(inspection, module, callee.name)
267
+ : null;
268
+ const next = target && parameterRouter(target.fn.params[position]);
269
+ if (next) {
270
+ await recordRouterRoutes(inspection, { module: target.module, fn: target.fn, router: next, depth: depth + 1, seen });
271
+ }
272
+ }
273
+ }
274
+
275
+ function recordRouterRoute(inspection, module, record) {
276
+ const { node, offset } = record;
277
+ const identity = `${module.path}:${offset + node.start}`;
278
+ if (inspection.routeRegistrations.has(identity)) {
279
+ return;
280
+ }
281
+ inspection.routeRegistrations.add(identity);
282
+ const method = routeLiteral(module, node.arguments[0]);
283
+ const operation = routeLiteral(module, node.arguments[1]);
284
+ const handler = node.arguments[3];
285
+ if (typeof method !== "string" || !/^[A-Za-z]+$/u.test(method) || typeof operation !== "string" || !handler || !isFunctionNode(handler)) {
286
+ warn(inspection, "CONTROLLER_UNRESOLVED", "A route's application handler or identity is dynamic.", module.path);
287
+ return;
288
+ }
289
+ const entry = {
290
+ id: `entry:${inspection.httpScope}:${module.path}:${node.start}`,
291
+ key: { protocol: "http", scope: inspection.httpScope, method: method.toUpperCase(), operation: `${inspection.apiBasePath}${operation}` },
292
+ controllerFile: module.path,
293
+ resolution: "resolved",
294
+ evidence: sourceEvidence(module, node, offset)
295
+ };
296
+ inspection.output.entryOperations.push(entry);
297
+ inspection.handlerRanges.set(entry, { file: module.path, start: offset + handler.start, end: offset + handler.end });
298
+ }
299
+
300
+ // A route method or path, as staticValue reads it, where a call of the
301
+ // kernel's resolveScopedApiBasePath with literal options (directly, through a
302
+ // local binding or inside a template literal) stands for the path it returns.
303
+ function routeLiteral(module, node, seen = new Set()) {
304
+ if (node?.type === "Identifier" && module.bindings.has(node.name) && !seen.has(node.name)) {
305
+ return routeLiteral(module, module.bindings.get(node.name), new Set([...seen, node.name]));
306
+ }
307
+ if (node?.type === "TemplateLiteral") {
308
+ const parts = node.expressions.map((entry) => routeLiteral(module, entry, seen));
309
+ return parts.every((part) => typeof part === "string")
310
+ ? node.quasis.map((entry, index) => entry.value.cooked + (parts[index] ?? "")).join("")
311
+ : undefined;
312
+ }
313
+ if (node?.type === "CallExpression" && node.callee.type === "Identifier" &&
314
+ module.imports.get(node.callee.name)?.source.startsWith("@jskit-ai/kernel/") &&
315
+ module.imports.get(node.callee.name).imported === "resolveScopedApiBasePath") {
316
+ const options = staticValue(node.arguments[0], module.bindings);
317
+ if (!options || typeof options !== "object" || Array.isArray(options) || "params" in options) {
318
+ return undefined;
319
+ }
320
+ try {
321
+ return resolveScopedApiBasePath(options);
322
+ } catch {
323
+ return undefined;
324
+ }
325
+ }
326
+ return staticValue(node, module.bindings, seen);
327
+ }
328
+
329
+ // The project function a name refers to in a module: a top-level function
330
+ // declaration or a function bound to the name, or one imported through named
331
+ // imports and re-exports of indexed files.
332
+ async function projectFunction(inspection, module, name, seen = new Set()) {
333
+ const identity = `${module.path}:${name}`;
334
+ if (seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
335
+ return null;
336
+ }
337
+ const next = new Set([...seen, identity]);
338
+ const imported = module.imports.get(name);
339
+ if (!imported) {
340
+ const fn = localFunction(module, name);
341
+ return fn ? { module, fn } : null;
342
+ }
343
+ return exportedFunction(inspection, module, imported.source, imported.imported, next);
344
+ }
345
+
346
+ async function exportedFunction(inspection, module, source, name, seen) {
347
+ const target = resolveImport(inspection, module.path, source);
348
+ const owner = target && await loadModule(inspection, target);
349
+ const entry = owner?.exports.get(name);
350
+ const identity = `${owner?.path}:export:${name}`;
351
+ if (!entry || seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
352
+ return null;
353
+ }
354
+ const next = new Set([...seen, identity]);
355
+ return entry.source
356
+ ? exportedFunction(inspection, owner, entry.source, entry.local, next)
357
+ : projectFunction(inspection, owner, entry.local, next);
358
+ }
359
+
360
+ function localFunction(module, name) {
361
+ const declaration = module.nodes.find(({ node, parent }) => node.type === "FunctionDeclaration" && node.id?.name === name &&
362
+ ["Program", "ExportNamedDeclaration"].includes(parent?.node.type));
363
+ if (declaration) {
364
+ return declaration.node;
365
+ }
366
+ const value = module.bindings.get(name);
367
+ return value && isFunctionNode(value) ? value : null;
368
+ }
369
+
370
+ // The literal value of a scope option: a string literal, or a local or
371
+ // imported const string literal. Undefined otherwise.
372
+ async function scopeOption(inspection, module, node, fallback) {
373
+ if (!node) {
374
+ return fallback;
375
+ }
376
+ if (node.type === "StringLiteral" || (node.type === "TemplateLiteral" && !node.expressions.length)) {
377
+ return staticValue(node, module.bindings);
378
+ }
379
+ if (node.type !== "Identifier") {
380
+ return undefined;
381
+ }
382
+ if (module.imports.has(node.name)) {
383
+ return importedConstant(inspection, module, node.name, new Set());
384
+ }
385
+ const value = module.constants.has(node.name) ? staticValue(module.bindings.get(node.name), module.bindings) : undefined;
386
+ return typeof value === "string" ? value : undefined;
387
+ }
388
+
389
+ async function importedConstant(inspection, module, name, seen) {
390
+ const imported = module.imports.get(name);
391
+ const target = imported && resolveImport(inspection, module.path, imported.source);
392
+ const owner = target && await loadModule(inspection, target);
393
+ return owner ? exportedConstant(inspection, owner, imported.imported, seen) : undefined;
394
+ }
395
+
396
+ async function exportedConstant(inspection, module, name, seen) {
397
+ const identity = `${module.path}:${name}`;
398
+ const entry = module.exports.get(name);
399
+ if (!entry || seen.has(identity) || seen.size >= MAX_RESOURCE_HOPS) {
400
+ return undefined;
401
+ }
402
+ const next = new Set([...seen, identity]);
403
+ if (entry.source) {
404
+ const target = resolveImport(inspection, module.path, entry.source);
405
+ const owner = target && await loadModule(inspection, target);
406
+ return owner ? exportedConstant(inspection, owner, entry.local, next) : undefined;
407
+ }
408
+ if (module.imports.has(entry.local)) {
409
+ return importedConstant(inspection, module, entry.local, next);
410
+ }
411
+ const value = module.constants.has(entry.local) ? staticValue(module.bindings.get(entry.local), module.bindings) : undefined;
412
+ return typeof value === "string" ? value : undefined;
413
+ }
414
+
415
+ // The scope's API base path, computed exactly as createActionRouteScope does;
416
+ // null when an option is not a literal.
417
+ async function actionRouteScopeBase(inspection, module, node) {
418
+ const options = bindingNode(node.arguments[1], module.bindings);
419
+ if (options && (options.type !== "ObjectExpression" || options.properties.some((entry) => entry.type === "SpreadElement"))) {
420
+ return null;
421
+ }
422
+ const routeBase = await scopeOption(inspection, module, objectProperty(options, "routeBase"), "/");
423
+ const relativePath = await scopeOption(inspection, module, objectProperty(options, "relativePath"), "");
424
+ const surface = await scopeOption(inspection, module, objectProperty(options, "surface"), "");
425
+ const strictParams = staticValue(objectProperty(options, "strictParams"), module.bindings) ?? false;
426
+ if (typeof routeBase !== "string" || typeof relativePath !== "string" || typeof surface !== "string" || typeof strictParams !== "boolean") {
427
+ return null;
428
+ }
429
+ try {
430
+ return resolveScopedApiBasePath({ routeBase, relativePath, strictParams });
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+
436
+ // The names a scope's route registrations are called through: the variable
437
+ // holding the scope, or destructured actionRoute / serviceRoute functions.
438
+ function scopeReceivers(record) {
439
+ const declarator = record.ancestors.at(-1);
440
+ if (declarator?.type !== "VariableDeclarator" || declarator.init !== record.node) {
441
+ return null;
442
+ }
443
+ const owner = record.ancestors.slice().reverse().find((node) => isFunctionNode(node) || node.type === "Program");
444
+ if (declarator.id.type === "Identifier") {
445
+ return { owner, object: declarator.id.name, functions: new Map() };
446
+ }
447
+ if (declarator.id.type !== "ObjectPattern") {
448
+ return null;
449
+ }
450
+ const functions = new Map();
451
+ for (const entry of declarator.id.properties) {
452
+ if (entry.type === "ObjectProperty" && entry.value.type === "Identifier" && ["actionRoute", "serviceRoute"].includes(propertyKey(entry))) {
453
+ functions.set(entry.value.name, propertyKey(entry));
454
+ }
455
+ }
456
+ return { owner, object: null, functions };
457
+ }
458
+
459
+ function routeRegistrationKind({ node, ancestors }, receivers) {
460
+ if (node.type !== "CallExpression" || !ancestors.includes(receivers.owner)) {
461
+ return "";
462
+ }
463
+ const callee = node.callee;
464
+ if (callee.type === "Identifier") {
465
+ return receivers.functions.get(callee.name) || "";
466
+ }
467
+ const named = callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" &&
468
+ callee.object.name === receivers.object;
469
+ return named && ["actionRoute", "serviceRoute"].includes(callee.property.name) ? callee.property.name : "";
470
+ }
471
+
472
+ function bindPattern(pattern, value, environment) {
473
+ if (!pattern) {
474
+ return;
475
+ }
476
+ if (pattern.type === "Identifier") {
477
+ environment.set(pattern.name, value);
478
+ } else if (pattern.type === "ArrayPattern") {
479
+ pattern.elements.forEach((element, index) => bindPattern(element, Array.isArray(value) ? value[index] : undefined, environment));
480
+ } else if (pattern.type === "ObjectPattern") {
481
+ for (const entry of pattern.properties) {
482
+ if (entry.type === "ObjectProperty" && !entry.computed) {
483
+ bindPattern(entry.value, value && typeof value === "object" ? value[propertyKey(entry)] : undefined, environment);
484
+ }
485
+ }
486
+ } else if (pattern.type === "AssignmentPattern") {
487
+ bindPattern(pattern.left, value, environment);
488
+ }
489
+ }
490
+
491
+ // One environment per element of each enclosing `for...of` or `.map` over a
492
+ // literal array of at most 64 elements; null beyond that bound.
493
+ function unrolledEnvironments(module, { node, ancestors }) {
494
+ let environments = [new Map()];
495
+ const chain = [...ancestors, node];
496
+ chain.forEach((current, index) => {
497
+ let pattern;
498
+ let source;
499
+ if (current.type === "ForOfStatement" && chain.includes(current.body) && current.left.type === "VariableDeclaration") {
500
+ pattern = current.left.declarations[0]?.id;
501
+ source = current.right;
502
+ }
503
+ const call = chain[index - 1];
504
+ if (isFunctionNode(current) && call?.type === "CallExpression" && call.arguments[0] === current &&
505
+ call.callee.type === "MemberExpression" && !call.callee.computed && call.callee.property.name === "map") {
506
+ pattern = current.params[0];
507
+ source = call.callee.object;
508
+ }
509
+ const elements = source && staticValue(source, module.bindings);
510
+ if (!pattern || !Array.isArray(elements) || elements.length > MAX_UNROLLED_ELEMENTS) {
511
+ return;
512
+ }
513
+ environments = environments.flatMap((environment) => elements.map((element) => {
514
+ const next = new Map(environment);
515
+ bindPattern(pattern, element, next);
516
+ return next;
517
+ }));
518
+ });
519
+ return environments.length <= MAX_UNROLLED_ELEMENTS ? environments : null;
520
+ }
521
+
522
+ // Routes registered through the kernel's createActionRouteScope. Scope
523
+ // options must be literals or const string literals; each route's method,
524
+ // suffix and actionId literals, templates or literal-array elements.
525
+ async function recordActionRouteScopes(inspection, { module, file }) {
526
+ const factories = importedNames(module, SOURCES.kernelHttp, "createActionRouteScope");
527
+ if (!factories.size) {
528
+ return;
529
+ }
530
+ for (const record of module.nodes.filter((entry) => isLocalCall(entry, factories))) {
531
+ const receivers = scopeReceivers(record);
532
+ if (!receivers) {
533
+ warn(inspection, "ACTION_ROUTE_SCOPE_UNBOUND", "An action route scope must be held in a local variable or destructured.", file);
534
+ continue;
535
+ }
536
+ const routeBase = await actionRouteScopeBase(inspection, module, record.node);
537
+ for (const registration of module.nodes) {
538
+ const kind = routeRegistrationKind(registration, receivers);
539
+ if (!kind) {
540
+ continue;
541
+ }
542
+ if (routeBase === null) {
543
+ warn(inspection, "ACTION_ROUTE_UNRESOLVED", "An action route scope needs literal routeBase, relativePath and surface options.", file);
544
+ continue;
545
+ }
546
+ recordScopedRoute(inspection, { module, file, routeBase, registration, kind });
547
+ }
548
+ }
549
+ }
550
+
551
+ function recordScopedRoute(inspection, { module, file, routeBase, registration, kind }) {
552
+ const { node, offset } = registration;
553
+ const environments = unrolledEnvironments(module, registration);
554
+ const [methodNode, suffixNode, optionsNode, handler] = node.arguments;
555
+ const options = bindingNode(optionsNode, module.bindings);
556
+ (environments || [null]).forEach((environment, index) => {
557
+ const method = environment && staticValue(methodNode, module.bindings, new Set(), environment);
558
+ const suffix = environment && staticValue(suffixNode, module.bindings, new Set(), environment);
559
+ const actionId = kind === "actionRoute" && environment
560
+ ? staticValue(objectProperty(options, "actionId"), module.bindings, new Set(), environment)
561
+ : "";
562
+ const literalAction = kind !== "actionRoute" || (typeof actionId === "string" && Boolean(actionId.trim()));
563
+ if (typeof method !== "string" || !/^[A-Za-z]+$/u.test(method) || typeof suffix !== "string" || !literalAction) {
564
+ warn(inspection, "ACTION_ROUTE_UNRESOLVED", "A route's method, path suffix or actionId is not a literal or literal-array element.", file);
565
+ return;
566
+ }
567
+ const entry = {
568
+ id: `entry:${inspection.httpScope}:${file}:${offset + node.start}${environments.length > 1 ? `:${index}` : ""}`,
569
+ key: {
570
+ protocol: "http",
571
+ scope: inspection.httpScope,
572
+ method: method.toUpperCase(),
573
+ operation: `${routeBase}${suffix}`.replace(/\/+/gu, "/").replace(/(.)\/$/u, "$1")
574
+ },
575
+ controllerFile: file,
576
+ ...(kind === "actionRoute" ? { actionId: actionId.trim() } : {}),
577
+ resolution: "resolved",
578
+ evidence: sourceEvidence(module, node, offset)
579
+ };
580
+ inspection.output.entryOperations.push(entry);
581
+ if (handler && isFunctionNode(handler)) {
582
+ inspection.handlerRanges.set(entry, { file, start: offset + handler.start, end: offset + handler.end });
583
+ }
584
+ });
585
+ }
586
+
587
+ export {
588
+ discoverEntryOperations
589
+ };