@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,147 @@
1
+ /**
2
+ * Findings are things worth telling someone about a repo that the diagram
3
+ * alone won't make them notice: dead code, endpoints with no handler, render
4
+ * cycles. Pure function over the serialized graph so the CLI, --json output and
5
+ * the HTML report all say exactly the same thing.
6
+ *
7
+ * The bar for a finding is that it is *actionable* and *true*. Anything the
8
+ * analyzer can't be sure about belongs in the notes, not here — a confidently
9
+ * wrong finding costs more trust than a missing one earns.
10
+ */
11
+ export function computeFindings(nodes, edges) {
12
+ const incoming = new Map()
13
+ const outgoing = new Map()
14
+ for (const e of edges) {
15
+ if (!incoming.has(e.to)) incoming.set(e.to, [])
16
+ incoming.get(e.to).push(e)
17
+ if (!outgoing.has(e.from)) outgoing.set(e.from, [])
18
+ outgoing.get(e.from).push(e)
19
+ }
20
+
21
+ const isLocal = (n) => n.meta.origin !== 'library' && !n.meta.pkg
22
+ const unreferenced = (n) => !incoming.get(n.id)?.length
23
+ const found = []
24
+ const add = (id, severity, nodeList, title, detail) => {
25
+ if (nodeList.length)
26
+ found.push({
27
+ id,
28
+ severity,
29
+ title: title(nodeList.length),
30
+ detail,
31
+ nodes: nodeList.map((n) => n.id),
32
+ })
33
+ }
34
+
35
+ add(
36
+ 'orphan-components',
37
+ 'warn',
38
+ nodes.filter((n) => n.type === 'component' && isLocal(n) && unreferenced(n)),
39
+ (n) => `${n} component${n === 1 ? '' : 's'} nothing renders`,
40
+ 'No page, layout, component or markdown file references these. Likely dead code.',
41
+ )
42
+
43
+ add(
44
+ 'unused-composables',
45
+ 'warn',
46
+ nodes.filter((n) => n.type === 'composable' && isLocal(n) && unreferenced(n)),
47
+ (n) => `${n} composable${n === 1 ? '' : 's'} nothing calls`,
48
+ 'Defined under composables/ but never called. The file may still be imported for its types, so check before deleting it.',
49
+ )
50
+
51
+ add(
52
+ 'missing-handlers',
53
+ 'warn',
54
+ nodes.filter((n) => n.type === 'server' && n.meta.inferred),
55
+ (n) => `${n} endpoint${n === 1 ? '' : 's'} with no handler file`,
56
+ 'Something fetches these paths but no matching file exists under server/. Either the path is wrong, or the route is served from elsewhere.',
57
+ )
58
+
59
+ add(
60
+ 'dead-server-routes',
61
+ 'info',
62
+ nodes.filter((n) => n.type === 'server' && !n.meta.inferred && unreferenced(n)),
63
+ (n) => `${n} server route${n === 1 ? '' : 's'} with no fetch call`,
64
+ 'No $fetch or useFetch in this app targets these. Normal for webhooks, cron targets, public APIs, and redirects reached by an href — this is not a dead-code claim.',
65
+ )
66
+
67
+ add(
68
+ 'empty-collections',
69
+ 'warn',
70
+ nodes.filter((n) => n.type === 'collection' && n.file && !n.meta.files && !n.meta.remote),
71
+ (n) => `${n} content collection${n === 1 ? '' : 's'} with no files`,
72
+ 'Declared in content.config.ts but its source glob matched nothing.',
73
+ )
74
+
75
+ add(
76
+ 'static-routes',
77
+ 'info',
78
+ nodes.filter(
79
+ (n) => n.type === 'route' && !(outgoing.get(n.id) ?? []).some((e) => DATA_KINDS.has(e.kind)),
80
+ ),
81
+ (n) => `${n} route${n === 1 ? '' : 's'} read no data`,
82
+ 'These pages query no collection, call no endpoint and read no config — fully static.',
83
+ )
84
+
85
+ for (const cycle of renderCycles(nodes, edges)) {
86
+ found.push({
87
+ id: `render-cycle:${cycle.join('>')}`,
88
+ severity: 'warn',
89
+ title: 'Render cycle',
90
+ detail: cycle.map((id) => id.split(':').pop()).join(' → '),
91
+ nodes: cycle,
92
+ })
93
+ }
94
+
95
+ const order = { warn: 0, info: 1 }
96
+ return found.sort(
97
+ (a, b) => order[a.severity] - order[b.severity] || b.nodes.length - a.nodes.length,
98
+ )
99
+ }
100
+
101
+ const DATA_KINDS = new Set(['queries', 'fetches', 'reads'])
102
+
103
+ /**
104
+ * Cycles in the render graph, via depth-first search for a back edge to a node
105
+ * still on the current path. A component that transitively renders itself is
106
+ * either intentional recursion or an infinite loop; either way it's worth
107
+ * knowing about.
108
+ */
109
+ function renderCycles(nodes, edges) {
110
+ const out = new Map()
111
+ for (const e of edges) {
112
+ if (e.kind !== 'renders') continue
113
+ if (!out.has(e.from)) out.set(e.from, [])
114
+ out.get(e.from).push(e.to)
115
+ }
116
+
117
+ const WHITE = 0
118
+ const GREY = 1
119
+ const BLACK = 2
120
+ const colour = new Map(nodes.map((n) => [n.id, WHITE]))
121
+ const path = []
122
+ const seen = new Set()
123
+ const cycles = []
124
+
125
+ const visit = (id) => {
126
+ colour.set(id, GREY)
127
+ path.push(id)
128
+ for (const next of out.get(id) ?? []) {
129
+ if (colour.get(next) === GREY) {
130
+ const cycle = path.slice(path.indexOf(next))
131
+ // Same cycle found from different entry points is one cycle.
132
+ const key = [...cycle].sort().join('|')
133
+ if (!seen.has(key)) {
134
+ seen.add(key)
135
+ cycles.push(cycle)
136
+ }
137
+ } else if (colour.get(next) === WHITE) {
138
+ visit(next)
139
+ }
140
+ }
141
+ path.pop()
142
+ colour.set(id, BLACK)
143
+ }
144
+
145
+ for (const n of nodes) if (colour.get(n.id) === WHITE) visit(n.id)
146
+ return cycles
147
+ }
@@ -0,0 +1,148 @@
1
+ import { computeFindings } from './findings.mjs'
2
+
3
+ /**
4
+ * The graph builder every adapter writes into, plus the finalize pipeline that
5
+ * turns a freshly-scanned graph into the serialized report shape. Nothing here
6
+ * knows about any framework: an adapter produces framework-specific nodes and
7
+ * edges in the canonical vocabulary (see schema.mjs), and this file does the
8
+ * universal post-processing — prune, group, findings, stats — that every
9
+ * adapter's output goes through identically.
10
+ */
11
+ export class Graph {
12
+ constructor() {
13
+ this.nodes = new Map()
14
+ this.edges = new Map()
15
+ }
16
+
17
+ node(id, data) {
18
+ if (!this.nodes.has(id)) this.nodes.set(id, { id, ...data, meta: data.meta ?? {} })
19
+ return this.nodes.get(id)
20
+ }
21
+
22
+ has(id) {
23
+ return this.nodes.has(id)
24
+ }
25
+
26
+ edge(from, to, kind) {
27
+ if (from === to) return
28
+ const key = `${from}|${to}|${kind}`
29
+ if (!this.edges.has(key)) this.edges.set(key, { from, to, kind })
30
+ }
31
+
32
+ serialize() {
33
+ return { nodes: [...this.nodes.values()], edges: [...this.edges.values()] }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Turn an adapter's result — `{ project, graph, warnings }` — into the report
39
+ * shape the CLI, `--json`, and the HTML renderer all consume. The order here is
40
+ * load-bearing: prune before grouping so a dropped node never seeds a group,
41
+ * and findings/stats run on the serialized form so they see exactly what ships.
42
+ */
43
+ export function finalize({ project, graph, warnings }) {
44
+ prune(graph)
45
+ assignGroups(graph)
46
+ const { nodes, edges } = graph.serialize()
47
+ return {
48
+ project: publicProject(project),
49
+ warnings,
50
+ nodes,
51
+ edges,
52
+ findings: computeFindings(nodes, edges),
53
+ stats: statsOf(nodes),
54
+ }
55
+ }
56
+
57
+ /**
58
+ * An adapter carries internals (absolute paths, raw config source) that have no
59
+ * business in a report someone may share. Only these fields ship — this is the
60
+ * canonical project shape every adapter is expected to fill.
61
+ */
62
+ function publicProject(project) {
63
+ return {
64
+ name: project.name,
65
+ description: project.description,
66
+ root: project.root,
67
+ modules: project.modules,
68
+ stack: project.stack,
69
+ }
70
+ }
71
+
72
+ function statsOf(nodes) {
73
+ const local = nodes.filter((n) => n.meta.loc)
74
+ return {
75
+ files: local.length,
76
+ loc: local.reduce((sum, n) => sum + n.meta.loc, 0),
77
+ contentFiles: nodes
78
+ .filter((n) => n.type === 'collection')
79
+ .reduce((sum, n) => sum + (n.meta.files ?? 0), 0),
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Every node gets an owner: the package it came from, or the directory it
85
+ * lives in. Directories already encode how the app is organised
86
+ * (components/hub, server/api/sync), so this is grouping the author wrote
87
+ * rather than grouping we invented.
88
+ */
89
+ function assignGroups(g) {
90
+ for (const node of g.nodes.values()) {
91
+ node.meta.group =
92
+ node.meta.pkg ?? SYNTHETIC_GROUP[node.type] ?? (node.file ? dirGroup(node.file) : node.type)
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Types whose home is conceptual rather than a directory. Collections are
98
+ * declared in content.config.ts, so grouping them by file would file them
99
+ * under the repo root alongside nothing else.
100
+ */
101
+ const SYNTHETIC_GROUP = {
102
+ collection: 'content',
103
+ config: 'config',
104
+ external: 'external services',
105
+ }
106
+
107
+ /**
108
+ * Directory of a file, minus dynamic segments, capped in depth — otherwise
109
+ * server/api/games/debate/[slug] becomes its own group of one and the grouping
110
+ * says nothing.
111
+ */
112
+ function dirGroup(file, depth = 3) {
113
+ const parts = file
114
+ .split('/')
115
+ .slice(0, -1)
116
+ .filter((p) => !p.startsWith('['))
117
+ return parts.slice(0, depth).join('/') || 'root'
118
+ }
119
+
120
+ /**
121
+ * Drop unconnected library nodes. A dependency exports far more than any one
122
+ * app uses (Rig ships ~30 composables), and listing the unused ones describes
123
+ * the dependency rather than this app. Unconnected *local* nodes stay — a
124
+ * component nothing renders is a finding, not noise.
125
+ */
126
+ function prune(g) {
127
+ const touched = new Set()
128
+ for (const e of g.edges.values()) {
129
+ touched.add(e.from)
130
+ touched.add(e.to)
131
+ }
132
+ for (const [id, node] of g.nodes) {
133
+ if (touched.has(id)) continue
134
+ const fromLibrary = node.meta.origin === 'library'
135
+ if (
136
+ node.type === 'lib-component' ||
137
+ node.type === 'external' ||
138
+ (node.type === 'composable' && fromLibrary)
139
+ ) {
140
+ g.nodes.delete(id)
141
+ }
142
+ }
143
+
144
+ // An edge to a pruned or never-created node would render as a dangling line.
145
+ for (const [key, e] of g.edges) {
146
+ if (!g.nodes.has(e.from) || !g.nodes.has(e.to)) g.edges.delete(key)
147
+ }
148
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * One layout engine for every view. It takes a projection (nodes, edges, and a
3
+ * rule for which column a node belongs to) and produces coordinates.
4
+ *
5
+ * Columns come from the view. Vertical order is barycenter: repeatedly place
6
+ * each node near the average position of its neighbours, which pulls connected
7
+ * things onto the same rows and takes most of the crossings out.
8
+ *
9
+ * Pure: no DOM. The browser client and the Node-side SVG renderer both import
10
+ * it, so the interactive diagram and a static export can never lay out
11
+ * differently.
12
+ */
13
+ import { adjacency, groupKey } from './views.mjs'
14
+
15
+ export const NODE_W = 190
16
+ export const NODE_H = 26
17
+ const ROW_GAP = 8
18
+ const ROW_H = NODE_H + ROW_GAP
19
+ const COL_GAP = 78
20
+ const GROUP_H = 20
21
+ const TOP = 44
22
+ const BARY_PASSES = 6
23
+
24
+ export function computeLayout(proj, state) {
25
+ const cols = new Map()
26
+ for (const n of proj.nodes) {
27
+ const c = proj.col(n) ?? 0
28
+ if (!cols.has(c)) cols.set(c, [])
29
+ cols.get(c).push(n)
30
+ }
31
+
32
+ const active = [...cols.keys()].sort((a, b) => a - b)
33
+ const { out, inc } = adjacency(proj.edges)
34
+
35
+ const order = new Map()
36
+ const reindex = () => {
37
+ for (const list of cols.values()) list.forEach((n, i) => order.set(n.id, i))
38
+ }
39
+ for (const c of active) cols.get(c).sort(byGroupThenLabel)
40
+ reindex()
41
+
42
+ for (let pass = 0; pass < BARY_PASSES; pass++) {
43
+ // Alternate which side we average against, so ordering information
44
+ // propagates both down and back up the columns.
45
+ const backward = pass % 2 === 1
46
+ for (const c of backward ? [...active].reverse() : active) {
47
+ const neighbours = backward ? out : inc
48
+ const list = cols.get(c)
49
+ const bary = new Map()
50
+ for (const n of list) {
51
+ const near = (neighbours.get(n.id) ?? [])
52
+ .map((id) => order.get(id))
53
+ .filter((v) => v !== undefined)
54
+ bary.set(
55
+ n.id,
56
+ near.length ? near.reduce((a, b) => a + b, 0) / near.length : order.get(n.id),
57
+ )
58
+ }
59
+ list.sort(state.group ? byGroupBarycenter(bary, groupMeans(list, bary)) : byBarycenter(bary))
60
+ reindex()
61
+ }
62
+ }
63
+
64
+ // Drop empty columns so a filtered or shallow view has no dead space.
65
+ const xs = new Map()
66
+ let x = 0
67
+ for (const c of active) {
68
+ xs.set(c, x)
69
+ x += NODE_W + COL_GAP
70
+ }
71
+
72
+ const pos = new Map()
73
+ const headers = []
74
+ let bottom = TOP
75
+ for (const c of active) {
76
+ let y = TOP
77
+ let current = null
78
+ for (const n of cols.get(c)) {
79
+ const key = groupKey(n)
80
+ if (state.group && key !== current) {
81
+ headers.push({ x: xs.get(c), y: y + GROUP_H - 7, text: key })
82
+ y += GROUP_H
83
+ current = key
84
+ }
85
+ pos.set(n.id, { x: xs.get(c), y })
86
+ y += ROW_H
87
+ }
88
+ bottom = Math.max(bottom, y)
89
+ }
90
+
91
+ return { cols, active, xs, pos, headers, width: Math.max(x - COL_GAP, NODE_W), height: bottom }
92
+ }
93
+
94
+ const byGroupThenLabel = (a, b) =>
95
+ groupKey(a).localeCompare(groupKey(b)) || a.label.localeCompare(b.label)
96
+ const byBarycenter = (bary) => (a, b) =>
97
+ bary.get(a.id) - bary.get(b.id) || a.label.localeCompare(b.label)
98
+
99
+ /** Average barycenter of each group, so a group can be placed as one block. */
100
+ function groupMeans(list, bary) {
101
+ const acc = new Map()
102
+ for (const n of list) {
103
+ const key = groupKey(n)
104
+ if (!acc.has(key)) acc.set(key, [])
105
+ acc.get(key).push(bary.get(n.id))
106
+ }
107
+ return new Map([...acc].map(([key, vs]) => [key, vs.reduce((a, b) => a + b, 0) / vs.length]))
108
+ }
109
+
110
+ /**
111
+ * With grouping on, a group is ordered as a block — placed at its members'
112
+ * average position — and members are ordered within it. Without this the
113
+ * barycenter pass would interleave groups and the headers would be a lie.
114
+ */
115
+ function byGroupBarycenter(bary, means) {
116
+ return (a, b) => {
117
+ const ga = groupKey(a)
118
+ const gb = groupKey(b)
119
+ if (ga !== gb) return means.get(ga) - means.get(gb) || ga.localeCompare(gb)
120
+ return bary.get(a.id) - bary.get(b.id) || a.label.localeCompare(b.label)
121
+ }
122
+ }
123
+
124
+ /**
125
+ * The cubic-bezier path for one edge between two laid-out nodes. Backwards or
126
+ * same-column edges bow out to the right rather than cutting back through the
127
+ * boxes between them. Shared by the interactive client and the SVG export.
128
+ */
129
+ export function edgePath(e, layout) {
130
+ const a = layout.pos.get(e.from)
131
+ const b = layout.pos.get(e.to)
132
+ const y1 = a.y + NODE_H / 2
133
+ const y2 = b.y + NODE_H / 2
134
+ if (b.x <= a.x) {
135
+ const bulge = a.x + NODE_W + 26
136
+ return `M${a.x + NODE_W},${y1} C${bulge},${y1} ${bulge},${y2} ${b.x + NODE_W},${y2}`
137
+ }
138
+ const x1 = a.x + NODE_W
139
+ const mid = (x1 + b.x) / 2
140
+ return `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${b.x},${y2}`
141
+ }
@@ -0,0 +1,92 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ /**
4
+ * Assembles the single-file HTML report. The client lives as ordinary files
5
+ * rather than template strings, and is inlined here — the output has to work
6
+ * over file:// with no server and no network.
7
+ *
8
+ * `views.mjs` and `layout.mjs` are real ESM modules, imported unchanged by the
9
+ * Node-side SVG renderer so the interactive diagram and a static export share
10
+ * one layout engine. For the browser they're inlined into one classic script:
11
+ * their `import`/`export` keywords are stripped and they run in a shared scope,
12
+ * which is exactly the environment they were written for.
13
+ */
14
+ const read = (name) => readFileSync(new URL(`./${name}`, import.meta.url), 'utf8')
15
+
16
+ // Turn a shared ESM module into a fragment safe to concatenate into the one
17
+ // classic <script>: drop imports (the dependency is inlined just above) and the
18
+ // `export` keyword (the declaration stays, now a plain top-level binding).
19
+ const inlineModule = (name) => {
20
+ const stripped = read(name)
21
+ .replace(/^\s*import[^\n]*\n/gm, '')
22
+ .replace(/^export\s+(?=(?:const|function|class|let|var)\b)/gm, '')
23
+ // The strip only handles single-line imports and `export <decl>`. A surviving
24
+ // module keyword (a multi-line import, an `export {…}` re-export, `export
25
+ // default`) would silently produce a broken browser bundle — fail loudly.
26
+ const leftover = stripped.match(/^\s*(?:import|export)\b/m)
27
+ if (leftover) {
28
+ throw new Error(`inlineModule(${name}): unstripped module statement "${leftover[0].trim()}"`)
29
+ }
30
+ return stripped
31
+ }
32
+
33
+ // Order matters: views defines TYPES/adjacency, layout imports them, then the
34
+ // browser-only scripts (overview, app) drive the page off those globals.
35
+ const CLIENT = [
36
+ inlineModule('views.mjs'),
37
+ inlineModule('layout.mjs'),
38
+ read('client/overview.js'),
39
+ read('client/app.js'),
40
+ ]
41
+
42
+ export function renderHtml({ project, nodes, edges, warnings, findings, stats }) {
43
+ const payload = JSON.stringify({ project, nodes, edges, warnings, findings, stats })
44
+ // Keep the JSON from terminating the <script> that carries it.
45
+ .replace(/</g, '\\u003c')
46
+
47
+ return `<!doctype html>
48
+ <html lang="en">
49
+ <head>
50
+ <meta charset="utf-8">
51
+ <meta name="viewport" content="width=device-width, initial-scale=1">
52
+ <title>${escapeHtml(project.name)} — app graph</title>
53
+ <style>${read('client/styles.css')}</style>
54
+ </head>
55
+ <body>
56
+ <header id="bar">
57
+ <div id="title">
58
+ <strong>${escapeHtml(project.name)}</strong>
59
+ <span id="subtitle">${escapeHtml(project.description || (project.stack?.nuxt ? 'Nuxt application' : 'Vue application'))}</span>
60
+ </div>
61
+ <div id="views" role="tablist"></div>
62
+ <div id="tools">
63
+ <label id="group-label"><input type="checkbox" id="group" checked> Group</label>
64
+ <input id="search" type="search" placeholder="Search…" autocomplete="off" spellcheck="false">
65
+ <button id="reset" type="button">Reset</button>
66
+ </div>
67
+ </header>
68
+ <div id="chips-row">
69
+ <div id="chips"></div>
70
+ <div id="focus-note" hidden>Isolated: <span></span><button id="focus-clear" type="button" title="Clear (Esc)">✕</button></div>
71
+ </div>
72
+ <main>
73
+ <div id="canvas">
74
+ <svg id="svg"><g id="viewport"><g id="links"></g><g id="cols"></g><g id="nodes"></g></g></svg>
75
+ <div id="warnings"></div>
76
+ <div id="hint"></div>
77
+ </div>
78
+ <aside id="panel"><div id="panel-body" class="overview"></div></aside>
79
+ </main>
80
+ <script id="data" type="application/json">${payload}</script>
81
+ <script>${CLIENT.join('\n')}</script>
82
+ </body>
83
+ </html>
84
+ `
85
+ }
86
+
87
+ function escapeHtml(s) {
88
+ return String(s).replace(
89
+ /[&<>"']/g,
90
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c],
91
+ )
92
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The canonical graph vocabulary — the contract between adapters and the core.
3
+ *
4
+ * An adapter's whole job is to read a framework's conventions and emit nodes
5
+ * and edges in *these* kinds. The core (findings, layout, render, client) is
6
+ * written against this vocabulary and nothing else, so a new framework becomes
7
+ * a new adapter rather than a change to everything downstream.
8
+ *
9
+ * The kinds below were derived from Nuxt but named for what they are in any web
10
+ * app, so other adapters map onto them: a SvelteKit `+page.svelte` is a `route`,
11
+ * a `.svelte` file a `component`, a `load` function or store a `composable`, a
12
+ * `+server.ts` an `endpoint` (`server`), and so on.
13
+ */
14
+
15
+ /** Bumped when the shape of a serialized graph changes in a breaking way. */
16
+ export const SCHEMA_VERSION = 1
17
+
18
+ /** The canonical node kinds and their human labels. */
19
+ export const NODE_KINDS = {
20
+ entry: { label: 'Entry' },
21
+ layout: { label: 'Layout' },
22
+ middleware: { label: 'Middleware' },
23
+ plugin: { label: 'Plugin' },
24
+ route: { label: 'Route' },
25
+ component: { label: 'Component' },
26
+ 'lib-component': { label: 'Library component' },
27
+ composable: { label: 'Composable' },
28
+ store: { label: 'Store' },
29
+ collection: { label: 'Content collection' },
30
+ server: { label: 'Server route' },
31
+ external: { label: 'External service' },
32
+ config: { label: 'Config' },
33
+ }
34
+
35
+ /** The canonical edge kinds. (The verb each reads as lives in the client's EDGE_VERB.) */
36
+ export const EDGE_KINDS = {
37
+ renders: 'renders',
38
+ wraps: 'wraps',
39
+ guards: 'guards',
40
+ uses: 'uses',
41
+ queries: 'queries',
42
+ fetches: 'fetches',
43
+ reads: 'reads',
44
+ }
45
+
46
+ /**
47
+ * Assert an adapter produced a well-formed graph before the core touches it.
48
+ * Cheap structural checks only — every claim here is something a broken adapter
49
+ * would get wrong, and catching it at the seam beats a confusing render later.
50
+ * Returns the list of problems ([] when the graph conforms).
51
+ */
52
+ export function validateGraph({ nodes, edges }) {
53
+ const problems = []
54
+ const ids = new Set()
55
+ for (const n of nodes ?? []) {
56
+ if (!n.id) problems.push(`node without an id: ${JSON.stringify(n)}`)
57
+ else if (ids.has(n.id)) problems.push(`duplicate node id: ${n.id}`)
58
+ else ids.add(n.id)
59
+ if (!NODE_KINDS[n.type]) problems.push(`unknown node kind "${n.type}" on ${n.id}`)
60
+ }
61
+ for (const e of edges ?? []) {
62
+ if (!EDGE_KINDS[e.kind]) problems.push(`unknown edge kind "${e.kind}" (${e.from} → ${e.to})`)
63
+ if (!ids.has(e.from)) problems.push(`edge from missing node: ${e.from}`)
64
+ if (!ids.has(e.to)) problems.push(`edge to missing node: ${e.to}`)
65
+ }
66
+ return problems
67
+ }