@jskit-ai/shell-web 0.1.239 → 0.1.240

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # Shell Web
2
+
3
+ The existing client exports provide the JSKIT web shell, navigation and outlet
4
+ placements. `@jskit-ai/shell-web/architecture` also exports
5
+ `inspectVueArchitecture({ projectRoot, input, aliases, httpScope, apiBasePath })`
6
+ for finite static architecture inspection.
7
+
8
+ ## Architecture inspection
9
+
10
+ Imported assets remain file-import evidence. The Vue adapter parses only its
11
+ supported JavaScript, TypeScript and Vue modules; binary images do not block
12
+ route discovery. Asset-to-component transformations require their own adapter
13
+ and remain explicitly unsupported here.
14
+
15
+ Run `jskit-inspect-vue` in the project directory. It reads one bounded JSON
16
+ document from stdin and writes one `genesis.architecture-observations.v1` JSON
17
+ document to stdout. Errors use stderr and a failing exit status. It does not
18
+ import application code, start a server, generate router declarations or retain
19
+ a watcher. The framework package has no Genesis or Vibe64 dependency.
20
+
21
+ Input supplies `schema: "vibe64.architecture-inspection-input.v1"`, indexed
22
+ `files: [{ path, hash }]` and `uiDiscovery.discoveries`. Optional `packages`
23
+ carry `id`, `name`, `manifest`, `declarations`, `root`, `opaque` and public
24
+ `entryPoints: [{ importPath, file }]`; `packageIdentity` binds observations to
25
+ the same inspected public boundaries. Paths are project-relative
26
+ and hashes use `sha256:<hex>`. Only declared, hash-matching UTF-8 files are read;
27
+ symlinks and files over 4 MiB are rejected.
28
+
29
+ Each discovery supplies `id`, `adapter`, `surface`, `mode`, `registrations`,
30
+ `pageRoots` and `componentRoots`. Supported adapters are `jskit`,
31
+ `vue-registered-router` and `vue-filesystem-router`.
32
+
33
+ - Registered mode reads literal Vue Router `createRouter({ routes })` declarations
34
+ from the registration files. Static local arrays and literal child routes work.
35
+ - Filesystem mode reads literal `VueRouter(...)` options from the registration
36
+ files and uses Vue Router's own scanner with declarations and watchers disabled.
37
+ Its actual `routesFolder` determines pages; a directory alone is not proof.
38
+ - Template component uses come from actual referenced imports. Shared definitions
39
+ retain their source identity. Unused imports create no composition relationship.
40
+ - Imported external elements retain the declared package/import-path/symbol owner.
41
+ Their implementation files are not read. Project-owned workspace exports can
42
+ resolve to indexed source files.
43
+ - Static imports from indexed JavaScript, TypeScript and Vue files also provide
44
+ optional `imports` observations. The framework resolver owns declared aliases,
45
+ root-relative paths and file resolution. Each record retains the source range,
46
+ current input hashes, target file and imported names, including aliases,
47
+ `default`, namespace `*` and type-only classification. These are import facts,
48
+ not evidence of runtime calls. Coverage remains partial; namespace use does
49
+ not establish which individual exports are consumed.
50
+ - An explicit `--http-scope shop --api-base-path /api` enables scoped literal
51
+ `fetch` uses and receivers registered through JSKIT's `defineFeature` setup
52
+ `http.router.register`. Use `--alias @=src` for declared project aliases.
53
+ - Actual `CrudListScreen` / `CrudAddEditScreen` uses whose bound screen comes from
54
+ the matching JSKIT composable can connect to exported `defineCrudJsonApiFeature`
55
+ controller declarations. The resource must resolve through indexed imports and
56
+ named re-exports to a literal `defineCrudResource` namespace and enabled
57
+ operations. Scope includes the explicit HTTP application identity, declared
58
+ surface and API suffix; the key uses `jskit.crud` resource operations rather
59
+ than claiming a fully expanded runtime URL. Custom clients, transport/resource
60
+ overrides, dynamic operations and disabled routes do not create guessed links.
61
+ This shows declared entry points; runtime provider activation is not proven.
62
+
63
+ Dynamic routing, template component tokens, runtime placement predicates,
64
+ application request wrappers and dispatcher conventions require more evidence or
65
+ an application-owned inspector. They remain partial or unsupported, never
66
+ guessed. This command does not claim complete runtime call analysis. Protocols
67
+ other than HTTP can use the same observation contract through their own inspector.
68
+
69
+ Example consumer-owned Stack operation:
70
+
71
+ ```markdown
72
+ ## Architecture inspection
73
+
74
+ ### Inspector `vue`: Inspect Vue pages
75
+
76
+ - Runtime: `nodejs`
77
+ - Directory: `.`
78
+ - Command: `node` `node_modules/@jskit-ai/shell-web/src/architecture/cli.js` `--alias` `@=src`
79
+ - Timeout seconds: `30`
80
+ - Max output bytes: `4194304`
81
+ ```
82
+
83
+ The consumer owns command admission, runtime selection, cancellation, publication
84
+ and validation against the indexed snapshot. It must retain the last valid
85
+ snapshot when an inspector fails or source changes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/shell-web",
3
- "version": "0.1.239",
3
+ "version": "0.1.240",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -25,13 +25,16 @@
25
25
  "./client/components/ShellTabLinkItem": "./src/client/components/ShellTabLinkItem.vue",
