| … | … | @@ -0,0 +1,296 @@ |
| 1 | # The Expression Floor | |
| 2 | ||
| 3 | How a file becomes nodes. | |
| 4 | ||
| 5 | The node view already climbs `system -> directory -> module -> file -> symbol`, | |
| 6 | and its own contract is that a node at one level opens into a whole graph at the | |
| 7 | next. The symbol level breaks that contract: a symbol node is marked | |
| 8 | un-openable and renders its body as text. This document specifies the rung | |
| 9 | below it, where statements and expressions become the graph, and the text stops | |
| 10 | being text. | |
| 11 | ||
| 12 | Every rule here is derived from the scan. Nothing is authored. | |
| 13 | ||
| 14 | --- | |
| 15 | ||
| 16 | ## 1. What is and is not a node | |
| 17 | ||
| 18 | The rung below a symbol is a **dataflow graph in SSA form**: operations, named | |
| 19 | values, control regions, and a convergence point where a region's writes rejoin. | |
| 20 | This is not a new decomposition — it is the one compilers settled on — which is | |
| 21 | why the Blender analogy holds at all: Geometry Nodes *is* a pure dataflow graph. | |
| 22 | ||
| 23 | ### R1. An operation is a node | |
| 24 | ||
| 25 | A call, an arithmetic or logical operation, an assignment, a construction, a | |
| 26 | return, a throw, a spread. Anything that computes or acts. | |
| 27 | ||
| 28 | ### R2. A value is a wire, never a node | |
| 29 | ||
| 30 | An identifier reference is one END of a noodle, drawn from wherever the value | |
| 31 | was defined. A literal renders INSIDE its socket, the way Blender puts a number | |
| 32 | in 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 | |
| 35 | not three nodes. Getting this wrong is the difference between a readable graph | |
| 36 | and an unreadable one: measured on this repo, treating every AST node as a node | |
| 37 | gives 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`. | |
| 42 | Local variables are not nodes; they are noodle labels. This is the decision that | |
| 43 | makes dataflow legible downstream, because a wire with a name is one you can | |
| 44 | recognise at its use site. | |
| 45 | ||
| 46 | A reassignment (`rows = ...`) is a new VERSION of that wire, which is precisely | |
| 47 | SSA'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 | ||
| 51 | A region (a "zone", after Blender's Repeat/Foreach/Simulation zones, which we | |
| 52 | already implement in proc-nodes as `ProcZoneInfo`) has an entry, a condition | |
| 53 | input 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 | ||
| 58 | These 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 | ||
| 67 | Collapsing property paths alone removes 622 nodes on this repo (3,637 member | |
| 68 | expressions 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 | ||
| 72 | Parameters are Group Input. Returns are Group Output. This is the same | |
| 73 | derivation `render-nodes.js` already performs for files and modules, one level | |
| 74 | down — which is the strongest evidence that R1-R5 are the right cut. | |
| 75 | ||
| 76 | ### R7. Identity is derived, never stored | |
| 77 | ||
| 78 | A node's id is `<file>:<startByte>-<endByte>`, computed per render from the | |
| 79 | tree. Nothing is persisted, so there is nothing to churn when an edit shifts | |
| 80 | every offset below it. | |
| 81 | ||
| 82 | --- | |
| 83 | ||
| 84 | ## 2. Zones | |
| 85 | ||
| 86 | ### Z1. A conditional has three shapes, not one | |
| 87 | ||
| 88 | Drawing all conditionals identically would be the dishonest option, because a | |
| 89 | guard clause genuinely has different control flow from an `if`/`else`. | |
| 90 | ||
| 91 | **Guard** — no `else`, body is a single jump. Nothing converges: control leaves | |
| 92 | the region. Rendered as a margin chevron plus an exit rail to wherever it lands, | |
| 93 | NOT as a box. Internally it is still a zone, so the exit taxonomy stays uniform; | |
| 94 | only its rendering differs. | |
| 95 | ||
| 96 | **Single-branch** — one conditional path and a fall-through. A tint band over | |
| 97 | its span. It has output sockets only for wires written inside and read after; if | |
| 98 | nothing escapes, it has none. | |
| 99 | ||
| 100 | **Two-exit** — `if`/`else`. A tint band, a divider at the `else` line, and | |
| 101 | convergence sockets at the closing line. An `else if` ladder collapses into ONE | |
| 102 | multi-band zone rather than N nested zones, which is also why `switch` needs no | |
| 103 | separate 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 | ||
| 115 | A zone's outputs are its phis: any wire written on a path through it and read | |
| 116 | after it. Identical machinery for `if`, `else if`, `switch`, `try` and every | |
| 117 | loop. The only differences between regions are HOW MANY EXITS they have and | |
| 118 | WHETHER THEY REPEAT. | |
| 119 | ||
| 120 | ### Z4. Every exit lands on an enclosing boundary | |
| 121 | ||
| 122 | An exit is typed by where it lands, and every landing site is already a socket | |
| 123 | on 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 | ||
| 132 | No case is left over. `throw` is the last to build, not the first: this repo | |
| 133 | contains zero `throw` statements, so its 81 `try` zones are catching from | |
| 134 | callees. | |
| 135 | ||
| 136 | ### Z5. Three departures from Blender, stated so they are not surprises | |
| 137 | ||
| 138 | 1. **Mutation.** 331 assignments here. A reassignment is a new wire version. | |
| 139 | 2. **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. | |
| 142 | 3. **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 | ||
| 149 | Adopted from codopticon (Bananaft, bitbucket.org/Bananaft/codopticon), read | |
| 150 | 2026-09-20 at `d6802e7`. We take the display technique, not the source: that | |
| 151 | repo ships no LICENSE. | |
| 152 | ||
| 153 | ### V1. Position comes from the source; there is no layout step | |
| 154 | ||
| 155 | A node IS its byte range — a rectangle in the text grid. Nesting is containment, | |
| 156 | free, because AST ranges nest. This is what makes the floor reachable: the | |
| 157 | layout problem disappears instead of being solved. | |
| 158 | ||
| 159 | Measured: 0.41 operations per line, 1,602 lines carrying exactly one, and of | |
| 160 | 12,176 lines only 25 carry more than six (max 10). In-place placement | |
| 161 | essentially never collides. | |
| 162 | ||
| 163 | ### V2. Viewport culling replaces model-level LOD | |
| 164 | ||
| 165 | Because positions are source positions, "what do I build" is a line-range test | |
| 166 | against the camera. The 4,994 nodes are never materialised at once; the visible | |
| 167 | lines 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 | ||
| 178 | Every node kind therefore needs a one-glyph form as well as a full form. | |
| 179 | codopticon's `L` for a loop is the pattern; ours carries the zone's derived | |
| 180 | facts (trip count, carried state) instead of nothing. | |
| 181 | ||
| 182 | ### V4. The gutters are wire canvas | |
| 183 | ||
| 184 | Blank cells are not drawn, so indentation columns are empty space: vertical wire | |
| 185 | runs belong there, and the ragged right margin carries the effect chain and the | |
| 186 | carried-state rails. This is what keeps in-place wiring from becoming spaghetti | |
| 187 | over text. | |
| 188 | ||
| 189 | ### V5. The per-character channel carries semantics | |
| 190 | ||
| 191 | The renderer samples a palette index per cell. Same channel, different content: | |
| 192 | zone depth, def/use role, unmated-connector flag. Semantics at every zoom, free. | |
| 193 | ||
| 194 | ### V6. Tint budget: four levels | |
| 195 | ||
| 196 | A 4-level ramp covers 96.5% of zones (1,085 of 1,124). The 39 deeper ones take | |
| 197 | their depth from the margin rail, so nesting never becomes mud. Safe because | |
| 198 | only 31.8% of lines sit inside any zone, with a median span of 3 lines. | |
| 199 | ||
| 200 | ### V7. Declutter by displacement | |
| 201 | ||
| 202 | A label that would collide with the previous one is pushed off its true line, | |
| 203 | trading positional accuracy for legibility, exactly as map labels do. This is | |
| 204 | what handles the 25 crowded lines. | |
| 205 | ||
| 206 | ### V8. A condition renders collapsed by default | |
| 207 | ||
| 208 | The 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 | |
| 210 | renders as its source text until expanded on demand. This is the one place the | |
| 211 | in-place rule gets crowded, and the only place we hide structure by default. | |
| 212 | ||
| 213 | --- | |
| 214 | ||
| 215 | ## 4. Measurements | |
| 216 | ||
| 217 | Every number in this document was produced on 2026-09-20 by parsing this repo's | |
| 218 | own source with the vendored acorn — 25 files, 12,176 lines, excluding `test/`, | |
| 219 | `vendor/` and `node_modules/`. | |
| 220 | ||
| 221 | ``` | |
| 222 | operation nodes 4,934 wires (refs/literals/members) 18,516 | |
| 223 | zones 1,124 named wires (declarations) 1,120 | |
| 224 | functions 328 operations per function 15.2 | |
| 225 | operations per line 0.41 | |
| 226 | ||
| 227 | ops 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 | ||
| 233 | zones 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 | ||
| 241 | exits 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 | |
| 246 | conditional shapes are the specification; the percentages only tell us which | |
| 247 | shape to build first (guards, at 40%). | |
| 248 | ||
| 249 | --- | |
| 250 | ||
| 251 | ## 5. Language coverage | |
| 252 | ||
| 253 | The derivation is per-language, and all four parsers keep a full tree, so all | |
| 254 | four 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 | ||
| 265 | JS is nearly free: its parser already walks every node. GDScript and C++ visit | |
| 266 | only top-level declarations today and need a body descent. GDScript has exactly | |
| 267 | two 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 | |
| 274 | filesystem, no network, no scan state. | |
| 275 | ||
| 276 | **E2. Every node, zone and exit carries a byte range and a line/column range.** | |
| 277 | V1 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 | |
| 280 | model (Z4). An exit whose target cannot be resolved is reported as such, never | |
| 281 | dropped. | |
| 282 | ||
| 283 | **E4. Zone nesting is a tree.** `parent` is either null or a zone whose range | |
| 284 | strictly contains this one. | |
| 285 | ||
| 286 | **E5. Wires resolve or are marked.** A reference either resolves to a | |
| 287 | declaration in scope, or is recorded as `free` (a global, an import binding, a | |
| 288 | builtin). It is never silently discarded. | |
| 289 | ||
| 290 | **E6. The derivation never throws on real code.** A parse failure returns an | |
| 291 | error entry; it does not take the process down. (`npm test` currently dies on | |
| 292 | `parser.test.js` for exactly this class of reason.) | |
| 293 | ||
| 294 | Acceptance: `codetree derive <file>` on any file in this repo prints a model | |
| 295 | satisfying E1-E6, and `test/derive.test.js` asserts them against a fixture whose | |
| 296 | ground truth is written into the fixture itself. |
| … | … | @@ -1,6 +1,7 @@ |
| 1 | 1 | #!/usr/bin/env bare |
| 2 | 2 | |
| 3 | 3 | import process from 'process' |
| 4 | import fs from 'fs' | |
| 4 | 5 | import { resolve } from 'path' |
| 5 | 6 | import { scan } from './lib/scanner.js' |
| 6 | 7 | |
| … | … | @@ -111,6 +112,9 @@ async function main () { |
| 111 | 112 | case 'imports': return cmdImports() |
| 112 | 113 | case 'docs': return cmdDocs() |
| 113 | 114 | case 'categories': return cmdCategories() |
| 115 | case 'disjuncts': return cmdDisjuncts() | |
| 116 | case 'misfits': return cmdMisfits() | |
| 117 | case 'derive': return cmdDerive() | |
| 114 | 118 | case 'summary': return cmdSummary() |
| 115 | 119 | case 'serve': return cmdServe() |
| 116 | 120 | default: |
| … | … | @@ -522,6 +526,171 @@ async function cmdCategories () { |
| 522 | 526 | } |
| 523 | 527 | } |
| 524 | 528 | |
| 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. | |
| 531 | async 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. | |
| 548 | async 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. | |
| 585 | async 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 | ||
| 525 | 694 | async function cmdSummary () { |
| 526 | 695 | const tree = await scan(dir, opts) |
| 527 | 696 | |
| … | … | @@ -581,6 +750,9 @@ Usage: |
| 581 | 750 | codetree imports <module> [dir] Show internal import graph and sub-modules |
| 582 | 751 | codetree docs [dir] Documentation cruft report |
| 583 | 752 | 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 | |
| 584 | 756 | codetree summary [dir] Human-readable overview |
| 585 | 757 | codetree serve [dir] [port] Start HTTP visualization |
| 586 | 758 | |
| … | … | @@ -618,6 +790,7 @@ Examples: |
| 618 | 790 | codetree exports hyper-orgs -p Show hyper-orgs public API |
| 619 | 791 | codetree imports pear-git -p Show pear-git internal architecture |
| 620 | 792 | codetree docs ../pear-git -p Find stale documentation |
| 793 | codetree derive lib/derive.js Show one file's expression-floor model | |
| 621 | 794 | codetree serve . 9078 Serve visualization on port 9078`) |
| 622 | 795 | } |
| 623 | 796 |
| … | … | @@ -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 | ||
| 24 | import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs' | |
| 25 | ||
| 26 | const 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. | |
| 39 | const 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 | ||
| 47 | const LOOPS = new Set([ | |
| 48 | 'ForStatement', 'ForInStatement', 'ForOfStatement', 'WhileStatement', | |
| 49 | 'DoWhileStatement' | |
| 50 | ]) | |
| 51 | ||
| 52 | const ZONES = new Set([...LOOPS, 'IfStatement', 'SwitchStatement', 'TryStatement']) | |
| 53 | ||
| 54 | const JUMPS = new Set([ | |
| 55 | 'ReturnStatement', 'BreakStatement', 'ContinueStatement', 'ThrowStatement' | |
| 56 | ]) | |
| 57 | ||
| 58 | const 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. | |
| 64 | const SKIP_KEYS = new Set(['loc', 'range', 'start', 'end', 'quasis']) | |
| 65 | ||
| 66 | function 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 | ||
| 77 | function 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. | |
| 83 | function 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. | |
| 98 | function rootOf (node) { | |
| 99 | let cur = node | |
| 100 | while (cur && cur.type === 'MemberExpression') cur = cur.object | |
| 101 | return cur | |
| 102 | } | |
| 103 | ||
| 104 | function 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. | |
| 133 | function 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 | */ | |
| 154 | function 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. | |
| 191 | function 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 | ||
| 230 | function 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. | |
| 238 | function 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 | ||
| 245 | function 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 | ||
| 254 | function 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)`. | |
| 261 | function 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 | ||
| 272 | export 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 | } |
| … | … | @@ -154,12 +154,20 @@ function extractImport (node) { |
| 154 | 154 | |
| 155 | 155 | function extractNamedExport (node, result, commentMap) { |
| 156 | 156 | // 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. | |
| 157 | 162 | if (node.source) { |
| 163 | const names = [] | |
| 158 | 164 | 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 }) | |
| 163 | 171 | } |
| 164 | 172 | return |
| 165 | 173 | } |
| … | … | @@ -259,6 +267,7 @@ function extractClass (node, commentMap) { |
| 259 | 267 | name: node.id ? node.id.name : '(anonymous)', |
| 260 | 268 | extends: node.superClass ? nodeToString(node.superClass) : null, |
| 261 | 269 | line: node.loc ? node.loc.start.line : null, |
| 270 | endLine: node.loc ? node.loc.end.line : null, | |
| 262 | 271 | constructor: null, |
| 263 | 272 | methods: [], |
| 264 | 273 | getters: [], |
| … | … | @@ -277,10 +286,11 @@ function extractClass (node, commentMap) { |
| 277 | 286 | cls.constructor = { |
| 278 | 287 | params: extractParams(member.value.params), |
| 279 | 288 | line: member.loc ? member.loc.start.line : null, |
| 289 | endLine: member.loc ? member.loc.end.line : null, | |
| 280 | 290 | jsdoc |
| 281 | 291 | } |
| 282 | 292 | } 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 }) | |
| 284 | 294 | } else if (member.kind === 'set') { |
| 285 | 295 | cls.getters.push({ name, kind: 'set', static: member.static || false }) |
| 286 | 296 | } else if (member.static) { |
| … | … | @@ -289,6 +299,7 @@ function extractClass (node, commentMap) { |
| 289 | 299 | params: extractParams(member.value.params), |
| 290 | 300 | async: member.value.async || false, |
| 291 | 301 | line: member.loc ? member.loc.start.line : null, |
| 302 | endLine: member.loc ? member.loc.end.line : null, | |
| 292 | 303 | jsdoc |
| 293 | 304 | }) |
| 294 | 305 | } else { |
| … | … | @@ -298,6 +309,7 @@ function extractClass (node, commentMap) { |
| 298 | 309 | async: member.value.async || false, |
| 299 | 310 | generator: member.value.generator || false, |
| 300 | 311 | line: member.loc ? member.loc.start.line : null, |
| 312 | endLine: member.loc ? member.loc.end.line : null, | |
| 301 | 313 | jsdoc |
| 302 | 314 | }) |
| 303 | 315 | } |
| … | … | @@ -324,6 +336,7 @@ function extractFunction (node, commentMap) { |
| 324 | 336 | async: node.async || false, |
| 325 | 337 | generator: node.generator || false, |
| 326 | 338 | line: node.loc ? node.loc.start.line : null, |
| 339 | endLine: node.loc ? node.loc.end.line : null, | |
| 327 | 340 | jsdoc: findJSDoc(commentMap, node) |
| 328 | 341 | } |
| 329 | 342 | } |
| … | … | @@ -374,6 +387,40 @@ function walkDeep (ast, result) { |
| 374 | 387 | const errors = [] |
| 375 | 388 | |
| 376 | 389 | 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 | ||
| 377 | 424 | // this._field = ... (in constructor or methods) |
| 378 | 425 | if ( |
| 379 | 426 | node.type === 'AssignmentExpression' && |
| … | … | @@ -86,6 +86,7 @@ export class TreeReducer { |
| 86 | 86 | const file = mod.files.get(e.fromRelInPkg) |
| 87 | 87 | if (!file) return |
| 88 | 88 | const edge = { specifier: e.specifier, resolved: e.resolvedRelPath } |
| 89 | if (e.names && e.names.length > 0) edge.names = e.names.slice() | |
| 89 | 90 | if (e.implicit) edge.implicit = true |
| 90 | 91 | file.localImports.push(edge) |
| 91 | 92 | } |
| … | … | @@ -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 | ||
| 29 | export 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 | ||
| 80 | export 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 · scroll to zoom · click a node title to open it</div> | |
| 86 | </div> | |
| 87 | ` | |
| 88 | ||
| 89 | export const NODE_VIEW_JS = ` | |
| 90 | // ====== NODE VIEW (nested groups) ====== | |
| 91 | ||
| 92 | var 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 | ||
| 105 | var NV_ROW = 15 | |
| 106 | var NV_HEAD = 20 | |
| 107 | var NV_W = 210 | |
| 108 | var NV_COL_GAP = 130 | |
| 109 | var NV_ROW_GAP = 26 | |
| 110 | ||
| 111 | function nvSocketColor (scope, mated) { | |
| 112 | if (!mated) return scope === 'unresolved' ? '#f85149' : '#6e7076' | |
| 113 | if (scope === 'package' || scope === 'external') return '#8b949e' | |
| 114 | return '#7ee787' | |
| 115 | } | |
| 116 | ||
| 117 | function 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. | |
| 128 | function 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. | |
| 147 | function 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. | |
| 201 | function 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 | ||
| 221 | function 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 | ||
| 254 | function 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. | |
| 279 | function 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. | |
| 329 | function 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. | |
| 412 | function 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 | ||
| 429 | window.nvToggleConnected = function (on) { | |
| 430 | NV.onlyConnected = !!on | |
| 431 | nvBuild() | |
| 432 | } | |
| 433 | ||
| 434 | window.nvToggleFrames = function (on) { | |
| 435 | NV.showFrames = !!on | |
| 436 | nvRender() | |
| 437 | } | |
| 438 | ||
| 439 | // ---- layout: columns by dependency depth, left = supplies, right = consumers | |
| 440 | ||
| 441 | function 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 | ||
| 498 | function 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 | ||
| 505 | function 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. | |
| 520 | var NV_FRAME_COLORS = ['#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72', | |
| 521 | '#ffa657', '#79c0ff', '#f778ba', '#56d364'] | |
| 522 | ||
| 523 | function 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 | ||
| 541 | function 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 | ||
| 590 | function 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 | ||
| 626 | function 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 | ||
| 700 | function 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 | ||
| 734 | function nvEnter (level) { | |
| 735 | if (!level) return | |
| 736 | NV.stack.push(level) | |
| 737 | nvBuild() | |
| 738 | } | |
| 739 | ||
| 740 | function nvGoto (depth) { | |
| 741 | NV.stack = NV.stack.slice(0, depth + 1) | |
| 742 | nvBuild() | |
| 743 | } | |
| 744 | ||
| 745 | function 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">›</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 | ||
| 765 | function 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 · <b>' + NV.links.length + '</b> noodles — ' + escapeHtml(what) + | |
| 775 | '<br><span style="color:#7ee787">●</span> mated ' + | |
| 776 | '<span style="color:#6e7076">○</span> unmated ' + | |
| 777 | '<span style="color:#f85149">○</span> unresolved' + | |
| 778 | '<br>left sockets demand · 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 | ||
| 794 | function 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. | |
| 849 | function 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 | ||
| 881 | function 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 | ||
| 888 | function 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 | ` |
| … | … | @@ -1,5 +1,10 @@ |
| 1 | import { NODE_VIEW_CSS, NODE_VIEW_HTML, NODE_VIEW_JS } from './render-nodes.js' | |
| 2 | ||
| 1 | 3 | export 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') | |
| 3 | 8 | const apiPrefix = prefix || '' |
| 4 | 9 | |
| 5 | 10 | return `<!DOCTYPE html> |
| … | … | @@ -485,6 +490,7 @@ h2 { font-size: 14px; color: #8b949e; margin-bottom: 6px; font-weight: normal; } |
| 485 | 490 | display: none; |
| 486 | 491 | } |
| 487 | 492 | .edge-hover-label.visible { display: block; } |
| 493 | ${NODE_VIEW_CSS} | |
| 488 | 494 | .graph-breadcrumb { |
| 489 | 495 | position: absolute; |
| 490 | 496 | top: 12px; left: 12px; |
| … | … | @@ -507,6 +513,7 @@ h2 { font-size: 14px; color: #8b949e; margin-bottom: 6px; font-weight: normal; } |
| 507 | 513 | <div class="tabs"> |
| 508 | 514 | <button class="tab main-tab active" data-tab="tree">Tree</button> |
| 509 | 515 | <button class="tab main-tab" data-tab="graph">Graph</button> |
| 516 | <button class="tab main-tab" data-tab="nodes">Nodes</button> | |
| 510 | 517 | <button class="tab main-tab" data-tab="validate">Validate</button> |
| 511 | 518 | <button class="tab main-tab" data-tab="docs">Docs</button> |
| 512 | 519 | </div> |
| … | … | @@ -556,6 +563,7 @@ h2 { font-size: 14px; color: #8b949e; margin-bottom: 6px; font-weight: normal; } |
| 556 | 563 | </div> |
| 557 | 564 | <div class="graph-tooltip" id="graph-tooltip"></div> |
| 558 | 565 | </div> |
| 566 | ${NODE_VIEW_HTML} | |
| 559 | 567 | </div> |
| 560 | 568 | <div id="resize-right" class="resize-handle" style="display:none"></div> |
| 561 | 569 | <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 => { |
| 590 | 598 | |
| 591 | 599 | const detail = document.getElementById('detail') |
| 592 | 600 | const graphContainer = document.getElementById('graph-container') |
| 601 | const nodeContainer = document.getElementById('node-container') | |
| 593 | 602 | const valPanel = document.getElementById('right-panel') |
| 594 | 603 | |
| 595 | 604 | // 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' : '' | |
| 597 | 606 | graphContainer.classList.toggle('active', currentTab === 'graph') |
| 607 | nodeContainer.classList.toggle('active', currentTab === 'nodes') | |
| 598 | 608 | |
| 599 | 609 | const rightPanel = document.getElementById('right-panel') |
| 600 | 610 | const resizeRight = document.getElementById('resize-right') |
| … | … | @@ -616,6 +626,10 @@ document.querySelectorAll('.main-tab').forEach(tab => { |
| 616 | 626 | renderDocsCruft() |
| 617 | 627 | } else if (currentTab === 'graph') { |
| 618 | 628 | if (!graphInitialized) { initGraph(); updateBreadcrumb() } |
| 629 | } else if (currentTab === 'nodes') { | |
| 630 | rightPanel.style.display = 'none' | |
| 631 | resizeRight.style.display = 'none' | |
| 632 | nvInit() | |
| 619 | 633 | } |
| 620 | 634 | }) |
| 621 | 635 | }) |
| … | … | @@ -1133,6 +1147,48 @@ function showDetail (type, data) { |
| 1133 | 1147 | panel.innerHTML = html |
| 1134 | 1148 | } |
| 1135 | 1149 | |
| 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. | |
| 1162 | let sectionData = null | |
| 1163 | ||
| 1164 | function 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 | ||
| 1175 | function misfitsFor (name) { | |
| 1176 | if (!sectionData || !sectionData.perModule) return null | |
| 1177 | return sectionData.perModule[name] || null | |
| 1178 | } | |
| 1179 | ||
| 1180 | // One short line for the tooltip. | |
| 1181 | function 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(' · ') | |
| 1190 | } | |
| 1191 | ||
| 1136 | 1192 | // ====== GRAPH VIEW ====== |
| 1137 | 1193 | let graphInitialized = false |
| 1138 | 1194 | const graphNodes = [] |
| … | … | @@ -1446,6 +1502,13 @@ function initGraph () { |
| 1446 | 1502 | edge.el = line |
| 1447 | 1503 | } |
| 1448 | 1504 | |
| 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 | ||
| 1449 | 1512 | // Draw nodes |
| 1450 | 1513 | for (let i = 0; i < graphNodes.length; i++) { |
| 1451 | 1514 | const n = graphNodes[i] |
| … | … | @@ -1511,10 +1574,7 @@ function initGraph () { |
| 1511 | 1574 | n.el = g |
| 1512 | 1575 | |
| 1513 | 1576 | // Node drag |
| 1514 | let dragStarted = false | |
| 1515 | let didDrag = false | |
| 1516 | 1577 | g.addEventListener('mousedown', (e) => { |
| 1517 | dragStarted = true | |
| 1518 | 1578 | didDrag = false |
| 1519 | 1579 | n.fixed = true |
| 1520 | 1580 | e.stopPropagation() |
| … | … | @@ -1801,6 +1861,7 @@ function initGraph () { |
| 1801 | 1861 | simRafId = requestAnimationFrame(simulate) |
| 1802 | 1862 | } |
| 1803 | 1863 | showGraphSettings() |
| 1864 | loadSections() | |
| 1804 | 1865 | } |
| 1805 | 1866 | |
| 1806 | 1867 | // --- Focus a module: highlight its connections --- |
| … | … | @@ -1865,13 +1926,28 @@ function unfocusAll () { |
| 1865 | 1926 | |
| 1866 | 1927 | function showTooltip (n) { |
| 1867 | 1928 | 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 ? '...' : '') : '') | |
| 1875 | 1951 | tooltip.style.display = 'block' |
| 1876 | 1952 | } |
| 1877 | 1953 | |
| … | … | @@ -2699,6 +2775,11 @@ function renderSubGraph (svg, cx, cy, W, H, nodeType) { |
| 2699 | 2775 | } |
| 2700 | 2776 | } |
| 2701 | 2777 | |
| 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 | ||
| 2702 | 2783 | // Draw nodes |
| 2703 | 2784 | for (let i = 0; i < graphNodes.length; i++) { |
| 2704 | 2785 | const n = graphNodes[i] |
| … | … | @@ -2764,7 +2845,6 @@ function renderSubGraph (svg, cx, cy, W, H, nodeType) { |
| 2764 | 2845 | n.el = g |
| 2765 | 2846 | |
| 2766 | 2847 | // Drag |
| 2767 | let didDrag = false | |
| 2768 | 2848 | g.addEventListener('mousedown', (e) => { |
| 2769 | 2849 | n.fixed = true; didDrag = false |
| 2770 | 2850 | e.stopPropagation(); e.preventDefault() |
| … | … | @@ -3834,9 +3914,10 @@ window.loadFileViewer = loadFileViewer |
| 3834 | 3914 | function roleIcon (role) { |
| 3835 | 3915 | return ({ entry: 'E', lib: 'L', test: 'T', schema: 'S', bin: 'B', script: 'X', example: 'x' })[role] || 'F' |
| 3836 | 3916 | } |
| 3837 | function escapeHtml (s) { | |
| 3838 | return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') | |
| 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. | |
| 3840 | 3921 | |
| 3841 | 3922 | // ====== RESIZABLE PANELS ====== |
| 3842 | 3923 | function initResizable () { |
| … | … | @@ -3879,6 +3960,8 @@ function makeResizable (handleId, panelId, side) { |
| 3879 | 3960 | }) |
| 3880 | 3961 | } |
| 3881 | 3962 | |
| 3963 | ${NODE_VIEW_JS} | |
| 3964 | ||
| 3882 | 3965 | // ====== INIT ====== |
| 3883 | 3966 | buildTree() |
| 3884 | 3967 | initResizable() |
| … | … | @@ -206,16 +206,37 @@ function walkFile (emitter, filePath, pkgDir, modName, visited, root, ignore, ct |
| 206 | 206 | |
| 207 | 207 | // Parse eagerly and emit. (Workers and mtime cache are future optimizations |
| 208 | 208 | // that plug in here — they just change who produces the file-parsed event.) |
| 209 | let parsed = null | |
| 209 | 210 | try { |
| 210 | const parsed = lang.parse(source, relInPkg) | |
| 211 | parsed = lang.parse(source, relInPkg) | |
| 211 | 212 | emitter.emit('file-parsed', { modName, relInPkg, relPath, parsed }) |
| 212 | 213 | } catch (err) { |
| 213 | 214 | emitter.emit('file-parsed', { modName, relInPkg, relPath, parsed: { error: err.message, filePath: relInPkg } }) |
| 214 | 215 | } |
| 215 | 216 | |
| 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 | ||
| 216 | 230 | // 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). | |
| 217 | 237 | 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) | |
| 219 | 240 | for (const spec of explicit) { |
| 220 | 241 | const resolved = lang.resolveImport(filePath, spec, ctx) |
| 221 | 242 | if (!resolved) continue |
| … | … | @@ -224,6 +245,7 @@ function walkFile (emitter, filePath, pkgDir, modName, visited, root, ignore, ct |
| 224 | 245 | fromRelInPkg: relInPkg, |
| 225 | 246 | specifier: spec, |
| 226 | 247 | resolvedRelPath: relative(root, resolved), |
| 248 | names: namesBySpec.get(spec) || [], | |
| 227 | 249 | implicit: false |
| 228 | 250 | }) |
| 229 | 251 | count += walkFile(emitter, resolved, pkgDir, modName, visited, root, ignore, ctx) |
| … | … | @@ -162,6 +162,11 @@ export async function scan (rootDir, opts = {}) { |
| 162 | 162 | |
| 163 | 163 | tree.validation = validation |
| 164 | 164 | 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) | |
| 165 | 170 | tree.docs = { |
| 166 | 171 | specDir: docs.specDir || null, |
| 167 | 172 | specCount: docs.specs.length, |
| … | … | @@ -170,3 +175,36 @@ export async function scan (rootDir, opts = {}) { |
| 170 | 175 | |
| 171 | 176 | return tree |
| 172 | 177 | } |
| 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 | */ | |
| 190 | function 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 | } |
| … | … | @@ -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. | |
| 20 | function 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. | |
| 26 | function 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. | |
| 33 | const 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 | ||
| 41 | function 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 | ||
| 53 | export 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 | } |
| … | … | @@ -20,6 +20,11 @@ export function startServer (tree, opts = {}) { |
| 20 | 20 | // Live store — subscribable event stream fed by fs.watch. opts.watch === false |
| 21 | 21 | // disables it (useful for one-shot CLI serve, or when the inotify limit |
| 22 | 22 | // 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 | ||
| 23 | 28 | const live = opts.graph |
| 24 | 29 | ? new LiveStore(opts.graph, { watch: opts.watch !== false }) |
| 25 | 30 | : null |
| … | … | @@ -89,6 +94,83 @@ export function startServer (tree, opts = {}) { |
| 89 | 94 | return |
| 90 | 95 | } |
| 91 | 96 | |
| 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 | ||
| 92 | 174 | // Server-Sent Events stream: replay current state as events, then push |
| 93 | 175 | // live events (file changes detected by fs.watch) as they happen. |
| 94 | 176 | // |
| … | … | @@ -39,6 +39,10 @@ export function buildTree (graph) { |
| 39 | 39 | type: mod.type, |
| 40 | 40 | entry: mod.entry ? basename(mod.entry) : null, |
| 41 | 41 | 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 || {}, | |
| 42 | 46 | importMaps: Object.keys(mod.importMaps).length > 0 ? mod.importMaps : undefined, |
| 43 | 47 | testCommand: mod.testCommand, |
| 44 | 48 | publicAPI: [], |
| … | … | @@ -0,0 +1,223 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { resolve, dirname } from 'path' | |
| 3 | import { fileURLToPath } from 'url' | |
| 4 | import fs from 'fs' | |
| 5 | import { deriveFile } from '../lib/derive.js' | |
| 6 | ||
| 7 | const HERE = dirname(fileURLToPath(import.meta.url)) | |
| 8 | const 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 | ||
| 35 | function derive (name) { | |
| 36 | const path = resolve(FIXTURE, name) | |
| 37 | return deriveFile(fs.readFileSync(path, 'utf8'), path) | |
| 38 | } | |
| 39 | ||
| 40 | test('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 | ||
| 51 | test('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 | ||
| 67 | test('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 | ||
| 76 | test('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 | ||
| 89 | test('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 | ||
| 100 | test('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 | ||
| 126 | test('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 | ||
| 137 | test('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 | ||
| 147 | test('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 | ||
| 165 | test('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 | ||
| 186 | test('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 | ||
| 199 | test('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 | ||
| 206 | test('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 | ||
| 212 | test('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 | }) |
| … | … | @@ -0,0 +1,3 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import b4a from 'b4a' | |
| 3 | export function demo () { return test && b4a } |
| … | … | @@ -0,0 +1,9 @@ |
| 1 | import { alpha } from './lib/a.js' | |
| 2 | import { beta } from './lib/b.js' | |
| 3 | export { alpha } | |
| 4 | export { shared } from './lib/util.js' | |
| 5 | export function entry () { return alpha() + beta() } | |
| 6 | export async function lazy () { | |
| 7 | const { lazyThing } = await import('./lib/dyn.js') | |
| 8 | return lazyThing() | |
| 9 | } |
| … | … | @@ -0,0 +1,3 @@ |
| 1 | import { shared } from './util.js' | |
| 2 | export function alpha () { return shared() } | |
| 3 | export function neverUsed () { return 1 } |
| … | … | @@ -0,0 +1,3 @@ |
| 1 | import { shared } from './util.js' | |
| 2 | import { missingThing } from './nope.js' | |
| 3 | export function beta () { return shared() + missingThing() } |
| … | … | @@ -0,0 +1 @@ |
| 1 | export function lazyThing () { return 'lazy' } |
| … | … | @@ -0,0 +1,2 @@ |
| 1 | export function shared () { return 42 } | |
| 2 | export const alsoDead = 7 |
| … | … | @@ -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 | } |
| … | … | @@ -0,0 +1 @@ |
| 1 | export function nope ( { |
| … | … | @@ -0,0 +1,3 @@ |
| 1 | export function format (text, at) { | |
| 2 | return text + ' @' + at | |
| 3 | } |
| … | … | @@ -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 | ||
| 6 | import { format } from './fmt.js' | |
| 7 | ||
| 8 | export 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 | ||
| 23 | export function label (row) { | |
| 24 | const at = Date.now() | |
| 25 | return format(`${row.name}: ${row.count}`, at) | |
| 26 | } |
| … | … | @@ -0,0 +1,70 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { resolve, dirname } from 'path' | |
| 3 | import { fileURLToPath } from 'url' | |
| 4 | import { scan } from '../lib/scanner.js' | |
| 5 | import { renderPage } from '../lib/render.js' | |
| 6 | import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs' | |
| 7 | ||
| 8 | const HERE = dirname(fileURLToPath(import.meta.url)) | |
| 9 | const 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 | ||
| 15 | function 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 | ||
| 22 | test('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 | ||
| 36 | test('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 | ||
| 54 | test('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 | }) |
| … | … | @@ -0,0 +1,140 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { resolve, dirname } from 'path' | |
| 3 | import { fileURLToPath } from 'url' | |
| 4 | import { scan } from '../lib/scanner.js' | |
| 5 | import { buildSections } from '../lib/sections.js' | |
| 6 | ||
| 7 | const HERE = dirname(fileURLToPath(import.meta.url)) | |
| 8 | const 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 | ||
| 21 | test('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 | ||
| 32 | test('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 | ||
| 46 | test('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 | ||
| 60 | test('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 | ||
| 68 | test('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 | ||
| 77 | test('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 | ||
| 85 | test('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 | ||
| 99 | test('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 | ||
| 115 | test('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 | ||
| 131 | test('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 | }) |