codelore-lib 0.26.0

CodeLore — Behavioral Code Analyzer library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
//! `--format spa` — single-file interactive dashboard emitter.
//!
//! Opt-in via the `spa` Cargo feature. When the feature is enabled,
//! `build.rs` fetches Apache `ECharts` and d3-hierarchy from jsDelivr at
//! pinned URLs + SHA-256-verifies them; this module embeds those JS deps
//! plus the HTML shell and the widget render glue inline at compile time
//! via `include_str!`, producing a single self-contained `codelore.html`
//! that opens in any browser, runs without a server, fits in a CI
//! artefact, and does not phone home.
//!
//! See `docs/ui-roadmap.md` for the widget plan and the
//! technical-stack justification.
//!
//! # Shape
//!
//! Mirrors `write_full_fact_store_sqlite` (multi-source composite), not
//! the per-row-type generic `write_html<T: Serialize>`. Callers populate
//! a [`SpaDashboard`] struct with the row vectors for each widget, then
//! invoke [`write_spa`] which serialises the struct as JSON, inlines it
//! into the HTML template alongside the vendored JS, and writes the
//! result to the provided sink.
//!
//! # XSS hygiene
//!
//! The serialised JSON sits inside `<script type="application/json">`
//! and must not contain a literal `</script>` substring that would
//! escape the script context. Same precaution as the existing
//! per-analysis HTML emitter in `output/html.rs`: any `</` in the JSON
//! is replaced with `<\/` (HTML-equivalent for spec parsers, foreign
//! to JSON parsers, so the inline JSON still round-trips correctly).

use std::io::Write;

use serde::Serialize;

use crate::analyses::architecture_roles::ArchitectureRoleRow;
use crate::analyses::architecture_trend::ArchitectureTrendRow;
use crate::analyses::code_familiarity::CodeFamiliarityRow;
use crate::analyses::code_health::CodeHealthRow;
use crate::analyses::coordination_needs::CoordinationNeedsRow;
use crate::analyses::coupling::CouplingRow;
use crate::analyses::dashboard::{
    CloneSummary, DailyCommit, ImportEdgeRow, KameiRiskRow, TrendPoint, XRayEntry,
};
use crate::analyses::effort_exposure::EffortExposureRow;
use crate::analyses::entity_ownership::EntityOwnershipRow;
use crate::analyses::function_xray::FunctionXrayRow;
use crate::analyses::hotspots::HotspotRow;
use crate::analyses::knowledge_islands::KnowledgeIslandRow;
use crate::analyses::marginal_owner_risk::MarginalOwnerRiskRow;
use crate::analyses::modularity_violations::ModularityViolationRow;
use crate::analyses::refactoring_targets::RefactoringTargetRow;
use crate::analyses::summary::SummaryRow;
use crate::analyses::team_composition::TeamCompositionRow;
use crate::analyses::unstable_interface::UnstableInterfaceRow;
use crate::{CodeLoreError, Result};

const TEMPLATE: &str = include_str!("spa/template.html");
const WIDGETS_JS: &str = concat!(
    include_str!("spa/js/00_setup_boot.js"),
    include_str!("spa/js/10_helpers.js"),
    include_str!("spa/js/12_drawer.js"),
    include_str!("spa/js/14_widgets_summary.js"),
    include_str!("spa/js/16_widgets_bars.js"),
    include_str!("spa/js/20_hotspots.js"),
    include_str!("spa/js/30_coupling_trends.js"),
    include_str!("spa/js/40_architecture.js"),
    include_str!("spa/js/50_calendar_xray.js"),
    include_str!("spa/js/90_toggles_utils.js"),
);
const ECHARTS_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/echarts.min.js"));
const D3_HIERARCHY_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/d3-hierarchy.min.js"));
const ALPINE_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/alpine.min.js"));
const ALPINE_PERSIST_JS: &str = include_str!(concat!(env!("OUT_DIR"), "/alpine-persist.min.js"));
// CSS lives as a regular source file (not a `build.rs`-fetched asset) —
// it's the precompiled output of `just spa-css-rebuild`, checked into
// the repo. See `spa/tailwind-src/README.md` for the rebuild workflow.
const TAILWIND_DAISY_CSS: &str = include_str!("spa/tailwind.daisyui.min.css");

