From 9587294981937292e4c88e0a487a28fc07e078d6 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 15:50:13 +0530 Subject: [PATCH 01/15] fix(report): state the beans the proxy scan could not inspect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overview KPI printed "0 CGLIB / 0 JDK" as fact while the JSON already recorded notInstantiatedSkipped — on the petclinic run, 3 beans that were never instantiated and therefore never inspected. Renders only when the count is > 0. --- .../src/main/resources/wiredoctor/report-template.html | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index 956ca71..7cbb49e 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -132,6 +132,7 @@ .kpi.danger .k-value { color: var(--danger); } .kpi.warn .k-value { color: var(--warn); } .kpi.ghost .k-value { color: var(--ghost); } +.kpi .k-note { font-size: 10.5px; color: var(--text-dim); margin-top: 5px; line-height: 1.35; } .two-col { grid-template-columns: 1fr 1fr; align-items: start; } @media (max-width: 1000px) { .two-col { grid-template-columns: 1fr; } } @@ -323,6 +324,8 @@ const cycles = deps.cycles || []; const cycleBeans = new Set(cycles.flat()); const proxyBeans = new Set([...(reportData.proxies.cglibBeans||[]), ...(reportData.proxies.jdkBeans||[])]); +// Counts above are what could be inspected; this is what could not. Never round it away. +const proxySkipped = reportData.proxies.notInstantiatedSkipped || 0; const orphans = new Set(deps.orphanBeans || []); const ghosts = new Set((reportData.ghostCandidates && reportData.ghostCandidates.beans) || []); const fanIn = (reportData.smells && reportData.smells.fanIn) || {}; @@ -422,7 +425,8 @@

Overview

Total beans
${fmt(deps.totalBeans)}
Wiring edges
${fmt(deps.totalEdges)}
Dependency cycles
${cycles.length}
-
Proxies (CGLIB/JDK)
${reportData.proxies.cglibCount} / ${reportData.proxies.jdkCount}
+
Proxies (CGLIB/JDK)
${reportData.proxies.cglibCount} / ${reportData.proxies.jdkCount}
${ + proxySkipped ? `
${fmt(proxySkipped)} bean${proxySkipped>1?'s':''} not instantiated at report time — not inspected.
` : ''}
Ghost candidates
${gc.count}
Critical path
${cp.available ? fmt(cp.totalMs)+'ms' : '—'}
From f8f437b64c102ea00c6a8bdb1dcb10ac427f5240 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 15:57:36 +0530 Subject: [PATCH 02/15] =?UTF-8?q?feat(report):=20Cycles=20tab=20=E2=80=94?= =?UTF-8?q?=20the=20cycle=20members=20and=20the=20@Lazy=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lazySuggestions had zero references in the template: the only actionable advice WireDoctor produces was console- and JSON-only since 0.3.0. The HTML showed cycles as a number and a graph filter, never the bean names. New sidebar tab (third, with a red count badge), one card per cycle: - the chain, first bean repeated so it reads as a loop - every member clickable through to that node in the graph - the @Lazy cut with the lowest downstreamImpact, alternatives dimmed below - no suggestion -> says only that, never that the cycle is unbreakable The overview KPI now links to the tab. --- .../resources/wiredoctor/report-template.html | 81 ++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index 7cbb49e..f9078da 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -280,6 +280,22 @@ } .cp-node b { color: var(--warn); font-weight: 600; } .cp-arrow { color: var(--text-dim); font-size: 12px; } +/* v1.1.3 — cycle chain: same chip as the critical path, but clickable and red */ +.cy-node { + font-family: var(--mono); font-size: 11.5px; padding: 4px 9px; border-radius: 5px; + background: var(--danger-dim); color: var(--danger); + border: 1px solid rgba(229,83,75,0.35); cursor: pointer; +} +.cy-node:hover { background: var(--danger); color: #fff; } +.cy-node:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.cy-fix { + margin-top: 16px; padding: 12px 14px; border-radius: 6px; + background: var(--surface-2); border-left: 3px solid var(--ok); +} +.cy-fix-h { font-size: 10.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ok); margin-bottom: 8px; } +.cy-fix.blocked { border-left-color: var(--warn); } +.cy-fix.blocked .cy-fix-h { color: var(--warn); } +.cy-lazy { color: var(--accent); } ::-webkit-scrollbar { width: 10px; height: 10px; } ::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; border: 2px solid var(--bg); } @@ -373,6 +389,7 @@ const TABS = [ { id:'overview', icon:'◈', label:'Overview' }, { id:'graph', icon:'⬡', label:'Graph', full:true }, + { id:'cycles', icon:'⟲', label:'Cycles', count: cycles.length, hot: true }, { id:'ghosts', icon:'◍', label:'Ghosts', count: gc.count + (ghostReport ? (ghostReport.untouchedCount||0) : 0) }, { id:'smells', icon:'△', label:'Smells' }, { id:'timing', icon:'◷', label:'Timing' }, @@ -383,7 +400,7 @@ const b = document.createElement('button'); b.className = 'nav-item' + (i===0?' active':''); b.innerHTML = `${t.icon}${t.label}` + - (t.count ? `${t.count}` : ''); + (t.count ? `${t.count}` : ''); b.onclick = () => activate(t.id); b.dataset.tab = t.id; nav.appendChild(b); @@ -424,7 +441,7 @@

Overview

Total beans
${fmt(deps.totalBeans)}
Wiring edges
${fmt(deps.totalEdges)}
-
Dependency cycles
${cycles.length}
+
Dependency cycles
${cycles.length}
Proxies (CGLIB/JDK)
${reportData.proxies.cglibCount} / ${reportData.proxies.jdkCount}
${ proxySkipped ? `
${fmt(proxySkipped)} bean${proxySkipped>1?'s':''} not instantiated at report time — not inspected.
` : ''}
Ghost candidates
${gc.count}
@@ -468,6 +485,57 @@

Overview

`; +/* ───────── Cycles tab (v1.1.3) ───────── + The cycle members and the @Lazy suggestions were in wiredoctor-report.json and + the console from 0.3.0 on, and nowhere in the HTML. This is that data. */ +const lazyFixes = reportData.lazySuggestions || []; +function cycleCards() { + if (!cycles.length) { + return '
No dependency cycles — Spring resolved every bean without a circular reference.
'; + } + return cycles.map((members, i) => { + // Repeat the first bean: a list of names does not read as a cycle, a loop does. + const chain = members.concat(members[0]); + // Lowest downstream impact first — that is the recommendation, fewer beans + // move to first-request initialisation. + const fixes = lazyFixes + .filter(f => (f.breaksCycles || []).includes(i)) + .sort((a, b) => (a.downstreamImpact || 0) - (b.downstreamImpact || 0)); + const primary = fixes[0]; + const alts = fixes.slice(1); + return `
+
⟲ Cycle ${i + 1} · ${members.length} bean${members.length > 1 ? 's' : ''}
+
${chain.map(b => + `${escBean(b)}` + ).join('')}
+ ${primary ? `
+
Break it
+
Make ${escBean(primary.beanName)} @Lazy
+
breaks ${primary.breaksCycles.length} cycle${primary.breaksCycles.length > 1 ? 's' : ''} · ${primary.downstreamImpact} downstream bean${primary.downstreamImpact === 1 ? ' initialises' : 's initialise'} lazily
+ ${alts.length ? `
Also breaks it: ${alts.map(f => + `${escBean(f.beanName)} (${f.downstreamImpact} downstream)`).join(' · ')}
` : ''} +
` : `
+
No @Lazy cut suggested
+
No suggestion for this cycle — either the simulator found no safe cut, or this report predates it. Breaking it by design: extract an interface, or invert one of the dependencies.
+
`} +
`; + }).join(''); +} +$('#tab-cycles').innerHTML = ` +

Dependency Cycles

+

Circular references Spring resolved at startup, and the smallest @Lazy cut that breaks each one. @Lazy defers construction — it does not remove the coupling, the cycle stays in the design.

+ ${cycleCards()}`; +/* Delegated so bean names never have to survive an inline onclick. */ +$('#tab-cycles').addEventListener('click', e => { + const el = e.target.closest('[data-bean]'); + if (el) focusBean(el.dataset.bean); +}); +$('#tab-cycles').addEventListener('keydown', e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const el = e.target.closest('[data-bean]'); + if (el) { e.preventDefault(); focusBean(el.dataset.bean); } +}); + /* ═══════════ v1.1.0 charts — hand-rolled SVG, no chart library ═══════════ Palettes validated for the dark surface #12161f: the timing ramp is single-hue ordinal (monotone lightness, adjacent ΔL ≥ 0.06, dark end ≥ 2:1 on surface); @@ -997,6 +1065,15 @@

Autoconfiguration Conditions

if (hit) { network.focus(hit.id, { scale: 1.2, animation: true }); network.selectNodes([hit.id]); showNode(hit.id); } }); } +/* Jump from anywhere to this bean in the graph. Degrades silently: no vis + library, or the node was dropped by graph truncation. */ +function focusBean(bean) { + activate('graph'); + if (!network || !allNodes || !allNodes.get(bean)) return; + network.focus(bean, { scale: 1.3, animation: true }); + network.selectNodes([bean]); + showNode(bean); +} function showNode(bean) { const panel = $('#node-panel'); const depsOut = (deps.graph && deps.graph[bean]) || []; From 4ac1a2aa69336c87de1e3259260eb45586a106d5 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:00:11 +0530 Subject: [PATCH 03/15] feat(report): name the bean behind each slow startup step startupSlowestSteps carries tags {beanName, beanType, threadName}; the table used none of them, so the twelve slowest rows read "spring.beans.instantiate" twelve times while the tag beside each one named the actual bean. - Bean column, present only when at least one row is tagged - beanType and threadName behind a native
(long, and thread only matters when it is not main) - off-main thread also shown as a pill on the row itself - untagged steps (spring.context.refresh, ...) render exactly as before Adds the shared .drill style used by the remaining drill-downs. --- .../resources/wiredoctor/report-template.html | 39 ++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index f9078da..091f3db 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -186,6 +186,22 @@ table.data tbody tr td:first-child { box-shadow: inset 2px 0 0 transparent; } table.data tbody tr:hover td:first-child { box-shadow: inset 2px 0 0 var(--accent); } +/* v1.1.3 — drill-down. Collapsed by default: the summary keeps the number the + cell showed before, the detail is one click away for whoever wants it. */ +.drill > summary { + cursor: pointer; list-style: none; display: inline-flex; align-items: center; gap: 5px; +} +.drill > summary::-webkit-details-marker { display: none; } +.drill > summary::after { content: '▸'; font-size: 9px; color: var(--text-dim); } +.drill[open] > summary::after { content: '▾'; } +.drill > summary:hover::after { color: var(--accent); } +.drill > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; } +.drill-body { + margin-top: 7px; padding-left: 10px; border-left: 2px solid var(--border-hi); + font-family: var(--mono); font-size: 11.5px; color: var(--text-2); line-height: 1.7; + word-break: break-all; +} + /* ═══════════ Charts (dark-validated: ordinal ramp + categorical slots) ═══════════ */ .chart { display: block; width: 100%; height: auto; } .chart .grid-line { stroke: var(--border); stroke-width: 1; } @@ -746,12 +762,33 @@

Architecture Smells

${fmt(r[msKey])}ms
`).join('')}`; } +/* v1.1.3 — startupSlowestSteps carries tags {beanName, beanType, threadName} and + the table showed none of it: fifteen rows all reading spring.beans.instantiate. + tags is optional — steps that are not bean instantiation render as before. */ +function stepTable(rows) { + if (!rows.length) return '
No data.
'; + const max = rows[0].durationMs || 1; + const anyTagged = rows.some(r => r.tags && r.tags.beanName); + return `${anyTagged?'':''}${ + rows.slice(0, 12).map(r => { + const t = r.tags || {}; + const offMain = t.threadName && t.threadName !== 'main'; + const bean = t.beanName + ? (t.beanType + ? `
${escBean(t.beanName)}
${esc(t.beanType)}${t.threadName?`
thread: ${esc(t.threadName)}`:''}
` + : `${escBean(t.beanName)}`) + : ''; + return ` + ${anyTagged?``:''} + `; + }).join('')}
StepBeanTime
${esc(r.name)}${offMain?` ${esc(t.threadName)}`:''}${bean}
${fmt(r.durationMs)}ms
`; +} $('#tab-timing').innerHTML = `

