@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.
@@ -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
+ }