| … | … | @@ -0,0 +1,257 @@ |
| 1 | /** | |
| 2 | * Cards — what a level of the ladder shows, derived from the model. | |
| 3 | * | |
| 4 | * Pure: model in, cards out. No DOM, no view state, no pixel sizes, so node can | |
| 5 | * import it directly and the browser can import the same file over /floor/cards.js. | |
| 6 | * It was inside level.js and therefore untestable, guarded only by a parse gate. | |
| 7 | * | |
| 8 | * Both rungs produce the SAME shape — { cards, links, containers } — which is | |
| 9 | * what lets one layout and one renderer serve both (spec section 3). | |
| 10 | * | |
| 11 | * A card is: | |
| 12 | * id the model id it came from | |
| 13 | * kind what it IS, semantically: function | class | const | module-body | | |
| 14 | * op | call | exit | nested. The view maps kind to colour; this | |
| 15 | * module has no opinion about pixels. | |
| 16 | * title the big label | |
| 17 | * badge the small right-hand label | |
| 18 | * sig one line under the title | |
| 19 | * sub counts and position | |
| 20 | * container the zone that holds it, or null | |
| 21 | * code how to find its source: { from, to } or { sparse: true, container } | |
| 22 | * openTo the symbol NAME this card descends into, or null | |
| 23 | */ | |
| 24 | ||
| 25 | /** The file level: one card per symbol (L1), including the module body (L2). */ | |
| 26 | export function buildFileLevel (model) { | |
| 27 | const cards = (model.symbols || []).map(sym => ({ | |
| 28 | id: sym.id, | |
| 29 | kind: sym.kind, | |
| 30 | title: sym.name, | |
| 31 | badge: sym.kind, | |
| 32 | sig: signatureOf(model, sym), | |
| 33 | sub: interiorCounts(model, sym) + ' L' + sym.range.startLine + '-' + sym.range.endLine, | |
| 34 | container: null, | |
| 35 | // The module body's RANGE is the whole file — it IS the program — so a | |
| 36 | // contiguous slice would dump every other symbol's body with it. Its own | |
| 37 | // code is the lines its operations and wires actually sit on. | |
| 38 | code: sym.kind === 'module-body' | |
| 39 | ? { sparse: true, container: sym.fn } | |
| 40 | : { from: sym.range.startLine, to: sym.range.endLine }, | |
| 41 | openTo: sym.fn ? sym.name : null | |
| 42 | })) | |
| 43 | return { cards, links: fileLinks(model), containers: [] } | |
| 44 | } | |
| 45 | ||
| 46 | /** | |
| 47 | * The symbol level: the expression floor for one symbol. | |
| 48 | * | |
| 49 | * Membership is by CONTAINER, never by range (L3/E4b) — an operation inside a | |
| 50 | * nested closure is also inside this symbol's range, and claiming it here would | |
| 51 | * put it in the wrong node. | |
| 52 | */ | |
| 53 | export function buildSymbolLevel (model, sym) { | |
| 54 | const fnId = sym.fn | |
| 55 | const cards = [] | |
| 56 | ||
| 57 | const zones = (model.zones || []).filter(z => z.container === fnId) | |
| 58 | const zoneIds = new Set(zones.map(z => z.id)) | |
| 59 | const containers = zones.map(z => ({ | |
| 60 | id: z.id, | |
| 61 | parent: zoneIds.has(z.parent) ? z.parent : null, | |
| 62 | label: zoneLabel(z), | |
| 63 | shape: z.shape, | |
| 64 | depth: z.depth | |
| 65 | })) | |
| 66 | ||
| 67 | // A declaration names its producing operation, so the value is recognisable | |
| 68 | // downstream by the name the code gave it (R3). | |
| 69 | const nameOfNode = new Map() | |
| 70 | for (const n of model.names || []) if (n.node) nameOfNode.set(n.node, n.name) | |
| 71 | ||
| 72 | for (const op of model.nodes || []) { | |
| 73 | if (op.container !== fnId) continue | |
| 74 | const named = nameOfNode.get(op.id) | |
| 75 | cards.push({ | |
| 76 | id: op.id, | |
| 77 | kind: op.kind === 'CallExpression' ? 'call' : 'op', | |
| 78 | title: op.label, | |
| 79 | badge: named ? '-> ' + named : shortKind(op.kind), | |
| 80 | sig: op.inputs.map(describeInput).join(', '), | |
| 81 | sub: 'L' + op.range.startLine + (op.writes ? ' writes ' + op.writes : ''), | |
| 82 | container: zoneIds.has(op.zone) ? op.zone : null, | |
| 83 | code: { from: op.range.startLine, to: op.range.endLine }, | |
| 84 | openTo: null | |
| 85 | }) | |
| 86 | } | |
| 87 | ||
| 88 | // A nested function is a node you can open in turn — Blender's nested groups, | |
| 89 | // falling out of the data rather than being invented. | |
| 90 | for (const fn of model.functions || []) { | |
| 91 | if (fn.parent !== fnId) continue | |
| 92 | cards.push({ | |
| 93 | id: fn.id, | |
| 94 | kind: 'nested', | |
| 95 | title: fn.name, | |
| 96 | badge: 'nested fn', | |
| 97 | sig: fn.name + ' (' + fn.params.join(', ') + ')', | |
| 98 | sub: (model.nodes || []).filter(n => n.container === fn.id).length + ' ops · L' + | |
| 99 | fn.range.startLine + '-' + fn.range.endLine, | |
| 100 | container: innermostZoneOf(zones, fn.range), | |
| 101 | code: { from: fn.range.startLine, to: fn.range.endLine }, | |
| 102 | openTo: null | |
| 103 | }) | |
| 104 | } | |
| 105 | ||
| 106 | // An exit with no operation of its own — break, continue — would otherwise be | |
| 107 | // invisible, even though it is what gives its zone the shape it has. | |
| 108 | for (const ex of model.exits || []) { | |
| 109 | if (ex.container !== fnId || ex.node) continue | |
| 110 | cards.push({ | |
| 111 | id: ex.id, | |
| 112 | kind: 'exit', | |
| 113 | title: ex.kind, | |
| 114 | badge: 'exit', | |
| 115 | sig: ex.lands ? String(ex.lands.on) : '', | |
| 116 | sub: 'L' + ex.range.startLine, | |
| 117 | container: zoneIds.has(ex.zone) ? ex.zone : null, | |
| 118 | code: { from: ex.range.startLine, to: ex.range.endLine }, | |
| 119 | openTo: null | |
| 120 | }) | |
| 121 | } | |
| 122 | ||
| 123 | const present = new Set(cards.map(c => c.id)) | |
| 124 | const links = [] | |
| 125 | for (const op of model.nodes || []) { | |
| 126 | if (!present.has(op.id)) continue | |
| 127 | for (const i of op.inputs) { | |
| 128 | if (i.source === 'node' && present.has(i.node)) links.push({ from: i.node, to: op.id }) | |
| 129 | } | |
| 130 | } | |
| 131 | return { cards, links, containers } | |
| 132 | } | |
| 133 | ||
| 134 | /** | |
| 135 | * Resolve a card's `code` descriptor against the file's lines. | |
| 136 | * | |
| 137 | * Returns explicit line NUMBERS rather than a start offset, because the module | |
| 138 | * body's slice is sparse — it skips over every other symbol's body — and the | |
| 139 | * numbers are the only way the view can mark where it jumped. | |
| 140 | */ | |
| 141 | export function resolveCode (model, lines, card, maxLines = 120) { | |
| 142 | if (!card.code) return { numbers: [], lines: [], clipped: false } | |
| 143 | ||
| 144 | if (card.code.sparse) { | |
| 145 | const all = ownLines(model, card.code.container) | |
| 146 | const numbers = all.slice(0, maxLines) | |
| 147 | return { | |
| 148 | numbers, | |
| 149 | lines: numbers.map(l => lines[l - 1] || ''), | |
| 150 | clipped: all.length > numbers.length | |
| 151 | } | |
| 152 | } | |
| 153 | ||
| 154 | const from = card.code.from | |
| 155 | const to = Math.min(card.code.to, from + maxLines - 1) | |
| 156 | const slice = lines.slice(from - 1, to) | |
| 157 | return { | |
| 158 | numbers: slice.map((_, i) => from + i), | |
| 159 | lines: slice, | |
| 160 | clipped: to < card.code.to | |
| 161 | } | |
| 162 | } | |
| 163 | ||
| 164 | /** The lines a container's own operations and wires sit on, ascending. */ | |
| 165 | export function ownLines (model, containerId) { | |
| 166 | const own = new Set() | |
| 167 | for (const list of [model.nodes || [], model.names || []]) { | |
| 168 | for (const entry of list) { | |
| 169 | if (entry.container !== containerId) continue | |
| 170 | for (let l = entry.range.startLine; l <= entry.range.endLine; l++) own.add(l) | |
| 171 | } | |
| 172 | } | |
| 173 | return Array.from(own).sort((a, b) => a - b) | |
| 174 | } | |
| 175 | ||
| 176 | /** The deepest zone covering each line — the semantic channel, per line. */ | |
| 177 | export function lineDepths (model, lineCount) { | |
| 178 | const out = new Array(lineCount + 2).fill(0) | |
| 179 | for (const z of model.zones || []) { | |
| 180 | for (let l = z.range.startLine; l <= z.range.endLine; l++) { | |
| 181 | if (l <= lineCount && z.depth > out[l]) out[l] = z.depth | |
| 182 | } | |
| 183 | } | |
| 184 | return out | |
| 185 | } | |
| 186 | ||
| 187 | /** | |
| 188 | * The file's own call graph: an operation calling `foo`, where `foo` is a symbol | |
| 189 | * in this file, is an edge from foo to whatever symbol holds the call. Derived, | |
| 190 | * never authored. | |
| 191 | */ | |
| 192 | export function fileLinks (model) { | |
| 193 | const byName = new Map() | |
| 194 | const byFn = new Map() | |
| 195 | for (const s of model.symbols || []) { | |
| 196 | if (s.name) byName.set(s.name, s) | |
| 197 | if (s.fn) byFn.set(s.fn, s) | |
| 198 | } | |
| 199 | ||
| 200 | const seen = new Set() | |
| 201 | const links = [] | |
| 202 | for (const op of model.nodes || []) { | |
| 203 | if (op.kind !== 'CallExpression') continue | |
| 204 | const callee = op.inputs.find(i => i.slot === 'callee') | |
| 205 | if (!callee || !callee.name) continue | |
| 206 | const target = byName.get(callee.name.split('.')[0].split('[')[0]) | |
| 207 | const holder = byFn.get(op.container) | |
| 208 | if (!target || !holder || target.id === holder.id) continue | |
| 209 | const key = target.id + '->' + holder.id | |
| 210 | if (seen.has(key)) continue | |
| 211 | seen.add(key) | |
| 212 | links.push({ from: target.id, to: holder.id }) | |
| 213 | } | |
| 214 | return links | |
| 215 | } | |
| 216 | ||
| 217 | export function signatureOf (model, sym) { | |
| 218 | const fn = (model.functions || []).find(f => f.id === sym.fn) | |
| 219 | if (fn) return fn.name + ' (' + fn.params.join(', ') + ')' | |
| 220 | if (sym.kind === 'class') return 'class ' + sym.name | |
| 221 | return sym.name | |
| 222 | } | |
| 223 | ||
| 224 | export function interiorCounts (model, sym) { | |
| 225 | if (!sym.fn) return sym.members.length > 0 ? sym.members.length + ' members' : '' | |
| 226 | return (model.nodes || []).filter(n => n.container === sym.fn).length + ' ops · ' + | |
| 227 | (model.zones || []).filter(z => z.container === sym.fn).length + ' zones · ' + | |
| 228 | (model.names || []).filter(n => n.container === sym.fn).length + ' wires' | |
| 229 | } | |
| 230 | ||
| 231 | export function zoneLabel (zone) { | |
| 232 | let out = zone.shape | |
| 233 | if (zone.condition) out += ' ' + zone.condition.text | |
| 234 | if (zone.carried && zone.carried.length > 0) out += ' carries ' + zone.carried.join(',') | |
| 235 | return out | |
| 236 | } | |
| 237 | ||
| 238 | function describeInput (i) { | |
| 239 | if (i.literal) return i.name | |
| 240 | return i.name + (i.source === 'node' ? '' : ' [' + i.source + ']') | |
| 241 | } | |
| 242 | ||
| 243 | function shortKind (kind) { | |
| 244 | return kind.replace('Expression', '').replace('Statement', '').toLowerCase() | |
| 245 | } | |
| 246 | ||
| 247 | // The innermost zone whose range covers this one — used for a nested function, | |
| 248 | // which has no `zone` field of its own. | |
| 249 | function innermostZoneOf (zones, range) { | |
| 250 | let best = null | |
| 251 | for (const z of zones) { | |
| 252 | if (z.range.startByte <= range.startByte && z.range.endByte >= range.endByte) { | |
| 253 | if (!best || z.depth > best.depth) best = z | |
| 254 | } | |
| 255 | } | |
| 256 | return best ? best.id : null | |
| 257 | } |
| … | … | @@ -13,9 +13,14 @@ |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { layout } from '/floor/layout.js' |
| 16 | import { | |
| 17 | buildFileLevel, buildSymbolLevel, resolveCode, lineDepths | |
| 18 | } from '/floor/cards.js' | |
| 16 | 19 | |
| 17 | 20 | const SVG_NS = 'http://www.w3.org/2000/svg' |
| 18 | 21 | |
| 22 | // A card's KIND is semantic and comes from cards.js; the mapping to a colour is | |
| 23 | // the view's business and lives only here. | |
| 19 | 24 | const KIND_COLOR = { |
| 20 | 25 | function: '#b8642a', |
| 21 | 26 | class: '#6b4a86', |
| … | … | @@ -45,6 +50,8 @@ const view = { |
| 45 | 50 | symbolId: null, // null = the file level |
| 46 | 51 | expanded: new Set(), |
| 47 | 52 | cards: [], |
| 53 | links: [], | |
| 54 | containers: [], | |
| 48 | 55 | laid: null, |
| 49 | 56 | panX: 60, |
| 50 | 57 | panY: 100, |
| … | … | @@ -54,8 +61,6 @@ const view = { |
| 54 | 61 | sy: 0 |
| 55 | 62 | } |
| 56 | 63 | |
| 57 | // --- data ------------------------------------------------------------------ | |
| 58 | ||
| 59 | 64 | async function load (path) { |
| 60 | 65 | const res = await fetch('/api/derive?path=' + encodeURIComponent(path)) |
| 61 | 66 | const data = await res.json() |
| … | … | @@ -70,11 +75,8 @@ async function load (path) { |
| 70 | 75 | view.lineDepth = lineDepths(data.model, view.lines.length) |
| 71 | 76 | view.expanded = new Set() |
| 72 | 77 | |
| 73 | const params = new URLSearchParams(location.search) | |
| 74 | const wantSymbol = params.get('symbol') | |
| 75 | const sym = wantSymbol | |
| 76 | ? data.model.symbols.find(s => s.name === wantSymbol) | |
| 77 | : null | |
| 78 | const wantSymbol = new URLSearchParams(location.search).get('symbol') | |
| 79 | const sym = wantSymbol ? data.model.symbols.find(s => s.name === wantSymbol) : null | |
| 78 | 80 | view.symbolId = sym ? sym.id : null |
| 79 | 81 | |
| 80 | 82 | applyExpandParam() |
| … | … | @@ -82,223 +84,26 @@ async function load (path) { |
| 82 | 84 | rebuild() |
| 83 | 85 | } |
| 84 | 86 | |
| 85 | // The deepest zone covering each line, so expanded code can show a depth rail. | |
| 86 | // Same semantic channel cells.js packs into a byte, at line granularity. | |
| 87 | function lineDepths (model, lineCount) { | |
| 88 | const out = new Array(lineCount + 2).fill(0) | |
| 89 | for (const z of model.zones || []) { | |
| 90 | for (let l = z.range.startLine; l <= z.range.endLine; l++) { | |
| 91 | if (l <= lineCount && z.depth > out[l]) out[l] = z.depth | |
| 92 | } | |
| 93 | } | |
| 94 | return out | |
| 95 | } | |
| 96 | ||
| 97 | 87 | function currentSymbol () { |
| 98 | 88 | if (!view.symbolId) return null |
| 99 | 89 | return view.model.symbols.find(s => s.id === view.symbolId) || null |
| 100 | 90 | } |
| 101 | 91 | |
| 102 | function ownLines (containerId) { | |
| 103 | const own = new Set() | |
| 104 | for (const n of view.model.nodes) { | |
| 105 | if (n.container !== containerId) continue | |
| 106 | for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l) | |
| 107 | } | |
| 108 | for (const n of view.model.names) { | |
| 109 | if (n.container !== containerId) continue | |
| 110 | for (let l = n.range.startLine; l <= n.range.endLine; l++) own.add(l) | |
| 111 | } | |
| 112 | return Array.from(own).sort((a, b) => a - b) | |
| 113 | } | |
| 114 | ||
| 115 | function codeFor (card) { | |
| 116 | if (!card.code) return { numbers: [], lines: [], clipped: false } | |
| 117 | if (card.code.sparse) { | |
| 118 | const nums = ownLines(card.code.container).slice(0, MAX_CODE_LINES) | |
| 119 | return { | |
| 120 | numbers: nums, | |
| 121 | lines: nums.map(l => view.lines[l - 1] || ''), | |
| 122 | clipped: ownLines(card.code.container).length > nums.length | |
| 123 | } | |
| 124 | } | |
| 125 | const from = card.code.from | |
| 126 | const to = Math.min(card.code.to, from + MAX_CODE_LINES - 1) | |
| 127 | const lines = view.lines.slice(from - 1, to) | |
| 128 | return { numbers: lines.map((_, i) => from + i), lines, clipped: to < card.code.to } | |
| 129 | } | |
| 130 | ||
| 131 | // --- the two levels, both producing cards ---------------------------------- | |
| 132 | ||
| 133 | function buildFileLevel () { | |
| 134 | const m = view.model | |
| 135 | const cards = m.symbols.map(sym => ({ | |
| 136 | id: sym.id, | |
| 137 | color: KIND_COLOR[sym.kind] || '#3a3d44', | |
| 138 | title: sym.name, | |
| 139 | kindLabel: sym.kind, | |
| 140 | sig: signatureOf(sym), | |
| 141 | sub: interiorCounts(sym) + ' L' + sym.range.startLine + '-' + sym.range.endLine, | |
| 142 | container: null, | |
| 143 | // The module body IS the program, so its range is the whole file; its own | |
| 144 | // code is only the lines its operations and wires sit on. | |
| 145 | code: sym.kind === 'module-body' | |
| 146 | ? { sparse: true, container: sym.fn } | |
| 147 | : { from: sym.range.startLine, to: sym.range.endLine }, | |
| 148 | openTo: sym.fn ? sym.name : null, | |
| 149 | wide: NODE_W_OPEN, | |
| 150 | narrow: NODE_W | |
| 151 | })) | |
| 152 | return { cards, links: fileLinks(), containers: [] } | |
| 153 | } | |
| 154 | ||
| 155 | function buildSymbolLevel (sym) { | |
| 156 | const m = view.model | |
| 157 | const fnId = sym.fn | |
| 158 | ||
| 159 | // Zones directly in this symbol become CONTAINERS. A zone inside a nested | |
| 160 | // function belongs to that function's own interior, not to this one. | |
| 161 | const zones = m.zones.filter(z => z.container === fnId) | |
| 162 | const zoneIds = new Set(zones.map(z => z.id)) | |
| 163 | const containers = zones.map(z => ({ | |
| 164 | id: z.id, | |
| 165 | parent: zoneIds.has(z.parent) ? z.parent : null, | |
| 166 | label: z.shape + (z.condition ? ' ' + clip(z.condition.text, 30) : '') + | |
| 167 | (z.carried && z.carried.length > 0 ? ' carries ' + z.carried.join(',') : '') | |
| 168 | })) | |
| 169 | ||
| 170 | const nameOfNode = new Map() | |
| 171 | for (const n of m.names) if (n.node) nameOfNode.set(n.node, n.name) | |
| 172 | ||
| 173 | const cards = [] | |
| 174 | for (const op of m.nodes) { | |
| 175 | if (op.container !== fnId) continue | |
| 176 | const named = nameOfNode.get(op.id) | |
| 177 | const ins = op.inputs.map(i => (i.literal ? i.name : i.name + (i.source === 'node' ? '' : ' [' + i.source + ']'))) | |
| 178 | cards.push({ | |
| 179 | id: op.id, | |
| 180 | color: op.kind === 'CallExpression' ? KIND_COLOR.call : KIND_COLOR.op, | |
| 181 | title: op.label, | |
| 182 | kindLabel: named ? '-> ' + named : shortKind(op.kind), | |
| 183 | sig: ins.length > 0 ? clip(ins.join(', '), 44) : '', | |
| 184 | sub: 'L' + op.range.startLine + (op.writes ? ' writes ' + op.writes : ''), | |
| 185 | container: zoneIds.has(op.zone) ? op.zone : null, | |
| 186 | code: { from: op.range.startLine, to: op.range.endLine }, | |
| 187 | openTo: null, | |
| 188 | wide: OP_W_OPEN, | |
| 189 | narrow: OP_W | |
| 190 | }) | |
| 191 | } | |
| 192 | ||
| 193 | // A nested function is a node you can open in turn — Blender's nested groups, | |
| 194 | // falling out of the data rather than being invented (L1). | |
| 195 | for (const fn of m.functions) { | |
| 196 | if (fn.parent !== fnId) continue | |
| 197 | cards.push({ | |
| 198 | id: fn.id, | |
| 199 | color: KIND_COLOR.nested, | |
| 200 | title: fn.name, | |
| 201 | kindLabel: 'nested fn', | |
| 202 | sig: fn.name + ' (' + fn.params.join(', ') + ')', | |
| 203 | sub: m.nodes.filter(n => n.container === fn.id).length + ' ops · L' + | |
| 204 | fn.range.startLine + '-' + fn.range.endLine, | |
| 205 | container: zoneOfRange(zones, fn.range), | |
| 206 | code: { from: fn.range.startLine, to: fn.range.endLine }, | |
| 207 | openTo: null, | |
| 208 | wide: OP_W_OPEN, | |
| 209 | narrow: OP_W | |
| 210 | }) | |
| 211 | } | |
| 212 | ||
| 213 | // An exit with no operation of its own — break, continue — would otherwise be | |
| 214 | // invisible, even though it is the thing that makes the zone's shape what it is. | |
| 215 | for (const ex of m.exits) { | |
| 216 | if (ex.container !== fnId || ex.node) continue | |
| 217 | cards.push({ | |
| 218 | id: ex.id, | |
| 219 | color: KIND_COLOR.exit, | |
| 220 | title: ex.kind, | |
| 221 | kindLabel: 'exit', | |
| 222 | sig: ex.lands ? String(ex.lands.on) : '', | |
| 223 | sub: 'L' + ex.range.startLine, | |
| 224 | container: zoneIds.has(ex.zone) ? ex.zone : null, | |
| 225 | code: { from: ex.range.startLine, to: ex.range.endLine }, | |
| 226 | openTo: null, | |
| 227 | wide: OP_W_OPEN, | |
| 228 | narrow: OP_W | |
| 229 | }) | |
| 230 | } | |
| 231 | ||
| 232 | const ids = new Set(cards.map(c => c.id)) | |
| 233 | const links = [] | |
| 234 | for (const op of m.nodes) { | |
| 235 | if (!ids.has(op.id)) continue | |
| 236 | for (const i of op.inputs) { | |
| 237 | if (i.source === 'node' && ids.has(i.node)) links.push({ from: i.node, to: op.id }) | |
| 238 | } | |
| 239 | } | |
| 240 | return { cards, links, containers } | |
| 241 | } | |
| 242 | ||
| 243 | function zoneOfRange (zones, range) { | |
| 244 | let best = null | |
| 245 | for (const z of zones) { | |
| 246 | if (z.range.startByte <= range.startByte && z.range.endByte >= range.endByte) { | |
| 247 | if (!best || z.depth > best.depth) best = z | |
| 248 | } | |
| 249 | } | |
| 250 | return best ? best.id : null | |
| 251 | } | |
| 252 | ||
| 253 | function shortKind (kind) { | |
| 254 | return kind.replace('Expression', '').replace('Statement', '').toLowerCase() | |
| 255 | } | |
| 256 | ||
| 257 | function fileLinks () { | |
| 258 | const m = view.model | |
| 259 | const byName = new Map() | |
| 260 | for (const s of m.symbols) if (s.name) byName.set(s.name, s) | |
| 261 | const byFn = new Map() | |
| 262 | for (const s of m.symbols) if (s.fn) byFn.set(s.fn, s) | |
| 263 | ||
| 264 | const seen = new Set() | |
| 265 | const links = [] | |
| 266 | for (const op of m.nodes) { | |
| 267 | if (op.kind !== 'CallExpression') continue | |
| 268 | const callee = op.inputs.find(i => i.slot === 'callee') | |
| 269 | if (!callee || !callee.name) continue | |
| 270 | const target = byName.get(callee.name.split('.')[0].split('[')[0]) | |
| 271 | const holder = byFn.get(op.container) | |
| 272 | if (!target || !holder || target.id === holder.id) continue | |
| 273 | const key = target.id + '->' + holder.id | |
| 274 | if (seen.has(key)) continue | |
| 275 | seen.add(key) | |
| 276 | links.push({ from: target.id, to: holder.id }) | |
| 277 | } | |
| 278 | return links | |
| 279 | } | |
| 280 | ||
| 281 | function signatureOf (sym) { | |
| 282 | const fn = (view.model.functions || []).find(f => f.id === sym.fn) | |
| 283 | if (fn) return fn.name + ' (' + fn.params.join(', ') + ')' | |
| 284 | if (sym.kind === 'class') return 'class ' + sym.name | |
| 285 | return sym.name | |
| 286 | } | |
| 92 | // --- layout ---------------------------------------------------------------- | |
| 287 | 93 | |
| 288 | function interiorCounts (sym) { | |
| 289 | const m = view.model | |
| 290 | if (!sym.fn) return sym.members.length > 0 ? sym.members.length + ' members' : '' | |
| 291 | return m.nodes.filter(n => n.container === sym.fn).length + ' ops · ' + | |
| 292 | m.zones.filter(z => z.container === sym.fn).length + ' zones · ' + | |
| 293 | m.names.filter(n => n.container === sym.fn).length + ' wires' | |
| 94 | // Pixel sizes are the VIEW's business; cards.js has no opinion about them. | |
| 95 | function widthOf (card) { | |
| 96 | const open = view.expanded.has(card.id) | |
| 97 | const symbolLevel = card.kind === 'op' || card.kind === 'call' || | |
| 98 | card.kind === 'exit' || card.kind === 'nested' | |
| 99 | if (symbolLevel) return open ? OP_W_OPEN : OP_W | |
| 100 | return open ? NODE_W_OPEN : NODE_W | |
| 294 | 101 | } |
| 295 | 102 | |
| 296 | // --- layout ---------------------------------------------------------------- | |
| 297 | ||
| 298 | 103 | function heightOf (card) { |
| 299 | 104 | let h = HEAD_H + (card.sig ? LINE_H : 0) + LINE_H + 6 |
| 300 | 105 | if (view.expanded.has(card.id)) { |
| 301 | const code = codeFor(card) | |
| 106 | const code = resolveCode(view.model, view.lines, card, MAX_CODE_LINES) | |
| 302 | 107 | let gaps = 0 |
| 303 | 108 | for (let i = 1; i < code.numbers.length; i++) { |
| 304 | 109 | if (code.numbers[i] > code.numbers[i - 1] + 1) gaps++ |
| … | … | @@ -311,7 +116,7 @@ function heightOf (card) { |
| 311 | 116 | |
| 312 | 117 | function rebuild () { |
| 313 | 118 | const sym = currentSymbol() |
| 314 | const built = sym ? buildSymbolLevel(sym) : buildFileLevel() | |
| 119 | const built = sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model) | |
| 315 | 120 | view.cards = built.cards |
| 316 | 121 | view.links = built.links |
| 317 | 122 | view.containers = built.containers |
| … | … | @@ -320,7 +125,7 @@ function rebuild () { |
| 320 | 125 | nodes: built.cards.map(c => ({ |
| 321 | 126 | id: c.id, |
| 322 | 127 | container: c.container, |
| 323 | w: view.expanded.has(c.id) ? c.wide : c.narrow, | |
| 128 | w: widthOf(c), | |
| 324 | 129 | h: heightOf(c) |
| 325 | 130 | })), |
| 326 | 131 | links: built.links, |
| … | … | @@ -396,7 +201,8 @@ function render () { |
| 396 | 201 | x: box.x, y: box.y, width: box.w, height: box.h, rx: 8 |
| 397 | 202 | })) |
| 398 | 203 | const label = el('text', { class: 'zone-label', x: box.x + 8, y: box.y + 13 }) |
| 399 | label.textContent = box.label || '' | |
| 204 | // cards.js builds the full label; how much of it fits is the view's problem. | |
| 205 | label.textContent = clip(box.label || '', Math.floor((box.w - 16) / 5.4)) | |
| 400 | 206 | world.appendChild(label) |
| 401 | 207 | } |
| 402 | 208 | |
| … | … | @@ -430,7 +236,7 @@ function drawCard (world, card, pos) { |
| 430 | 236 | g.appendChild(el('rect', { class: 'n-body', width: pos.w, height: pos.h, rx: 5 })) |
| 431 | 237 | g.appendChild(el('path', { |
| 432 | 238 | class: 'n-head', |
| 433 | fill: card.color, | |
| 239 | fill: KIND_COLOR[card.kind] || '#3a3d44', | |
| 434 | 240 | d: 'M 0 5 a 5 5 0 0 1 5 -5 h ' + (pos.w - 10) + ' a 5 5 0 0 1 5 5 v ' + (HEAD_H - 5) + |
| 435 | 241 | ' h ' + -pos.w + ' z' |
| 436 | 242 | })) |
| … | … | @@ -444,7 +250,7 @@ function drawCard (world, card, pos) { |
| 444 | 250 | g.appendChild(title) |
| 445 | 251 | |
| 446 | 252 | const kind = el('text', { class: 'n-sub', x: pos.w - 7, y: 14, 'text-anchor': 'end' }) |
| 447 | kind.textContent = card.kindLabel | |
| 253 | kind.textContent = card.badge | |
| 448 | 254 | g.appendChild(kind) |
| 449 | 255 | |
| 450 | 256 | let y = HEAD_H + 11 |
| … | … | @@ -459,7 +265,7 @@ function drawCard (world, card, pos) { |
| 459 | 265 | g.appendChild(sub) |
| 460 | 266 | |
| 461 | 267 | if (open) { |
| 462 | const code = codeFor(card) | |
| 268 | const code = resolveCode(view.model, view.lines, card, MAX_CODE_LINES) | |
| 463 | 269 | y += 12 |
| 464 | 270 | let prev = null |
| 465 | 271 | for (let i = 0; i < code.lines.length; i++) { |
| … | … | @@ -544,7 +350,7 @@ function applyExpandParam () { |
| 544 | 350 | if (!want) return |
| 545 | 351 | if (want === 'all') { |
| 546 | 352 | const sym = currentSymbol() |
| 547 | const built = sym ? buildSymbolLevel(sym) : buildFileLevel() | |
| 353 | const built = sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model) | |
| 548 | 354 | for (const c of built.cards) view.expanded.add(c.id) |
| 549 | 355 | return |
| 550 | 356 | } |
| … | … | @@ -286,6 +286,10 @@ export function startServer (tree, opts = {}) { |
| 286 | 286 | } |
| 287 | 287 | // The view imports the SAME layout module the tests run against, rather than |
| 288 | 288 | // a copy of it that can drift. |
| 289 | if (url === '/floor/cards.js') { | |
| 290 | sendFile(req, res, 'floor/cards.js', 'application/javascript; charset=utf-8') | |
| 291 | return | |
| 292 | } | |
| 289 | 293 | if (url === '/floor/layout.js') { |
| 290 | 294 | sendFile(req, res, 'layout.js', 'application/javascript; charset=utf-8') |
| 291 | 295 | return |
| … | … | @@ -0,0 +1,165 @@ |
| 1 | import { test } from 'brittle' | |
| 2 | import { buildFileLevel, buildSymbolLevel, resolveCode, ownLines, lineDepths, fileLinks } from '../lib/floor/cards.js' | |
| 3 | import { deriveFile } from '../lib/derive.js' | |
| 4 | ||
| 5 | // These were inside level.js and therefore untestable — the view imported the | |
| 6 | // served '/floor/layout.js' path, so node could not load the file at all. They | |
| 7 | // are the logic that decides what each level of the ladder SHOWS, which is | |
| 8 | // exactly the part worth pinning. | |
| 9 | ||
| 10 | const SRC = [ | |
| 11 | "import { helper } from './h.js'", // 1 | |
| 12 | '', // 2 | |
| 13 | 'const LIMIT = 10', // 3 | |
| 14 | '', // 4 | |
| 15 | 'export function tally (rows) {', // 5 | |
| 16 | ' let total = 0', // 6 | |
| 17 | ' for (const r of rows) {', // 7 zone: foreach | |
| 18 | ' const bump = (n) => n + 1', // 8 nested function | |
| 19 | ' if (r.skip) continue', // 9 zone: guard + a continue exit | |
| 20 | ' const stepped = bump(total)', // 10 a CALL produces a named wire | |
| 21 | ' total = stepped', // 11 | |
| 22 | ' }', // 12 | |
| 23 | ' return total', // 13 | |
| 24 | '}', // 14 | |
| 25 | '', // 15 | |
| 26 | 'export class Store {', // 16 | |
| 27 | ' read (name) { return helper(name) }', // 17 | |
| 28 | '}', // 18 | |
| 29 | '', // 19 | |
| 30 | 'const ready = tally([1])', // 20 top-level -> module body | |
| 31 | '' | |
| 32 | ].join('\n') | |
| 33 | ||
| 34 | const LINES = SRC.split('\n') | |
| 35 | const model = deriveFile(SRC, '/x/cards.js') | |
| 36 | const tally = model.symbols.find(s => s.name === 'tally') | |
| 37 | ||
| 38 | test('cards: the file level is one card per symbol', (t) => { | |
| 39 | const { cards, containers } = buildFileLevel(model) | |
| 40 | ||
| 41 | t.alike(cards.map(c => c.title).sort(), | |
| 42 | ['(module body)', 'LIMIT', 'Store', 'ready', 'tally'].sort(), 'five cards') | |
| 43 | t.alike(containers, [], 'the file level has no containers') | |
| 44 | ||
| 45 | const byTitle = {} | |
| 46 | for (const c of cards) byTitle[c.title] = c | |
| 47 | t.is(byTitle.tally.kind, 'function', 'kind is semantic, not a colour') | |
| 48 | t.is(byTitle.tally.openTo, 'tally', 'a symbol with a body can be opened') | |
| 49 | t.is(byTitle.LIMIT.openTo, null, 'a const cannot') | |
| 50 | t.ok(byTitle.tally.sig.includes('rows'), 'the signature carries its params') | |
| 51 | t.ok(byTitle.Store.sig.startsWith('class '), 'a class signature says so') | |
| 52 | }) | |
| 53 | ||
| 54 | test('cards: the module body card slices sparsely, not the whole file', (t) => { | |
| 55 | const { cards } = buildFileLevel(model) | |
| 56 | const body = cards.find(c => c.kind === 'module-body') | |
| 57 | ||
| 58 | t.ok(body.code.sparse, 'its code descriptor is sparse') | |
| 59 | const code = resolveCode(model, LINES, body) | |
| 60 | t.ok(code.numbers.length > 0, 'it resolves to some lines') | |
| 61 | t.ok(code.numbers.length < LINES.length, 'but not the whole file') | |
| 62 | t.absent(code.numbers.includes(10), 'and not a line from inside another symbol') | |
| 63 | t.ok(code.numbers.includes(20), 'while including its own top-level statement') | |
| 64 | }) | |
| 65 | ||
| 66 | test('cards: file links are the call graph, deduped and self-free', (t) => { | |
| 67 | const links = fileLinks(model) | |
| 68 | const byId = {} | |
| 69 | for (const s of model.symbols) byId[s.id] = s.name | |
| 70 | ||
| 71 | const named = links.map(l => byId[l.from] + '->' + byId[l.to]) | |
| 72 | t.ok(named.includes('tally->(module body)'), 'the module body calls tally') | |
| 73 | for (const l of links) t.not(l.from, l.to, 'no self-edge') | |
| 74 | t.is(new Set(named).size, named.length, 'no duplicates') | |
| 75 | }) | |
| 76 | ||
| 77 | test('cards: the symbol level shows only what the symbol CONTAINS', (t) => { | |
| 78 | const { cards } = buildSymbolLevel(model, tally) | |
| 79 | const ids = new Set(cards.map(c => c.id)) | |
| 80 | ||
| 81 | // The discriminating case: `n + 1` lives inside `bump`, which is inside | |
| 82 | // tally's RANGE. Range containment would wrongly claim it here. | |
| 83 | const bump = model.functions.find(f => f.name === 'bump') | |
| 84 | const inBump = model.nodes.filter(n => n.container === bump.id) | |
| 85 | t.ok(inBump.length > 0, 'bump has operations of its own') | |
| 86 | for (const op of inBump) t.absent(ids.has(op.id), 'which do not appear in tally') | |
| 87 | ||
| 88 | // bump itself IS a card here, as something you open in turn. | |
| 89 | const nested = cards.find(c => c.kind === 'nested') | |
| 90 | t.ok(nested, 'the nested function is a card') | |
| 91 | t.is(nested.title, 'bump', 'named from its binding, not anonymous') | |
| 92 | }) | |
| 93 | ||
| 94 | test('cards: zones become containers, nested by parent', (t) => { | |
| 95 | const { containers, cards } = buildSymbolLevel(model, tally) | |
| 96 | ||
| 97 | const shapes = containers.map(c => c.shape).sort() | |
| 98 | t.alike(shapes, ['foreach', 'guard'], 'the loop and the guard inside it') | |
| 99 | ||
| 100 | const loop = containers.find(c => c.shape === 'foreach') | |
| 101 | const guard = containers.find(c => c.shape === 'guard') | |
| 102 | t.is(loop.parent, null, 'the loop is top level in this symbol') | |
| 103 | t.is(guard.parent, loop.id, 'the guard is nested inside it') | |
| 104 | t.ok(loop.label.startsWith('foreach'), 'the label names the shape') | |
| 105 | t.ok(loop.label.includes('carries total'), 'and its carried state: ' + loop.label) | |
| 106 | ||
| 107 | // A card in the guard names the guard, not the loop. | |
| 108 | const inGuard = cards.filter(c => c.container === guard.id) | |
| 109 | t.ok(inGuard.length > 0, 'the guard holds at least one card') | |
| 110 | }) | |
| 111 | ||
| 112 | test('cards: an exit with no operation of its own still gets a card', (t) => { | |
| 113 | const { cards } = buildSymbolLevel(model, tally) | |
| 114 | const exit = cards.find(c => c.kind === 'exit') | |
| 115 | ||
| 116 | t.ok(exit, 'the continue is visible') | |
| 117 | t.is(exit.title, 'continue', 'labelled by what it is') | |
| 118 | t.ok(exit.sig.includes('zone-entry'), 'and says where it lands: ' + exit.sig) | |
| 119 | }) | |
| 120 | ||
| 121 | test('cards: a declaration badges the operation that produces it (R3)', (t) => { | |
| 122 | const { cards } = buildSymbolLevel(model, tally) | |
| 123 | // `const stepped = bump(total)` — the CALL is the producer, so the call card | |
| 124 | // carries the name rather than the name floating free. | |
| 125 | const badged = cards.filter(c => c.badge.startsWith('-> ')) | |
| 126 | t.is(badged.length, 1, 'exactly one card produces a named wire') | |
| 127 | t.is(badged[0].badge, '-> stepped', 'named for the declaration') | |
| 128 | t.is(badged[0].kind, 'call', 'and it is the call that produces it') | |
| 129 | }) | |
| 130 | ||
| 131 | test('cards: links are dataflow between operations present at this level', (t) => { | |
| 132 | const { cards, links } = buildSymbolLevel(model, tally) | |
| 133 | const ids = new Set(cards.map(c => c.id)) | |
| 134 | for (const l of links) { | |
| 135 | t.ok(ids.has(l.from), 'link source is on screen') | |
| 136 | t.ok(ids.has(l.to), 'link target is on screen') | |
| 137 | } | |
| 138 | }) | |
| 139 | ||
| 140 | test('cards: resolveCode clips, and reports that it clipped', (t) => { | |
| 141 | const { cards } = buildFileLevel(model) | |
| 142 | const tallyCard = cards.find(c => c.title === 'tally') | |
| 143 | ||
| 144 | const full = resolveCode(model, LINES, tallyCard) | |
| 145 | t.is(full.numbers[0], 5, 'starts at the symbol') | |
| 146 | t.absent(full.clipped, 'and is not clipped at the default limit') | |
| 147 | ||
| 148 | const tight = resolveCode(model, LINES, tallyCard, 3) | |
| 149 | t.is(tight.lines.length, 3, 'clips to the limit') | |
| 150 | t.ok(tight.clipped, 'and says so') | |
| 151 | }) | |
| 152 | ||
| 153 | test('cards: lineDepths is the per-line semantic channel', (t) => { | |
| 154 | const depths = lineDepths(model, LINES.length) | |
| 155 | t.is(depths[6], 0, 'line 6 is outside every zone') | |
| 156 | t.is(depths[7], 1, 'the for line is depth 1') | |
| 157 | t.is(depths[9], 2, 'the guard inside it is depth 2') | |
| 158 | t.is(depths[13], 0, 'and the return is back outside') | |
| 159 | }) | |
| 160 | ||
| 161 | test('cards: pure — same model in, same cards out', (t) => { | |
| 162 | t.alike(buildFileLevel(model), buildFileLevel(model), 'file level') | |
| 163 | t.alike(buildSymbolLevel(model, tally), buildSymbolLevel(model, tally), 'symbol level') | |
| 164 | t.alike(ownLines(model, tally.fn), ownLines(model, tally.fn), 'own lines') | |
| 165 | }) |
| … | … | @@ -35,6 +35,54 @@ test('floor view: no duplicate top-level function declarations', (t) => { |
| 35 | 35 | t.alike(dupes, [], 'no name declared twice: ' + dupes.join(', ')) |
| 36 | 36 | }) |
| 37 | 37 | |
| 38 | test('floor view: every function it calls is declared or imported', (t) => { | |
| 39 | // A parse gate cannot catch a MISSING function — the file still parses fine. | |
| 40 | // This test exists because a scripted extraction removed `load` and | |
| 41 | // `currentSymbol` along with the functions it was told to cut, and the page | |
| 42 | // would have shipped broken with every other gate green. | |
| 43 | const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8') | |
| 44 | const ast = acornParse(src, { ecmaVersion: 'latest', sourceType: 'module' }) | |
| 45 | ||
| 46 | const declared = new Set() | |
| 47 | for (const n of ast.body) { | |
| 48 | if (n.type === 'FunctionDeclaration') declared.add(n.id.name) | |
| 49 | if (n.type === 'VariableDeclaration') { | |
| 50 | for (const d of n.declarations) if (d.id.type === 'Identifier') declared.add(d.id.name) | |
| 51 | } | |
| 52 | if (n.type === 'ImportDeclaration') { | |
| 53 | for (const sp of n.specifiers) declared.add(sp.local.name) | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | const called = new Set() | |
| 58 | const walk = (node) => { | |
| 59 | if (!node || typeof node.type !== 'string') return | |
| 60 | if (node.type === 'CallExpression' && node.callee.type === 'Identifier') { | |
| 61 | called.add(node.callee.name) | |
| 62 | } | |
| 63 | for (const k of Object.keys(node)) { | |
| 64 | const v = node[k] | |
| 65 | if (Array.isArray(v)) v.forEach(walk) | |
| 66 | else if (v && typeof v === 'object' && typeof v.type === 'string') walk(v) | |
| 67 | } | |
| 68 | } | |
| 69 | walk(ast) | |
| 70 | ||
| 71 | const builtins = new Set(['fetch', 'parseInt', 'parseFloat', 'String', 'Number', | |
| 72 | 'Set', 'Map', 'Array', 'Object', 'encodeURIComponent', 'decodeURIComponent', 'isNaN']) | |
| 73 | const missing = [...called].filter(n => !declared.has(n) && !builtins.has(n)) | |
| 74 | t.alike(missing, [], 'nothing calls a function that does not exist: ' + missing.join(', ')) | |
| 75 | }) | |
| 76 | ||
| 77 | test('floor view: the card builders live in a module node can import', (t) => { | |
| 78 | const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8') | |
| 79 | t.ok(src.includes("from '/floor/cards.js'"), 'the view imports them') | |
| 80 | t.absent(/function buildSymbolLevel/.test(src), 'and does not define its own copy') | |
| 81 | ||
| 82 | const server = readFileSync(resolve(LIB, 'server.js'), 'utf-8') | |
| 83 | t.ok(server.includes("'/floor/cards.js'"), 'the server routes that path') | |
| 84 | }) | |
| 85 | ||
| 38 | 86 | test('floor view: it imports the real layout module, not a copy', (t) => { |
| 39 | 87 | const src = readFileSync(resolve(LIB, 'floor/level.js'), 'utf-8') |
| 40 | 88 | t.ok(src.includes("from '/floor/layout.js'"), 'imports the served layout') |