@jskit-ai/shell-web 0.1.250 → 0.1.252

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