@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 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
+ [![npm](https://img.shields.io/npm/v/@amulet-laboratories/astrolabe.svg)](https://www.npmjs.com/package/@amulet-laboratories/astrolabe)
4
+ [![CI](https://github.com/Amulet-Laboratories/app-graph/actions/workflows/ci.yml/badge.svg)](https://github.com/Amulet-Laboratories/app-graph/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/@amulet-laboratories/astrolabe.svg)](./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
+ ![An Astrolabe report for nuxt/movies](docs/hero.png)
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
+ }