26
26
  "./client/composables/useShellLayoutState": "./src/client/composables/useShellLayoutState.js",
27
27
  "./client/providers/ShellWebClientProvider": "./src/client/providers/ShellWebClientProvider.js",
28
- "./test/adaptiveShellSmoke": "./src/test/adaptiveShellSmoke.js"
28
+ "./test/adaptiveShellSmoke": "./src/test/adaptiveShellSmoke.js",
29
+ "./architecture": "./src/architecture/inspectVueArchitecture.js"
29
30
  },
30
31
  "dependencies": {
31
- "@mdi/js": "^7.4.47"
32
+ "@mdi/js": "^7.4.47",
33
+ "@babel/parser": "7.29.9",
34
+ "@vue/compiler-sfc": "3.5.43"
32
35
  },
33
36
  "peerDependencies": {
34
- "@jskit-ai/kernel": "0.1.235",
37
+ "@jskit-ai/kernel": "0.1.236",
35
38
  "pinia": "^4.0.3",
36
39
  "vue": "^3.5.43",
37
40
  "vue-router": "^5.3.1",
@@ -379,5 +382,8 @@
379
382
  }
380
383
  }
381
384
  }
385
+ },
386
+ "bin": {
387
+ "jskit-inspect-vue": "./src/architecture/cli.js"
382
388
  }
383
389
  }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { inspectVueArchitecture } from "./inspectVueArchitecture.js";