/// Per-file function-level X-Ray data for the SPA file-detail drawer.
/// Carries the `run_function_xray` result for one hotspot path so the
/// drawer can render an "X-Ray" tab with change-frequency and complexity
/// per function without a second round-trip to the server.
#[derive(Debug, Default, Serialize, serde::Deserialize)]
pub struct FileFunctionXray {
    /// Repo-relative path this entry covers (matches the hotspot `path`).
    pub path: String,
    /// Function-level rows sorted by `change_freq` DESC, then name ASC.
    pub rows: Vec<FunctionXrayRow>,
}

/// Composite of all per-widget data the SPA dashboard renders.
/// Each field carries the rows for one widget; widgets that opt out
/// via `skip_serializing_if` are simply absent from the payload.
/// Adding a field here + updating the JSON consumer in `widgets.js`
/// is the canonical extension point for a new widget.
#[derive(Debug, Default, Serialize)]
pub struct SpaDashboard {
    pub hotspots: Vec<HotspotRow>,
    /// Per-file code-health rows (drill-down details + median KPI).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub code_health: Vec<CodeHealthRow>,
    /// Aggregate metric rows (KPI tile values: commits, authors, etc.).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub summary: Vec<SummaryRow>,
    /// Coupling pairs (sankey widget + per-file partner list in the
    /// detail drawer).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub coupling: Vec<CouplingRow>,
    /// Knowledge-island rows — `CodeLore`'s auto-detected ex-developer
    /// signal. Empty when no contributors have departed.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub knowledge_islands: Vec<KnowledgeIslandRow>,
    /// Entity-ownership rows feeding the knowledge-map widget (W7).
    /// Each row is one (path, author) tuple; the JS picks the primary
    /// author per path (max added `LoC`) and palette-colors the circles.
    ///
    /// This is the largest embedded field on big repos — `O(files × authors)`
    /// — so the SPA builder (`build_spa_dashboard`) caps it to the ownership
    /// rows for the top-N hotspot paths (the only paths the circle-pack
    /// colours, the drawer opens for, and the table lists). Rows for files
    /// outside that set are never rendered, so dropping them costs no
    /// on-screen data. When the cap actually drops a *displayable* (hotspot)
    /// file's ownership, [`Self::entity_ownership_cap`] carries the
    /// retained-file count so the UI can show a truncation note.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub entity_ownership: Vec<EntityOwnershipRow>,
    /// Number of top-activity files whose ownership rows were retained when
    /// the [`Self::entity_ownership`] embed was truncated to bound the HTML
    /// size. `None` when the full ownership set for every displayable file
    /// fit (the common case) — the SPA then shows no truncation note.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub entity_ownership_cap: Option<u32>,
    /// Function-level entries feeding the X-Ray sunburst widget (W8).
    /// Each row is one function with its cognitive complexity.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub xray: Vec<XRayEntry>,
    /// Per-day commit counts feeding the calendar-heatmap widget (W10).
    /// Each row is `(date YYYY-MM-DD, count)`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub daily_commits: Vec<DailyCommit>,
    /// Per-month hotspot snapshots feeding the trends widget (W9).
    /// Each row is `(month YYYY-MM-01, path, hotspot_score)`.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub trends: Vec<TrendPoint>,
    /// Repo-relative MI band counts (`high` / `moderate` / `low` / `unknown`)
    /// for the KPI tile. Derived from `hotspots[*].mi_rank` at dispatch
    /// time via [`crate::analyses::mi::MiRollup::from_hotspots`]. `None`
    /// when no hotspots are present.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mi_rollup: Option<crate::analyses::mi::MiRollup>,
    /// Density of the behavioral coupling graph in `[0, 1]` — the ratio
    /// of Fisher-significant pairs to the maximum possible pairs in the
    /// `revs >= min_revs` candidate node set. Computed via
    /// [`crate::analyses::coupling::density`] over the same node universe
    /// `run_coupling` uses. `None` when coupling analysis was skipped.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub coupling_density: Option<f64>,
    /// Per-file clone-group counts feeding the hotspot circle-pack's
    /// "Clones" colour mode. One row per path that appears in at least
    /// one clone family. Files with zero clone groups are omitted from
    /// the payload (the widget falls back to neutral grey for them).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub clones: Vec<CloneSummary>,
    /// Resolved import edges feeding the architecture force-graph
    /// widget. One row per resolved import from the imports table.
    /// Empty until the resolver covers the repo's language mix
    /// (Rust + Python + JS/TS today).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub imports: Vec<ImportEdgeRow>,
    /// Modularity violations — Fisher-significant co-change pairs with
    /// NO structural import edge. Overlaid on the architecture graph as
    /// "temporal-only" edges: the implicit/hidden coupling that an
    /// import-only graph cannot show (the structure×history fusion).
    /// Empty when every co-change pair also imports.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub modularity_violations: Vec<ModularityViolationRow>,
    /// Unstable interfaces — heavily-imported files that change often
    /// and co-change with their dependents. Highlighted as warning
    /// nodes on the architecture graph. Empty when no interface is
    /// both widely-imported and churning.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub unstable_interface: Vec<UnstableInterfaceRow>,
    /// Per-file architectural role (Core/Shared/Control/Periphery) +
    /// visibility reach + cycle/layer info, from the import-graph
    /// reachability kernel. Colours the architecture-graph nodes by
    /// role, rings cycle members, and drives the propagation-cost
    /// caption. Empty when no imports resolve.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub architecture_roles: Vec<ArchitectureRoleRow>,
    /// Architecture-decay trend — structural-health metrics (propagation
    /// cost, dependency-cycle count) recomputed at sampled historical
    /// revisions. Drives the "Architecture trend" line chart so the SPA
    /// shows whether the architecture is decaying over time, not just its
    /// HEAD state. Empty when the historical scan is skipped or no
    /// imports resolve at any sampled rev.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub architecture_trend: Vec<ArchitectureTrendRow>,
    /// Repo health timeline — three health scores (architectural, code,
    /// and combined, each 0–100, higher = healthier) recomputed at
    /// evenly-spaced historical revisions. Drives the "Repo Health
    /// Timeline" line chart so the SPA shows whether overall health is
    /// improving or decaying over time. Empty when the historical scan
    /// is skipped or the repo has no commits.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub health_trend: Vec<crate::analyses::health_trend::HealthTrendRow>,
    /// Per-file health score series for the top-50 hotspot paths across
    /// the same sampled historical revisions as `health_trend`. Drives the
    /// per-file health sparkline in the detail drawer. Empty when no
    /// hotspot data is available or the historical scan is skipped.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub file_health_series: Vec<crate::analyses::health_trend::FileHealthPoint>,
    /// Signal-bearing band transitions (regressions and improvements) across
    /// all paths at all sampled revisions. Drives the improvements feed card.
    /// Newest-first. Empty when there are no signal-bearing transitions.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub health_transitions: Vec<crate::analyses::health_trend::HealthTransitionRow>,
    /// Effort-exposure rows — LOC share, commit share, and churn share per
    /// code-health band (red / yellow / green) in the trailing window.
    /// Drives the stacked share bars and effort dot strip in the Code Health
    /// section. Empty when code-health data is unavailable (e.g. no
    /// `complexity_metrics` at HEAD).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub effort_exposure: Vec<EffortExposureRow>,
    /// Four-factor dashboard header tiles: Code, Architecture, Knowledge,
    /// Delivery. Each carries a headline 0–100, a historical series for the
    /// sparkline, and an XmR-gated attention flag. Empty when no factor data
    /// is available (e.g. first run with no health-trend history).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub factors: Vec<crate::analyses::factors::FactorTile>,
    /// Per-commit Kamei JIT-SDP feature vector for the Delivery Risk
    /// Sparkline widget. One row per commit in the last-N (capped at
    /// 30) chronological window. Surfaces the raw Kamei 14-feature
    /// signal — la/ld (size), nf (spread), ndev (concurrency), exp
    /// (author experience), entropy (file distribution), fix (bug-
    /// fix-ness) — so the SPA can compute a composite risk score per
    /// dimension and explain *which* dimension dominates each
    /// commit's risk. Beyond-CodeScene differentiator (`CodeScene`
    /// reports an opaque score; `CodeLore` reports the peer-reviewed
    /// dimensions).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub kamei_risk: Vec<KameiRiskRow>,
    /// Marginal-owner risk rows — files in the yellow/red health band
    /// where the most knowledgeable active author holds a low share.
    /// Drives the risk chip in the file-detail drawer. Empty when no
    /// file meets the high/elevated thresholds.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub marginal_owner_risk: Vec<MarginalOwnerRiskRow>,
    /// Code-familiarity summary (repo-level: `familiarity_pct`,
    /// `islands_pct`, `active_authors`, `verdict`). At most one row per run.
    /// Drives the Knowledge card's familiarity bullet bars. Empty when
    /// `knowledge_shares` is unavailable (e.g. no complexity metrics at HEAD).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub code_familiarity: Vec<CodeFamiliarityRow>,
    /// Team-composition rows — one row per author (tenure bucket,
    /// active flag, commit count, files touched, onboarding weeks)
    /// plus a `__summary__` carrier row holding the bucket-share
    /// percentages. Drives the stacked bucket bar in the Knowledge
    /// card.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub team_composition: Vec<TeamCompositionRow>,
    /// Top coordination-needs rows (capped at 10, sorted by tier desc
    /// then co-change entropy desc). Each row is a file with its
    /// fragmentation, interleave, entropy, and tier. Drives the
    /// coordination table in the Knowledge card. Empty when no
    /// `knowledge_shares` data is available.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub coordination_needs: Vec<CoordinationNeedsRow>,
    /// Delivery-metrics percentile distributions — one row per metric
    /// (`batch_size_files`, `batch_size_loc`, `branch_duration_hours`,
    /// `rework_pct`, `lead_proxy_hours`). Drives the Delivery factor tile
    /// numbers and the delivery card in the SPA. Empty when
    /// `--include-merges` was not set or the repo has no merge commits.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub delivery_metrics: Vec<crate::analyses::delivery_metrics::DeliveryMetricsRow>,
    /// Release-cadence rows — one row per matched release tag plus a
    /// `__summary__` row carrying the median inter-release gap in days.
    /// Drives the cadence number in the Delivery factor tile. Empty when
    /// no tags match `--release-tag-glob` or `Repo::tags()` is unavailable.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub release_cadence: Vec<crate::analyses::release_cadence::ReleaseCadenceRow>,
    /// Delivery-friction rows — top files ranked by composite delivery
    /// friction score (churn × lead-time × cognitive). Used for the
    /// "where is friction" drill line in the delivery card. Empty when
    /// the analysis was not run or produced no results.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub delivery_friction: Vec<crate::analyses::delivery_friction::DeliveryFrictionRow>,
    /// Per-file function-level X-Ray data for the top-10 hotspot paths.
    /// Each entry holds the `run_function_xray` result (change-frequency,
    /// LOC, cyclomatic complexity per function) for one hotspot path.
    /// Drives the "X-Ray" tab in the file-detail drawer. Empty on repos
    /// with no Tier-1 language source files or when ingest produces no
    /// hotspots.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub function_xray: Vec<FileFunctionXray>,
    /// Top refactoring targets ranked by return-on-investment:
    /// `(structural_risk × hotspot_score) / max(loc, floor)`. Drives the
    /// guided tour's "Refactoring targets" step, which brushes the top-N
    /// paths across every widget — a genuinely different ordering from raw
    /// hotspot score (dividing risk by inspection effort favours small,
    /// dense, churning, unhealthy files over large ones). Capped in the
    /// builder (`build_spa_dashboard`). Empty when the code-health composite
    /// is unavailable (e.g. no `complexity_metrics` at HEAD); the tour then
    /// falls back to brushing the top hotspots by score.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub refactoring_targets: Vec<RefactoringTargetRow>,
    /// Effective thresholds for THIS run, snapshotted at dispatch.
    /// Surfaced into the SPA's `data.options` block so per-metric
    /// tooltips can interpolate `${min_shared_revs}` /
    /// `${fisher_significance}` / `${min_revs}` and show the actual
    /// values used — V5: tooltips claimed to surface formula
    /// provenance but referenced parameter NAMES verbatim, hiding the
    /// effective gates. `Default` so callers (tests, step-summary)
    /// continue to use `..Default::default()` unchanged; production
    /// dispatch in `build_spa_dashboard` populates from real `Options`.
    #[serde(default)]
    pub options: SpaOptionsSnapshot,
}