Startup Timing

From BufferingApplicationStartup — real measured instantiation, no reflection heuristics.

${threadDistributionCard()}
-
🐢 Slowest startup steps
${timingTable(steps,'name','durationMs')}
+
🐢 Slowest startup steps
${stepTable(steps)}
⏳ Slow bean instantiation
${timingTable(slowBeans,'beanName','durationMs')}
From d211b9754de27d33362dd532fa53ea8fe4154aca Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:02:01 +0530 Subject: [PATCH 04/15] feat(report): answer "who" on the fan-in / fan-out tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smellTable took the strings 'dependents' and 'dependencies' as column labels while the arrays with those exact names sat unused in the same objects — the table said a bean has 4 dependents and never which 4. Click the bean name to expand them, capped at 30 with a pointer to the JSON for the rest. --- .../resources/wiredoctor/report-template.html | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index 091f3db..0058679 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -729,16 +729,30 @@

Ghost Beans

/* ───────── Smells tab ───────── */ const sm = reportData.smells || {}; -function smellTable(list, valKey, valLabel) { +/* v1.1.3 — namesKey ('dependents' / 'dependencies') was used as a column label + while the array of that exact name sat unused in the same object. The table + answered "how many" and never "who". Click the bean to find out. */ +const NAME_LIST_CAP = 30; +function nameList(names) { + const shown = names.slice(0, NAME_LIST_CAP).map(n => escBean(n)).join(', '); + const rest = names.length - NAME_LIST_CAP; + return shown + (rest > 0 ? `
… and ${fmt(rest)} more — full list in wiredoctor-report.json` : ''); +} +function smellTable(list, valKey, namesKey) { if (!list || !list.length) return '
None detected.
'; const max = Math.max(...list.map(e=>e[valKey])) || 1; - return `${ - list.map(e=>` - `).join('')}
Bean${valLabel}
${escBean(e.beanName)}
${e[valKey]}
`; + return `${ + list.map(e => { + const names = e[namesKey] || []; + return ` + `; + }).join('')}
Bean${namesKey}
${names.length + ? `
${escBean(e.beanName)}
${nameList(names)}
` + : `${escBean(e.beanName)}`}
${e[valKey]}
`; } $('#tab-smells').innerHTML = `

Architecture Smells

-

Computed on the live resolved graph — what Spring actually wired. ${sm.frameworkFiltered?'Framework beans filtered out (rankings show beans you can refactor).':''}

+

Computed on the live resolved graph — what Spring actually wired. ${sm.frameworkFiltered?'Framework beans filtered out (rankings show beans you can refactor).':''} Click a bean to see which beans are on the other end.

🔗 High fan-in · coupling hotspots
${smellTable(sm.highFanIn,'inDegree','dependents')}
💥 High fan-out · shotgun surgery risk
${smellTable(sm.highFanOut,'outDegree','dependencies')}
From bcfddfdab8a1a09ba7c99411f24c75c99c8267fc Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:04:41 +0530 Subject: [PATCH 05/15] feat(report): show how far into the critical path each bean sits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cumulativeMs had zero references. A 369ms bean at the end of a 1111ms chain means something different from the same 369ms at the start, and the chain chips showed only ownMs. Dim running total on each chip, cumulative in the timeline row tooltip, and the explaining note renders only when the field is present — pre-1.1.3 reports keep exactly the chain they had. --- .../src/main/resources/wiredoctor/report-template.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index 0058679..9f25aeb 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -677,7 +677,7 @@

Dependency Cycles

const bg = isCycle ? 'var(--danger)' : 'var(--accent)'; const label = n.beanName.length > 28 ? n.beanName.slice(0,25)+'...' : n.beanName; return `
- ${escBean(label)} + ${escBean(label)}
${n.ownMs!=null?n.ownMs+'ms':''} @@ -811,7 +811,9 @@

Startup Timing

${cp.available && cp.path ? `
Critical path — the chain your readiness sits on
-
${cp.path.map(n=>`${escBean(n.beanName)} ${n.ownMs!=null?n.ownMs+'ms':''}`).join('')}
+
${cp.path.map(n=>`${escBean(n.beanName)} ${n.ownMs!=null?n.ownMs+'ms':''}${ + n.cumulativeMs!=null?` · ${fmt(n.cumulativeMs)}ms in`:''}`).join('')}
+ ${cp.path.some(n=>n.cumulativeMs!=null) ? `

Bold is the bean's own instantiation time; the dim number is how far into the ${fmt(cp.totalMs)}ms chain it sits.

` : ''}
`:''} ${trendSparkline()}`; From a6f4652a58d402fcf7342ed35bed992d10e89589 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:10:36 +0530 Subject: [PATCH 06/15] feat(report): expand the orphan and proxied bean names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orphanBeans, cglibBeans and jdkBeans were read only to colour graph nodes and produce a count — the names never appeared as text anywhere in the report. Both are now expandable rows in the composition card, capped at 30 names with a pointer to the JSON. Orphans keep their heuristic caveat, spelled out where the list is: nothing declares a dependency on them, which is not the same as unused. nameList/NAME_LIST_CAP moved up to the shared helpers — the overview renders before the smells tab, so declaring them there was a TDZ error on any report with at least one orphan. --- .../resources/wiredoctor/report-template.html | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index 9f25aeb..7a870ea 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -350,6 +350,14 @@ // Bean names only: Spring's & prefix means "the FactoryBean, not its product". // Spelled out here; the raw name stays in the JSON. const escBean = s => { const t = String(s); return esc(t.startsWith('&') ? t.slice(1) + ' (FactoryBean)' : t); }; +/* v1.1.3 — bean-name lists that used to be counts only (fan-in/out drill-down, + orphans, proxies). Capped: a framework bean can have 200 dependents. */ +const NAME_LIST_CAP = 30; +function nameList(names) { + const shown = names.slice(0, NAME_LIST_CAP).map(n => escBean(n)).join(', '); + const rest = names.length - NAME_LIST_CAP; + return shown + (rest > 0 ? `
… and ${fmt(rest)} more — full list in wiredoctor-report.json` : ''); +} const fmt = n => n.toLocaleString('en-US'); const deps = reportData.dependencies; @@ -659,12 +667,21 @@

Dependency Cycles

Framework ${fmt(fw)} · ${(fw/total*100).toFixed(0)}%
-
- Orphans (heuristic)${orphans.size} -
+ ${countRow('Orphans (heuristic)', [...orphans], 'Nothing declares a dependency on these. Not the same as unused — reflective and programmatic lookups are invisible here.')} + ${countRow('Proxied (CGLIB/JDK)', [...proxyBeans], '')}
`; } +function countRow(label, names, note) { + const head = `${esc(label)}`; + const body = names.length + ? `
${head}
${nameList(names)}${ + note ? `
${esc(note)}
` : ''}
` + : head; + return `
+ ${body}${fmt(names.length)} +
`; +} /* v1.1.0: critical path timeline — horizontal stacked bar */ function criticalPathTimeline() { if (!cp.available || !cp.path || !cp.path.length) return ''; @@ -729,15 +746,6 @@

Ghost Beans

/* ───────── Smells tab ───────── */ const sm = reportData.smells || {}; -/* v1.1.3 — namesKey ('dependents' / 'dependencies') was used as a column label - while the array of that exact name sat unused in the same object. The table - answered "how many" and never "who". Click the bean to find out. */ -const NAME_LIST_CAP = 30; -function nameList(names) { - const shown = names.slice(0, NAME_LIST_CAP).map(n => escBean(n)).join(', '); - const rest = names.length - NAME_LIST_CAP; - return shown + (rest > 0 ? `
… and ${fmt(rest)} more — full list in wiredoctor-report.json` : ''); -} function smellTable(list, valKey, namesKey) { if (!list || !list.length) return '
None detected.
'; const max = Math.max(...list.map(e=>e[valKey])) || 1; From c4cab8592224bc0fe215dcc16fa1b63233c9ddcb Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:11:29 +0530 Subject: [PATCH 07/15] test(report): jsdom render check for the template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node --check only proves the template's JS parses. This runs it against a real wiredoctor-report.json and fails on any runtime error or empty pane — it is what caught the NAME_LIST_CAP TDZ error in the previous commit. Renders without the vis-network bundle, so the no-library degrade path is covered every run. Skips cleanly when jsdom is absent, so it never becomes a build dependency. --- tools/render-check.js | 52 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tools/render-check.js diff --git a/tools/render-check.js b/tools/render-check.js new file mode 100644 index 0000000..afc5b5b --- /dev/null +++ b/tools/render-check.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/* + * Renders report-template.html with a real wiredoctor-report.json in jsdom and + * fails on any runtime error. node --check only proves the JS parses; this + * proves it runs — it is what caught the NAME_LIST_CAP TDZ error in v1.1.3. + * + * npm i jsdom # once, anywhere on NODE_PATH + * node tools/render-check.js [ghost-report.json] + * DUMP='#tab-cycles' node tools/render-check.js report.json # print a pane + * + * The template is rendered WITHOUT the vendored vis-network bundle, so the + * no-library degrade path is exercised on every run. + */ +const fs = require('fs'); +const path = require('path'); + +let jsdom; +try { jsdom = require('jsdom'); } +catch { console.log('SKIP: jsdom not installed (npm i jsdom)'); process.exit(0); } + +const tpl = path.join(__dirname, '..', 'wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html'); +const [dataPath, ghostPath] = process.argv.slice(2); +if (!dataPath) { console.error('usage: node tools/render-check.js [wiredoctor-ghost-report.json]'); process.exit(2); } + +const html = fs.readFileSync(tpl, 'utf8') + .replace('', '') + .replace('', 'render-check') + .replace('/* DATA_INJECTION_POINT */', fs.readFileSync(dataPath, 'utf8')) + .replace('/* GHOST_INJECTION_POINT */', ghostPath ? fs.readFileSync(ghostPath, 'utf8') : 'null'); + +const errors = []; +const vc = new jsdom.VirtualConsole(); +vc.on('jsdomError', e => errors.push(e.stack || String(e))); +vc.on('error', (...a) => errors.push('console.error: ' + a.join(' '))); +const doc = new jsdom.JSDOM(html, { runScripts: 'dangerously', virtualConsole: vc }).window.document; + +if (errors.length) { console.error('FAIL — runtime errors:\n' + errors.join('\n---\n')); process.exit(1); } + +const badge = n => { const c = n.querySelector('.count'); return c ? `(${c.textContent}${c.classList.contains('hot') ? ' hot' : ''})` : ''; }; +console.log('tabs: ' + [...doc.querySelectorAll('.nav-item')].map(n => n.dataset.tab + badge(n)).join(' | ')); +let empty = 0; +for (const pane of doc.querySelectorAll('.tab')) { + const len = pane.innerHTML.length; + if (len < 200) { empty++; console.error(`FAIL — ${pane.id} rendered ${len} chars`); } + else console.log(` ${pane.id}: ${len} chars`); +} +for (const sel of (process.env.DUMP || '').split(',').filter(Boolean)) { + const el = doc.querySelector(sel); + console.log(`\n--- ${sel} ---\n` + (el ? el.textContent.replace(/\n\s*\n/g, '\n').trim() : 'MISSING')); +} +console.log(empty ? 'FAIL' : 'OK'); +process.exit(empty ? 1 : 0); From ff6acce33742a5e736bbd2ebca7471e88e647e7a Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:18:26 +0530 Subject: [PATCH 08/15] =?UTF-8?q?feat(report):=20Pareto=20curve=20?= =?UTF-8?q?=E2=80=94=20how=20few=20beans=20carry=2080%=20of=20the=20cost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slow-bean table answers "which bean is slowest". It does not answer the question a developer actually has before deciding to spend an afternoon on it: is the cost concentrated in a handful of beans, or spread thin across hundreds? startupPareto() sorts beanTimings descending and plots the cumulative share. The knee — the bean where the running total crosses 80% — is marked with a dashed line and labelled, so the count is readable without counting pixels. On petclinic that is 33 of 274 beans. The caption does not present the sum of beanTimings as wall-clock time. spring.beans.instantiate steps nest, and the analyzer keeps max-per-bean, so a bean's number includes the beans its constructor triggered and the sum counts nested work more than once. The share is a valid ranking of where cost concentrates; the total is not a budget, and a dim line under the curve says so. Degrades to the empty state below 5 timed beans (a curve through 3 points claims a distribution that isn't there) — verified against a report with beanTimings: {}. --- .../resources/wiredoctor/report-template.html | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index 7a870ea..4688765 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -805,9 +805,59 @@

Architecture Smells

${fmt(r.durationMs)}ms
`; }).join('')}`; } +/* v1.1.3 — Pareto: the histogram says how instantiation times are distributed, + this says how few beans you would have to fix. Same data, actionable version. */ +function startupPareto() { + const entries = Object.entries(beanTimings) + .filter(([, v]) => typeof v === 'number' && v > 0) + .sort((a, b) => b[1] - a[1]); + if (entries.length < 5) { + return '
No per-bean timings captured (startup was not buffering).
'; + } + const total = entries.reduce((sum, [, v]) => sum + v, 0); + const W = 620, H = 250, L = 54, R = 18, T = 18, B = 46; + const pw = W - L - R, ph = H - T - B, base = T + ph; + const x = i => L + (i + 1) / entries.length * pw; + let cum = 0, knee = -1; + const pts = entries.map(([, v], i) => { + cum += v; + const share = cum / total; + if (knee < 0 && share >= 0.8) knee = i; + return `${x(i).toFixed(1)},${(base - share * ph).toFixed(1)}`; + }); + if (knee < 0) knee = entries.length - 1; // one bean over 80% on its own + const grid = [0, 0.25, 0.5, 0.75, 1].map(f => { + const y = base - ph * f; + return `` + + `${(f * 100).toFixed(0)}%`; + }).join(''); + const y80 = base - ph * 0.8, kx = x(knee); + const kneeLabelRight = kx < W * 0.55; + const top = entries.slice(0, 5); + return ` + ${grid} + + + + + ${fmt(knee + 1)} bean${knee ? 's' : ''} = 80% + 1 + ${fmt(entries.length)} + beans, slowest first → + +

${fmt(knee + 1)} of ${fmt(entries.length)} beans account for 80% of measured bean-instantiation time. + Slowest: ${top.map(([b, v]) => `${escBean(b)} ${fmt(v)}ms`).join(' · ')}.

+

A bean's time includes the beans its constructor triggers, so the ${fmt(total)}ms these shares are taken from counts nested instantiation more than once — it is a ranking, not a wall-clock budget.

`; +} $('#tab-timing').innerHTML = `

Startup Timing

From BufferingApplicationStartup — real measured instantiation, no reflection heuristics.

+
+
📉 How few beans you would have to fix cumulative
+ ${startupPareto()} +
${threadDistributionCard()}
🐢 Slowest startup steps
${stepTable(steps)}
From 85b6c9cd658c5af47097e535616dd2fb618e0d17 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:28:07 +0530 Subject: [PATCH 09/15] =?UTF-8?q?feat(report):=20coupling=20quadrant=20?= =?UTF-8?q?=E2=80=94=20the=20shape=20the=20top-10=20tables=20hide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit highFanIn and highFanOut are ten rows each. They name the extremes and say nothing about the distribution, which is the question an architect actually asks: is coupling concentrated in three god beans, or is this a flat field of leaves? One dot per (fan-out, fan-in) position answers it. Bucketed by position, because 95 petclinic beans sit at fan-out 0 / fan-in 1 and 95 overlapping identical dots would be a lie told with ink. Dot area is how many beans share the spot; the tooltip names the first six. Square-root axes rather than the clamped edge band the plan called for. Clamping the top decile puts a fan-in 25 god bean in the same band as a fan-in 4 bean and destroys the only reading worth having. Every tick prints its real count, so the scale is visible instead of silently applied. The dashed curve is I = Ce/(Ca+Ce) = 0.8 — the same threshold the unstable table uses, so chart and table cannot disagree on screen. Framework beans are dimmed, not dropped, with one checkbox to hide them; the caption states the heuristic's blind spot (a camelCase framework bean reads as user-defined). isFw moves up to the shared helpers: the smells tab renders long before the graph section, so reading it in place was the NAME_LIST_CAP TDZ error again. The render check now exercises the toggle, which is the template's only interactive re-render. --- tools/render-check.js | 16 +++ .../resources/wiredoctor/report-template.html | 102 +++++++++++++++++- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/tools/render-check.js b/tools/render-check.js index afc5b5b..9977309 100644 --- a/tools/render-check.js +++ b/tools/render-check.js @@ -44,6 +44,22 @@ for (const pane of doc.querySelectorAll('.tab')) { if (len < 200) { empty++; console.error(`FAIL — ${pane.id} rendered ${len} chars`); } else console.log(` ${pane.id}: ${len} chars`); } +// The coupling quadrant's framework toggle re-runs the whole render on click — +// the only interactive re-render in the template, so exercise it here. +const scHide = doc.querySelector('#sc-hide-fw'), scWrap = doc.querySelector('#sc-wrap'); +if (scHide && scWrap) { + const before = scWrap.innerHTML; + scHide.checked = true; + scHide.dispatchEvent(new (scWrap.ownerDocument.defaultView.Event)('change')); + const after = scWrap.innerHTML; + if (errors.length) { console.error('FAIL — errors after framework toggle:\n' + errors.join('\n')); process.exit(1); } + // A degenerate graph renders the same empty state either way, so only the + // absence of errors and of an emptied container is asserted. + if (!after.trim()) { console.error('FAIL — framework toggle emptied the quadrant'); process.exit(1); } + console.log(` quadrant toggle: ${before.length} -> ${after.length} chars`); + scHide.checked = false; + scHide.dispatchEvent(new (scWrap.ownerDocument.defaultView.Event)('change')); +} for (const sel of (process.env.DUMP || '').split(',').filter(Boolean)) { const el = doc.querySelector(sel); console.log(`\n--- ${sel} ---\n` + (el ? el.textContent.replace(/\n\s*\n/g, '\n').trim() : 'MISSING')); diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index 4688765..6699ec0 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -213,6 +213,15 @@ .chart .col { transition: opacity .12s; cursor: default; } .chart .col:hover { opacity: 0.75; } .chart-note { font-size: 11px; color: var(--text-dim); margin: 10px 0 0; } +.sc-dot { stroke: var(--surface); stroke-width: 0.6; } +.sc-dot.user { fill: var(--accent); } +.sc-dot.fw { fill: var(--text-dim); opacity: 0.4; } +.sc-dot.one { cursor: pointer; } +.sc-dot.one:hover { stroke: var(--text); stroke-width: 1.4; } +.sc-toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; + color: var(--text-2); cursor: pointer; user-select: none; } +.sc-toggle input { accent-color: var(--accent); } +.sc-q { font-size: 9.5px; fill: var(--text-dim); } .stack { display: flex; gap: 2px; height: 22px; border-radius: 4px; overflow: hidden; } .stack > span { min-width: 3px; } .stack-legend { display: flex; flex-wrap: wrap; gap: 6px 18px; margin-top: 12px; font-size: 12px; } @@ -359,6 +368,12 @@ return shown + (rest > 0 ? `
… and ${fmt(rest)} more — full list in wiredoctor-report.json` : ''); } const fmt = n => n.toLocaleString('en-US'); +/* Package-prefix heuristic, shared by the graph's Framework filter and the + coupling quadrant. Only catches beans registered under their FQCN — a + camelCase framework bean reads as user-defined. Declared here so both + readers see it initialised. */ +const FRAMEWORK_PREFIX = /^(org\.springframework|org\.apache|com\.sun|java\.|javax\.|jakarta\.|io\.netty|com\.fasterxml|io\.micrometer)/; +const isFw = b => FRAMEWORK_PREFIX.test(b); const deps = reportData.dependencies; const cycles = deps.cycles || []; @@ -758,9 +773,86 @@

Ghost Beans

${e[valKey]}
`; }).join('')}`; } +/* v1.1.3 — the fan-in / fan-out tables are top-10: they show the extremes and + hide the shape. One dot per (fan-out, fan-in) position answers the question + the tables cannot: is coupling concentrated in a few god beans or spread flat? + Positions are bucketed — 95 petclinic beans share (0,1), and 95 identical + overlapping dots would be a lie told with ink. */ +const SC_CAP = 6; // names listed in a dot's tooltip +function scatterSvg(hideFw) { + const g = deps.graph || {}; + const all = [...new Set([...Object.keys(g), ...Object.keys(fanIn)])] + .filter(b => !(hideFw && isFw(b))); + if (all.length < 5) return '
Not enough beans in the resolved graph to plot.
'; + const xOf = b => (g[b] || []).length, yOf = b => fanIn[b] || 0; + const xMax = Math.max(1, ...all.map(xOf)), yMax = Math.max(1, ...all.map(yOf)); + if (xMax === 1 && yMax === 1) return '
Every bean has at most one dependency in either direction — nothing to rank.
'; + // Buckets keyed by position AND classification, so a shared position can still + // show which side of it is framework. + const buckets = new Map(); + all.forEach(b => { + const k = xOf(b) + '|' + yOf(b) + '|' + (isFw(b) ? 1 : 0); + (buckets.get(k) || buckets.set(k, []).get(k)).push(b); + }); + const maxCount = Math.max(...[...buckets.values()].map(v => v.length)); + const W = 620, H = 290, L = 46, R = 22, T = 26, B = 44; + const pw = W - L - R, ph = H - T - B, base = T + ph; + // Square-root axes: petclinic's max fan-in is 25 with a median of 1, and a + // linear axis puts every bean in the corner. Every tick prints its real value, + // so the scale is visible rather than silently applied. + const px = v => L + Math.sqrt(v / xMax) * pw; + const py = v => base - Math.sqrt(v / yMax) * ph; + const ticks = max => [0, 1, 2, 3, 5, 8, 12, 20, 30, 50, 80, 120, 200].filter(t => t <= max).concat(max); + const grid = [...new Set(ticks(yMax))].map(t => + `` + + `${t}`).join('') + + [...new Set(ticks(xMax))].map(t => + `${t}`).join(''); + // I = Ce/(Ca+Ce) >= 0.8 <=> fanOut >= 4 * fanIn — the same threshold the + // unstable table uses, so the chart and the table cannot disagree. + const iPts = []; + for (let i = 0; i <= 24; i++) { + const x = xMax * i / 24, y = x / 4; + if (y > yMax) break; + iPts.push(`${px(x).toFixed(1)},${py(y).toFixed(1)}`); + } + const dots = [...buckets.entries()].map(([k, beans]) => { + const [x, y, fw] = k.split('|'); + const r = 2.5 + 7 * Math.sqrt(beans.length / maxCount); + const names = beans.slice(0, SC_CAP).map(b => b.replace(/^&/, '')).join(', '); + const more = beans.length > SC_CAP ? ` … +${beans.length - SC_CAP} more` : ''; + const head = beans.length === 1 ? '' : `${beans.length} beans · `; + return { fw: fw === '1', svg: `${head}fan-out ${x}, fan-in ${y}\n${esc(names)}${esc(more)}` }; + }); + const fwCount = all.filter(isFw).length; + return ` + ${grid} + ${iPts.length > 1 ? ` + I = 0.8 · below: unstable` : ''} + ↖ god beans — many depend on them + shotgun surgery risk ↘ + ${dots.filter(d => d.fw).map(d => d.svg).join('')} + ${dots.filter(d => !d.fw).map(d => d.svg).join('')} + fan-out — beans it depends on → + ↑ fan-in + +

