OverviewFilesCommitsIssuesDocsReleasesPeers

codetree commits / bf463d9

Phase 0e — validation + cruft pages in serve
New deep-linkable routes for doc-dedup reports: /validation Full validator report (existing panel, routed) /validation/module/:name Per-module drift (filtered to one module) /docs Doc cruft list — new view in center pane /docs/*path Open cruft details focused on one doc /suggestions/*path Placeholder (live --suggest over HTTP is deferred — shows CLI command to run) New main-tab "Docs" joins Tree / Graph / Validate. Switches the center pane to renderDocsCruft() which shows: - Header summary (docs / lines / fresh / stale / valid / missing / external) - Collapsed <details> per stale doc ordered by staleness score - Expanded view shows missing refs (up to 30), external refs summary - Each doc path is a real <a href> to /docs/<path> — SPA nav with middle/ctrl-click fallback for new-tab opens Routing architecture refactor in lib/render.js: graphStackFromUrl(path) → now delegates to routeFromUrl(path).stack routeFromUrl(path) → returns full route object { kind: 'system' | 'module-graph' | 'path-graph' | 'validation' | 'docs' | 'suggestions', stack: [...], extra: { moduleName?, docPath?, suggestPath? } } applyRouteFromUrl(route) → dispatches to the right view; activates main-tab; falls through to applyStackFromUrl for graph routes (back- compat for Phase 0d callers). popstate + initial-URL handler updated to route via applyRouteFromUrl. navigateToDocs(path) / navigateToValidation(moduleName) programmatic helpers exposed on window for links inside rendered panels. Smoke tests (curl on `node index.js serve ... 9278`): / → 200 (SPA shell) /validation → 200 /validation/module/hyper-orgs → 200 /docs → 200 /docs/spec/SYSTEM.md → 200 /suggestions/spec/BUGS.md → 200 (placeholder) /nonexistent → 404 Served HTML grew from 604KB to 614KB (+10KB); contains 16 references to the new routing + renderDocsCruft functions. 96/96 tests, 478/478 asserts pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
bf463d94ee567c5be174253e19f60f11f32c476b
by plankatron <fleeky@gmail.com> · 2026-04-16T17:45:46.000Z

Changed files (1) +227 -25

lib/render.js +227 -25
……@@ -508,6 +508,7 @@ h2 { font-size: 14px; color: #8b949e; margin-bottom: 6px; font-weight: normal; }
508508 <button class="tab main-tab active" data-tab="tree">Tree</button>
509509 <button class="tab main-tab" data-tab="graph">Graph</button>
510510 <button class="tab main-tab" data-tab="validate">Validate</button>
511 <button class="tab main-tab" data-tab="docs">Docs</button>
511512 </div>
512513 <input class="search" id="search" type="text" placeholder="Filter modules, files, symbols...">
513514 <div style="display:flex;gap:4px;margin-bottom:8px">
……@@ -606,6 +607,13 @@ document.querySelectorAll('.main-tab').forEach(tab => {
606607 // Don't change center content — revert to previous tab's center
607608 detail.style.display = ''
608609 graphContainer.classList.remove('active')
610 } else if (currentTab === 'docs') {
611 // Docs tab = show doc cruft report in the center pane
612 rightPanel.style.display = 'none'
613 resizeRight.style.display = 'none'
614 detail.style.display = ''
615 graphContainer.classList.remove('active')
616 renderDocsCruft()
609617 } else if (currentTab === 'graph') {
610618 if (!graphInitialized) { initGraph(); updateBreadcrumb() }
611619 }
……@@ -1927,6 +1935,14 @@ function urlFromGraphStack (stack) {
19271935}
19281936
19291937function graphStackFromUrl (pathname) {
1938 return routeFromUrl(pathname).stack
1939}
1940
1941// Parse URL into a full route object.
1942// kind: 'system' | 'module-graph' | 'path-graph' | 'validation' | 'docs' | 'suggestions'
1943// stack: graph stack (only meaningful for *-graph kinds)
1944// extra: { moduleName (validation), docPath (docs), suggestPath (suggestions) }
1945function routeFromUrl (pathname) {
19301946 const base = _basePrefix()
19311947 let p = pathname || '/'
19321948 if (base && p.startsWith(base)) p = p.slice(base.length) || '/'
……@@ -1934,10 +1950,25 @@ function graphStackFromUrl (pathname) {
19341950 // Normalize: drop trailing slash (except root)
19351951 if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1)
19361952
1937 if (p === '/' || p === '/system' || p === '') return []
1953 if (p === '/' || p === '/system' || p === '') return { kind: 'system', stack: [], extra: {} }
19381954
19391955 const segs = p.split('/').filter(Boolean)
19401956
1957 if (segs[0] === 'validation') {
1958 const moduleName = (segs[1] === 'module' && segs[2]) ? decodeURIComponent(segs[2]) : null
1959 return { kind: 'validation', stack: [], extra: { moduleName } }
1960 }
1961
1962 if (segs[0] === 'docs') {
1963 const docPath = segs.length > 1 ? segs.slice(1).map(decodeURIComponent).join('/') : null
1964 return { kind: 'docs', stack: [], extra: { docPath } }
1965 }
1966
1967 if (segs[0] === 'suggestions' && segs.length > 1) {
1968 const suggestPath = segs.slice(1).map(decodeURIComponent).join('/')
1969 return { kind: 'suggestions', stack: [], extra: { suggestPath } }
1970 }
1971
19411972 if (segs[0] === 'module' && segs[1]) {
19421973 const moduleName = decodeURIComponent(segs[1])
19431974 const stack = [{ type: 'module', name: moduleName, label: moduleName }]
……@@ -1950,19 +1981,18 @@ function graphStackFromUrl (pathname) {
19501981 label: filePath.split('/').pop()
19511982 })
19521983 }
1953 return stack
1984 return { kind: 'module-graph', stack, extra: {} }
19541985 }
19551986
19561987 if (segs[0] === 'path' && segs.length > 1) {
1957 return segs.slice(1).map(s => {
1988 const stack = segs.slice(1).map(s => {
19581989 const name = decodeURIComponent(s)
19591990 return { type: 'path', name, label: name }
19601991 })
1992 return { kind: 'path-graph', stack, extra: {} }
19611993 }
19621994
1963 // Unknown paths (e.g. /validation, /docs — Phase 0e) → leave stack empty,
1964 // let the respective panels handle their own routing.
1965 return []
1995 return { kind: 'system', stack: [], extra: {} }
19661996}
19671997
19681998// Set to true inside popstate handling so nav functions skip pushing again.
……@@ -2016,6 +2046,91 @@ function applyStackFromUrl (stack) {
20162046 }
20172047}
20182048
2049// Apply a full route object (the post-0e extension of applyStackFromUrl).
2050// Activates the right main tab and renders whatever view the URL describes.
2051function applyRouteFromUrl (route) {
2052 _suppressPushState = true
2053 try {
2054 switch (route.kind) {
2055 case 'validation': {
2056 activateMainTab('validate')
2057 if (!valInitialized) initValidation()
2058 switchRightPanel('validation')
2059 if (route.extra.moduleName) {
2060 renderValidation(route.extra.moduleName)
2061 } else {
2062 renderValidation(null)
2063 }
2064 break
2065 }
2066 case 'docs': {
2067 activateMainTab('docs')
2068 renderDocsCruft(route.extra.docPath)
2069 break
2070 }
2071 case 'suggestions': {
2072 // Phase 0e leaves suggestions as a SSR-style placeholder — codetree
2073 // serve can't currently run --suggest per file on demand. Show a
2074 // friendly placeholder that points users at the CLI.
2075 activateMainTab('docs')
2076 const detail = document.getElementById('detail')
2077 if (detail) {
2078 detail.innerHTML = '<div style="padding:20px;color:#8b949e;max-width:800px;margin:0 auto">' +
2079 '<h1 style="color:#58a6ff;font-size:1.3rem">Suggested fixes for ' +
2080 escapeHtml(route.extra.suggestPath || '') + '</h1>' +
2081 '<p>Run <code style="background:#161b22;padding:2px 6px;border-radius:3px">' +
2082 'codetree docs . --suggest --target ' + escapeHtml(route.extra.suggestPath || '') + '</code> ' +
2083 'to generate a patch preview on the command line. Live per-file suggestions via /suggestions/* ' +
2084 'will land in a later phase.</p>' +
2085 '</div>'
2086 }
2087 break
2088 }
2089 case 'module-graph':
2090 case 'path-graph':
2091 case 'system':
2092 default:
2093 applyStackFromUrl(route.stack)
2094 break
2095 }
2096 } finally {
2097 _suppressPushState = false
2098 }
2099}
2100
2101function activateMainTab (name) {
2102 const tabs = document.querySelectorAll('.main-tab')
2103 for (const t of tabs) {
2104 if (t.dataset.tab === name && !t.classList.contains('active')) {
2105 t.click()
2106 return
2107 }
2108 }
2109}
2110
2111// Programmatic nav helpers — used by links inside rendered panels.
2112window.navigateToValidation = function (moduleName) {
2113 const base = _basePrefix()
2114 const url = moduleName
2115 ? base + '/validation/module/' + encodeURIComponent(moduleName)
2116 : base + '/validation'
2117 try {
2118 window.history.pushState({}, '', url)
2119 } catch {}
2120 applyRouteFromUrl(routeFromUrl(url))
2121}
2122
2123window.navigateToDocs = function (docPath) {
2124 const base = _basePrefix()
2125 const url = docPath
2126 ? base + '/docs/' + docPath.split('/').map(encodeURIComponent).join('/')
2127 : base + '/docs'
2128 try {
2129 window.history.pushState({}, '', url)
2130 } catch {}
2131 applyRouteFromUrl(routeFromUrl(url))
2132}
2133
20192134function updateBreadcrumb () {
20202135 const bc = document.getElementById('graph-breadcrumb')
20212136 // Render breadcrumb segments as real <a href> so middle-click /
……@@ -3529,6 +3644,95 @@ function valBadgeHTML (name) {
35293644 return '<span class="val-badge val-badge-pass">' + v.pass + ' ok</span>'
35303645}
35313646
3647// ====== DOCS CRUFT VIEW (Phase 0e) ======
3648// Renders the docCruft report in the main content area when the Docs
3649// main-tab is active. Each doc is deep-linkable at /docs/<path>.
3650function renderDocsCruft (focusPath) {
3651 const detail = document.getElementById('detail')
3652 if (!detail) return
3653 const cruft = tree.docCruft
3654 if (!cruft) {
3655 detail.innerHTML = '<div style="padding:20px;color:#8b949e">No doc cruft report available. Run codetree on a project with markdown files to see this view.</div>'
3656 return
3657 }
3658
3659 let html = '<div style="padding:16px;max-width:1000px;margin:0 auto">'
3660 html += '<h1 style="color:#58a6ff;font-size:1.4rem;margin-bottom:0.5rem">Documentation cruft</h1>'
3661 html += '<div style="color:#8b949e;font-size:0.85rem;margin-bottom:1.5rem">'
3662 html += cruft.summary.totalDocs + ' docs, ' + cruft.summary.totalLines + ' lines &middot; '
3663 html += '<span style="color:#3fb950">' + cruft.summary.freshDocs + ' fresh</span> &middot; '
3664 html += '<span style="color:#d29922">' + cruft.summary.staleDocs + ' stale</span> &middot; '
3665 html += cruft.summary.validReferences + ' valid, '
3666 html += cruft.summary.missingReferences + ' missing refs'
3667 if (cruft.summary.externalReferences) {
3668 html += ', ' + cruft.summary.externalReferences + ' external'
3669 }
3670 html += '</div>'
3671
3672 for (const doc of cruft.docs) {
3673 if (doc.references.total === 0 && doc.staleness.score < 0.3) continue
3674
3675 const score = doc.staleness.score
3676 const icon = score > 0.5 ? 'STALE' : score > 0.3 ? 'WARN' : 'FRESH'
3677 const color = score > 0.5 ? '#f85149' : score > 0.3 ? '#d29922' : '#3fb950'
3678
3679 const docHref = (API || '') + '/docs/' + doc.path.split('/').map(encodeURIComponent).join('/')
3680 const open = (focusPath && doc.path === focusPath) ? ' open' : ''
3681
3682 html += '<details' + open + ' style="border:1px solid #21262d;border-radius:6px;padding:8px 12px;margin-bottom:6px;background:#0d1117">'
3683 html += '<summary style="cursor:pointer;display:flex;align-items:center;gap:0.75rem">'
3684 html += '<span style="font-family:monospace;font-size:0.7rem;color:' + color + ';min-width:44px">' + icon + '</span>'
3685 html += '<a href="' + docHref + '" style="color:#58a6ff;text-decoration:none;font-family:monospace" onclick="event.stopPropagation()">' + escapeHtml(doc.path) + '</a>'
3686 html += '<span style="color:#6e7681;font-size:0.75rem;margin-left:auto">'
3687 html += doc.lines + ' lines &middot; ' + doc.lastModified + ' &middot; score ' + score
3688 html += '</span>'
3689 html += '</summary>'
3690
3691 // Per-ref details, grouped by status
3692 const missing = doc.references.details.filter(d => d.status === 'missing')
3693 const external = doc.references.details.filter(d => d.status === 'external')
3694 const valid = doc.references.details.filter(d => d.status === 'valid')
3695
3696 html += '<div style="margin-top:10px;font-size:0.8rem">'
3697 html += '<div style="color:#8b949e;margin-bottom:6px">'
3698 html += valid.length + ' valid &middot; ' + missing.length + ' missing'
3699 if (external.length) html += ' &middot; ' + external.length + ' external'
3700 html += '</div>'
3701
3702 if (missing.length > 0) {
3703 html += '<div style="margin-bottom:8px"><span style="color:#f85149;font-weight:bold">Missing (' + missing.length + '):</span><ul style="margin-top:4px;padding-left:20px;color:#c9d1d9">'
3704 for (const m of missing.slice(0, 30)) {
3705 html += '<li><code>' + escapeHtml(m.ref) + '</code> <span style="color:#6e7681">(' + m.type + ', line ' + m.line + ')</span></li>'
3706 }
3707 if (missing.length > 30) html += '<li style="color:#6e7681">… and ' + (missing.length - 30) + ' more</li>'
3708 html += '</ul></div>'
3709 }
3710
3711 if (external.length > 0) {
3712 html += '<div style="margin-bottom:8px"><span style="color:#6e7681;font-weight:bold">External (' + external.length + '):</span> <span style="color:#6e7681">' +
3713 external.slice(0, 10).map(e => '<code>' + escapeHtml(e.ref) + '</code>').join(', ') +
3714 (external.length > 10 ? ', …' : '') + '</span></div>'
3715 }
3716
3717 html += '</div></details>'
3718 }
3719 html += '</div>'
3720
3721 detail.innerHTML = html
3722
3723 // Intercept clicks on doc links for SPA nav
3724 for (const a of detail.querySelectorAll('a[href^="' + (API || '') + '/docs/"]')) {
3725 a.addEventListener('click', (e) => {
3726 if (e.button !== 0) return
3727 if (e.metaKey || e.ctrlKey || e.shiftKey) return
3728 e.preventDefault()
3729 const href = a.getAttribute('href')
3730 const path = href.replace((API || '') + '/docs/', '').split('/').map(decodeURIComponent).join('/')
3731 window.navigateToDocs(path)
3732 })
3733 }
3734}
3735
35323736// ====== TREE DEP HIGHLIGHTING ======
35333737function highlightTreeDeps (moduleName) {
35343738 // Clear previous highlights
……@@ -3680,18 +3884,16 @@ buildTree()
36803884initResizable()
36813885
36823886// ====== URL ROUTING — popstate + initial URL ======
3683// On cold page load, parse location.pathname to derive the starting
3684// graphStack. If it's non-empty (deep link), switch to the graph tab
3685// and apply the stack. Otherwise stay on the default tree view.
3686//
3687// popstate (back/forward button) re-derives the stack from the URL
3688// and applies it without pushing new history.
3887// On cold page load, parse location.pathname and route to the right view.
3888// popstate (back/forward button) re-derives the route from the URL and
3889// applies it without pushing new history.
36893890(function initUrlRouting () {
3690 window.addEventListener('popstate', (ev) => {
3691 const stack = graphStackFromUrl(window.location.pathname)
3692 applyStackFromUrl(stack)
3693 // Make sure we're in the graph tab if we're displaying graph content
3694 if (stack.length > 0) ensureGraphTabActive()
3891 window.addEventListener('popstate', () => {
3892 const route = routeFromUrl(window.location.pathname)
3893 if (route.kind === 'module-graph' || route.kind === 'path-graph') {
3894 ensureGraphTabActive()
3895 }
3896 applyRouteFromUrl(route)
36953897 })
36963898
36973899 function ensureGraphTabActive () {
……@@ -3704,19 +3906,19 @@ initResizable()
37043906 }
37053907 }
37063908
3707 const initialStack = graphStackFromUrl(window.location.pathname)
3708 if (initialStack.length > 0) {
3709 // Deep link — switch to graph tab and render the stack. Also replace
3710 // state (not push) so we start with a correct history entry.
3909 const initialRoute = routeFromUrl(window.location.pathname)
3910 if (initialRoute.kind === 'module-graph' || initialRoute.kind === 'path-graph') {
37113911 ensureGraphTabActive()
3712 // Let the graph tab initialize first, then apply the stack.
3713 // Using a microtask so initGraph() has run.
37143912 Promise.resolve().then(() => {
3715 applyStackFromUrl(initialStack)
3913 applyRouteFromUrl(initialRoute)
37163914 replaceStateForStack()
37173915 })
3916 } else if (initialRoute.kind !== 'system') {
3917 // /validation, /docs, /suggestions — apply directly, the handlers switch tabs
3918 Promise.resolve().then(() => {
3919 applyRouteFromUrl(initialRoute)
3920 })
37183921 } else {
3719 // System overview — seed history with a canonical URL.
37203922 replaceStateForStack()
37213923 }
37223924})()