OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 8d48a93

Nodes view + the expression floor
Two bodies of work, both previously uncommitted. ## The Nodes view (written 2026-09-18) A Geometry-Nodes-shaped view of the scan: a germ is a node, an import an input socket, an export an output socket, a mating a noodle, and an unmated socket visibly empty. lib/sections.js is the model underneath — a dependency edge is not primitive, it is the MATING of one germ's -name with another germ's +name, which is what a module resolver does. The payoff is the connectors that DON'T mate: dead exports, unresolved imports, leaked and unused deps. - lib/sections.js connector/mating model + misfits - lib/render-nodes.js path/module/file levels, derived group interface, semantic frames, pan/zoom - /api/sections served on demand; the embedded tree JSON is already large - codetree disjuncts one line per germ: its connector set, diffable - codetree misfits dead exports, unresolved imports, leaked deps - scanner moduleCategories resolved ONCE, so the CLI and both views cannot drift apart Fixes swept in with it: - escapeHtml was declared twice in the emitted page and the weaker copy won for the whole script (a later declaration silently replaces an earlier one) - the embedded tree JSON could close its own <script> tag and truncate the app - didDrag was declared per-node, so the mousemove handler registered after the loop assigned an implicit global instead, and releasing a drag also fired the node's click action - a multi-line `import {` produced NO edge at all: lang.js's regex cannot cross newlines, so specifiers now come from the AST whenever the file parsed - a re-export is both a demand AND a supply; recording only the export made every file behind a barrel look unreachable - endLine on classes/functions/methods; devDependencies carried on the tree - dynamic `await import()` recorded, with its destructured names ## The expression floor (2026-09-20) docs/EXPRESSION-FLOOR.md specifies the rung below a symbol, where statements and expressions become the graph and text stops being text. The cut that matters: an operation is a node, a value is a wire, a declaration NAMES a wire, control flow is a region. Treating every AST node as a node gives 23,164 nodes on this repo; these rules give 4,934. lib/derive.js implements it and `codetree derive <file>` prints the model, so the decomposition is verifiable before any renderer exists. - a conditional has three shapes — guard / single-branch / two-exit — because a guard clause genuinely has different control flow from an if/else - an else-if ladder is ONE multi-band zone, which is also why switch needs no separate treatment - a zone's outputs are its phis: identical machinery to a loop's carried state - every exit names the enclosing boundary it lands on, with no case left over Display contract adopted from codopticon (bitbucket.org/Bananaft/codopticon, read at d6802e7 — technique only, that repo ships no LICENSE): position comes from the source, so there is no layout step, and viewport culling replaces model-level LOD. ## Tests 14 new tests, 236 asserts, green. The suite's 15 pre-existing failures (parser 8, traverse 2, tree 5) are expectations pinned to the LIVE sibling ../hyper-orgs, which moved 0.2.0 -> 0.2.2 and dropped _room — a violation of this repo's own invariant I1. `npm test` dies on parser.test.js and masks every file after it, so run per-file until those are decoupled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8d48a93f57e501d74001ad8e2f1737b83d5de422
by plankatron <fleeky@gmail.com> · 2026-09-20T21:11:28.000Z

Changed files (25) +3101 -23