/// Snapshot of the analysis-threshold subset of `Options` that drive
/// formula provenance in the SPA. Kept narrow (six numeric fields) so
/// the JSON payload doesn't balloon; the broader `Options` carries
/// path filters, AI-detection toggles, output routing flags, etc., which
/// have no place in a UI tooltip. Fields are public for `Default::default`
/// to populate them with reasonable code-maat parity values when a test
/// constructs `SpaDashboard` without going through `build_spa_dashboard`.
#[derive(Debug, Clone, Serialize)]
pub struct SpaOptionsSnapshot {
    pub min_revs: u32,
    pub min_shared_revs: u32,
    pub min_coupling_pct: u8,
    pub max_coupling_pct: u8,
    pub max_changeset_size: u32,
    pub fisher_significance: f64,
    /// Minimum health score (0–100) for the green band. Matches
    /// [`crate::bands::HEALTH_GREEN_MIN`]. Exposed so the SPA JS can
    /// read band thresholds from `data.options` rather than hardcode them.
    pub health_green_min: f64,
    /// Minimum health score (0–100) for the yellow band. Matches
    /// [`crate::bands::HEALTH_YELLOW_MIN`].
    pub health_yellow_min: f64,
    /// Trailing-window size in days used by windowed analyses (effort
    /// exposure, share bars, etc.). Exposed so the SPA JS can show
    /// "last N days" in captions without hardcoding the default.
    pub window_days: u32,
}

