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 -[![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. 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/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.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-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 @@

Overview

Total beans
${fmt(deps.totalBeans)}
Wiring edges
${fmt(deps.totalEdges)}
-
Dependency cycles
${cycles.length}
-
Proxies (CGLIB/JDK)
${reportData.proxies.cglibCount} / ${reportData.proxies.jdkCount}
+
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}
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 `
+
⟲ 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); @@ -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 @@

Overview

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 ''; @@ -589,7 +709,7 @@

Overview

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':''} @@ -641,16 +761,98 @@

Ghost Beans

/* ───────── Smells tab ───────── */ const sm = reportData.smells || {}; -function smellTable(list, valKey, valLabel) { +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]}
`; +} +/* 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).':''}

+

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')}
@@ -661,9 +863,30 @@

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 || []; +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) { @@ -674,12 +897,83 @@

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
`; +} +/* 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
${timingTable(steps,'name','durationMs')}
+
🐢 Slowest startup steps
${stepTable(steps)}
⏳ Slow bean instantiation
${timingTable(slowBeans,'beanName','durationMs')}
@@ -688,57 +982,109 @@

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()}`; -/* 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.'}

`; } @@ -866,9 +1212,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. @@ -993,6 +1336,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]) || []; 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( 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