4
+
5
+ try {
6
+ const { values } = parseArgs({ options: {
7
+ "http-scope": { type: "string", default: "" },
8
+ "api-base-path": { type: "string", default: "" },
9
+ alias: { type: "string", multiple: true, default: [] }
10
+ } });
11
+ const chunks = [];
12
+ let length = 0;
13
+ for await (const chunk of process.stdin) {
14
+ length += chunk.length;
15
+ if (length > 4 * 1024 * 1024) throw new Error("Architecture inspection input exceeds 4 MiB.");
16
+ chunks.push(chunk);
17
+ }
18
+ const aliases = {};
19
+ for (const entry of values.alias) {
20
+ const separator = entry.indexOf("=");
21
+ if (separator < 1 || separator === entry.length - 1) throw new Error("Use --alias name=project-directory.");
22
+ const name = entry.slice(0, separator);
23
+ if (Object.hasOwn(aliases, name)) throw new Error("Duplicate architecture alias.");
24
+ aliases[name] = entry.slice(separator + 1);
25
+ }
26
+ const result = await inspectVueArchitecture({
27
+ projectRoot: process.cwd(), input: JSON.parse(Buffer.concat(chunks).toString("utf8")),
28
+ aliases, httpScope: values["http-scope"], apiBasePath: values["api-base-path"]
29
+ });
30
+ process.stdout.write(`${JSON.stringify(result)}\n`);
31
+ } catch (error) {
32
+ process.stderr.write(`${error.message}\n`);
33
+ process.exitCode = 1;
34
+ }
@@ -0,0 +1,400 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parse as parseJavaScript } from "@babel/parser";
5
+ import { parse as parseSfc } from "@vue/compiler-sfc";
6
+ import { createRoutesContext, resolveOptions } from "vue-router/unplugin";
7
+
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;
11
+
12
+ function nodes(root) {
13
+ const result = []; const pending = [{ node: root, ancestors: [] }];
14
+ while (pending.length) {
15
+ const current = pending.pop(); const { node, ancestors } = current;
16
+ if (!node || typeof node.type !== "string") continue;
17
+ result.push(current);
18
+ for (const value of Object.values(node)) {
19
+ 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] });
21
+ }
22
+ }
23
+ return result;
24
+ }
25
+
26
+ 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]));
31
+ if (node.type === "ArrayExpression") {
32
+ const values = node.elements.map(entry => staticValue(entry, bindings, seen));
33
+ return values.every(value => value !== undefined) ? values : undefined;
34
+ }
35
+ 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;
43
+ }
44
+ 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;
47
+ return node.quasis.map((entry, index) => entry.value.cooked + (values[index] || "")).join("");
48
+ }
49
+ if (node.type === "BinaryExpression" && node.operator === "+") {
50
+ const left = staticValue(node.left, bindings, seen); const right = staticValue(node.right, bindings, seen);
51
+ return typeof left === "string" && typeof right === "string" ? left + right : undefined;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ function bindingNode(node, bindings, seen = new Set()) {
57
+ if (node?.type !== "Identifier" || seen.has(node.name) || !bindings.has(node.name)) return node;
58
+ return bindingNode(bindings.get(node.name), bindings, new Set([...seen, node.name]));
59
+ }
60
+
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.");
65
+ const files = new Map();
66
+ 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.");
68
+ files.set(file.path, file);
69
+ }
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();
73
+ 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;
88
+ }
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 });
101
+ }
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 });
116
+ }
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
+ }
136
+ }
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;
160
+ }
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 || [];
204
+ let supported = 0;
205
+ 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; }
207
+ 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; }
209
+ 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
+ }
234
+ } 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++;
257
+ }
258
+ }
259
+ }
260
+ }
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 }));
263
+ 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 || []));
277
+ }
278
+ 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
+ }
300
+ }
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));
348
+ 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);
388
+ 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) });
397
+ }
398
+ }
399
+ return output;
400
+ }
@@ -0,0 +1,154 @@
1
+ import assert from "node:assert/strict";
2
+ import { createHash } from "node:crypto";
3
+ import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { inspectVueArchitecture } from "../src/architecture/inspectVueArchitecture.js";
8
+
9
+ async function fixture(t, source, discovery) {
10
+ const root = await mkdtemp(path.join(os.tmpdir(), "jskit-architecture-"));
11
+ t.after(() => rm(root, { recursive: true, force: true }));
12
+ const files = [];
13
+ for (const [file, text] of Object.entries(source)) {
14
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true });
15
+ await writeFile(path.join(root, file), text);
16
+ files.push({ path: file, hash: `sha256:${createHash("sha256").update(text).digest("hex")}` });
17
+ }
18
+ return { root, input: { schema: "vibe64.architecture-inspection-input.v1", files, uiDiscovery: { discoveries: [discovery] } } };
19
+ }
20
+
21
+ test('source import evidence resolves configured aliases and project-root paths without treating every target export as imported', async t => {
22
+ const { root, input } = await fixture(t, {
23
+ 'src/tools.js': 'export function run() {} export function internal() {}',
24
+ 'src/use.vue': '<template>Use</template>\n<script setup>import { run as invoke } from "@/tools.js"; import * as tools from "/src/tools.js"; invoke();</script>',
25
+ 'outside.js': 'import { run } from "@/tools.js";',
26
+ }, { id: 'unused', adapter: 'native', surface: 'app', mode: 'registered', registrations: [], pageRoots: [] });
27
+ const result = await inspectVueArchitecture({ projectRoot: root, input, aliases: { '@': 'src' } });
28
+ assert.equal(result.imports.length, 3);
29
+ assert.ok(result.imports.every(use => use.targetFile === 'src/tools.js' && use.resolution === 'resolved'));
30
+ assert.deepEqual(result.imports.map(use => use.names).sort(), [['*'], ['run'], ['run']].sort());
31
+ assert.equal(result.imports.some(use => use.names.includes('internal')), false);
32
+ for (const use of result.imports) {
33
+ const range = use.evidence[0];
34
+ const line = (await readFile(path.join(root, range.path), 'utf8')).split('\n')[range.start.line - 1];
35
+ assert.match(line.slice(range.start.column - 1, range.end.column - 1), /^import /);
36
+ }
37
+ assert.equal(result.inputs.some(file => file.path.startsWith('node_modules/')), false);
38
+ });
39
+
40
+ test('binary asset imports do not prevent route and cross-file import inspection', async t => {
41
+ const { root, input } = await fixture(t, {
42
+ 'routes.js': 'import { createRouter } from "vue-router"; import Screen from "./Screen.vue"; createRouter({ routes: [{ path: "/", component: Screen }] });',
43
+ 'Screen.vue': '<script setup>import image from "@/image.png";</script><template><img :src="image" /></template>',
44
+ 'src/image.png': Buffer.from([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00]),
45
+ }, { id: 'app', adapter: 'vue-registered-router', surface: 'app', mode: 'registered', registrations: ['routes.js'], pageRoots: [] });
46
+ const result = await inspectVueArchitecture({ projectRoot: root, input, aliases: { '@': 'src' } });
47
+ assert.equal(result.pages.length, 1);
48
+ assert.equal(result.pages[0].file, 'Screen.vue');
49
+ assert.ok(result.imports.some(use => use.targetFile === 'src/image.png' && use.names.includes('default')));
50
+ assert.ok(result.inputs.some(file => file.path === 'src/image.png' && file.hash === input.files.find(file => file.path === 'src/image.png').hash));
51
+ assert.deepEqual(result.diagnostics, []);
52
+ });
53
+
54
+ test("explicit registered routes discover real pages and referenced shared elements", async t => {
55
+ const source = {
56
+ "routes.ts": 'import { createRouter } from "vue-router";\nimport Checkout from "./views/Checkout.vue";\nconst routes = [{ path: "/shop", component: Checkout, children: [{ path: "detail", component: () => import("./views/Detail.vue") }] }];\ncreateRouter({ routes });\n',
57
+ "views/Checkout.vue": '<template><SharedForm /></template>\n<script setup>import SharedForm from "../packages/payments/SharedForm.vue"; import Unused from "../Unused.vue";</script>\n',
58
+ "views/Detail.vue": '<template>Detail</template>\n',
59
+ "packages/payments/SharedForm.vue": '<template><button @click="save">Save</button></template>\n<script setup>async function save() { await fetch("/api/save", { method: "POST" }); } function unused() { fetch("/api/unused"); }</script>\n',
60
+ "Unused.vue": '<template>Unused</template>\n',
61
+ "engine/Feature.js": 'import { defineFeature } from "@jskit-ai/kernel/server/features";\nexport const feature = defineFeature({ setup({ http }) { http.router.register("POST", "/save", {}, async () => ({ ok: true })); } });\n',
62
+ };
63
+ const { root, input } = await fixture(t, source, { id: "shop", adapter: "vue-registered-router", surface: "app", mode: "registered", registrations: ["routes.ts"], pageRoots: [], componentRoots: [] });
64
+ const result = await inspectVueArchitecture({ projectRoot: root, input, httpScope: "shop", apiBasePath: "/api" });
65
+ assert.equal(result.pages.length, 2);
66
+ assert.equal(result.registrations[1].identity, "/shop/detail");
67
+ assert.equal(result.pages.find(page => page.file === "views/Detail.vue").parentId, result.pages.find(page => page.file === "views/Checkout.vue").id);
68
+ assert.deepEqual(result.components.map(component => component.file), ["packages/payments/SharedForm.vue"]);
69
+ assert.equal(result.operationUses.length, 1);
70
+ assert.equal(result.operationUses[0].key.operation, "/api/save");
71
+ assert.deepEqual(result.entryOperations[0].key, result.operationUses[0].key);
72
+ assert.equal(result.entryOperations[0].controllerFile, "engine/Feature.js");
73
+ const use = result.componentUses[0];
74
+ const range = use.evidence[0];
75
+ assert.equal(source[range.path].split("\n")[range.start.line - 1].slice(range.start.column - 1, range.end.column - 1), "<SharedForm />");
76
+ });
77
+
78
+ test("filesystem routing reuses the Vue Router scanner without generated files or watchers", async t => {
79
+ const { root, input } = await fixture(t, {
80
+ "vite.config.mjs": 'import VueRouter from "vue-router/vite";\nVueRouter({ routesFolder: "pages", dts: "generated-router.d.ts", nestedChildren: false });\n',
81
+ "pages/shop.vue": '<template><router-view /></template>\n',
82
+ "pages/shop/index.vue": '<template>Shop index</template>\n',
83
+ "pages/shop/[id].vue": '<template>Shop detail</template>\n',
84
+ "pages/shop/helper.ts": 'export const helper = true;\n',
85
+ }, { id: "shop", adapter: "jskit", surface: "app", mode: "filesystem", registrations: ["vite.config.mjs"], pageRoots: ["pages"], componentRoots: [] });
86
+ const result = await inspectVueArchitecture({ projectRoot: root, input });
87
+ assert.equal(result.pages.length, 3);
88
+ assert.equal(result.pages.find(page => page.file === "pages/shop/index.vue").title, "shop");
89
+ assert.ok(result.registrations.some(registration => registration.identity.includes(":id")));
90
+ assert.equal(result.pages.some(page => page.file.endsWith("helper.ts")), false);
91
+ await assert.rejects(readFile(path.join(root, "generated-router.d.ts")), { code: "ENOENT" });
92
+ assert.deepEqual((await readdir(root)).sort(), ["pages", "vite.config.mjs"]);
93
+ });
94
+
95
+ test("dynamic registration and unsupported routing adapters do not invent pages", async t => {
96
+ const { root, input } = await fixture(t, { "routes.js": 'import { createRouter } from "vue-router"; createRouter({ routes: makeRoutes() });\n' }, { id: "dynamic", adapter: "vue-registered-router", surface: "app", mode: "registered", registrations: ["routes.js"], pageRoots: [] });
97
+ const result = await inspectVueArchitecture({ projectRoot: root, input });
98
+ assert.equal(result.pages.length, 0);
99
+ assert.equal(result.capabilities.pages, "unsupported");
100
+ assert.ok(result.diagnostics.some(diagnostic => diagnostic.code === "ROUTES_DYNAMIC"));
101
+ input.uiDiscovery.discoveries[0].adapter = "native-scenes";
102
+ const other = await inspectVueArchitecture({ projectRoot: root, input });
103
+ assert.equal(other.pages.length, 0);
104
+ assert.equal(other.capabilities.pages, "unsupported");
105
+ });
106
+
107
+ test('actual library component uses reference the declared package boundary without opening its internals', async t => {
108
+ const { root, input } = await fixture(t, {
109
+ 'routes.js': 'import { createRouter } from "vue-router"; import Screen from "./Screen.vue"; createRouter({ routes: [{ path: "/", component: Screen }] });',
110
+ 'Screen.vue': '<template><LibraryButton /></template>\n<script setup>import { Button as LibraryButton, Unused } from "widgets";</script>',
111
+ }, { id: 'library', adapter: 'vue-registered-router', surface: 'app', mode: 'registered', registrations: ['routes.js'], pageRoots: [] });
112
+ input.packages = [{ id: 'package:widgets', name: 'widgets', root: 'node_modules/widgets', manifest: 'package.json', opaque: true, entryPoints: [{ importPath: '.', file: 'node_modules/widgets/index.js' }] }];
113
+ input.packageIdentity = 'inspected-package-identity';
114
+ const result = await inspectVueArchitecture({ projectRoot: root, input });
115
+ assert.equal(result.packageIdentity, input.packageIdentity);
116
+ assert.equal(result.components.length, 1);
117
+ assert.equal(result.components[0].file, null);
118
+ assert.deepEqual(result.components[0].origin, { kind: 'package', packageId: 'package:widgets', importPath: '.', symbol: 'Button' });
119
+ assert.equal(result.componentUses[0].componentId, result.components[0].id);
120
+ assert.equal(result.inputs.some(entry => entry.path.startsWith('node_modules/')), false);
121
+ });
122
+
123
+
124
+ test('used CRUD screens resolve resources through workspace barrels to exported controller declarations', async t => {
125
+ const source = {
126
+ 'package.json': '{}',
127
+ 'routes.js': 'import { createRouter } from "vue-router"; import Screen from "./Screen.vue"; createRouter({ routes: [{ path: "/new", component: Screen }] });',
128
+ 'Screen.vue': '<template><CrudAddEditScreen :screen="screen" /></template>\n<script setup>import CrudAddEditScreen from "@jskit-ai/http-web/client/components/CrudAddEditScreen"; import { useCrudAddEditScreen } from "@jskit-ai/http-web/client/composables/useCrudAddEditScreen"; import { resource } from "@local/resource/shared"; const screen = useCrudAddEditScreen({ resource, operationName: "create", addEditOptions: { apiSuffix: "/names" } }); const unused = useCrudAddEditScreen({ resource, operationName: "view" });</script>',
129
+ 'packages/resource/shared.js': 'export { resource } from "./resource.js";',
130
+ 'packages/resource/resource.js': 'import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource"; export const resource = defineCrudResource({ namespace: "names", tableName: "names", crudOperations: ["list", "create"] });',
131
+ 'packages/resource/Feature.js': 'import { defineCrudJsonApiFeature } from "@jskit-ai/crud-core/server/defineCrudJsonApiFeature"; import { resource } from "./resource.js"; export const NamesFeature = defineCrudJsonApiFeature({ resource, surface: "home", relativePath: "/names" });',
132
+ 'packages/resource/Disabled.js': 'import { defineCrudJsonApiFeature } from "@jskit-ai/crud-core/server/defineCrudJsonApiFeature"; import { resource } from "./resource.js"; export const Disabled = defineCrudJsonApiFeature({ resource, surface: "home", routes: false });',
133
+ };
134
+ const { root, input } = await fixture(t, source, { id: 'crud', adapter: 'vue-registered-router', surface: 'home', mode: 'registered', registrations: ['routes.js'] });
135
+ input.packages = [{ id: 'package:local', name: '@local/resource', root: 'packages/resource', manifest: 'package.json', opaque: false, entryPoints: [{ importPath: './shared', file: 'packages/resource/shared.js' }] }];
136
+ const options = { projectRoot: root, input, httpScope: 'app' };
137
+ const result = await inspectVueArchitecture(options);
138
+ assert.equal(result.operationUses.length, 1);
139
+ assert.equal(result.entryOperations.length, 2);
140
+ const receiving = result.entryOperations.find(entry => entry.key.operation === 'names:create');
141
+ assert.deepEqual(receiving.key, result.operationUses[0].key);
142
+ assert.equal(receiving.controllerFile, 'packages/resource/Feature.js');
143
+ assert.ok(result.operationUses[0].evidence.some(entry => entry.path === 'packages/resource/resource.js'));
144
+ assert.ok(!result.entryOperations.some(entry => entry.controllerFile.endsWith('Disabled.js')));
145
+ await writeFile(path.join(root, 'Screen.vue'), source['Screen.vue'].replace('apiSuffix: "/names"', 'apiSuffix: "/other"'));
146
+ input.files.find(entry => entry.path === 'Screen.vue').hash = `sha256:${createHash('sha256').update(await readFile(path.join(root, 'Screen.vue'))).digest('hex')}`;
147
+ const otherPath = await inspectVueArchitecture(options);
148
+ assert.notDeepEqual(receiving.key, otherPath.operationUses[0].key);
149
+ await writeFile(path.join(root, 'Screen.vue'), source['Screen.vue'].replace('resource, operationName: "create"', 'resource, operationName: "create", client: alternateClient'));
150
+ input.files.find(entry => entry.path === 'Screen.vue').hash = `sha256:${createHash('sha256').update(await readFile(path.join(root, 'Screen.vue'))).digest('hex')}`;
151
+ const dynamic = await inspectVueArchitecture(options);
152
+ assert.equal(dynamic.operationUses[0].resolution, 'unresolved');
153
+ assert.equal(dynamic.operationUses[0].key, undefined);
154
+ });