OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / 65fed87

One representation: the SPA and the floor stop answering the same questions twice
From the audit, three of the four divergences. The views stay separate elements; what they render FROM is now one module. ## What was duplicated, and what it cost **"What is at this path"** — getNodesAtPath walked tree.dirTree in render.js while buildModuleLevel walked tree.modules in the floor. Two implementations, two SOURCES, nothing keeping them in step. Now one: nodesAtPath(tree, path) in cards.js, with findDirTreeNode and countPackagesUnder beside it. render.js keeps the local names as one-line delegations, so every call site is untouched. **Categories** — the scanner resolves these ONCE into tree.moduleCategories (curated groupings, then the most-shared-dependency fallback, then "Other") specifically so the CLI and the views cannot drift. render.js reimplemented that derivation against tree.categories: a second answer, kept in step by nothing. It now calls categoryOf(). The groupBy=deps override stays — that is a real alternate grouping, not duplication. **Category colours** covered only the CURATED categories, so every derived one ("b4a users") fell through to a per-module colour and its grouping lost its cue. Measured from the rendered graph tab: 9 distinct category fills now, one per palette entry. **Member counts** — render.js carried TWO definitions: at module level it counted only class methods and getters, at file level it also counted free functions. A module of plain functions therefore reported zero members in the graph and a real number in the file view. One definition now, pinned by a test. ## The boot dependency this exposed The shared modules are what the page renders FROM, so waiting for them to appear on window was a race. First attempt rendered EMPTY tabs on a deep link and correct ones on a human click. Boot now awaits them once, and fails loudly with a message rather than rendering nothing. ## A gate for a mistake made twice in one sitting render.js returns one enormous template literal, so a single backtick anywhere inside it — including in a comment I was writing — terminates the literal and breaks the module. It is invisible until import. test/render.test.js now scans the page literal for backticks and names the offending lines. Verified all three tabs render from the shared module: nodes 15 frames / 280 nodes, graph 56 nodes, tree 57 modules. Remaining from the audit: the two location models and URL schemes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
65fed87ccab8d73966c064c6ad2e0ff40467435f
by plankatron <fleeky@gmail.com> · 2026-09-21T00:33:34.000Z

Changed files (5) +283 -111

