@amulet-laboratories/astrolabe 0.4.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 +145 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/bin/astrolabe.mjs +136 -0
- package/package.json +78 -0
- package/src/adapters/nuxt/index.mjs +541 -0
- package/src/adapters/nuxt/project.mjs +174 -0
- package/src/adapters/registry.mjs +23 -0
- package/src/adapters/shared.mjs +191 -0
- package/src/adapters/vue/index.mjs +211 -0
- package/src/adapters/vue/project.mjs +116 -0
- package/src/core/client/app.js +662 -0
- package/src/core/client/overview.js +255 -0
- package/src/core/client/styles.css +669 -0
- package/src/core/diff.mjs +0 -0
- package/src/core/findings.mjs +147 -0
- package/src/core/graph.mjs +148 -0
- package/src/core/layout.mjs +141 -0
- package/src/core/render.mjs +92 -0
- package/src/core/schema.mjs +67 -0
- package/src/core/sfc.mjs +134 -0
- package/src/core/svg.mjs +102 -0
- package/src/core/syntax.mjs +59 -0
- package/src/core/views.mjs +304 -0
- package/src/index.d.ts +155 -0
- package/src/index.mjs +29 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are recorded here. This project follows [semantic versioning](https://semver.org).
|
|
4
|
+
The **graph schema** (`schemaVersion` in the serialized output) and the **adapter interface** are the
|
|
5
|
+
two stability contracts; breaking either is a major version bump once the project reaches 1.0.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.4.0]
|
|
10
|
+
|
|
11
|
+
Renamed to **Astrolabe** (`@amulet-laboratories/astrolabe`), and the first release built to be
|
|
12
|
+
published: it now reads Vue as well as Nuxt, and a round of real-repo testing sharpened its findings.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- **Renamed to Astrolabe.** Package `@amulet-laboratories/astrolabe`, CLI command `astrolabe`.
|
|
17
|
+
- **Publish-ready packaging:** `LICENSE`, `publishConfig.access: public`, `repository`/`homepage`/
|
|
18
|
+
`bugs`, subpath types for the adapters, and `NodeType`/`ViewName` corrected (they were missing
|
|
19
|
+
`store`, `sitemap`, `cleanup`).
|
|
20
|
+
- **Release engineering:** a Node 22/24 test matrix and an `npm pack` tarball gate in CI, a
|
|
21
|
+
tag-triggered `npm publish --provenance` workflow, and a README hero + badges. A `v0.4.0` tag is
|
|
22
|
+
all that stands between here and npm.
|
|
23
|
+
- **Pre-publish lean-up (−231 LOC).** Framework-neutral plumbing (walk, parallel reads, churn,
|
|
24
|
+
package stack, route names, tag linking) is hoisted into one `adapters/shared.mjs` both adapters
|
|
25
|
+
build on; the Vue adapter no longer imports from the Nuxt one, so the dependency direction is
|
|
26
|
+
clean (`adapters → shared → core`). Dead fields and an unused export subpath dropped, `sfc.mjs`
|
|
27
|
+
moved to `core/`. Verified byte-identical against the Nuxt and Vue baselines.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **Plugin and middleware bodies are analyzed.** A composable used only from an auth middleware or
|
|
32
|
+
an analytics plugin no longer reads as "nothing calls it" — their bodies now draw the same
|
|
33
|
+
`uses`/`fetches`/`reads` edges as pages and components.
|
|
34
|
+
- **The graph diff surfaces a single new finding within an existing category.** Findings are diffed
|
|
35
|
+
at the (finding, node) grain, so "1 component nothing renders: NewThing" shows even when the
|
|
36
|
+
orphan-components category already existed — the whole point of the living-docs diff.
|
|
37
|
+
|
|
38
|
+
### Fixed (from running across real repos — elk, nuxt.com, it-tools …)
|
|
39
|
+
|
|
40
|
+
- **Fallback component naming** matches Nuxt's prefix rule: `account/AccountAvatar` →
|
|
41
|
+
`AccountAvatar`, not `AccountAccountAvatar`. This alone cut elk-zone/elk's false orphans from 189
|
|
42
|
+
to 38 without `.nuxt`.
|
|
43
|
+
- **Test/spec files are never analyzed** (Nuxt side; Vue already did), removing phantom composables.
|
|
44
|
+
- **Unresolvable content sources aren't flagged empty** — a `repository:` (git-fetched) or a
|
|
45
|
+
variable/array source can't be counted locally, so it's no longer a false "empty collection"
|
|
46
|
+
(nuxt.com: 5 → 1).
|
|
47
|
+
- **Vue manifest paths resolve correctly regardless of where the manifest sits** (found on
|
|
48
|
+
antfu/vitesse). A `./…` specifier in `components.d.ts`/`auto-imports.d.ts` is relative to the
|
|
49
|
+
manifest's own directory, not the project root — so a manifest committed under `src/` (vs. at the
|
|
50
|
+
root, like it-tools) no longer looks every file up one directory too high. Extensionless TS
|
|
51
|
+
specifiers (`./stores/user`) also resolve now by trying the source extensions. vitesse: 5 read
|
|
52
|
+
failures → 0, with render/uses edges wired. Fixture: `test/fixtures/vue-src-manifest`.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **The report is executed in CI.** A jsdom smoke test boots the interactive report, switches every
|
|
57
|
+
view, and opens the command palette — catching runtime errors the bundle's syntax check can't.
|
|
58
|
+
- **Plain Vue (Vite) support — a second adapter.** app-graph now reads non-Nuxt Vue apps too:
|
|
59
|
+
file-based routes (`src/pages/`), components and composables resolved from the committed unplugin
|
|
60
|
+
manifests (`components.d.ts` / `auto-imports.d.ts` — accurate on a bare clone, no build needed),
|
|
61
|
+
and **Pinia stores** as a new node kind. The framework-neutral core is untouched; the Nuxt adapter
|
|
62
|
+
is unchanged and still wins detection for Nuxt apps. Strong on convention/unplugin Vue; hand-written
|
|
63
|
+
and dynamic routers are a known next step. Test/spec files and non-`use*` utility modules are
|
|
64
|
+
excluded (the false-positive classes the Nuxt corpus surfaced).
|
|
65
|
+
- **Two new views.** **Sitemap** lays the graph out from the routes, forward — everything a page
|
|
66
|
+
reaches, with dead code dropping away. **Cleanup** gathers only what the findings flag as warnings
|
|
67
|
+
(dead components, uncalled composables, empty collections, render cycles) in one place. Both are
|
|
68
|
+
pure projections, available in the report and via `--svg --view sitemap|cleanup`.
|
|
69
|
+
- **Command palette (⌘K / Ctrl-K).** Fuzzy jump-to-node over the whole graph, with a visible `⌘K`
|
|
70
|
+
affordance in the toolbar. Selecting a result moves the camera, panel, and URL like any other pick.
|
|
71
|
+
- **Heat overlay.** When the app is a git repo, a **Heat** toggle shades every file-backed node by
|
|
72
|
+
how often it changes (commit count), so hotspots — where the risk and the reading are — surface at
|
|
73
|
+
a glance. Churn also shows per node in the detail panel.
|
|
74
|
+
|
|
75
|
+
### Changed
|
|
76
|
+
|
|
77
|
+
- **Edges read as structure, not noise.** In the interactive report, each edge is now tinted by its
|
|
78
|
+
destination's kind at low opacity, so a dense graph shows the flow (data vs render vs uses) instead
|
|
79
|
+
of a grey hairball.
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
|
|
83
|
+
- **PascalCase MDC components are traced.** `::QuickAnswer` and `:Icon{}` (PascalCase block/inline
|
|
84
|
+
MDC) were missed — only kebab-case matched — so a component referenced that way from content was
|
|
85
|
+
wrongly flagged as an orphan. Found by running the analyzer across a real fleet.
|
|
86
|
+
- **`_`-prefixed content partials are ignored**, matching `@nuxt/content` — they no longer inflate a
|
|
87
|
+
collection's file count or draw render edges from content that never publishes.
|
|
88
|
+
|
|
89
|
+
### Added
|
|
90
|
+
|
|
91
|
+
- **Nuxt layers.** Local `extends` layers are now followed — a base layer's pages, components,
|
|
92
|
+
composables, layouts, middleware, server routes and content merge into the graph, with the app
|
|
93
|
+
winning on name collisions. Remote layers are out of scope. (`test/fixtures/layered`.)
|
|
94
|
+
- **Never-crash.** A malformed SFC, config or composable becomes a warning instead of aborting the
|
|
95
|
+
scan. (`test/fixtures/malformed`.)
|
|
96
|
+
|
|
97
|
+
### Changed
|
|
98
|
+
|
|
99
|
+
- **Performance.** Per-directory file reads now fan out in parallel while preserving byte-identical
|
|
100
|
+
output — a large app scans noticeably faster.
|
|
101
|
+
|
|
102
|
+
### Added (earlier in this cycle)
|
|
103
|
+
|
|
104
|
+
- **Living-docs outputs.** `--svg` renders a static SVG of any view server-side (no browser),
|
|
105
|
+
reusing the interactive report's exact layout engine. `app-graph diff <base> <head>` reports what
|
|
106
|
+
changed architecturally between two apps or two graph JSONs, as Markdown (for a PR comment) or
|
|
107
|
+
`--json`. `renderSvg`, `diffGraphs`, `isEmptyDiff` and `formatDiffMarkdown` are exported from the
|
|
108
|
+
package.
|
|
109
|
+
- A ready-made PR workflow in `examples/architecture-diff.yml` that posts the diff as a sticky
|
|
110
|
+
comment and uploads the report + SVG.
|
|
111
|
+
|
|
112
|
+
### Changed
|
|
113
|
+
|
|
114
|
+
- The client's pure layout and view-projection logic moved to `src/core/layout.mjs` and
|
|
115
|
+
`src/core/views.mjs` as ESM modules, shared by the browser report and the Node SVG renderer. The
|
|
116
|
+
interactive report is unchanged in behavior.
|
|
117
|
+
|
|
118
|
+
## [0.2.0]
|
|
119
|
+
|
|
120
|
+
Framework-agnostic core with per-framework adapters. No change to what the Nuxt report contains —
|
|
121
|
+
the HTML output is byte-identical to 0.1.0 — but the internals are now a reusable engine.
|
|
122
|
+
|
|
123
|
+
### Added
|
|
124
|
+
|
|
125
|
+
- `src/core/` — a framework-neutral engine: the `Graph` builder, the `finalize` pipeline
|
|
126
|
+
(prune → group → findings → stats), the HTML renderer + client, and `schema.mjs` defining the
|
|
127
|
+
canonical node/edge vocabulary.
|
|
128
|
+
- `src/adapters/` — the adapter layer. `adapters/nuxt/` holds all Nuxt specifics; `registry.mjs`
|
|
129
|
+
detects which adapter an app needs.
|
|
130
|
+
- Published TypeScript types (`src/index.d.ts`) for the full public surface, and an `exports` map
|
|
131
|
+
exposing `.`, `./schema`, and `./adapters/nuxt`.
|
|
132
|
+
- `schemaVersion` on the serialized graph, and a `validateGraph()` conformance checker.
|
|
133
|
+
- `--schema` CLI flag that prints the canonical graph schema.
|
|
134
|
+
- `test/conformance.test.mjs` — the framework-agnostic contract every adapter must pass.
|
|
135
|
+
|
|
136
|
+
### Changed
|
|
137
|
+
|
|
138
|
+
- Public entry is now `@amulet-laboratories/app-graph` (`src/index.mjs`); `scan` and `renderHtml`
|
|
139
|
+
are re-exported from there. Deep imports of `src/scan.mjs` / `src/findings.mjs` have moved under
|
|
140
|
+
`src/adapters/nuxt/` and `src/core/` respectively.
|
|
141
|
+
|
|
142
|
+
## [0.1.0]
|
|
143
|
+
|
|
144
|
+
- Initial Nuxt app-graph analyzer: routes, components, composables, content collections, server
|
|
145
|
+
routes and external services as one interactive, self-contained HTML diagram, with findings.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amulet Laboratories
|
|
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,178 @@
|
|
|
1
|
+
# Astrolabe
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@amulet-laboratories/astrolabe)
|
|
4
|
+
[](https://github.com/Amulet-Laboratories/app-graph/actions/workflows/ci.yml)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Static analyzer that maps a **Nuxt or Vue** app into one interactive diagram — routes, components, composables, stores, content collections, server routes and the external services they talk to, plus the edges between them.
|
|
8
|
+
|
|
9
|
+
The goal is to open one file and understand an app you've never seen: what the pages are, what each renders, and where its data comes from.
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
> _One self-contained HTML file for [nuxt/movies](https://github.com/nuxt/movies) — pan, zoom, ⌘K to any node, and the findings the diagram won't make you notice._
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
# run it without installing
|
|
19
|
+
npx @amulet-laboratories/astrolabe ./my-app --open
|
|
20
|
+
|
|
21
|
+
# or add it to a project
|
|
22
|
+
npm i -D @amulet-laboratories/astrolabe
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
astrolabe ./my-app --open # interactive report
|
|
27
|
+
astrolabe ./my-app --svg -o docs/architecture.svg
|
|
28
|
+
astrolabe diff main-graph.json pr-graph.json # what changed
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Or import it:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
import { scan, renderHtml } from '@amulet-laboratories/astrolabe'
|
|
35
|
+
const graph = await scan('./my-app') // typed graph JSON
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
astrolabe [dir] [options]
|
|
42
|
+
astrolabe diff <base> <head> [options]
|
|
43
|
+
|
|
44
|
+
dir Path to the Nuxt or Vue app (default: current directory)
|
|
45
|
+
diff Compare two apps or two graph JSON files, base → head
|
|
46
|
+
|
|
47
|
+
-o, --out FILE Output path (default: astrolabe.<html|json|svg>)
|
|
48
|
+
--json Emit graph JSON instead of HTML (for diff: JSON not Markdown)
|
|
49
|
+
--svg Emit a static SVG snapshot instead of HTML
|
|
50
|
+
--view NAME View for --svg: layers (default), render, data, modules, sitemap, cleanup
|
|
51
|
+
--schema Print the canonical graph schema and exit
|
|
52
|
+
--open Open the result in your default browser
|
|
53
|
+
-h, --help Show this help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The HTML is fully self-contained — no network, no build step. Drag to pan, scroll to zoom, click any node to see its file, its data access, and everything connected to it in both directions. The chips along the top filter entity types, and the diagram re-flows to fit whatever is left.
|
|
57
|
+
|
|
58
|
+
## Living docs
|
|
59
|
+
|
|
60
|
+
The graph is only useful if it stays current, so three outputs are built to run in CI and keep it honest:
|
|
61
|
+
|
|
62
|
+
- **A static SVG** (`--svg`) of any view, rendered server-side with no browser — for embedding in a README or a docs page that can't run JavaScript. It uses the exact same layout engine as the interactive report, so the two can't disagree.
|
|
63
|
+
- **A graph diff** (`astrolabe diff base head`) that reports what changed _architecturally_ between two versions — "added 2 routes, a new external service dependency, 1 orphan" — rather than line by line. Each side is either an app directory or a saved `--json` graph. Default output is Markdown for a PR comment; `--json` gives the structured delta.
|
|
64
|
+
- **A ready-made PR workflow** in [`examples/architecture-diff.yml`](examples/architecture-diff.yml): on every pull request it scans base and head, posts the diff as a sticky comment, and uploads the full report + SVG as artifacts.
|
|
65
|
+
|
|
66
|
+
## Views
|
|
67
|
+
|
|
68
|
+
There is one graph. A view is a projection of it — which nodes it keeps, which edges it keeps, and what a column means. Everything else (layout, grouping, the detail panel) is shared, so each view is a different question asked of the same data rather than a different diagram.
|
|
69
|
+
|
|
70
|
+
| View | The question it answers |
|
|
71
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
72
|
+
| **Layers** | _What is in this app?_ Every entity, in columns by kind. The overview. |
|
|
73
|
+
| **Render tree** | _What renders what?_ Only `renders`/`wraps` edges, columns are nesting depth, so the leftmost column is the true entry points. |
|
|
74
|
+
| **Data flow** | _Where does data come from and where does it end up?_ Every edge reversed, so it reads origin → who reads it → where it surfaces. Nodes that never touch data are dropped. |
|
|
75
|
+
| **Modules** | _Who consumes what?_ Folders and packages collapsed to one block each, with line weight showing how many references cross between them. A whole app in ~30 boxes. |
|
|
76
|
+
| **Sitemap** | _What are the pages, and what powers each?_ Laid out from the routes, forward — everything a page reaches. Whatever no page reaches (dead code) drops away. |
|
|
77
|
+
| **Cleanup** | _What's safe to delete?_ Only what the findings flag as warnings — dead components, uncalled composables, empty collections, render cycles — gathered in one place. |
|
|
78
|
+
|
|
79
|
+
**Group** buckets nodes inside each column by owner — the directory a file lives in, or the package it came from. Directories already encode how the app is organised (`components/hub`, `server/api/sync`), so this is grouping you wrote rather than grouping the tool invented. It's off in Modules, where the module already _is_ the group.
|
|
80
|
+
|
|
81
|
+
**Isolate** (in the detail panel, `f`, or Esc to clear) reduces any view to one node plus everything it reaches and everything that reaches it. This is the fastest way to ask "if I change this, what breaks?" It persists across view switches, so you can isolate a collection and then flip to Data flow to see where it surfaces.
|
|
82
|
+
|
|
83
|
+
## The overview
|
|
84
|
+
|
|
85
|
+
With nothing selected, the side panel is a briefing on the repo rather than a prompt: size, findings, composition, the most-referenced entities, the largest files, the stack it runs on, and a legend. Every name in it is clickable and jumps to that node.
|
|
86
|
+
|
|
87
|
+
**Findings** are the things the diagram won't make you notice — dead components, composables nothing calls, endpoints with no handler, render cycles, collections whose glob matched nothing. They're computed once in [`src/core/findings.mjs`](src/core/findings.mjs) and shared by the CLI, `--json`, and the report, so all three can never disagree.
|
|
88
|
+
|
|
89
|
+
The bar for a finding is that it is **actionable and true**. A confidently wrong finding costs more trust than a missing one earns, so anything the analyzer can't be sure of is worded for what was actually checked — "17 server routes with no fetch call" (which is a fact) rather than "17 dead routes" (which would be a guess, and wrong for redirects reached by an `href`).
|
|
90
|
+
|
|
91
|
+
## Keyboard
|
|
92
|
+
|
|
93
|
+
`1`–`6` switch views · `/` search · `g` group · `f` isolate the selection · `Esc` clear
|
|
94
|
+
|
|
95
|
+
The view, selection and isolation are kept in the URL, so any particular reading of the graph can be shared as a link.
|
|
96
|
+
|
|
97
|
+
## What it reads
|
|
98
|
+
|
|
99
|
+
Everything comes from Nuxt's own conventions, so there's nothing to configure:
|
|
100
|
+
|
|
101
|
+
| Entity | Source |
|
|
102
|
+
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
103
|
+
| Routes | `pages/**` file-based routing, including `[param]` and `[...catchall]` |
|
|
104
|
+
| Components | `components/**`, resolved to their auto-import names |
|
|
105
|
+
| Composables | `composables/**` plus auto-imports from libraries — including their bodies, which is where most apps keep their data access |
|
|
106
|
+
| Content collections | `content.config.ts` collections, their Zod schema fields, and their file counts (markdown, JSON and YAML alike) |
|
|
107
|
+
| Server routes | `server/api/**`, `server/routes/**`, with HTTP method |
|
|
108
|
+
| Layouts, middleware, plugins | `layouts/**`, `middleware/**`, `plugins/**` |
|
|
109
|
+
| Stack | Nuxt version, modules, dependencies, engines, package manager |
|
|
110
|
+
| Size | non-blank lines per file, aggregated per node and repo |
|
|
111
|
+
|
|
112
|
+
A caller's `` `/api/debate/${slug}/vote` `` and its handler `server/api/debate/[slug]/vote.post.ts` are the same endpoint, so dynamic segments are collapsed to `*` when matching. Without that the caller invents a phantom endpoint and the real handler looks uncalled — two wrong facts from one mismatch.
|
|
113
|
+
|
|
114
|
+
Edges are extracted per file: `renders` from template tags and from components used in markdown (all three MDC syntaxes — `::block`, `:inline{}`, and plain `<PascalCase />` tags), `queries` from `queryCollection()`, `fetches` from `$fetch`/`useFetch` (splitting internal API routes from external hosts), `uses` from composable calls, `reads` from `useAppConfig`/`useRuntimeConfig`/`useState`, and `wraps`/`guards` from `definePageMeta`.
|
|
115
|
+
|
|
116
|
+
Because markdown render paths count, a component used only from content is not mistaken for an orphan — and one that really is unused gets flagged as such.
|
|
117
|
+
|
|
118
|
+
### `.nuxt` makes it sharper
|
|
119
|
+
|
|
120
|
+
When `.nuxt/` is present (after any `nuxt dev`, `nuxt build`, or `nuxt prepare`), the tool reads `components.d.ts` and `imports.d.ts` for the exact set of auto-imports Nuxt resolved. That's what lets it show **library** components and composables — the ones coming from Rig, `@nuxt/content`, and Nuxt itself — attributed to their package.
|
|
121
|
+
|
|
122
|
+
Without `.nuxt/`, it falls back to scanning `components/` by convention and prints a warning. You still get your own app's structure; you just lose the library layer.
|
|
123
|
+
|
|
124
|
+
### Layers
|
|
125
|
+
|
|
126
|
+
Apps that `extends` a local base layer are followed: the layer's pages, components, composables, layouts, middleware, server routes and content are merged into the graph as if they were the app's own, with the app winning on any name collision (its `pages/index.vue` overrides the layer's). Only local layers — a relative or absolute path — are resolved; remote layers (`github:…`, an npm package) would need a package install and are left out. A layer that itself extends another is followed too.
|
|
127
|
+
|
|
128
|
+
Library entities that nothing references are dropped: a dependency exports far more than any one app uses, and listing the unused half describes the dependency rather than your app. Unconnected _local_ components are kept and flagged as "Nothing renders this component" — that's a finding, not noise.
|
|
129
|
+
|
|
130
|
+
## Reading the diagram
|
|
131
|
+
|
|
132
|
+
Columns run left to right in dependency order: entry and layouts → routes → your components → library components → composables → data and services. Within a column, nodes are ordered by barycenter so connected things line up. Selecting a node dims everything it doesn't touch, which is the fastest way to answer "what does this page actually depend on?"
|
|
133
|
+
|
|
134
|
+
## Architecture
|
|
135
|
+
|
|
136
|
+
The engine is split into a framework-neutral **core** and per-framework **adapters**, so
|
|
137
|
+
supporting another framework is adding an adapter rather than changing everything downstream:
|
|
138
|
+
|
|
139
|
+
| Path | What lives there |
|
|
140
|
+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
141
|
+
| `src/core/` | The `Graph` builder, the `finalize` pipeline (prune → group → findings → stats), the HTML renderer + client, and `schema.mjs` — the canonical node/edge vocabulary every adapter targets. Knows about no framework. |
|
|
142
|
+
| `src/adapters/nuxt/` | Everything Nuxt-specific: file-based routing, `content.config.ts`, `.nuxt/*.d.ts`, SFC parsing. Emits canonical nodes and edges. |
|
|
143
|
+
| `src/adapters/registry.mjs` | Adapter list + `detectAdapter(dir)` — first adapter whose `detect()` matches wins. |
|
|
144
|
+
| `src/index.mjs` | Public API: `scan(dir)` detects the adapter, runs it, and finalizes; plus `renderHtml`, the schema, and the registry. |
|
|
145
|
+
|
|
146
|
+
An adapter is a module exporting `{ name, detect(dir), scan(dir) }`, where `scan` returns
|
|
147
|
+
`{ project, graph, warnings }` in the canonical schema. [`test/conformance.test.mjs`](test/conformance.test.mjs)
|
|
148
|
+
is the contract every adapter must pass, independent of any framework — pointing a new adapter at
|
|
149
|
+
it is what proves the core stayed neutral.
|
|
150
|
+
|
|
151
|
+
## Developing
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pnpm install
|
|
155
|
+
pnpm test # vitest, runs against fixture apps in test/fixtures/
|
|
156
|
+
pnpm check # lint + format + test, what CI runs
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Lint and format mirror `@amulet-laboratories/config` rule-for-rule. They're inlined rather than imported because that package isn't published, and its only consumable form (`file:../Rig/config`) has no sibling checkout in CI. Swap to the shared package once it ships to the registry.
|
|
160
|
+
|
|
161
|
+
### The testing bar
|
|
162
|
+
|
|
163
|
+
This tool's entire value is being right about a repo. It almost never fails loudly — when it's wrong it reports something false with total confidence, and someone deletes code because of it. So the standard is:
|
|
164
|
+
|
|
165
|
+
**Every bug gets a fixture, not just a fix.** `test/fixtures/` holds small real Nuxt apps; tests assert against the graph they produce. Each regression test names the false claim it prevents. The bugs already pinned there — composable bodies unparsed, `type: 'data'` collections reported empty, phantom endpoints, missed MDC tags, mis-parsed one-line props — were all found by hand on real repos, and every one would have shipped again without a fixture.
|
|
166
|
+
|
|
167
|
+
That's also why fixtures beat testing against your own apps: the one-line props bug was invisible across all 8 Amulet repos because they all happen to format multi-line. Fixtures let you write the shape you _don't_ have.
|
|
168
|
+
|
|
169
|
+
## Limitations
|
|
170
|
+
|
|
171
|
+
Analysis is lexical, not type-aware. It recognizes the conventional Nuxt call shapes, which is what the diagram is for. Known blind spots, all of which affect what you should conclude from a finding:
|
|
172
|
+
|
|
173
|
+
- A collection or endpoint named at runtime (`queryCollection(someVar)`, `$fetch(url)`) isn't traced — only literal and template strings are.
|
|
174
|
+
- A component rendered via `<component :is>` isn't traced.
|
|
175
|
+
- **Links aren't traced.** A server route reached by an `href` (a `/go/:slug` redirect) shows no incoming edge, which is why the finding says "no fetch call" rather than "dead".
|
|
176
|
+
- A composable file's data access is attributed to every composable that file exports. Files almost always export one; where they don't, this over-attributes rather than dropping the edge.
|
|
177
|
+
- Dynamic route params are shown as the pattern, not the pages they expand to.
|
|
178
|
+
- Only local `extends` layers are followed. A remote layer (`github:…`, an npm package) is not fetched, so its entities are absent rather than wrong.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
EDGE_KINDS,
|
|
8
|
+
NODE_KINDS,
|
|
9
|
+
renderHtml,
|
|
10
|
+
renderSvg,
|
|
11
|
+
scan,
|
|
12
|
+
SCHEMA_VERSION,
|
|
13
|
+
} from '../src/index.mjs'
|
|
14
|
+
import { diffGraphs, formatDiffMarkdown } from '../src/core/diff.mjs'
|
|
15
|
+
|
|
16
|
+
const USAGE = `
|
|
17
|
+
astrolabe — map a Nuxt or Vue app's structure into one interactive diagram
|
|
18
|
+
|
|
19
|
+
astrolabe [dir] [options]
|
|
20
|
+
astrolabe diff <base> <head> [options]
|
|
21
|
+
|
|
22
|
+
dir Path to the app (default: current directory)
|
|
23
|
+
diff Compare two apps or two graph JSON files, base → head
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-o, --out FILE Output path (default: astrolabe.<html|json|svg>)
|
|
27
|
+
--json Emit graph JSON instead of HTML (for diff: JSON not Markdown)
|
|
28
|
+
--svg Emit a static SVG snapshot instead of HTML
|
|
29
|
+
--view NAME View for --svg: layers (default), render, data, modules, sitemap, cleanup
|
|
30
|
+
--schema Print the canonical graph schema and exit
|
|
31
|
+
--open Open the result in your default browser
|
|
32
|
+
-h, --help Show this help
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
const { values, positionals } = parseArgs({
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
options: {
|
|
38
|
+
out: { type: 'string', short: 'o' },
|
|
39
|
+
json: { type: 'boolean', default: false },
|
|
40
|
+
svg: { type: 'boolean', default: false },
|
|
41
|
+
view: { type: 'string', default: 'layers' },
|
|
42
|
+
schema: { type: 'boolean', default: false },
|
|
43
|
+
open: { type: 'boolean', default: false },
|
|
44
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
45
|
+
},
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
if (values.help) {
|
|
49
|
+
console.log(USAGE)
|
|
50
|
+
process.exit(0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (values.schema) {
|
|
54
|
+
console.log(
|
|
55
|
+
JSON.stringify(
|
|
56
|
+
{
|
|
57
|
+
schemaVersion: SCHEMA_VERSION,
|
|
58
|
+
nodeKinds: NODE_KINDS,
|
|
59
|
+
edgeKinds: EDGE_KINDS,
|
|
60
|
+
},
|
|
61
|
+
null,
|
|
62
|
+
2,
|
|
63
|
+
),
|
|
64
|
+
)
|
|
65
|
+
process.exit(0)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A `<path>` is either a saved graph JSON or an app directory to scan fresh.
|
|
69
|
+
async function loadGraph(path) {
|
|
70
|
+
if (path.endsWith('.json')) return JSON.parse(await readFile(resolve(path), 'utf8'))
|
|
71
|
+
return scan(resolve(path))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (positionals[0] === 'diff') {
|
|
75
|
+
const [, base, head] = positionals
|
|
76
|
+
if (!base || !head) {
|
|
77
|
+
console.error('astrolabe diff: needs two arguments — <base> <head>')
|
|
78
|
+
process.exit(1)
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const d = diffGraphs(await loadGraph(base), await loadGraph(head))
|
|
82
|
+
const output = values.json ? JSON.stringify(d, null, 2) : formatDiffMarkdown(d)
|
|
83
|
+
if (values.out) await writeFile(resolve(values.out), output)
|
|
84
|
+
else console.log(output)
|
|
85
|
+
// A diff is information, not a failure — always exit 0. Callers that want to
|
|
86
|
+
// gate a build on it can inspect the --json output themselves.
|
|
87
|
+
process.exit(0)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
console.error(`astrolabe: ${err.message}`)
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const ext = values.json ? 'json' : values.svg ? 'svg' : 'html'
|
|
95
|
+
const dir = resolve(positionals[0] ?? '.')
|
|
96
|
+
const out = resolve(values.out ?? `astrolabe.${ext}`)
|
|
97
|
+
|
|
98
|
+
const renderOutput = (graph) => {
|
|
99
|
+
if (values.json) return JSON.stringify(graph, null, 2)
|
|
100
|
+
if (values.svg) return renderSvg(graph, { view: values.view })
|
|
101
|
+
return renderHtml(graph)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const graph = await scan(dir)
|
|
106
|
+
await writeFile(out, renderOutput(graph))
|
|
107
|
+
|
|
108
|
+
const counts = graph.nodes.reduce((acc, n) => ((acc[n.type] = (acc[n.type] ?? 0) + 1), acc), {})
|
|
109
|
+
const summary = Object.entries(counts)
|
|
110
|
+
.sort((a, b) => b[1] - a[1])
|
|
111
|
+
.map(([type, n]) => `${n} ${type}`)
|
|
112
|
+
.join(', ')
|
|
113
|
+
|
|
114
|
+
console.log(`${graph.project.name}: ${summary}`)
|
|
115
|
+
console.log(
|
|
116
|
+
`${graph.stats.files} files, ${graph.stats.loc.toLocaleString()} lines, ${graph.edges.length} edges`,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
for (const f of graph.findings) {
|
|
120
|
+
const mark = f.severity === 'warn' ? '!' : '·'
|
|
121
|
+
console.log(` ${mark} ${f.title}`)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log(`→ ${out}`)
|
|
125
|
+
for (const w of graph.warnings) console.warn(`warning: ${w}`)
|
|
126
|
+
|
|
127
|
+
if (values.open) {
|
|
128
|
+
const { spawn } = await import('node:child_process')
|
|
129
|
+
const cmd =
|
|
130
|
+
process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'
|
|
131
|
+
spawn(cmd, [out], { detached: true, stdio: 'ignore' }).unref()
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
console.error(`astrolabe: ${err.message}`)
|
|
135
|
+
process.exit(1)
|
|
136
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@amulet-laboratories/astrolabe",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Astrolabe maps a Nuxt or Vue app into one interactive diagram — routes, components, composables, stores, data sources and the services they talk to — plus the findings the diagram won't make you notice.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"packageManager": "pnpm@10.30.2",
|
|
8
|
+
"author": "Amulet Laboratories",
|
|
9
|
+
"homepage": "https://github.com/Amulet-Laboratories/astrolabe#readme",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/Amulet-Laboratories/astrolabe.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/Amulet-Laboratories/astrolabe/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"nuxt",
|
|
19
|
+
"vue",
|
|
20
|
+
"static-analysis",
|
|
21
|
+
"architecture",
|
|
22
|
+
"diagram",
|
|
23
|
+
"dependency-graph",
|
|
24
|
+
"dead-code",
|
|
25
|
+
"documentation",
|
|
26
|
+
"code-visualization"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"bin": {
|
|
32
|
+
"astrolabe": "bin/astrolabe.mjs"
|
|
33
|
+
},
|
|
34
|
+
"types": "./src/index.d.ts",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./src/index.d.ts",
|
|
38
|
+
"default": "./src/index.mjs"
|
|
39
|
+
},
|
|
40
|
+
"./schema": {
|
|
41
|
+
"types": "./src/index.d.ts",
|
|
42
|
+
"default": "./src/core/schema.mjs"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"files": [
|
|
46
|
+
"bin",
|
|
47
|
+
"src",
|
|
48
|
+
"README.md",
|
|
49
|
+
"CHANGELOG.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"start": "node bin/astrolabe.mjs",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:watch": "vitest",
|
|
59
|
+
"lint": "eslint .",
|
|
60
|
+
"types": "tsc --noEmit",
|
|
61
|
+
"format": "prettier --write .",
|
|
62
|
+
"format:check": "prettier --check .",
|
|
63
|
+
"check": "eslint . && prettier --check . && tsc --noEmit && vitest run"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@vue/compiler-sfc": "^3.5.0"
|
|
67
|
+
},
|
|
68
|
+
"devDependencies": {
|
|
69
|
+
"@eslint/js": "^10.0.1",
|
|
70
|
+
"eslint": "^10.7.0",
|
|
71
|
+
"eslint-config-prettier": "^10.1.8",
|
|
72
|
+
"globals": "^17.7.0",
|
|
73
|
+
"jsdom": "^30.0.0",
|
|
74
|
+
"prettier": "^3.9.5",
|
|
75
|
+
"typescript": "^7.0.2",
|
|
76
|
+
"vitest": "^4.1.10"
|
|
77
|
+
}
|
|
78
|
+
}
|