${fmt(all.length)} beans, ${fmt(buckets.size)} distinct positions — dot size is how many beans share one. + ${hideFw ? '' : `${fmt(fwCount)} dimmed as framework by package prefix (the graph filter's heuristic — a camelCase framework bean reads as user-defined).`} + Axes are square-root scaled so the tail stays readable; every tick is a real count. Click a single-bean dot to open it in the graph.

`; +} $('#tab-smells').innerHTML = `

Architecture Smells

Computed on the live resolved graph — what Spring actually wired. ${sm.frameworkFiltered?'Framework beans filtered out (rankings show beans you can refactor).':''} Click a bean to see which beans are on the other end.

+
+
🧭 Coupling quadrant + +
+
${scatterSvg(false)}
+
🔗 High fan-in · coupling hotspots
${smellTable(sm.highFanIn,'inDegree','dependents')}
💥 High fan-out · shotgun surgery risk
${smellTable(sm.highFanOut,'outDegree','dependencies')}
@@ -771,6 +863,13 @@

Architecture Smells

sm.unstable.map(e=>`${escBean(e.beanName)}${e.instability}${e.fanIn}${e.fanOut}`).join('')}` : '
None over threshold.
'}
`; +const scWrap = $('#sc-wrap'), scHide = $('#sc-hide-fw'); +if (scHide) scHide.onchange = () => { scWrap.innerHTML = scatterSvg(scHide.checked); }; +if (scWrap) scWrap.addEventListener('click', e => { + const dot = e.target.closest('.sc-dot.one'); + if (dot) focusBean(dot.dataset.bean); +}); + /* ───────── Timing tab ───────── */ const steps = reportData.startupSlowestSteps || []; const trendHistory = reportData.trendHistory || []; @@ -1049,9 +1148,6 @@

