OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 2b63902

The module and system levels: the ladder is complete
system -> module nodes, grouped by CATEGORY module -> file nodes, grouped by DIRECTORY file -> symbol nodes symbol -> operations, grouped by ZONE All four rungs now exist, all four produce { cards, links, containers }, and one layout and one renderer serve all of them. ## Containers at every level, which is the point The categories that overlap in the old view are CONTAINERS here, so the layout places them as whole rectangles and they cannot interleave — the bug reported at the start of this work is gone by construction rather than by box-fitting. Directories nest the same way: lib/handlers sits inside lib, exactly as a guard sits inside a foreach one rung down. ## Links read supplier-first at every level - system: a module's dependency feeds it, and ONLY between modules the scan has (b4a is a shared dep but not a module, so it is not a card and gets no edge pointing at nothing) - module: importGraph edges reversed, so what a file imports sits to its left - file: the call graph - symbol: dataflow ## Navigation Click a title to descend a rung, breadcrumb or Esc to climb one. Esc now walks UP a single level rather than jumping to the file level. Every rung is deep-linkable: /floor, ?module=NAME, ?path=FILE, ?path=FILE&symbol=NAME. The system level is now the default rather than guessing a file out of /api/files. ## Tests 7 tests, 43 asserts, against the fixture workspace — which earns its keep a second time here, since it has four modules, a real dependency edge, a shared external dep that must NOT become an edge, and files nested two directories deep. Verified by screenshot at both new rungs: two disjoint category containers with the widget-rooms -> widget-store edge drawn, and widget-store's five files with lib/handlers nested inside lib. Suite: 192 tests, 1124 asserts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2b639021e60d87032b9af2d65470830d2a1ed5de
by plankatron <fleeky@gmail.com> · 2026-09-20T23:33:48.000Z

Changed files (3) +352 -44