impl Default for SpaOptionsSnapshot {
    fn default() -> Self {
        // Mirrors `Options::default()` — code-maat parity baseline.
        // Kept in sync via `default_options_match_code_maat_thresholds`
        // in tests/types_test.rs (asserts the source-of-truth values
        // haven't drifted).
        Self {
            min_revs: 5,
            min_shared_revs: 5,
            min_coupling_pct: 30,
            max_coupling_pct: 100,
            max_changeset_size: 30,
            fisher_significance: 0.05,
            health_green_min: crate::bands::HEALTH_GREEN_MIN,
            health_yellow_min: crate::bands::HEALTH_YELLOW_MIN,
            window_days: crate::constants::DEFAULT_WINDOW_DAYS,
        }
    }
}

impl SpaOptionsSnapshot {
    /// Snapshot the threshold subset of [`crate::Options`].
    #[must_use]
    pub fn from_options(opts: &crate::Options) -> Self {
        Self {
            min_revs: opts.min_revs,
            min_shared_revs: opts.min_shared_revs,
            min_coupling_pct: opts.min_coupling_pct,
            max_coupling_pct: opts.max_coupling_pct,
            max_changeset_size: opts.max_changeset_size,
            fisher_significance: opts.fisher_significance,
            health_green_min: crate::bands::HEALTH_GREEN_MIN,
            health_yellow_min: crate::bands::HEALTH_YELLOW_MIN,
            window_days: opts.window_days,
        }
    }
}

