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.
@@ -0,0 +1,24 @@
1
+ import { a as Context, c as Layer, d as Reference, f as ReferenceKind, g as SourceOption, h as Severity, i as BaselineResult, l as LayerRule, m as RuleName, n as AnalyzeResult, o as Edge, p as ResolutionSource, r as BaselineEntry, s as Finding, t as defineConfig, u as LayerscopeConfig } from "./define-DNRGLVG_.mjs";
2
+ import { t as ProjectConfig } from "./effective-Dqg4EwH_.mjs";
3
+ //#region src/module/index.d.ts
4
+ interface ModuleOptions extends ProjectConfig {
5
+ /** Write `.nuxt/layerscope/registry.json`. Defaults to `true`. */
6
+ enabled: boolean;
7
+ /** Add a Layerscope tab to Nuxt DevTools while `nuxi dev` runs. Defaults to `true`. */
8
+ devtools: boolean;
9
+ }
10
+ /**
11
+ * Records the registry Nuxt resolves (layers, components including overridden ones, app and
12
+ * server auto-imports) into `.nuxt/layerscope/registry.json` for the `layerscope` CLI.
13
+ */
14
+ declare const layerscopeModule: import("@nuxt/schema").NuxtModule<ModuleOptions, ModuleOptions, false>;
15
+ declare module '@nuxt/schema' {
16
+ interface NuxtConfig {
17
+ layerscope?: Partial<ModuleOptions>;
18
+ }
19
+ interface NuxtOptions {
20
+ layerscope: ModuleOptions;
21
+ }
22
+ }
23
+ //#endregion
24
+ export { type AnalyzeResult, type BaselineEntry, type BaselineResult, type Context, type Edge, type Finding, type Layer, type LayerRule, type LayerscopeConfig, type ModuleOptions, type Reference, type ReferenceKind, type ResolutionSource, type RuleName, type Severity, type SourceOption, layerscopeModule as default, defineConfig };
package/dist/index.mjs ADDED
@@ -0,0 +1,364 @@
1
+ import { f as pickProjectConfig, l as REGISTRY_FILE, r as layerFromNuxt } from "./layer-factory-xZmIAPrA.mjs";
2
+ import { t as summarize } from "./summary-DiUYKMPe.mjs";
3
+ import { i as plural, t as compareStrings } from "./strings-CrOjNZmB.mjs";
4
+ import { t as packageVersion } from "./version-D6whhsHH.mjs";
5
+ import { t as globComponents } from "./component-files-DgOXNE6l.mjs";
6
+ import { t as defineConfig } from "./define-l62B9fVk.mjs";
7
+ import { mkdir, writeFile } from "node:fs/promises";
8
+ import { addDevServerHandler, defineNuxtModule, getLayerDirectories } from "@nuxt/kit";
9
+ import { dirname, isAbsolute, join, relative, resolve } from "pathe";
10
+ import { defineEventHandler, getQuery, setResponseHeader } from "h3";
11
+ //#region src/module/nuxt.ts
12
+ /** `nitro:init` is typed by Nuxt's Nitro integration, not by `@nuxt/schema`. */
13
+ function onNitroInit(nuxt, handler) {
14
+ nuxt.hook("nitro:init", handler);
15
+ }
16
+ /** Typed by `@nuxt/devtools`, which the module does not depend on. */
17
+ function onDevtoolsCustomTabs(nuxt, handler) {
18
+ nuxt.hook("devtools:customTabs", handler);
19
+ }
20
+ //#endregion
21
+ //#region src/devtools/html.ts
22
+ const ENTITIES = {
23
+ "&": "&amp;",
24
+ "<": "&lt;",
25
+ ">": "&gt;",
26
+ "\"": "&quot;",
27
+ "'": "&#39;"
28
+ };
29
+ function escapeHtml(text) {
30
+ return text.replaceAll(/[&<>"']/gu, (char) => ENTITIES[char] ?? char);
31
+ }
32
+ //#endregion
33
+ //#region src/devtools/page-assets.ts
34
+ const PAGE_STYLE = `
35
+ :root { color-scheme: light dark; font: 14px/1.5 system-ui, sans-serif; }
36
+ body { margin: 0; padding: 16px 24px; }
37
+ header { display: flex; align-items: center; justify-content: space-between; }
38
+ h1 { font-size: 18px; margin: 0; }
39
+ h2 { font-size: 15px; margin: 24px 0 8px; }
40
+ table { border-collapse: collapse; width: 100%; }
41
+ th, td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #8884; }
42
+ ul.findings { list-style: none; padding: 0; margin: 0; }
43
+ ul.findings li { padding: 8px 0; border-bottom: 1px solid #8884; }
44
+ .badge { font-size: 12px; padding: 1px 6px; border-radius: 4px; color: #fff; }
45
+ .error .badge { background: #d73a49; }
46
+ .warn .badge { background: #b08800; }
47
+ .rule, .summary, .target { opacity: 0.7; }
48
+ .note, .error-message { padding: 8px; border-radius: 4px; background: #b0880022; }
49
+ .ok { color: #22863a; }
50
+ a { color: inherit; }
51
+ button { cursor: pointer; }
52
+ `;
53
+ const PAGE_SCRIPT = `
54
+ const endpoint = document.getElementById('open-endpoint')?.textContent;
55
+ document.addEventListener('click', event => {
56
+ const link = event.target.closest('[data-open]');
57
+ if (link && endpoint) {
58
+ event.preventDefault();
59
+ fetch(endpoint + '?file=' + encodeURIComponent(link.dataset.open));
60
+ }
61
+ if (event.target.closest('[data-reload]')) {
62
+ location.reload();
63
+ }
64
+ });
65
+ `;
66
+ //#endregion
67
+ //#region src/devtools/page.ts
68
+ function fileLink(file, line, column, input) {
69
+ const label = `${relative(input.result.rootDir, file)}:${line}:${column}`;
70
+ return `<a href="#" data-open="${escapeHtml(`${file}:${line}:${column}`)}">${escapeHtml(label)}</a>`;
71
+ }
72
+ function layersTable(input) {
73
+ return `<table><thead><tr><th>Layer</th><th>Root</th><th>May depend on</th></tr></thead><tbody>${input.result.layers.map((layer) => {
74
+ const allow = input.result.config.layers?.[layer.name]?.allow;
75
+ const allowed = allow === void 0 ? "<em>unrestricted</em>" : escapeHtml(allow.join(", ") || "—");
76
+ const root = relative(input.result.rootDir, layer.root) || ".";
77
+ return `<tr><td>${escapeHtml(layer.name)}</td><td><code>${escapeHtml(root)}</code></td><td>${allowed}</td></tr>`;
78
+ }).join("")}</tbody></table>`;
79
+ }
80
+ function findingItem(finding, input) {
81
+ const target = finding.target === null ? "" : `<div class="target">→ ${fileLink(finding.target, 1, 1, input)}</div>`;
82
+ return `<li class="${finding.severity}"><span class="badge">${finding.severity}</span> ${escapeHtml(finding.message)} <span class="rule">${finding.rule}</span><div>${fileLink(finding.file, finding.line, finding.column, input)}</div>${target}</li>`;
83
+ }
84
+ function findingsList(input) {
85
+ const { findings } = input.result;
86
+ if (findings.length === 0) return "<p class=\"ok\">✔ No problems</p>";
87
+ return `<ul class="findings">${findings.map((finding) => findingItem(finding, input)).join("")}</ul>`;
88
+ }
89
+ function summaryLine(result) {
90
+ const { errors, warnings } = summarize(result.findings);
91
+ const source = result.source === "registry" ? "module registry" : "generated .d.ts files";
92
+ return `${plural(result.files.length, "file")} in ${plural(result.layers.length, "layer")} · ${plural(errors, "error")}, ${plural(warnings, "warning")} · symbols from the ${source}`;
93
+ }
94
+ function document(body) {
95
+ return `<!doctype html><html><head><meta charset="utf-8"><title>layerscope</title><style>${PAGE_STYLE}</style></head><body>${body}<script>${PAGE_SCRIPT}<\/script></body></html>`;
96
+ }
97
+ /** The DevTools tab: layers, their allowed dependencies and current findings. */
98
+ function renderPage(input) {
99
+ const notes = input.result.notes.map((note) => `<p class="note">${escapeHtml(note)}</p>`).join("");
100
+ return document(`<header><h1>layerscope</h1><button data-reload>Re-run</button></header><p class="summary">${summaryLine(input.result)}</p>${notes}<h2>Layers</h2>${layersTable(input)}<h2>Findings</h2>${findingsList(input)}<template id="open-endpoint">${escapeHtml(input.openInEditor)}</template>`);
101
+ }
102
+ function renderError(message) {
103
+ return document(`<header><h1>layerscope</h1><button data-reload>Re-run</button></header><p class="error-message">${escapeHtml(message)}</p>`);
104
+ }
105
+ //#endregion
106
+ //#region src/devtools/index.ts
107
+ const DEVTOOLS_ROUTE = "/__layerscope";
108
+ async function renderReport(nuxt, format) {
109
+ const [{ analyze }, { formatResult }] = await Promise.all([import("./analyze-YePAJEVS.mjs"), import("./report-DtAi0vPb.mjs")]);
110
+ const { rootDir, app } = nuxt.options;
111
+ const result = await analyze({ rootDir });
112
+ if (format === "json") return formatResult(result, "json", rootDir);
113
+ return renderPage({
114
+ result,
115
+ openInEditor: join("/", app.baseURL, app.buildAssetsDir, "__open-in-editor")
116
+ });
117
+ }
118
+ /** A Nuxt DevTools tab with the layer report, served while `nuxi dev` runs. */
119
+ function setupDevtools(nuxt) {
120
+ addDevServerHandler({
121
+ route: DEVTOOLS_ROUTE,
122
+ handler: defineEventHandler(async (event) => {
123
+ const { format } = getQuery(event);
124
+ setResponseHeader(event, "content-type", format === "json" ? "application/json" : "text/html; charset=utf-8");
125
+ try {
126
+ return await renderReport(nuxt, format);
127
+ } catch (error) {
128
+ return renderError(error.message);
129
+ }
130
+ })
131
+ });
132
+ onDevtoolsCustomTabs(nuxt, (tabs) => {
133
+ tabs.push({
134
+ name: "layerscope",
135
+ title: "Layerscope",
136
+ icon: "carbon:flow-connection",
137
+ view: {
138
+ type: "iframe",
139
+ src: DEVTOOLS_ROUTE
140
+ }
141
+ });
142
+ });
143
+ }
144
+ //#endregion
145
+ //#region src/module/collect.ts
146
+ const COMPONENT_IGNORE = ["**/*{M,.m,-m}ixin.{js,ts,jsx,tsx}", "**/*.{d.ts,d.mts,d.cts,d.vue.ts,d.vue.mts,d.vue.cts}"];
147
+ const SHARED_BUILTINS = [
148
+ {
149
+ name: "useRuntimeConfig",
150
+ from: "nuxt"
151
+ },
152
+ {
153
+ name: "useAppConfig",
154
+ from: "nuxt"
155
+ },
156
+ {
157
+ name: "defineAppConfig",
158
+ from: "nuxt"
159
+ },
160
+ {
161
+ name: "createError",
162
+ from: "h3"
163
+ },
164
+ {
165
+ name: "setResponseStatus",
166
+ from: "h3"
167
+ }
168
+ ];
169
+ const SHARED_BUILTIN_NAMES = new Set(SHARED_BUILTINS.map((entry) => entry.name));
170
+ const SERVER_PLACEHOLDER = "/components/server-placeholder";
171
+ function resolveAlias(path, aliases) {
172
+ const key = Object.keys(aliases).toSorted((a, b) => b.length - a.length).find((alias) => path === alias || path.startsWith(`${alias}/`));
173
+ return key === void 0 ? path : aliases[key] + path.slice(key.length);
174
+ }
175
+ function toMode(mode) {
176
+ return mode === "client" || mode === "server" ? mode : "all";
177
+ }
178
+ function byName(entries) {
179
+ return entries.toSorted((a, b) => compareStrings(a.name, b.name));
180
+ }
181
+ function importName(entry) {
182
+ return entry.as ?? entry.name;
183
+ }
184
+ async function activeImports(context) {
185
+ return (context === null || context === void 0 ? [] : await context.getImports()).filter((entry) => entry.type !== true && entry.disabled !== true);
186
+ }
187
+ function toRegistryImports(imports, aliases) {
188
+ return byName(imports.map((entry) => {
189
+ const from = resolveAlias(entry.from, aliases);
190
+ return {
191
+ name: importName(entry),
192
+ from: isAbsolute(from) ? resolve(from) : from
193
+ };
194
+ }));
195
+ }
196
+ function withoutDot(extension) {
197
+ return extension.replace(/^\./u, "");
198
+ }
199
+ /**
200
+ * Records what Nuxt resolves while it prepares or builds the app. Nuxt drops overridden
201
+ * components before `components:extend`, so each component dir's `extendComponent` is wrapped to
202
+ * see every scanned component, including the ones a higher-priority layer replaces.
203
+ */
204
+ var RegistryCollector = class {
205
+ constructor(nuxt, config) {
206
+ this.dirs = [];
207
+ this.scanned = /* @__PURE__ */ new Map();
208
+ this.registered = [];
209
+ this.appImports = null;
210
+ this.nitro = null;
211
+ this.config = config;
212
+ this.nuxt = nuxt;
213
+ }
214
+ install() {
215
+ const { nuxt } = this;
216
+ nuxt.hook("modules:done", () => {
217
+ nuxt.hook("components:dirs", (dirs) => {
218
+ this.wrapDirs(dirs);
219
+ });
220
+ });
221
+ nuxt.hook("components:extend", (components) => {
222
+ this.registered = components;
223
+ });
224
+ nuxt.hook("imports:context", (context) => {
225
+ this.appImports = context;
226
+ });
227
+ onNitroInit(nuxt, (nitro) => {
228
+ this.nitro = nitro;
229
+ });
230
+ }
231
+ wrapDirs(dirs) {
232
+ this.dirs = [];
233
+ for (const [index, entry] of dirs.entries()) {
234
+ const dir = typeof entry === "string" ? { path: entry } : entry;
235
+ const extend = dir.extendComponent;
236
+ dir.extendComponent = async (component) => {
237
+ const result = (extend === void 0 ? void 0 : await extend(component)) ?? component;
238
+ this.scanned.set(result.filePath, result);
239
+ return result;
240
+ };
241
+ dirs[index] = dir;
242
+ this.dirs.push(dir);
243
+ }
244
+ }
245
+ async collect() {
246
+ const [componentDirs, imports] = await Promise.all([this.componentDirs(), this.imports()]);
247
+ const layers = this.layers();
248
+ const components = this.components();
249
+ return {
250
+ version: 1,
251
+ generator: {
252
+ name: "nuxt-layerscope",
253
+ version: packageVersion(),
254
+ nuxt: this.nuxt._version
255
+ },
256
+ layers,
257
+ components,
258
+ shadowedComponents: this.shadowed(components),
259
+ componentDirs,
260
+ imports,
261
+ ...Object.keys(this.config).length > 0 ? { config: this.config } : {}
262
+ };
263
+ }
264
+ layers() {
265
+ const dirs = getLayerDirectories(this.nuxt);
266
+ return this.nuxt.options._layers.map((layer, index) => layerFromNuxt(layer, index, dirs[index]));
267
+ }
268
+ toFile(path) {
269
+ const file = resolveAlias(path, this.nuxt.options.alias);
270
+ return isAbsolute(file) ? resolve(file) : file;
271
+ }
272
+ toComponent(component) {
273
+ return {
274
+ name: component.pascalName,
275
+ file: this.toFile(component.filePath),
276
+ mode: toMode(component.mode),
277
+ island: component.island === true,
278
+ priority: component.priority ?? 0
279
+ };
280
+ }
281
+ components() {
282
+ return this.registered.filter((component) => !component.filePath.includes(SERVER_PLACEHOLDER)).map((component) => this.toComponent(component));
283
+ }
284
+ shadowed(registered) {
285
+ const files = new Set(registered.map((component) => component.file));
286
+ return [...this.scanned.values()].map((component) => this.toComponent(component)).filter((component) => !files.has(component.file)).flatMap((component) => {
287
+ const winner = registered.find((other) => other.name === component.name && (other.mode === "all" || component.mode === "all" || other.mode === component.mode));
288
+ return winner === void 0 ? [] : [{
289
+ ...component,
290
+ shadowedBy: winner.file
291
+ }];
292
+ }).toSorted((a, b) => compareStrings(a.file, b.file));
293
+ }
294
+ async componentDirs() {
295
+ const { alias, extensions } = this.nuxt.options;
296
+ const jobs = this.dirs.map(async (dir) => {
297
+ const path = resolve(resolveAlias(dir.path, alias));
298
+ const exts = (dir.extensions ?? extensions).map((ext) => withoutDot(ext));
299
+ const pattern = dir.pattern ?? (exts.length > 1 ? `**/*.{${exts.join(",")}}` : `**/*.${exts[0] ?? "*"}`);
300
+ const ignore = [...COMPONENT_IGNORE, ...dir.ignore ?? []];
301
+ return {
302
+ path,
303
+ pattern,
304
+ ignore,
305
+ files: path.includes("/node_modules/") ? [] : await globComponents(path, pattern, ignore)
306
+ };
307
+ });
308
+ return await Promise.all(jobs);
309
+ }
310
+ async imports() {
311
+ const serverContext = this.nitro?.unimport;
312
+ await serverContext?.init?.();
313
+ const app = await activeImports(this.appImports);
314
+ const server = await activeImports(serverContext);
315
+ const serverFrom = new Map(server.map((entry) => [importName(entry), entry.from]));
316
+ const shared = app.filter((entry) => !SHARED_BUILTIN_NAMES.has(importName(entry)) && serverFrom.get(importName(entry)) === entry.from);
317
+ const nuxtAliases = this.nuxt.options.alias;
318
+ const serverAliases = {
319
+ ...nuxtAliases,
320
+ ...this.nitro?.options.alias
321
+ };
322
+ return {
323
+ app: toRegistryImports(app, nuxtAliases),
324
+ server: toRegistryImports(server, serverAliases),
325
+ shared: byName([...SHARED_BUILTINS, ...toRegistryImports(shared, nuxtAliases)])
326
+ };
327
+ }
328
+ };
329
+ //#endregion
330
+ //#region src/module/index.ts
331
+ async function writeRegistry(collector, buildDir) {
332
+ const file = join(buildDir, REGISTRY_FILE);
333
+ await mkdir(dirname(file), { recursive: true });
334
+ await writeFile(file, `${JSON.stringify(await collector.collect(), null, 2)}\n`);
335
+ }
336
+ //#endregion
337
+ //#region src/index.ts
338
+ var src_default = defineNuxtModule({
339
+ meta: {
340
+ name: "nuxt-layerscope",
341
+ configKey: "layerscope",
342
+ version: packageVersion(),
343
+ compatibility: { nuxt: ">=3.12.0" }
344
+ },
345
+ defaults: {
346
+ enabled: true,
347
+ devtools: true
348
+ },
349
+ setup(options, nuxt) {
350
+ if (!options.enabled) return;
351
+ const collector = new RegistryCollector(nuxt, pickProjectConfig(options));
352
+ collector.install();
353
+ const write = async () => {
354
+ await writeRegistry(collector, nuxt.options.buildDir);
355
+ };
356
+ nuxt.hook("prepare:types", write);
357
+ if (nuxt.options.dev) {
358
+ nuxt.hook("app:templatesGenerated", write);
359
+ if (options.devtools) setupDevtools(nuxt);
360
+ }
361
+ }
362
+ });
363
+ //#endregion
364
+ export { src_default as default, defineConfig };
@@ -0,0 +1,168 @@
1
+ import { basename, dirname, join, resolve } from "pathe";
2
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
3
+ //#region src/errors.ts
4
+ /** A config or input problem; the CLI exits with code 2. */
5
+ var LayerscopeError = class extends Error {
6
+ constructor(..._args) {
7
+ super(..._args);
8
+ this.name = "LayerscopeError";
9
+ }
10
+ };
11
+ //#endregion
12
+ //#region src/config/rules.ts
13
+ const DEFAULT_SEVERITY = {
14
+ "layer-boundary": "error",
15
+ "unresolved-reference": "warn",
16
+ "shadowed-component": "warn"
17
+ };
18
+ /** Accepted in config for forward compatibility; not implemented yet. */
19
+ const RESERVED_RULES = /* @__PURE__ */ new Set(["unused-symbol"]);
20
+ const SEVERITIES = /* @__PURE__ */ new Set([
21
+ "off",
22
+ "warn",
23
+ "error"
24
+ ]);
25
+ function isRuleName(name) {
26
+ return Object.hasOwn(DEFAULT_SEVERITY, name);
27
+ }
28
+ function ruleSeverity(config, rule) {
29
+ return config.rules?.[rule] ?? DEFAULT_SEVERITY[rule];
30
+ }
31
+ //#endregion
32
+ //#region src/config/validate.ts
33
+ function validateConfig(config, file) {
34
+ for (const [rule, severity] of Object.entries(config.rules ?? {})) {
35
+ if (!isRuleName(rule) && !RESERVED_RULES.has(rule)) throw new LayerscopeError(`${file}: unknown rule "${rule}"`);
36
+ if (!SEVERITIES.has(String(severity))) throw new LayerscopeError(`${file}: rule "${rule}" must be "off", "warn" or "error", got ${JSON.stringify(severity)}`);
37
+ }
38
+ for (const [name, layer] of Object.entries(config.layers ?? {})) {
39
+ if (layer.allow !== void 0 && !Array.isArray(layer.allow)) throw new LayerscopeError(`${file}: layers.${name}.allow must be an array of layer names`);
40
+ if (layer.path !== void 0 && layer.source !== void 0) throw new LayerscopeError(`${file}: layers.${name} sets both "path" and "source"; use one`);
41
+ }
42
+ }
43
+ //#endregion
44
+ //#region src/config/effective.ts
45
+ /** Where the module's options live when they come from `nuxt.config`. */
46
+ const NUXT_CONFIG_LABEL = "nuxt.config (layerscope)";
47
+ /** The keys that describe the project; `buildDir` only says where to look. */
48
+ const PROJECT_CONFIG_KEYS = [
49
+ "layers",
50
+ "rules",
51
+ "ignore",
52
+ "globals"
53
+ ];
54
+ function hasProjectConfig(config) {
55
+ return PROJECT_CONFIG_KEYS.some((key) => config[key] !== void 0);
56
+ }
57
+ function pickProjectConfig(config) {
58
+ return Object.fromEntries(PROJECT_CONFIG_KEYS.flatMap((key) => config[key] === void 0 ? [] : [[key, config[key]]]));
59
+ }
60
+ /**
61
+ * `layerscope.config.*` or the `layerscope` key of `nuxt.config`, which the module records in the
62
+ * registry. Both at once is an error rather than a merge, so it is always clear which one applies.
63
+ */
64
+ function effectiveConfig(fileConfig, recorded) {
65
+ if (recorded === void 0 || !hasProjectConfig(recorded)) return fileConfig;
66
+ if (hasProjectConfig(fileConfig)) throw new LayerscopeError("Layers or rules are set both in layerscope.config and in the \"layerscope\" key of nuxt.config. Keep one.");
67
+ validateConfig(recorded, NUXT_CONFIG_LABEL);
68
+ return {
69
+ ...fileConfig,
70
+ ...recorded
71
+ };
72
+ }
73
+ //#endregion
74
+ //#region src/registry/schema.ts
75
+ /** Bumped on breaking changes to `registry.json`; readers fall back to the `.d.ts` files. */
76
+ const REGISTRY_VERSION = 1;
77
+ /** Relative to the Nuxt build dir. */
78
+ const REGISTRY_FILE = "layerscope/registry.json";
79
+ //#endregion
80
+ //#region src/utils/fs.ts
81
+ function isFile(path) {
82
+ try {
83
+ return statSync(path).isFile();
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+ function readJson(path) {
89
+ return JSON.parse(readFileSync(path, "utf8").replaceAll(/^\s*\/\/.*$/gmu, ""));
90
+ }
91
+ /** Symlinks resolved (pnpm links packages), or the path itself when it does not exist. */
92
+ function realPath(path) {
93
+ try {
94
+ return realpathSync(path);
95
+ } catch {
96
+ return path;
97
+ }
98
+ }
99
+ //#endregion
100
+ //#region src/nuxt/remote-layer.ts
101
+ /** c12 clones remote layers into `node_modules/.c12`, or into `~/.cache/c12` without one. */
102
+ const CLONE_DIRS = /* @__PURE__ */ new Set([".c12", "c12"]);
103
+ /**
104
+ * c12 names a clone after the first three word segments of its `extends` source plus a hash of
105
+ * the whole source (`github:org/repo#v1` → `github_org_repo_<hash>`). The hash changes with the
106
+ * ref, so names and config matching use the prefix only.
107
+ */
108
+ function clonePrefix(source) {
109
+ return source.replaceAll(/\W+/gu, "_").split("_").slice(0, 3).join("_");
110
+ }
111
+ /** The clone name without its hash, so it survives ref changes; `null` for other layers. */
112
+ function remoteLayerName(root) {
113
+ if (!CLONE_DIRS.has(basename(dirname(root)))) return null;
114
+ const name = basename(root);
115
+ const hashStart = name.lastIndexOf("_");
116
+ return hashStart === -1 ? name : name.slice(0, hashStart);
117
+ }
118
+ /** Whether the layer at `root` was cloned from this `extends` source. */
119
+ function isClonedFrom(root, source) {
120
+ return remoteLayerName(root) === clonePrefix(source);
121
+ }
122
+ //#endregion
123
+ //#region src/nuxt/layer-factory.ts
124
+ const ROOT_LAYER_NAME = "root";
125
+ /** Fallback for `@nuxt/kit` releases without `getLayerDirectories`. */
126
+ function deriveDirectories(layer, index, options) {
127
+ const root = resolve(layer.config.rootDir ?? layer.cwd);
128
+ return {
129
+ root,
130
+ app: resolve(root, layer.config.srcDir ?? "."),
131
+ server: index === 0 && options.serverDir !== void 0 ? resolve(options.serverDir) : resolve(root, layer.config.serverDir ?? "server"),
132
+ shared: resolve(root, layer.config.dir?.shared ?? options.dir?.shared ?? "shared")
133
+ };
134
+ }
135
+ /** Real paths, so they match symlinked paths in generated types and imports. */
136
+ function toDirs(dirs) {
137
+ const dir = (path) => realPath(resolve(path));
138
+ return {
139
+ root: dir(dirs.root),
140
+ srcDir: dir(dirs.app),
141
+ serverDir: dir(dirs.server),
142
+ sharedDir: dir(dirs.shared)
143
+ };
144
+ }
145
+ function layerFromNuxt(layer, index, dirs) {
146
+ const resolved = toDirs(dirs);
147
+ return {
148
+ name: layer.meta?.name ?? layer.config.$meta?.name ?? (index === 0 ? "root" : remoteLayerName(resolved.root) ?? basename(resolved.root)),
149
+ ...resolved,
150
+ defaultComponents: layer.config.components === void 0
151
+ };
152
+ }
153
+ /** Without @nuxt/kit, assume Nuxt defaults: `app/` as srcDir when it exists (Nuxt 4). */
154
+ function layerFromPath(name, root) {
155
+ const appDir = join(root, "app");
156
+ return {
157
+ name,
158
+ ...toDirs({
159
+ root,
160
+ app: existsSync(appDir) ? appDir : root,
161
+ server: join(root, "server"),
162
+ shared: join(root, "shared")
163
+ }),
164
+ defaultComponents: true
165
+ };
166
+ }
167
+ //#endregion
168
+ export { isClonedFrom as a, realPath as c, effectiveConfig as d, pickProjectConfig as f, LayerscopeError as g, ruleSeverity as h, layerFromPath as i, REGISTRY_FILE as l, isRuleName as m, deriveDirectories as n, isFile as o, validateConfig as p, layerFromNuxt as r, readJson as s, ROOT_LAYER_NAME as t, REGISTRY_VERSION as u };
@@ -0,0 +1,131 @@
1
+ import { t as summarize } from "./summary-DiUYKMPe.mjs";
2
+ import { i as plural } from "./strings-CrOjNZmB.mjs";
3
+ import { relative } from "pathe";
4
+ //#region src/report/baseline.ts
5
+ /** `layer-boundary useCart → web (×2)` */
6
+ function describeEntry(entry) {
7
+ const target = entry.toLayer === null ? "" : ` → ${entry.toLayer}`;
8
+ const count = (entry.count ?? 1) > 1 ? ` (×${entry.count})` : "";
9
+ return `${entry.rule} ${entry.symbol}${target}${count}`;
10
+ }
11
+ const UPDATE_HINT = "Run \"layerscope check --update-baseline\" to remove";
12
+ //#endregion
13
+ //#region src/report/details.ts
14
+ /** Where the symbol resolves to and what the layer may use, so the fix is obvious. */
15
+ function findingDetails(finding, cwd) {
16
+ const details = [];
17
+ if (finding.target !== null) details.push(`${finding.symbol} → ${relative(cwd, finding.target)}`);
18
+ if (finding.allowed !== void 0) {
19
+ const allowed = finding.allowed.length > 0 ? finding.allowed.join(", ") : "no other layers";
20
+ details.push(`allowed for "${finding.fromLayer}": ${allowed}`);
21
+ }
22
+ return details;
23
+ }
24
+ //#endregion
25
+ //#region src/report/github.ts
26
+ function escapeData(value) {
27
+ return value.replaceAll("%", "%25").replaceAll("\r", "%0D").replaceAll("\n", "%0A");
28
+ }
29
+ function escapeProperty(value) {
30
+ return escapeData(value).replaceAll(":", "%3A").replaceAll(",", "%2C");
31
+ }
32
+ function formatAnnotation(finding, cwd) {
33
+ return `::${finding.severity === "error" ? "error" : "warning"} ${[
34
+ `file=${escapeProperty(relative(cwd, finding.file))}`,
35
+ `line=${finding.line}`,
36
+ `col=${finding.column}`,
37
+ `title=${escapeProperty(`layerscope ${finding.rule}`)}`
38
+ ].join(",")}::${escapeData([finding.message, ...findingDetails(finding, cwd)].join("\n"))}\n`;
39
+ }
40
+ function formatRemovable$1(entry, file) {
41
+ const message = `Fixed baseline entry in ${entry.file}: ${describeEntry(entry)}. ${UPDATE_HINT} it.`;
42
+ return `::notice file=${escapeProperty(file)},title=layerscope baseline::${escapeData(message)}\n`;
43
+ }
44
+ /** Workflow commands that GitHub shows inline on the pull request diff. */
45
+ function formatGithub(result, cwd) {
46
+ const annotations = result.findings.map((finding) => formatAnnotation(finding, cwd));
47
+ const { baseline } = result;
48
+ const removable = baseline === void 0 ? [] : baseline.removable.map((entry) => formatRemovable$1(entry, relative(cwd, baseline.file)));
49
+ return [...annotations, ...removable].join("");
50
+ }
51
+ //#endregion
52
+ //#region src/report/json.ts
53
+ /** Bumped on breaking changes to the JSON report shape. */
54
+ const JSON_REPORT_VERSION = 1;
55
+ function relativeFinding(finding, cwd) {
56
+ return {
57
+ ...finding,
58
+ file: relative(cwd, finding.file),
59
+ target: finding.target === null ? null : relative(cwd, finding.target)
60
+ };
61
+ }
62
+ function formatBaseline(baseline, cwd) {
63
+ return {
64
+ file: relative(cwd, baseline.file),
65
+ suppressed: baseline.suppressed.map((finding) => relativeFinding(finding, cwd)),
66
+ removable: baseline.removable
67
+ };
68
+ }
69
+ function formatJson(result, cwd) {
70
+ const report = {
71
+ version: 1,
72
+ source: result.source,
73
+ notes: result.notes,
74
+ layers: result.layers.map((layer) => ({
75
+ name: layer.name,
76
+ root: relative(cwd, layer.root) || "."
77
+ })),
78
+ summary: {
79
+ files: result.files.length,
80
+ ...summarize(result.findings)
81
+ },
82
+ findings: result.findings.map((finding) => relativeFinding(finding, cwd)),
83
+ ...result.baseline === void 0 ? {} : { baseline: formatBaseline(result.baseline, cwd) }
84
+ };
85
+ return `${JSON.stringify(report, null, 2)}\n`;
86
+ }
87
+ //#endregion
88
+ //#region src/report/text.ts
89
+ const DETAIL_INDENT = " ".repeat(17);
90
+ function formatFinding(finding, cwd) {
91
+ return [` ${`${finding.line}:${finding.column}`.padEnd(8)}${finding.severity.padEnd(7)}${finding.message} ${finding.rule}`, ...findingDetails(finding, cwd).map((detail) => `${DETAIL_INDENT}${detail}`)].join("\n");
92
+ }
93
+ function formatRemovable(baseline, cwd) {
94
+ if (baseline.removable.length === 0) return null;
95
+ const entries = baseline.removable.map((entry) => ` ${entry.file} ${describeEntry(entry)}`);
96
+ const count = baseline.removable.length;
97
+ return [`${relative(cwd, baseline.file)}: ${plural(count, "fixed entry", "fixed entries")}. ${UPDATE_HINT} ${count === 1 ? "it" : "them"}.`, ...entries].join("\n");
98
+ }
99
+ function formatFooter(result) {
100
+ const suppressed = result.baseline?.suppressed.length ?? 0;
101
+ const baselined = suppressed > 0 ? `, ${suppressed} more in the baseline` : "";
102
+ if (result.findings.length === 0) return `✔ No problems in ${plural(result.files.length, "file")} across ${plural(result.layers.length, "layer")}${baselined}`;
103
+ const { errors, warnings } = summarize(result.findings);
104
+ return `✖ ${plural(result.findings.length, "problem")} (${plural(errors, "error")}, ${plural(warnings, "warning")})${baselined}`;
105
+ }
106
+ function formatText(result, cwd) {
107
+ const sections = [...Map.groupBy(result.findings, (finding) => relative(cwd, finding.file))].map(([file, findings]) => [file, ...findings.map((finding) => formatFinding(finding, cwd))].join("\n"));
108
+ const removable = result.baseline === void 0 ? null : formatRemovable(result.baseline, cwd);
109
+ return `${[...removable === null ? sections : [...sections, removable], formatFooter(result)].join("\n\n")}\n`;
110
+ }
111
+ //#endregion
112
+ //#region src/report/index.ts
113
+ const OUTPUT_FORMATS = [
114
+ "text",
115
+ "github",
116
+ "json"
117
+ ];
118
+ const formatters = {
119
+ text: formatText,
120
+ github: formatGithub,
121
+ json: formatJson
122
+ };
123
+ function isOutputFormat(value) {
124
+ return Object.hasOwn(formatters, value);
125
+ }
126
+ /** Paths in the output are relative to `cwd`. */
127
+ function formatResult(result, format, cwd = process.cwd()) {
128
+ return formatters[format](result, cwd);
129
+ }
130
+ //#endregion
131
+ export { JSON_REPORT_VERSION as i, formatResult as n, isOutputFormat as r, OUTPUT_FORMATS as t };