Autoconfiguration Conditions

if (orphans.has(bean)) return { bg:'#e8a33d', glow:false }; return { bg:'#3d4a63', glow:false }; } -const FRAMEWORK_PREFIX = /^(org\.springframework|org\.apache|com\.sun|java\.|javax\.|jakarta\.|io\.netty|com\.fasterxml|io\.micrometer)/; -const isFw = b => FRAMEWORK_PREFIX.test(b); - function buildGraph() { if (typeof vis === 'undefined') { // Only reachable via the CDN fallback (bundled resource missing) while offline. From f940b604790af3ca67706af005cb73dd1dec2239 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:31:53 +0530 Subject: [PATCH 10/15] feat(baseline): record the bean count in each trend entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trendHistory carried timestamp, totalStartupMs and slowBeanCount. That is enough to draw a line and not enough to read it: a run that got 800ms slower because someone added forty beans looks identical to one that got 800ms slower for no reason, and only the second is a bug. beanCount is the number the dependencies section already reports, so the two cannot disagree. Carried forward from prior entries only when present — a baseline written before 1.1.3 keeps its entries and simply has no count on them, rather than being back-filled with a number nobody measured. Prepares the trend chart's explained / unexplained verdict. 259 tests. --- .../com/wiredoctor/WireDoctorAnalyzer.java | 11 +++++++++ .../WireDoctorAnalyzerIntegrationTest.java | 23 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/wiredoctor-autoconfigure/src/main/java/com/wiredoctor/WireDoctorAnalyzer.java b/wiredoctor-autoconfigure/src/main/java/com/wiredoctor/WireDoctorAnalyzer.java index 51064f3..02e135a 100644 --- a/wiredoctor-autoconfigure/src/main/java/com/wiredoctor/WireDoctorAnalyzer.java +++ b/wiredoctor-autoconfigure/src/main/java/com/wiredoctor/WireDoctorAnalyzer.java @@ -804,9 +804,13 @@ private WireDoctorRegressionException runRegressionGuard(Map g JsonNode ts = entry.get("timestamp"); JsonNode ms = entry.get("totalStartupMs"); JsonNode sb = entry.get("slowBeanCount"); + // v1.1.3: absent in pre-1.1.3 entries — carried + // forward only when present, never invented. + JsonNode bc = entry.get("beanCount"); if (ts != null) map.put("timestamp", ts.asLong()); if (ms != null) map.put("totalStartupMs", ms.asLong()); if (sb != null) map.put("slowBeanCount", sb.asInt()); + if (bc != null) map.put("beanCount", bc.asInt()); if (!map.isEmpty()) trendHistory.add(map); } } @@ -821,6 +825,13 @@ private WireDoctorRegressionException runRegressionGuard(Map g currentEntry.put("totalStartupMs", totalStartupMs); } currentEntry.put("slowBeanCount", currentSlowBeans.size()); + // v1.1.3: the trend chart cannot tell a real regression from an + // app that simply grew without knowing how many beans each run + // had. Same number the dependencies section reports. + Object beanCount = deps.get("totalBeans"); + if (beanCount != null) { + currentEntry.put("beanCount", beanCount); + } trendHistory.add(currentEntry); // Cap at configured size (0 = unlimited) if (trendCap > 0 && trendHistory.size() > trendCap) { diff --git a/wiredoctor-autoconfigure/src/test/java/com/wiredoctor/WireDoctorAnalyzerIntegrationTest.java b/wiredoctor-autoconfigure/src/test/java/com/wiredoctor/WireDoctorAnalyzerIntegrationTest.java index 491fd76..cfceb99 100644 --- a/wiredoctor-autoconfigure/src/test/java/com/wiredoctor/WireDoctorAnalyzerIntegrationTest.java +++ b/wiredoctor-autoconfigure/src/test/java/com/wiredoctor/WireDoctorAnalyzerIntegrationTest.java @@ -301,9 +301,32 @@ void baselineWriteProducesTrendHistory(@TempDir Path tempDir) throws Exception { assertThat(trend.get(0).has("timestamp")).isTrue(); assertThat(trend.get(0).has("totalStartupMs")).isTrue(); assertThat(trend.get(0).has("slowBeanCount")).isTrue(); + // v1.1.3: the trend chart divides startup growth into explained + // (more beans) and unexplained — it needs the bean count per run. + assertThat(trend.get(0).path("beanCount").asInt()) + .isEqualTo(report.path("dependencies").path("totalBeans").asInt()) + .isPositive(); } } + @Test + void trendHistoryPreservesBeanCountAcrossWrites(@TempDir Path tempDir) throws Exception { + for (int i = 0; i < 2; i++) { + try (ConfigurableApplicationContext ctx = boot( + "wiredoctor.baseline=" + tempDir.resolve("baseline.json"), + "wiredoctor.baseline-write=true")) { + assertThat(tempDir.resolve("baseline.json")).exists(); + } + } + JsonNode trend = new ObjectMapper() + .readTree(tempDir.resolve("baseline.json").toFile()) + .path("trendHistory"); + assertThat(trend).hasSize(2); + // The carried-forward entry keeps its own count — it is not re-derived + // from the current run, which would flatten the growth the chart reads. + trend.forEach(e -> assertThat(e.path("beanCount").asInt()).isPositive()); + } + @Test void trendHistoryCarriesForwardAcrossWrites(@TempDir Path tempDir) throws Exception { try (ConfigurableApplicationContext ctx1 = boot( From ba7a425328241d9bb2a009901a02dbf954228a29 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:48:58 +0530 Subject: [PATCH 11/15] feat(report): trend chart says whether the slowdown is explained MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sparkline plotted one line and could not answer the only question worth asking about it. An app that added forty beans and an app that added none draw the identical rising curve, and only the second one is a bug. Bean count now shares the plot on its own labelled axis (dashed, faint), and the signal is the band between two consecutive runs rather than the points: red regressed past the gate's thresholds with bean growth under 1% amber regressed, and the app grew — or one endpoint has no bean count green improved past the thresholds plain inside the thresholds Thresholds come from gates.config in the report, not from a constant, so the chart cannot call something a regression that the gate lets pass. The fallback for a pre-0.7.1 report without that section is WireDoctorProperties' own default (500ms / 20%) — guessing lower would paint bands the gate would have passed. Red needs a bean count on BOTH endpoints. A pre-1.1.3 entry can only reach amber, because "nothing was added" is a claim its data cannot support. The bean line is drawn as segments over consecutive counted runs, so it never joins run 2 to run 5 across a hole as though the runs between had been measured. The caption says "unexplained by bean count", never "regression detected". A flat bean count with rising startup does not prove a code regression — a bigger dataset, a slower runner or a cold page cache draw the same line. The band says where to look. Verified on six real wiredoctor-test runs with the regression induced on purpose (AlphaBean's sleep 50 -> 450ms, bean count held at 67): the four band states came out amber, green, red, green in the order constructed. Entries with no totalStartupMs are dropped rather than read as zero. 259 tests. --- .../resources/wiredoctor/report-template.html | 142 +++++++++++++----- 1 file changed, 103 insertions(+), 39 deletions(-) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index 6699ec0..b95a2c4 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -873,6 +873,20 @@

Architecture Smells

/* ───────── Timing tab ───────── */ const steps = reportData.startupSlowestSteps || []; const trendHistory = reportData.trendHistory || []; +const TREND_CFG = (reportData.gates && reportData.gates.config) || {}; +const TREND_ABS = TREND_CFG.startupTimeAbsoluteThresholdMs != null ? TREND_CFG.startupTimeAbsoluteThresholdMs : 500; +const TREND_PCT = TREND_CFG.startupTimeRelativePercent != null ? TREND_CFG.startupTimeRelativePercent : 20; +const TREND_BAND = { + unexplained: { fill: 'var(--danger)', op: 0.17, txt: 'unexplained by bean count' }, + grew: { fill: 'var(--warn)', op: 0.15, txt: 'slower, and the app grew' }, + unknown: { fill: 'var(--warn)', op: 0.15, txt: 'slower; this run pair has no bean count' }, + faster: { fill: 'var(--ok)', op: 0.15, txt: 'faster' } +}; +const beanDelta = (a, b) => { + const d = b - a; + if (d === 0) return 'with no new beans'; + return d > 0 ? `with ${fmt(d)} new bean${d > 1 ? 's' : ''}` : `with ${fmt(-d)} bean${d < -1 ? 's' : ''} gone`; +}; const threadDist = reportData.threadDistribution; const slowBeans = reportData.slowBeans || []; function timingTable(rows, nameKey, msKey) { @@ -974,53 +988,103 @@

Startup Timing

`:''} ${trendSparkline()}`; -/* v1.1.0: trend sparkline */ +/* v1.1.0 trend, rebuilt in v1.1.3. + One line said startup got slower and could not say why: an app that added 40 + beans and an app that added none draw the identical curve, and only the second + is a bug. Bean count now shares the plot on its own axis, and each interval + between two runs carries a verdict computed with the startup-time gate's own + thresholds — so the chart can never call something a regression the gate lets + pass. */ +/* Red requires a bean count on BOTH ends — a pre-1.1.3 entry can only ever reach + amber, because "nothing was added" is a claim its data cannot support. */ +function trendVerdict(a, b) { + const d = b.totalStartupMs - a.totalStartupMs; + const pct = a.totalStartupMs > 0 ? d / a.totalStartupMs * 100 : 0; + if (-d >= TREND_ABS && -pct >= TREND_PCT) return 'faster'; + if (!(d >= TREND_ABS && pct >= TREND_PCT)) return 'none'; + if (a.beanCount == null || b.beanCount == null) return 'unknown'; + return (b.beanCount - a.beanCount) / Math.max(1, a.beanCount) * 100 >= 1 ? 'grew' : 'unexplained'; +} function trendSparkline() { - if (trendHistory.length < 2) { + // An entry written on a run with no ApplicationReadyEvent timing has no + // totalStartupMs; it cannot be plotted and must not be read as zero. + const tr = trendHistory.filter(e => typeof e.totalStartupMs === 'number'); + if (tr.length < 2) { return '
Need at least 2 baseline writes to show a trend. Run with wiredoctor.baseline-write=true across builds.
'; } - const w = 600, h = 120, pad = { top: 10, right: 60, bottom: 30, left: 50 }; - const values = trendHistory.map(e => e.totalStartupMs); - const min = Math.min(...values), max = Math.max(...values); - const range = max - min || 1; - const pts = trendHistory.map((e, i) => { - const x = pad.left + (trendHistory.length === 1 ? w/2 : (i / (trendHistory.length - 1)) * (w - pad.left - pad.right)); - const y = pad.top + (1 - (e.totalStartupMs - min) / range) * (h - pad.top - pad.bottom); - return `${x.toFixed(1)},${y.toFixed(1)}`; + const W = 680, H = 240, L = 56, R = 58, T = 18, B = 40; + const pw = W - L - R, ph = H - T - B, base = T + ph; + const x = i => L + (i / (tr.length - 1)) * pw; + const ms = tr.map(e => e.totalStartupMs); + const msMin = Math.min(...ms), msMax = Math.max(...ms), msRange = (msMax - msMin) || 1; + const y1 = v => T + (1 - (v - msMin) / msRange) * ph; + // Counted runs in consecutive stretches: a baseline can hold pre-1.1.3 entries + // with no count, and one line drawn across that hole would join run 2 to run 5 + // as though the runs between them had been measured. + const segs = []; + tr.forEach((e, i) => { + if (typeof e.beanCount !== 'number') { segs.push([]); return; } + (segs.length ? segs[segs.length - 1] : segs[segs.push([]) - 1]).push(i); }); - const last = trendHistory[trendHistory.length - 1]; - const prev = trendHistory.length >= 2 ? trendHistory[trendHistory.length - 2] : last; + const beanSegs = segs.filter(seg => seg.length >= 2); + const counted = tr.filter(e => typeof e.beanCount === 'number'); + const showBeans = beanSegs.length > 0; + const bc = counted.map(e => e.beanCount); + const bcMin = showBeans ? Math.min(...bc) : 0, bcMax = showBeans ? Math.max(...bc) : 1; + const bcRange = (bcMax - bcMin) || 1; + const y2 = v => T + (1 - (v - bcMin) / bcRange) * ph; + const bands = [], legend = new Set(); + for (let i = 0; i < tr.length - 1; i++) { + const v = trendVerdict(tr[i], tr[i + 1]); + if (v === 'none') continue; + legend.add(v); + const b = TREND_BAND[v], d = tr[i + 1].totalStartupMs - tr[i].totalStartupMs; + const dBeans = (tr[i].beanCount != null && tr[i + 1].beanCount != null) + ? ' ' + beanDelta(tr[i].beanCount, tr[i + 1].beanCount) : ''; + bands.push(`Run ${i + 1} → ${i + 2}: ${d >= 0 ? '+' : ''}${fmt(d)}ms${dBeans} — ${b.txt}`); + } + const yTicks = [msMin, msMin + msRange / 2, msMax].map(v => Math.round(v)); + const grid = yTicks.map(v => `` + + `${fmt(v)}ms`).join(''); + const bcTicks = showBeans ? [bcMin, bcMax].map(v => `${fmt(v)}`).join('') : ''; + const dates = tr.map(e => new Date(e.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })); + const last = tr[tr.length - 1], prev = tr[tr.length - 2]; const delta = last.totalStartupMs - prev.totalStartupMs; - const pct = prev.totalStartupMs > 0 ? ((delta / prev.totalStartupMs) * 100).toFixed(1) : '0.0'; + const pct = prev.totalStartupMs > 0 ? (delta / prev.totalStartupMs * 100).toFixed(1) : '0.0'; + const lastV = trendVerdict(prev, last); const arrow = delta > 0 ? '↑' : delta < 0 ? '↓' : '→'; - const color = delta > 0 ? 'var(--danger)' : delta < 0 ? 'var(--ok)' : 'var(--text-2)'; - const dates = trendHistory.map(e => { - const d = new Date(e.timestamp); - return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - }); - const yTicks = [min, min + range * 0.5, max].map(v => Math.round(v) + 'ms').reverse(); + const chip = lastV === 'unexplained' ? 'var(--danger)' : lastV === 'faster' ? 'var(--ok)' + : lastV === 'none' ? 'var(--text-2)' : 'var(--warn)'; + const head = `Run ${tr.length - 1} → ${tr.length}: ${delta >= 0 ? '+' : ''}${fmt(delta)}ms (${pct}%)`; + const verdictLine = + lastV === 'none' ? `${head} — inside the gate's ${fmt(TREND_ABS)}ms / ${TREND_PCT}% thresholds.` + : lastV === 'unknown' ? `${head} — slower, and neither run recorded a bean count, so nothing here can rule it explained or not.` + : `${head} ${beanDelta(prev.beanCount, last.beanCount)} — ${TREND_BAND[lastV].txt}.`; return `
-
Startup time trend ${arrow} ${delta>0?'+':''}${delta}ms (${pct}%)
-
${trendHistory.length} baseline entries · cap ${reportData.gates && reportData.gates.trendHistoryCap ? reportData.gates.trendHistoryCap : 'unlimited'}
- - - - ${yTicks.map((t, i) => { - const y = pad.top + (i / (yTicks.length - 1)) * (h - pad.top - pad.bottom); - return `${t} - `; - }).join('')} - - ${trendHistory.map((e, i) => { - const x = pad.left + (trendHistory.length === 1 ? w/2 : (i / (trendHistory.length - 1)) * (w - pad.left - pad.right)); - const y = pad.top + (1 - (e.totalStartupMs - min) / range) * (h - pad.top - pad.bottom); - return i === trendHistory.length - 1 - ? `${e.totalStartupMs}ms` - : ``; - }).join('')} - ${dates[0]} - ${trendHistory.length > 1 ? `${dates[dates.length-1]}` : ''} +
Startup time trend ${arrow} ${delta > 0 ? '+' : ''}${fmt(delta)}ms (${pct}%)
+
${fmt(tr.length)} baseline entries · cap ${reportData.gates && reportData.gates.trendHistoryCap ? reportData.gates.trendHistoryCap : 'unlimited'} · bands use the startup-time gate's thresholds (≥${fmt(TREND_ABS)}ms AND ≥${TREND_PCT}%)
+ + ${bands.join('')} + ${grid}${bcTicks} + + + ${beanSegs.map(seg => ``).join('')} + + ${tr.map((e, i) => i === tr.length - 1 + ? `` + : ``).join('')} + ${dates[0]} · run 1 + ${dates[dates.length - 1]} · run ${tr.length} + ${showBeans ? `beans (dashed)` : ''} + startup ms +

${verdictLine} + ${legend.size ? `
${[...legend].map(v => ` ${TREND_BAND[v].txt}`).join(' · ')}` : ''}

+

Both axes span the observed range, not zero. A flat bean count with rising startup does not prove a code regression — a bigger dataset, a slower runner or a cold cache draw the same line. The band says where to look, not what is wrong.${showBeans ? '' : ' No entry pair carries a bean count yet, so no interval can be ruled unexplained.'}

`; } From b7c45a986a90c44cae08fda5a953650905892dd1 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 16:53:51 +0530 Subject: [PATCH 12/15] fix(report): stop calling the 100ms histogram bucket "the threshold" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distribution caption read "N over 100ms — the slow-bean threshold". The last histogram bucket is fixed at 100ms+; the threshold is configurable, so on the demo app (50ms) the sentence named the wrong number and read as if the tool had measured against it. Found by re-running the JSON-key-vs-template diff: slowBeanThreshold was in the report and nowhere in the HTML. Now the bucket and the threshold are stated separately, the threshold read from the report — "66 beans measured · 0 at 100ms or more · slow-bean threshold 50ms". --- .../src/main/resources/wiredoctor/report-template.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html index b95a2c4..ca6e549 100644 --- a/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html +++ b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html @@ -658,7 +658,7 @@

Dependency Cycles

${grid}${cols} instantiation time (ms) -

${fmt(vals.length)} beans measured · ${fmt(slow)} over 100ms — the slow-bean threshold. Brighter column = slower bucket.

`; +

${fmt(vals.length)} beans measured · ${fmt(slow)} at 100ms or more · slow-bean threshold ${fmt(reportData.slowBeanThreshold != null ? reportData.slowBeanThreshold : 100)}ms. Brighter column = slower bucket.

`; } function compositionDonut() { From 151ab022310332a46e79a7e7183018ef5b9fa8d4 Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 19:20:41 +0530 Subject: [PATCH 13/15] chore(release): bump version to 1.1.3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent version bumped in all three child poms as well — a bump that misses one child breaks the reactor build in CI. --- pom.xml | 2 +- wiredoctor-actuator/pom.xml | 2 +- wiredoctor-autoconfigure/pom.xml | 2 +- wiredoctor-test/pom.xml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index a8febdb..f8f9fc9 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 io.github.ddsha441981 wiredoctor-parent - 1.1.1 + 1.1.3 pom WireDoctor diff --git a/wiredoctor-actuator/pom.xml b/wiredoctor-actuator/pom.xml index 8a75ee5..09b85b0 100644 --- a/wiredoctor-actuator/pom.xml +++ b/wiredoctor-actuator/pom.xml @@ -6,7 +6,7 @@ io.github.ddsha441981 wiredoctor-parent - 1.1.1 + 1.1.3 wiredoctor-actuator WireDoctor Actuator diff --git a/wiredoctor-autoconfigure/pom.xml b/wiredoctor-autoconfigure/pom.xml index b5b0963..62aa840 100644 --- a/wiredoctor-autoconfigure/pom.xml +++ b/wiredoctor-autoconfigure/pom.xml @@ -6,7 +6,7 @@ io.github.ddsha441981 wiredoctor-parent - 1.1.1 + 1.1.3 wiredoctor-autoconfigure WireDoctor AutoConfiguration diff --git a/wiredoctor-test/pom.xml b/wiredoctor-test/pom.xml index ff7afb7..166a96b 100644 --- a/wiredoctor-test/pom.xml +++ b/wiredoctor-test/pom.xml @@ -6,7 +6,7 @@ io.github.ddsha441981 wiredoctor-parent - 1.1.1 + 1.1.3 wiredoctor-test From 79d06403b5962a5521d1f12a198813149cbbfe6d Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 19:22:58 +0530 Subject: [PATCH 14/15] =?UTF-8?q?docs(changelog):=201.1.3=20=E2=80=94=20re?= =?UTF-8?q?port=20readability=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 301017a..b8f871f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,73 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.3] - 2026-08-24 + +Report readability pass. 1.1.2 fixed what the report *claimed*; this release +fixes what it *shows*. Two themes: every ranking that named a bean but not its +counterpart now answers "which one?" without opening the graph, and three charts +replace the numbers you had to hold in your head. No API, schema, or +configuration changes. + +### Added + +- **Coupling quadrant (Smells tab).** A fan-out vs fan-in scatter of every bean, + so the shape of the graph is visible instead of two top-10 tables: god beans + climb the left edge, shotgun-surgery risks run along the bottom, and the + dashed I = 0.8 line marks the instability threshold the tables use. Dot size is + how many beans share a position, framework beans are dimmed (with a + **Hide framework beans** toggle), and a single-bean dot opens that bean in the + graph. Axes are square-root scaled so a 400-bean tail stays readable; every + tick is a real count. +- **Pareto curve (Timing tab).** "How few beans you would have to fix" — the + cumulative share of measured bean-instantiation time, with the 80% knee + marked. On start.spring.io: 37 of 283 beans. The caption states plainly that a + bean's time includes the beans its constructor triggers, so the total counts + nested instantiation more than once — it is a ranking, not a wall-clock budget. +- **Startup-time trend verdicts (Timing tab).** The v1.1.0 sparkline is now a + two-axis chart: startup time plus a dashed bean-count line, and a coloured band + on every interval that crosses the `startup-time` gate's own thresholds + (default 500ms AND 20%) — amber when the app also grew, green when it got + faster, red when the slowdown is **unexplained by bean count**. The bean-count + line is drawn in segments so it never spans a run that predates the field, and + such an interval reads amber ("this run pair has no bean count"), never red. + The caption names the thresholds it used and states that a flat bean count with + rising startup does not prove a code regression — a bigger dataset, a slower + runner or a cold cache draw the same line. +- **`beanCount` in each `trendHistory[]` entry**, the same number the + dependencies section reports. Without it the trend chart cannot tell a + regression from an app that simply grew. Older entries stay valid; the chart + handles a missing count rather than guessing. +- **Cycles tab drill-down.** Each cycle now lists its members in order and the + `@Lazy` edge that breaks it, so the fix does not require re-reading the graph. +- **jsdom render check** (`tools/render-check.js`). The report template is one + top-down script, so a `const` declared below a tab's render is in temporal dead + zone for it — and `node --check` passes such code. The harness renders every + tab against a report JSON and fails on the first console error; it caught three + of these during this release. + +### Changed + +- **Fan-in / fan-out rankings answer "who".** Every row expands to the beans on + the other end of the coupling, instead of a count you had to trace by hand. +- **Slow startup steps name their bean.** A `spring.beans.instantiate` step used + to show only the step name; the bean it instantiated is now its own column. +- **Critical path chips show cumulative position.** Each bean carries its own + instantiation time in bold and how far into the chain it sits in dim text, so + the expensive segment is visible at a glance. +- **Orphan and proxied bean names are listed**, not just counted — a count with + no names cannot be acted on. + +### Fixed + +- **The proxy card claimed a count it had not verified.** Beans not instantiated + at report time cannot be inspected for a proxy; the card now states how many + were skipped rather than implying the scan was complete. +- **The histogram caption called the wrong number "the threshold".** It labelled + its fixed 100ms bucket as the slow-bean threshold, which is configurable and + was 50ms on the run being described. It now prints the real + `slow-bean-threshold-ms` alongside the bucket count. + ## [1.1.2] - 2026-08-24 Precision pass on report accuracy: four ways WireDoctor reported something the From 4c265ddefe2bbc12cc335b420609db76a0ac21ba Mon Sep 17 00:00:00 2001 From: ddsha441981 Date: Mon, 24 Aug 2026 19:26:14 +0530 Subject: [PATCH 15/15] docs: document the 1.1.3 charts and drill-downs Report tour gains the coupling quadrant and the Pareto curve, and states that its 0.7.1 screenshots predate them. The trend guide gets the verdict-band table, the beanCount field and the two claims the chart deliberately does not make. --- README.md | 4 ++-- docs/index.md | 2 +- docs/report-tour.md | 16 +++++++++++++++- docs/roadmap.md | 3 +++ docs/startup-time-trend.md | 30 +++++++++++++++++++++++------- 5 files changed, 44 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f01ece6..9407a9e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 🩺 WireDoctor -[![Maven Central](https://img.shields.io/maven-central/v/io.github.ddsha441981/wiredoctor-autoconfigure.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/io.github.ddsha441981/wiredoctor-autoconfigure) [![CI Tests](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml/badge.svg)](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml) [![Tests](https://img.shields.io/badge/Tests-253%20passed-success.svg)](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml) [![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE) [![Awesome Java](https://awesome.re/mentioned-badge.svg)](https://github.com/akullpp/awesome-java#architecture) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.ddsha441981/wiredoctor-autoconfigure.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/io.github.ddsha441981/wiredoctor-autoconfigure) [![CI Tests](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml/badge.svg)](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml) [![Tests](https://img.shields.io/badge/Tests-259%20passed-success.svg)](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml) [![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE) [![Awesome Java](https://awesome.re/mentioned-badge.svg)](https://github.com/akullpp/awesome-java#architecture) > *"Your bean graph has a story. WireDoctor reads it."* @@ -80,7 +80,7 @@ Notes: | [Upgrade Guard](https://ddsha441981.github.io/wiredoctor/upgrade-guard.html) | Catching silent autoconfiguration changes across Boot upgrades | | [Ghost Detector](https://ddsha441981.github.io/wiredoctor/ghost-detector.html) | Passive candidates + opt-in first-touch tracking, and their trust postures | | [Thread Distribution](https://ddsha441981.github.io/wiredoctor/thread-distribution.html) | Per-thread bean map with donut chart (v1.1.0) | -| [Startup Time Trend](https://ddsha441981.github.io/wiredoctor/startup-time-trend.html) | trendHistory in baseline + sparkline (v1.1.0) | +| [Startup Time Trend](https://ddsha441981.github.io/wiredoctor/startup-time-trend.html) | trendHistory in baseline + trend chart with verdict bands (v1.1.3) | | [Security posture](https://ddsha441981.github.io/wiredoctor/security-posture.html) | What the reports expose, offline-only network behavior | | [Known Limitations](https://ddsha441981.github.io/wiredoctor/known-limitations.html) | Honest heuristics and what the tool cannot guarantee | diff --git a/docs/index.md b/docs/index.md index d37374e..6c6a9dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -78,7 +78,7 @@ Run your app once — `wiredoctor-report.json` and `wiredoctor-report.html` appe | [Upgrade Guard](upgrade-guard.html) | Catching silent autoconfiguration changes across Boot upgrades | | [Ghost Detector](ghost-detector.html) | Passive candidates + opt-in first-touch tracking, and their trust postures | | [Thread Distribution](thread-distribution.html) | Per-thread bean map with donut chart (v1.1.0) | -| [Startup Time Trend](startup-time-trend.html) | trendHistory in baseline + sparkline (v1.1.0) | +| [Startup Time Trend](startup-time-trend.html) | trendHistory in baseline + trend chart with verdict bands (v1.1.3) | | [Security posture](security-posture.html) | What the reports expose, offline-only network behavior | | [Known Limitations](known-limitations.html) | Honest heuristics and what the tool cannot guarantee | diff --git a/docs/report-tour.md b/docs/report-tour.md index c1759b7..8be7849 100644 --- a/docs/report-tour.md +++ b/docs/report-tour.md @@ -9,6 +9,8 @@ A guided walkthrough of the WireDoctor HTML console, tab by tab. All screenshots The report is a single self-contained `wiredoctor-report.html` — the graph library is inlined at generation time, so it renders completely offline. Just open it in a browser. +The screenshots below were captured on 0.7.1. Everything they show is still there; the charts added in v1.1.3 (coupling quadrant, Pareto curve, trend verdict bands) are described in the Smells and Timing sections but are not in these images yet. + --- ## Overview tab @@ -67,6 +69,15 @@ Architecture smells computed on the **live resolved graph** — what Spring actu - **High fan-in · coupling hotspots**: beans the most others depend on. A change here ripples widest — here `AzureTokenCredentialAutoConfiguration` (8 dependents) and `initializrMetadataProvider` (6) top the list. - **High fan-out · shotgun surgery risk**: beans that depend on the most others — they break when any of their many dependencies change. +- **Who is on the other end (v1.1.3)**: every row in both tables expands to the actual beans it is coupled to, so "fan-in 6" no longer means tracing six edges in the graph by hand. + +Above the tables sits the **coupling quadrant** (v1.1.3) — a fan-out vs fan-in scatter of every bean, so you see the shape the two top-10 lists hide: + +- **Up the left edge**: god beans — high fan-in, low fan-out. Many beans depend on them, so a change ripples widest. +- **Along the bottom**: shotgun-surgery risks — high fan-out, low fan-in. +- The dashed **I = 0.8** line is the same instability threshold the *Unstable beans* table uses; everything below it is unstable. +- Dot size is how many beans share that exact position, framework beans are dimmed (with a **Hide framework beans** toggle), and clicking a dot that holds a single bean opens it in the Graph tab. +- Axes are square-root scaled so a 400-bean tail stays readable — but every tick is a real count, not a bucket. --- @@ -76,9 +87,12 @@ Architecture smells computed on the **live resolved graph** — what Spring actu Real measured startup numbers from `BufferingApplicationStartup` — no reflection heuristics: -- **Slowest startup steps**: the Boot lifecycle phases, with `spring.context.refresh` (4,619ms) at the top and individual `spring.beans.instantiate` steps below. +- **How few beans you would have to fix (v1.1.3)**: a Pareto curve of cumulative bean-instantiation time with the 80% knee marked — on a start.spring.io run, **37 of 283 beans** carry 80% of it. The caption is explicit that a bean's time includes the beans its constructor triggers, so the total counts nested instantiation more than once: it is a ranking of where to look, not a wall-clock budget. +- **Slowest startup steps**: the Boot lifecycle phases, with `spring.context.refresh` (4,619ms) at the top and individual `spring.beans.instantiate` steps below. Since v1.1.3 the bean each `instantiate` step created is its own column, so a step no longer names a phase without naming what it built. - **Slow bean instantiation**: every bean over the `slow-bean-threshold-ms` (default 100ms), ranked. On this run, `bomRangesInfoContributor` (315ms) and `initializrMetadataProvider` (313ms) lead. +The **startup time trend** chart closes the tab: startup time plus a dashed bean-count line, with a coloured band on every interval that crosses the `startup-time` gate's thresholds — red when a slowdown is *unexplained by bean count*, amber when the app also grew, green when it got faster. It only has data from the second `baseline-write=true` run onward; see the [Startup Time Trend guide](startup-time-trend.html) for the verdict rules and the caveats they carry. + Since v0.7.1, this tab also hosts the **Performance Gates card**: each gate (startup-time, slow-bean, new-cycle, condition-changed) with its threshold, actual value, and a PASS/FAIL/NOT RUN verdict chip — plus `not armed` tags and a CI hint when gates aren't configured. This is the UI counterpart of `wiredoctor.fail-on` CI gating (see the [Performance Gates guide](performance-gates.html)). --- diff --git a/docs/roadmap.md b/docs/roadmap.md index f89db8a..59aadc0 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,6 +11,9 @@ nav_order: 13 | Version | Date | What | |---------|------|------| +| v1.1.3 | 2026-08-24 | **Report readability** — coupling quadrant, Pareto curve, trend verdict bands, drill-downs on every ranking | +| v1.1.2 | 2026-08-24 | Report accuracy — type-collected beans are not ghosts, baseline diff noise masked | +| v1.1.1 | 2026-08-08 | Log format fix | | v1.1.0 | 2026-08-22 | **Longitudinal Visibility** — startup time trend with sparkline, thread distribution with donut chart, EnvironmentPostProcessor migration, foreign ApplicationStartup respect | | v1.0.0 | 2026-08 | **Stability Contract** — frozen JSON schema (`schemaVersion: 1`), frozen config property names, performance budget (< 5s on 1k beans), zero-intrusion guarantee | | v0.10.0 | 2026-07-29 | Graph timing heat + critical path chips | diff --git a/docs/startup-time-trend.md b/docs/startup-time-trend.md index cce38b2..ef17fef 100644 --- a/docs/startup-time-trend.md +++ b/docs/startup-time-trend.md @@ -3,9 +3,9 @@ title: Startup Time Trend nav_order: 10 --- -# Startup Time Trend — track creep over time (v1.1.0) +# Startup Time Trend — track creep over time (v1.1.0, chart rebuilt in v1.1.3) -Every baseline write appends a timestamped snapshot to `trendHistory[]` inside `wiredoctor-baseline.json`. The Timing tab renders a **sparkline** so you can see startup-time creep before the gate trips. +Every baseline write appends a timestamped snapshot to `trendHistory[]` inside `wiredoctor-baseline.json`. The Timing tab renders it as a chart so you can see startup-time creep before the gate trips — and, since v1.1.3, says whether a slowdown is explained by the app having grown. --- @@ -14,28 +14,44 @@ Every baseline write appends a timestamped snapshot to `trendHistory[]` inside ` When WireDoctor runs with `wiredoctor.baseline-write=true`, before writing the baseline: 1. Read the existing baseline JSON (if present) and extract `trendHistory[]` -2. Append a new entry: `{timestamp, totalStartupMs, slowBeanCount}` +2. Append a new entry: `{timestamp, totalStartupMs, slowBeanCount, beanCount}` 3. Cap at `wiredoctor.trend-history-size` (default: 30 entries, `0` = unlimited) 4. Trim oldest entries if over cap 5. Write the updated baseline The `totalStartupMs` field is omitted from entries when it was `null` (pre-v0.7.0 baselines where `ApplicationReadyEvent.getTimeTaken()` was unavailable). This lets the trend grow organically across Boot upgrades. +`beanCount` (v1.1.3) is the same number the report's dependencies section shows. Entries written before v1.1.3 have no `beanCount`, and that is handled rather than guessed — see the verdict rules below. + Example `trendHistory` in `wiredoctor-baseline.json`: ```json { "trendHistory": [ - { "timestamp": 1723900800000, "totalStartupMs": 3420, "slowBeanCount": 2 }, - { "timestamp": 1723987200000, "totalStartupMs": 3580, "slowBeanCount": 3 }, - { "timestamp": 1724073600000, "totalStartupMs": 4759, "slowBeanCount": 5 } + { "timestamp": 1723900800000, "totalStartupMs": 3420, "slowBeanCount": 2, "beanCount": 388 }, + { "timestamp": 1723987200000, "totalStartupMs": 3580, "slowBeanCount": 3, "beanCount": 391 }, + { "timestamp": 1724073600000, "totalStartupMs": 4759, "slowBeanCount": 5, "beanCount": 391 } ] } ``` ## In the HTML report -The Timing tab shows a **sparkline chart** of `totalStartupMs` over time. Each point is a baseline write. The chart makes gradual creep visible — a 200ms drift per week becomes obvious before the `startup-time` gate trips on a single bad run. +The Timing tab shows a two-axis chart: `totalStartupMs` as a solid line (left axis) and `beanCount` as a dashed line (right axis). Each point is a baseline write, so gradual creep becomes visible — a 200ms drift per week is obvious long before the `startup-time` gate trips on a single bad run. + +Any interval that crosses the **`startup-time` gate's own thresholds** (both must hold — default `500ms` AND `20%`; see [Performance gates](performance-gates.html)) gets a coloured band, and the caption below states the verdict for the latest interval: + +| Band | Meaning | +|------|---------| +| 🟥 red | slower, and the bean count barely moved — **unexplained by bean count** | +| 🟧 amber | slower, and the app grew by ≥1% more beans | +| 🟧 amber | slower, but one of the two runs predates `beanCount`, so nothing can be ruled explained or not | +| 🟩 green | faster by the same margins | +| no band | inside the gate's thresholds | + +Two things the chart deliberately does not claim. Both axes span the observed range rather than starting at zero, so the line exaggerates small absolute changes — read the numbers, not the slope. And a flat bean count with rising startup is **not** proof of a code regression: a bigger dataset, a slower CI runner or a cold cache draw exactly the same line. The band says where to look, not what is wrong. + +The dashed bean-count line is drawn in segments, so it never bridges a run that recorded no count. **The sparkline only appears in reports produced by a `baseline-write=true` run, and only from the second write onward.** `trendHistory[]` lives in the baseline file, not in the per-run report: a normal diff/gate run does not read it back, so its report shows the "Need at least 2 baseline writes" placeholder even when the baseline already holds 30 entries. To see the trend, look at the report from your baseline-refresh job (a nightly CI run is the natural place), or read `trendHistory[]` out of `wiredoctor-baseline.json` directly.