docs/EXPRESSION-FLOOR.md +296 -0
……@@ -0,0 +1,296 @@
1# The Expression Floor
2
3How a file becomes nodes.
4
5The node view already climbs `system -> directory -> module -> file -> symbol`,
6and its own contract is that a node at one level opens into a whole graph at the
7next. The symbol level breaks that contract: a symbol node is marked
8un-openable and renders its body as text. This document specifies the rung
9below it, where statements and expressions become the graph, and the text stops
10being text.
11
12Every rule here is derived from the scan. Nothing is authored.
13
14---
15
16## 1. What is and is not a node
17
18The rung below a symbol is a **dataflow graph in SSA form**: operations, named
19values, control regions, and a convergence point where a region's writes rejoin.
20This is not a new decomposition — it is the one compilers settled on — which is
21why the Blender analogy holds at all: Geometry Nodes *is* a pure dataflow graph.
22
23### R1. An operation is a node
24
25A call, an arithmetic or logical operation, an assignment, a construction, a
26return, a throw, a spread. Anything that computes or acts.
27
28### R2. A value is a wire, never a node
29
30An identifier reference is one END of a noodle, drawn from wherever the value
31was defined. A literal renders INSIDE its socket, the way Blender puts a number
32in the socket rather than spawning a Value node.
33
34`x + 1` is ONE node — an add, with one wired input and one literal input. It is
35not three nodes. Getting this wrong is the difference between a readable graph
36and an unreadable one: measured on this repo, treating every AST node as a node
37gives 23,164 nodes where the rules here give 4,934.
38
39### R3. A declaration names a wire
40
41`const rows = buildRows(x)` is the call node with its output labelled `rows`.
42Local variables are not nodes; they are noodle labels. This is the decision that
43makes dataflow legible downstream, because a wire with a name is one you can
44recognise at its use site.
45
46A reassignment (`rows = ...`) is a new VERSION of that wire, which is precisely
47SSA's phi, and precisely a loop's carried state. One mechanism, three names.
48
49### R4. Control flow is a region, not a node
50
51A region (a "zone", after Blender's Repeat/Foreach/Simulation zones, which we
52already implement in proc-nodes as `ProcZoneInfo`) has an entry, a condition
53input where applicable, a body, typed exits, and carried state. Loops, `if`,
54`switch` and `try` are all regions. See section 2.
55
56### R5. Collapse rules
57
58These fold into the consuming node instead of becoming nodes of their own:
59
60| folded | into |
61|---|---|
62| a property path (`foo.bar.baz`) | the consuming node's socket label |
63| `await`, parentheses, type casts | the node they wrap |
64| template literal glue | one node for the whole template |
65| a literal | the socket it feeds |
66
67Collapsing property paths alone removes 622 nodes on this repo (3,637 member
68expressions reduce to 3,015 chain roots, and callee chains fold further).
69
70### R6. A function's boundary is the group interface we already have
71
72Parameters are Group Input. Returns are Group Output. This is the same
73derivation `render-nodes.js` already performs for files and modules, one level
74down — which is the strongest evidence that R1-R5 are the right cut.
75
76### R7. Identity is derived, never stored
77
78A node's id is `<file>:<startByte>-<endByte>`, computed per render from the
79tree. Nothing is persisted, so there is nothing to churn when an edit shifts
80every offset below it.
81
82---
83
84## 2. Zones
85
86### Z1. A conditional has three shapes, not one
87
88Drawing all conditionals identically would be the dishonest option, because a
89guard clause genuinely has different control flow from an `if`/`else`.
90
91**Guard** — no `else`, body is a single jump. Nothing converges: control leaves
92the region. Rendered as a margin chevron plus an exit rail to wherever it lands,
93NOT as a box. Internally it is still a zone, so the exit taxonomy stays uniform;
94only its rendering differs.
95
96**Single-branch** — one conditional path and a fall-through. A tint band over
97its span. It has output sockets only for wires written inside and read after; if
98nothing escapes, it has none.
99
100**Two-exit** — `if`/`else`. A tint band, a divider at the `else` line, and
101convergence sockets at the closing line. An `else if` ladder collapses into ONE
102multi-band zone rather than N nested zones, which is also why `switch` needs no
103separate treatment.
104
105### Z2. A loop is a zone whose region repeats
106
107| loop | zone type |
108|---|---|
109| counted `for`, `while`, `do..while` | repeat |
110| `for..of`, `for..in`, range-for | foreach |
111| state carried across invocations (`_process`, a request loop) | simulation |
112
113### Z3. Convergence is carried state
114
115A zone's outputs are its phis: any wire written on a path through it and read
116after it. Identical machinery for `if`, `else if`, `switch`, `try` and every
117loop. The only differences between regions are HOW MANY EXITS they have and
118WHETHER THEY REPEAT.
119
120### Z4. Every exit lands on an enclosing boundary
121
122An exit is typed by where it lands, and every landing site is already a socket
123on some enclosing region:
124
125| exit | lands on |
126|---|---|
127| `return` | the function's Group Output |
128| `continue` | the enclosing loop zone's entry |
129| `break` | the enclosing loop zone's exit |
130| `throw` | the nearest enclosing `try` zone's catch entry, else the function's error output |
131
132No case is left over. `throw` is the last to build, not the first: this repo
133contains zero `throw` statements, so its 81 `try` zones are catching from
134callees.
135
136### Z5. Three departures from Blender, stated so they are not surprises
137
1381. **Mutation.** 331 assignments here. A reassignment is a new wire version.
1392. **Side-effect order.** A pure dataflow graph loses statement order. Impure
140 nodes carry a faint effect wire threading them in source order, or the graph
141 lies about what runs when. Blender has no analogue; we need one anyway.
1423. **Multi-exit.** 418 returns plus break/continue/throw. A Blender zone has one
143 output path. Ours do not.
144
145---
146
147## 3. Rendering contract
148
149Adopted from codopticon (Bananaft, bitbucket.org/Bananaft/codopticon), read
1502026-09-20 at `d6802e7`. We take the display technique, not the source: that
151repo ships no LICENSE.
152
153### V1. Position comes from the source; there is no layout step
154
155A node IS its byte range — a rectangle in the text grid. Nesting is containment,
156free, because AST ranges nest. This is what makes the floor reachable: the
157layout problem disappears instead of being solved.
158
159Measured: 0.41 operations per line, 1,602 lines carrying exactly one, and of
16012,176 lines only 25 carry more than six (max 10). In-place placement
161essentially never collides.
162
163### V2. Viewport culling replaces model-level LOD
164
165Because positions are source positions, "what do I build" is a line-range test
166against the camera. The 4,994 nodes are never materialised at once; the visible
167lines are. This only works because of V1.
168
169### V3. Detail bands
170
171| band | drawn |
172|---|---|
173| far | file ink + syntax texture, module labels |
174| mid | margin labels — symbol names, one glyph per zone |
175| near | zone tints over their line spans, nesting as nested tint |
176| nearest | operation boxes on their spans, wires between tokens, literals in sockets |
177
178Every node kind therefore needs a one-glyph form as well as a full form.
179codopticon's `L` for a loop is the pattern; ours carries the zone's derived
180facts (trip count, carried state) instead of nothing.
181
182### V4. The gutters are wire canvas
183
184Blank cells are not drawn, so indentation columns are empty space: vertical wire
185runs belong there, and the ragged right margin carries the effect chain and the
186carried-state rails. This is what keeps in-place wiring from becoming spaghetti
187over text.
188
189### V5. The per-character channel carries semantics
190
191The renderer samples a palette index per cell. Same channel, different content:
192zone depth, def/use role, unmated-connector flag. Semantics at every zoom, free.
193
194### V6. Tint budget: four levels
195
196A 4-level ramp covers 96.5% of zones (1,085 of 1,124). The 39 deeper ones take
197their depth from the margin rail, so nesting never becomes mud. Safe because
198only 31.8% of lines sit inside any zone, with a median span of 3 lines.
199
200### V7. Declutter by displacement
201
202A label that would collide with the previous one is pushed off its true line,
203trading positional accuracy for legibility, exactly as map labels do. This is
204what handles the 25 crowded lines.
205
206### V8. A condition renders collapsed by default
207
208The condition is an expression, so its operations sit on the zone's header line.
209`if (!x)` reads fine; a compound condition would put 3-4 boxes on that line. It
210renders as its source text until expanded on demand. This is the one place the
211in-place rule gets crowded, and the only place we hide structure by default.
212
213---
214
215## 4. Measurements
216
217Every number in this document was produced on 2026-09-20 by parsing this repo's
218own source with the vendored acorn — 25 files, 12,176 lines, excluding `test/`,
219`vendor/` and `node_modules/`.
220
221```
222operation nodes 4,934 wires (refs/literals/members) 18,516
223zones 1,124 named wires (declarations) 1,120
224functions 328 operations per function 15.2
225 operations per line 0.41
226
227ops call 1794 · binary 847 · logical 446 · return 418 · object 344
228 assign 331 · unary 254 · array 233 · conditional 120 · new 67
229 template 50 · update 30
230 (await 23 and spread 37 were counted as operations before R5 folded
231 them into the node they wrap; 4,994 - 60 = 4,934)
232
233zones if 745 · for..of 231 · try 81 · do..while 30 · while 18 · for 10
234 switch 9
235 of the 745 ifs: 295 guards, 74 with else (45 of them ladders),
236 189 with multi-statement bodies
237 443 of 1,124 zones span one line or less
238 depth 449@1 343@2 205@3 88@4, 39 deeper, max 8
239 31.8% of lines are inside some zone; median span 3, p90 15, max 100
240
241exits return 418 · continue 136 · break 30 · throw 0
242```
243
244**These ratios are style-specific.** This codebase is guard-heavy with almost no
245`else`. C++, or GDScript with `match`, will skew differently. The three
246conditional shapes are the specification; the percentages only tell us which
247shape to build first (guards, at 40%).
248
249---
250
251## 5. Language coverage
252
253The derivation is per-language, and all four parsers keep a full tree, so all
254four can reach the floor. Verified node names:
255
256| construct | JS (acorn) | GDScript (lezer) | C++ (lezer-cpp) | GLSL |
257|---|---|---|---|---|
258| counted `for` | `ForStatement` | — | `ForStatement` | `for_statement` |
259| collection `for` | `ForOfStatement` | `ForNode` | `ForRangeLoop` | — |
260| key `for..in` | `ForInStatement` | — | — | — |
261| `while` | `WhileStatement` | `WhileNode` | `WhileStatement` | `while_statement` |
262| `do..while` | `DoWhileStatement` | — | `DoStatement` | `do_statement` |
263| labelled exit | `LabeledStatement` + labelled jump | unlabelled only | jumps + `goto` | `break`/`continue` |
264
265JS is nearly free: its parser already walks every node. GDScript and C++ visit
266only top-level declarations today and need a body descent. GDScript has exactly
267two loop forms and is the cheapest to add.
268
269---
270
271## 6. Invariants
272
273**E1. The derivation is pure.** Same source in, same model out. No clock, no
274filesystem, no network, no scan state.
275
276**E2. Every node, zone and exit carries a byte range and a line/column range.**
277V1 depends on it; a model entry without a position is a bug.
278
279**E3. No orphan exits.** Every exit names a landing site that exists in the same
280model (Z4). An exit whose target cannot be resolved is reported as such, never
281dropped.
282
283**E4. Zone nesting is a tree.** `parent` is either null or a zone whose range
284strictly contains this one.
285
286**E5. Wires resolve or are marked.** A reference either resolves to a
287declaration in scope, or is recorded as `free` (a global, an import binding, a
288builtin). It is never silently discarded.
289
290**E6. The derivation never throws on real code.** A parse failure returns an
291error entry; it does not take the process down. (`npm test` currently dies on
292`parser.test.js` for exactly this class of reason.)
293
294Acceptance: `codetree derive <file>` on any file in this repo prints a model
295satisfying E1-E6, and `test/derive.test.js` asserts them against a fixture whose
296ground truth is written into the fixture itself.
index.js +173 -0
……@@ -1,6 +1,7 @@
11#!/usr/bin/env bare
22
33import process from 'process'
4import fs from 'fs'
45import { resolve } from 'path'
56import { scan } from './lib/scanner.js'
67
……@@ -111,6 +112,9 @@ async function main () {
111112 case 'imports': return cmdImports()
112113 case 'docs': return cmdDocs()
113114 case 'categories': return cmdCategories()
115 case 'disjuncts': return cmdDisjuncts()
116 case 'misfits': return cmdMisfits()
117 case 'derive': return cmdDerive()
114118 case 'summary': return cmdSummary()
115119 case 'serve': return cmdServe()
116120 default:
……@@ -522,6 +526,171 @@ async function cmdCategories () {
522526 }
523527}
524528
529// One line per germ: its connector set. Diffable — a commit that changes a
530// disjunct changed the architecture; one that doesn't, didn't.
531async function cmdDisjuncts () {
532 const { buildSections } = await import('./lib/sections.js')
533 const tree = await scan(dir, opts)
534 const index = buildSections(tree)
535
536 const wantScale = args.includes('--scale') ? args[args.indexOf('--scale') + 1] : 'file'
537 const sections = index.sections.filter(s => s.scale === wantScale)
538
539 if (format === 'json') {
540 output(sections.map(s => ({ id: s.id, germ: s.germ, disjunct: index.disjunct(s) })))
541 return
542 }
543 for (const s of sections) console.log(index.disjunct(s))
544}
545
546// The connectors that found no partner. This is the whole point of the model:
547// a dead export and a broken import are the same question asked twice.
548async function cmdMisfits () {
549 const { buildSections } = await import('./lib/sections.js')
550 const tree = await scan(dir, opts)
551 const index = buildSections(tree)
552 const m = index.misfits()
553
554 if (format === 'json') {
555 output({
556 dead: m.dead.map(d => ({ germ: d.germ, type: d.type })),
557 unresolved: m.unresolved.map(u => ({ germ: u.germ, type: u.type, specifier: u.specifier })),
558 leak: m.leak,
559 unused: m.unused
560 })
561 return
562 }
563
564 console.log('DEAD exports (' + m.dead.length + ') — exported, nothing imports it')
565 for (const d of m.dead) console.log(' ' + d.germ + ' +' + d.type)
566
567 console.log('\nUNRESOLVED imports (' + m.unresolved.length + ') — imported, nothing supplies it')
568 for (const u of m.unresolved) {
569 console.log(' ' + u.germ + ' -' + u.type + ' (' + u.specifier + ')')
570 }
571
572 console.log('\nLEAKED deps (' + m.leak.length + ') — used in code, absent from package.json')
573 for (const l of m.leak) console.log(' ' + l.module + ' ' + l.specifier + ' (' + l.germ + ')')
574
575 console.log('\nUNUSED deps (' + m.unused.length + ') — in package.json, never imported')
576 for (const u of m.unused) console.log(' ' + u.module + ' ' + u.specifier)
577
578 const total = m.dead.length + m.unresolved.length + m.leak.length + m.unused.length
579 console.log('\n' + index.sections.length + ' sections, ' + total + ' misfits')
580}
581
582// The expression floor: one file's operations, wires, zones and exits.
583// Spec: docs/EXPRESSION-FLOOR.md. This exists so the MODEL is verifiable before
584// any renderer does — if the decomposition is wrong, it is wrong here first.
585async function cmdDerive () {
586 const { deriveFile } = await import('./lib/derive.js')
587
588 const target = positionalArgs[0]
589 if (!target) {
590 console.error('derive needs a file: codetree derive <file.js>')
591 process.exit(1)
592 }
593 const path = resolve(target)
594
595 let source
596 try {
597 source = fs.readFileSync(path, 'utf-8')
598 } catch (err) {
599 console.error('cannot read ' + path + ': ' + (err.message || err))
600 process.exit(1)
601 }
602
603 const model = deriveFile(source, path)
604
605 if (format === 'json') {
606 output(model)
607 return
608 }
609
610 if (model.error) {
611 console.log(path + ' — PARSE ERROR: ' + model.error)
612 process.exit(1)
613 }
614
615 const want = (name) => {
616 const flags = ['--nodes', '--zones', '--exits', '--names', '--functions']
617 const asked = flags.filter(f => args.includes(f))
618 return asked.length === 0 || asked.includes('--' + name)
619 }
620
621 const rel = path.split('/').slice(-2).join('/')
622 console.log(rel + ' — ' + model.nodes.length + ' nodes · ' + model.zones.length +
623 ' zones · ' + model.exits.length + ' exits · ' + model.names.length +
624 ' names · ' + model.functions.length + ' functions')
625
626 const zoneById = new Map(model.zones.map(z => [z.id, z]))
627 const line = (r) => String(r.startLine).padStart(4)
628 const clip = (text, n) => {
629 const flat = text.replace(/\s+/g, ' ')
630 return flat.length > n ? flat.slice(0, n - 1) + '\u2026' : flat
631 }
632 const landing = (l) => {
633 if (!l) return '?'
634 if (l.on === 'zone-entry' || l.on === 'zone-exit' || l.on === 'catch-entry') {
635 const z = zoneById.get(l.id)
636 return l.on + ' ' + (z ? z.shape + '@' + z.range.startLine : '?')
637 }
638 return l.on
639 }
640
641 if (want('functions') && model.functions.length > 0) {
642 console.log('\nFUNCTIONS')
643 for (const fn of model.functions) {
644 console.log(' ' + line(fn.range) + ' ' + fn.name + '(' + fn.params.join(', ') + ')' +
645 (fn.async ? ' async' : '') + ' lines ' + fn.range.startLine + '-' + fn.range.endLine)
646 }
647 }
648
649 if (want('zones') && model.zones.length > 0) {
650 console.log('\nZONES')
651 for (const z of model.zones) {
652 const indent = ' '.repeat(z.depth)
653 const bits = []
654 if (z.item) bits.push('item=' + z.item)
655 if (z.carried.length > 0) bits.push('carried=' + z.carried.join(','))
656 if (z.bands) bits.push('bands=' + z.bands.length)
657 if (z.exits.length > 0) bits.push('exits=' + z.exits.length)
658 console.log(' ' + line(z.range) + ' ' + indent + z.shape.padEnd(13) +
659 (z.condition ? clip(z.condition.text, 34) : '').padEnd(35) +
660 'L' + z.range.startLine + '-' + z.range.endLine +
661 (bits.length > 0 ? ' ' + bits.join(' ') : ''))
662 }
663 }
664
665 if (want('names') && model.names.length > 0) {
666 console.log('\nNAMED WIRES')
667 for (const n of model.names) {
668 console.log(' ' + line(n.range) + ' ' + n.name.padEnd(22) + 'from ' + n.from)
669 }
670 }
671
672 if (want('nodes') && model.nodes.length > 0) {
673 console.log('\nOPERATIONS')
674 for (const n of model.nodes) {
675 const ins = n.inputs.map(i => {
676 if (i.literal) return i.name
677 if (i.source === 'node') return '<' + i.name + '>'
678 return i.name + '[' + i.source + ']'
679 })
680 console.log(' ' + line(n.range) + ' ' + n.label.padEnd(20) +
681 (n.writes ? '-> ' + n.writes + ' ' : '') +
682 (ins.length > 0 ? '<- ' + clip(ins.join(', '), 70) : ''))
683 }
684 }
685
686 if (want('exits') && model.exits.length > 0) {
687 console.log('\nEXITS')
688 for (const e of model.exits) {
689 console.log(' ' + line(e.range) + ' ' + e.kind.padEnd(10) + '-> ' + landing(e.lands))
690 }
691 }
692}
693
525694async function cmdSummary () {
526695 const tree = await scan(dir, opts)
527696
……@@ -581,6 +750,9 @@ Usage:
581750 codetree imports <module> [dir] Show internal import graph and sub-modules
582751 codetree docs [dir] Documentation cruft report
583752 codetree categories [dir] Show module categorization (SYSTEM.md + auto)
753 codetree disjuncts [dir] One line per germ: its connector set
754 codetree misfits [dir] Dead exports, unresolved imports, leaked deps
755 codetree derive <file> Expression floor: operations, wires, zones, exits
584756 codetree summary [dir] Human-readable overview
585757 codetree serve [dir] [port] Start HTTP visualization
586758
……@@ -618,6 +790,7 @@ Examples:
618790 codetree exports hyper-orgs -p Show hyper-orgs public API
619791 codetree imports pear-git -p Show pear-git internal architecture
620792 codetree docs ../pear-git -p Find stale documentation
793 codetree derive lib/derive.js Show one file's expression-floor model
621794 codetree serve . 9078 Serve visualization on port 9078`)
622795}
623796
lib/derive.js +611 -0
……@@ -0,0 +1,611 @@
1/**
2 * The expression floor — one file becomes operations, wires, zones and exits.
3 *
4 * Specified in docs/EXPRESSION-FLOOR.md. The short version:
5 *
6 * an operation -> a node (R1)
7 * a value -> a wire endpoint (R2) — never a node
8 * a declaration -> a wire's NAME (R3) — never a node
9 * control flow -> a zone (region) (R4) — never a node
10 * a property path -> a socket label (R5)
11 *
12 * So `x + 1` is ONE node with one wired input and one literal input, not three.
13 * That single distinction is the difference between 23,164 nodes and ~4,900 on
14 * this repo, which is the difference between a readable graph and a wall.
15 *
16 * Everything carries a byte range, because the renderer positions nodes AT their
17 * source location rather than laying them out (spec V1). Ids are derived from
18 * that range and never stored (R7).
19 *
20 * JS only for now. GDScript, C++ and GLSL all keep a full tree and can reach the
21 * floor the same way; they visit only top-level declarations today.
22 */
23
24import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs'
25
26const PARSE_OPTS = {
27 ecmaVersion: 'latest',
28 sourceType: 'module',
29 locations: true,
30 ranges: true,
31 allowHashBang: true
32}
33
34// R1 — these compute or act, so they are nodes.
35//
36// Note what is NOT here: `await` and `...spread` are MARKERS on the thing they
37// wrap (R5), not nodes of their own, so `await f()` is one call node flagged
38// awaited rather than two nodes in a chain.
39const OPS = new Set([
40 'CallExpression', 'NewExpression', 'BinaryExpression', 'LogicalExpression',
41 'UnaryExpression', 'UpdateExpression', 'AssignmentExpression',
42 'ConditionalExpression', 'ObjectExpression', 'ArrayExpression',
43 'TemplateLiteral', 'TaggedTemplateExpression', 'ReturnStatement',
44 'ThrowStatement', 'YieldExpression'
45])
46
47const LOOPS = new Set([
48 'ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement',
49 'DoWhileStatement'
50])
51
52const ZONES = new Set([...LOOPS, 'IfStatement', 'SwitchStatement', 'TryStatement'])
53
54const JUMPS = new Set([
55 'ReturnStatement', 'BreakStatement', 'ContinueStatement', 'ThrowStatement'
56])
57
58const FUNCS = new Set([
59 'FunctionDeclaration', 'FunctionExpression', 'ArrowFunctionExpression'
60])
61
62// Children that are never part of the model: positions, and the raw token soup
63// of a template literal.
64const SKIP_KEYS = new Set(['loc', 'range', 'start', 'end', 'quasis'])
65
66function rangeOf (node) {
67 return {
68 startByte: node.range[0],
69 endByte: node.range[1],
70 startLine: node.loc.start.line,
71 startCol: node.loc.start.column,
72 endLine: node.loc.end.line,
73 endCol: node.loc.end.column
74 }
75}
76
77function idOf (filePath, node) {
78 return filePath + ':' + node.range[0] + '-' + node.range[1]
79}
80
81// R5 — a property path is one label, not a chain of member nodes. Computed
82// access becomes `[..]` because the key is a value, not a name.
83function pathOf (node) {
84 if (!node) return null
85 if (node.type === 'Identifier') return node.name
86 if (node.type === 'ThisExpression') return 'this'
87 if (node.type === 'Literal') return String(node.value)
88 if (node.type === 'MemberExpression') {
89 const base = pathOf(node.object)
90 if (base === null) return null
91 if (node.computed) return base + '[..]'
92 return base + '.' + pathOf(node.property)
93 }
94 return null
95}
96
97// The identifier a path is rooted in — what actually gets resolved in scope.
98function rootOf (node) {
99 let cur = node
100 while (cur && cur.type === 'MemberExpression') cur = cur.object
101 return cur
102}
103
104function labelFor (node) {
105 switch (node.type) {
106 case 'CallExpression':
107 case 'NewExpression': {
108 const p = pathOf(node.callee)
109 const prefix = node.type === 'NewExpression' ? 'new ' : ''
110 return prefix + (p === null ? '(expr)' : p)
111 }
112 case 'BinaryExpression':
113 case 'LogicalExpression':
114 case 'AssignmentExpression':
115 return node.operator
116 case 'UnaryExpression':
117 case 'UpdateExpression':
118 return node.operator
119 case 'ConditionalExpression': return '?:'
120 case 'ObjectExpression': return '{}'
121 case 'ArrayExpression': return '[]'
122 case 'TemplateLiteral':
123 case 'TaggedTemplateExpression': return 'template'
124 case 'ReturnStatement': return 'return'
125 case 'ThrowStatement': return 'throw'
126 case 'YieldExpression': return 'yield'
127 default: return node.type
128 }
129}
130
131// `await x` / `...x` wrap the thing that matters. Unwrap to the node underneath
132// and report the markers, so the operand is described once, with its flags.
133function unwrap (node) {
134 const flags = {}
135 let cur = node
136 while (cur) {
137 if (cur.type === 'AwaitExpression') { flags.awaited = true; cur = cur.argument; continue }
138 if (cur.type === 'SpreadElement') { flags.spread = true; cur = cur.argument; continue }
139 if (cur.type === 'ParenthesizedExpression') { cur = cur.expression; continue }
140 if (cur.type === 'TSAsExpression' || cur.type === 'TSNonNullExpression') { cur = cur.expression; continue }
141 break
142 }
143 return { node: cur, flags }
144}
145
146/**
147 * One operand slot -> one input descriptor.
148 *
149 * A literal names itself and sits IN the socket (R2). A reference or a path
150 * names the wire and records where it resolved (E5). An operand that is itself
151 * an operation points at that node's id — the operand still becomes its own
152 * node when the walk reaches it.
153 */
154function inputFor (raw, slot, ctx) {
155 const { node, flags } = unwrap(raw)
156 if (!node) return null
157
158 const base = { slot }
159 if (flags.awaited) base.awaited = true
160 if (flags.spread) base.spread = true
161
162 if (node.type === 'Literal') {
163 return { ...base, name: node.raw === undefined ? String(node.value) : node.raw, literal: true }
164 }
165 if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {
166 return { ...base, name: 'template', literal: true }
167 }
168 if (node.type === 'Identifier' || node.type === 'ThisExpression' || node.type === 'MemberExpression') {
169 const path = pathOf(node)
170 const root = rootOf(node)
171 if (path !== null && root) {
172 return {
173 ...base,
174 name: path,
175 source: root.type === 'ThisExpression' ? 'this' : ctx.resolve(root.name)
176 }
177 }
178 }
179 if (FUNCS.has(node.type)) {
180 return { ...base, name: '(fn)', source: 'closure', node: idOf(ctx.filePath, node) }
181 }
182 if (OPS.has(node.type)) {
183 return { ...base, name: labelFor(node), source: 'node', node: idOf(ctx.filePath, node) }
184 }
185 // Anything else (a class expression, a sequence, a regex-as-node): still an
186 // endpoint, still positioned, just not one we have a better name for.
187 return { ...base, name: node.type, source: 'expr' }
188}
189
190// Operand slots per operation, in the order they read.
191function operandsOf (node) {
192 switch (node.type) {
193 case 'CallExpression':
194 case 'NewExpression': {
195 // The callee keeps a socket even though its PATH became the label, so the
196 // binding it resolves to (an import, a global) is never lost (E5).
197 const out = [[node.callee, 'callee']]
198 for (let i = 0; i < node.arguments.length; i++) out.push([node.arguments[i], 'arg' + i])
199 return out
200 }
201 case 'BinaryExpression':
202 case 'LogicalExpression':
203 return [[node.left, 'left'], [node.right, 'right']]
204 case 'AssignmentExpression':
205 // `left` is the WRITE target, recorded separately — only the value flows in.
206 return [[node.right, 'value']]
207 case 'UnaryExpression':
208 return [[node.argument, 'value']]
209 case 'UpdateExpression':
210 return [[node.argument, 'value']]
211 case 'ConditionalExpression':
212 return [[node.test, 'test'], [node.consequent, 'then'], [node.alternate, 'else']]
213 case 'ObjectExpression':
214 return node.properties.map((p, i) => [p.type === 'Property' ? p.value : p, keyName(p, i)])
215 case 'ArrayExpression':
216 return node.elements.map((e, i) => [e, 'item' + i])
217 case 'TemplateLiteral':
218 return node.expressions.map((e, i) => [e, 'slot' + i])
219 case 'TaggedTemplateExpression':
220 return [[node.tag, 'tag']]
221 case 'ReturnStatement':
222 case 'ThrowStatement':
223 case 'YieldExpression':
224 return node.argument ? [[node.argument, 'value']] : []
225 default:
226 return []
227 }
228}
229
230function keyName (prop, i) {
231 if (prop.type !== 'Property') return 'item' + i
232 if (prop.key && prop.key.type === 'Identifier') return prop.key.name
233 if (prop.key && prop.key.type === 'Literal') return String(prop.key.value)
234 return 'item' + i
235}
236
237// Z1 — a conditional's shape follows its control flow, not its keyword.
238function shapeOfIf (node) {
239 const body = node.consequent.type === 'BlockStatement' ? node.consequent.body : [node.consequent]
240 if (!node.alternate && body.length === 1 && JUMPS.has(body[0].type)) return 'guard'
241 if (node.alternate) return 'two-exit'
242 return 'single-branch'
243}
244
245function shapeOfZone (node) {
246 if (node.type === 'IfStatement') return shapeOfIf(node)
247 if (node.type === 'ForOfStatement' || node.type === 'ForInStatement') return 'foreach'
248 if (LOOPS.has(node.type)) return 'repeat'
249 if (node.type === 'SwitchStatement') return 'switch'
250 if (node.type === 'TryStatement') return 'try'
251 return node.type
252}
253
254function conditionOf (node, source) {
255 const test = node.test || node.discriminant
256 if (!test) return null
257 return { text: source.slice(test.range[0], test.range[1]), range: rangeOf(test) }
258}
259
260// The binding a foreach zone hands its body, e.g. `row` in `for (const row of rows)`.
261function itemOf (node) {
262 if (node.type !== 'ForOfStatement' && node.type !== 'ForInStatement') return null
263 const left = node.left
264 if (left.type === 'VariableDeclaration' && left.declarations.length > 0) {
265 const id = left.declarations[0].id
266 return id.type === 'Identifier' ? id.name : null
267 }
268 if (left.type === 'Identifier') return left.name
269 return null
270}
271
272export function deriveFile (source, filePath) {
273 const empty = {
274 filePath,
275 functions: [],
276 nodes: [],
277 names: [],
278 zones: [],
279 exits: []
280 }
281
282 let ast
283 try {
284 ast = acornParse(source, PARSE_OPTS)
285 } catch (err) {
286 try {
287 ast = acornParse(source, { ...PARSE_OPTS, sourceType: 'script' })
288 } catch {
289 return { ...empty, error: err.message }
290 }
291 }
292
293 const model = { ...empty }
294
295 // --- scope chain. A reference resolves to a kind, or is marked free (E5).
296 const scopes = [new Map()]
297
298 function declare (name, kind) {
299 if (!name) return
300 scopes[scopes.length - 1].set(name, kind)
301 }
302
303 function resolve (name) {
304 for (let i = scopes.length - 1; i >= 0; i--) {
305 const kind = scopes[i].get(name)
306 if (kind) return kind
307 }
308 return 'free'
309 }
310
311 const ctx = { filePath, resolve }
312
313 // Hoist the declarations of one scope BEFORE walking it, so a reference that
314 // reads before its declaration still resolves instead of being called free.
315 function hoist (body) {
316 for (const stmt of body) {
317 if (!stmt || typeof stmt.type !== 'string') continue
318 const inner = stmt.type === 'ExportNamedDeclaration' || stmt.type === 'ExportDefaultDeclaration'
319 ? stmt.declaration
320 : stmt
321 if (!inner) continue
322 if (inner.type === 'VariableDeclaration') {
323 for (const d of inner.declarations) collectPattern(d.id, 'local')
324 } else if (inner.type === 'FunctionDeclaration' || inner.type === 'ClassDeclaration') {
325 if (inner.id) declare(inner.id.name, 'function')
326 } else if (inner.type === 'ImportDeclaration') {
327 for (const spec of inner.specifiers) declare(spec.local.name, 'import')
328 }
329 }
330 }
331
332 function collectPattern (pattern, kind) {
333 if (!pattern) return
334 switch (pattern.type) {
335 case 'Identifier': declare(pattern.name, kind); break
336 case 'ObjectPattern':
337 for (const p of pattern.properties) {
338 if (p.type === 'Property') collectPattern(p.value, kind)
339 else if (p.type === 'RestElement') collectPattern(p.argument, kind)
340 }
341 break
342 case 'ArrayPattern':
343 for (const e of pattern.elements) collectPattern(e, kind)
344 break
345 case 'AssignmentPattern': collectPattern(pattern.left, kind); break
346 case 'RestElement': collectPattern(pattern.argument, kind); break
347 }
348 }
349
350 // --- writes and reads, collected for the carried-state pass (Z3).
351 const writes = [] // { name, at, declaredAt }
352 const reads = [] // { name, at }
353 const declaredAt = new Map()
354
355 function noteWrite (target, at) {
356 const root = rootOf(target)
357 if (!root || root.type !== 'Identifier') return null
358 writes.push({ name: root.name, at })
359 return root.name
360 }
361
362 // --- the walk
363 let zoneStack = []
364 let fnStack = []
365
366 function currentLoop () {
367 for (let i = zoneStack.length - 1; i >= 0; i--) {
368 const z = zoneStack[i]
369 if (z.kind === 'loop') return z
370 }
371 return null
372 }
373
374 function currentBreakTarget () {
375 for (let i = zoneStack.length - 1; i >= 0; i--) {
376 const z = zoneStack[i]
377 if (z.kind === 'loop' || z.shape === 'switch') return z
378 }
379 return null
380 }
381
382 function currentTry () {
383 for (let i = zoneStack.length - 1; i >= 0; i--) {
384 if (zoneStack[i].shape === 'try') return zoneStack[i]
385 }
386 return null
387 }
388
389 function landingFor (node) {
390 const fn = fnStack[fnStack.length - 1]
391 switch (node.type) {
392 case 'ReturnStatement':
393 return fn ? { on: 'function-output', id: fn.id } : { on: 'module-output', id: filePath }
394 case 'ContinueStatement': {
395 const loop = currentLoop()
396 return loop ? { on: 'zone-entry', id: loop.id } : { on: 'unresolved', id: null }
397 }
398 case 'BreakStatement': {
399 const target = currentBreakTarget()
400 return target
401 ? { on: target.shape === 'switch' ? 'zone-exit' : 'zone-exit', id: target.id }
402 : { on: 'unresolved', id: null }
403 }
404 case 'ThrowStatement': {
405 const tryZone = currentTry()
406 if (tryZone) return { on: 'catch-entry', id: tryZone.id }
407 return fn ? { on: 'function-error-output', id: fn.id } : { on: 'unresolved', id: null }
408 }
409 default:
410 return { on: 'unresolved', id: null }
411 }
412 }
413
414 // An `else if` chain is ONE multi-band zone, not N nested ones (Z1). Absorb
415 // the chain here and return the alternates the caller must NOT re-enter as
416 // zones of their own.
417 function bandsOf (node, absorbed) {
418 const bands = [{ kind: 'then', test: conditionOf(node, source), range: rangeOf(node.consequent) }]
419 let alt = node.alternate
420 while (alt && alt.type === 'IfStatement') {
421 absorbed.add(alt)
422 bands.push({ kind: 'else-if', test: conditionOf(alt, source), range: rangeOf(alt.consequent) })
423 alt = alt.alternate
424 }
425 if (alt) bands.push({ kind: 'else', test: null, range: rangeOf(alt) })
426 return bands
427 }
428
429 const absorbed = new Set()
430
431 function walk (node) {
432 if (!node || typeof node.type !== 'string') return
433
434 let pushedScope = false
435 let pushedZone = null
436 let pushedFn = null
437
438 // --- functions: a new scope, and R6's group interface.
439 if (FUNCS.has(node.type)) {
440 const fn = {
441 id: idOf(filePath, node),
442 name: node.id ? node.id.name : '(anonymous)',
443 kind: node.type,
444 range: rangeOf(node),
445 params: [],
446 async: !!node.async,
447 generator: !!node.generator
448 }
449 scopes.push(new Map())
450 pushedScope = true
451 for (const p of node.params) {
452 collectPattern(p, 'param')
453 const n = p.type === 'Identifier' ? p.name : (p.type === 'AssignmentPattern' && p.left.type === 'Identifier' ? p.left.name : '(pattern)')
454 fn.params.push(n)
455 }
456 if (node.body && node.body.type === 'BlockStatement') hoist(node.body.body)
457 model.functions.push(fn)
458 fnStack.push(fn)
459 pushedFn = fn
460 }
461
462 // --- zones (R4). An absorbed `else if` link is a band of its parent.
463 if (ZONES.has(node.type) && !absorbed.has(node)) {
464 const parent = zoneStack.length > 0 ? zoneStack[zoneStack.length - 1] : null
465 const zone = {
466 id: idOf(filePath, node),
467 type: node.type,
468 kind: LOOPS.has(node.type) ? 'loop' : (node.type === 'TryStatement' ? 'try' : 'branch'),
469 shape: shapeOfZone(node),
470 range: rangeOf(node),
471 depth: parent ? parent.depth + 1 : 1,
472 parent: parent ? parent.id : null,
473 condition: conditionOf(node, source),
474 item: itemOf(node),
475 bands: node.type === 'IfStatement' && node.alternate ? bandsOf(node, absorbed) : null,
476 carried: [],
477 exits: []
478 }
479 model.zones.push(zone)
480 zoneStack.push(zone)
481 pushedZone = zone
482 // A loop's binding lives in the loop's own scope.
483 if (zone.item) {
484 scopes.push(new Map())
485 declare(zone.item, 'local')
486 zone.__scoped = true
487 }
488 }
489
490 // --- a block introduces a scope, and hoists into it.
491 if (node.type === 'BlockStatement' && !(pushedFn)) {
492 scopes.push(new Map())
493 hoist(node.body)
494 pushedScope = true
495 }
496 if (node.type === 'Program') hoist(node.body)
497
498 // --- operations (R1)
499 if (OPS.has(node.type)) {
500 const op = {
501 id: idOf(filePath, node),
502 kind: node.type,
503 label: labelFor(node),
504 range: rangeOf(node),
505 zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
506 fn: fnStack.length > 0 ? fnStack[fnStack.length - 1].id : null,
507 inputs: []
508 }
509 for (const [operand, slot] of operandsOf(node)) {
510 const input = inputFor(operand, slot, ctx)
511 if (input) op.inputs.push(input)
512 }
513 // An assignment WRITES a wire: that is a new version of it (R3/Z3).
514 if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') {
515 const target = node.type === 'AssignmentExpression' ? node.left : node.argument
516 const written = noteWrite(target, rangeOf(node))
517 if (written) {
518 op.writes = pathOf(target)
519 op.writesRoot = written
520 }
521 }
522 model.nodes.push(op)
523 }
524
525 // --- declarations name wires (R3), not nodes.
526 if (node.type === 'VariableDeclarator' && node.id.type === 'Identifier') {
527 const inLoopHead = zoneStack.length > 0 &&
528 zoneStack[zoneStack.length - 1].item === node.id.name &&
529 zoneStack[zoneStack.length - 1].range.startByte <= node.range[0] &&
530 node.range[0] < (zoneStack[zoneStack.length - 1].condition
531 ? zoneStack[zoneStack.length - 1].condition.range.endByte
532 : zoneStack[zoneStack.length - 1].range.endByte)
533
534 // A loop binding is the zone's ITEM socket, not a named wire of its own.
535 if (!inLoopHead) {
536 const entry = {
537 name: node.id.name,
538 range: rangeOf(node),
539 from: 'none',
540 node: null
541 }
542 const init = node.init ? unwrap(node.init).node : null
543 if (!init) entry.from = 'none'
544 else if (init.type === 'Literal') entry.from = 'literal'
545 else if (OPS.has(init.type)) { entry.from = 'node'; entry.node = idOf(filePath, init) }
546 else if (FUNCS.has(init.type)) { entry.from = 'closure'; entry.node = idOf(filePath, init) }
547 else entry.from = 'wire'
548 model.names.push(entry)
549 declaredAt.set(node.id.name, node.range[0])
550 }
551 }
552
553 // --- exits (Z4)
554 if (JUMPS.has(node.type)) {
555 model.exits.push({
556 id: idOf(filePath, node) + ':exit',
557 kind: node.type === 'ReturnStatement' ? 'return'
558 : node.type === 'BreakStatement' ? 'break'
559 : node.type === 'ContinueStatement' ? 'continue' : 'throw',
560 node: OPS.has(node.type) ? idOf(filePath, node) : null,
561 range: rangeOf(node),
562 zone: zoneStack.length > 0 ? zoneStack[zoneStack.length - 1].id : null,
563 lands: landingFor(node)
564 })
565 if (zoneStack.length > 0) {
566 zoneStack[zoneStack.length - 1].exits.push(idOf(filePath, node) + ':exit')
567 }
568 }
569
570 // --- reads, for the carried-state pass
571 if (node.type === 'Identifier') reads.push({ name: node.name, at: node.range[0] })
572
573 // --- recurse
574 for (const key of Object.keys(node)) {
575 if (SKIP_KEYS.has(key)) continue
576 const value = node[key]
577 if (Array.isArray(value)) {
578 for (const child of value) walk(child)
579 } else if (value && typeof value === 'object' && typeof value.type === 'string') {
580 walk(value)
581 }
582 }
583
584 if (pushedZone) {
585 if (pushedZone.__scoped) { scopes.pop(); delete pushedZone.__scoped }
586 zoneStack.pop()
587 }
588 if (pushedFn) fnStack.pop()
589 if (pushedScope) scopes.pop()
590 }
591
592 walk(ast)
593
594 // --- Z3: a zone's outputs are its phis. A name written inside the zone, whose
595 // declaration is OUTSIDE it, and which is read after the zone closes, converges
596 // out of it. Same rule for a loop's carried state and an if/else's convergence —
597 // which is the whole point of treating both as regions.
598 for (const zone of model.zones) {
599 const carried = new Set()
600 for (const w of writes) {
601 if (w.at.startByte < zone.range.startByte || w.at.endByte > zone.range.endByte) continue
602 const decl = declaredAt.get(w.name)
603 if (decl !== undefined && decl >= zone.range.startByte) continue // declared inside: cannot escape
604 const readAfter = reads.some(r => r.name === w.name && r.at > zone.range.endByte)
605 if (readAfter) carried.add(w.name)
606 }
607 zone.carried = [...carried].sort()
608 }
609
610 return model
611}
lib/parsers/js.js +52 -5
……@@ -154,12 +154,20 @@ function extractImport (node) {
154154
155155function extractNamedExport (node, result, commentMap) {
156156 // Re-export: export { X } from './file.js'
157 //
158 // This is sugar for import-then-export, so it is BOTH. Recording only the
159 // export leaves the source file with nothing pointing at it: a pure re-export
160 // barrel (index.js doing nothing but `export { x } from './lib/x.js'`) then
161 // makes every file it fronts look unreachable.
157162 if (node.source) {
163 const names = []
158164 for (const spec of node.specifiers) {
159 result.exports.push({
160 name: spec.exported.name || spec.exported.value,
161 from: node.source.value
162 })
165 const name = spec.exported.name || spec.exported.value
166 names.push(spec.local ? (spec.local.name || name) : name)
167 result.exports.push({ name, from: node.source.value })
168 }
169 if (names.length > 0) {
170 result.imports.push({ specifier: node.source.value, names, reExport: true })
163171 }
164172 return
165173 }
……@@ -259,6 +267,7 @@ function extractClass (node, commentMap) {
259267 name: node.id ? node.id.name : '(anonymous)',
260268 extends: node.superClass ? nodeToString(node.superClass) : null,
261269 line: node.loc ? node.loc.start.line : null,
270 endLine: node.loc ? node.loc.end.line : null,
262271 constructor: null,
263272 methods: [],
264273 getters: [],
……@@ -277,10 +286,11 @@ function extractClass (node, commentMap) {
277286 cls.constructor = {
278287 params: extractParams(member.value.params),
279288 line: member.loc ? member.loc.start.line : null,
289 endLine: member.loc ? member.loc.end.line : null,
280290 jsdoc
281291 }
282292 } else if (member.kind === 'get') {
283 cls.getters.push({ name, static: member.static || false, line: member.loc ? member.loc.start.line : null })
293 cls.getters.push({ name, static: member.static || false, line: member.loc ? member.loc.start.line : null, endLine: member.loc ? member.loc.end.line : null })
284294 } else if (member.kind === 'set') {
285295 cls.getters.push({ name, kind: 'set', static: member.static || false })
286296 } else if (member.static) {
……@@ -289,6 +299,7 @@ function extractClass (node, commentMap) {
289299 params: extractParams(member.value.params),
290300 async: member.value.async || false,
291301 line: member.loc ? member.loc.start.line : null,
302 endLine: member.loc ? member.loc.end.line : null,
292303 jsdoc
293304 })
294305 } else {
……@@ -298,6 +309,7 @@ function extractClass (node, commentMap) {
298309 async: member.value.async || false,
299310 generator: member.value.generator || false,
300311 line: member.loc ? member.loc.start.line : null,
312 endLine: member.loc ? member.loc.end.line : null,
301313 jsdoc
302314 })
303315 }
……@@ -324,6 +336,7 @@ function extractFunction (node, commentMap) {
324336 async: node.async || false,
325337 generator: node.generator || false,
326338 line: node.loc ? node.loc.start.line : null,
339 endLine: node.loc ? node.loc.end.line : null,
327340 jsdoc: findJSDoc(commentMap, node)
328341 }
329342}
……@@ -374,6 +387,40 @@ function walkDeep (ast, result) {
374387 const errors = []
375388
376389 walk(ast, (node) => {
390 // Dynamic import. The top-level switch only sees static ImportDeclaration,
391 // so a module reached solely via `await import('x')` looked like nothing
392 // imported it — and every one of its exports read as a dead export.
393 //
394 // const { startServer } = await import('./lib/server.js')
395 //
396 // Visited parent-first, so the destructured form is recorded (with its
397 // names) before the bare ImportExpression fallback below sees it.
398 if (
399 node.type === 'VariableDeclarator' &&
400 node.id && node.id.type === 'ObjectPattern' &&
401 node.init && node.init.type === 'AwaitExpression' &&
402 node.init.argument && node.init.argument.type === 'ImportExpression' &&
403 node.init.argument.source && node.init.argument.source.type === 'Literal'
404 ) {
405 const spec = node.init.argument.source.value
406 if (typeof spec === 'string' && !result.imports.some(i => i.specifier === spec)) {
407 const names = []
408 for (const p of node.id.properties) {
409 if (p.type === 'Property' && p.key && p.key.name) names.push(p.key.name)
410 }
411 result.imports.push({ specifier: spec, names, dynamic: true })
412 }
413 }
414
415 // Any other dynamic import — bare `import('x')`, or one whose result is not
416 // destructured. The edge still matters even when no names are recoverable.
417 if (node.type === 'ImportExpression' && node.source && node.source.type === 'Literal') {
418 const spec = node.source.value
419 if (typeof spec === 'string' && !result.imports.some(i => i.specifier === spec)) {
420 result.imports.push({ specifier: spec, names: [], dynamic: true })
421 }
422 }
423
377424 // this._field = ... (in constructor or methods)
378425 if (
379426 node.type === 'AssignmentExpression' &&
lib/reducers/tree-reducer.js +1 -0
……@@ -86,6 +86,7 @@ export class TreeReducer {
8686 const file = mod.files.get(e.fromRelInPkg)
8787 if (!file) return
8888 const edge = { specifier: e.specifier, resolved: e.resolvedRelPath }
89 if (e.names && e.names.length > 0) edge.names = e.names.slice()
8990 if (e.implicit) edge.implicit = true
9091 file.localImports.push(edge)
9192 }
lib/render-nodes.js +924 -0
……@@ -0,0 +1,924 @@
1/**
2 * The node view — code as a Geometry-Nodes graph of nested groups.
3 *
4 * Everything here is DERIVED from the scan. Nothing is authored.
5 *
6 * a germ -> a node
7 * an import -> an input socket (a demand)
8 * an export -> an output socket (a supply)
9 * a mating -> a noodle from an output socket to an input socket
10 * an unmated socket -> an empty socket, visibly unconnected
11 *
12 * Scales nest exactly the way a Blender node group does: a node at one level
13 * opens into a whole graph at the next, and the group's interface — its Group
14 * Input and Group Output nodes — is the germ's own boundary, which is to say
15 * the connectors left over once the interior's matings cancel.
16 *
17 * root module nodes no interface
18 * module file nodes in: the module's package deps out: its publicAPI
19 * file symbol nodes in: the file's imports out: its exports
20 * symbol THE SOURCE in: its params out: its name
21 *
22 * The lowest nodes carry the actual code, read from /api/file over the line
23 * range the parser recorded.
24 *
25 * The emitted script avoids backticks and dollar-brace: it is interpolated into
26 * render.js's template literal, and this file is a template literal too.
27 */
28
29export const NODE_VIEW_CSS = `
30#node-container {
31 position: absolute; top: 0; left: 0; right: 0; bottom: 0;
32 display: none; overflow: hidden; background: #131417;
33}
34#node-container.active { display: block; }
35#node-svg { width: 100%; height: 100%; cursor: grab; }
36#node-svg.dragging { cursor: grabbing; }
37
38.nv-node-body { fill: #26272b; stroke: #17181a; stroke-width: 1; }
39.nv-node-head { stroke: none; }
40.nv-node-title { fill: #e8e8ea; font-size: 11px; font-weight: bold; pointer-events: none; }
41.nv-node-sub { fill: #a0a0a8; font-size: 9px; pointer-events: none; }
42.nv-sock-label { fill: #c4c4cc; font-size: 10px; pointer-events: none; }
43.nv-sock-label.unmated { fill: #6e7076; font-style: italic; }
44.nv-noodle { fill: none; stroke-width: 1.6; pointer-events: none; }
45.nv-node.selected .nv-node-body { stroke: #ffa657; stroke-width: 2; }
46.nv-group-node .nv-node-body { stroke: #58a6ff; stroke-dasharray: 4,2; }
47.nv-code { font-family: 'SF Mono','Fira Code',monospace; font-size: 8px; fill: #9aa0a6; pointer-events: none; white-space: pre; }
48.nv-code-kw { fill: #ff7b72; }
49.nv-lineno { fill: #4a4d55; font-size: 7px; pointer-events: none; }
50
51.nv-frame { fill-opacity: 0.10; stroke-opacity: 0.55; stroke-width: 1.5; rx: 8; }
52.nv-frame-label { font-size: 12px; font-weight: bold; fill-opacity: 0.95; pointer-events: none; }
53
54#node-breadcrumb {
55 position: absolute; top: 12px; left: 12px; display: flex; gap: 4px;
56 align-items: center; font-size: 12px; z-index: 5;
57}
58#node-breadcrumb a {
59 color: #8b949e; cursor: pointer; padding: 4px 9px; text-decoration: none;
60 background: #1c1d21; border: 1px solid #2f3136; border-radius: 4px;
61}
62#node-breadcrumb a:hover { color: #e8e8ea; border-color: #58a6ff; }
63#node-breadcrumb a.current { color: #ffa657; border-color: #ffa657; cursor: default; }
64#node-breadcrumb .sep { color: #4a4d55; }
65
66#node-legend {
67 position: absolute; bottom: 12px; left: 12px; background: #1c1d21;
68 border: 1px solid #2f3136; border-radius: 6px; padding: 8px 12px;
69 font-size: 10px; color: #8b949e; pointer-events: none; line-height: 1.7;
70}
71#node-legend label { pointer-events: auto; }
72#node-legend b { color: #c9d1d9; }
73#node-hint {
74 position: absolute; bottom: 12px; right: 12px; background: #1c1d21;
75 border: 1px solid #2f3136; border-radius: 6px; padding: 8px 12px;
76 font-size: 10px; color: #8b949e; pointer-events: none;
77}
78`
79
80export const NODE_VIEW_HTML = `
81 <div id="node-container">
82 <svg id="node-svg"><defs></defs></svg>
83 <div id="node-breadcrumb"></div>
84 <div id="node-legend"></div>
85 <div id="node-hint">drag to pan &middot; scroll to zoom &middot; click a node title to open it</div>
86 </div>
87`
88
89export const NODE_VIEW_JS = `
90// ====== NODE VIEW (nested groups) ======
91
92var NV = {
93 stack: [{ kind: 'path', path: '', label: 'System' }],
94 onlyConnected: false,
95 showFrames: true,
96 nodes: [],
97 links: [],
98 zoom: 0.9, panX: 60, panY: 40,
99 panning: false, panSX: 0, panSY: 0,
100 initialized: false,
101 sourceCache: {},
102 selected: null
103}
104
105var NV_ROW = 15
106var NV_HEAD = 20
107var NV_W = 210
108var NV_COL_GAP = 130
109var NV_ROW_GAP = 26
110
111function nvSocketColor (scope, mated) {
112 if (!mated) return scope === 'unresolved' ? '#f85149' : '#6e7076'
113 if (scope === 'package' || scope === 'external') return '#8b949e'
114 return '#7ee787'
115}
116
117function nvRoleColor (role) {
118 var c = { entry: '#b8642a', test: '#3f7a4a', lib: '#2f5b86', schema: '#6b4a86',
119 bin: '#8a3b36', script: '#8a6a2a', header: '#2f5b86', source: '#2f5b86',
120 shader: '#6b4a86', module: '#7a4a1f', group: '#1f4a6b' }
121 return c[role] || '#3a3d44'
122}
123
124// ---- data assembly, all derived from the scan ----
125
126// Every germ contained under a dirTree entry, so a directory can report what
127// actually crosses its boundary.
128function nvContainedModules (entry) {
129 var out = []
130 function visit (n) {
131 if (!n) return
132 if (n.module && tree.modules[n.module]) out.push(n.module)
133 if (n.children) for (var i = 0; i < n.children.length; i++) visit(n.children[i])
134 }
135 visit(entry)
136 return out
137}
138
139// One level of the directory tree, exactly the set the graph view shows at the
140// same path — reusing its helper so the two views cannot disagree.
141//
142// A node's sockets are the module names that CROSS its boundary: inputs are
143// modules outside it that its contents depend on, outputs are modules inside it
144// that something outside depends on. Named that way, a noodle connects an
145// output socket to the input socket with the SAME NAME, so the wire and the
146// socket always agree.
147function nvPathNodes (path) {
148 var items = getNodesAtPath(path)
149 var nodes = []
150 var owner = {}
151
152 for (var i = 0; i < items.length; i++) {
153 var it = items[i]
154 var contained = it.type === 'module' ? [it.name] : nvContainedModules(it.dirEntry)
155 var id = (it.type === 'module' ? 'mod:' : 'dir:') + it.name
156 for (var c = 0; c < contained.length; c++) owner[contained[c]] = id
157 nodes.push({
158 id: id, title: it.name + (it.type === 'dir' ? '/' : ''),
159 sub: it.type === 'module' ? (it.mod ? it.mod.version : '') : (it.packageCount + ' pkg'),
160 role: it.type === 'module' ? 'module' : 'group',
161 contained: contained, kind: it.type,
162 inputs: [], outputs: [],
163 openable: true,
164 open: it.type === 'module'
165 ? { kind: 'module', name: it.name, label: it.name }
166 : { kind: 'path', path: path ? path + '/' + it.name : it.name, label: it.name }
167 })
168 }
169
170 // Derive the crossing sets from the real dependency graph.
171 var inSet = {}, outSet = {}
172 for (var i = 0; i < nodes.length; i++) { inSet[nodes[i].id] = {}; outSet[nodes[i].id] = {} }
173
174 for (var m = 0; m < tree.system.modules.length; m++) {
175 var name = tree.system.modules[m]
176 var mod = tree.modules[name]
177 if (!mod) continue
178 var from = owner[name]
179 if (!from) continue
180 var deps = Object.keys(mod.dependencies || {})
181 for (var d = 0; d < deps.length; d++) {
182 var to = owner[deps[d]]
183 if (!to || to === from) continue // internal to this node: it cancels
184 inSet[from][deps[d]] = true
185 outSet[to][deps[d]] = true
186 }
187 }
188
189 for (var i = 0; i < nodes.length; i++) {
190 var n = nodes[i]
191 var ins = Object.keys(inSet[n.id]).sort()
192 var outs = Object.keys(outSet[n.id]).sort()
193 for (var a = 0; a < ins.length; a++) n.inputs.push({ name: ins[a], scope: 'local', mated: true })
194 for (var b = 0; b < outs.length; b++) n.outputs.push({ name: outs[b], scope: 'local', mated: true })
195 }
196
197 return nodes
198}
199
200// A noodle per crossing module name, anchored on the sockets that carry it.
201function nvPathLinks (nodes) {
202 var links = []
203 for (var i = 0; i < nodes.length; i++) {
204 var src = nodes[i]
205 for (var o = 0; o < src.outputs.length; o++) {
206 var provided = src.outputs[o].name
207 for (var j = 0; j < nodes.length; j++) {
208 if (i === j) continue
209 var dst = nodes[j]
210 for (var k = 0; k < dst.inputs.length; k++) {
211 if (dst.inputs[k].name !== provided) continue
212 links.push({ from: src.id, fromSock: o, fromSide: 'out',
213 to: dst.id, toSock: k, toSide: 'in' })
214 }
215 }
216 }
217 }
218 return links
219}
220
221function nvFileNodes (data) {
222 // data = { module, sections: [file-scale sections with conns] }
223 var nodes = []
224 var secById = {}
225 for (var i = 0; i < data.sections.length; i++) secById[data.sections[i].id] = data.sections[i]
226
227 for (var i = 0; i < data.sections.length; i++) {
228 var sec = data.sections[i]
229 var ins = []
230 var outs = []
231 var connIndex = {}
232 for (var c = 0; c < sec.conns.length; c++) {
233 var conn = sec.conns[c]
234 var mated = conn.mates && conn.mates.length > 0
235 if (conn.pol === '-') {
236 connIndex[conn.id] = { side: 'in', idx: ins.length }
237 ins.push({ name: conn.type, scope: conn.scope, mated: mated, connId: conn.id, target: conn.target })
238 } else {
239 connIndex[conn.id] = { side: 'out', idx: outs.length }
240 outs.push({ name: conn.type, scope: conn.scope, mated: mated, connId: conn.id })
241 }
242 }
243 nodes.push({
244 id: sec.id, title: sec.germ.name, sub: sec.germ.role,
245 role: sec.germ.role, inputs: ins, outputs: outs,
246 connIndex: connIndex, germ: sec.germ,
247 openable: true,
248 open: { kind: 'file', module: data.module, path: sec.germ.name, label: sec.germ.name }
249 })
250 }
251 return { nodes: nodes, secById: secById }
252}
253
254function nvFileLinks (nodes) {
255 var byId = {}
256 for (var i = 0; i < nodes.length; i++) byId[nodes[i].id] = nodes[i]
257 var links = []
258 for (var i = 0; i < nodes.length; i++) {
259 var n = nodes[i]
260 for (var s = 0; s < n.inputs.length; s++) {
261 var sock = n.inputs[s]
262 if (!sock.mated || !sock.target) continue
263 var src = byId[sock.target]
264 if (!src) continue
265 // Find the matching supply socket by name on the target node.
266 var fi = -1
267 for (var o = 0; o < src.outputs.length; o++) {
268 if (src.outputs[o].name === sock.name) { fi = o; break }
269 }
270 if (fi < 0) continue
271 links.push({ from: src.id, fromSock: fi, fromSide: 'out', to: n.id, toSock: s, toSide: 'in' })
272 }
273 }
274 return links
275}
276
277// The symbol level: one node per class / function / const, each carrying its
278// own source text. This is the bottom of the ladder.
279function nvSymbolNodes (moduleName, filePath) {
280 var mod = tree.modules[moduleName]
281 if (!mod) return []
282 var file = mod.files[filePath]
283 if (!file) return []
284 var nodes = []
285
286 function add (kind, name, line, endLine, params, extra) {
287 nodes.push({
288 id: 'sym:' + filePath + ':' + name,
289 title: name, sub: kind + (line ? ' :' + line : ''),
290 role: kind === 'class' ? 'schema' : kind === 'const' ? 'script' : 'lib',
291 inputs: (params || []).map(function (p) { return { name: p, scope: 'local', mated: false } }),
292 outputs: [{ name: name, scope: 'local', mated: true }],
293 code: null, line: line, endLine: endLine,
294 filePath: filePath, moduleName: moduleName,
295 openable: false, extra: extra || null
296 })
297 }
298
299 var cls = file.classes || []
300 for (var i = 0; i < cls.length; i++) {
301 var c = cls[i]
302 add('class', c.name, c.line, c.endLine, c.constructor ? c.constructor.params : [],
303 (c.methods || []).length + ' methods')
304 }
305 var fns = file.functions || []
306 for (var i = 0; i < fns.length; i++) {
307 add('function', fns[i].name, fns[i].line, fns[i].endLine, fns[i].params, null)
308 }
309 var cs = file.constants || []
310 for (var i = 0; i < cs.length; i++) {
311 if (cs[i].type === 'class' || cs[i].type === 'function') continue
312 add('const', cs[i].name, cs[i].line, cs[i].endLine, [], null)
313 }
314 return nodes
315}
316
317// ---- the group interface: Group Input / Group Output ----
318//
319// These are the germ's OWN connectors, seen from inside. Group Input supplies
320// what the interior demands from outside; Group Output collects what the
321// interior offers outward. Exactly Blender's group interface.
322// The group interface is DERIVED, never assumed. A germ only gets a Group
323// Input if something inside it actually demands from outside, and only gets a
324// Group Output if it actually supplies outward. A module with no external deps
325// has no Group Input; a CLI or a test file supplies nothing and has no Group
326// Output. Emitting empty ones would draw structure that is not there.
327//
328// Returns { gin, gout, links } — already wired to the interior.
329function nvGroupInterface (level, interior) {
330 var gin = null, gout = null
331 var links = []
332
333 // --- Group Input: every unmated demand the interior makes on the outside,
334 // named by the binding the code actually uses.
335 var demandRows = [] // { name, scope, specifier, nodeId, sockIdx }
336 for (var i = 0; i < interior.length; i++) {
337 var n = interior[i]
338 for (var k = 0; k < n.inputs.length; k++) {
339 var sock = n.inputs[k]
340 if (sock.mated) continue
341 demandRows.push({ name: sock.name, scope: sock.scope,
342 specifier: sock.specifier || '', nodeId: n.id, sockIdx: k })
343 }
344 }
345
346 if (demandRows.length > 0) {
347 // One socket per distinct name; several files demanding the same binding
348 // share it, which is what a group input does.
349 var seen = {}
350 var outs = []
351 for (var i = 0; i < demandRows.length; i++) {
352 var r = demandRows[i]
353 if (seen[r.name] === undefined) {
354 seen[r.name] = outs.length
355 outs.push({ name: r.name, scope: r.scope, mated: true, specifier: r.specifier })
356 }
357 }
358 gin = { id: '__group_in__', title: 'Group Input',
359 sub: demandRows.length + ' from outside',
360 role: 'group', inputs: [], outputs: outs,
361 openable: false, isInterface: true }
362 for (var i = 0; i < demandRows.length; i++) {
363 var r = demandRows[i]
364 links.push({ from: '__group_in__', fromSock: seen[r.name], fromSide: 'out',
365 to: r.nodeId, toSock: r.sockIdx, toSide: 'in' })
366 }
367 }
368
369 // --- Group Output: what this germ offers outward.
370 var wanted = []
371 if (level.kind === 'module') {
372 var mod = tree.modules[level.name]
373 wanted = (mod && mod.publicAPI) ? mod.publicAPI.slice() : []
374 } else if (level.kind === 'file') {
375 var m2 = tree.modules[level.module]
376 var f = m2 && m2.files[level.path]
377 var ex = (f && f.exports) || []
378 for (var i = 0; i < ex.length; i++) if (ex[i].name && ex[i].name !== '*') wanted.push(ex[i].name)
379 }
380
381 if (wanted.length > 0) {
382 var ins = []
383 var wire = []
384 for (var i = 0; i < wanted.length; i++) {
385 var name = wanted[i]
386 // Which interior node supplies it?
387 var srcId = null, srcSock = -1
388 for (var j = 0; j < interior.length; j++) {
389 for (var o = 0; o < interior[j].outputs.length; o++) {
390 if (interior[j].outputs[o].name === name) { srcId = interior[j].id; srcSock = o; break }
391 }
392 if (srcId) break
393 }
394 ins.push({ name: name, scope: 'local', mated: !!srcId })
395 if (srcId) wire.push({ from: srcId, fromSock: srcSock, fromSide: 'out',
396 to: '__group_out__', toSock: i, toSide: 'in' })
397 }
398 gout = { id: '__group_out__', title: 'Group Output',
399 sub: level.kind === 'module' ? 'publicAPI' : 'exports',
400 role: 'group', inputs: ins, outputs: [],
401 openable: false, isInterface: true }
402 for (var i = 0; i < wire.length; i++) links.push(wire[i])
403 }
404
405 return { gin: gin, gout: gout, links: links }
406}
407
408// Collapse to only connected: drop every node with no noodle on it. At a
409// directory level most packages depend on nobody visible, and hiding them is
410// the difference between a wall and a readable graph. Group Input/Output are
411// the interface and always stay.
412function nvApplyCollapse (nodes, links) {
413 if (!NV.onlyConnected) return { nodes: nodes, links: links }
414 var used = {}
415 for (var i = 0; i < links.length; i++) { used[links[i].from] = true; used[links[i].to] = true }
416 var kept = []
417 for (var i = 0; i < nodes.length; i++) {
418 if (used[nodes[i].id] || nodes[i].isInterface) kept.push(nodes[i])
419 }
420 var keptIds = {}
421 for (var i = 0; i < kept.length; i++) keptIds[kept[i].id] = true
422 var keptLinks = []
423 for (var i = 0; i < links.length; i++) {
424 if (keptIds[links[i].from] && keptIds[links[i].to]) keptLinks.push(links[i])
425 }
426 return { nodes: kept, links: keptLinks }
427}
428
429window.nvToggleConnected = function (on) {
430 NV.onlyConnected = !!on
431 nvBuild()
432}
433
434window.nvToggleFrames = function (on) {
435 NV.showFrames = !!on
436 nvRender()
437}
438
439// ---- layout: columns by dependency depth, left = supplies, right = consumers
440
441function nvLayout (nodes, links) {
442 var byId = {}
443 for (var i = 0; i < nodes.length; i++) byId[nodes[i].id] = nodes[i]
444
445 var depth = {}
446 for (var i = 0; i < nodes.length; i++) depth[nodes[i].id] = 0
447 // Longest-path layering. Bounded iterations so a dependency cycle cannot spin.
448 for (var pass = 0; pass < nodes.length + 2; pass++) {
449 var moved = false
450 for (var l = 0; l < links.length; l++) {
451 var a = links[l].from, b = links[l].to
452 if (byId[a] === undefined || byId[b] === undefined) continue
453 if (depth[b] < depth[a] + 1) { depth[b] = depth[a] + 1; moved = true }
454 }
455 if (!moved) break
456 }
457
458 // Interface nodes are pinned to the far edges.
459 for (var i = 0; i < nodes.length; i++) {
460 if (nodes[i].id === '__group_in__') depth[nodes[i].id] = -1
461 }
462 var maxD = 0
463 for (var i = 0; i < nodes.length; i++) {
464 if (nodes[i].id === '__group_out__') continue
465 if (depth[nodes[i].id] > maxD) maxD = depth[nodes[i].id]
466 }
467 for (var i = 0; i < nodes.length; i++) {
468 if (nodes[i].id === '__group_out__') depth[nodes[i].id] = maxD + 1
469 }
470
471 var cols = {}
472 for (var i = 0; i < nodes.length; i++) {
473 var d = depth[nodes[i].id]
474 if (!cols[d]) cols[d] = []
475 cols[d].push(nodes[i])
476 }
477 var keys = Object.keys(cols).map(Number).sort(function (a, b) { return a - b })
478 var x = 0
479 for (var c = 0; c < keys.length; c++) {
480 var col = cols[keys[c]]
481 // Order rows within a column by frame, so a frame's members end up adjacent
482 // and its box encloses them instead of straddling unrelated nodes.
483 col.sort(function (a, b) {
484 var fa = nvFrameFor(a) || '~', fb = nvFrameFor(b) || '~'
485 if (fa !== fb) return fa < fb ? -1 : 1
486 return a.title < b.title ? -1 : a.title > b.title ? 1 : 0
487 })
488 var y = 0
489 for (var r = 0; r < col.length; r++) {
490 col[r].x = x
491 col[r].y = y
492 y += nvNodeHeight(col[r]) + NV_ROW_GAP
493 }
494 x += NV_W + NV_COL_GAP
495 }
496}
497
498function nvNodeHeight (n) {
499 var rows = n.inputs.length + n.outputs.length
500 var h = NV_HEAD + rows * NV_ROW + 8
501 if (n.code) h += n.code.length * 9 + 8
502 return Math.max(h, NV_HEAD + 16)
503}
504
505function nvSocketPos (n, side, idx) {
506 // Outputs are listed first inside the node body, then inputs — Blender's order.
507 var row = side === 'out' ? idx : n.outputs.length + idx
508 return {
509 x: n.x + (side === 'out' ? NV_W : 0),
510 y: n.y + NV_HEAD + row * NV_ROW + NV_ROW / 2
511 }
512}
513
514// ---- frames (Blender's label nodes) ----
515//
516// A frame is a labelled box drawn behind a set of nodes. Ours come from the
517// semantic groupings the scan already carries: the categories declared in
518// spec/SYSTEM.md, falling back to the most-shared dependency. That mapping is
519// resolved once in scanner.js so the CLI and both views cannot disagree.
520var NV_FRAME_COLORS = ['#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72',
521 '#ffa657', '#79c0ff', '#f778ba', '#56d364']
522
523function nvFrameFor (n) {
524 if (!NV.showFrames) return null
525 var cats = tree.moduleCategories || {}
526 if (n.kind === 'module') return cats[n.title] || null
527 if (n.contained && n.contained.length > 0) {
528 // A directory frames under the category its contents mostly belong to.
529 var tally = {}
530 for (var i = 0; i < n.contained.length; i++) {
531 var c = cats[n.contained[i]]
532 if (c) tally[c] = (tally[c] || 0) + 1
533 }
534 var best = null, bestN = 0
535 for (var k in tally) if (tally[k] > bestN) { best = k; bestN = tally[k] }
536 return best
537 }
538 return null
539}
540
541function nvDrawFrames (parent) {
542 if (!NV.showFrames) return
543 var groups = {}
544 for (var i = 0; i < NV.nodes.length; i++) {
545 var n = NV.nodes[i]
546 if (n.isInterface) continue
547 var f = nvFrameFor(n)
548 if (!f) continue
549 if (!groups[f]) groups[f] = []
550 groups[f].push(n)
551 }
552 var names = Object.keys(groups).sort()
553 for (var g = 0; g < names.length; g++) {
554 var members = groups[names[g]]
555 if (members.length < 2) continue
556 var x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity
557 for (var i = 0; i < members.length; i++) {
558 var m = members[i]
559 if (m.x < x0) x0 = m.x
560 if (m.y < y0) y0 = m.y
561 if (m.x + NV_W > x1) x1 = m.x + NV_W
562 var h = nvNodeHeight(m)
563 if (m.y + h > y1) y1 = m.y + h
564 }
565 var pad = 16
566 var color = NV_FRAME_COLORS[g % NV_FRAME_COLORS.length]
567 var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
568 r.setAttribute('class', 'nv-frame')
569 r.setAttribute('x', x0 - pad)
570 r.setAttribute('y', y0 - pad - 16)
571 r.setAttribute('width', (x1 - x0) + pad * 2)
572 r.setAttribute('height', (y1 - y0) + pad * 2 + 16)
573 r.setAttribute('rx', 8)
574 r.setAttribute('fill', color)
575 r.setAttribute('stroke', color)
576 parent.appendChild(r)
577
578 var t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
579 t.setAttribute('class', 'nv-frame-label')
580 t.setAttribute('x', x0 - pad + 8)
581 t.setAttribute('y', y0 - pad - 3)
582 t.setAttribute('fill', color)
583 t.textContent = names[g]
584 parent.appendChild(t)
585 }
586}
587
588// ---- rendering ----
589
590function nvRender () {
591 var svg = document.getElementById('node-svg')
592 if (!svg) return
593 svg.innerHTML = '<defs></defs><g id="nv-world"><g id="nv-frames"></g><g id="nv-noodles"></g><g id="nv-nodes"></g></g>'
594 var world = document.getElementById('nv-world')
595 var frameG = document.getElementById('nv-frames')
596 var noodleG = document.getElementById('nv-noodles')
597 var nodeG = document.getElementById('nv-nodes')
598
599 var byId = {}
600 for (var i = 0; i < NV.nodes.length; i++) byId[NV.nodes[i].id] = NV.nodes[i]
601
602 for (var l = 0; l < NV.links.length; l++) {
603 var lk = NV.links[l]
604 var a = byId[lk.from], b = byId[lk.to]
605 if (!a || !b) continue
606 var p1 = nvSocketPos(a, lk.fromSide, lk.fromSock)
607 var p2 = nvSocketPos(b, lk.toSide, lk.toSock)
608 var dx = Math.max(40, Math.abs(p2.x - p1.x) * 0.5)
609 var path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
610 path.setAttribute('d', 'M ' + p1.x + ' ' + p1.y +
611 ' C ' + (p1.x + dx) + ' ' + p1.y + ', ' + (p2.x - dx) + ' ' + p2.y +
612 ', ' + p2.x + ' ' + p2.y)
613 path.setAttribute('class', 'nv-noodle')
614 path.setAttribute('stroke', '#5a6570')
615 noodleG.appendChild(path)
616 }
617
618 nvDrawFrames(frameG)
619 for (var i = 0; i < NV.nodes.length; i++) nvDrawNode(nodeG, NV.nodes[i])
620
621 nvApplyTransform()
622 nvBreadcrumb()
623 nvLegend()
624}
625
626function nvDrawNode (parent, n) {
627 var h = nvNodeHeight(n)
628 var g = document.createElementNS('http://www.w3.org/2000/svg', 'g')
629 g.setAttribute('class', 'nv-node' + (n.isInterface ? ' nv-group-node' : ''))
630 g.setAttribute('transform', 'translate(' + n.x + ',' + n.y + ')')
631
632 var body = document.createElementNS('http://www.w3.org/2000/svg', 'rect')
633 body.setAttribute('class', 'nv-node-body')
634 body.setAttribute('width', NV_W)
635 body.setAttribute('height', h)
636 body.setAttribute('rx', 5)
637 g.appendChild(body)
638
639 var head = document.createElementNS('http://www.w3.org/2000/svg', 'path')
640 head.setAttribute('class', 'nv-node-head')
641 head.setAttribute('d', 'M 0 5 a 5 5 0 0 1 5 -5 h ' + (NV_W - 10) +
642 ' a 5 5 0 0 1 5 5 v ' + (NV_HEAD - 5) + ' h ' + (-NV_W) + ' z')
643 head.setAttribute('fill', nvRoleColor(n.role))
644 g.appendChild(head)
645
646 var title = document.createElementNS('http://www.w3.org/2000/svg', 'text')
647 title.setAttribute('class', 'nv-node-title')
648 title.setAttribute('x', 7)
649 title.setAttribute('y', 14)
650 var shortT = n.title.length > 26 ? n.title.slice(0, 25) + '\\u2026' : n.title
651 title.textContent = shortT
652 g.appendChild(title)
653
654 if (n.sub) {
655 var sub = document.createElementNS('http://www.w3.org/2000/svg', 'text')
656 sub.setAttribute('class', 'nv-node-sub')
657 sub.setAttribute('x', NV_W - 7)
658 sub.setAttribute('y', 14)
659 sub.setAttribute('text-anchor', 'end')
660 sub.textContent = n.sub
661 g.appendChild(sub)
662 }
663
664 var row = 0
665 for (var o = 0; o < n.outputs.length; o++) { nvDrawSocket(g, n, n.outputs[o], 'out', row); row++ }
666 for (var i2 = 0; i2 < n.inputs.length; i2++) { nvDrawSocket(g, n, n.inputs[i2], 'in', row); row++ }
667
668 if (n.code) {
669 var top = NV_HEAD + row * NV_ROW + 6
670 for (var c = 0; c < n.code.length; c++) {
671 var ln = document.createElementNS('http://www.w3.org/2000/svg', 'text')
672 ln.setAttribute('class', 'nv-lineno')
673 ln.setAttribute('x', 5)
674 ln.setAttribute('y', top + c * 9 + 7)
675 ln.textContent = (n.line + c)
676 g.appendChild(ln)
677 var t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
678 t.setAttribute('class', 'nv-code')
679 t.setAttribute('x', 26)
680 t.setAttribute('y', top + c * 9 + 7)
681 var src = n.code[c].replace(/\\t/g, ' ')
682 if (src.length > 44) src = src.slice(0, 43) + '\\u2026'
683 t.textContent = src
684 g.appendChild(t)
685 }
686 }
687
688 if (n.openable) {
689 head.style.cursor = 'pointer'
690 title.style.cursor = 'pointer'
691 var openIt = function (e) { e.stopPropagation(); nvEnter(n.open) }
692 head.addEventListener('click', openIt)
693 title.addEventListener('click', openIt)
694 }
695
696 parent.appendChild(g)
697 n.el = g
698}
699
700function nvDrawSocket (g, n, sock, side, row) {
701 var y = NV_HEAD + row * NV_ROW + NV_ROW / 2
702 var x = side === 'out' ? NV_W : 0
703 var c = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
704 c.setAttribute('cx', x)
705 c.setAttribute('cy', y)
706 c.setAttribute('r', 4)
707 c.setAttribute('fill', sock.mated ? nvSocketColor(sock.scope, true) : '#1a1b1e')
708 c.setAttribute('stroke', nvSocketColor(sock.scope, sock.mated))
709 c.setAttribute('stroke-width', '1.5')
710 g.appendChild(c)
711
712 var t = document.createElementNS('http://www.w3.org/2000/svg', 'text')
713 t.setAttribute('class', 'nv-sock-label' + (sock.mated ? '' : ' unmated'))
714 t.setAttribute('y', y + 3)
715 if (side === 'out') {
716 t.setAttribute('x', NV_W - 9)
717 t.setAttribute('text-anchor', 'end')
718 } else {
719 t.setAttribute('x', 9)
720 }
721 var lbl = sock.name
722 if (lbl.length > 24) lbl = lbl.slice(0, 23) + '\\u2026'
723 t.textContent = lbl
724 g.appendChild(t)
725
726 var title = document.createElementNS('http://www.w3.org/2000/svg', 'title')
727 title.textContent = (side === 'out' ? 'supplies ' : 'demands ') + sock.name +
728 ' [' + sock.scope + ']' + (sock.mated ? '' : ' — UNMATED')
729 c.appendChild(title)
730}
731
732// ---- navigation ----
733
734function nvEnter (level) {
735 if (!level) return
736 NV.stack.push(level)
737 nvBuild()
738}
739
740function nvGoto (depth) {
741 NV.stack = NV.stack.slice(0, depth + 1)
742 nvBuild()
743}
744
745function nvBreadcrumb () {
746 var bc = document.getElementById('node-breadcrumb')
747 if (!bc) return
748 var html = ''
749 for (var i = 0; i < NV.stack.length; i++) {
750 if (i > 0) html += '<span class="sep">&rsaquo;</span>'
751 var last = i === NV.stack.length - 1
752 html += '<a href="#" data-d="' + i + '"' + (last ? ' class="current"' : '') + '>' +
753 escapeHtml(NV.stack[i].label) + '</a>'
754 }
755 bc.innerHTML = html
756 var as = bc.querySelectorAll('a')
757 for (var i = 0; i < as.length; i++) {
758 as[i].addEventListener('click', function (e) {
759 e.preventDefault()
760 nvGoto(parseInt(this.getAttribute('data-d'), 10))
761 })
762 }
763}
764
765function nvLegend () {
766 var el = document.getElementById('node-legend')
767 if (!el) return
768 var lvl = NV.stack[NV.stack.length - 1]
769 var what = lvl.kind === 'root' ? 'modules'
770 : lvl.kind === 'module' ? 'files in ' + lvl.name
771 : lvl.kind === 'file' ? 'symbols in ' + lvl.path
772 : ''
773 el.innerHTML =
774 '<b>' + NV.nodes.length + '</b> nodes &middot; <b>' + NV.links.length + '</b> noodles &mdash; ' + escapeHtml(what) +
775 '<br><span style="color:#7ee787">&#9679;</span> mated &nbsp;' +
776 '<span style="color:#6e7076">&#9675;</span> unmated &nbsp;' +
777 '<span style="color:#f85149">&#9675;</span> unresolved' +
778 '<br>left sockets demand &middot; right sockets supply' +
779 '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
780 '<input type="checkbox" id="nv-only-connected"' + (NV.onlyConnected ? ' checked' : '') +
781 ' style="vertical-align:-1px;accent-color:#58a6ff"> collapse to only connected</label>' +
782 '<br><label style="pointer-events:auto;cursor:pointer;color:#c9d1d9">' +
783 '<input type="checkbox" id="nv-frames"' + (NV.showFrames ? ' checked' : '') +
784 ' style="vertical-align:-1px;accent-color:#58a6ff"> semantic frames</label>'
785
786 var cb = document.getElementById('nv-only-connected')
787 if (cb) cb.addEventListener('change', function () { nvToggleConnected(this.checked) })
788 var fb = document.getElementById('nv-frames')
789 if (fb) fb.addEventListener('change', function () { nvToggleFrames(this.checked) })
790}
791
792// ---- build per level ----
793
794function nvBuild () {
795 var lvl = NV.stack[NV.stack.length - 1]
796 NV.nodes = []
797 NV.links = []
798
799 if (lvl.kind === 'path') {
800 var pn = nvPathNodes(lvl.path)
801 var pl = nvPathLinks(pn)
802 var filtered = nvApplyCollapse(pn, pl)
803 NV.nodes = filtered.nodes
804 NV.links = filtered.links
805 nvLayout(NV.nodes, NV.links)
806 nvRender()
807 return
808 }
809
810 if (lvl.kind === 'module') {
811 fetch(API + '/api/sections?module=' + encodeURIComponent(lvl.name))
812 .then(function (r) { return r.json() })
813 .then(function (data) {
814 if (!data || !data.sections) { NV.nodes = []; nvRender(); return }
815 var built = nvFileNodes(data)
816 var iface = nvGroupInterface(lvl, built.nodes)
817 var all = built.nodes.slice()
818 if (iface.gin) all.unshift(iface.gin)
819 if (iface.gout) all.push(iface.gout)
820 var fl = nvFileLinks(built.nodes).concat(iface.links)
821 var col = nvApplyCollapse(all, fl)
822 NV.nodes = col.nodes
823 NV.links = col.links
824 nvLayout(NV.nodes, NV.links)
825 nvRender()
826 })
827 .catch(function () { NV.nodes = []; nvRender() })
828 return
829 }
830
831 if (lvl.kind === 'file') {
832 var syms = nvSymbolNodes(lvl.module, lvl.path)
833 var iface = nvGroupInterface(lvl, syms)
834 var all = syms.slice()
835 if (iface.gin) all.unshift(iface.gin)
836 if (iface.gout) all.push(iface.gout)
837 NV.nodes = all
838 NV.links = iface.links
839 nvLayout(NV.nodes, NV.links)
840 nvRender()
841 // The bottom of the ladder: fetch the real source and put it in the nodes.
842 nvLoadSource(lvl, syms)
843 return
844 }
845}
846
847// Read the file once and slice each symbol's own lines out of it, using the
848// range the parser recorded. This is what makes the lowest nodes show code.
849function nvLoadSource (lvl, syms) {
850 var mod = tree.modules[lvl.module]
851 if (!mod) return
852 var full = (mod.path && mod.path !== '.') ? mod.path + '/' + lvl.path : lvl.path
853 var cached = NV.sourceCache[full]
854
855 function apply (lines) {
856 for (var i = 0; i < syms.length; i++) {
857 var s = syms[i]
858 if (!s.line) continue
859 var end = s.endLine && s.endLine >= s.line ? s.endLine : s.line
860 if (end - s.line > 13) end = s.line + 13
861 s.code = lines.slice(s.line - 1, end)
862 }
863 nvLayout(NV.nodes, NV.links)
864 nvRender()
865 }
866
867 if (cached) { apply(cached); return }
868 fetch(API + '/api/file?path=' + encodeURIComponent(full))
869 .then(function (r) { return r.json() })
870 .then(function (d) {
871 if (!d || d.error || !d.content) return
872 var lines = d.content.split('\\n')
873 NV.sourceCache[full] = lines
874 apply(lines)
875 })
876 .catch(function () {})
877}
878
879// ---- pan / zoom ----
880
881function nvApplyTransform () {
882 var w = document.getElementById('nv-world')
883 if (!w) return
884 w.setAttribute('transform', 'translate(' + NV.panX.toFixed(1) + ',' + NV.panY.toFixed(1) +
885 ') scale(' + NV.zoom.toFixed(3) + ')')
886}
887
888function nvInit () {
889 if (NV.initialized) return
890 NV.initialized = true
891 var svg = document.getElementById('node-svg')
892
893 svg.addEventListener('wheel', function (e) {
894 e.preventDefault()
895 var f = e.deltaY > 0 ? 0.9 : 1.1
896 var nz = Math.max(0.08, Math.min(4, NV.zoom * f))
897 var r = svg.getBoundingClientRect()
898 var mx = e.clientX - r.left, my = e.clientY - r.top
899 NV.panX = mx - (mx - NV.panX) * (nz / NV.zoom)
900 NV.panY = my - (my - NV.panY) * (nz / NV.zoom)
901 NV.zoom = nz
902 nvApplyTransform()
903 }, { passive: false })
904
905 svg.addEventListener('mousedown', function (e) {
906 NV.panning = true
907 NV.panSX = e.clientX - NV.panX
908 NV.panSY = e.clientY - NV.panY
909 svg.classList.add('dragging')
910 })
911 document.addEventListener('mousemove', function (e) {
912 if (!NV.panning) return
913 NV.panX = e.clientX - NV.panSX
914 NV.panY = e.clientY - NV.panSY
915 nvApplyTransform()
916 })
917 document.addEventListener('mouseup', function () {
918 NV.panning = false
919 svg.classList.remove('dragging')
920 })
921
922 nvBuild()
923}
924`
lib/render.js +99 -16
……@@ -1,5 +1,10 @@
1import { NODE_VIEW_CSS, NODE_VIEW_HTML, NODE_VIEW_JS } from './render-nodes.js'
2
13export function renderPage (tree, prefix) {
2 const treeJSON = JSON.stringify(tree)
4 // `<` is escaped to its JSON unicode form so nothing in the scanned data —
5 // a JSDoc comment, a file path, a role string — can close the <script> tag
6 // it is embedded in and truncate the whole application.
7 const treeJSON = JSON.stringify(tree).replace(/</g, '\\u003c')
38 const apiPrefix = prefix || ''
49
510 return `<!DOCTYPE html>
……@@ -485,6 +490,7 @@ h2 { font-size: 14px; color: #8b949e; margin-bottom: 6px; font-weight: normal; }
485490 display: none;
486491}
487492.edge-hover-label.visible { display: block; }
493${NODE_VIEW_CSS}
488494.graph-breadcrumb {
489495 position: absolute;
490496 top: 12px; left: 12px;
……@@ -507,6 +513,7 @@ h2 { font-size: 14px; color: #8b949e; margin-bottom: 6px; font-weight: normal; }
507513 <div class="tabs">
508514 <button class="tab main-tab active" data-tab="tree">Tree</button>
509515 <button class="tab main-tab" data-tab="graph">Graph</button>
516 <button class="tab main-tab" data-tab="nodes">Nodes</button>
510517 <button class="tab main-tab" data-tab="validate">Validate</button>
511518 <button class="tab main-tab" data-tab="docs">Docs</button>
512519 </div>
……@@ -556,6 +563,7 @@ h2 { font-size: 14px; color: #8b949e; margin-bottom: 6px; font-weight: normal; }
556563 </div>
557564 <div class="graph-tooltip" id="graph-tooltip"></div>
558565 </div>
566${NODE_VIEW_HTML}
559567 </div>
560568 <div id="resize-right" class="resize-handle" style="display:none"></div>
561569 <div id="right-panel" class="panel" style="width:360px;border-left:1px solid #21262d;display:none;overflow-y:auto;padding:12px 12px 12px 16px">
……@@ -590,11 +598,13 @@ document.querySelectorAll('.main-tab').forEach(tab => {
590598
591599 const detail = document.getElementById('detail')
592600 const graphContainer = document.getElementById('graph-container')
601 const nodeContainer = document.getElementById('node-container')
593602 const valPanel = document.getElementById('right-panel')
594603
595604 // In graph mode, hide the detail/file-viewer panel — right panel handles everything
596 detail.style.display = currentTab === 'graph' ? 'none' : ''
605 detail.style.display = (currentTab === 'graph' || currentTab === 'nodes') ? 'none' : ''
597606 graphContainer.classList.toggle('active', currentTab === 'graph')
607 nodeContainer.classList.toggle('active', currentTab === 'nodes')
598608
599609 const rightPanel = document.getElementById('right-panel')
600610 const resizeRight = document.getElementById('resize-right')
……@@ -616,6 +626,10 @@ document.querySelectorAll('.main-tab').forEach(tab => {
616626 renderDocsCruft()
617627 } else if (currentTab === 'graph') {
618628 if (!graphInitialized) { initGraph(); updateBreadcrumb() }
629 } else if (currentTab === 'nodes') {
630 rightPanel.style.display = 'none'
631 resizeRight.style.display = 'none'
632 nvInit()
619633 }
620634 })
621635})
……@@ -1133,6 +1147,48 @@ function showDetail (type, data) {
11331147 panel.innerHTML = html
11341148}
11351149
1150// ====== CONNECTOR INDEX ======
1151//
1152// Imports are minus connectors, exports are plus. An edge is the MATING of one
1153// germ's minus-name with another germ's plus-name. What matters visually is
1154// the connectors that DON'T mate: an unmated plus is an export nothing takes,
1155// an unmated minus is an import nothing supplies. Those are drawn as stubs.
1156//
1157// NOTE: no backticks anywhere below — this whole file body is one template
1158// literal, so a raw backtick terminates it.
1159//
1160// Fetched from /api/sections rather than inlined: the tree JSON embedded in
1161// this page is already large, and connectors would multiply it.
1162let sectionData = null
1163
1164function loadSections () {
1165 if (sectionData) return Promise.resolve(sectionData)
1166 return fetch(API + '/api/sections')
1167 .then(r => r.json())
1168 .then(data => {
1169 if (data && data.perModule) sectionData = data
1170 return sectionData
1171 })
1172 .catch(() => null)
1173}
1174
1175function misfitsFor (name) {
1176 if (!sectionData || !sectionData.perModule) return null
1177 return sectionData.perModule[name] || null
1178}
1179
1180// One short line for the tooltip.
1181function misfitSummaryFor (name) {
1182 const m = misfitsFor(name)
1183 if (!m) return ''
1184 const parts = []
1185 if (m.dead) parts.push('<span style="color:#d29922">' + m.dead + ' dead export' + (m.dead === 1 ? '' : 's') + '</span>')
1186 if (m.unresolved) parts.push('<span style="color:#ff7b72">' + m.unresolved + ' unresolved</span>')
1187 if (m.leak) parts.push('<span style="color:#ff7b72">' + m.leak + ' undeclared dep' + (m.leak === 1 ? '' : 's') + '</span>')
1188 if (m.unused) parts.push('<span style="color:#8b949e">' + m.unused + ' unused dep' + (m.unused === 1 ? '' : 's') + '</span>')
1189 return parts.join(' &middot; ')
1190}
1191
11361192// ====== GRAPH VIEW ======
11371193let graphInitialized = false
11381194const graphNodes = []
……@@ -1446,6 +1502,13 @@ function initGraph () {
14461502 edge.el = line
14471503 }
14481504
1505 // Set by the document-level mousemove handler registered further down, read
1506 // by every node's click handler. It MUST live out here: when it was declared
1507 // inside the loop below, the mousemove handler — registered after the loop
1508 // closes — assigned to an implicit global instead, so the per-node copy was
1509 // never true and releasing a drag also fired the node's click action.
1510 let didDrag = false
1511
14491512 // Draw nodes
14501513 for (let i = 0; i < graphNodes.length; i++) {
14511514 const n = graphNodes[i]
……@@ -1511,10 +1574,7 @@ function initGraph () {
15111574 n.el = g
15121575
15131576 // Node drag
1514 let dragStarted = false
1515 let didDrag = false
15161577 g.addEventListener('mousedown', (e) => {
1517 dragStarted = true
15181578 didDrag = false
15191579 n.fixed = true
15201580 e.stopPropagation()
……@@ -1801,6 +1861,7 @@ function initGraph () {
18011861 simRafId = requestAnimationFrame(simulate)
18021862 }
18031863 showGraphSettings()
1864 loadSections()
18041865}
18051866
18061867// --- Focus a module: highlight its connections ---
……@@ -1865,13 +1926,28 @@ function unfocusAll () {
18651926
18661927function showTooltip (n) {
18671928 const tooltip = document.getElementById('graph-tooltip')
1868 const depsOn = n.dependsOn.map(t => graphNodes[t].name)
1869 const usedBy = n.usedBy.map(s => graphNodes[s].name)
1870 tooltip.innerHTML = '<strong>' + n.name + '</strong> ' + n.mod.version +
1871 '<br>' + n.fileCount + ' files, ' + n.memberCount + ' members, ' + n.mod.publicAPI.length + ' exports' +
1872 (depsOn.length ? '<br><span class="tt-deps">depends on: ' + depsOn.join(', ') + '</span>' : '') +
1873 (usedBy.length ? '<br><span class="tt-used">used by: ' + usedBy.join(', ') + '</span>' : '') +
1874 (n.mod.publicAPI.length ? '<br>API: ' + n.mod.publicAPI.slice(0, 6).join(', ') + (n.mod.publicAPI.length > 6 ? '...' : '') : '')
1929
1930 // Directory nodes carry mod === null. Reading n.mod.version unconditionally
1931 // threw on every hover over a directory, killing the tooltip entirely.
1932 if (!n.mod) {
1933 tooltip.innerHTML = '<strong>' + escapeHtml(n.name) + '/</strong>' +
1934 '<br>' + (n.packageCount || 0) + ' packages inside' +
1935 '<br><span style="color:#484f58">click to descend</span>'
1936 tooltip.style.display = 'block'
1937 return
1938 }
1939
1940 const depsOn = (n.dependsOn || []).map(t => graphNodes[t].name)
1941 const usedBy = (n.usedBy || []).map(s => graphNodes[s].name)
1942 const api = n.mod.publicAPI || []
1943 const misfit = misfitSummaryFor(n.name)
1944
1945 tooltip.innerHTML = '<strong>' + escapeHtml(n.name) + '</strong> ' + escapeHtml(n.mod.version) +
1946 '<br>' + n.fileCount + ' files, ' + n.memberCount + ' members, ' + api.length + ' exports' +
1947 (misfit ? '<br>' + misfit : '') +
1948 (depsOn.length ? '<br><span class="tt-deps">depends on: ' + escapeHtml(depsOn.join(', ')) + '</span>' : '') +
1949 (usedBy.length ? '<br><span class="tt-used">used by: ' + escapeHtml(usedBy.join(', ')) + '</span>' : '') +
1950 (api.length ? '<br>API: ' + escapeHtml(api.slice(0, 6).join(', ')) + (api.length > 6 ? '...' : '') : '')
18751951 tooltip.style.display = 'block'
18761952}
18771953
……@@ -2699,6 +2775,11 @@ function renderSubGraph (svg, cx, cy, W, H, nodeType) {
26992775 }
27002776 }
27012777
2778 // Shared by every node's click handler and the moveHandler registered below.
2779 // Declared out here for the same reason as in initGraph: a per-node copy is
2780 // invisible to a handler created after the loop closes.
2781 let didDrag = false
2782
27022783 // Draw nodes
27032784 for (let i = 0; i < graphNodes.length; i++) {
27042785 const n = graphNodes[i]
……@@ -2764,7 +2845,6 @@ function renderSubGraph (svg, cx, cy, W, H, nodeType) {
27642845 n.el = g
27652846
27662847 // Drag
2767 let didDrag = false
27682848 g.addEventListener('mousedown', (e) => {
27692849 n.fixed = true; didDrag = false
27702850 e.stopPropagation(); e.preventDefault()
……@@ -3834,9 +3914,10 @@ window.loadFileViewer = loadFileViewer
38343914function roleIcon (role) {
38353915 return ({ entry: 'E', lib: 'L', test: 'T', schema: 'S', bin: 'B', script: 'X', example: 'x' })[role] || 'F'
38363916}
3837function escapeHtml (s) {
3838 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
3839}
3917// NOTE: escapeHtml is declared once, near the URL-routing helpers. A second,
3918// weaker copy used to live here — and because a later function declaration
3919// silently replaces an earlier one, THAT was the version the whole page used:
3920// no quote escaping and no String() coercion.
38403921
38413922// ====== RESIZABLE PANELS ======
38423923function initResizable () {
……@@ -3879,6 +3960,8 @@ function makeResizable (handleId, panelId, side) {
38793960 })
38803961}
38813962
3963${NODE_VIEW_JS}
3964
38823965// ====== INIT ======
38833966buildTree()
38843967initResizable()
lib/scan-stream.js +24 -2
……@@ -206,16 +206,37 @@ function walkFile (emitter, filePath, pkgDir, modName, visited, root, ignore, ct
206206
207207 // Parse eagerly and emit. (Workers and mtime cache are future optimizations
208208 // that plug in here — they just change who produces the file-parsed event.)
209 let parsed = null
209210 try {
210 const parsed = lang.parse(source, relInPkg)
211 parsed = lang.parse(source, relInPkg)
211212 emitter.emit('file-parsed', { modName, relInPkg, relPath, parsed })
212213 } catch (err) {
213214 emitter.emit('file-parsed', { modName, relInPkg, relPath, parsed: { error: err.message, filePath: relInPkg } })
214215 }
215216
217 // Imported symbol names per specifier, taken from the AST parse. The edge
218 // carries these so downstream consumers can mate a specific demand (`-name`)
219 // against the specific export that supplies it, instead of re-deriving the
220 // association by string-matching specifiers later.
221 const namesBySpec = new Map()
222 if (parsed && Array.isArray(parsed.imports)) {
223 for (const imp of parsed.imports) {
224 if (!imp || !imp.specifier) continue
225 const prev = namesBySpec.get(imp.specifier) || []
226 namesBySpec.set(imp.specifier, prev.concat(imp.names || []))
227 }
228 }
229
216230 // Resolve imports (explicit + implicit) and emit edges for each.
231 //
232 // The specifier list comes from the AST whenever the file parsed. The regex
233 // extractor in lang.js cannot see a multi-line import statement — its `.`
234 // does not cross newlines — so every wrapped `import {\n a,\n b\n} from 'x'`
235 // silently produced no edge at all. Fall back to the regex only when there
236 // is no usable parse (parse error, or a language with no AST import list).
217237 let count = 1
218 const explicit = lang.extractImports(source)
238 const parsedOk = parsed && !parsed.error && Array.isArray(parsed.imports)
239 const explicit = parsedOk ? [...namesBySpec.keys()] : lang.extractImports(source)
219240 for (const spec of explicit) {
220241 const resolved = lang.resolveImport(filePath, spec, ctx)
221242 if (!resolved) continue
……@@ -224,6 +245,7 @@ function walkFile (emitter, filePath, pkgDir, modName, visited, root, ignore, ct
224245 fromRelInPkg: relInPkg,
225246 specifier: spec,
226247 resolvedRelPath: relative(root, resolved),
248 names: namesBySpec.get(spec) || [],
227249 implicit: false
228250 })
229251 count += walkFile(emitter, resolved, pkgDir, modName, visited, root, ignore, ctx)
lib/scanner.js +38 -0
……@@ -162,6 +162,11 @@ export async function scan (rootDir, opts = {}) {
162162
163163 tree.validation = validation
164164 tree.categories = Object.keys(categories).length > 0 ? categories : null
165
166 // Resolve every module to one category, once, here — curated SYSTEM.md
167 // groupings first, then the shared-dependency fallback. The CLI and both
168 // views need this mapping; computing it in each of them is how they drift.
169 tree.moduleCategories = assignCategories(tree, categories)
165170 tree.docs = {
166171 specDir: docs.specDir || null,
167172 specCount: docs.specs.length,
……@@ -170,3 +175,36 @@ export async function scan (rootDir, opts = {}) {
170175
171176 return tree
172177}
178
179/**
180 * Map every module to exactly one category name.
181 *
182 * Two sources, in order:
183 * 1. curated — the groupings declared in spec/SYSTEM.md
184 * 2. derived — the dependency a module shares with the most other modules,
185 * which is the strongest relatedness signal available without authoring
186 * anything. Reported as "<dep> users" so it is obvious it was inferred.
187 *
188 * A module that matches neither lands in "Other".
189 */
190function assignCategories (tree, categories) {
191 const assigned = {}
192 for (const [cat, mods] of Object.entries(categories || {})) {
193 for (const m of mods) assigned[m] = cat
194 }
195
196 const shared = tree.system.sharedDeps || {}
197 const topDep = {}
198 for (const [dep, users] of Object.entries(shared)) {
199 for (const u of users) {
200 const prev = topDep[u]
201 if (!prev || users.length > shared[prev].length) topDep[u] = dep
202 }
203 }
204
205 for (const name of tree.system.modules) {
206 if (assigned[name]) continue
207 assigned[name] = topDep[name] ? topDep[name] + ' users' : 'Other'
208 }
209 return assigned
210}
lib/sections.js +305 -0
……@@ -0,0 +1,305 @@
1/**
2 * Sections — the connector model over a scanned tree.
3 *
4 * A germ (a file, or a module) plus its typed connectors is a Section.
5 * Imports are `-` connectors (demands). Exports are `+` connectors (supplies).
6 * A dependency edge is not primitive: it is the MATING of one germ's `-name`
7 * with another germ's `+name`. That is exactly what a module resolver does.
8 *
9 * The payoff is the connectors that DON'T mate:
10 * dead an unmated `+` — exported, nothing takes it
11 * unresolved an unmated `-` — imported, nothing supplies it
12 * leak a module whose files demand a package its package.json omits
13 *
14 * Scales currently built: 'file' and 'module'. A 'member' scale (params and
15 * returns as connectors) needs call-site extraction, which no parser does yet.
16 */
17
18// A file's exports are its public boundary when it is the module's entry —
19// nothing inside the scan needs to import them for them to be legitimate.
20function entryKeyFor (mod) {
21 if (!mod.entry) return mod.files['index.js'] ? 'index.js' : null
22 return mod.files[mod.entry] ? mod.entry : (mod.files['index.js'] ? 'index.js' : null)
23}
24
25// Node/Bare builtins and bare specifiers resolve outside the scan by design.
26function isBareSpecifier (spec) {
27 return !spec.startsWith('.') && !spec.startsWith('/')
28}
29
30// Runtime builtins are supplied by the runtime, never by package.json. A
31// project may additionally redirect them through package.json "imports"
32// (bare-fs for fs, and so on) — either way they are not a missing dependency.
33const BUILTINS = new Set([
34 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console',
35 'constants', 'crypto', 'dgram', 'dns', 'events', 'fs', 'http', 'http2',
36 'https', 'module', 'net', 'os', 'path', 'perf_hooks', 'process', 'punycode',
37 'querystring', 'readline', 'repl', 'stream', 'string_decoder', 'sys',
38 'timers', 'tls', 'tty', 'url', 'util', 'v8', 'vm', 'worker_threads', 'zlib'
39])
40
41function isRuntimeSupplied (spec, mod) {
42 const bare = spec.startsWith('node:') ? spec.slice(5) : spec
43 if (BUILTINS.has(bare) || BUILTINS.has(bare.split('/')[0])) return true
44 const maps = mod.importMaps || {}
45 if (maps[spec]) return true
46 // package.json "imports" supports trailing wildcards, e.g. "fs/*"
47 for (const key of Object.keys(maps)) {
48 if (key.endsWith('*') && spec.startsWith(key.slice(0, -1))) return true
49 }
50 return false
51}
52
53export function buildSections (tree, opts = {}) {
54 const config = opts.config || tree.config || {}
55 const externalPackages = new Set(config.externalPackages || [])
56
57 const sections = []
58 const byId = new Map()
59 const connectors = new Map()
60 let connSeq = 0
61
62 function addConn (section, conn) {
63 conn.id = 'c' + (connSeq++)
64 conn.germId = section.id
65 // A `-` mates at most one `+`. A `+` is a BROADCAST supply — one export can
66 // serve every file that imports it — so it mates 0..N demands. This is where
67 // code departs from a literal jigsaw, whose tabs are strictly 1:1.
68 conn.mate = null
69 conn.mates = []
70 section.conns.push(conn)
71 connectors.set(conn.id, conn)
72 return conn
73 }
74
75 for (const [modName, mod] of Object.entries(tree.modules)) {
76 const entryKey = entryKeyFor(mod)
77
78 // root-relative resolved path -> package-relative file key, so an edge's
79 // `resolved` can be turned back into the germ it points at.
80 const resolvedToKey = new Map()
81 for (const filePath of Object.keys(mod.files)) {
82 const rootRel = mod.path === '.' ? filePath : mod.path + '/' + filePath
83 resolvedToKey.set(rootRel, filePath)
84 }
85
86 // --- file-scale germs ---
87 for (const [filePath, file] of Object.entries(mod.files)) {
88 const section = {
89 id: 'file:' + modName + ':' + filePath,
90 scale: 'file',
91 germ: {
92 name: filePath,
93 module: modName,
94 path: mod.path === '.' ? filePath : mod.path + '/' + filePath,
95 role: file.role || 'lib',
96 isEntry: filePath === entryKey
97 },
98 conns: []
99 }
100
101 // `+` — every export is a supply. A re-export (`export { x } from './y'`)
102 // is a pass-through tab: it is wired to another germ's tab, not to a
103 // definition in this file.
104 for (const exp of file.exports || []) {
105 if (!exp.name || exp.name === '*') continue
106 addConn(section, {
107 pol: '+',
108 type: exp.name,
109 scope: 'local',
110 reExport: !!exp.from,
111 from: exp.from || null,
112 site: { file: filePath }
113 })
114 }
115
116 // `-` — every imported name is a demand. Local edges carry the resolved
117 // target so mating is per-symbol, not per-file.
118 const resolvedSpecs = new Set()
119 for (const edge of file.localImports || []) {
120 resolvedSpecs.add(edge.specifier)
121 const targetKey = resolvedToKey.get(edge.resolved)
122 for (const name of edge.names || []) {
123 addConn(section, {
124 pol: '-',
125 type: name,
126 scope: 'local',
127 target: targetKey ? 'file:' + modName + ':' + targetKey : null,
128 specifier: edge.specifier,
129 site: { file: filePath }
130 })
131 }
132 }
133
134 // Imports the resolver never produced an edge for: either a package
135 // (expected to live outside the scan) or a relative path that points at
136 // nothing (a real broken import).
137 for (const imp of file.imports || []) {
138 if (!imp.specifier || resolvedSpecs.has(imp.specifier)) continue
139 const bare = isBareSpecifier(imp.specifier)
140 const scope = bare
141 ? (externalPackages.has(imp.specifier) || mod.dependencies[imp.specifier] ? 'external' : 'package')
142 : 'unresolved'
143 for (const name of imp.names || []) {
144 addConn(section, {
145 pol: '-',
146 type: name,
147 scope,
148 target: null,
149 specifier: imp.specifier,
150 site: { file: filePath }
151 })
152 }
153 }
154
155 sections.push(section)
156 byId.set(section.id, section)
157 }
158
159 // --- module-scale germ ---
160 const modSection = {
161 id: 'module:' + modName,
162 scale: 'module',
163 germ: { name: modName, module: modName, path: mod.path, version: mod.version },
164 conns: []
165 }
166 for (const name of mod.publicAPI || []) {
167 addConn(modSection, { pol: '+', type: name, scope: 'local', site: { file: entryKey } })
168 }
169 for (const dep of Object.keys(mod.dependencies || {})) {
170 addConn(modSection, { pol: '-', type: dep, scope: 'package', site: { file: 'package.json' } })
171 }
172 sections.push(modSection)
173 byId.set(modSection.id, modSection)
174 }
175
176 // --- mating ---
177 // A `-` mates the `+` of the same type on the germ its specifier resolved to.
178 for (const section of sections) {
179 if (section.scale !== 'file') continue
180 for (const conn of section.conns) {
181 if (conn.pol !== '-' || conn.mate || !conn.target) continue
182 const target = byId.get(conn.target)
183 if (!target) continue
184 const supply = target.conns.find(c => c.pol === '+' && c.type === conn.type)
185 if (!supply) continue
186 conn.mate = supply.id
187 conn.mates = [supply.id]
188 supply.mates.push(conn.id)
189 if (!supply.mate) supply.mate = conn.id
190 }
191 }
192
193 // Module `-` (a package.json dep) mates the module germ that supplies it.
194 for (const section of sections) {
195 if (section.scale !== 'module') continue
196 for (const conn of section.conns) {
197 if (conn.pol !== '-') continue
198 const target = byId.get('module:' + conn.type)
199 if (target) conn.mate = 'module:' + conn.type
200 }
201 }
202
203 const index = {
204 sections,
205 byId,
206 connectorById (id) { return connectors.get(id) || null },
207
208 /** Every connector that found no partner, grouped by what that means. */
209 misfits () {
210 const dead = []
211 const unresolved = []
212 for (const section of sections) {
213 if (section.scale !== 'file') continue
214 for (const conn of section.conns) {
215 if (conn.mates.length > 0) continue
216 if (conn.pol === '+') {
217 // Entry-file exports face outward — the module's public surface.
218 if (section.germ.isEntry) continue
219 dead.push({ type: conn.type, germ: section.germ.path, scale: 'file', conn })
220 } else if (conn.scope === 'unresolved' || (conn.scope === 'local' && conn.target)) {
221 unresolved.push({
222 type: conn.type,
223 germ: section.germ.path,
224 specifier: conn.specifier,
225 conn
226 })
227 }
228 }
229 }
230 return { dead, unresolved, leak: leaks(), unused: unusedDeps() }
231 },
232
233 /** One line per germ: the connector set, stably ordered. */
234 disjunct (section) {
235 const minus = section.conns.filter(c => c.pol === '-')
236 .map(c => '-' + c.type).sort()
237 const plus = section.conns.filter(c => c.pol === '+')
238 .map(c => '+' + c.type).sort()
239 return section.germ.name + ': ' + [...minus, ...plus].join(' ')
240 }
241 }
242
243 // Every package specifier the module's files actually demand.
244 function demandedPackages (modName) {
245 const out = new Set()
246 for (const section of sections) {
247 if (section.scale !== 'file' || section.germ.module !== modName) continue
248 for (const conn of section.conns) {
249 if (conn.pol !== '-') continue
250 if (conn.scope === 'package' || conn.scope === 'external') out.add(conn.specifier)
251 }
252 }
253 return out
254 }
255
256 // A module leaks when its files demand a package its package.json omits.
257 // Runtime builtins are supplied by the runtime, so they never count.
258 function leaks () {
259 const out = []
260 for (const [modName, mod] of Object.entries(tree.modules)) {
261 const declared = new Set([
262 ...Object.keys(mod.dependencies || {}),
263 ...Object.keys(mod.devDependencies || {})
264 ])
265 const seen = new Set()
266 for (const section of sections) {
267 if (section.scale !== 'file' || section.germ.module !== modName) continue
268 for (const conn of section.conns) {
269 if (conn.pol !== '-' || conn.scope !== 'package') continue
270 if (declared.has(conn.specifier) || seen.has(conn.specifier)) continue
271 if (isRuntimeSupplied(conn.specifier, mod)) continue
272 seen.add(conn.specifier)
273 out.push({ module: modName, specifier: conn.specifier, germ: section.germ.path })
274 }
275 }
276 }
277 return out
278 }
279
280 // The mirror of a leak: package.json declares a dependency that no file in
281 // the module ever demands. An unmated `-` at module scale.
282 function unusedDeps () {
283 const out = []
284 for (const [modName, mod] of Object.entries(tree.modules)) {
285 const demanded = demandedPackages(modName)
286 const allDeclared = { ...(mod.dependencies || {}), ...(mod.devDependencies || {}) }
287 for (const dep of Object.keys(allDeclared)) {
288 // A local dep is satisfied by the module germ it points at, not by an
289 // import — don't report workspace siblings as unused.
290 if (tree.modules[dep]) continue
291 if (demanded.has(dep)) continue
292 // Scoped/subpath imports: `foo` is used if anything imports `foo/bar`.
293 let viaSubpath = false
294 for (const spec of demanded) {
295 if (spec.startsWith(dep + '/')) { viaSubpath = true; break }
296 }
297 if (viaSubpath) continue
298 out.push({ module: modName, specifier: dep })
299 }
300 }
301 return out
302 }
303
304 return index
305}
lib/server.js +82 -0
……@@ -20,6 +20,11 @@ export function startServer (tree, opts = {}) {
2020 // Live store — subscribable event stream fed by fs.watch. opts.watch === false
2121 // disables it (useful for one-shot CLI serve, or when the inotify limit
2222 // would be exceeded by recursive watching of a huge tree).
23 // Built on first request to /api/sections, then reused. Building it walks
24 // every file's connectors, which is not worth doing at boot for a user who
25 // never opens the graph.
26 let sectionsCache = null
27
2328 const live = opts.graph
2429 ? new LiveStore(opts.graph, { watch: opts.watch !== false })
2530 : null
……@@ -89,6 +94,83 @@ export function startServer (tree, opts = {}) {
8994 return
9095 }
9196
97 // Connector index. Served on demand rather than inlined into the page:
98 // the full tree is already ~77KB per module in the HTML, and folding every
99 // connector into it would multiply that. The UI fetches this once.
100 //
101 // GET /api/sections per-module misfit counts (small)
102 // GET /api/sections?full=1 every section with its connectors
103 if (url === '/api/sections') {
104 ;(async () => {
105 try {
106 if (!sectionsCache) {
107 const { buildSections } = await import('./sections.js')
108 const index = buildSections(tree)
109 const m = index.misfits()
110
111 // Roll misfits up per module — that is all the graph needs to size
112 // and colour a node's stubs.
113 const perModule = {}
114 function bump (modName, key) {
115 if (!modName) return
116 if (!perModule[modName]) perModule[modName] = { dead: 0, unresolved: 0, leak: 0, unused: 0 }
117 perModule[modName][key]++
118 }
119 const germModule = new Map()
120 for (const s of index.sections) germModule.set(s.germ.path, s.germ.module)
121 for (const d of m.dead) bump(germModule.get(d.germ), 'dead')
122 for (const u of m.unresolved) bump(germModule.get(u.germ), 'unresolved')
123 for (const l of m.leak) bump(l.module, 'leak')
124 for (const u of m.unused) bump(u.module, 'unused')
125
126 sectionsCache = {
127 perModule,
128 totals: {
129 dead: m.dead.length,
130 unresolved: m.unresolved.length,
131 leak: m.leak.length,
132 unused: m.unused.length
133 },
134 index
135 }
136 }
137
138 const params = query ? new URLSearchParams(query) : null
139
140 // One module's file-scale sections, with every connector. This is
141 // what the socket view renders, and scoping it to a module keeps the
142 // payload bounded no matter how large the scan is.
143 const wantModule = params && params.get('module')
144 if (wantModule) {
145 const subset = sectionsCache.index.sections.filter(
146 s => s.scale === 'file' && s.germ.module === wantModule
147 )
148 sendJSON(req, res, 200, {
149 module: wantModule,
150 sections: subset,
151 misfits: sectionsCache.perModule[wantModule] || null
152 })
153 return
154 }
155
156 if (params && params.get('full')) {
157 sendJSON(req, res, 200, {
158 sections: sectionsCache.index.sections,
159 totals: sectionsCache.totals
160 })
161 } else {
162 sendJSON(req, res, 200, {
163 perModule: sectionsCache.perModule,
164 totals: sectionsCache.totals
165 })
166 }
167 } catch (err) {
168 sendJSON(req, res, 500, { error: err.message || String(err) })
169 }
170 })()
171 return
172 }
173
92174 // Server-Sent Events stream: replay current state as events, then push
93175 // live events (file changes detected by fs.watch) as they happen.
94176 //
lib/tree.js +4 -0
……@@ -39,6 +39,10 @@ export function buildTree (graph) {
3939 type: mod.type,
4040 entry: mod.entry ? basename(mod.entry) : null,
4141 dependencies: mod.dependencies,
42 // devDependencies are declared too — a package imported only by tests or
43 // examples is not a missing dependency. Without this the tree could not
44 // tell "undeclared" from "declared as a dev dep".
45 devDependencies: mod.devDependencies || {},
4246 importMaps: Object.keys(mod.importMaps).length > 0 ? mod.importMaps : undefined,
4347 testCommand: mod.testCommand,
4448 publicAPI: [],
test/derive.test.js +223 -0
……@@ -0,0 +1,223 @@
1import { test } from 'brittle'
2import { resolve, dirname } from 'path'
3import { fileURLToPath } from 'url'
4import fs from 'fs'
5import { deriveFile } from '../lib/derive.js'
6
7const HERE = dirname(fileURLToPath(import.meta.url))
8const FIXTURE = resolve(HERE, 'fixtures/derive')
9
10// Ground truth for fixtures/derive/sample.js, counted by hand from the source.
11//
12// tally()
13// !rows unary op + GUARD zone (return null)
14// return null return op + exit -> function output
15// for (const row of rows) FOREACH zone, item 'row', carried ['total']
16// row.skip collapses to a socket label (R5), not a node
17// continue exit -> the foreach zone's entry, in a GUARD zone
18// total + row.count binary op
19// total = ... assign op (a new version of the 'total' wire)
20// total > limit binary op + GUARD zone (break)
21// break exit -> the foreach zone's exit
22// total > 0 binary op
23// if/else TWO-EXIT zone; BOTH paths exit, so no convergence
24// return total, return 0 two return ops + two exits
25//
26// label()
27// Date.now() call op, 'Date' is a FREE wire
28// `${row.name}: ...` one template op (R5: glue folds in)
29// format(...) call op, 'format' is an IMPORT wire
30// return return op + exit <- the FOURTH return in the file
31//
32// Totals: 2 functions, 12 operation nodes, 5 zones, 6 exits, 2 named wires
33// ('total', 'at' — a loop binding is the zone's item, not a named wire)
34
35function derive (name) {
36 const path = resolve(FIXTURE, name)
37 return deriveFile(fs.readFileSync(path, 'utf8'), path)
38}
39
40test('derive: the counts the fixture documents', (t) => {
41 const m = derive('sample.js')
42
43 t.absent(m.error, 'no error')
44 t.is(m.functions.length, 2, '2 functions')
45 t.is(m.nodes.length, 12, '12 operation nodes')
46 t.is(m.zones.length, 5, '5 zones')
47 t.is(m.exits.length, 6, '6 exits — four returns, one continue, one break')
48 t.is(m.names.length, 2, '2 named wires')
49})
50
51test('derive: R2 — a reference or literal is never a node', (t) => {
52 const m = derive('sample.js')
53 const kinds = m.nodes.map(n => n.kind)
54
55 t.absent(kinds.includes('Identifier'), 'no Identifier node')
56 t.absent(kinds.includes('Literal'), 'no Literal node')
57 t.absent(kinds.includes('MemberExpression'), 'no MemberExpression node (R5)')
58
59 // `!rows` is ONE node, and `rows` is a wire into it.
60 const unary = m.nodes.find(n => n.kind === 'UnaryExpression')
61 t.ok(unary, 'the unary op is a node')
62 t.is(unary.inputs.length, 1, 'one input')
63 t.is(unary.inputs[0].name, 'rows', 'fed by the rows wire')
64 t.is(unary.inputs[0].source, 'param', 'which resolves to a parameter')
65})
66
67test('derive: R5 — a property path folds into a socket label', (t) => {
68 const m = derive('sample.js')
69 const add = m.nodes.find(n => n.kind === 'BinaryExpression' && n.label === '+')
70 t.ok(add, 'found the + node')
71 const labels = add.inputs.map(i => i.name)
72 t.ok(labels.includes('total'), 'left input is the total wire')
73 t.ok(labels.includes('row.count'), 'right input is the folded path row.count')
74})
75
76test('derive: R3 — a declaration names a wire, literals sit in the socket', (t) => {
77 const m = derive('sample.js')
78 const names = m.names.map(n => n.name).sort()
79 t.alike(names, ['at', 'total'], 'total and at')
80
81 const total = m.names.find(n => n.name === 'total')
82 t.is(total.from, 'literal', 'total is named on a literal, not an operation')
83
84 const at = m.names.find(n => n.name === 'at')
85 t.is(at.from, 'node', 'at is named on an operation')
86 t.ok(at.node, 'and points at it')
87})
88
89test('derive: Z1 — three conditional shapes', (t) => {
90 const m = derive('sample.js')
91 const shapes = {}
92 for (const z of m.zones) shapes[z.shape] = (shapes[z.shape] || 0) + 1
93
94 t.is(shapes.guard, 3, '3 guards')
95 t.is(shapes.foreach, 1, '1 foreach loop')
96 t.is(shapes['two-exit'], 1, '1 two-exit conditional')
97 t.absent(shapes['single-branch'], 'no single-branch conditional in this fixture')
98})
99
100test('derive: Z4 — every exit lands on an enclosing boundary', (t) => {
101 const m = derive('sample.js')
102 const byKind = {}
103 for (const e of m.exits) {
104 byKind[e.kind] = (byKind[e.kind] || 0) + 1
105 t.ok(e.lands, e.kind + ' has a landing site')
106 t.ok(e.lands.on, e.kind + ' landing names what it lands on')
107 }
108 t.is(byKind.return, 4, '4 returns')
109 t.is(byKind.continue, 1, '1 continue')
110 t.is(byKind.break, 1, '1 break')
111
112 const loop = m.zones.find(z => z.shape === 'foreach')
113 const cont = m.exits.find(e => e.kind === 'continue')
114 const brk = m.exits.find(e => e.kind === 'break')
115
116 t.is(cont.lands.on, 'zone-entry', 'continue lands on a zone entry')
117 t.is(cont.lands.id, loop.id, 'specifically the enclosing loop')
118 t.is(brk.lands.on, 'zone-exit', 'break lands on a zone exit')
119 t.is(brk.lands.id, loop.id, 'specifically the enclosing loop')
120
121 for (const r of m.exits.filter(e => e.kind === 'return')) {
122 t.is(r.lands.on, 'function-output', 'a return lands on the function output')
123 }
124})
125
126test('derive: Z3 — carried state is the zone output', (t) => {
127 const m = derive('sample.js')
128 const loop = m.zones.find(z => z.shape === 'foreach')
129 t.alike(loop.carried, ['total'], 'the loop carries total')
130 t.is(loop.item, 'row', 'and binds row as its item')
131
132 // Both branches return, so nothing converges out of the if/else.
133 const cond = m.zones.find(z => z.shape === 'two-exit')
134 t.alike(cond.carried, [], 'the two-exit zone converges nothing')
135})
136
137test('derive: a return is both an operation and an exit, sharing identity', (t) => {
138 const m = derive('sample.js')
139 const returnNodes = m.nodes.filter(n => n.kind === 'ReturnStatement')
140 t.is(returnNodes.length, 4, '4 return nodes')
141 for (const r of returnNodes) {
142 const exit = m.exits.find(e => e.node === r.id)
143 t.ok(exit, 'each return node has the exit that shares its id')
144 }
145})
146
147test('derive: E4 — zone nesting is a tree', (t) => {
148 const m = derive('sample.js')
149 const byId = new Map(m.zones.map(z => [z.id, z]))
150
151 for (const z of m.zones) {
152 if (z.parent === null) continue
153 const p = byId.get(z.parent)
154 t.ok(p, 'parent exists in the model')
155 t.ok(p.range.startByte <= z.range.startByte && p.range.endByte >= z.range.endByte,
156 'parent range contains the child')
157 t.is(z.depth, p.depth + 1, 'depth is parent depth + 1')
158 }
159
160 const loop = m.zones.find(z => z.shape === 'foreach')
161 const inner = m.zones.filter(z => z.parent === loop.id)
162 t.is(inner.length, 2, 'the two inner guards are children of the loop')
163})
164
165test('derive: E5 — a reference resolves or is marked', (t) => {
166 const m = derive('sample.js')
167 const sources = {}
168 for (const n of m.nodes) {
169 for (const i of n.inputs) {
170 if (i.source) sources[i.source] = (sources[i.source] || 0) + 1
171 }
172 }
173 t.ok(sources.free > 0, 'Date is recorded as free')
174 t.ok(sources.import > 0, 'format is recorded as an import binding')
175 t.ok(sources.param > 0, 'rows/limit resolve to parameters')
176 t.ok(sources.local > 0, 'total/at resolve to locals')
177
178 for (const n of m.nodes) {
179 for (const i of n.inputs) {
180 if (i.literal) continue
181 t.ok(i.source, 'every non-literal input names where it came from')
182 }
183 }
184})
185
186test('derive: E2 — everything carries a position', (t) => {
187 const m = derive('sample.js')
188 for (const list of [m.nodes, m.zones, m.exits, m.names]) {
189 for (const item of list) {
190 t.ok(item.range, 'has a range')
191 t.ok(Number.isInteger(item.range.startByte), 'has startByte')
192 t.ok(Number.isInteger(item.range.endByte), 'has endByte')
193 t.ok(item.range.startLine >= 1, 'has a 1-based startLine')
194 t.ok(item.range.endLine >= item.range.startLine, 'endLine follows startLine')
195 }
196 }
197})
198
199test('derive: R7 — identity is derived from the byte range', (t) => {
200 const m = derive('sample.js')
201 for (const n of m.nodes) {
202 t.is(n.id, m.filePath + ':' + n.range.startByte + '-' + n.range.endByte, 'id is file:start-end')
203 }
204})
205
206test('derive: E1 — the derivation is pure', (t) => {
207 const a = derive('sample.js')
208 const b = derive('sample.js')
209 t.is(JSON.stringify(a), JSON.stringify(b), 'same source, byte-identical model')
210})
211
212test('derive: E6 — a parse failure is reported, not thrown', (t) => {
213 let m = null
214 try {
215 m = derive('broken.js')
216 t.pass('did not throw')
217 } catch (err) {
218 t.fail('threw on broken source: ' + err.message)
219 return
220 }
221 t.ok(m.error, 'reports an error')
222 t.alike(m.nodes, [], 'and no nodes')
223})
test/fixtures/connectors/examples/demo.js +3 -0
……@@ -0,0 +1,3 @@
1import { test } from 'brittle'
2import b4a from 'b4a'
3export function demo () { return test && b4a }
test/fixtures/connectors/index.js +9 -0
……@@ -0,0 +1,9 @@
1import { alpha } from './lib/a.js'
2import { beta } from './lib/b.js'
3export { alpha }
4export { shared } from './lib/util.js'
5export function entry () { return alpha() + beta() }
6export async function lazy () {
7 const { lazyThing } = await import('./lib/dyn.js')
8 return lazyThing()
9}
test/fixtures/connectors/lib/a.js +3 -0
……@@ -0,0 +1,3 @@
1import { shared } from './util.js'
2export function alpha () { return shared() }
3export function neverUsed () { return 1 }
test/fixtures/connectors/lib/b.js +3 -0
……@@ -0,0 +1,3 @@
1import { shared } from './util.js'
2import { missingThing } from './nope.js'
3export function beta () { return shared() + missingThing() }
test/fixtures/connectors/lib/dyn.js +1 -0
……@@ -0,0 +1 @@
1export function lazyThing () { return 'lazy' }
test/fixtures/connectors/lib/util.js +2 -0
……@@ -0,0 +1,2 @@
1export function shared () { return 42 }
2export const alsoDead = 7
test/fixtures/connectors/package.json +8 -0
……@@ -0,0 +1,8 @@
1{
2 "name": "connectors-fixture",
3 "version": "1.0.0",
4 "type": "module",
5 "main": "index.js",
6 "dependencies": { "b4a": "^1.6.0" },
7 "devDependencies": { "brittle": "^3.0.0" }
8}
test/fixtures/derive/broken.js +1 -0
……@@ -0,0 +1 @@
1export function nope ( {
test/fixtures/derive/fmt.js +3 -0
……@@ -0,0 +1,3 @@
1export function format (text, at) {
2 return text + ' @' + at
3}
test/fixtures/derive/sample.js +26 -0
……@@ -0,0 +1,26 @@
1// Fixture for the expression-floor derivation. Its ground truth is written out
2// in test/derive.test.js and must be kept in step with this file BY HAND — that
3// is the point of a fixture: if the derivation changes shape, this file's
4// documented counts are what fails.
5
6import { format } from './fmt.js'
7
8export function tally (rows, limit) {
9 let total = 0
10 if (!rows) return null
11 for (const row of rows) {
12 if (row.skip) continue
13 total = total + row.count
14 if (total > limit) break
15 }
16 if (total > 0) {
17 return total
18 } else {
19 return 0
20 }
21}
22
23export function label (row) {
24 const at = Date.now()
25 return format(`${row.name}: ${row.count}`, at)
26}
test/render.test.js +70 -0
……@@ -0,0 +1,70 @@
1import { test } from 'brittle'
2import { resolve, dirname } from 'path'
3import { fileURLToPath } from 'url'
4import { scan } from '../lib/scanner.js'
5import { renderPage } from '../lib/render.js'
6import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs'
7
8const HERE = dirname(fileURLToPath(import.meta.url))
9const FIXTURE = resolve(HERE, 'fixtures/connectors')
10
11// render.js emits a whole browser app as a template literal. Nothing type-checks
12// it, lints it, or parses it at build time — a stray brace ships a white page.
13// These tests are the parse gate.
14
15function inlineScript (html) {
16 const open = html.indexOf('<script>')
17 const close = html.lastIndexOf('</script>')
18 if (open === -1 || close === -1) throw new Error('no inline script found')
19 return html.slice(open + '<script>'.length, close)
20}
21
22test('render: the emitted page parses as JavaScript', async (t) => {
23 const tree = await scan(FIXTURE)
24 const html = renderPage(tree, '')
25 const js = inlineScript(html)
26
27 t.ok(js.length > 1000, 'got a substantial script')
28 try {
29 acornParse(js, { ecmaVersion: 'latest', sourceType: 'script', locations: true })
30 t.pass('inline script parses')
31 } catch (err) {
32 t.fail('inline script is not valid JS: ' + err.message)
33 }
34})
35
36test('render: no duplicate top-level function declarations', async (t) => {
37 const tree = await scan(FIXTURE)
38 const js = inlineScript(renderPage(tree, ''))
39 const ast = acornParse(js, { ecmaVersion: 'latest', sourceType: 'script' })
40
41 const seen = new Map()
42 const dupes = []
43 for (const node of ast.body) {
44 if (node.type !== 'FunctionDeclaration' || !node.id) continue
45 if (seen.has(node.id.name)) dupes.push(node.id.name)
46 seen.set(node.id.name, true)
47 }
48
49 // A second declaration silently replaces the first for the whole script.
50 // escapeHtml was declared twice, and the weaker one won.
51 t.alike(dupes, [], 'no function name is declared twice: ' + dupes.join(', '))
52})
53
54test('render: embedded tree JSON cannot break out of the script tag', async (t) => {
55 const tree = await scan(FIXTURE)
56 // Smuggle a closing tag through data that reaches the inlined JSON.
57 tree.modules['connectors-fixture'].files['index.js'].role = '</script><b>x'
58 const html = renderPage(tree, '')
59
60 const open = html.indexOf('<script>')
61 const firstClose = html.indexOf('</script>', open)
62 const js = html.slice(open + '<script>'.length, firstClose)
63
64 try {
65 acornParse(js, { ecmaVersion: 'latest', sourceType: 'script' })
66 t.pass('script survives a </script> in the data')
67 } catch (err) {
68 t.fail('data containing </script> truncated the script: ' + err.message)
69 }
70})
test/sections.test.js +140 -0
……@@ -0,0 +1,140 @@
1import { test } from 'brittle'
2import { resolve, dirname } from 'path'
3import { fileURLToPath } from 'url'
4import { scan } from '../lib/scanner.js'
5import { buildSections } from '../lib/sections.js'
6
7const HERE = dirname(fileURLToPath(import.meta.url))
8const FIXTURE = resolve(HERE, 'fixtures/connectors')
9
10// The fixture's ground truth, by construction:
11//
12// index.js -alpha(lib/a.js) -beta(lib/b.js) +alpha(re-export) +entry
13// lib/a.js -shared(util.js) +alpha +neverUsed
14// lib/b.js -shared(util.js) -missingThing(./nope.js MISSING) +beta
15// lib/util.js +shared +alsoDead
16//
17// So: neverUsed and alsoDead are DEAD (+ that nothing takes).
18// missingThing is UNRESOLVED (- that nothing supplies).
19// alpha and entry are the module's public boundary, NOT dead.
20
21test('sections: every file becomes a germ with connectors', async (t) => {
22 const tree = await scan(FIXTURE)
23 const index = buildSections(tree)
24
25 const files = index.sections.filter(s => s.scale === 'file')
26 t.is(files.length, 6, 'six file germs')
27
28 const names = files.map(s => s.germ.name).sort()
29 t.alike(names, ['examples/demo.js', 'index.js', 'lib/a.js', 'lib/b.js', 'lib/dyn.js', 'lib/util.js'], 'germ names')
30})
31
32test('sections: imports are minus connectors, exports are plus', async (t) => {
33 const tree = await scan(FIXTURE)
34 const index = buildSections(tree)
35
36 const util = index.sections.find(s => s.germ.name === 'lib/util.js')
37 const plus = util.conns.filter(c => c.pol === '+').map(c => c.type).sort()
38 t.alike(plus, ['alsoDead', 'shared'], 'util supplies shared + alsoDead')
39 t.is(util.conns.filter(c => c.pol === '-').length, 0, 'util demands nothing')
40
41 const b = index.sections.find(s => s.germ.name === 'lib/b.js')
42 const minus = b.conns.filter(c => c.pol === '-').map(c => c.type).sort()
43 t.alike(minus, ['missingThing', 'shared'], 'b demands shared + missingThing')
44})
45
46test('sections: mating pairs a minus with the plus that supplies it', async (t) => {
47 const tree = await scan(FIXTURE)
48 const index = buildSections(tree)
49
50 const a = index.sections.find(s => s.germ.name === 'lib/a.js')
51 const sharedDemand = a.conns.find(c => c.pol === '-' && c.type === 'shared')
52 t.ok(sharedDemand.mate, 'a.js -shared is mated')
53
54 const mate = index.connectorById(sharedDemand.mate)
55 t.is(mate.pol, '+', 'mated to a supply')
56 t.is(mate.type, 'shared', 'same type')
57 t.is(mate.germId, 'file:connectors-fixture:lib/util.js', 'supplied by util.js')
58})
59
60test('sections: unmated plus on a non-entry file is a DEAD export', async (t) => {
61 const tree = await scan(FIXTURE)
62 const index = buildSections(tree)
63
64 const dead = index.misfits().dead.map(m => m.type).sort()
65 t.alike(dead, ['alsoDead', 'demo', 'neverUsed'], 'exactly the dead exports')
66})
67
68test('sections: entry-file exports are public boundary, never dead', async (t) => {
69 const tree = await scan(FIXTURE)
70 const index = buildSections(tree)
71
72 const dead = index.misfits().dead.map(m => m.type)
73 t.absent(dead.includes('entry'), 'entry is public API, not dead')
74 t.absent(dead.includes('alpha'), 're-exported alpha is public API, not dead')
75})
76
77test('sections: unmated minus is an UNRESOLVED demand', async (t) => {
78 const tree = await scan(FIXTURE)
79 const index = buildSections(tree)
80
81 const unresolved = index.misfits().unresolved.map(m => m.type)
82 t.alike(unresolved, ['missingThing'], 'the one import that resolves to nothing')
83})
84
85test('sections: a dynamically imported export is mated, not dead', async (t) => {
86 const tree = await scan(FIXTURE)
87 const index = buildSections(tree)
88
89 // index.js reaches lib/dyn.js only via `await import('./lib/dyn.js')`.
90 // A parser that sees static imports only would call lazyThing a dead export.
91 const dead = index.misfits().dead.map(m => m.type)
92 t.absent(dead.includes('lazyThing'), 'lazyThing is imported dynamically, not dead')
93
94 const dyn = index.sections.find(s => s.germ.name === 'lib/dyn.js')
95 const supply = dyn.conns.find(c => c.pol === '+' && c.type === 'lazyThing')
96 t.is(supply.mates.length, 1, 'mated to exactly one demand')
97})
98
99test('sections: a devDependency used in examples/tests is not a leak', async (t) => {
100 const tree = await scan(FIXTURE)
101 const index = buildSections(tree)
102 const m = index.misfits()
103
104 // examples/demo.js imports brittle (a devDependency) and b4a (a dependency).
105 // Neither is missing — reading only `dependencies` would report brittle leaked.
106 const leaked = m.leak.map(l => l.specifier)
107 t.absent(leaked.includes('brittle'), 'brittle is declared in devDependencies')
108 t.absent(leaked.includes('b4a'), 'b4a is declared in dependencies')
109
110 const unused = m.unused.map(u => u.specifier)
111 t.absent(unused.includes('brittle'), 'brittle is imported, so not unused')
112 t.absent(unused.includes('b4a'), 'b4a is imported, so not unused')
113})
114
115test('sections: a re-export is a demand AND a supply', async (t) => {
116 const tree = await scan(FIXTURE)
117 const index = buildSections(tree)
118
119 // index.js does `export { shared } from './lib/util.js'` and imports nothing.
120 // That is sugar for import-then-export, so it must create a demand too —
121 // otherwise nothing ever reaches util.js and it reads as an orphan.
122 const entry = index.sections.find(s => s.germ.name === 'index.js')
123 const demand = entry.conns.find(c => c.pol === '-' && c.type === 'shared')
124 t.ok(demand, 'the re-export creates a demand')
125 t.ok(demand && demand.mate, 'and that demand is mated to util.js')
126
127 const supply = entry.conns.find(c => c.pol === '+' && c.type === 'shared')
128 t.ok(supply && supply.reExport, 'it is still a supply, marked as a pass-through')
129})
130
131test('sections: a disjunct string round-trips the germ', async (t) => {
132 const tree = await scan(FIXTURE)
133 const index = buildSections(tree)
134
135 const b = index.sections.find(s => s.germ.name === 'lib/b.js')
136 const d = index.disjunct(b)
137 t.ok(d.includes('-shared'), 'shows the shared demand')
138 t.ok(d.includes('-missingThing'), 'shows the missing demand')
139 t.ok(d.includes('+beta'), 'shows the beta supply')
140})