/// Render the SPA HTML and write it to `w`. The HTML is fully
/// self-contained: opening it locally in any browser renders the
/// dashboard offline.
pub fn write_spa<W: Write>(
    dash: &SpaDashboard,
    title: &str,
    repo_path: &str,
    generated_at: &str,
    w: &mut W,
) -> Result<()> {
    let data_json = serde_json::to_string(dash)
        .map_err(|e| CodeLoreError::Output(format!("spa json serialize: {e}")))?;
    let data_json_safe = data_json.replace("</", "<\\/");

    // Single-pass templating via `output::template::substitute`. This
    // matters more here than in `output::html` because the SPA payload
    // includes the ~1.1 MB `echarts.min.js` blob plus widget glue plus
    // the per-analysis JSON data block. The chained-`.replace()` form
    // copied that multi-megabyte intermediate 7 times per emit; one
    // pass + a capacity hint cuts the allocation traffic ~7×.
    let title_escaped = escape_html(title);
    let repo_path_escaped = escape_html(repo_path);
    let generated_at_escaped = escape_html(generated_at);
    let html = crate::output::template::substitute(
        TEMPLATE,
        &[
            ("{{TITLE}}", &title_escaped),
            ("{{REPO_PATH}}", &repo_path_escaped),
            ("{{GENERATED_AT}}", &generated_at_escaped),
            ("{{DATA_JSON}}", &data_json_safe),
            ("{{ECHARTS_JS}}", ECHARTS_JS),
            ("{{D3_HIERARCHY_JS}}", D3_HIERARCHY_JS),
            ("{{ALPINE_JS}}", ALPINE_JS),
            ("{{ALPINE_PERSIST_JS}}", ALPINE_PERSIST_JS),
            ("{{TAILWIND_DAISY_CSS}}", TAILWIND_DAISY_CSS),
            ("{{WIDGETS_JS}}", WIDGETS_JS),
        ],
    );

    w.write_all(html.as_bytes())
        .map_err(|e| CodeLoreError::Output(format!("spa write: {e}")))?;
    Ok(())
}