lib/floor/cards.js +114 -0
……@@ -22,6 +22,120 @@
2222 * openTo the symbol NAME this card descends into, or null
2323 */
2424
25/**
26 * The system level: one card per module, grouped into CONTAINERS by category.
27 *
28 * The categories were drawn as frames after layout in the old view, which is how
29 * they came to overlap. Here they are containers, so the layout places them as
30 * whole rectangles and they cannot interleave.
31 */
32export function buildSystemLevel (tree) {
33 const categories = tree.moduleCategories || {}
34 const used = new Set()
35
36 const cards = (tree.system.modules || []).map(name => {
37 const mod = tree.modules[name] || {}
38 const category = categories[name] || null
39 if (category) used.add(category)
40 const deps = Object.keys(mod.dependencies || {})
41 const api = mod.publicAPI || []
42 return {
43 id: 'module:' + name,
44 kind: 'module',
45 title: name,
46 badge: mod.version || '',
47 sig: api.length > 0 ? api.slice(0, 4).join(', ') + (api.length > 4 ? ', ...' : '') : '',
48 sub: Object.keys(mod.files || {}).length + ' files · ' + deps.length + ' deps · ' +
49 api.length + ' exports',
50 container: category ? 'cat:' + category : null,
51 code: null,
52 span: null,
53 openTo: name
54 }
55 })
56
57 const containers = Array.from(used).sort().map(c => ({
58 id: 'cat:' + c, parent: null, label: c, shape: 'category', depth: 1
59 }))
60
61 // Only edges between modules the scan actually has. An external dependency is
62 // not a card here, so an edge to it would point at nothing.
63 const present = new Set(cards.map(c => c.id))
64 const links = []
65 for (const [name, deps] of Object.entries(tree.system.dependencies || {})) {
66 for (const dep of deps) {
67 const from = 'module:' + dep
68 const to = 'module:' + name
69 if (present.has(from) && present.has(to) && from !== to) links.push({ from, to })
70 }
71 }
72 return { cards, links, containers }
73}
74
75/**
76 * The module level: one card per file, grouped into CONTAINERS by directory.
77 *
78 * Directories nest, so the containers nest — lib/handlers sits inside lib — which
79 * is the same machinery zones use one rung further down.
80 */
81export function buildModuleLevel (tree, moduleName) {
82 const mod = (tree.modules || {})[moduleName]
83 if (!mod) return { cards: [], links: [], containers: [] }
84
85 const dirs = new Set()
86 const cards = Object.entries(mod.files || {}).map(([path, file]) => {
87 const dir = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : null
88 if (dir) {
89 // Register every ancestor, so lib/handlers implies lib.
90 const parts = dir.split('/')
91 for (let i = 1; i <= parts.length; i++) dirs.add(parts.slice(0, i).join('/'))
92 }
93 const exports = file.exports || []
94 return {
95 id: 'file:' + moduleName + ':' + path,
96 kind: 'file',
97 title: path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path,
98 badge: file.role || 'lib',
99 sig: exports.length > 0
100 ? exports.map(e => e.name).slice(0, 4).join(', ') + (exports.length > 4 ? ', ...' : '')
101 : '',
102 sub: (file.imports || []).length + ' imports · ' + exports.length + ' exports · ' +
103 (file.functions || []).length + ' functions',
104 container: dir ? 'dir:' + dir : null,
105 code: null,
106 span: null,
107 openTo: mod.path && mod.path !== '.' ? mod.path + '/' + path : path
108 }
109 })
110
111 const containers = Array.from(dirs).sort().map(dir => {
112 const parent = dir.includes('/') ? dir.slice(0, dir.lastIndexOf('/')) : null
113 return {
114 id: 'dir:' + dir,
115 parent: parent ? 'dir:' + parent : null,
116 label: dir + '/',
117 shape: 'directory',
118 depth: dir.split('/').length
119 }
120 })
121
122 // `from` imports `to`, so the supplier is on the left, matching every other
123 // level's reading direction.
124 const present = new Set(cards.map(c => c.id))
125 const links = []
126 const seen = new Set()
127 for (const edge of (mod.importGraph && mod.importGraph.edges) || []) {
128 const from = 'file:' + moduleName + ':' + edge.to
129 const to = 'file:' + moduleName + ':' + edge.from
130 if (!present.has(from) || !present.has(to) || from === to) continue
131 const key = from + '->' + to
132 if (seen.has(key)) continue
133 seen.add(key)
134 links.push({ from, to })
135 }
136 return { cards, links, containers }
137}
138
25139/** The file level: one card per symbol (L1), including the module body (L2). */
26140export function buildFileLevel (model) {
27141 const cards = (model.symbols || []).map(sym => ({
lib/floor/level.js +133 -44
……@@ -14,7 +14,8 @@
1414
1515import { layout } from '/floor/layout.js'
1616import {
17 buildFileLevel, buildSymbolLevel, resolveCode, lineDepths, spanOnLine, marksOwnSpan
17 buildSystemLevel, buildModuleLevel, buildFileLevel, buildSymbolLevel,
18 resolveCode, lineDepths, spanOnLine, marksOwnSpan
1819} from '/floor/cards.js'
1920
2021const SVG_NS = 'http://www.w3.org/2000/svg'
……@@ -26,6 +27,8 @@ const KIND_COLOR = {
2627 class: '#6b4a86',
2728 const: '#8a6a2a',
2829 'module-body': '#1f4a6b',
30 module: '#1f4a6b',
31 file: '#2f5b86',
2932 op: '#2f5b86',
3033 call: '#2f6b6b',
3134 exit: '#8a3b36',
……@@ -42,6 +45,8 @@ const CODE_LINE_H = 10
4245const MAX_CODE_LINES = 120
4346
4447const view = {
48 tree: null, // the scan, for the system and module levels
49 module: null, // the module level, when there is no path
4550 path: null,
4651 model: null,
4752 source: null,
……@@ -61,6 +66,22 @@ const view = {
6166 sy: 0
6267}
6368
69// system -> module -> file -> symbol. Which rung we are on is decided by what
70// the URL names, so every level is deep-linkable (invariant I5).
71function levelKind () {
72 if (view.path && view.symbolId) return 'symbol'
73 if (view.path) return 'file'
74 if (view.module) return 'module'
75 return 'system'
76}
77
78async function loadTree () {
79 if (view.tree) return view.tree
80 const res = await fetch('/api/tree')
81 view.tree = await res.json()
82 return view.tree
83}
84
6485async function load (path) {
6586 const res = await fetch('/api/derive?path=' + encodeURIComponent(path))
6687 const data = await res.json()
……@@ -114,9 +135,18 @@ function heightOf (card) {
114135 return h
115136}
116137
138function builtLevel () {
139 const kind = levelKind()
140 if (kind === 'system') return buildSystemLevel(view.tree)
141 if (kind === 'module') return buildModuleLevel(view.tree, view.module)
142 const sym = currentSymbol()
143 return sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model)
144}
145
117146function rebuild () {
147 const kind = levelKind()
118148 const sym = currentSymbol()
119 const built = sym ? buildSymbolLevel(view.model, sym) : buildFileLevel(view.model)
149 const built = builtLevel()
120150 view.cards = built.cards
121151 view.links = built.links
122152 view.containers = built.containers
……@@ -132,15 +162,28 @@ function rebuild () {
132162 containers: built.containers
133163 })
134164
165 const NAMES = {
166 system: '<b>system level</b> - one node per module, grouped by category',
167 module: '<b>module level</b> - one node per file, grouped by directory',
168 file: '<b>file level</b> - one node per symbol',
169 symbol: '<b>symbol level</b> - operations inside their zones'
170 }
171 document.getElementById('levelname').innerHTML = NAMES[kind]
172
135173 const m = view.model
136 document.getElementById('levelname').innerHTML = sym
137 ? '<b>symbol level</b> - operations inside their zones'
138 : '<b>file level</b> - one node per symbol'
139 document.getElementById('stats').textContent = sym
140 ? built.cards.length + ' nodes · ' + built.containers.length + ' zones in ' + sym.name
141 : m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' + m.zones.length +
174 let stats = ''
175 if (kind === 'system') {
176 stats = built.cards.length + ' modules · ' + built.containers.length + ' categories'
177 } else if (kind === 'module') {
178 stats = built.cards.length + ' files · ' + built.containers.length + ' directories'
179 } else if (kind === 'symbol') {
180 stats = built.cards.length + ' nodes · ' + built.containers.length + ' zones in ' + sym.name
181 } else {
182 stats = m.symbols.length + ' symbols · ' + m.nodes.length + ' ops · ' + m.zones.length +
142183 ' zones · ' + m.exits.length + ' exits' + (m.language ? ' · ' + m.language : '')
143 banner(m.partial ? 'PARTIAL - ' + m.partialReason : null)
184 }
185 document.getElementById('stats').textContent = stats
186 banner(m && m.partial ? 'PARTIAL - ' + m.partialReason : null)
144187 breadcrumb()
145188 render()
146189}
……@@ -162,29 +205,32 @@ function banner (text) {
162205function breadcrumb () {
163206 const bc = document.getElementById('crumb')
164207 bc.textContent = ''
208 const kind = levelKind()
165209 const sym = currentSymbol()
166210
167 const file = document.createElement('a')
168 file.textContent = view.path
169 file.href = '#'
170 file.className = sym ? '' : 'current'
171 file.addEventListener('click', (e) => {
172 e.preventDefault()
173 enter(null)
211 const rungs = [{ label: 'system', go: () => goSystem() }]
212 if (view.module) rungs.push({ label: view.module, go: () => goModule(view.module) })
213 if (view.path) rungs.push({ label: view.path, go: () => goFile(view.path) })
214 if (sym) rungs.push({ label: sym.name, go: null })
215
216 rungs.forEach((rung, i) => {
217 if (i > 0) {
218 const sep = document.createElement('span')
219 sep.className = 'sep'
220 sep.textContent = ' > '
221 bc.appendChild(sep)
222 }
223 const a = document.createElement('a')
224 a.textContent = rung.label
225 a.href = '#'
226 const last = i === rungs.length - 1
227 if (last) a.className = 'current'
228 if (!last && rung.go) {
229 a.addEventListener('click', (e) => { e.preventDefault(); rung.go() })
230 }
231 bc.appendChild(a)
174232 })
175 bc.appendChild(file)
176
177 if (sym) {
178 const sep = document.createElement('span')
179 sep.className = 'sep'
180 sep.textContent = ' > '
181 bc.appendChild(sep)
182 const here = document.createElement('a')
183 here.textContent = sym.name
184 here.href = '#'
185 here.className = 'current'
186 bc.appendChild(here)
187 }
233 void kind
188234}
189235
190236function render () {
……@@ -310,7 +356,9 @@ function drawCard (world, card, pos) {
310356 title.classList.add('n-open')
311357 title.addEventListener('click', (e) => {
312358 e.stopPropagation()
313 enter(card.openTo)
359 if (card.kind === 'module') goModule(card.openTo)
360 else if (card.kind === 'file') goFile(card.openTo)
361 else enter(card.openTo)
314362 })
315363 }
316364
……@@ -362,6 +410,41 @@ function clip (text, n) {
362410
363411// --- navigation ------------------------------------------------------------
364412
413async function goSystem () {
414 await loadTree()
415 view.module = null
416 view.path = null
417 view.model = null
418 view.symbolId = null
419 resetView()
420 syncUrl()
421 rebuild()
422}
423
424async function goModule (name) {
425 await loadTree()
426 view.module = name
427 view.path = null
428 view.model = null
429 view.symbolId = null
430 resetView()
431 syncUrl()
432 rebuild()
433}
434
435async function goFile (path) {
436 view.symbolId = null
437 resetView()
438 await load(path)
439 syncUrl()
440}
441
442function resetView () {
443 view.expanded = new Set()
444 view.panX = 60
445 view.panY = 110
446}
447
365448function enter (symbolName) {
366449 const sym = symbolName ? view.model.symbols.find(s => s.name === symbolName) : null
367450 view.symbolId = sym ? sym.id : null
……@@ -396,9 +479,11 @@ function applyExpandParam () {
396479
397480function syncUrl () {
398481 const sym = currentSymbol()
399 let url = '/floor?path=' + encodeURIComponent(view.path)
400 if (sym) url += '&symbol=' + encodeURIComponent(sym.name)
401 history.replaceState(null, '', url)
482 const q = []
483 if (view.module) q.push('module=' + encodeURIComponent(view.module))
484 if (view.path) q.push('path=' + encodeURIComponent(view.path))
485 if (sym) q.push('symbol=' + encodeURIComponent(sym.name))
486 history.replaceState(null, '', '/floor' + (q.length > 0 ? '?' + q.join('&') : ''))
402487}
403488
404489function applyTransform () {
……@@ -448,7 +533,12 @@ function wire () {
448533 view.expanded.clear()
449534 rebuild()
450535 } else if (e.key === 'Escape') {
451 enter(null)
536 const kind = levelKind()
537 if (kind === 'symbol') enter(null)
538 else if (kind === 'file') {
539 if (view.module) goModule(view.module)
540 else goSystem()
541 } else if (kind === 'module') goSystem()
452542 }
453543 })
454544
……@@ -464,19 +554,18 @@ function wire () {
464554
465555async function start () {
466556 wire()
557 await loadTree()
467558 const params = new URLSearchParams(location.search)
468 let path = params.get('path')
469 if (!path) {
470 const res = await fetch('/api/files')
471 const data = await res.json()
472 const first = (data.files || []).find(f => f.path.endsWith('.js')) || (data.files || [])[0]
473 path = first ? first.path : null
474 }
475 if (!path) {
476 banner('no files in the scan to open')
559 view.module = params.get('module')
560 const path = params.get('path')
561
562 if (path) {
563 await load(path)
564 syncUrl()
477565 return
478566 }
479 await load(path)
567 // No path: the system level is the top of the ladder and the honest default.
568 rebuild()
480569}
481570
482571start()
test/floor-upper-levels.test.js +105 -0
……@@ -0,0 +1,105 @@
1import { test } from 'brittle'
2import { resolve, dirname } from 'path'
3import { fileURLToPath } from 'url'
4import { scan } from '../lib/scanner.js'
5import { buildSystemLevel, buildModuleLevel } from '../lib/floor/cards.js'
6
7const HERE = dirname(fileURLToPath(import.meta.url))
8const WORKSPACE = resolve(HERE, 'fixtures/workspace')
9
10// The top two rungs, against the fixture workspace: four modules, one shared
11// dep, one real dependency edge, and a module whose files nest two directories
12// deep. Categories and directories are CONTAINERS here — the same machinery
13// zones use at the symbol level — which is why they cannot overlap.
14
15let tree = null
16test('upper levels: scan the fixture workspace', async (t) => {
17 tree = await scan(WORKSPACE, { maxDepth: 1 })
18 t.is(tree.system.modules.length, 4, 'four modules')
19})
20
21test('system level: one card per module, categories as containers', (t) => {
22 const { cards, containers, links } = buildSystemLevel(tree)
23
24 t.is(cards.length, 4, 'four cards')
25 const byTitle = {}
26 for (const c of cards) byTitle[c.title] = c
27 t.ok(byTitle['widget-store'], 'widget-store is there')
28 t.is(byTitle['widget-store'].kind, 'module', 'kind is module')
29 t.is(byTitle['widget-store'].badge, '0.2.0', 'badged with its version')
30 t.is(byTitle['widget-store'].openTo, 'widget-store', 'and opens into itself')
31 t.ok(byTitle['widget-store'].sub.includes('files'), 'counts its files: ' + byTitle['widget-store'].sub)
32
33 // Every card belongs to the container its category names.
34 for (const c of cards) {
35 const category = tree.moduleCategories[c.title]
36 t.is(c.container, category ? 'cat:' + category : null, c.title + ' is in its category')
37 }
38 const ids = new Set(containers.map(c => c.id))
39 for (const c of cards) {
40 if (c.container) t.ok(ids.has(c.container), 'the container it names exists')
41 }
42 t.ok(links.length >= 0, 'links computed')
43})
44
45test('system level: only edges between modules the scan HAS', (t) => {
46 const { cards, links } = buildSystemLevel(tree)
47 const ids = new Set(cards.map(c => c.id))
48
49 const named = links.map(l => l.from.replace('module:', '') + '->' + l.to.replace('module:', ''))
50 t.ok(named.includes('widget-rooms->widget-store'), 'the real dependency edge is drawn')
51 // b4a is a shared dependency but is NOT a module in the scan, so it is not a
52 // card and must not be an edge endpoint pointing at nothing.
53 for (const l of links) {
54 t.ok(ids.has(l.from), 'edge source is a card')
55 t.ok(ids.has(l.to), 'edge target is a card')
56 }
57 t.absent(named.some(n => n.includes('b4a')), 'no edge to an external dependency')
58})
59
60test('module level: one card per file, directories as NESTED containers', (t) => {
61 const { cards, containers } = buildModuleLevel(tree, 'widget-store')
62
63 const titles = cards.map(c => c.title).sort()
64 t.alike(titles, ['index.js', 'schema.js', 'store.test.js', 'widget-store.js', 'widgets.js'],
65 'every file is a card')
66
67 const byId = {}
68 for (const c of containers) byId[c.id] = c
69 t.ok(byId['dir:lib'], 'lib/ is a container')
70 t.ok(byId['dir:lib/handlers'], 'lib/handlers/ is a container')
71 t.ok(byId['dir:test'], 'test/ is a container')
72 t.is(byId['dir:lib/handlers'].parent, 'dir:lib', 'and it nests inside lib')
73 t.is(byId['dir:lib'].parent, null, 'while lib is top level')
74
75 const handlers = cards.find(c => c.title === 'widgets.js')
76 t.is(handlers.container, 'dir:lib/handlers', 'a nested file names the deepest directory')
77 const entry = cards.find(c => c.title === 'index.js')
78 t.is(entry.container, null, 'a root file has no directory container')
79 t.is(entry.badge, 'entry', 'and keeps its role as its badge')
80})
81
82test('module level: links read supplier-first, like every other level', (t) => {
83 const { cards, links } = buildModuleLevel(tree, 'widget-store')
84 const byId = {}
85 for (const c of cards) byId[c.id] = c.title
86
87 const named = links.map(l => byId[l.from] + '->' + byId[l.to])
88 t.ok(named.includes('widget-store.js->index.js'),
89 'the barrel is fed by what it re-exports: ' + named.join(', '))
90 t.ok(named.includes('schema.js->widget-store.js'), 'and schema feeds the class')
91 t.is(new Set(named).size, named.length, 'deduped')
92})
93
94test('module level: opening a file gives a path the file level can load', (t) => {
95 const { cards } = buildModuleLevel(tree, 'widget-store')
96 for (const c of cards) {
97 t.ok(typeof c.openTo === 'string' && c.openTo.length > 0, c.title + ' opens to a path')
98 t.ok(c.openTo.endsWith(c.title), 'which ends in the file name: ' + c.openTo)
99 }
100})
101
102test('upper levels: a module that does not exist yields an empty level, not a throw', (t) => {
103 const out = buildModuleLevel(tree, 'nope')
104 t.alike(out, { cards: [], links: [], containers: [] }, 'empty')
105})