lib/floor/cards.js +135 -0
……@@ -536,3 +536,138 @@ function innermostZoneOf (zones, range) {
536536 }
537537 return best ? best.id : null
538538}
539
540// --- the tree, walked once -------------------------------------------------
541//
542// These were implemented twice: here for the floor, and again inside render.js
543// for the SPA, from a DIFFERENT field of the same scan. getNodesAtPath walked
544// `tree.dirTree`, buildModuleLevel walked `tree.modules`, and nothing made them
545// agree about what exists at a path. One implementation, both views.
546
547export function findDirTreeNode (tree, path) {
548 if (!path) return tree.dirTree
549 const parts = path.split('/')
550 let node = tree.dirTree
551 for (const part of parts) {
552 if (!node || !node.children) return null
553 const next = node.children.find(c => c.name === part)
554 if (!next) return null
555 node = next
556 }
557 return node
558}
559
560export function countPackagesUnder (dirEntry) {
561 let count = 0
562 const visit = (n) => {
563 if (!n) return
564 if (n.module) count++
565 if (n.children) for (const c of n.children) visit(c)
566 }
567 visit(dirEntry)
568 return count
569}
570
571/** One level of the directory tree: [{ name, type: 'dir'|'module', mod, dirEntry, packageCount }] */
572export function nodesAtPath (tree, path) {
573 const node = findDirTreeNode(tree, path)
574 if (!node || !node.children) return []
575 const out = []
576 for (const child of node.children) {
577 if (child.type === 'file') continue
578 if (child.module && tree.modules[child.module]) {
579 out.push({
580 name: child.module,
581 type: 'module',
582 dirEntry: child,
583 mod: tree.modules[child.module],
584 packageCount: 1
585 })
586 } else if (child.children && child.children.length > 0) {
587 out.push({
588 name: child.name,
589 type: 'dir',
590 dirEntry: child,
591 mod: null,
592 packageCount: countPackagesUnder(child)
593 })
594 }
595 }
596 return out
597}
598
599/**
600 * The category a module belongs to.
601 *
602 * The scanner resolves this ONCE into tree.moduleCategories — curated groupings
603 * from spec/SYSTEM.md first, then the most-shared-dependency fallback — exactly
604 * so the CLI and the views cannot drift. render.js reimplemented that algorithm
605 * against tree.categories instead, which is a second answer to the same
606 * question that nothing keeps in step.
607 */
608export function categoryOf (tree, moduleName) {
609 const resolved = tree.moduleCategories || {}
610 if (resolved[moduleName]) return resolved[moduleName]
611
612 // A tree scanned before the scanner resolved categories: fall back to the
613 // curated map alone rather than reimplementing the derivation.
614 for (const [cat, mods] of Object.entries(tree.categories || {})) {
615 if (mods.includes(moduleName)) return cat
616 }
617 return 'Other'
618}
619
620/** Every category in use, in stable order. */
621export function categoriesOf (tree) {
622 const seen = new Set()
623 for (const name of (tree.system && tree.system.modules) || []) seen.add(categoryOf(tree, name))
624 return Array.from(seen).sort()
625}
626
627const CATEGORY_COLORS = [
628 '#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72',
629 '#ffa657', '#79c0ff', '#f778ba', '#56d364'
630]
631
632/**
633 * A stable colour for a category.
634 *
635 * render.js coloured only the CURATED categories, so every derived one ("b4a
636 * users") fell through to a per-module colour and the grouping lost its cue.
637 */
638export function categoryColor (tree, category) {
639 const all = categoriesOf(tree)
640 const idx = all.indexOf(category)
641 return CATEGORY_COLORS[(idx < 0 ? 0 : idx) % CATEGORY_COLORS.length]
642}
643
644/**
645 * The counts every view shows for a module, computed once.
646 *
647 * render.js carried TWO definitions of memberCount: at module level it counted
648 * only class methods and getters, at file level it also counted free functions.
649 * So a module of plain functions reported zero members in the graph and a real
650 * number in the file view. This is the fuller definition, used by both.
651 */
652export function moduleStats (mod) {
653 if (!mod) return { fileCount: 0, memberCount: 0, exportCount: 0 }
654 const files = mod.files || {}
655 let memberCount = 0
656 for (const file of Object.values(files)) memberCount += fileMembers(file)
657 return {
658 fileCount: Object.keys(files).length,
659 memberCount,
660 exportCount: (mod.publicAPI || []).length
661 }
662}
663
664/** Members of one file: its functions, plus every class method and getter. */
665export function fileMembers (file) {
666 if (!file) return 0
667 let count = (file.functions || []).length
668 for (const cls of file.classes || []) {
669 count += (cls.methods || []).length
670 count += (cls.getters || []).length
671 }
672 return count
673}
lib/render-nodes.js +3 -15
……@@ -442,21 +442,9 @@ window.nvToggleFrames = function (on) {
442442
443443function nvLayout (nodes, links) {
444444 NV.frames = []
445 // The <script type="module"> that supplies the shared layout is DEFERRED, so
446 // it has not necessarily run when this does — a human clicking the tab gets
447 // it, an immediate deep-link does not, which is the worst kind of bug. Pull it
448 // in on demand and redraw once it lands.
449 if (!window.__ctLayout) {
450 if (!NV.layoutPending) {
451 NV.layoutPending = true
452 import(API + '/floor/layout.js').then(function (m) {
453 window.__ctLayout = m.layout
454 NV.layoutPending = false
455 nvBuild()
456 }).catch(function () { NV.layoutPending = false })
457 }
458 return nvLayoutFallback(nodes, links)
459 }
445 // boot() awaits the shared modules before anything renders, so this is a
446 // guard against a genuinely broken load, not a race.
447 if (!window.__ctLayout) return nvLayoutFallback(nodes, links)
460448
461449 // A group is a CONTAINER in the layout, not a rectangle drawn around whatever
462450 // positions fell out afterwards. That is the whole difference: containers are
lib/render.js +68 -95
……@@ -584,14 +584,6 @@ ${NODE_VIEW_HTML}
584584 </div>
585585</div>
586586
587<script type="module">
588 // The Nodes tab uses the SAME compound layout as /floor and the node tests.
589 // Its group boxes used to be drawn AFTER layering, from each group's member
590 // bounding box, so two groups whose members interleaved across columns made
591 // crossing boxes — the overlap reported all along.
592 import { layout } from '${apiPrefix}/floor/layout.js'
593 window.__ctLayout = layout
594</script>
595587<script>
596588const tree = ${treeJSON};
597589const API = '${apiPrefix}';
……@@ -1218,56 +1210,22 @@ function getCurrentPath () {
12181210 return graphStack.filter(e => e.type === 'path').map(e => e.name).join('/')
12191211}
12201212
1213// These three walked tree.dirTree here while the floor walked tree.modules for
1214// the same question, with nothing keeping the two answers in step. They now
1215// delegate to the shared module; the local names stay so every call site is
1216// unchanged.
12211217function findDirTreeNode (path) {
1222 if (!path) return tree.dirTree
1223 const parts = path.split('/')
1224 let node = tree.dirTree
1225 for (const part of parts) {
1226 if (!node.children) return null
1227 const next = node.children.find(c => c.name === part)
1228 if (!next) return null
1229 node = next
1230 }
1231 return node
1218 return window.__ct ? window.__ct.findDirTreeNode(tree, path) : null
12321219}
12331220
12341221function countPackagesUnder (dirEntry) {
1235 let count = 0
1236 function visit (n) {
1237 if (n.module) count++
1238 if (n.children) for (const c of n.children) visit(c)
1239 }
1240 visit(dirEntry)
1241 return count
1222 return window.__ct ? window.__ct.countPackagesUnder(dirEntry) : 0
12421223}
12431224
12441225// Returns: array of { name, type: 'dir'|'module', mod?, dirEntry, packageCount? }
12451226// Each entry is one node to render at the current location.
12461227function getNodesAtPath (path) {
1247 const node = findDirTreeNode(path)
1248 if (!node || !node.children) return []
1249 const result = []
1250 for (const child of node.children) {
1251 if (child.type === 'file') continue
1252 if (child.module && tree.modules[child.module]) {
1253 result.push({
1254 name: child.module,
1255 type: 'module',
1256 dirEntry: child,
1257 mod: tree.modules[child.module]
1258 })
1259 } else if (child.children && child.children.length > 0) {
1260 // Pure directory with content
1261 result.push({
1262 name: child.name,
1263 type: 'dir',
1264 dirEntry: child,
1265 mod: null,
1266 packageCount: countPackagesUnder(child)
1267 })
1268 }
1269 }
1270 return result
1228 return window.__ct ? window.__ct.nodesAtPath(tree, path) : []
12711229}
12721230
12731231// Click handler for a directory node: descend into it.
……@@ -1287,11 +1245,20 @@ function initGraph () {
12871245 const cx = W / 2
12881246 const cy = H / 2
12891247
1290 // Compute category groups
1248 // Category groups come from the scanner, which resolves them ONCE into
1249 // tree.moduleCategories precisely so the CLI and the views cannot drift. This
1250 // used to reimplement that derivation against tree.categories — a second
1251 // answer to the same question, kept in step by nothing.
12911252 const categories = tree.categories || {}
12921253 const moduleToCategory = {}
1293 for (const [cat, mods] of Object.entries(categories)) {
1294 for (const m of mods) moduleToCategory[m] = cat
1254 if (window.__ct) {
1255 for (const name of tree.system.modules) {
1256 moduleToCategory[name] = window.__ct.categoryOf(tree, name)
1257 }
1258 } else {
1259 for (const [cat, mods] of Object.entries(categories)) {
1260 for (const m of mods) moduleToCategory[m] = cat
1261 }
12951262 }
12961263
12971264 if (gfx.groupBy === 'deps') {
……@@ -1307,32 +1274,19 @@ function initGraph () {
13071274 for (const name of tree.system.modules) {
13081275 if (!moduleToCategory[name]) moduleToCategory[name] = 'Other'
13091276 }
1310 } else {
1311 // Default: use SYSTEM.md categories (already in moduleToCategory).
1312 // Uncategorized modules fall back to shared-dependency clustering — the
1313 // strongest data-driven signal we have for relatedness without hardcoded
1314 // module names. If no shared dep covers a module, it lands in "Other".
1315 const shared = tree.system.sharedDeps || {}
1316 const depToMostShared = {}
1317 for (const [dep, users] of Object.entries(shared)) {
1318 for (const u of users) {
1319 const prev = depToMostShared[u]
1320 if (!prev || users.length > shared[prev].length) depToMostShared[u] = dep
1321 }
1322 }
1323 for (const name of tree.system.modules) {
1324 if (moduleToCategory[name]) continue
1325 const topDep = depToMostShared[name]
1326 moduleToCategory[name] = topDep ? (topDep + ' users') : 'Other'
1327 }
1277 }
1278 // No else branch any more: the shared resolution above already applies the
1279 // curated groupings, then the shared-dependency fallback, then "Other".
1280 for (const name of tree.system.modules) {
1281 if (!moduleToCategory[name]) moduleToCategory[name] = 'Other'
13281282 }
13291283
1284 // Colour EVERY category in use, not only the curated ones: a derived category
1285 // ("b4a users") used to fall through to a per-module colour, so the grouping
1286 // it belonged to lost its visual cue entirely.
13301287 const categoryColors = {}
1331 const catColorList = ['#58a6ff', '#3fb950', '#d2a8ff', '#f0883e', '#ff7b72', '#ffa657', '#79c0ff', '#f778ba']
1332 let catIdx = 0
1333 for (const cat of Object.keys(categories)) {
1334 categoryColors[cat] = catColorList[catIdx % catColorList.length]
1335 catIdx++
1288 for (const cat of new Set(Object.values(moduleToCategory))) {
1289 categoryColors[cat] = window.__ct ? window.__ct.categoryColor(tree, cat) : '#58a6ff'
13361290 }
13371291
13381292 // Build nodes for what's at the current path. This is the file-manager
……@@ -1368,13 +1322,12 @@ function initGraph () {
13681322 const name = item.name
13691323 const mod = item.mod
13701324
1371 const fileCount = mod ? Object.keys(mod.files).length : 0
1372 let memberCount = 0
1373 if (mod) {
1374 for (const f of Object.values(mod.files)) {
1375 if (f.classes) for (const c of f.classes) memberCount += c.methods.length + (c.getters ? c.getters.length : 0)
1376 }
1377 }
1325 // One definition of these counts, shared with every other view. This used
1326 // to count only class methods and getters, so a module of free functions
1327 // reported zero members here and a real number in the file view.
1328 const stats = window.__ct.moduleStats(mod)
1329 const fileCount = stats.fileCount
1330 const memberCount = stats.memberCount
13781331
13791332 const cat = isDir ? 'Directories' : (moduleToCategory[name] || 'Other')
13801333 const catI = catList.indexOf(cat)
……@@ -2424,10 +2377,8 @@ function drillIntoModule (moduleName, fromNav) {
24242377 for (let i = 0; i < files.length; i++) {
24252378 const [path, file] = files[i]
24262379 nameToIdx[path] = i
2427 let memberCount = 0
2428 if (file.classes) for (const c of file.classes) memberCount += c.methods.length + (c.getters ? c.getters.length : 0)
2429 if (file.functions) memberCount += file.functions.length
2430 let exportCount = file.exports ? file.exports.length : 0
2380 const memberCount = window.__ct.fileMembers(file)
2381 const exportCount = file.exports ? file.exports.length : 0
24312382 fileData.push({ path, file, memberCount, exportCount, role: file.role || 'lib' })
24322383 }
24332384
……@@ -3971,17 +3922,39 @@ function makeResizable (handleId, panelId, side) {
39713922${NODE_VIEW_JS}
39723923
39733924// ====== INIT ======
3974buildTree()
3975initResizable()
3925//
3926// The shared modules are a HARD dependency, not a hopeful global: they are the
3927// representation this page renders from — the dirTree walk, the categories, the
3928// layout. A deferred module script means waiting on window.__ct to
3929// appear is a race, and losing it renders an empty page. Await it, once, here.
3930;(async function boot () {
3931 if (!window.__ct) {
3932 try {
3933 const [cards, layoutMod] = await Promise.all([
3934 import(API + '/floor/cards.js'),
3935 import(API + '/floor/layout.js')
3936 ])
3937 window.__ct = cards
3938 window.__ctLayout = layoutMod.layout
3939 } catch (err) {
3940 document.getElementById('detail').innerHTML =
3941 '<div style="padding:20px;color:#ff7b72">Could not load the shared view modules: ' +
3942 escapeHtml(String(err && err.message ? err.message : err)) + '</div>'
3943 return
3944 }
3945 }
39763946
3977// The tab is part of WHERE YOU ARE, so it belongs in the URL like every other
3978// view here (invariant I5). ?tab=nodes opens the Nodes tab directly, which also
3979// makes the tab reviewable without a human clicking it.
3980;(function selectTabFromUrl () {
3947 buildTree()
3948 initResizable()
3949
3950 // The tab is part of WHERE YOU ARE, so it belongs in the URL like every other
3951 // view here (invariant I5). It is also the only way to review a tab without a
3952 // human clicking it.
39813953 const wanted = new URLSearchParams(location.search).get('tab')
3982 if (!wanted) return
3983 const tab = document.querySelector('.main-tab[data-tab="' + wanted + '"]')
3984 if (tab) tab.click()
3954 if (wanted) {
3955 const tab = document.querySelector('.main-tab[data-tab="' + wanted + '"]')
3956 if (tab) tab.click()
3957 }
39853958})()
39863959
39873960// ====== URL ROUTING — popstate + initial URL ======
test/floor-upper-levels.test.js +57 -1
……@@ -3,7 +3,8 @@ import { resolve, dirname } from 'path'
33import { fileURLToPath } from 'url'
44import { scan } from '../lib/scanner.js'
55import {
6 buildSystemLevel, buildModuleLevel, bundleLinks, dependencyClosure, depthColor
6 buildSystemLevel, buildModuleLevel, bundleLinks, dependencyClosure, depthColor,
7 nodesAtPath, categoryOf, categoriesOf, categoryColor, moduleStats
78} from '../lib/floor/cards.js'
89
910const HERE = dirname(fileURLToPath(import.meta.url))
……@@ -268,3 +269,58 @@ test('gradient: colour moves with depth and is stable at the ends', (t) => {
268269 t.ok(near.startsWith('hsl('), 'and they are hsl ramps: ' + near + ' -> ' + far)
269270 t.is(depthColor(9, 1), depthColor(9, 1), 'deterministic')
270271})
272
273test('shared: nodesAtPath walks the dirTree, one implementation for both views', (t) => {
274 const roots = nodesAtPath(tree, '')
275 t.ok(roots.length > 0, 'the workspace root has entries')
276 const names = roots.map(r => r.name).sort()
277 t.alike(names, ['legacy-sync', 'mapped-io', 'widget-rooms', 'widget-store'], 'the four modules')
278
279 for (const r of roots) {
280 t.is(r.type, 'module', r.name + ' is a module, not a bare directory')
281 t.ok(r.mod, 'and carries its module entry')
282 t.ok(r.dirEntry, 'and its dirTree entry')
283 }
284 t.alike(nodesAtPath(tree, 'does/not/exist'), [], 'a missing path is empty, not a throw')
285})
286
287test('shared: categoryOf uses the scanner`s single resolution', (t) => {
288 for (const name of tree.system.modules) {
289 t.is(categoryOf(tree, name), tree.moduleCategories[name],
290 name + ' agrees with the scanner')
291 }
292 t.is(categoryOf(tree, 'not-a-module'), 'Other', 'an unknown module lands in Other')
293})
294
295test('shared: every category in use gets a colour, derived ones included', (t) => {
296 const cats = categoriesOf(tree)
297 t.ok(cats.length > 0, 'categories: ' + cats.join(', '))
298
299 const derived = cats.filter(c => c.endsWith(' users'))
300 t.ok(derived.length > 0, 'this workspace has a DERIVED category: ' + derived.join(', '))
301
302 const colors = new Set()
303 for (const c of cats) {
304 const col = categoryColor(tree, c)
305 t.ok(/^#[0-9a-f]{6}$/i.test(col), c + ' has a colour: ' + col)
306 colors.add(col)
307 }
308 t.is(colors.size, cats.length, 'and distinct categories get distinct colours')
309 t.is(categoryColor(tree, cats[0]), categoryColor(tree, cats[0]), 'stable')
310})
311
312test('shared: moduleStats counts what both views show', (t) => {
313 const mod = tree.modules['widget-store']
314 const stats = moduleStats(mod)
315 t.is(stats.fileCount, Object.keys(mod.files).length, 'files')
316 t.is(stats.exportCount, mod.publicAPI.length, 'exports')
317 // Pin the definition: functions + class methods + getters, across every file.
318 let expected = 0
319 for (const f of Object.values(mod.files)) {
320 expected += (f.functions || []).length
321 for (const c of f.classes || []) expected += (c.methods || []).length + (c.getters || []).length
322 }
323 t.is(stats.memberCount, expected, 'members are functions + methods + getters: ' + expected)
324 t.ok(expected > 0, 'and this fixture has some')
325 t.alike(moduleStats(null), { fileCount: 0, memberCount: 0, exportCount: 0 }, 'no module is zeros')
326})
test/render.test.js +20 -0
……@@ -1,4 +1,5 @@
11import { test } from 'brittle'
2import { readFileSync } from 'fs'
23import { resolve, dirname } from 'path'
34import { fileURLToPath } from 'url'
45import { scan } from '../lib/scanner.js'
……@@ -7,6 +8,7 @@ import { parse as acornParse } from '../vendor/acorn/dist/acorn.mjs'
78
89const HERE = dirname(fileURLToPath(import.meta.url))
910const FIXTURE = resolve(HERE, 'fixtures/connectors')
11const BACKTICK = String.fromCharCode(96)
1012
1113// render.js emits a whole browser app as a template literal. Nothing type-checks
1214// it, lints it, or parses it at build time — a stray brace ships a white page.
……@@ -33,6 +35,24 @@ test('render: the emitted page parses as JavaScript', async (t) => {
3335 }
3436})
3537
38test('render: the page literal contains NO backtick', (t) => {
39 // renderPage returns one enormous template literal, so a single backtick
40 // anywhere inside it — including inside a comment — terminates the literal and
41 // breaks the module. That happened twice in one sitting while editing
42 // comments, and it is invisible until the file is imported.
43 const src = readFileSync(resolve(HERE, '../lib/render.js'), 'utf-8')
44 const start = src.indexOf('return ' + BACKTICK + '<!DOCTYPE html>')
45 t.ok(start > 0, 'found the page literal')
46 const body = src.slice(start + ('return ' + BACKTICK).length, src.lastIndexOf(BACKTICK))
47
48 const lines = body.split('\n')
49 const offenders = []
50 for (let i = 0; i < lines.length; i++) {
51 if (lines[i].includes(BACKTICK)) offenders.push((i + 1) + ': ' + lines[i].trim().slice(0, 60))
52 }
53 t.alike(offenders, [], 'no backticks inside the page literal: ' + offenders.join(' | '))
54})
55
3656test('render: no duplicate top-level function declarations', async (t) => {
3757 const tree = await scan(FIXTURE)
3858 const js = inlineScript(renderPage(tree, ''))