fn escape_html(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&#39;")
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_hotspots() -> Vec<HotspotRow> {
        vec![
            HotspotRow {
                path: "src/main.rs".into(),
                revisions: 12,
                cognitive: 42.0,
                cognitive_health: 78.0,
                hotspot_score: 5.5,
                mi: Some(54.0),
                // Bottom quartile → Low band when MiRollup runs over this set.
                mi_rank: Some(0.0),
                ai_pct: None,
                hotspot_score_anchored: None,
            },
            HotspotRow {
                path: "src/lib/util.rs".into(),
                revisions: 8,
                cognitive: 28.0,
                cognitive_health: 88.0,
                hotspot_score: 2.1,
                mi: Some(82.5),
                // Top quartile → High band.
                mi_rank: Some(1.0),
                ai_pct: None,
                hotspot_score_anchored: None,
            },
        ]
    }

    #[test]
    fn write_spa_embeds_all_expected_markers() {
        let dash = SpaDashboard {
            hotspots: sample_hotspots(),
            ..SpaDashboard::default()
        };
        let mut buf = Vec::new();
        write_spa(
            &dash,
            "CodeLore Dashboard",
            "/tmp/example-repo",
            "2026-06-11 00:00:00 UTC",
            &mut buf,
        )
        .expect("write_spa");
        let html = String::from_utf8(buf).expect("utf8");

        assert!(
            html.contains("CodeLore Dashboard"),
            "title missing from output",
        );
        assert!(
            html.contains("/tmp/example-repo"),
            "repo path missing from output",
        );
        assert!(
            html.contains("widget-hotspot-circle-pack"),
            "hotspot circle-pack widget mount point missing",
        );
        assert!(
            html.contains("widget-hotspot-table"),
            "hotspot table widget mount point missing",
        );
        assert!(
            html.contains("widget-kpi-tiles"),
            "KPI tiles widget mount point missing",
        );
        assert!(
            html.contains("widget-knowledge-islands"),
            "knowledge islands widget mount point missing",
        );
        assert!(
            html.contains("widget-coupling-sankey"),
            "change-coupling sankey widget mount point missing",
        );
        assert!(
            html.contains("file-detail-drawer"),
            "file detail drawer mount point missing",
        );
        assert!(
            html.contains("src/main.rs"),
            "embedded hotspot row missing from JSON block",
        );
        // ECharts global must be in the embedded JS payload.
        assert!(html.contains("echarts"), "ECharts payload missing");
        // d3-hierarchy global must also be there.
        assert!(html.contains("d3"), "d3-hierarchy payload missing");
        // The widget render closure must be present.
        assert!(
            html.contains("renderHotspotCirclePack"),
            "widget render fn missing",
        );
    }

    #[test]
    fn write_spa_escapes_xss_in_metadata() {
        let dash = SpaDashboard::default();
        let mut buf = Vec::new();
        write_spa(
            &dash,
            "<script>alert(1)</script>",
            "</title><script>alert(2)</script>",
            "2026-06-11",
            &mut buf,
        )
        .expect("write_spa");
        let html = String::from_utf8(buf).expect("utf8");

        // The literal injection strings must NOT appear unescaped.
        assert!(
            !html.contains("<script>alert(1)</script>"),
            "title injection survived: HTML escape broken",
        );
        assert!(
            !html.contains("<script>alert(2)</script>"),
            "repo-path injection survived: HTML escape broken",
        );
        // Their escaped forms SHOULD appear.
        assert!(
            html.contains("&lt;script&gt;alert(1)&lt;/script&gt;"),
            "expected escaped title",
        );
    }

    #[test]
    fn write_spa_escapes_script_terminator_in_json() {
        let mut rows = sample_hotspots();
        // Cram a script-terminator into a row's path.
        rows[0].path = "src/</script><script>alert('xss')</script>.rs".into();
        let dash = SpaDashboard {
            hotspots: rows,
            ..SpaDashboard::default()
        };

        let mut buf = Vec::new();
        write_spa(&dash, "x", "y", "z", &mut buf).expect("write_spa");
        let html = String::from_utf8(buf).expect("utf8");

        // The raw </script> in JSON would break out of the script block.
        // After the `</` → `<\/` rewrite, it must be the escaped form
        // inside the JSON payload.
        assert!(
            html.contains(r"<\/script>"),
            "expected escaped script terminator inside JSON block",
        );
        // The unescaped form COULD legitimately appear in the template
        // (the `</script>` that closes the embedded data block, for
        // instance). What's NOT allowed is the unescaped form INSIDE
        // the JSON data block. We check by ensuring at least one
        // escaped occurrence exists for every injection-style attempt.
    }
}