@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,134 @@
1
+ import { parse } from '@vue/compiler-sfc'
2
+
3
+ import { sliceBalanced, topLevelKeys } from './syntax.mjs'
4
+
5
+ const ELEMENT = 1
6
+
7
+ /** kebab-case / PascalCase tags both resolve to one auto-import name. */
8
+ export function normalizeTag(tag) {
9
+ return tag
10
+ .replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())
11
+ .replace(/^./, (c) => c.toUpperCase())
12
+ }
13
+
14
+ /**
15
+ * Parse a Vue SFC into the two things the graph cares about: which components
16
+ * its template renders, and what its script reaches out to for data.
17
+ */
18
+ export function parseSfc(source, file) {
19
+ const { descriptor } = parse(source, { filename: file })
20
+ const script = [descriptor.script?.content, descriptor.scriptSetup?.content]
21
+ .filter(Boolean)
22
+ .join('\n')
23
+
24
+ return {
25
+ tags: descriptor.template?.ast ? collectTags(descriptor.template.ast) : new Set(),
26
+ ...parseScript(script),
27
+ }
28
+ }
29
+
30
+ function collectTags(ast, found = new Set()) {
31
+ for (const node of ast.children ?? []) {
32
+ if (node.type === ELEMENT && node.tag) found.add(node.tag)
33
+ // Recurse through branches too — v-if/v-for wrap children in their own nodes.
34
+ collectTags(node, found)
35
+ for (const branch of node.branches ?? []) collectTags(branch, found)
36
+ }
37
+ return found
38
+ }
39
+
40
+ /**
41
+ * Pull data-access out of a <script> body by pattern. This is deliberately
42
+ * lexical rather than a full type-aware trace: it catches the conventional
43
+ * Nuxt call shapes, which is what a reader of the diagram is looking for.
44
+ */
45
+ export function parseScript(script) {
46
+ const strip = script.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
47
+
48
+ const collections = new Set()
49
+ for (const [, name] of strip.matchAll(
50
+ /queryCollection(?:Navigation|ItemSurroundings)?\s*\(\s*['"]([^'"]+)['"]/g,
51
+ )) {
52
+ collections.add(name)
53
+ }
54
+
55
+ const endpoints = new Set()
56
+ const externals = new Set()
57
+ for (const [, url] of strip.matchAll(
58
+ /(?:\$fetch|useFetch|useLazyFetch)\s*(?:<[^>]*>)?\s*\(\s*[`'"]([^`'"]+)[`'"]/g,
59
+ )) {
60
+ if (/^https?:\/\//.test(url)) externals.add(hostOf(url))
61
+ else endpoints.add(url.split('?')[0])
62
+ }
63
+
64
+ const calls = new Set()
65
+ for (const [, name] of strip.matchAll(/\b(use[A-Z]\w*)\s*\(/g)) calls.add(name)
66
+
67
+ return {
68
+ collections,
69
+ endpoints,
70
+ externals,
71
+ calls,
72
+ meta: parsePageMeta(strip),
73
+ usesAppConfig: /\buseAppConfig\s*\(/.test(strip),
74
+ usesRuntimeConfig: /\buseRuntimeConfig\s*\(/.test(strip),
75
+ usesState: /\buseState\s*\(/.test(strip),
76
+ props: parseProps(strip),
77
+ }
78
+ }
79
+
80
+ /** Props from either the type form `defineProps<{…}>()` or the runtime form `defineProps({…})`. */
81
+ function parseProps(script) {
82
+ const props = []
83
+ for (const m of script.matchAll(/defineProps\s*[<(]/g)) {
84
+ const start = script.indexOf('{', m.index)
85
+ if (start === -1) continue
86
+ props.push(...topLevelKeys(sliceBalanced(script, start, '{', '}')))
87
+ }
88
+ return props
89
+ }
90
+
91
+ function parsePageMeta(script) {
92
+ const block = script.match(/definePageMeta\s*\(\s*\{([\s\S]*?)\}\s*\)/)?.[1]
93
+ if (!block) return { middleware: [] }
94
+ // middleware takes either a single name or an array of them.
95
+ const mw = block.match(/middleware\s*:\s*(\[[^\]]*\]|['"][\w-]+['"])/)?.[1]
96
+ return {
97
+ layout: block.match(/layout\s*:\s*['"]([^'"]+)['"]/)?.[1],
98
+ middleware: mw ? [...mw.matchAll(/['"]([\w-]+)['"]/g)].map((m) => m[1]) : [],
99
+ }
100
+ }
101
+
102
+ function hostOf(url) {
103
+ try {
104
+ return new URL(url).host
105
+ } catch {
106
+ return url
107
+ }
108
+ }
109
+
110
+ /**
111
+ * MDC lets markdown render Vue components. Those edges are invisible to a
112
+ * template scan but are real render paths — miss them and a component rendered
113
+ * only from content looks like an orphan.
114
+ *
115
+ * Three syntaxes, all in use: `::block`, `:inline{}`, and ordinary tag syntax
116
+ * (`<ProductCardWrapper />`). MDC accepts a component name in either case in
117
+ * every form — `::quick-answer` and `::QuickAnswer` both resolve — so the block
118
+ * and inline patterns match both, and `normalizeTag` folds them to one name.
119
+ * The tag patterns are shaped so plain HTML — div, br, img — can never match: a
120
+ * component tag is either PascalCase or contains a dash.
121
+ */
122
+ export function parseMdcComponents(markdown) {
123
+ const body = markdown
124
+ .replace(/^---\n[\s\S]*?\n---\n/, '')
125
+ .replace(/```[\s\S]*?```/g, '')
126
+ .replace(/`[^`\n]*`/g, '')
127
+
128
+ const found = new Set()
129
+ for (const [, name] of body.matchAll(/^\s*::+([A-Za-z][\w-]*)/gm)) found.add(name)
130
+ for (const [, name] of body.matchAll(/(?<!:):([A-Za-z][\w-]*)\{/g)) found.add(name)
131
+ for (const [, name] of body.matchAll(/<([A-Z][A-Za-z0-9]*)[\s/>]/g)) found.add(name)
132
+ for (const [, name] of body.matchAll(/<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]/g)) found.add(name)
133
+ return found
134
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Render a scanned graph to a static SVG, server-side, with no browser.
3
+ *
4
+ * It reuses the exact projection and layout the interactive report uses
5
+ * (`views.mjs` + `layout.mjs`), so an embedded SVG and the live diagram can
6
+ * never disagree about structure. The point is living docs: a graph that embeds
7
+ * in a README or a docs page that can't run JavaScript, regenerated in CI.
8
+ */
9
+ import { computeLayout, edgePath, NODE_H, NODE_W } from './layout.mjs'
10
+ import { TYPES, VIEWS } from './views.mjs'
11
+
12
+ // Concrete colours for a standalone file. The interactive report reads these
13
+ // from CSS custom properties in styles.css; these mirror its light theme.
14
+ const PALETTE = {
15
+ entry: '#8b7cc8',
16
+ route: '#2f6fd0',
17
+ component: '#2e9e73',
18
+ lib: '#9a9a90',
19
+ composable: '#d08a2f',
20
+ store: '#7b61c9',
21
+ collection: '#c05a8a',
22
+ server: '#c0563f',
23
+ external: '#7a7a72',
24
+ config: '#4b8fa8',
25
+ }
26
+
27
+ const colorOf = (n) => PALETTE[TYPES[n.type]?.color] ?? '#888888'
28
+ const truncate = (s, max) => (s.length > max ? `${s.slice(0, max - 1)}…` : s)
29
+ const esc = (s) =>
30
+ String(s).replace(
31
+ /[&<>"]/g,
32
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c],
33
+ )
34
+
35
+ const MARGIN = 24
36
+
37
+ /**
38
+ * Serialize `graph` (the shape `scan()` returns) into an SVG string for one
39
+ * view. `view` is any of the report's views; `group` buckets by owner exactly
40
+ * as the interactive Group toggle does.
41
+ */
42
+ export function renderSvg(graph, { view = 'layers', group = true } = {}) {
43
+ const v = VIEWS[view]
44
+ if (!v) throw new Error(`Unknown view "${view}" — one of: ${Object.keys(VIEWS).join(', ')}`)
45
+
46
+ const state = { view, group, types: new Set(Object.keys(TYPES)), focus: null }
47
+ const proj = v.project(graph, state)
48
+ const grouping = group && v.grouping !== false
49
+ const layout = computeLayout(proj, { ...state, group: grouping })
50
+ const byId = new Map(proj.nodes.map((n) => [n.id, n]))
51
+
52
+ const width = Math.ceil(layout.width + MARGIN * 2)
53
+ const height = Math.ceil(layout.height + MARGIN * 2)
54
+ const parts = []
55
+
56
+ for (const e of proj.edges) {
57
+ const stroke = colorOf(byId.get(e.to))
58
+ const weight = e.weight > 1 ? Math.min(6, 1 + Math.log2(e.weight)).toFixed(2) : 1
59
+ parts.push(
60
+ `<path d="${edgePath(e, layout)}" fill="none" stroke="${stroke}" stroke-opacity="0.35" stroke-width="${weight}"/>`,
61
+ )
62
+ }
63
+
64
+ for (const c of layout.active) {
65
+ const x = layout.xs.get(c)
66
+ const name = proj.title(c) ?? `Column ${c}`
67
+ parts.push(
68
+ `<text x="${x}" y="16" class="col">${esc(name)} (${layout.cols.get(c).length})</text>`,
69
+ )
70
+ parts.push(`<line x1="${x}" y1="26" x2="${x + NODE_W}" y2="26" class="rule"/>`)
71
+ }
72
+
73
+ for (const h of layout.headers) {
74
+ parts.push(`<text x="${h.x + 2}" y="${h.y}" class="grp">${esc(h.text)}</text>`)
75
+ }
76
+
77
+ for (const n of proj.nodes) {
78
+ const { x, y } = layout.pos.get(n.id)
79
+ parts.push(
80
+ `<g transform="translate(${x},${y})">` +
81
+ `<rect width="${NODE_W}" height="${NODE_H}" rx="4" fill="#ffffff" stroke="#d8d8d8"/>` +
82
+ `<rect width="3" height="${NODE_H}" rx="1.5" fill="${colorOf(n)}"/>` +
83
+ `<text x="10" y="${NODE_H / 2 + 4}" class="lbl">${esc(truncate(n.label, 24))}</text>` +
84
+ `</g>`,
85
+ )
86
+ }
87
+
88
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="ui-sans-serif, system-ui, -apple-system, sans-serif">
89
+ <style>
90
+ text { fill: #1c1c1c }
91
+ .col { font-size: 11px; font-weight: 600; fill: #555555 }
92
+ .grp { font-size: 10px; fill: #8a8a8a }
93
+ .lbl { font-size: 12px }
94
+ .rule { stroke: #e4e4e2 }
95
+ </style>
96
+ <rect width="${width}" height="${height}" fill="#fbfbfa"/>
97
+ <g transform="translate(${MARGIN},${MARGIN})">
98
+ ${parts.join('\n')}
99
+ </g>
100
+ </svg>
101
+ `
102
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Small brace-aware scanners shared by the SFC and config parsers.
3
+ *
4
+ * These exist because line-oriented regexes quietly get the wrong answer on
5
+ * code that happens to be formatted on one line — `{ title?: string; size?:
6
+ * number }` reported one prop instead of two. Counting delimiters costs a few
7
+ * lines and stops formatting from changing the result.
8
+ */
9
+
10
+ /** The balanced `open`…`close` span starting at `start`, inclusive. */
11
+ export function sliceBalanced(str, start, open, close) {
12
+ let depth = 0
13
+ for (let i = start; i < str.length; i++) {
14
+ if (str[i] === open) depth++
15
+ else if (str[i] === close && --depth === 0) return str.slice(start, i + 1)
16
+ }
17
+ return str.slice(start)
18
+ }
19
+
20
+ // Only these nest. Angle brackets are deliberately excluded: `=>` in a function
21
+ // type would otherwise read as a closing bracket and unbalance everything.
22
+ const OPEN = '{(['
23
+ const CLOSE = '})]'
24
+
25
+ /**
26
+ * Keys declared at the top level of an object-ish body — `{ a: X; b?: Y }` →
27
+ * ['a', 'b']. Nested keys are skipped, so `{ a: { b: X } }` yields only 'a'.
28
+ * Works the same whether the source is one line or twenty.
29
+ */
30
+ export function topLevelKeys(body) {
31
+ const keys = []
32
+ let depth = 0
33
+ let token = ''
34
+
35
+ for (const char of body) {
36
+ if (OPEN.includes(char)) {
37
+ depth++
38
+ token = ''
39
+ continue
40
+ }
41
+ if (CLOSE.includes(char)) {
42
+ depth--
43
+ token = ''
44
+ continue
45
+ }
46
+ if (depth !== 1) continue
47
+
48
+ if (/[\w$]/.test(char)) {
49
+ token += char
50
+ } else if (char === ':' || char === '?') {
51
+ // A name immediately followed by `:` or `?:` is a key declaration.
52
+ if (token) keys.push(token)
53
+ token = ''
54
+ } else {
55
+ token = ''
56
+ }
57
+ }
58
+ return keys
59
+ }
@@ -0,0 +1,304 @@
1
+ /**
2
+ * A view is a projection of the one underlying graph: which nodes it keeps,
3
+ * which edges it keeps, and what a column means. Everything downstream —
4
+ * layout, drawing, the detail panel — is shared, so adding a view means
5
+ * answering those three questions and nothing else.
6
+ */
7
+
8
+ export const TYPES = {
9
+ entry: { col: 0, label: 'Entry', color: 'entry' },
10
+ layout: { col: 0, label: 'Layout', color: 'entry' },
11
+ middleware: { col: 0, label: 'Middleware', color: 'entry' },
12
+ plugin: { col: 0, label: 'Plugin', color: 'entry' },
13
+ route: { col: 1, label: 'Route', color: 'route' },
14
+ component: { col: 2, label: 'Component', color: 'component' },
15
+ 'lib-component': { col: 3, label: 'Library component', color: 'lib' },
16
+ composable: { col: 4, label: 'Composable', color: 'composable' },
17
+ store: { col: 4, label: 'Store', color: 'store' },
18
+ collection: { col: 5, label: 'Content collection', color: 'collection' },
19
+ server: { col: 5, label: 'Server route', color: 'server' },
20
+ external: { col: 5, label: 'External service', color: 'external' },
21
+ config: { col: 5, label: 'Config', color: 'config' },
22
+ }
23
+
24
+ const LAYER_TITLES = [
25
+ 'Entry & layouts',
26
+ 'Routes',
27
+ 'Components',
28
+ 'Library',
29
+ 'Composables',
30
+ 'Data & services',
31
+ ]
32
+ export const EDGE_VERB = {
33
+ renders: 'renders',
34
+ uses: 'uses',
35
+ queries: 'queries',
36
+ fetches: 'fetches',
37
+ reads: 'reads',
38
+ wraps: 'wraps',
39
+ guards: 'guards',
40
+ depends: 'depends on',
41
+ }
42
+
43
+ const STRUCTURE_KINDS = new Set(['renders', 'wraps'])
44
+ const SOURCE_TYPES = new Set(['collection', 'server', 'external', 'config'])
45
+
46
+ export const groupKey = (n) => n.meta.group ?? n.type
47
+
48
+ export function adjacency(edges) {
49
+ const out = new Map()
50
+ const inc = new Map()
51
+ for (const e of edges) {
52
+ if (!out.has(e.from)) out.set(e.from, [])
53
+ out.get(e.from).push(e.to)
54
+ if (!inc.has(e.to)) inc.set(e.to, [])
55
+ inc.get(e.to).push(e.from)
56
+ }
57
+ return { out, inc }
58
+ }
59
+
60
+ /**
61
+ * Depth of each node as its longest path from a root. Longest rather than
62
+ * shortest so a component used at depth 1 and depth 4 sits after everything
63
+ * that renders it, which is what keeps edges pointing forwards. Iteration is
64
+ * bounded because a render cycle would otherwise never settle.
65
+ */
66
+ function longestPath(nodes, edges, maxDepth = 24) {
67
+ const depth = new Map(nodes.map((n) => [n.id, 0]))
68
+ for (let pass = 0; pass < nodes.length; pass++) {
69
+ let changed = false
70
+ for (const e of edges) {
71
+ if (!depth.has(e.from) || !depth.has(e.to)) continue
72
+ const next = Math.min(depth.get(e.from) + 1, maxDepth)
73
+ if (next > depth.get(e.to)) {
74
+ depth.set(e.to, next)
75
+ changed = true
76
+ }
77
+ }
78
+ if (!changed) break
79
+ }
80
+ return depth
81
+ }
82
+
83
+ function subgraph(G, keep, edgeFilter = () => true) {
84
+ const nodes = G.nodes.filter((n) => keep(n))
85
+ const ids = new Set(nodes.map((n) => n.id))
86
+ const edges = G.edges.filter((e) => ids.has(e.from) && ids.has(e.to) && edgeFilter(e))
87
+ return { nodes, edges }
88
+ }
89
+
90
+ export const VIEWS = {
91
+ layers: {
92
+ name: 'Layers',
93
+ hint: 'Every entity, arranged by kind. The whole app at once.',
94
+ project(G, state) {
95
+ const { nodes, edges } = subgraph(G, (n) => state.types.has(n.type))
96
+ return { nodes, edges, col: (n) => TYPES[n.type].col, title: (c) => LAYER_TITLES[c] }
97
+ },
98
+ },
99
+
100
+ render: {
101
+ name: 'Render tree',
102
+ hint: 'What renders what, from entry points down. Depth is nesting depth.',
103
+ project(G, state) {
104
+ const keep = new Set(['entry', 'layout', 'route', 'component', 'lib-component'])
105
+ // Markdown renders components too (MDC). Drop the collections that do and
106
+ // those components lose their only parent, then masquerade as entry
107
+ // points — so content collections that render something belong here.
108
+ const renderers = new Set()
109
+ for (const e of G.edges) {
110
+ if (STRUCTURE_KINDS.has(e.kind)) renderers.add(e.from)
111
+ }
112
+ const { nodes, edges } = subgraph(
113
+ G,
114
+ (n) =>
115
+ state.types.has(n.type) &&
116
+ (keep.has(n.type) || (n.type === 'collection' && renderers.has(n.id))),
117
+ (e) => STRUCTURE_KINDS.has(e.kind),
118
+ )
119
+ const depth = longestPath(nodes, edges)
120
+ return {
121
+ nodes,
122
+ edges,
123
+ col: (n) => depth.get(n.id),
124
+ title: (c) => (c === 0 ? 'Entry points' : `Nested depth ${c}`),
125
+ }
126
+ },
127
+ },
128
+
129
+ data: {
130
+ name: 'Data flow',
131
+ hint: 'Every edge reversed, so data reads left to right: origin → who reads it → where it surfaces.',
132
+ project(G, state) {
133
+ // Keep only what can actually reach a data source, walking backwards from
134
+ // each source. A purely presentational component has no place here.
135
+ const { inc } = adjacency(G.edges)
136
+ const keep = new Set(G.nodes.filter((n) => SOURCE_TYPES.has(n.type)).map((n) => n.id))
137
+ const stack = [...keep]
138
+ while (stack.length) {
139
+ for (const from of inc.get(stack.pop()) ?? []) {
140
+ if (!keep.has(from)) {
141
+ keep.add(from)
142
+ stack.push(from)
143
+ }
144
+ }
145
+ }
146
+ const picked = subgraph(G, (n) => keep.has(n.id) && state.types.has(n.type))
147
+ // Reversed: the dependency graph points consumer → source, but data
148
+ // travels the other way.
149
+ const edges = picked.edges.map((e) => ({
150
+ from: e.to,
151
+ to: e.from,
152
+ kind: e.kind,
153
+ reversed: true,
154
+ }))
155
+ const depth = longestPath(picked.nodes, edges)
156
+ return {
157
+ nodes: picked.nodes,
158
+ edges,
159
+ col: (n) => depth.get(n.id),
160
+ title: (c) =>
161
+ ['Data origin', 'Read directly by', 'Surfaced by'][c] ?? `Surfaced by · ${c - 1}`,
162
+ }
163
+ },
164
+ },
165
+
166
+ modules: {
167
+ name: 'Modules',
168
+ hint: 'Folders and packages as single blocks. Line weight is how many references cross between them.',
169
+ // The module is the group, so grouping again would just repeat every label.
170
+ grouping: false,
171
+ project(G, state) {
172
+ const groups = new Map()
173
+ for (const n of G.nodes) {
174
+ if (!state.types.has(n.type)) continue
175
+ const k = groupKey(n)
176
+ if (!groups.has(k)) groups.set(k, [])
177
+ groups.get(k).push(n)
178
+ }
179
+
180
+ const nodes = []
181
+ const moduleOf = new Map()
182
+ for (const [key, members] of groups) {
183
+ const id = `module:${key}`
184
+ for (const m of members) moduleOf.set(m.id, id)
185
+ const kinds = tally(members.map((m) => m.type))
186
+ nodes.push({
187
+ id,
188
+ type: kinds[0][0],
189
+ label: key,
190
+ file: null,
191
+ meta: {
192
+ module: true,
193
+ group: key,
194
+ members: members.length,
195
+ kinds: kinds.map(([t, n]) => `${n} ${TYPES[t].label.toLowerCase()}`),
196
+ names: members.map((m) => m.label).sort(),
197
+ // A module's column is where its members mostly live, so the
198
+ // aggregate still reads left-to-right like the layer view.
199
+ col: Math.round(mean(members.map((m) => TYPES[m.type].col))),
200
+ },
201
+ })
202
+ }
203
+
204
+ const agg = new Map()
205
+ for (const e of G.edges) {
206
+ const from = moduleOf.get(e.from)
207
+ const to = moduleOf.get(e.to)
208
+ if (!from || !to || from === to) continue
209
+ const key = `${from}|${to}`
210
+ if (!agg.has(key)) agg.set(key, { from, to, kind: 'depends', weight: 0 })
211
+ agg.get(key).weight++
212
+ }
213
+
214
+ return {
215
+ nodes,
216
+ edges: [...agg.values()],
217
+ col: (n) => n.meta.col,
218
+ title: (c) => LAYER_TITLES[c],
219
+ }
220
+ },
221
+ },
222
+
223
+ sitemap: {
224
+ name: 'Sitemap',
225
+ hint: 'Every page and everything it pulls in, laid out from the routes. Whatever a page never reaches — dead code — drops away.',
226
+ project(G, state) {
227
+ // Walk forward from the routes; keep only what a page can actually reach.
228
+ const { out } = adjacency(G.edges)
229
+ const keep = new Set(G.nodes.filter((n) => n.type === 'route').map((n) => n.id))
230
+ const stack = [...keep]
231
+ while (stack.length) {
232
+ for (const to of out.get(stack.pop()) ?? []) {
233
+ if (!keep.has(to)) {
234
+ keep.add(to)
235
+ stack.push(to)
236
+ }
237
+ }
238
+ }
239
+ // Drop wraps/guards so routes are the true left-most spine, not layouts.
240
+ const { nodes, edges } = subgraph(
241
+ G,
242
+ (n) => keep.has(n.id) && state.types.has(n.type),
243
+ (e) => e.kind !== 'wraps' && e.kind !== 'guards',
244
+ )
245
+ const depth = longestPath(nodes, edges)
246
+ return {
247
+ nodes,
248
+ edges,
249
+ col: (n) => depth.get(n.id),
250
+ title: (c) => (c === 0 ? 'Pages' : c === 1 ? 'Renders & uses' : `Reached · ${c}`),
251
+ }
252
+ },
253
+ },
254
+
255
+ cleanup: {
256
+ name: 'Cleanup',
257
+ hint: 'Only what the findings flag — dead components, uncalled composables, empty collections, render cycles — gathered in one place.',
258
+ // Each flagged node stands on its own; grouping would just repeat kinds.
259
+ grouping: false,
260
+ project(G, state) {
261
+ // Only warnings — genuine remove-candidates. Info findings (a static
262
+ // route, a webhook with no caller) are normal, not cleanup.
263
+ const flagged = new Set(
264
+ (G.findings ?? []).filter((f) => f.severity === 'warn').flatMap((f) => f.nodes),
265
+ )
266
+ const { nodes, edges } = subgraph(G, (n) => flagged.has(n.id) && state.types.has(n.type))
267
+ return { nodes, edges, col: (n) => TYPES[n.type].col, title: (c) => LAYER_TITLES[c] }
268
+ },
269
+ },
270
+ }
271
+
272
+ function tally(items) {
273
+ const counts = new Map()
274
+ for (const it of items) counts.set(it, (counts.get(it) ?? 0) + 1)
275
+ return [...counts].sort((a, b) => b[1] - a[1])
276
+ }
277
+
278
+ const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length
279
+
280
+ /**
281
+ * Isolate one node: keep only what it reaches and what reaches it. This is the
282
+ * "who consumes what" question asked of a single entity.
283
+ */
284
+ export function applyFocus(proj, focusId) {
285
+ if (!focusId || !proj.nodes.some((n) => n.id === focusId)) return proj
286
+ const { out, inc } = adjacency(proj.edges)
287
+ const keep = new Set([focusId])
288
+ for (const dir of [out, inc]) {
289
+ const stack = [focusId]
290
+ while (stack.length) {
291
+ for (const next of dir.get(stack.pop()) ?? []) {
292
+ if (!keep.has(next)) {
293
+ keep.add(next)
294
+ stack.push(next)
295
+ }
296
+ }
297
+ }
298
+ }
299
+ return {
300
+ ...proj,
301
+ nodes: proj.nodes.filter((n) => keep.has(n.id)),
302
+ edges: proj.edges.filter((e) => keep.has(e.from) && keep.has(e.to)),
303
+ }
304
+ }