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
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hamed Niroomand
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
# nuxt-layerscope
|
|
2
|
+
|
|
3
|
+
Layer boundary checks for Nuxt 3 and 4 apps that also see auto-imports.
|
|
4
|
+
|
|
5
|
+
Boundary tools usually read `import` statements only, so a component in `admin` that calls an
|
|
6
|
+
auto-imported composable from `web` goes unnoticed. layerscope reads the registry Nuxt resolves
|
|
7
|
+
(components, app and server auto-imports, layers), resolves every auto-imported identifier,
|
|
8
|
+
component tag and explicit import to its source file and layer, and checks the result against
|
|
9
|
+
your rules.
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
layers/admin/app/components/AdminPanel.vue
|
|
13
|
+
2:14 error Auto-import "useCart" crosses from layer "admin" into "web" layer-boundary
|
|
14
|
+
useCart → layers/web/app/composables/useCart.ts
|
|
15
|
+
allowed for "admin": shared, auth
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx nuxi module add nuxt-layerscope --dev # installs it and adds it to `modules`
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// nuxt.config.ts
|
|
26
|
+
export default defineNuxtConfig({
|
|
27
|
+
modules: ['nuxt-layerscope'],
|
|
28
|
+
});
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx nuxi prepare
|
|
33
|
+
npx layerscope check # or: npx layerscope check --prepare
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The module records what Nuxt resolves during `nuxi prepare`, `dev` and `build` into
|
|
37
|
+
`.nuxt/layerscope/registry.json`. Without it, layerscope falls back to the generated `.d.ts`
|
|
38
|
+
files, which gives the same boundary findings but cannot see overridden components (see
|
|
39
|
+
[`shadowed-component`](#rules)). `npx nuxt-layerscope check` works without installing anything.
|
|
40
|
+
Set `layerscope: { enabled: false }` in `nuxt.config` to turn the module off.
|
|
41
|
+
|
|
42
|
+
| Option | Description |
|
|
43
|
+
| ------------------- | ------------------------------------------------------------------------ |
|
|
44
|
+
| `[root]` | Nuxt project root (default: current dir) |
|
|
45
|
+
| `--format <format>` | `text` (default), `github` or `json` |
|
|
46
|
+
| `--config <file>` | Config path (default: `layerscope.config.ts`) |
|
|
47
|
+
| `--prepare` | Run `nuxi prepare` first |
|
|
48
|
+
| `--baseline <file>` | Accepted findings, relative to the root (`layerscope-baseline.json`) |
|
|
49
|
+
| `--update-baseline` | Write every current finding to the baseline file and exit `0` |
|
|
50
|
+
| `--source <source>` | `auto` (default: registry when present), `registry` or `types` (`.d.ts`) |
|
|
51
|
+
| `--verbose` | Print where symbols were read from (to stderr) |
|
|
52
|
+
|
|
53
|
+
Exit codes: `0` clean, `1` rule errors, `2` config or input error. Notes, such as a rule that
|
|
54
|
+
could not run, go to stderr so stdout stays machine-readable.
|
|
55
|
+
|
|
56
|
+
`--format github` prints workflow commands, so findings show up inline on the pull request diff.
|
|
57
|
+
JSON findings carry `target` (the file the symbol resolves to) and, for `layer-boundary`,
|
|
58
|
+
`allowed`.
|
|
59
|
+
|
|
60
|
+
### Baseline
|
|
61
|
+
|
|
62
|
+
Turn the check on in a codebase that already has violations, then fix them over time:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx layerscope check --update-baseline # writes layerscope-baseline.json; commit it
|
|
66
|
+
npx layerscope check # fails only on findings missing from the baseline
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Entries are keyed by rule, file, symbol and target layer, not by line, so unrelated edits and
|
|
70
|
+
moving a violation within its file keep the entry. Entries that no longer occur are listed as
|
|
71
|
+
fixed (and as `::notice` annotations with `--format github`); run `--update-baseline` again to
|
|
72
|
+
drop them, so the file only shrinks.
|
|
73
|
+
|
|
74
|
+
### GitHub Action
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
# .github/workflows/layerscope.yml
|
|
78
|
+
on: pull_request
|
|
79
|
+
jobs:
|
|
80
|
+
layerscope:
|
|
81
|
+
runs-on: ubuntu-latest
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v6
|
|
84
|
+
- uses: actions/setup-node@v6
|
|
85
|
+
with:
|
|
86
|
+
node-version: 24
|
|
87
|
+
- run: npm ci
|
|
88
|
+
- run: npx nuxi prepare
|
|
89
|
+
- uses: hamedniroomand/nuxt-layerscope@main
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
It runs `layerscope check --format github`, so findings show up inline on the pull request. Inputs:
|
|
93
|
+
`root` (default `.`), `config`, `prepare` (`true` runs `nuxi prepare` instead of the separate
|
|
94
|
+
step), `baseline` (default `layerscope-baseline.json`) and `version` (used through `npx` when the
|
|
95
|
+
project does not install `nuxt-layerscope`).
|
|
96
|
+
|
|
97
|
+
### `layerscope why <symbol> [root]`
|
|
98
|
+
|
|
99
|
+
Lists every file that uses a symbol, where the symbol resolves to and the layer path each use
|
|
100
|
+
crosses. It accepts auto-imports, component names (`BaseButton`, `base-button` and
|
|
101
|
+
`LazyBaseButton` are the same component) and import specifiers.
|
|
102
|
+
|
|
103
|
+
```text
|
|
104
|
+
$ npx layerscope why useCart
|
|
105
|
+
useCart → layers/web/app/composables/useCart.ts (web)
|
|
106
|
+
layers/admin/app/components/AdminPanel.vue:2:14 admin → web ✖ not allowed
|
|
107
|
+
layers/web/app/components/CartSummary.vue:3:14 web → web ✔ same layer
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Takes `--format text|json`, `--config`, `--prepare`, `--source` and `--verbose`. Exit codes: `0` when the symbol is used,
|
|
111
|
+
`2` when no use is found.
|
|
112
|
+
|
|
113
|
+
### `layerscope graph [root]`
|
|
114
|
+
|
|
115
|
+
Prints the dependency graph between layers (`--by layer`, the default) or between files
|
|
116
|
+
(`--by file`, grouped by layer). Edges that break the layer rules are drawn in red and labelled
|
|
117
|
+
with how many references they carry. Packages are left out.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npx layerscope graph > layers.mmd # Mermaid, for docs and PR descriptions
|
|
121
|
+
npx layerscope graph --by file --format dot | dot -Tsvg > files.svg
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`--format mermaid` (default), `dot` or `json`. Always exits `0`.
|
|
125
|
+
|
|
126
|
+
### `layerscope unused [root]`
|
|
127
|
+
|
|
128
|
+
Lists components and auto-imports (composables, utils, server utils) that nothing references,
|
|
129
|
+
per layer. Explicit imports of a file count as using everything it exports. When the project
|
|
130
|
+
renders components chosen at runtime (`<component :is>` bound to a value, `resolveComponent(x)`),
|
|
131
|
+
unused components are marked "possibly used at runtime". Layers installed as packages are not
|
|
132
|
+
checked. `--format text` (default) or `json`. Always exits `0`.
|
|
133
|
+
|
|
134
|
+
### Editor feedback: ESLint and oxlint
|
|
135
|
+
|
|
136
|
+
`nuxt-layerscope/eslint` reports the same findings as `check` while you type. It reads the
|
|
137
|
+
registry the module writes, so the module is required, and it never boots Nuxt.
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
// eslint.config.js
|
|
141
|
+
import layerscope from 'nuxt-layerscope/eslint';
|
|
142
|
+
|
|
143
|
+
export default [
|
|
144
|
+
// ...your parsers (vue-eslint-parser for .vue files)
|
|
145
|
+
layerscope.configs.recommended,
|
|
146
|
+
];
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The rules are `layerscope/layer-boundary` and `layerscope/unresolved-reference`. Each takes an
|
|
150
|
+
optional `{ root }` (the Nuxt project, relative to the working directory); by default the project
|
|
151
|
+
is the nearest directory above the file with `.nuxt/layerscope/registry.json`. Set `root` for layers
|
|
152
|
+
that live outside the app (for example a layer package in a monorepo).
|
|
153
|
+
|
|
154
|
+
oxlint loads the same plugin through `jsPlugins`:
|
|
155
|
+
|
|
156
|
+
```json
|
|
157
|
+
{
|
|
158
|
+
"jsPlugins": [{ "name": "layerscope", "specifier": "nuxt-layerscope/eslint" }],
|
|
159
|
+
"rules": { "layerscope/layer-boundary": "error", "layerscope/unresolved-reference": "warn" }
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
oxlint passes only the `<script>` of `.vue` files to plugins, so findings in a template are shown
|
|
164
|
+
at the start of the script with their template line in the message.
|
|
165
|
+
|
|
166
|
+
### Nuxt DevTools
|
|
167
|
+
|
|
168
|
+
While `nuxi dev` runs, the module adds a **Layerscope** tab to Nuxt DevTools with the layers,
|
|
169
|
+
what each may depend on and the current findings. File links open in your editor. The report is
|
|
170
|
+
also served as JSON at `/__layerscope?format=json`. Turn it off with `layerscope: { devtools: false }`.
|
|
171
|
+
|
|
172
|
+
## Config
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// layerscope.config.ts
|
|
176
|
+
import { defineConfig } from 'nuxt-layerscope';
|
|
177
|
+
|
|
178
|
+
export default defineConfig({
|
|
179
|
+
layers: {
|
|
180
|
+
shared: { allow: [] },
|
|
181
|
+
auth: { allow: ['shared'] },
|
|
182
|
+
web: { allow: ['shared', 'auth'] },
|
|
183
|
+
admin: { allow: ['shared', 'auth'] },
|
|
184
|
+
},
|
|
185
|
+
rules: {
|
|
186
|
+
'layer-boundary': 'error',
|
|
187
|
+
'unresolved-reference': 'warn',
|
|
188
|
+
'shadowed-component': 'warn',
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Or put the same options under `layerscope` in `nuxt.config`, next to `enabled` and `devtools`. The
|
|
194
|
+
module records them on `nuxi prepare`, `dev` and `build`; use one place or the other, not both.
|
|
195
|
+
|
|
196
|
+
- Layer names come from Nuxt: the folder name for `layers/<name>` and for `extends` entries, or
|
|
197
|
+
`$meta.name`. The project itself is `root`. Set `path` on an entry to name a layer explicitly,
|
|
198
|
+
or `source` (the `extends` string, such as `github:acme/console`) to name a remote layer.
|
|
199
|
+
- A layer missing from `layers` is unrestricted. Edges within a layer are always allowed.
|
|
200
|
+
- `ignore` adds globs to skip, and `globals` lists identifiers or components registered at runtime
|
|
201
|
+
(for example by a plugin) so they are not reported as unresolved.
|
|
202
|
+
|
|
203
|
+
## Rules
|
|
204
|
+
|
|
205
|
+
- `layer-boundary`: a file depends on a layer its own layer does not `allow`. This covers
|
|
206
|
+
auto-imported composables and utils, components (including `Lazy*`), Nitro server utils and
|
|
207
|
+
explicit imports (`#layers/...`, `~/...` and relative paths).
|
|
208
|
+
- `unresolved-reference`: something could not be resolved, so layerscope will not call it safe.
|
|
209
|
+
Examples are an identifier that is neither a local binding, a known global nor an auto-import;
|
|
210
|
+
a component missing from `components.d.ts`; an import path that does not exist; or
|
|
211
|
+
`<component :is>` bound to a runtime value.
|
|
212
|
+
- `shadowed-component` (warning): two layers register a component with the same name and Nuxt
|
|
213
|
+
uses the higher-priority one. The overridden component is reported with both paths, so
|
|
214
|
+
accidental overrides are visible. Needs the module; with the `.d.ts` fallback the rule reports
|
|
215
|
+
nothing and says so on stderr.
|
|
216
|
+
|
|
217
|
+
## How it works
|
|
218
|
+
|
|
219
|
+
1. Layers come from the project's own `@nuxt/kit`, which every Nuxt install already has:
|
|
220
|
+
`loadNuxtConfig` for the list and names, `getLayerDirectories` for each layer's `srcDir`,
|
|
221
|
+
`serverDir` and `shared` dir. Without kit, layers can be declared with `path` in the config.
|
|
222
|
+
2. Each file belongs to the layer with the longest matching root, compared as real paths so
|
|
223
|
+
pnpm symlinks do not matter. Layers installed from npm or a remote source live under
|
|
224
|
+
`node_modules` and still own their files, so boundaries apply to them; their own code is
|
|
225
|
+
resolved but not checked. Anything outside every layer is external. Files under a layer's
|
|
226
|
+
server dir use the Nitro symbol table, and files under its shared dir use the shared one.
|
|
227
|
+
3. Scripts are parsed with `oxc-parser` and scope-tracked, so a local `useCart` shadows the
|
|
228
|
+
auto-imported one. Templates are compiled with `@vue/compiler-sfc`, and the render function's
|
|
229
|
+
`resolveComponent("X")` calls and `_ctx.x` reads show which components and identifiers Nuxt
|
|
230
|
+
would auto-import.
|
|
231
|
+
4. With the module, symbols come from `.nuxt/layerscope/registry.json`, recorded from Nuxt's own
|
|
232
|
+
hooks: `components:dirs` and `components:extend` (every scanned component, including
|
|
233
|
+
overridden ones), `imports:context` (app auto-imports), Nitro's unimport context (server
|
|
234
|
+
auto-imports) and `getLayerDirectories()` (layers in priority order). The file has a versioned
|
|
235
|
+
schema; a registry from an incompatible release is ignored with a note.
|
|
236
|
+
5. Without the module, components come from `.nuxt/components.d.ts` (or
|
|
237
|
+
`.nuxt/types/components.d.ts`) and auto-imports from `.nuxt/types/*imports.d.ts`. On Nuxt 3,
|
|
238
|
+
which writes no `shared-imports.d.ts`, the shared context gets the imports app and server
|
|
239
|
+
have in common, as Nuxt 4 does.
|
|
240
|
+
6. Generated files that point at deleted files, or that miss a component, are treated as stale
|
|
241
|
+
(exit `2`). With the registry every dir Nuxt scanned is compared, including custom component
|
|
242
|
+
dirs; without it only the default `components/` dirs are.
|
|
243
|
+
|
|
244
|
+
The JSON report (`--format json`) is versioned and deterministic: the same input produces the same
|
|
245
|
+
bytes.
|
|
246
|
+
|
|
247
|
+
## Package entry points
|
|
248
|
+
|
|
249
|
+
| Import | Contents |
|
|
250
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------- |
|
|
251
|
+
| `nuxt-layerscope` | The Nuxt module (default export), `defineConfig` and the types. Light enough for Nuxt startup. |
|
|
252
|
+
| `nuxt-layerscope/api` | `analyze`, `formatResult`, `createBaseline` and the report and registry constants. |
|
|
253
|
+
| `nuxt-layerscope/eslint` | ESLint and oxlint plugin with `layer-boundary` and `unresolved-reference` rules. |
|
|
254
|
+
| `layerscope` (bin) | The CLI. |
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
import { analyze, formatResult } from 'nuxt-layerscope/api';
|
|
258
|
+
|
|
259
|
+
const result = await analyze({ rootDir: 'apps/shop' });
|
|
260
|
+
process.stdout.write(formatResult(result, 'text'));
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
## Compared with other tools
|
|
264
|
+
|
|
265
|
+
Other tools cover parts of this. layerscope is narrower: it resolves symbols exactly the way Nuxt
|
|
266
|
+
does, understands Nuxt layer semantics, and is built to fail a CI job.
|
|
267
|
+
|
|
268
|
+
| | layerscope | [eslint-plugin-nuxt-layers] | [nuxt-fsd] | [Archora] | [Nuxt DevTools] |
|
|
269
|
+
| --------------------------------------------- | -------------------------------------- | --------------------------- | --------------- | ----------------------------- | ------------------- |
|
|
270
|
+
| Boundary rules on explicit imports | ✓ | ✓ | ✓ (FSD layers) | ✓ | – |
|
|
271
|
+
| Boundary rules on auto-imports and components | ✓ | – | – | ✓ | – |
|
|
272
|
+
| Resolves symbols from | Nuxt's generated types and `@nuxt/kit` | import paths | its own aliases | its own analyzer | the running app |
|
|
273
|
+
| Knows Nuxt layers (`extends`, `layers/`, npm) | ✓ | folder layout | – | not Nuxt-specific | shows them |
|
|
274
|
+
| CI gate | exit codes, PR annotations | via ESLint | – | exit codes, thresholds | – |
|
|
275
|
+
| Scope | Nuxt layer boundaries | import boundaries | FSD structure | general architecture analyzer | dev-time inspection |
|
|
276
|
+
|
|
277
|
+
- **eslint-plugin-nuxt-layers** checks `import` statements against a layer map. It works inside
|
|
278
|
+
ESLint and the editor, but auto-imports have no import statement to check.
|
|
279
|
+
- **nuxt-fsd** sets up Feature-Sliced Design in Nuxt and blocks cross-imports, but its own README
|
|
280
|
+
notes that auto-imports do not respect those rules.
|
|
281
|
+
- **Archora** (`@archora/cli`) is a general frontend architecture analyzer that also resolves Nuxt
|
|
282
|
+
auto-imported composables, alongside cycles, churn and bundle analysis. Pick it for a broad
|
|
283
|
+
architecture report; pick layerscope for exact Nuxt layer boundaries as a CI gate.
|
|
284
|
+
- **Nuxt DevTools** shows components and auto-imports of a running app, but does not enforce rules.
|
|
285
|
+
|
|
286
|
+
[eslint-plugin-nuxt-layers]: https://github.com/alexanderop/eslint-plugin-nuxt-layers
|
|
287
|
+
[nuxt-fsd]: https://github.com/aabounegm/nuxt-fsd
|
|
288
|
+
[Archora]: https://github.com/archora-dev/archora
|
|
289
|
+
[Nuxt DevTools]: https://devtools.nuxt.com
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { c as realPath, d as effectiveConfig, g as LayerscopeError, h as ruleSeverity, m as isRuleName, s as readJson } from "./layer-factory-xZmIAPrA.mjs";
|
|
2
|
+
import { r as pascalCase, t as compareStrings } from "./strings-CrOjNZmB.mjs";
|
|
3
|
+
import { t as globComponents } from "./component-files-DgOXNE6l.mjs";
|
|
4
|
+
import { c as createOwnerLookup, d as createAnalysisEnv, f as readRegistry, h as boundaryFindings, m as loadSymbolTable, n as analyzeFile, p as tableFromRegistry, r as collectFiles, u as resolveLayers, v as loadConfig } from "./file-analysis-Bkq_69x4.mjs";
|
|
5
|
+
import { basename, extname, join, relative, resolve } from "pathe";
|
|
6
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { escapePath, glob } from "tinyglobby";
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
//#region src/baseline/index.ts
|
|
10
|
+
const BASELINE_FILE = "layerscope-baseline.json";
|
|
11
|
+
/** Bumped on breaking changes to the baseline file. */
|
|
12
|
+
const BASELINE_VERSION = 1;
|
|
13
|
+
/** Rule, file, symbol and target layer; lines are left out so unrelated edits keep the entry. */
|
|
14
|
+
function keyOf(entry) {
|
|
15
|
+
return [
|
|
16
|
+
entry.rule,
|
|
17
|
+
entry.file,
|
|
18
|
+
entry.symbol,
|
|
19
|
+
entry.toLayer ?? ""
|
|
20
|
+
].join("\0");
|
|
21
|
+
}
|
|
22
|
+
function toKeyed(finding, rootDir) {
|
|
23
|
+
return {
|
|
24
|
+
rule: finding.rule,
|
|
25
|
+
file: relative(rootDir, finding.file),
|
|
26
|
+
symbol: finding.symbol,
|
|
27
|
+
toLayer: finding.toLayer
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function compareEntries(a, b) {
|
|
31
|
+
return compareStrings(a.file, b.file) || compareStrings(a.rule, b.rule) || compareStrings(a.symbol, b.symbol) || compareStrings(a.toLayer ?? "", b.toLayer ?? "");
|
|
32
|
+
}
|
|
33
|
+
function createBaseline(findings, rootDir) {
|
|
34
|
+
const entries = /* @__PURE__ */ new Map();
|
|
35
|
+
for (const finding of findings) {
|
|
36
|
+
const keyed = toKeyed(finding, rootDir);
|
|
37
|
+
const key = keyOf(keyed);
|
|
38
|
+
const entry = entries.get(key);
|
|
39
|
+
if (entry === void 0) entries.set(key, keyed);
|
|
40
|
+
else entry.count = (entry.count ?? 1) + 1;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
version: 1,
|
|
44
|
+
entries: [...entries.values()].toSorted(compareEntries)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function writeBaseline(file, baseline) {
|
|
48
|
+
writeFileSync(file, `${JSON.stringify(baseline, null, 2)}\n`);
|
|
49
|
+
}
|
|
50
|
+
function isEntry(value) {
|
|
51
|
+
const entry = value;
|
|
52
|
+
return typeof entry === "object" && entry !== null && typeof entry.rule === "string" && isRuleName(entry.rule) && typeof entry.file === "string" && typeof entry.symbol === "string" && (entry.toLayer === null || typeof entry.toLayer === "string");
|
|
53
|
+
}
|
|
54
|
+
function readBaseline(file) {
|
|
55
|
+
if (!existsSync(file)) return null;
|
|
56
|
+
const baseline = readJson(file);
|
|
57
|
+
if (baseline?.version !== 1 || !Array.isArray(baseline.entries)) throw new LayerscopeError(`${file} is not a layerscope baseline (version 1). Recreate it with --update-baseline.`);
|
|
58
|
+
const invalid = baseline.entries.findIndex((entry) => !isEntry(entry));
|
|
59
|
+
if (invalid !== -1) throw new LayerscopeError(`${file}: entry ${invalid} is not a valid baseline entry`);
|
|
60
|
+
return {
|
|
61
|
+
version: 1,
|
|
62
|
+
entries: baseline.entries
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Findings missing from the baseline stay; the rest are suppressed, up to each entry's count. */
|
|
66
|
+
function applyBaseline(findings, baseline, file, rootDir) {
|
|
67
|
+
const remaining = new Map(baseline.entries.map((entry) => [keyOf(entry), {
|
|
68
|
+
entry,
|
|
69
|
+
left: entry.count ?? 1
|
|
70
|
+
}]));
|
|
71
|
+
const fresh = [];
|
|
72
|
+
const suppressed = [];
|
|
73
|
+
for (const finding of findings) {
|
|
74
|
+
const slot = remaining.get(keyOf(toKeyed(finding, rootDir)));
|
|
75
|
+
if (slot === void 0 || slot.left === 0) fresh.push(finding);
|
|
76
|
+
else {
|
|
77
|
+
slot.left -= 1;
|
|
78
|
+
suppressed.push(finding);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
findings: fresh,
|
|
83
|
+
baseline: {
|
|
84
|
+
file,
|
|
85
|
+
suppressed,
|
|
86
|
+
removable: [...remaining.values()].flatMap(({ entry, left }) => left === 0 ? [] : [left === (entry.count ?? 1) ? entry : {
|
|
87
|
+
...entry,
|
|
88
|
+
count: left
|
|
89
|
+
}])
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** The result with the baseline applied, or unchanged when the file does not exist. */
|
|
94
|
+
function applyBaselineFile(result, file) {
|
|
95
|
+
const baseline = readBaseline(file);
|
|
96
|
+
return baseline === null ? result : {
|
|
97
|
+
...result,
|
|
98
|
+
...applyBaseline(result.findings, baseline, file, result.rootDir)
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/rules/compare.ts
|
|
103
|
+
/** Total order on position, then rule or kind, then symbol, for deterministic reports. */
|
|
104
|
+
function compareByPosition(a, b) {
|
|
105
|
+
return compareStrings(a.file, b.file) || a.line - b.line || a.column - b.column || compareStrings(a.rule ?? a.kind ?? "", b.rule ?? b.kind ?? "") || compareStrings(a.symbol, b.symbol);
|
|
106
|
+
}
|
|
107
|
+
//#endregion
|
|
108
|
+
//#region src/rules/shadowed-component.ts
|
|
109
|
+
const SHADOWED_NEEDS_REGISTRY = "shadowed-component needs the full component registry: add \"nuxt-layerscope\" to \"modules\" in nuxt.config. The generated .d.ts files list only the winning component, so the rule reported nothing.";
|
|
110
|
+
/** Components a higher-priority layer replaces, so accidental overrides are visible. */
|
|
111
|
+
function shadowedFindings(registry, ownerOf, config) {
|
|
112
|
+
const severity = ruleSeverity(config, "shadowed-component");
|
|
113
|
+
if (severity === "off") return [];
|
|
114
|
+
return registry.shadowedComponents.flatMap((component) => {
|
|
115
|
+
const file = realPath(component.file);
|
|
116
|
+
const target = realPath(component.shadowedBy);
|
|
117
|
+
const from = ownerOf(file);
|
|
118
|
+
if (from === null) return [];
|
|
119
|
+
const to = ownerOf(target);
|
|
120
|
+
const winner = to === null ? target : `layer "${to.name}"`;
|
|
121
|
+
return [{
|
|
122
|
+
rule: "shadowed-component",
|
|
123
|
+
severity,
|
|
124
|
+
file,
|
|
125
|
+
line: 1,
|
|
126
|
+
column: 1,
|
|
127
|
+
symbol: component.name,
|
|
128
|
+
fromLayer: from.name,
|
|
129
|
+
toLayer: to?.name ?? null,
|
|
130
|
+
target,
|
|
131
|
+
message: `Component <${component.name}> of layer "${from.name}" is overridden by ${winner}`
|
|
132
|
+
}];
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/rules/index.ts
|
|
137
|
+
/** Every rule's findings in report order, and notes on rules that could not run. */
|
|
138
|
+
function runRules(input) {
|
|
139
|
+
const { registry, config } = input;
|
|
140
|
+
const notes = [];
|
|
141
|
+
if (registry === null && ruleSeverity(config, "shadowed-component") !== "off") notes.push(SHADOWED_NEEDS_REGISTRY);
|
|
142
|
+
return {
|
|
143
|
+
findings: [
|
|
144
|
+
...input.unresolved,
|
|
145
|
+
...boundaryFindings(input.edges, config),
|
|
146
|
+
...registry === null ? [] : shadowedFindings(registry, input.ownerOf, config)
|
|
147
|
+
].toSorted(compareByPosition),
|
|
148
|
+
notes
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/analyze/freshness.ts
|
|
153
|
+
const RERUN_HINT = "Run \"nuxi prepare\" again, or pass --prepare.";
|
|
154
|
+
const COMPONENT_SUFFIX = /(?:\.(?:client|server))?(?:\.global|\.island)*$/u;
|
|
155
|
+
function allTargets(table) {
|
|
156
|
+
return [...table.components.values(), ...Object.values(table.imports).flatMap((map) => Array.from(map.values()))];
|
|
157
|
+
}
|
|
158
|
+
function assertNoDeletedTargets(targets) {
|
|
159
|
+
const deleted = targets.find((target) => target.file !== null && !existsSync(target.file));
|
|
160
|
+
if (deleted !== void 0 && deleted.file !== null) throw new LayerscopeError(`Generated types reference ${deleted.file}, which no longer exists. ${RERUN_HINT}`);
|
|
161
|
+
}
|
|
162
|
+
async function defaultComponentFiles(layers) {
|
|
163
|
+
const dirs = layers.filter((layer) => layer.defaultComponents).map((layer) => join(layer.srcDir, "components")).filter((dir) => existsSync(dir));
|
|
164
|
+
if (dirs.length === 0) return [];
|
|
165
|
+
const patterns = dirs.map((dir) => join(escapePath(dir), "**/*.vue"));
|
|
166
|
+
const ignore = dirs.flatMap((dir) => [join(escapePath(dir), "islands/**"), join(escapePath(dir), "**/*.island.vue")]);
|
|
167
|
+
return (await glob(patterns, {
|
|
168
|
+
absolute: true,
|
|
169
|
+
ignore
|
|
170
|
+
})).toSorted();
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The `.d.ts` files list only the winner of a layer override, so a file whose name matches a
|
|
174
|
+
* registered component is taken as overridden rather than new.
|
|
175
|
+
*/
|
|
176
|
+
function isRegisteredName(file, names) {
|
|
177
|
+
const name = pascalCase(basename(file, extname(file)).replace(COMPONENT_SUFFIX, ""));
|
|
178
|
+
return names.some((registered) => registered.endsWith(name));
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Stale generated types either point at deleted files or miss new components. File mtimes are
|
|
182
|
+
* not used: Nuxt skips rewriting unchanged templates, so they would report false staleness.
|
|
183
|
+
* Without the registry only the default `components/` dirs are known.
|
|
184
|
+
*/
|
|
185
|
+
async function assertFresh(table, layers) {
|
|
186
|
+
const targets = allTargets(table);
|
|
187
|
+
assertNoDeletedTargets(targets);
|
|
188
|
+
const known = new Set(targets.map((target) => target.file));
|
|
189
|
+
const names = [...table.components.keys()];
|
|
190
|
+
const unknown = (await defaultComponentFiles(layers)).find((file) => !known.has(file) && !isRegisteredName(file, names));
|
|
191
|
+
if (unknown !== void 0) throw new LayerscopeError(`${unknown} is missing from components.d.ts. ${RERUN_HINT}`);
|
|
192
|
+
}
|
|
193
|
+
/** With the registry, every dir Nuxt scanned (`components:dirs`) is compared, not just the default. */
|
|
194
|
+
async function assertRegistryFresh(table, registry) {
|
|
195
|
+
assertNoDeletedTargets(allTargets(table));
|
|
196
|
+
for (const dir of registry.componentDirs) {
|
|
197
|
+
if (dir.path.includes("/node_modules/") || !existsSync(dir.path)) continue;
|
|
198
|
+
const recorded = new Set(dir.files);
|
|
199
|
+
const added = (await globComponents(dir.path, dir.pattern, dir.ignore)).find((file) => !recorded.has(file));
|
|
200
|
+
if (added !== void 0) throw new LayerscopeError(`${added} is missing from the layerscope registry. ${RERUN_HINT}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/analyze/layers.ts
|
|
205
|
+
/**
|
|
206
|
+
* A layer's `srcDir` leaks into a project that does not set its own, so Nuxt then looks for the
|
|
207
|
+
* project's app in a dir that does not exist while `app/` is ignored.
|
|
208
|
+
*/
|
|
209
|
+
function layerNotes(layers, rootDir) {
|
|
210
|
+
return layers.flatMap((layer) => {
|
|
211
|
+
const appDir = join(layer.root, "app");
|
|
212
|
+
if (existsSync(layer.srcDir) || !existsSync(appDir)) return [];
|
|
213
|
+
const srcDir = relative(rootDir, layer.srcDir) || ".";
|
|
214
|
+
return [`Layer "${layer.name}" has srcDir ${srcDir}, which does not exist, so Nuxt ignores ${relative(rootDir, appDir)}. An extended layer's srcDir may have been merged in; set srcDir in its nuxt.config.`];
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
/** Layers in priority order, with the lookup of which layer owns a file. */
|
|
218
|
+
async function loadLayers(rootDir, config, registryLayers) {
|
|
219
|
+
const layers = await resolveLayers(rootDir, config, registryLayers);
|
|
220
|
+
return {
|
|
221
|
+
layers,
|
|
222
|
+
ownerOf: createOwnerLookup(layers),
|
|
223
|
+
notes: layerNotes(layers, rootDir)
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
//#endregion
|
|
227
|
+
//#region src/analyze/symbols.ts
|
|
228
|
+
/** The module's registry when present (or required), else the generated `.d.ts` files. */
|
|
229
|
+
function loadSymbols(buildDir, source) {
|
|
230
|
+
const read = source === "types" ? null : readRegistry(buildDir);
|
|
231
|
+
if (read?.kind === "ok") {
|
|
232
|
+
const { file, registry } = read;
|
|
233
|
+
return {
|
|
234
|
+
source: "registry",
|
|
235
|
+
sourceFile: file,
|
|
236
|
+
registry,
|
|
237
|
+
table: tableFromRegistry(registry, buildDir),
|
|
238
|
+
notes: []
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
const notes = [];
|
|
242
|
+
if (read?.kind === "incompatible") {
|
|
243
|
+
const found = JSON.stringify(read.version);
|
|
244
|
+
const message = `${read.file} has schema version ${found}, which this layerscope does not read.`;
|
|
245
|
+
if (source === "registry") throw new LayerscopeError(`${message} Update nuxt-layerscope in the project.`);
|
|
246
|
+
notes.push(`${message} Falling back to the generated .d.ts files.`);
|
|
247
|
+
} else if (read !== null && source === "registry") throw new LayerscopeError(`${read.file} not found. Add "nuxt-layerscope" to "modules" in nuxt.config and run "nuxi prepare".`);
|
|
248
|
+
const table = loadSymbolTable(buildDir);
|
|
249
|
+
return {
|
|
250
|
+
source: "types",
|
|
251
|
+
sourceFile: join(buildDir, "types"),
|
|
252
|
+
registry: null,
|
|
253
|
+
table,
|
|
254
|
+
notes
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region src/analyze/environment.ts
|
|
259
|
+
/** Read even when symbols come from the `.d.ts` files, so `nuxt.config` options still apply. */
|
|
260
|
+
function recordedConfig(buildDir, registry) {
|
|
261
|
+
if (registry !== null) return registry.config;
|
|
262
|
+
const read = readRegistry(buildDir);
|
|
263
|
+
return read.kind === "ok" ? read.registry.config : void 0;
|
|
264
|
+
}
|
|
265
|
+
/** Everything the analysis reads from Nuxt: layers, the symbol registry and aliases. */
|
|
266
|
+
async function loadEnvironment(options) {
|
|
267
|
+
const { rootDir, buildDir } = options;
|
|
268
|
+
const { registry, table, ...symbols } = loadSymbols(buildDir, options.source);
|
|
269
|
+
const config = effectiveConfig(options.config, recordedConfig(buildDir, registry));
|
|
270
|
+
const { layers, ownerOf, notes } = await loadLayers(rootDir, config, registry?.layers);
|
|
271
|
+
await (registry === null ? assertFresh(table, layers) : assertRegistryFresh(table, registry));
|
|
272
|
+
return {
|
|
273
|
+
...symbols,
|
|
274
|
+
config,
|
|
275
|
+
notes: [...symbols.notes, ...notes],
|
|
276
|
+
registry,
|
|
277
|
+
layers,
|
|
278
|
+
env: createAnalysisEnv(table, ownerOf, buildDir, config)
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
//#region src/analyze/prepare.ts
|
|
283
|
+
function prepareNuxt(rootDir) {
|
|
284
|
+
if (spawnSync("npx", [
|
|
285
|
+
"--no-install",
|
|
286
|
+
"nuxi",
|
|
287
|
+
"prepare"
|
|
288
|
+
], {
|
|
289
|
+
cwd: rootDir,
|
|
290
|
+
stdio: [
|
|
291
|
+
"ignore",
|
|
292
|
+
2,
|
|
293
|
+
2
|
|
294
|
+
]
|
|
295
|
+
}).status !== 0) throw new LayerscopeError(`"nuxi prepare" failed in ${rootDir}`);
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/analyze/index.ts
|
|
299
|
+
async function analyze(options = {}) {
|
|
300
|
+
const rootDir = resolve(options.rootDir ?? process.cwd());
|
|
301
|
+
const fileConfig = options.config ?? await loadConfig(rootDir, options.configFile);
|
|
302
|
+
const buildDir = resolve(rootDir, fileConfig.buildDir ?? ".nuxt");
|
|
303
|
+
if (options.prepare === true) prepareNuxt(rootDir);
|
|
304
|
+
const environment = await loadEnvironment({
|
|
305
|
+
rootDir,
|
|
306
|
+
buildDir,
|
|
307
|
+
config: fileConfig,
|
|
308
|
+
source: options.source ?? "auto"
|
|
309
|
+
});
|
|
310
|
+
const { layers, env, registry, config } = environment;
|
|
311
|
+
const files = await collectFiles(layers, env.ownerOf, config.ignore ?? []);
|
|
312
|
+
const analyses = [...files].map(([file, layer]) => analyzeFile(file, layer, env));
|
|
313
|
+
const edges = analyses.flatMap((analysis) => analysis.edges).toSorted(compareByPosition);
|
|
314
|
+
const { findings, notes } = runRules({
|
|
315
|
+
edges,
|
|
316
|
+
unresolved: analyses.flatMap((analysis) => analysis.unresolved),
|
|
317
|
+
registry,
|
|
318
|
+
ownerOf: env.ownerOf,
|
|
319
|
+
config
|
|
320
|
+
});
|
|
321
|
+
const result = {
|
|
322
|
+
rootDir,
|
|
323
|
+
config,
|
|
324
|
+
source: environment.source,
|
|
325
|
+
sourceFile: environment.sourceFile,
|
|
326
|
+
layers,
|
|
327
|
+
files: [...files.keys()],
|
|
328
|
+
edges,
|
|
329
|
+
findings,
|
|
330
|
+
notes: [...environment.notes, ...notes],
|
|
331
|
+
symbols: env.table,
|
|
332
|
+
dynamicComponentFiles: analyses.filter((analysis) => analysis.hasDynamicComponent).map((analysis) => analysis.file)
|
|
333
|
+
};
|
|
334
|
+
return options.baseline === void 0 ? result : applyBaselineFile(result, resolve(rootDir, options.baseline));
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
export { writeBaseline as a, createBaseline as i, BASELINE_FILE as n, BASELINE_VERSION as r, analyze as t };
|