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
diff --git a/README.md b/README.md
index f01ece6..9407a9e 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 🩺 WireDoctor
-[](https://central.sonatype.com/artifact/io.github.ddsha441981/wiredoctor-autoconfigure) [](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml) [](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml) [](LICENSE) [](https://github.com/akullpp/awesome-java#architecture)
+[](https://central.sonatype.com/artifact/io.github.ddsha441981/wiredoctor-autoconfigure) [](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml) [](https://github.com/ddsha441981/wiredoctor/actions/workflows/compat.yml) [](LICENSE) [](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.
diff --git a/pom.xml b/pom.xml
index a8febdb..f8f9fc9 100644
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
4.0.0io.github.ddsha441981wiredoctor-parent
- 1.1.1
+ 1.1.3pomWireDoctor
diff --git a/tools/render-check.js b/tools/render-check.js
new file mode 100644
index 0000000..9977309
--- /dev/null
+++ b/tools/render-check.js
@@ -0,0 +1,68 @@
+#!/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`);
+}
+// 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'));
+}
+console.log(empty ? 'FAIL' : 'OK');
+process.exit(empty ? 1 : 0);
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.ddsha441981wiredoctor-parent
- 1.1.1
+ 1.1.3wiredoctor-actuatorWireDoctor 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.ddsha441981wiredoctor-parent
- 1.1.1
+ 1.1.3wiredoctor-autoconfigureWireDoctor AutoConfiguration
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/main/resources/wiredoctor/report-template.html b/wiredoctor-autoconfigure/src/main/resources/wiredoctor/report-template.html
index 956ca71..ca6e549 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; } }
@@ -185,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; }
@@ -196,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; }
@@ -279,6 +305,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); }
@@ -317,12 +359,28 @@
// 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');
+/* 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 || [];
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) || {};
@@ -370,6 +428,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' },
@@ -380,7 +439,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);
@@ -421,8 +480,9 @@
${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' : '—'}
@@ -464,6 +524,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 `
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.
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);
@@ -547,7 +658,7 @@
Overview
${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() {
@@ -571,12 +682,21 @@
+ ${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 '';
@@ -589,7 +709,7 @@
`;
+}
+/* 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 '
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 `
+
${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).':''}
+
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.
`).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 '
`;
+}
+/* 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 `
+
${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.
Bold is the bean's own instantiation time; the dim number is how far into the ${fmt(cp.totalMs)}ms chain it sits.
` : ''}
`:''}
${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}%)