@jskit-ai/architecture-inspector 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,30 @@
1
+ // Vue Preview locator: a classic script a Preview bridge can inline. It defines
2
+ // globalThis.__jskitLocateElement(element), which returns the nearest project
3
+ // component that rendered the element in a Vue development build, as
4
+ // { file, name? } with the file as the build reports it, or null. It reads only
5
+ // Vue's own component bookkeeping (`__vueParentComponent`, the component's
6
+ // parent and its `__file`/`name`), never an element's text or value.
7
+ (function defineVueLocator() {
8
+ "use strict";
9
+ var MAX_STEPS = 512;
10
+
11
+ function projectComponent(instance) {
12
+ for (var current = instance, steps = 0; current && steps < MAX_STEPS; current = current.parent, steps += 1) {
13
+ var type = current.type;
14
+ if (type && typeof type.__file === "string" && type.__file) {
15
+ var name = type.name || type.__name;
16
+ return typeof name === "string" && name ? { file: type.__file, name: name } : { file: type.__file };
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+
22
+ globalThis.__jskitLocateElement = function locateElement(element) {
23
+ for (var node = element, steps = 0; node && steps < MAX_STEPS; node = node.parentNode, steps += 1) {
24
+ if (node.__vueParentComponent) {
25
+ return projectComponent(node.__vueParentComponent);
26
+ }
27
+ }
28
+ return null;
29
+ };
30
+ })();
@@ -0,0 +1,293 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parse as parseJavaScript } from "@babel/parser";
4
+ import { parse as parseSfc } from "@vue/compiler-sfc";
5
+ import { collectNodes, directoryPrefixes, hashBytes, isCanonicalPath } from "./syntax.js";
6
+
7
+ // The project's declared sources: reading each once against its declared
8
+ // hash, parsing scripts and Vue single-file components, and resolving imports
9
+ // to project files or package entry points.
10
+
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 RESOLVE_EXTENSIONS = Object.freeze([".vue", ".js", ".ts", "/index.js", "/index.ts"]);
15
+
16
+ // Framework sources whose declarations this inspector reads statically.
17
+ const SOURCES = Object.freeze({
18
+ crudResource: "@jskit-ai/resource-crud-core/shared/crudResource",
19
+ crudJsonApiFeature: "@jskit-ai/crud-core/server/defineCrudJsonApiFeature",
20
+ kernelFeatures: "@jskit-ai/kernel/server/features",
21
+ kernelHttp: "@jskit-ai/kernel/server/http",
22
+ crudListScreen: "@jskit-ai/http-web/client/components/CrudListScreen",
23
+ crudAddEditScreen: "@jskit-ai/http-web/client/components/CrudAddEditScreen",
24
+ crudComposables: "@jskit-ai/http-web/client/composables",
25
+ filesystemRouter: "vue-router/vite",
26
+ registeredRouter: "vue-router"
27
+ });
28
+
29
+ function warn(inspection, code, message, source) {
30
+ inspection.output.diagnostics.push({ code, message, ...(source ? { path: source } : {}) });
31
+ }
32
+
33
+ // Reads one indexed, hash-matching source file once and records it as an
34
+ // input. Symbolic links, changed sources and oversized files are refused.
35
+ async function readDeclaredSource(inspection, filePath) {
36
+ const { files, output, projectRoot, sources } = inspection;
37
+ if (sources.has(filePath)) {
38
+ return sources.get(filePath);
39
+ }
40
+ for (const directory of directoryPrefixes(filePath)) {
41
+ if (inspection.checkedDirectories.has(directory)) {
42
+ continue;
43
+ }
44
+ if ((await lstat(path.join(projectRoot, directory))).isSymbolicLink()) {
45
+ throw new Error("Architecture inspection cannot follow symbolic links.");
46
+ }
47
+ inspection.checkedDirectories.add(directory);
48
+ }
49
+ const bytes = await readFile(path.join(projectRoot, filePath));
50
+ if (bytes.length > MAX_SOURCE_BYTES || hashBytes(bytes) !== files.get(filePath).hash) {
51
+ throw new Error(`Source changed or exceeded inspection bounds: ${filePath}`);
52
+ }
53
+ output.inputs.push({ path: filePath, hash: files.get(filePath).hash });
54
+ sources.set(filePath, bytes);
55
+ return bytes;
56
+ }
57
+
58
+ // A JSKIT application serves its own HTTP operations, so without an explicit
59
+ // scope the root manifest's package name identifies them. A manifest that is
60
+ // absent, unnamed or not JSON leaves HTTP observations unconfigured.
61
+ async function projectHttpScope(inspection) {
62
+ if (!inspection.files.has("package.json")) {
63
+ return "";
64
+ }
65
+ const bytes = await readDeclaredSource(inspection, "package.json");
66
+ try {
67
+ const { name } = JSON.parse(bytes.toString("utf8"));
68
+ return typeof name === "string" ? name.trim() : "";
69
+ } catch {
70
+ return "";
71
+ }
72
+ }
73
+
74
+ function utf8Source(bytes, filePath) {
75
+ const source = bytes.toString("utf8");
76
+ if (!Buffer.from(source).equals(bytes)) {
77
+ throw new Error(`Source is not UTF-8: ${filePath}`);
78
+ }
79
+ return source;
80
+ }
81
+
82
+ // Reads one indexed source file once and parses its scripts. Non-script files
83
+ // are recorded as inputs without parsing.
84
+ async function loadModule(inspection, filePath) {
85
+ const { files, modules } = inspection;
86
+ if (modules.has(filePath)) {
87
+ return modules.get(filePath);
88
+ }
89
+ if (!files.has(filePath)) {
90
+ return null;
91
+ }
92
+ const bytes = await readDeclaredSource(inspection, filePath);
93
+ if (!SCRIPT_FILE_PATTERN.test(filePath)) {
94
+ modules.set(filePath, null);
95
+ return null;
96
+ }
97
+ const module = {
98
+ path: filePath,
99
+ source: utf8Source(bytes, filePath),
100
+ hash: files.get(filePath).hash,
101
+ bindings: new Map(),
102
+ constants: new Set(),
103
+ imports: new Map(),
104
+ nodes: [],
105
+ exports: new Map(),
106
+ descriptor: null
107
+ };
108
+ modules.set(filePath, module);
109
+ for (const unit of scriptUnits(inspection, module)) {
110
+ indexScriptUnit(inspection, module, unit);
111
+ }
112
+ return module;
113
+ }
114
+
115
+ function scriptUnits(inspection, module) {
116
+ if (!module.path.endsWith(".vue")) {
117
+ return [{ source: module.source, offset: 0, setup: false }];
118
+ }
119
+ const parsed = parseSfc(module.source, { filename: module.path, sourceMap: false });
120
+ if (parsed.errors.length) {
121
+ warn(inspection, "VUE_PARSE_FAILED", "The component could not be parsed completely.", module.path);
122
+ return [];
123
+ }
124
+ module.descriptor = parsed.descriptor;
125
+ const units = [];
126
+ const scripts = [parsed.descriptor.script, parsed.descriptor.scriptSetup].filter(Boolean);
127
+ for (const script of scripts) {
128
+ if (script.src) {
129
+ warn(inspection, "VUE_EXTERNAL_SCRIPT", "External component scripts need another inspection adapter.", module.path);
130
+ continue;
131
+ }
132
+ units.push({ source: script.content, offset: script.loc.start.offset, setup: script === parsed.descriptor.scriptSetup });
133
+ }
134
+ return units;
135
+ }
136
+
137
+ function indexScriptUnit(inspection, module, unit) {
138
+ try {
139
+ const ast = parseJavaScript(unit.source, {
140
+ sourceType: "unambiguous",
141
+ plugins: ["typescript", "jsx"],
142
+ createImportExpressions: true
143
+ });
144
+ const records = collectNodes(ast);
145
+ for (const record of records) {
146
+ Object.assign(record, { offset: unit.offset, setup: unit.setup });
147
+ module.nodes.push(record);
148
+ indexDeclaration(module, record.node, record.parent?.node);
149
+ }
150
+ } catch {
151
+ warn(inspection, "JAVASCRIPT_PARSE_FAILED", "A script needs unsupported syntax or could not be parsed.", module.path);
152
+ }
153
+ }
154
+
155
+ function indexDeclaration(module, node, parent) {
156
+ if (node.type === "VariableDeclarator" && node.id.type === "Identifier") {
157
+ // A name bound twice in one module is ambiguous, so it resolves to nothing.
158
+ module.bindings.set(node.id.name, module.bindings.has(node.id.name) ? undefined : node.init);
159
+ if (parent?.kind === "const") {
160
+ module.constants.add(node.id.name);
161
+ }
162
+ }
163
+ if (node.type === "ExportNamedDeclaration") {
164
+ for (const specifier of node.specifiers) {
165
+ if (specifier.type === "ExportSpecifier") {
166
+ const exported = specifier.exported.name || specifier.exported.value;
167
+ const local = specifier.local.name || specifier.local.value;
168
+ module.exports.set(exported, { local, source: node.source?.value });
169
+ }
170
+ }
171
+ if (node.declaration?.type === "VariableDeclaration") {
172
+ for (const entry of node.declaration.declarations) {
173
+ if (entry.id.type === "Identifier") {
174
+ module.exports.set(entry.id.name, { local: entry.id.name });
175
+ }
176
+ }
177
+ }
178
+ if (node.declaration?.type === "FunctionDeclaration" && node.declaration.id) {
179
+ module.exports.set(node.declaration.id.name, { local: node.declaration.id.name });
180
+ }
181
+ }
182
+ if (node.type === "ImportDeclaration") {
183
+ for (const specifier of node.specifiers) {
184
+ module.imports.set(specifier.local.name, {
185
+ source: node.source.value,
186
+ imported: specifier.imported?.name || "default",
187
+ node
188
+ });
189
+ }
190
+ }
191
+ }
192
+
193
+ // Resolves an import specifier to an indexed project file through relative
194
+ // paths, project-root paths, declared aliases and declared package entry points.
195
+ function resolveImport(inspection, from, specifier) {
196
+ const { aliases, files } = inspection;
197
+ if (typeof specifier !== "string") {
198
+ return null;
199
+ }
200
+ let base;
201
+ if (specifier.startsWith(".")) {
202
+ base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier));
203
+ } else if (specifier.startsWith("/")) {
204
+ base = specifier.slice(1);
205
+ } else {
206
+ const alias = Object.keys(aliases)
207
+ .sort((a, b) => b.length - a.length)
208
+ .find((key) => specifier === key || specifier.startsWith(key + "/"));
209
+ if (alias) {
210
+ base = path.posix.join(aliases[alias], specifier.slice(alias.length));
211
+ }
212
+ }
213
+ if (!base) {
214
+ const packageEntry = resolvePackageEntryFile(inspection, from, specifier);
215
+ if (packageEntry) {
216
+ return packageEntry;
217
+ }
218
+ }
219
+ if (!isCanonicalPath(base)) {
220
+ return null;
221
+ }
222
+ const candidates = [base, ...RESOLVE_EXTENSIONS.map((extension) => base + extension)];
223
+ return candidates.find((candidate) => files.has(candidate)) || null;
224
+ }
225
+
226
+ function resolvePackageEntryFile(inspection, from, specifier) {
227
+ const dependency = resolvePackageImport(inspection, from, specifier);
228
+ if (!dependency || dependency.record.opaque) {
229
+ return null;
230
+ }
231
+ const entryFiles = dependency.record.entryPoints
232
+ .map((entry) => packageEntryTarget(entry, dependency.importPath))
233
+ .filter((file) => file && inspection.files.has(file));
234
+ const candidates = [...new Set(entryFiles)];
235
+ return candidates.length === 1 ? candidates[0] : null;
236
+ }
237
+
238
+ // The file an entry point serves for an import path: its own file for an
239
+ // exact subpath, or a "./name/*" pattern's file with the matched part put in
240
+ // place of its "*", as Node resolves subpath patterns.
241
+ function packageEntryTarget(entry, importPath) {
242
+ if (entry.importPath === importPath) {
243
+ return entry.file;
244
+ }
245
+ const star = entry.importPath.indexOf("*");
246
+ if (star < 0 || entry.importPath.indexOf("*", star + 1) >= 0 || !entry.file.includes("*")) {
247
+ return null;
248
+ }
249
+ const prefix = entry.importPath.slice(0, star);
250
+ const suffix = entry.importPath.slice(star + 1);
251
+ if (importPath.length <= prefix.length + suffix.length || !importPath.startsWith(prefix) || !importPath.endsWith(suffix)) {
252
+ return null;
253
+ }
254
+ return entry.file.replace("*", importPath.slice(prefix.length, importPath.length - suffix.length));
255
+ }
256
+
257
+ // The declared package an import names, chosen by the nearest manifest that
258
+ // contains the importing file.
259
+ function resolvePackageImport(inspection, from, specifier) {
260
+ if (typeof specifier !== "string" || specifier.startsWith(".") || specifier.startsWith("/")) {
261
+ return null;
262
+ }
263
+ const namesPackage = (entry) => specifier === entry.name || specifier.startsWith(`${entry.name}/`);
264
+ const containsFile = (declaration) => {
265
+ const parent = path.posix.dirname(declaration.manifest);
266
+ return parent === "." || from.startsWith(`${parent}/`);
267
+ };
268
+ const candidates = inspection.packages
269
+ .flatMap((entry) =>
270
+ (entry.declarations || [{ manifest: entry.manifest }])
271
+ .filter((declaration) => containsFile(declaration) && namesPackage(entry))
272
+ .map((declaration) => ({ record: entry, manifest: declaration.manifest }))
273
+ )
274
+ .sort((a, b) => b.manifest.length - a.manifest.length);
275
+ const record = candidates[0]?.record;
276
+ if (!record) {
277
+ return null;
278
+ }
279
+ return { record, importPath: specifier === record.name ? "." : `.${specifier.slice(record.name.length)}` };
280
+ }
281
+
282
+ export {
283
+ MAX_RESOURCE_HOPS,
284
+ SCRIPT_FILE_PATTERN,
285
+ SOURCES,
286
+ loadModule,
287
+ projectHttpScope,
288
+ readDeclaredSource,
289
+ resolveImport,
290
+ resolvePackageImport,
291
+ utf8Source,
292
+ warn
293
+ };