nuxt-layerscope 0.1.0
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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +289 -0
- package/dist/analyze-DS51xy6X.mjs +337 -0
- package/dist/analyze-YePAJEVS.mjs +2 -0
- package/dist/api.d.mts +148 -0
- package/dist/api.mjs +6 -0
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +374 -0
- package/dist/component-files-DgOXNE6l.mjs +12 -0
- package/dist/define-DNRGLVG_.d.mts +126 -0
- package/dist/define-l62B9fVk.mjs +6 -0
- package/dist/define.d.mts +2 -0
- package/dist/define.mjs +2 -0
- package/dist/effective-Dqg4EwH_.d.mts +7 -0
- package/dist/eslint.d.mts +7 -0
- package/dist/eslint.mjs +213 -0
- package/dist/file-analysis-Bkq_69x4.mjs +1085 -0
- package/dist/index.d.mts +24 -0
- package/dist/index.mjs +364 -0
- package/dist/layer-factory-xZmIAPrA.mjs +168 -0
- package/dist/report-BNW0GCl9.mjs +131 -0
- package/dist/report-DtAi0vPb.mjs +2 -0
- package/dist/strings-CrOjNZmB.mjs +17 -0
- package/dist/summary-DiUYKMPe.mjs +10 -0
- package/dist/unused-Dxep1p6u.mjs +164 -0
- package/dist/version-D6whhsHH.mjs +9 -0
- package/package.json +93 -0
|
@@ -0,0 +1,1085 @@
|
|
|
1
|
+
import { a as isClonedFrom, c as realPath, g as LayerscopeError, h as ruleSeverity, i as layerFromPath, l as REGISTRY_FILE, n as deriveDirectories, o as isFile, p as validateConfig, r as layerFromNuxt, s as readJson, t as ROOT_LAYER_NAME } from "./layer-factory-xZmIAPrA.mjs";
|
|
2
|
+
import { n as escapeRegExp, r as pascalCase, t as compareStrings } from "./strings-CrOjNZmB.mjs";
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve } from "pathe";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
6
|
+
import { glob } from "tinyglobby";
|
|
7
|
+
import { createJiti } from "jiti";
|
|
8
|
+
import globals from "globals";
|
|
9
|
+
import { ScopeTracker, isReferenceIdentifier, parseAndWalk, walk } from "oxc-walker";
|
|
10
|
+
import { compileScript, compileTemplate, parse } from "@vue/compiler-sfc";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
//#region src/config/load.ts
|
|
13
|
+
const CONFIG_NAMES = [
|
|
14
|
+
"layerscope.config.ts",
|
|
15
|
+
"layerscope.config.mts",
|
|
16
|
+
"layerscope.config.js",
|
|
17
|
+
"layerscope.config.mjs"
|
|
18
|
+
];
|
|
19
|
+
/**
|
|
20
|
+
* Config files import `defineConfig` from this package. Aliasing it to our own module lets
|
|
21
|
+
* them load even when layerscope runs through npx without being a project dependency.
|
|
22
|
+
*/
|
|
23
|
+
function defineConfigModule() {
|
|
24
|
+
const candidates = ["./define.ts", "./define.mjs"].map((path) => fileURLToPath(new URL(path, import.meta.url)));
|
|
25
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[0];
|
|
26
|
+
}
|
|
27
|
+
function findConfigFile(rootDir, configFile) {
|
|
28
|
+
if (configFile !== void 0) {
|
|
29
|
+
const file = resolve(process.cwd(), configFile);
|
|
30
|
+
if (!existsSync(file)) throw new LayerscopeError(`Config file not found: ${file}`);
|
|
31
|
+
return file;
|
|
32
|
+
}
|
|
33
|
+
return CONFIG_NAMES.map((name) => resolve(rootDir, name)).find((path) => existsSync(path)) ?? null;
|
|
34
|
+
}
|
|
35
|
+
function createLoader() {
|
|
36
|
+
const self = defineConfigModule();
|
|
37
|
+
return createJiti(import.meta.url, {
|
|
38
|
+
alias: {
|
|
39
|
+
"nuxt-layerscope": self,
|
|
40
|
+
layerscope: self
|
|
41
|
+
},
|
|
42
|
+
moduleCache: false
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
function toConfig(value, file) {
|
|
46
|
+
if (typeof value !== "object" || value === null) throw new LayerscopeError(`${file} must default-export a config object`);
|
|
47
|
+
validateConfig(value, file);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function loadFailed(file, error) {
|
|
51
|
+
return new LayerscopeError(`Failed to load ${file}: ${error.message}`);
|
|
52
|
+
}
|
|
53
|
+
async function loadConfig(rootDir, configFile) {
|
|
54
|
+
const file = findConfigFile(rootDir, configFile);
|
|
55
|
+
if (file === null) return {};
|
|
56
|
+
return toConfig(await createLoader().import(file, { default: true }).catch((error) => {
|
|
57
|
+
throw loadFailed(file, error);
|
|
58
|
+
}), file);
|
|
59
|
+
}
|
|
60
|
+
function requireConfig(file) {
|
|
61
|
+
try {
|
|
62
|
+
return createLoader()(file);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw loadFailed(file, error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** For callers that cannot await, such as lint rules. */
|
|
68
|
+
function loadConfigSync(rootDir) {
|
|
69
|
+
const file = findConfigFile(rootDir);
|
|
70
|
+
if (file === null) return {};
|
|
71
|
+
const module = requireConfig(file);
|
|
72
|
+
return toConfig(module?.default ?? module, file);
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/rules/layer-boundary.ts
|
|
76
|
+
function describeEdge(edge) {
|
|
77
|
+
if (edge.kind === "component") return `Component <${edge.symbol}>`;
|
|
78
|
+
return edge.kind === "import" ? `Import "${edge.symbol}"` : `Auto-import "${edge.symbol}"`;
|
|
79
|
+
}
|
|
80
|
+
/** Edges to packages and within a layer are always allowed. */
|
|
81
|
+
function isAllowed(edge, allow) {
|
|
82
|
+
return edge.toLayer === null || edge.toLayer === edge.fromLayer || allow.includes(edge.toLayer);
|
|
83
|
+
}
|
|
84
|
+
function boundaryFindings(edges, config) {
|
|
85
|
+
const severity = ruleSeverity(config, "layer-boundary");
|
|
86
|
+
if (severity === "off") return [];
|
|
87
|
+
return edges.flatMap((edge) => {
|
|
88
|
+
const allow = config.layers?.[edge.fromLayer]?.allow;
|
|
89
|
+
if (allow === void 0 || isAllowed(edge, allow)) return [];
|
|
90
|
+
return [{
|
|
91
|
+
rule: "layer-boundary",
|
|
92
|
+
severity,
|
|
93
|
+
file: edge.file,
|
|
94
|
+
line: edge.line,
|
|
95
|
+
column: edge.column,
|
|
96
|
+
symbol: edge.symbol,
|
|
97
|
+
fromLayer: edge.fromLayer,
|
|
98
|
+
toLayer: edge.toLayer,
|
|
99
|
+
target: edge.to,
|
|
100
|
+
allowed: allow,
|
|
101
|
+
message: `${describeEdge(edge)} crosses from layer "${edge.fromLayer}" into "${edge.toLayer}"`
|
|
102
|
+
}];
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
//#region src/resolve/file.ts
|
|
107
|
+
const EXTENSIONS = [
|
|
108
|
+
".ts",
|
|
109
|
+
".tsx",
|
|
110
|
+
".mts",
|
|
111
|
+
".js",
|
|
112
|
+
".mjs",
|
|
113
|
+
".jsx",
|
|
114
|
+
".vue",
|
|
115
|
+
".json"
|
|
116
|
+
];
|
|
117
|
+
function withTsExtension(path) {
|
|
118
|
+
const jsExt = /\.(m?)js$/u.exec(path);
|
|
119
|
+
if (!jsExt) return null;
|
|
120
|
+
const base = path.slice(0, -jsExt[0].length);
|
|
121
|
+
return [`${base}.${jsExt[1]}ts`, `${base}.tsx`].find((candidate) => isFile(candidate)) ?? null;
|
|
122
|
+
}
|
|
123
|
+
/** Bundler-style lookup: exact, added extension, `.js` → `.ts`, then `index.*`. */
|
|
124
|
+
function resolveFile(path) {
|
|
125
|
+
const direct = [path, ...EXTENSIONS.map((ext) => path + ext)].find((candidate) => isFile(candidate));
|
|
126
|
+
if (direct !== void 0) return direct;
|
|
127
|
+
return withTsExtension(path) ?? EXTENSIONS.map((ext) => join(path, `index${ext}`)).find((candidate) => isFile(candidate)) ?? null;
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/resolve/package-name.ts
|
|
131
|
+
const NODE_MODULES = "/node_modules/";
|
|
132
|
+
/** Package name of a bare specifier or of a path inside node_modules. */
|
|
133
|
+
function packageName(specifier) {
|
|
134
|
+
const index = specifier.lastIndexOf(NODE_MODULES);
|
|
135
|
+
const bare = index === -1 ? specifier : specifier.slice(index + 14);
|
|
136
|
+
const parts = bare.split("/");
|
|
137
|
+
return bare.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/nuxt/symbols.ts
|
|
141
|
+
const GLOBAL_CONST = /^ {2}const ([\w$]+): (.*)$/u;
|
|
142
|
+
const EXPORT_CONST = /^export const ([\w$]+): (.*)$/u;
|
|
143
|
+
const INTERFACE_MEMBER = /^ {2}([\w$]+): (.*)$/u;
|
|
144
|
+
const TYPEOF_IMPORT = /typeof import\((['"])(.+?)\1\)/u;
|
|
145
|
+
const INLINE_TYPE = {
|
|
146
|
+
file: null,
|
|
147
|
+
module: "nuxt"
|
|
148
|
+
};
|
|
149
|
+
/** Target of an absolute path or a bare module specifier. */
|
|
150
|
+
function targetOf(source, buildDir) {
|
|
151
|
+
if (!isAbsolute(source)) return {
|
|
152
|
+
file: null,
|
|
153
|
+
module: packageName(source)
|
|
154
|
+
};
|
|
155
|
+
if (source === buildDir || source.startsWith(`${buildDir}/`)) return {
|
|
156
|
+
file: null,
|
|
157
|
+
module: "#build"
|
|
158
|
+
};
|
|
159
|
+
if (source.includes("/node_modules/")) {
|
|
160
|
+
const file = resolveFile(source);
|
|
161
|
+
return {
|
|
162
|
+
file: file === null ? null : realPath(file),
|
|
163
|
+
module: packageName(source)
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
file: realPath(resolveFile(source) ?? source),
|
|
168
|
+
module: null
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function toTarget(declaration, fromDir, buildDir) {
|
|
172
|
+
const specifier = TYPEOF_IMPORT.exec(declaration)?.[2];
|
|
173
|
+
if (specifier === void 0) return INLINE_TYPE;
|
|
174
|
+
return targetOf(specifier.startsWith(".") || specifier.startsWith("/") ? resolve(fromDir, specifier) : specifier, buildDir);
|
|
175
|
+
}
|
|
176
|
+
/** Only `declare global { ... }` blocks hold auto-imports; the rest are type augmentations. */
|
|
177
|
+
function parseGlobalDeclarations(file, buildDir) {
|
|
178
|
+
const table = /* @__PURE__ */ new Map();
|
|
179
|
+
if (!existsSync(file)) return table;
|
|
180
|
+
let inGlobal = false;
|
|
181
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
182
|
+
if (line.startsWith("declare global") || line.startsWith("}")) {
|
|
183
|
+
inGlobal = line.startsWith("declare global");
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const match = inGlobal ? GLOBAL_CONST.exec(line) : null;
|
|
187
|
+
if (match) table.set(match[1], toTarget(match[2], dirname(file), buildDir));
|
|
188
|
+
}
|
|
189
|
+
return table;
|
|
190
|
+
}
|
|
191
|
+
/** `.nuxt/components.d.ts`: one `export const` per component. */
|
|
192
|
+
function parseComponentExports(file, buildDir) {
|
|
193
|
+
const table = /* @__PURE__ */ new Map();
|
|
194
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
195
|
+
const match = EXPORT_CONST.exec(line);
|
|
196
|
+
if (match && TYPEOF_IMPORT.test(match[2])) table.set(match[1], toTarget(match[2], dirname(file), buildDir));
|
|
197
|
+
}
|
|
198
|
+
return table;
|
|
199
|
+
}
|
|
200
|
+
/** `.nuxt/types/components.d.ts` (Nuxt 4.1+): members of `interface _GlobalComponents`. */
|
|
201
|
+
function parseComponentInterface(file, buildDir) {
|
|
202
|
+
const table = /* @__PURE__ */ new Map();
|
|
203
|
+
let inInterface = false;
|
|
204
|
+
for (const line of readFileSync(file, "utf8").split("\n")) {
|
|
205
|
+
if (line.startsWith("interface _GlobalComponents") || line.startsWith("}")) {
|
|
206
|
+
inInterface = line.startsWith("interface _GlobalComponents");
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const match = inInterface ? INTERFACE_MEMBER.exec(line) : null;
|
|
210
|
+
if (match && TYPEOF_IMPORT.test(match[2])) table.set(match[1], toTarget(match[2], dirname(file), buildDir));
|
|
211
|
+
}
|
|
212
|
+
return table;
|
|
213
|
+
}
|
|
214
|
+
function loadComponents(buildDir) {
|
|
215
|
+
const root = join(buildDir, "components.d.ts");
|
|
216
|
+
if (existsSync(root)) return parseComponentExports(root, buildDir);
|
|
217
|
+
const types = join(buildDir, "types/components.d.ts");
|
|
218
|
+
return existsSync(types) ? parseComponentInterface(types, buildDir) : null;
|
|
219
|
+
}
|
|
220
|
+
function sameTarget(a, b) {
|
|
221
|
+
return a.file === b.file && a.module === b.module;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Nuxt 3 writes no `shared-imports.d.ts`; Nuxt 4 derives it from the imports both contexts
|
|
225
|
+
* share, so the same rule applies here.
|
|
226
|
+
*/
|
|
227
|
+
function loadSharedImports(buildDir, app, server) {
|
|
228
|
+
const file = join(buildDir, "types/shared-imports.d.ts");
|
|
229
|
+
if (existsSync(file)) return parseGlobalDeclarations(file, buildDir);
|
|
230
|
+
return new Map([...app].filter(([name, target]) => {
|
|
231
|
+
const other = server.get(name);
|
|
232
|
+
return other !== void 0 && sameTarget(target, other);
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
function loadSymbolTable(buildDir) {
|
|
236
|
+
const app = join(buildDir, "types/imports.d.ts");
|
|
237
|
+
const components = existsSync(app) ? loadComponents(buildDir) : null;
|
|
238
|
+
if (components === null) {
|
|
239
|
+
const missing = existsSync(app) ? join(buildDir, "components.d.ts") : app;
|
|
240
|
+
throw new LayerscopeError(`${missing} not found. Run "nuxi prepare" first, or pass --prepare.`);
|
|
241
|
+
}
|
|
242
|
+
const appImports = parseGlobalDeclarations(app, buildDir);
|
|
243
|
+
const serverImports = parseGlobalDeclarations(join(buildDir, "types/nitro-imports.d.ts"), buildDir);
|
|
244
|
+
return {
|
|
245
|
+
imports: {
|
|
246
|
+
app: appImports,
|
|
247
|
+
server: serverImports,
|
|
248
|
+
shared: loadSharedImports(buildDir, appImports, serverImports)
|
|
249
|
+
},
|
|
250
|
+
components
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/registry/read.ts
|
|
255
|
+
function readRegistry(buildDir) {
|
|
256
|
+
const file = join(buildDir, REGISTRY_FILE);
|
|
257
|
+
if (!existsSync(file)) return {
|
|
258
|
+
kind: "missing",
|
|
259
|
+
file
|
|
260
|
+
};
|
|
261
|
+
const registry = readJson(file);
|
|
262
|
+
if (registry.version !== 1) return {
|
|
263
|
+
kind: "incompatible",
|
|
264
|
+
file,
|
|
265
|
+
version: registry.version
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
kind: "ok",
|
|
269
|
+
file,
|
|
270
|
+
registry
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
function importMap(imports, buildDir) {
|
|
274
|
+
return new Map(imports.map((entry) => [entry.name, targetOf(entry.from, buildDir)]));
|
|
275
|
+
}
|
|
276
|
+
/** The same table the `.d.ts` parser builds, from Nuxt's own registry. */
|
|
277
|
+
function tableFromRegistry(registry, buildDir) {
|
|
278
|
+
const components = /* @__PURE__ */ new Map();
|
|
279
|
+
const lazy = /* @__PURE__ */ new Map();
|
|
280
|
+
for (const component of registry.components) {
|
|
281
|
+
const target = targetOf(component.file, buildDir);
|
|
282
|
+
components.set(component.name, target);
|
|
283
|
+
lazy.set(`Lazy${component.name}`, target);
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
imports: {
|
|
287
|
+
app: importMap(registry.imports.app, buildDir),
|
|
288
|
+
server: importMap(registry.imports.server, buildDir),
|
|
289
|
+
shared: importMap(registry.imports.shared, buildDir)
|
|
290
|
+
},
|
|
291
|
+
components: new Map([...components, ...lazy])
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
//#endregion
|
|
295
|
+
//#region src/nuxt/aliases.ts
|
|
296
|
+
const TSCONFIG_FILES = [
|
|
297
|
+
"tsconfig.app.json",
|
|
298
|
+
"tsconfig.json",
|
|
299
|
+
"tsconfig.server.json",
|
|
300
|
+
"tsconfig.shared.json"
|
|
301
|
+
];
|
|
302
|
+
/** Nuxt's alias map, as written to the generated tsconfig files. */
|
|
303
|
+
function loadAliases(buildDir) {
|
|
304
|
+
const map = /* @__PURE__ */ new Map();
|
|
305
|
+
const files = TSCONFIG_FILES.map((name) => join(buildDir, name)).filter((file) => existsSync(file));
|
|
306
|
+
for (const file of files) {
|
|
307
|
+
const paths = readJson(file).compilerOptions?.paths ?? {};
|
|
308
|
+
for (const [key, targets] of Object.entries(paths)) {
|
|
309
|
+
const alias = key.replace(/\/\*$/u, "");
|
|
310
|
+
const target = targets.at(0)?.replace(/\/\*$/u, "");
|
|
311
|
+
if (target !== void 0 && !map.has(alias)) {
|
|
312
|
+
const isPath = isAbsolute(target) || target.startsWith(".");
|
|
313
|
+
map.set(alias, isPath ? resolve(buildDir, target) : target);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
map.set("#build", buildDir);
|
|
318
|
+
return [...map].toSorted((a, b) => b[0].length - a[0].length);
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/analyze/known-globals.ts
|
|
322
|
+
const SFC_MACROS = [
|
|
323
|
+
"defineProps",
|
|
324
|
+
"defineEmits",
|
|
325
|
+
"defineExpose",
|
|
326
|
+
"defineOptions",
|
|
327
|
+
"defineSlots",
|
|
328
|
+
"defineModel",
|
|
329
|
+
"withDefaults"
|
|
330
|
+
];
|
|
331
|
+
/** Registered globally by Vue and vue-router, so never listed in components.d.ts. */
|
|
332
|
+
const GLOBAL_COMPONENTS = ["RouterLink", "RouterView"];
|
|
333
|
+
function knownIdentifiers(extra) {
|
|
334
|
+
return /* @__PURE__ */ new Set([
|
|
335
|
+
...Object.keys(globals.builtin),
|
|
336
|
+
...Object.keys(globals.browser),
|
|
337
|
+
...Object.keys(globals.node),
|
|
338
|
+
...SFC_MACROS,
|
|
339
|
+
"undefined",
|
|
340
|
+
"arguments",
|
|
341
|
+
...extra
|
|
342
|
+
]);
|
|
343
|
+
}
|
|
344
|
+
function knownComponents(extra) {
|
|
345
|
+
return /* @__PURE__ */ new Set([...GLOBAL_COMPONENTS, ...extra]);
|
|
346
|
+
}
|
|
347
|
+
//#endregion
|
|
348
|
+
//#region src/analyze/analysis-env.ts
|
|
349
|
+
/** What every file analysis needs: symbols, aliases, ownership and known globals. */
|
|
350
|
+
function createAnalysisEnv(table, ownerOf, buildDir, config) {
|
|
351
|
+
return {
|
|
352
|
+
table,
|
|
353
|
+
aliases: loadAliases(buildDir),
|
|
354
|
+
buildDir,
|
|
355
|
+
ownerOf,
|
|
356
|
+
identifiers: knownIdentifiers(config.globals ?? []),
|
|
357
|
+
components: knownComponents(config.globals ?? []),
|
|
358
|
+
unresolvedSeverity: ruleSeverity(config, "unresolved-reference")
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/nuxt/kit.ts
|
|
363
|
+
function findPackageDir(from, name) {
|
|
364
|
+
for (let dir = from;; dir = dirname(dir)) {
|
|
365
|
+
const candidate = join(dir, "node_modules", name);
|
|
366
|
+
if (existsSync(join(candidate, "package.json"))) return realpathSync(candidate);
|
|
367
|
+
if (dirname(dir) === dir) return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
function packageEntry(dir) {
|
|
371
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
372
|
+
let entry = pkg.exports?.["."];
|
|
373
|
+
while (typeof entry === "object" && entry !== null) {
|
|
374
|
+
const conditions = entry;
|
|
375
|
+
entry = conditions.import ?? conditions.node ?? conditions.default;
|
|
376
|
+
}
|
|
377
|
+
return join(dir, typeof entry === "string" ? entry : pkg.module ?? pkg.main ?? "index.js");
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Loads `@nuxt/kit` from the project's own Nuxt install rather than bundling one, so the
|
|
381
|
+
* config defaults match the project's Nuxt major version.
|
|
382
|
+
*/
|
|
383
|
+
async function loadNuxtKit(rootDir) {
|
|
384
|
+
let kitDir = findPackageDir(rootDir, "@nuxt/kit");
|
|
385
|
+
if (kitDir === null) {
|
|
386
|
+
const nuxtDir = findPackageDir(rootDir, "nuxt");
|
|
387
|
+
kitDir = nuxtDir === null ? null : findPackageDir(nuxtDir, "@nuxt/kit");
|
|
388
|
+
}
|
|
389
|
+
if (kitDir === null) return null;
|
|
390
|
+
return await import(pathToFileURL(packageEntry(kitDir)).href);
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region src/nuxt/validate-layers.ts
|
|
394
|
+
function assertUniqueNames(layers) {
|
|
395
|
+
const roots = /* @__PURE__ */ new Map();
|
|
396
|
+
for (const layer of layers) {
|
|
397
|
+
const other = roots.get(layer.name);
|
|
398
|
+
if (other !== void 0) throw new LayerscopeError(`Two layers are named "${layer.name}" (${other} and ${layer.root}). Set "path" in layerscope.config.ts to tell them apart.`);
|
|
399
|
+
roots.set(layer.name, layer.root);
|
|
400
|
+
}
|
|
401
|
+
return new Set(roots.keys());
|
|
402
|
+
}
|
|
403
|
+
function validateLayers(layers, config) {
|
|
404
|
+
const names = assertUniqueNames(layers);
|
|
405
|
+
for (const [name, rule] of Object.entries(config.layers ?? {})) {
|
|
406
|
+
if (!names.has(name)) {
|
|
407
|
+
const known = [...names].toSorted().join(", ");
|
|
408
|
+
throw new LayerscopeError(`Unknown layer "${name}" in config. Known layers: ${known}`);
|
|
409
|
+
}
|
|
410
|
+
const unknown = rule.allow?.find((allowed) => !names.has(allowed));
|
|
411
|
+
if (unknown !== void 0) throw new LayerscopeError(`layers.${name}.allow: unknown layer "${unknown}"`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
//#endregion
|
|
415
|
+
//#region src/nuxt/layers.ts
|
|
416
|
+
function configuredPaths(rootDir, config) {
|
|
417
|
+
return Object.entries(config.layers ?? {}).flatMap(([name, rule]) => rule.path === void 0 ? [] : [[name, realPath(resolve(rootDir, rule.path))]]);
|
|
418
|
+
}
|
|
419
|
+
function configuredSources(config) {
|
|
420
|
+
return Object.entries(config.layers ?? {}).flatMap(([name, rule]) => rule.source === void 0 ? [] : [[name, rule.source]]);
|
|
421
|
+
}
|
|
422
|
+
function layersFromConfig(rootDir, config) {
|
|
423
|
+
const paths = configuredPaths(rootDir, config);
|
|
424
|
+
if (paths.length === 0) throw new LayerscopeError(`Could not load @nuxt/kit from ${rootDir}. Install dependencies, or give every layer a "path" in layerscope.config.ts.`);
|
|
425
|
+
const layers = paths.map(([name, root]) => layerFromPath(name, root));
|
|
426
|
+
const root = realPath(resolve(rootDir));
|
|
427
|
+
return layers.some((layer) => layer.root === root) ? layers : [layerFromPath(ROOT_LAYER_NAME, root), ...layers];
|
|
428
|
+
}
|
|
429
|
+
async function layersFromNuxt(kit, rootDir) {
|
|
430
|
+
let options = null;
|
|
431
|
+
try {
|
|
432
|
+
options = await kit.loadNuxtConfig({ cwd: rootDir });
|
|
433
|
+
} catch (error) {
|
|
434
|
+
throw new LayerscopeError(`Failed to load Nuxt config: ${error.message}`);
|
|
435
|
+
}
|
|
436
|
+
const dirs = kit.getLayerDirectories?.({ options }) ?? options._layers.map((layer, index) => deriveDirectories(layer, index, options));
|
|
437
|
+
return options._layers.map((layer, index) => layerFromNuxt(layer, index, dirs[index]));
|
|
438
|
+
}
|
|
439
|
+
/** `path` in config names the Nuxt layer at that root. */
|
|
440
|
+
function applyConfiguredNames(layers, rootDir, config) {
|
|
441
|
+
const named = layers.map((layer) => ({ ...layer }));
|
|
442
|
+
for (const [name, root] of configuredPaths(rootDir, config)) {
|
|
443
|
+
const layer = named.find((candidate) => candidate.root === root);
|
|
444
|
+
if (layer === void 0) throw new LayerscopeError(`layers.${name}.path: no Nuxt layer at ${root}`);
|
|
445
|
+
layer.name = name;
|
|
446
|
+
}
|
|
447
|
+
for (const [name, source] of configuredSources(config)) {
|
|
448
|
+
const matches = named.filter((candidate) => isClonedFrom(candidate.root, source));
|
|
449
|
+
if (matches.length !== 1) {
|
|
450
|
+
const found = matches.length === 0 ? "no layer cloned" : "several layers cloned";
|
|
451
|
+
throw new LayerscopeError(`layers.${name}.source: ${found} from "${source}". Set $meta.name in the layer's nuxt.config instead.`);
|
|
452
|
+
}
|
|
453
|
+
matches[0].name = name;
|
|
454
|
+
}
|
|
455
|
+
return named;
|
|
456
|
+
}
|
|
457
|
+
/** Applies `path` names from config to recorded layers and checks config against them. */
|
|
458
|
+
function nameLayers(layers, rootDir, config) {
|
|
459
|
+
const named = applyConfiguredNames(layers, rootDir, config);
|
|
460
|
+
validateLayers(named, config);
|
|
461
|
+
return named;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* The ordered layer list, highest priority first. `recorded` are the layers the Nuxt module
|
|
465
|
+
* wrote to the registry; without them the layers come from the project's `@nuxt/kit`.
|
|
466
|
+
*/
|
|
467
|
+
async function resolveLayers(rootDir, config, recorded) {
|
|
468
|
+
let layers = recorded;
|
|
469
|
+
if (layers === void 0) {
|
|
470
|
+
const kit = await loadNuxtKit(rootDir);
|
|
471
|
+
layers = kit === null ? layersFromConfig(rootDir, config) : await layersFromNuxt(kit, rootDir);
|
|
472
|
+
}
|
|
473
|
+
return nameLayers(layers, rootDir, config);
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
476
|
+
//#region src/nuxt/owner.ts
|
|
477
|
+
/** The layer whose root is the longest prefix of the file; files in node_modules have none. */
|
|
478
|
+
function createOwnerLookup(layers) {
|
|
479
|
+
const deepestFirst = layers.toSorted((a, b) => b.root.length - a.root.length);
|
|
480
|
+
return (file) => {
|
|
481
|
+
const layer = deepestFirst.find((candidate) => file.startsWith(`${candidate.root}/`));
|
|
482
|
+
if (layer === void 0) return null;
|
|
483
|
+
return file.slice(layer.root.length).includes("/node_modules/") ? null : layer;
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/scan/ast.ts
|
|
488
|
+
function stringLiteral(node) {
|
|
489
|
+
return node?.type === "Literal" && typeof node.value === "string" ? node.value : null;
|
|
490
|
+
}
|
|
491
|
+
function importedName(spec) {
|
|
492
|
+
if (spec.type === "ImportSpecifier") return spec.imported.type === "Identifier" ? spec.imported.name : String(spec.imported.value);
|
|
493
|
+
return spec.type === "ImportDefaultSpecifier" ? "default" : "*";
|
|
494
|
+
}
|
|
495
|
+
function reexportedName(spec) {
|
|
496
|
+
if (spec.type !== "ExportSpecifier") return "*";
|
|
497
|
+
return spec.local.type === "Identifier" ? spec.local.name : String(spec.local.value);
|
|
498
|
+
}
|
|
499
|
+
//#endregion
|
|
500
|
+
//#region src/scan/script-visitors.ts
|
|
501
|
+
function skip() {
|
|
502
|
+
this.skip();
|
|
503
|
+
}
|
|
504
|
+
const scriptVisitors = {
|
|
505
|
+
ImportDeclaration(node, _parent, { scan, mapOffset }) {
|
|
506
|
+
if (node.type !== "ImportDeclaration") return;
|
|
507
|
+
scan.imports.push({
|
|
508
|
+
specifier: node.source.value,
|
|
509
|
+
names: node.specifiers.length > 0 ? node.specifiers.map(importedName) : ["*"],
|
|
510
|
+
offset: mapOffset(node.source.start)
|
|
511
|
+
});
|
|
512
|
+
this.skip();
|
|
513
|
+
},
|
|
514
|
+
ExportNamedDeclaration(node, _parent, { scan, mapOffset }) {
|
|
515
|
+
if (node.type !== "ExportNamedDeclaration" || !node.source) return;
|
|
516
|
+
scan.imports.push({
|
|
517
|
+
specifier: node.source.value,
|
|
518
|
+
names: node.specifiers.map(reexportedName),
|
|
519
|
+
offset: mapOffset(node.source.start)
|
|
520
|
+
});
|
|
521
|
+
this.skip();
|
|
522
|
+
},
|
|
523
|
+
ExportAllDeclaration(node, _parent, { scan, mapOffset }) {
|
|
524
|
+
if (node.type !== "ExportAllDeclaration") return;
|
|
525
|
+
scan.imports.push({
|
|
526
|
+
specifier: node.source.value,
|
|
527
|
+
names: ["*"],
|
|
528
|
+
offset: mapOffset(node.source.start)
|
|
529
|
+
});
|
|
530
|
+
this.skip();
|
|
531
|
+
},
|
|
532
|
+
ImportExpression(node, _parent, { scan, mapOffset }) {
|
|
533
|
+
if (node.type !== "ImportExpression") return;
|
|
534
|
+
const specifier = stringLiteral(node.source);
|
|
535
|
+
if (specifier !== null) scan.imports.push({
|
|
536
|
+
specifier,
|
|
537
|
+
names: ["*"],
|
|
538
|
+
offset: mapOffset(node.source.start)
|
|
539
|
+
});
|
|
540
|
+
},
|
|
541
|
+
TSModuleDeclaration: skip,
|
|
542
|
+
TSImportType: skip,
|
|
543
|
+
CallExpression(node, _parent, { scan, mapOffset }) {
|
|
544
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier") return;
|
|
545
|
+
const [arg] = node.arguments;
|
|
546
|
+
if (node.callee.name !== "resolveComponent" || arg === void 0) return;
|
|
547
|
+
const name = stringLiteral(arg);
|
|
548
|
+
if (name === null) scan.dynamicComponents.push(mapOffset(arg.start));
|
|
549
|
+
else scan.components.push({
|
|
550
|
+
name,
|
|
551
|
+
offset: mapOffset(arg.start)
|
|
552
|
+
});
|
|
553
|
+
},
|
|
554
|
+
Identifier(node, parent, { scan, tracker, mapOffset }) {
|
|
555
|
+
if (node.type === "Identifier" && isReferenceIdentifier(node, parent) && !tracker.isDeclared(node.name)) scan.free.push({
|
|
556
|
+
name: node.name,
|
|
557
|
+
offset: mapOffset(node.start)
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/scan/types.ts
|
|
563
|
+
function emptyScan() {
|
|
564
|
+
return {
|
|
565
|
+
free: [],
|
|
566
|
+
templateIdents: [],
|
|
567
|
+
components: [],
|
|
568
|
+
dynamicComponents: [],
|
|
569
|
+
imports: [],
|
|
570
|
+
error: null
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function toLang(lang) {
|
|
574
|
+
return lang === "ts" || lang === "tsx" || lang === "jsx" ? lang : "js";
|
|
575
|
+
}
|
|
576
|
+
//#endregion
|
|
577
|
+
//#region src/scan/script.ts
|
|
578
|
+
/**
|
|
579
|
+
* Parses and pre-walks the code so hoisted declarations are known before references are
|
|
580
|
+
* checked; a local `useFoo` then correctly shadows the auto-imported one.
|
|
581
|
+
*/
|
|
582
|
+
function parseScoped(code, lang) {
|
|
583
|
+
const tracker = new ScopeTracker({ preserveExitedScopes: true });
|
|
584
|
+
const parsed = parseAndWalk(code, `file.${lang}`, {
|
|
585
|
+
scopeTracker: tracker,
|
|
586
|
+
parseOptions: {
|
|
587
|
+
lang,
|
|
588
|
+
sourceType: "module"
|
|
589
|
+
}
|
|
590
|
+
});
|
|
591
|
+
tracker.freeze();
|
|
592
|
+
const first = parsed.errors.at(0);
|
|
593
|
+
const error = first === void 0 ? null : {
|
|
594
|
+
message: first.message,
|
|
595
|
+
start: first.labels[0]?.start ?? 0
|
|
596
|
+
};
|
|
597
|
+
return {
|
|
598
|
+
program: parsed.program,
|
|
599
|
+
tracker,
|
|
600
|
+
error
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
function scanScript(code, lang, mapOffset, scan) {
|
|
604
|
+
const { program, tracker, error } = parseScoped(code, lang);
|
|
605
|
+
if (error !== null) {
|
|
606
|
+
scan.error = {
|
|
607
|
+
message: error.message,
|
|
608
|
+
offset: mapOffset(error.start)
|
|
609
|
+
};
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const ctx = {
|
|
613
|
+
scan,
|
|
614
|
+
tracker,
|
|
615
|
+
mapOffset
|
|
616
|
+
};
|
|
617
|
+
walk(program, {
|
|
618
|
+
scopeTracker: tracker,
|
|
619
|
+
enter(node, parent) {
|
|
620
|
+
scriptVisitors[node.type]?.call(this, node, parent, ctx);
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
function collectTopLevelNames(code, lang) {
|
|
625
|
+
const { program, tracker } = parseScoped(code, lang);
|
|
626
|
+
const bindings = {};
|
|
627
|
+
walk(program, {
|
|
628
|
+
scopeTracker: tracker,
|
|
629
|
+
enter(node) {
|
|
630
|
+
if (node.type === "Identifier" && tracker.getDeclaration(node.name)?.scope === "") bindings[node.name] = "setup-maybe-ref";
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
return bindings;
|
|
634
|
+
}
|
|
635
|
+
function scanModule(code, file) {
|
|
636
|
+
const scan = emptyScan();
|
|
637
|
+
const ext = /\.[cm]?([jt]sx?)$/u.exec(file)?.[1];
|
|
638
|
+
scanScript(code, toLang(ext), (offset) => offset, scan);
|
|
639
|
+
return scan;
|
|
640
|
+
}
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/scan/render-visitors.ts
|
|
643
|
+
/** `$setup.X` where X is a const binding, such as an imported component. */
|
|
644
|
+
function isSetupConst(arg, bindings) {
|
|
645
|
+
if (arg?.type !== "MemberExpression" || arg.object.type !== "Identifier") return false;
|
|
646
|
+
if (arg.object.name !== "$setup") return false;
|
|
647
|
+
const key = arg.property.type === "Identifier" && !arg.computed ? arg.property.name : stringLiteral(arg.property);
|
|
648
|
+
return key !== null && bindings[key] === "setup-const";
|
|
649
|
+
}
|
|
650
|
+
function visitDynamicComponent(arg, ctx) {
|
|
651
|
+
const literal = stringLiteral(arg);
|
|
652
|
+
if (literal !== null) ctx.scan.components.push({
|
|
653
|
+
name: pascalCase(literal),
|
|
654
|
+
offset: ctx.elementOffset((el) => el.isAttr === literal)
|
|
655
|
+
});
|
|
656
|
+
else if (!isSetupConst(arg, ctx.bindings)) ctx.scan.dynamicComponents.push(ctx.elementOffset((el) => el.dynamicIs));
|
|
657
|
+
}
|
|
658
|
+
function visitRenderCall(node, ctx) {
|
|
659
|
+
if (node.type !== "CallExpression" || node.callee.type !== "Identifier") return;
|
|
660
|
+
const arg = node.arguments[0];
|
|
661
|
+
const literal = stringLiteral(arg);
|
|
662
|
+
if (node.callee.name === "_resolveComponent" && literal !== null) {
|
|
663
|
+
const name = pascalCase(literal);
|
|
664
|
+
ctx.scan.components.push({
|
|
665
|
+
name,
|
|
666
|
+
offset: ctx.elementOffset((el) => pascalCase(el.tag) === name)
|
|
667
|
+
});
|
|
668
|
+
} else if (node.callee.name === "_resolveDynamicComponent") visitDynamicComponent(arg, ctx);
|
|
669
|
+
}
|
|
670
|
+
function visitRenderMember(node, ctx) {
|
|
671
|
+
if (node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === "_ctx" && node.property.type === "Identifier") {
|
|
672
|
+
const { name } = node.property;
|
|
673
|
+
ctx.scan.templateIdents.push({
|
|
674
|
+
name,
|
|
675
|
+
offset: ctx.identOffset(name)
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
//#endregion
|
|
680
|
+
//#region src/scan/template-index.ts
|
|
681
|
+
const ELEMENT = 1;
|
|
682
|
+
const INTERPOLATION = 5;
|
|
683
|
+
const ATTRIBUTE = 6;
|
|
684
|
+
const DIRECTIVE = 7;
|
|
685
|
+
function indexElement(node, index) {
|
|
686
|
+
let isAttr = null;
|
|
687
|
+
let dynamicIs = false;
|
|
688
|
+
for (const prop of node.props ?? []) {
|
|
689
|
+
if (prop.type === ATTRIBUTE && prop.name === "is") isAttr = prop.value?.content ?? null;
|
|
690
|
+
if (prop.type === DIRECTIVE) {
|
|
691
|
+
dynamicIs ||= prop.name === "bind" && prop.arg?.content === "is";
|
|
692
|
+
for (const part of [prop.exp, prop.arg]) if (part !== void 0) index.expressions.push([part.loc.start.offset, part.loc.end.offset]);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
index.elements.push({
|
|
696
|
+
tag: node.tag ?? "",
|
|
697
|
+
offset: node.loc.start.offset,
|
|
698
|
+
isAttr,
|
|
699
|
+
dynamicIs
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
/** Element and expression positions, to point findings back at the source template. */
|
|
703
|
+
function indexTemplate(root) {
|
|
704
|
+
const index = {
|
|
705
|
+
elements: [],
|
|
706
|
+
expressions: []
|
|
707
|
+
};
|
|
708
|
+
const visit = (node) => {
|
|
709
|
+
if (node.type === ELEMENT) indexElement(node, index);
|
|
710
|
+
if (node.type === INTERPOLATION && typeof node.content === "object") index.expressions.push([node.content.loc.start.offset, node.content.loc.end.offset]);
|
|
711
|
+
for (const child of node.children ?? []) visit(child);
|
|
712
|
+
};
|
|
713
|
+
visit(root);
|
|
714
|
+
return index;
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/scan/template.ts
|
|
718
|
+
function firstIdentOffset(name, source, index) {
|
|
719
|
+
const pattern = new RegExp(`(?<![\\w$.])${escapeRegExp(name)}(?![\\w$])`, "u");
|
|
720
|
+
for (const [start, end] of index.expressions) {
|
|
721
|
+
const match = pattern.exec(source.slice(start, end));
|
|
722
|
+
if (match) return start + match.index;
|
|
723
|
+
}
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Compiles the template like Vue does and reads what the render function resolves at runtime:
|
|
728
|
+
* `_resolveComponent("X")` for components and `_ctx.x` for identifiers without a setup binding.
|
|
729
|
+
* The compiler already handles v-for and slot scopes, so no template scope analysis is needed.
|
|
730
|
+
*/
|
|
731
|
+
function scanTemplate(descriptor, source, bindings, scan) {
|
|
732
|
+
const { template } = descriptor;
|
|
733
|
+
if (!template?.ast || template.lang !== void 0 && template.lang !== "html") return;
|
|
734
|
+
const templateStart = template.loc.start.offset;
|
|
735
|
+
const compiled = compileTemplate({
|
|
736
|
+
source: template.content,
|
|
737
|
+
filename: descriptor.filename,
|
|
738
|
+
id: "layerscope",
|
|
739
|
+
compilerOptions: {
|
|
740
|
+
bindingMetadata: bindings,
|
|
741
|
+
prefixIdentifiers: true
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
const error = compiled.errors.at(0);
|
|
745
|
+
if (error !== void 0) {
|
|
746
|
+
scan.error = {
|
|
747
|
+
message: typeof error === "string" ? error : error.message,
|
|
748
|
+
offset: templateStart
|
|
749
|
+
};
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
const index = indexTemplate(template.ast);
|
|
753
|
+
const ctx = {
|
|
754
|
+
scan,
|
|
755
|
+
bindings,
|
|
756
|
+
elementOffset: (predicate) => index.elements.find(predicate)?.offset ?? templateStart,
|
|
757
|
+
identOffset: (name) => firstIdentOffset(name, source, index) ?? templateStart
|
|
758
|
+
};
|
|
759
|
+
const { program } = parseAndWalk(compiled.code, "render.js", { parseOptions: { lang: "js" } });
|
|
760
|
+
walk(program, { enter(node) {
|
|
761
|
+
visitRenderCall(node, ctx);
|
|
762
|
+
visitRenderMember(node, ctx);
|
|
763
|
+
} });
|
|
764
|
+
}
|
|
765
|
+
//#endregion
|
|
766
|
+
//#region src/scan/vue.ts
|
|
767
|
+
/** `<script>` and `<script setup>` share one module scope, so they are scanned as one program. */
|
|
768
|
+
function combineScripts(blocks, descriptor) {
|
|
769
|
+
const starts = [];
|
|
770
|
+
let at = 0;
|
|
771
|
+
for (const block of blocks) {
|
|
772
|
+
starts.push([at, block.loc.start.offset]);
|
|
773
|
+
at += block.content.length + 1;
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
code: blocks.map((block) => block.content).join("\n"),
|
|
777
|
+
lang: toLang(descriptor.scriptSetup?.lang ?? descriptor.script?.lang),
|
|
778
|
+
mapOffset: (offset) => {
|
|
779
|
+
const [combined, original] = starts.findLast(([start]) => start <= offset) ?? [0, 0];
|
|
780
|
+
return original + offset - combined;
|
|
781
|
+
}
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
function templateBindings(descriptor, script) {
|
|
785
|
+
try {
|
|
786
|
+
return compileScript(descriptor, { id: "layerscope" }).bindings ?? {};
|
|
787
|
+
} catch {
|
|
788
|
+
return collectTopLevelNames(script.code, script.lang);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
function scanVue(source, file) {
|
|
792
|
+
const scan = emptyScan();
|
|
793
|
+
const { descriptor, errors } = parse(source, {
|
|
794
|
+
filename: file,
|
|
795
|
+
ignoreEmpty: true
|
|
796
|
+
});
|
|
797
|
+
const error = errors.at(0);
|
|
798
|
+
if (error !== void 0) {
|
|
799
|
+
scan.error = {
|
|
800
|
+
message: error.message,
|
|
801
|
+
offset: 0
|
|
802
|
+
};
|
|
803
|
+
return scan;
|
|
804
|
+
}
|
|
805
|
+
const blocks = [descriptor.script, descriptor.scriptSetup].filter((block) => block !== null);
|
|
806
|
+
let bindings = {};
|
|
807
|
+
if (blocks.length > 0) {
|
|
808
|
+
const script = combineScripts(blocks, descriptor);
|
|
809
|
+
scanScript(script.code, script.lang, script.mapOffset, scan);
|
|
810
|
+
if (scan.error !== null) return scan;
|
|
811
|
+
bindings = templateBindings(descriptor, script);
|
|
812
|
+
}
|
|
813
|
+
scanTemplate(descriptor, source, bindings, scan);
|
|
814
|
+
return scan;
|
|
815
|
+
}
|
|
816
|
+
//#endregion
|
|
817
|
+
//#region src/scan/index.ts
|
|
818
|
+
function scanFile(source, file) {
|
|
819
|
+
return file.endsWith(".vue") ? scanVue(source, file) : scanModule(source, file);
|
|
820
|
+
}
|
|
821
|
+
//#endregion
|
|
822
|
+
//#region src/utils/position.ts
|
|
823
|
+
/** 1-based line and column of a character offset. */
|
|
824
|
+
function lineColumn(source, offset) {
|
|
825
|
+
let line = 1;
|
|
826
|
+
let lineStart = 0;
|
|
827
|
+
for (let i = source.indexOf("\n"); i !== -1 && i < offset; i = source.indexOf("\n", i + 1)) {
|
|
828
|
+
line += 1;
|
|
829
|
+
lineStart = i + 1;
|
|
830
|
+
}
|
|
831
|
+
return {
|
|
832
|
+
line,
|
|
833
|
+
column: offset - lineStart + 1
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
/** Character offset of a 1-based line and column; the inverse of `lineColumn`. */
|
|
837
|
+
function offsetAt(source, { line, column }) {
|
|
838
|
+
let lineStart = 0;
|
|
839
|
+
for (let current = 1; current < line; current += 1) lineStart = source.indexOf("\n", lineStart) + 1;
|
|
840
|
+
return lineStart + column - 1;
|
|
841
|
+
}
|
|
842
|
+
//#endregion
|
|
843
|
+
//#region src/analyze/files.ts
|
|
844
|
+
const SOURCE_GLOB = "**/*.{vue,ts,tsx,mts,js,jsx,mjs}";
|
|
845
|
+
const DEFAULT_IGNORE = [
|
|
846
|
+
"**/node_modules/**",
|
|
847
|
+
"**/.nuxt/**",
|
|
848
|
+
"**/.output/**",
|
|
849
|
+
"**/dist/**",
|
|
850
|
+
"**/*.d.ts",
|
|
851
|
+
"**/*.d.mts",
|
|
852
|
+
"**/nuxt.config.*",
|
|
853
|
+
"**/.config/**",
|
|
854
|
+
"**/*.{test,spec}.*",
|
|
855
|
+
"**/__tests__/**",
|
|
856
|
+
"modules/**",
|
|
857
|
+
"public/**",
|
|
858
|
+
"layerscope.config.*"
|
|
859
|
+
];
|
|
860
|
+
function scannedDirs(layer) {
|
|
861
|
+
if (layer.root.includes("/node_modules/")) return [];
|
|
862
|
+
return [.../* @__PURE__ */ new Set([
|
|
863
|
+
layer.srcDir,
|
|
864
|
+
layer.serverDir,
|
|
865
|
+
layer.sharedDir
|
|
866
|
+
])].filter((dir) => existsSync(dir));
|
|
867
|
+
}
|
|
868
|
+
/** Whether `collectFiles` would pick up this file of the given layer. */
|
|
869
|
+
function isCheckedFile(file, layer, ignore) {
|
|
870
|
+
const dir = scannedDirs(layer).find((candidate) => file.startsWith(`${candidate}/`));
|
|
871
|
+
if (dir === void 0) return false;
|
|
872
|
+
const relativePath = relative(dir, file);
|
|
873
|
+
return path.matchesGlob(relativePath, SOURCE_GLOB) && ![...DEFAULT_IGNORE, ...ignore].some((pattern) => path.matchesGlob(relativePath, pattern));
|
|
874
|
+
}
|
|
875
|
+
/** Source files of every layer, sorted, mapped to the layer that owns them. */
|
|
876
|
+
async function collectFiles(layers, ownerOf, ignore) {
|
|
877
|
+
const jobs = layers.flatMap((layer) => scannedDirs(layer).map(async (dir) => {
|
|
878
|
+
return (await glob(SOURCE_GLOB, {
|
|
879
|
+
cwd: dir,
|
|
880
|
+
absolute: true,
|
|
881
|
+
ignore: [...DEFAULT_IGNORE, ...ignore]
|
|
882
|
+
})).filter((file) => ownerOf(file) === layer).map((file) => [file, layer]);
|
|
883
|
+
}));
|
|
884
|
+
const entries = (await Promise.all(jobs)).flat();
|
|
885
|
+
return new Map(entries.toSorted(([a], [b]) => compareStrings(a, b)));
|
|
886
|
+
}
|
|
887
|
+
function contextOf(file, layer) {
|
|
888
|
+
if (file.startsWith(`${layer.serverDir}/`)) return "server";
|
|
889
|
+
if (file.startsWith(`${layer.sharedDir}/`)) return "shared";
|
|
890
|
+
return "app";
|
|
891
|
+
}
|
|
892
|
+
//#endregion
|
|
893
|
+
//#region src/resolve/specifier.ts
|
|
894
|
+
function isRelative(specifier) {
|
|
895
|
+
return /^\.\.?(?:\/|$)/u.test(specifier);
|
|
896
|
+
}
|
|
897
|
+
function resolvePath(path, buildDir) {
|
|
898
|
+
if (path === buildDir || path.startsWith(`${buildDir}/`)) return {
|
|
899
|
+
kind: "external",
|
|
900
|
+
module: "#build"
|
|
901
|
+
};
|
|
902
|
+
const file = resolveFile(path);
|
|
903
|
+
if (file === null) return { kind: "missing" };
|
|
904
|
+
const module = file.includes("/node_modules/") ? packageName(file) : null;
|
|
905
|
+
return {
|
|
906
|
+
kind: "file",
|
|
907
|
+
file: realPath(file),
|
|
908
|
+
module
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
function resolveAlias(specifier, aliases, buildDir) {
|
|
912
|
+
const match = aliases.find(([alias]) => specifier === alias || specifier.startsWith(`${alias}/`));
|
|
913
|
+
if (match === void 0) return null;
|
|
914
|
+
const [alias, target] = match;
|
|
915
|
+
const path = target + specifier.slice(alias.length);
|
|
916
|
+
return isAbsolute(target) ? resolvePath(path, buildDir) : {
|
|
917
|
+
kind: "external",
|
|
918
|
+
module: packageName(path)
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function resolveSpecifier(specifier, fromFile, aliases, buildDir) {
|
|
922
|
+
if (isRelative(specifier)) return resolvePath(resolve(dirname(fromFile), specifier), buildDir);
|
|
923
|
+
if (isAbsolute(specifier)) return resolvePath(specifier, buildDir);
|
|
924
|
+
const aliased = resolveAlias(specifier, aliases, buildDir);
|
|
925
|
+
if (aliased !== null) return aliased;
|
|
926
|
+
if (specifier.startsWith("#") || specifier.includes(":")) return {
|
|
927
|
+
kind: "external",
|
|
928
|
+
module: specifier.split("/")[0]
|
|
929
|
+
};
|
|
930
|
+
return {
|
|
931
|
+
kind: "external",
|
|
932
|
+
module: packageName(specifier)
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/analyze/import-refs.ts
|
|
937
|
+
function virtualModule(specifier, context, table) {
|
|
938
|
+
if (specifier === "#imports") return table.imports[context];
|
|
939
|
+
return specifier === "#components" ? table.components : null;
|
|
940
|
+
}
|
|
941
|
+
/** `#imports` and `#components` are resolved by name through the symbol table. */
|
|
942
|
+
function resolveImportRef(ref, file, context, env) {
|
|
943
|
+
const virtual = virtualModule(ref.specifier, context, env.table);
|
|
944
|
+
if (virtual !== null) return ref.names.map((name) => {
|
|
945
|
+
const target = virtual.get(name);
|
|
946
|
+
return target === void 0 ? {
|
|
947
|
+
resolved: false,
|
|
948
|
+
message: `"${name}" is not exported by ${ref.specifier}`
|
|
949
|
+
} : {
|
|
950
|
+
resolved: true,
|
|
951
|
+
symbol: `${ref.specifier}:${name}`,
|
|
952
|
+
target: {
|
|
953
|
+
to: target.file,
|
|
954
|
+
external: target.module
|
|
955
|
+
}
|
|
956
|
+
};
|
|
957
|
+
});
|
|
958
|
+
const resolution = resolveSpecifier(ref.specifier, file, env.aliases, env.buildDir);
|
|
959
|
+
if (resolution.kind === "missing") return [{
|
|
960
|
+
resolved: false,
|
|
961
|
+
message: `Cannot resolve import "${ref.specifier}"`
|
|
962
|
+
}];
|
|
963
|
+
const target = resolution.kind === "file" ? {
|
|
964
|
+
to: resolution.file,
|
|
965
|
+
external: resolution.module
|
|
966
|
+
} : {
|
|
967
|
+
to: null,
|
|
968
|
+
external: resolution.module
|
|
969
|
+
};
|
|
970
|
+
return [{
|
|
971
|
+
resolved: true,
|
|
972
|
+
symbol: ref.specifier,
|
|
973
|
+
target
|
|
974
|
+
}];
|
|
975
|
+
}
|
|
976
|
+
//#endregion
|
|
977
|
+
//#region src/analyze/file-analysis.ts
|
|
978
|
+
/** Runtime registrations (plugins, `app.component`, `vueApp.use`) are invisible statically. */
|
|
979
|
+
const GLOBALS_HINT = "if it is registered at runtime, add it to \"globals\" in layerscope.config.ts";
|
|
980
|
+
/** Turns one file's scan into dependency edges and unresolved-reference findings. */
|
|
981
|
+
var FileAnalysis = class {
|
|
982
|
+
constructor(file, source, layer, env) {
|
|
983
|
+
this.edges = [];
|
|
984
|
+
this.unresolved = [];
|
|
985
|
+
this.hasDynamicComponent = false;
|
|
986
|
+
this.seen = /* @__PURE__ */ new Set();
|
|
987
|
+
this.file = file;
|
|
988
|
+
this.source = source;
|
|
989
|
+
this.layer = layer;
|
|
990
|
+
this.env = env;
|
|
991
|
+
this.context = contextOf(file, layer);
|
|
992
|
+
}
|
|
993
|
+
run(scan) {
|
|
994
|
+
if (scan.error !== null) {
|
|
995
|
+
this.addUnresolved("<parse error>", scan.error.offset, `Could not parse file: ${scan.error.message}`);
|
|
996
|
+
return this;
|
|
997
|
+
}
|
|
998
|
+
this.addIdentifiers(scan);
|
|
999
|
+
this.addComponents(scan);
|
|
1000
|
+
this.addImports(scan);
|
|
1001
|
+
return this;
|
|
1002
|
+
}
|
|
1003
|
+
addIdentifiers(scan) {
|
|
1004
|
+
const imports = this.env.table.imports[this.context];
|
|
1005
|
+
for (const ref of scan.free) {
|
|
1006
|
+
const target = imports.get(ref.name);
|
|
1007
|
+
if (target !== void 0) this.addEdge("auto-import", ref.name, ref.offset, {
|
|
1008
|
+
to: target.file,
|
|
1009
|
+
external: target.module
|
|
1010
|
+
});
|
|
1011
|
+
else if (!this.env.identifiers.has(ref.name)) this.addUnresolved(ref.name, ref.offset, `"${ref.name}" is not a local binding, a known global or an auto-import in the ${this.context} context; ${GLOBALS_HINT}`);
|
|
1012
|
+
}
|
|
1013
|
+
for (const ref of scan.templateIdents) {
|
|
1014
|
+
const target = imports.get(ref.name);
|
|
1015
|
+
if (target !== void 0) this.addEdge("auto-import", ref.name, ref.offset, {
|
|
1016
|
+
to: target.file,
|
|
1017
|
+
external: target.module
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
addComponents(scan) {
|
|
1022
|
+
for (const ref of scan.components) {
|
|
1023
|
+
const target = this.env.table.components.get(ref.name);
|
|
1024
|
+
if (target !== void 0) this.addEdge("component", ref.name, ref.offset, {
|
|
1025
|
+
to: target.file,
|
|
1026
|
+
external: target.module
|
|
1027
|
+
});
|
|
1028
|
+
else if (!this.env.components.has(ref.name)) this.addUnresolved(ref.name, ref.offset, `Component <${ref.name}> is not in components.d.ts; ${GLOBALS_HINT}`);
|
|
1029
|
+
}
|
|
1030
|
+
this.hasDynamicComponent = scan.dynamicComponents.length > 0;
|
|
1031
|
+
for (const offset of scan.dynamicComponents) this.addUnresolved("<component :is>", offset, "Dynamic component bound to a runtime value cannot be resolved statically");
|
|
1032
|
+
}
|
|
1033
|
+
addImports(scan) {
|
|
1034
|
+
for (const ref of scan.imports) for (const outcome of resolveImportRef(ref, this.file, this.context, this.env)) if (outcome.resolved) this.addEdge("import", outcome.symbol, ref.offset, outcome.target);
|
|
1035
|
+
else this.addUnresolved(ref.specifier, ref.offset, outcome.message);
|
|
1036
|
+
}
|
|
1037
|
+
reference(kind, symbol, offset) {
|
|
1038
|
+
return {
|
|
1039
|
+
file: this.file,
|
|
1040
|
+
...lineColumn(this.source, offset),
|
|
1041
|
+
kind,
|
|
1042
|
+
symbol
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
/** Only the first use of a symbol per file is recorded. */
|
|
1046
|
+
firstSeen(key) {
|
|
1047
|
+
if (this.seen.has(key)) return false;
|
|
1048
|
+
this.seen.add(key);
|
|
1049
|
+
return true;
|
|
1050
|
+
}
|
|
1051
|
+
addEdge(kind, symbol, offset, target) {
|
|
1052
|
+
if (!this.firstSeen(`${kind}\0${symbol}`)) return;
|
|
1053
|
+
const toLayer = target.to === null ? null : this.env.ownerOf(target.to);
|
|
1054
|
+
const isPackage = toLayer === null && target.external !== null;
|
|
1055
|
+
this.edges.push({
|
|
1056
|
+
...this.reference(kind, symbol, offset),
|
|
1057
|
+
fromLayer: this.layer.name,
|
|
1058
|
+
to: isPackage ? null : target.to,
|
|
1059
|
+
toLayer: toLayer?.name ?? null,
|
|
1060
|
+
external: toLayer === null ? target.external ?? "unknown" : null
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
addUnresolved(symbol, offset, message) {
|
|
1064
|
+
const severity = this.env.unresolvedSeverity;
|
|
1065
|
+
if (severity === "off" || !this.firstSeen(`unresolved\0${symbol}`)) return;
|
|
1066
|
+
this.unresolved.push({
|
|
1067
|
+
rule: "unresolved-reference",
|
|
1068
|
+
severity,
|
|
1069
|
+
file: this.file,
|
|
1070
|
+
...lineColumn(this.source, offset),
|
|
1071
|
+
symbol,
|
|
1072
|
+
fromLayer: this.layer.name,
|
|
1073
|
+
toLayer: null,
|
|
1074
|
+
target: null,
|
|
1075
|
+
message
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
/** Reads, scans and analyzes one source file. */
|
|
1080
|
+
function analyzeFile(file, layer, env) {
|
|
1081
|
+
const source = readFileSync(file, "utf8");
|
|
1082
|
+
return new FileAnalysis(file, source, layer, env).run(scanFile(source, file));
|
|
1083
|
+
}
|
|
1084
|
+
//#endregion
|
|
1085
|
+
export { findConfigFile as _, lineColumn as a, createOwnerLookup as c, createAnalysisEnv as d, readRegistry as f, isAllowed as g, boundaryFindings as h, isCheckedFile as i, nameLayers as l, loadSymbolTable as m, analyzeFile as n, offsetAt as o, tableFromRegistry as p, collectFiles as r, scanFile as s, FileAnalysis as t, resolveLayers as u, loadConfig as v, loadConfigSync as y };
|