@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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { buildStack, pkgOf } from '../shared.mjs'
|
|
5
|
+
|
|
6
|
+
const CONFIG_NAMES = ['nuxt.config.ts', 'nuxt.config.js', 'nuxt.config.mjs']
|
|
7
|
+
|
|
8
|
+
/** Whether `dir` looks like a Nuxt app — the presence of a nuxt.config. */
|
|
9
|
+
export function detect(dir) {
|
|
10
|
+
const root = resolve(dir)
|
|
11
|
+
return CONFIG_NAMES.some((n) => existsSync(join(root, n)))
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Locate the Nuxt app at `dir` and resolve the conventions that change where
|
|
16
|
+
* everything lives: srcDir (Nuxt 4 nests the app under app/) and whether
|
|
17
|
+
* component auto-import names carry their directory as a prefix.
|
|
18
|
+
*/
|
|
19
|
+
export function loadProject(dir) {
|
|
20
|
+
const root = resolve(dir)
|
|
21
|
+
const configName = CONFIG_NAMES.find((n) => existsSync(join(root, n)))
|
|
22
|
+
if (!configName) {
|
|
23
|
+
throw new Error(`No nuxt.config found in ${root} — is this a Nuxt app?`)
|
|
24
|
+
}
|
|
25
|
+
const configRaw = readFileSync(join(root, configName), 'utf8')
|
|
26
|
+
const srcDir = resolveSrcDir(root, configRaw)
|
|
27
|
+
|
|
28
|
+
let pkg = {}
|
|
29
|
+
try {
|
|
30
|
+
pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
|
31
|
+
} catch {
|
|
32
|
+
// A Nuxt app without a readable package.json is unusual but not fatal.
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Layers this app extends. The main app is the first source and wins on any
|
|
36
|
+
// name collision; layers add whatever the app itself doesn't define.
|
|
37
|
+
const layers = resolveLayers(root, configRaw)
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
root,
|
|
41
|
+
srcDir,
|
|
42
|
+
configName,
|
|
43
|
+
configRaw,
|
|
44
|
+
name: pkg.name || root.split('/').pop(),
|
|
45
|
+
description: pkg.description || '',
|
|
46
|
+
modules: parseModules(configRaw),
|
|
47
|
+
stack: buildStack(pkg),
|
|
48
|
+
pathPrefix: !/pathPrefix\s*:\s*false/.test(configRaw),
|
|
49
|
+
nuxtDir: existsSync(join(root, '.nuxt')) ? join(root, '.nuxt') : null,
|
|
50
|
+
layers,
|
|
51
|
+
// Every place to look for app code, main first. Scanners iterate this so a
|
|
52
|
+
// layer's pages/components/etc. land in the graph just like the app's own.
|
|
53
|
+
sources: [{ root, srcDir }, ...layers],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Nuxt 4 defaults srcDir to app/; an explicit srcDir in the config wins. */
|
|
58
|
+
function resolveSrcDir(root, configRaw) {
|
|
59
|
+
const explicit = configRaw.match(/srcDir\s*:\s*['"]([^'"]+)['"]/)?.[1]
|
|
60
|
+
if (explicit) return resolve(root, explicit)
|
|
61
|
+
if (existsSync(join(root, 'app', 'pages')) || existsSync(join(root, 'app', 'app.vue'))) {
|
|
62
|
+
return join(root, 'app')
|
|
63
|
+
}
|
|
64
|
+
return root
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve `extends` to a flat list of `{ root, srcDir }` layers, depth-first so
|
|
69
|
+
* a nearer layer precedes a farther one. Only *local* layers (a relative or
|
|
70
|
+
* absolute path) are followed — remote layers (`github:…`, an npm package) would
|
|
71
|
+
* need a package install to resolve and are out of scope. A layer may itself
|
|
72
|
+
* extend further layers; a seen-set stops cycles and re-visits.
|
|
73
|
+
*/
|
|
74
|
+
function resolveLayers(root, configRaw, seen = new Set(), depth = 0) {
|
|
75
|
+
if (depth > 8) return []
|
|
76
|
+
const out = []
|
|
77
|
+
for (const spec of parseExtends(configRaw)) {
|
|
78
|
+
if (!spec.startsWith('.') && !spec.startsWith('/')) continue
|
|
79
|
+
const layerRoot = resolve(root, spec)
|
|
80
|
+
if (seen.has(layerRoot) || !existsSync(layerRoot)) continue
|
|
81
|
+
seen.add(layerRoot)
|
|
82
|
+
const cfg = CONFIG_NAMES.map((n) => join(layerRoot, n)).find(existsSync)
|
|
83
|
+
const layerRaw = cfg ? readFileSync(cfg, 'utf8') : ''
|
|
84
|
+
out.push({ root: layerRoot, srcDir: resolveSrcDir(layerRoot, layerRaw) })
|
|
85
|
+
out.push(...resolveLayers(layerRoot, layerRaw, seen, depth + 1))
|
|
86
|
+
}
|
|
87
|
+
return out
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The layer specifiers in an `extends: '…'` or `extends: ['…', ['…', {}]]`. */
|
|
91
|
+
function parseExtends(configRaw) {
|
|
92
|
+
const block = configRaw.match(/extends\s*:\s*(\[[\s\S]*?\]|['"][^'"]+['"])/)?.[1]
|
|
93
|
+
if (!block) return []
|
|
94
|
+
return [...block.matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1])
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseModules(configRaw) {
|
|
98
|
+
const block = configRaw.match(/modules\s*:\s*\[([\s\S]*?)\]/)?.[1]
|
|
99
|
+
if (!block) return []
|
|
100
|
+
return [...block.matchAll(/['"]([^'"]+)['"]/g)].map((m) => m[1])
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `.nuxt/components.d.ts` is the ground truth for auto-imported components:
|
|
105
|
+
* it names every component Nuxt resolved, including ones from node_modules
|
|
106
|
+
* (Rig, MDC prose components) that a convention scan of components/ can't see.
|
|
107
|
+
* Only present after `nuxt prepare` / `nuxt dev` has run.
|
|
108
|
+
*/
|
|
109
|
+
export function readGeneratedComponents(project) {
|
|
110
|
+
if (!project.nuxtDir) return null
|
|
111
|
+
const file = join(project.nuxtDir, 'components.d.ts')
|
|
112
|
+
if (!existsSync(file)) return null
|
|
113
|
+
|
|
114
|
+
const map = new Map()
|
|
115
|
+
const raw = readFileSync(file, 'utf8')
|
|
116
|
+
const re = /export const (\w+)\s*:\s*typeof import\("([^"]+)"\)/g
|
|
117
|
+
for (const [, name, spec] of raw.matchAll(re)) {
|
|
118
|
+
// Nuxt emits a Lazy* alias for every component; it's the same node.
|
|
119
|
+
if (name.startsWith('Lazy')) continue
|
|
120
|
+
if (map.has(name)) continue
|
|
121
|
+
map.set(name, classifySpecifier(spec, project))
|
|
122
|
+
}
|
|
123
|
+
return map.size ? map : null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Auto-imported composables/utils, keyed by name. Nuxt writes these as
|
|
128
|
+
* `export { a, b } from '<specifier>'`, where the specifier is a virtual
|
|
129
|
+
* module (#app/...), a bare package (@amulet-laboratories/rig), or a relative
|
|
130
|
+
* path into node_modules — each of which classifies differently.
|
|
131
|
+
*/
|
|
132
|
+
export function readGeneratedImports(project) {
|
|
133
|
+
if (!project.nuxtDir) return null
|
|
134
|
+
const file = join(project.nuxtDir, 'imports.d.ts')
|
|
135
|
+
if (!existsSync(file)) return null
|
|
136
|
+
|
|
137
|
+
const map = new Map()
|
|
138
|
+
const raw = readFileSync(file, 'utf8')
|
|
139
|
+
const re = /export\s*\{([^}]*)\}\s*from\s*'([^']+)'/g
|
|
140
|
+
for (const [, names, spec] of raw.matchAll(re)) {
|
|
141
|
+
const info = classifySpecifier(spec, project)
|
|
142
|
+
for (const part of names.split(',')) {
|
|
143
|
+
// Handle `foo as bar` — the local name is what appears in source. split()
|
|
144
|
+
// never yields an empty array, so pop() is always a string.
|
|
145
|
+
const name = part
|
|
146
|
+
.trim()
|
|
147
|
+
.split(/\s+as\s+/)
|
|
148
|
+
.pop()
|
|
149
|
+
.trim()
|
|
150
|
+
if (name && !map.has(name)) map.set(name, info)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return map.size ? map : null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function classifySpecifier(spec, project) {
|
|
157
|
+
if (spec.startsWith('#')) return { file: spec, origin: 'framework', pkg: spec.split('/')[0] }
|
|
158
|
+
if (spec.startsWith('.')) {
|
|
159
|
+
const file = resolve(project.nuxtDir, spec)
|
|
160
|
+
return { file, ...originOf(file) }
|
|
161
|
+
}
|
|
162
|
+
const pkg = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]
|
|
163
|
+
return { file: spec, origin: 'library', pkg }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Classify a resolved component/composable file: code you own vs. code a
|
|
168
|
+
* dependency brought in. The graph leans on this to keep your app's own
|
|
169
|
+
* structure visually dominant.
|
|
170
|
+
*/
|
|
171
|
+
export function originOf(file) {
|
|
172
|
+
if (!file.includes('node_modules')) return { origin: 'local', pkg: null }
|
|
173
|
+
return { origin: 'library', pkg: pkgOf(file) }
|
|
174
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter registry. An adapter is a module exporting `{ name, detect, scan }`:
|
|
3
|
+
* `detect(dir)` returns whether it recognizes the app, and `scan(dir)` returns
|
|
4
|
+
* `{ project, graph, warnings }` in the canonical schema. Adding a framework is
|
|
5
|
+
* adding a module here — the core never imports an adapter directly.
|
|
6
|
+
*
|
|
7
|
+
* Order is priority: the first adapter whose `detect` passes wins, so more
|
|
8
|
+
* specific frameworks should precede more general ones.
|
|
9
|
+
*/
|
|
10
|
+
import * as nuxt from './nuxt/index.mjs'
|
|
11
|
+
import * as vue from './vue/index.mjs'
|
|
12
|
+
|
|
13
|
+
// Nuxt before Vue: a Nuxt app also has `vue`, so the more specific adapter wins.
|
|
14
|
+
export const ADAPTERS = [nuxt, vue]
|
|
15
|
+
|
|
16
|
+
export function detectAdapter(dir) {
|
|
17
|
+
const found = ADAPTERS.find((a) => a.detect(dir))
|
|
18
|
+
if (!found) {
|
|
19
|
+
const names = ADAPTERS.map((a) => a.name).join(', ')
|
|
20
|
+
throw new Error(`No supported framework found in ${dir} — looked for: ${names}`)
|
|
21
|
+
}
|
|
22
|
+
return found
|
|
23
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-neutral plumbing shared by the adapters. Nuxt is Vue-with-more, so
|
|
3
|
+
* both adapters build on this: filesystem walking, parallel reads with warnings,
|
|
4
|
+
* git churn, the package stack, file-based route names, and the tag→render
|
|
5
|
+
* linking. Anything framework-specific stays in the adapter; this is the kit.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from 'node:child_process'
|
|
8
|
+
import { readFile, readdir } from 'node:fs/promises'
|
|
9
|
+
import { join, relative } from 'node:path'
|
|
10
|
+
import { promisify } from 'node:util'
|
|
11
|
+
|
|
12
|
+
import { normalizeTag } from '../core/sfc.mjs'
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile)
|
|
15
|
+
|
|
16
|
+
/** Colocated tests aren't app source — never analyze them (found on real repos). */
|
|
17
|
+
export const isTest = (file) => /\.(test|spec)\./.test(file) || /(^|\/)__tests__\//.test(file)
|
|
18
|
+
|
|
19
|
+
/** Non-blank lines. A rough but honest proxy for how much code a file is. */
|
|
20
|
+
export const countLoc = (source) => source.split('\n').filter((l) => l.trim()).length
|
|
21
|
+
|
|
22
|
+
/** The package a node_modules file belongs to, pnpm layout included. */
|
|
23
|
+
export function pkgOf(file) {
|
|
24
|
+
const m = file.match(/node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)/)
|
|
25
|
+
return m ? m[1] : 'unknown'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A node_modules file shortened to its package-relative path. */
|
|
29
|
+
export const shortenPkgPath = (file) =>
|
|
30
|
+
file.match(/node_modules\/(?:\.pnpm\/[^/]+\/node_modules\/)?(.+)$/)?.[1] ?? file
|
|
31
|
+
|
|
32
|
+
/** A shareable path for a node: repo-relative, or short package path for deps. */
|
|
33
|
+
export const rel = (file, root) =>
|
|
34
|
+
file.includes('node_modules') ? shortenPkgPath(file) : relative(root, file)
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Walk a directory returning repo-relative paths of files matching `exts`.
|
|
38
|
+
* Missing directories yield an empty list — most app dirs are optional.
|
|
39
|
+
*/
|
|
40
|
+
export async function walk(dir, exts, root) {
|
|
41
|
+
const out = []
|
|
42
|
+
let entries
|
|
43
|
+
try {
|
|
44
|
+
entries = await readdir(dir, { withFileTypes: true })
|
|
45
|
+
} catch {
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const full = join(dir, entry.name)
|
|
50
|
+
if (entry.isDirectory()) {
|
|
51
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
52
|
+
out.push(...(await walk(full, exts, root)))
|
|
53
|
+
} else if (exts.some((e) => entry.name.endsWith(e))) {
|
|
54
|
+
out.push(relative(root, full))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out.sort()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read `paths` in parallel and hand each result to `fn(path, source, i)` in
|
|
62
|
+
* array order, so a parallelized scan produces byte-identical output to a serial
|
|
63
|
+
* one. A read failure or a throw inside `fn` becomes a warning — keyed by
|
|
64
|
+
* `label(path)` — rather than aborting the whole scan: one malformed file must
|
|
65
|
+
* never cost the report.
|
|
66
|
+
*/
|
|
67
|
+
export async function readEach(paths, warnings, fn, label = (p) => p) {
|
|
68
|
+
const sources = await Promise.all(paths.map((p) => readFile(p, 'utf8').catch(() => null)))
|
|
69
|
+
paths.forEach((path, i) => {
|
|
70
|
+
if (sources[i] === null) {
|
|
71
|
+
warnings.push(`could not read ${label(path)}`)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
fn(path, sources[i], i)
|
|
76
|
+
} catch (err) {
|
|
77
|
+
warnings.push(`skipped ${label(path)}: ${err.message}`)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `readEach` over every non-test file under `dir` matching `exts`, walk() order. */
|
|
83
|
+
export async function eachFile(dir, exts, warnings, fn) {
|
|
84
|
+
const files = (await walk(dir, exts, dir)).filter((f) => !isTest(f))
|
|
85
|
+
await readEach(
|
|
86
|
+
files.map((f) => join(dir, f)),
|
|
87
|
+
warnings,
|
|
88
|
+
(full, source, i) => fn(files[i], source),
|
|
89
|
+
(full) => relative(dir, full),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** pages/category/[slug].vue → /category/:slug (file-based routing). */
|
|
94
|
+
export function routeFromFile(file) {
|
|
95
|
+
const segments = file.replace(/\.vue$/, '').split('/')
|
|
96
|
+
const parts = []
|
|
97
|
+
for (const seg of segments) {
|
|
98
|
+
if (seg === 'index') continue
|
|
99
|
+
if (seg.startsWith('[...')) parts.push(`*${seg.slice(4, -1)}`)
|
|
100
|
+
else if (seg.startsWith('[')) parts.push(`:${seg.slice(1, -1)}`)
|
|
101
|
+
else parts.push(seg)
|
|
102
|
+
}
|
|
103
|
+
return `/${parts.join('/')}`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Wire a template's tags into `renders` edges, creating library-component nodes. */
|
|
107
|
+
export function linkTags(g, fromId, tags, components) {
|
|
108
|
+
for (const tag of tags) {
|
|
109
|
+
const name = normalizeTag(tag.replace(/^Lazy/, ''))
|
|
110
|
+
const info = components.get(name)
|
|
111
|
+
if (!info) continue
|
|
112
|
+
const id = `component:${name}`
|
|
113
|
+
if (info.origin === 'library') {
|
|
114
|
+
g.node(id, {
|
|
115
|
+
type: 'lib-component',
|
|
116
|
+
label: name,
|
|
117
|
+
file: shortenPkgPath(info.file),
|
|
118
|
+
meta: { origin: 'library', pkg: info.pkg },
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
g.edge(fromId, id, 'renders')
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* How many commits have touched each file under `root`, keyed by root-relative
|
|
127
|
+
* path — a cheap proxy for churn / hotspots. One `git log` for the whole tree.
|
|
128
|
+
* Returns null when `root` isn't a git work tree or git is missing, so churn is
|
|
129
|
+
* always optional and never fatal.
|
|
130
|
+
*/
|
|
131
|
+
export async function gitChurn(root) {
|
|
132
|
+
let prefix, log
|
|
133
|
+
try {
|
|
134
|
+
;[prefix, log] = await Promise.all([
|
|
135
|
+
execFileAsync('git', ['-C', root, 'rev-parse', '--show-prefix'], { maxBuffer: 1 << 20 }),
|
|
136
|
+
execFileAsync('git', ['-C', root, 'log', '--format=', '--name-only'], { maxBuffer: 1 << 28 }),
|
|
137
|
+
])
|
|
138
|
+
} catch {
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
return tallyNameOnly(log.stdout, prefix.stdout.trim())
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Count each path in `git log --name-only` output, keeping only paths under
|
|
146
|
+
* `prefix` (the scanned dir's location within the repo) and re-rooting them to
|
|
147
|
+
* match the graph's repo-relative `file` fields. Pure, so it can be tested.
|
|
148
|
+
*/
|
|
149
|
+
export function tallyNameOnly(stdout, prefix = '') {
|
|
150
|
+
const counts = new Map()
|
|
151
|
+
for (const raw of stdout.split('\n')) {
|
|
152
|
+
const line = raw.trim()
|
|
153
|
+
if (!line || (prefix && !line.startsWith(prefix))) continue
|
|
154
|
+
const rel = prefix ? line.slice(prefix.length) : line
|
|
155
|
+
counts.set(rel, (counts.get(rel) ?? 0) + 1)
|
|
156
|
+
}
|
|
157
|
+
return counts
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* What an app is built on. Runtime dependencies first — they say more about the
|
|
162
|
+
* app than the toolchain does — with a headline framework version pulled out.
|
|
163
|
+
*/
|
|
164
|
+
export function buildStack(pkg) {
|
|
165
|
+
const deps = Object.entries(pkg.dependencies ?? {}).map(([name, version]) => ({
|
|
166
|
+
name,
|
|
167
|
+
version,
|
|
168
|
+
dev: false,
|
|
169
|
+
}))
|
|
170
|
+
const devDeps = Object.entries(pkg.devDependencies ?? {}).map(([name, version]) => ({
|
|
171
|
+
name,
|
|
172
|
+
version,
|
|
173
|
+
dev: true,
|
|
174
|
+
}))
|
|
175
|
+
const all = [...deps, ...devDeps]
|
|
176
|
+
return {
|
|
177
|
+
nuxt: all.find((d) => d.name === 'nuxt')?.version ?? null,
|
|
178
|
+
packageManager: pkg.packageManager ?? null,
|
|
179
|
+
node: pkg.engines?.node ?? null,
|
|
180
|
+
dependencies: all.sort((a, b) => Number(a.dev) - Number(b.dev) || a.name.localeCompare(b.name)),
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Annotate each file-backed node with its commit count (the heat overlay). */
|
|
185
|
+
export async function annotateChurn(g, root) {
|
|
186
|
+
const churn = await gitChurn(root)
|
|
187
|
+
if (!churn) return
|
|
188
|
+
for (const node of g.nodes.values()) {
|
|
189
|
+
if (node.file && churn.has(node.file)) node.meta.churn = churn.get(node.file)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { Graph } from '../../core/graph.mjs'
|
|
5
|
+
import { normalizeTag, parseScript, parseSfc } from '../../core/sfc.mjs'
|
|
6
|
+
import {
|
|
7
|
+
annotateChurn,
|
|
8
|
+
countLoc,
|
|
9
|
+
eachFile,
|
|
10
|
+
isTest,
|
|
11
|
+
linkTags,
|
|
12
|
+
readEach,
|
|
13
|
+
rel,
|
|
14
|
+
routeFromFile,
|
|
15
|
+
walk,
|
|
16
|
+
} from '../shared.mjs'
|
|
17
|
+
import { detect, loadProject } from './project.mjs'
|
|
18
|
+
|
|
19
|
+
export const name = 'vue'
|
|
20
|
+
export { detect }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Read a plain Vue (Vite) app into the canonical graph. Reuses the Nuxt/Vue SFC
|
|
24
|
+
* parsing and file-route conventions; adds Pinia stores and Vue-flavoured data
|
|
25
|
+
* access, and drops the Nuxt-only pieces (content collections, Nitro server
|
|
26
|
+
* routes). Accurate on a bare clone because the unplugin manifests are committed.
|
|
27
|
+
*/
|
|
28
|
+
export async function scan(dir) {
|
|
29
|
+
const project = loadProject(dir)
|
|
30
|
+
const g = new Graph()
|
|
31
|
+
const warnings = []
|
|
32
|
+
|
|
33
|
+
const components = await resolveComponents(project)
|
|
34
|
+
if (!project.components) {
|
|
35
|
+
warnings.push(
|
|
36
|
+
'No components.d.ts found — falling back to a flat scan of src/components/. ' +
|
|
37
|
+
'Commit the unplugin manifest (or run the build) for exact auto-import names.',
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
const composables = await resolveComposables(project)
|
|
41
|
+
const ctx = { components, composables, stores: new Set() }
|
|
42
|
+
|
|
43
|
+
for (const [name, info] of components) {
|
|
44
|
+
if (info.origin === 'local') {
|
|
45
|
+
g.node(`component:${name}`, {
|
|
46
|
+
type: 'component',
|
|
47
|
+
label: name,
|
|
48
|
+
file: rel(info.file, project.root),
|
|
49
|
+
meta: { origin: 'local' },
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const [name, info] of composables) {
|
|
54
|
+
g.node(`composable:${name}`, {
|
|
55
|
+
type: 'composable',
|
|
56
|
+
label: name,
|
|
57
|
+
file: rel(info.file, project.root),
|
|
58
|
+
meta: { origin: info.origin, pkg: info.pkg, loc: info.loc },
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await scanStores(project, g, ctx, warnings)
|
|
63
|
+
await scanPages(project, g, ctx, warnings)
|
|
64
|
+
await scanComponentBodies(project, g, ctx, warnings)
|
|
65
|
+
await scanComposableBodies(project, g, ctx, warnings)
|
|
66
|
+
await scanEntry(project, g, ctx, warnings)
|
|
67
|
+
|
|
68
|
+
await annotateChurn(g, project.root)
|
|
69
|
+
|
|
70
|
+
return { project, graph: g, warnings }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function resolveComponents(project) {
|
|
74
|
+
if (project.components) return project.components
|
|
75
|
+
const map = new Map()
|
|
76
|
+
const dir = join(project.srcDir, 'components')
|
|
77
|
+
for (const file of await walk(dir, ['.vue'], dir)) {
|
|
78
|
+
const name = normalizeTag(
|
|
79
|
+
file
|
|
80
|
+
.replace(/\.vue$/, '')
|
|
81
|
+
.split('/')
|
|
82
|
+
.pop(),
|
|
83
|
+
)
|
|
84
|
+
if (!map.has(name)) map.set(name, { file: join(dir, file), origin: 'local', pkg: null })
|
|
85
|
+
}
|
|
86
|
+
return map
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* App composables: the `use*` auto-imports the manifest lists, plus a scan of
|
|
91
|
+
* composables/ for `export const use…`. Only `use*` exports become nodes — a
|
|
92
|
+
* utility module that exports plain helpers is not a phantom composable (the
|
|
93
|
+
* false positive the Nuxt corpus surfaced), and test files are skipped.
|
|
94
|
+
*/
|
|
95
|
+
async function resolveComposables(project) {
|
|
96
|
+
const map = new Map(project.autoImports ?? [])
|
|
97
|
+
const dir = join(project.srcDir, 'composables')
|
|
98
|
+
for (const file of await walk(dir, ['.ts', '.js', '.mts'], dir)) {
|
|
99
|
+
if (isTest(file)) continue
|
|
100
|
+
const full = join(dir, file)
|
|
101
|
+
const source = await readFile(full, 'utf8').catch(() => null)
|
|
102
|
+
if (source == null) continue
|
|
103
|
+
for (const [, name] of source.matchAll(
|
|
104
|
+
/export\s+(?:const|function|async function)\s+(use[A-Z]\w*)/g,
|
|
105
|
+
)) {
|
|
106
|
+
if (!map.has(name))
|
|
107
|
+
map.set(name, { file: full, origin: 'local', pkg: null, loc: countLoc(source) })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return map
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Pinia stores: `export const useXStore = defineStore('id', …)`. */
|
|
114
|
+
async function scanStores(project, g, ctx, warnings) {
|
|
115
|
+
const dir = join(project.srcDir, 'stores')
|
|
116
|
+
await eachFile(dir, ['.ts', '.js', '.mts'], warnings, (file, source) => {
|
|
117
|
+
for (const [, useName, id] of source.matchAll(
|
|
118
|
+
/export\s+const\s+(use\w+)\s*=\s*defineStore\s*\(\s*['"`]([^'"`]+)/g,
|
|
119
|
+
)) {
|
|
120
|
+
const nid = `store:${useName}`
|
|
121
|
+
g.node(nid, {
|
|
122
|
+
type: 'store',
|
|
123
|
+
label: id,
|
|
124
|
+
file: rel(join(dir, file), project.root),
|
|
125
|
+
meta: { id, loc: countLoc(source) },
|
|
126
|
+
})
|
|
127
|
+
ctx.stores.add(useName)
|
|
128
|
+
linkScript(g, nid, parseScript(source), ctx)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function scanPages(project, g, ctx, warnings) {
|
|
134
|
+
const dir = join(project.srcDir, 'pages')
|
|
135
|
+
await eachFile(dir, ['.vue'], warnings, (file, source) => {
|
|
136
|
+
const route = routeFromFile(file)
|
|
137
|
+
const id = `route:${route}`
|
|
138
|
+
const parsed = parseSfc(source, join(dir, file))
|
|
139
|
+
g.node(id, {
|
|
140
|
+
type: 'route',
|
|
141
|
+
label: route,
|
|
142
|
+
file: rel(join(dir, file), project.root),
|
|
143
|
+
meta: { dynamic: /[[\]]/.test(file), props: parsed.props, loc: countLoc(source) },
|
|
144
|
+
})
|
|
145
|
+
linkTags(g, id, parsed.tags, ctx.components)
|
|
146
|
+
linkScript(g, id, parsed, ctx)
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function scanComponentBodies(project, g, ctx, warnings) {
|
|
151
|
+
const locals = [...ctx.components].filter(([, info]) => info.origin === 'local')
|
|
152
|
+
await readEach(
|
|
153
|
+
locals.map(([, info]) => info.file),
|
|
154
|
+
warnings,
|
|
155
|
+
(file, source, i) => {
|
|
156
|
+
const [name, info] = locals[i]
|
|
157
|
+
const id = `component:${name}`
|
|
158
|
+
const parsed = parseSfc(source, info.file)
|
|
159
|
+
const node = g.nodes.get(id)
|
|
160
|
+
if (node) node.meta = { ...node.meta, props: parsed.props, loc: countLoc(source) }
|
|
161
|
+
linkTags(g, id, parsed.tags, ctx.components)
|
|
162
|
+
linkScript(g, id, parsed, ctx)
|
|
163
|
+
},
|
|
164
|
+
(file) => rel(file, project.root),
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function scanComposableBodies(project, g, ctx, warnings) {
|
|
169
|
+
const byFile = new Map()
|
|
170
|
+
for (const [name, info] of ctx.composables) {
|
|
171
|
+
if (info.origin !== 'local') continue
|
|
172
|
+
if (!byFile.has(info.file)) byFile.set(info.file, [])
|
|
173
|
+
byFile.get(info.file).push(name)
|
|
174
|
+
}
|
|
175
|
+
await readEach(
|
|
176
|
+
[...byFile.keys()],
|
|
177
|
+
warnings,
|
|
178
|
+
(file, source) => {
|
|
179
|
+
const parsed = parseScript(source)
|
|
180
|
+
for (const name of byFile.get(file)) linkScript(g, `composable:${name}`, parsed, ctx)
|
|
181
|
+
},
|
|
182
|
+
(file) => rel(file, project.root),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function scanEntry(project, g, ctx, warnings) {
|
|
187
|
+
const appFile = join(project.srcDir, 'App.vue')
|
|
188
|
+
await readEach([appFile], warnings, (file, source) => {
|
|
189
|
+
const parsed = parseSfc(source, appFile)
|
|
190
|
+
g.node('entry:App', {
|
|
191
|
+
type: 'entry',
|
|
192
|
+
label: 'App.vue',
|
|
193
|
+
file: rel(appFile, project.root),
|
|
194
|
+
meta: { loc: countLoc(source) },
|
|
195
|
+
})
|
|
196
|
+
linkTags(g, 'entry:App', parsed.tags, ctx.components)
|
|
197
|
+
linkScript(g, 'entry:App', parsed, ctx)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Data access, Vue-flavoured: composable/store calls, and external hosts. */
|
|
202
|
+
function linkScript(g, fromId, parsed, ctx) {
|
|
203
|
+
for (const name of parsed.calls) {
|
|
204
|
+
if (ctx.composables.has(name)) g.edge(fromId, `composable:${name}`, 'uses')
|
|
205
|
+
else if (ctx.stores.has(name)) g.edge(fromId, `store:${name}`, 'uses')
|
|
206
|
+
}
|
|
207
|
+
for (const host of parsed.externals) {
|
|
208
|
+
g.node(`external:${host}`, { type: 'external', label: host, meta: {} })
|
|
209
|
+
g.edge(fromId, `external:${host}`, 'fetches')
|
|
210
|
+
}
|
|
211
|
+
}
|