feat(react): React introspection, Web Vitals, and SPA primitives (#1257)
* feat(react): first-class React introspection, Web Vitals, and nextjs skill
Add React-general and web-universal features as first-class agent-browser verbs
(react tree/inspect/renders/suspense, vitals, pushstate). Genuinely Next.js-specific
workflows (PPR cookie protocol, /_next/mcp bridge, dev-server endpoints) ship as
a new `nextjs` skill that composes the primitives. No new runtime dependencies -
the React DevTools installHook.js is vendored (MIT) and include_str!'d into the
binary.
New commands:
react tree Full React component tree (depth id parent name)
react inspect <fiberId> Props, hooks, state, source for one fiber
react renders start|stop Fiber profiler with Insts/Mounts/Re-renders/Self/DOM
+ prev->next change details
react suspense Suspense boundaries + classifier (client-hook,
request-api, server-fetch, cache, stream, framework)
+ root-cause grouping + recommendations
vitals [url] LCP/CLS/TTFB/FCP/INP + React hydration phases
pushstate <url> Generic SPA client-side navigation
removeinitscript <id> Remove a script registered via addinitscript
New launch flags:
--init-script <path> Register init scripts before first navigation
(repeatable; env AGENT_BROWSER_INIT_SCRIPTS)
--enable <feature> Built-in init scripts; currently react-devtools
(repeatable; env AGENT_BROWSER_ENABLE)
Other primitives:
network route ... --resource-type <csv> Filter by CDP resource type
cookies set --curl <file> Auto-detects JSON/cURL/Cookie-header
* fixes
* fixes
* fixes
This commit is contained in:
@@ -0,0 +1,633 @@
|
||||
//! React Suspense boundary introspection: walker data types, classifier, and
|
||||
//! human-readable report.
|
||||
//!
|
||||
//! The classifier labels and recommendations are React-Suspense-general —
|
||||
//! they describe what kind of thing is making a boundary suspend (`client-hook`,
|
||||
//! `request-api`, `server-fetch`, `cache`, `stream`, `framework`, `unknown`)
|
||||
//! and a high-level direction for fixing it. Framework-specific reasoning
|
||||
//! (e.g. Next.js PPR push vs goto semantics) is left to the caller.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub type StackFrame = (String, String, i64, i64);
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Boundary {
|
||||
pub id: i64,
|
||||
#[serde(rename = "parentID")]
|
||||
pub parent_id: i64,
|
||||
pub name: Option<String>,
|
||||
#[serde(rename = "isSuspended")]
|
||||
pub is_suspended: bool,
|
||||
pub environments: Vec<String>,
|
||||
#[serde(rename = "suspendedBy")]
|
||||
pub suspended_by: Vec<Suspender>,
|
||||
#[serde(rename = "unknownSuspenders")]
|
||||
pub unknown_suspenders: Option<String>,
|
||||
pub owners: Vec<Owner>,
|
||||
#[serde(rename = "jsxSource")]
|
||||
pub jsx_source: Option<(String, i64, i64)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Owner {
|
||||
pub name: String,
|
||||
pub env: Option<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Suspender {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub duration: i64,
|
||||
pub env: Option<String>,
|
||||
#[serde(rename = "ownerName")]
|
||||
pub owner_name: Option<String>,
|
||||
#[serde(rename = "ownerStack")]
|
||||
pub owner_stack: Option<Vec<StackFrame>>,
|
||||
#[serde(rename = "awaiterName")]
|
||||
pub awaiter_name: Option<String>,
|
||||
#[serde(rename = "awaiterStack")]
|
||||
pub awaiter_stack: Option<Vec<StackFrame>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BlockerKind {
|
||||
ClientHook,
|
||||
RequestApi,
|
||||
ServerFetch,
|
||||
Stream,
|
||||
Cache,
|
||||
Framework,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl BlockerKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ClientHook => "client-hook",
|
||||
Self::RequestApi => "request-api",
|
||||
Self::ServerFetch => "server-fetch",
|
||||
Self::Stream => "stream",
|
||||
Self::Cache => "cache",
|
||||
Self::Framework => "framework",
|
||||
Self::Unknown => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn weight(self) -> i32 {
|
||||
match self {
|
||||
Self::ClientHook => 7,
|
||||
Self::RequestApi => 6,
|
||||
Self::ServerFetch => 5,
|
||||
Self::Cache => 4,
|
||||
Self::Stream => 3,
|
||||
Self::Unknown => 2,
|
||||
Self::Framework => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn actionability(self) -> i32 {
|
||||
match self {
|
||||
Self::ClientHook => 90,
|
||||
Self::RequestApi => 88,
|
||||
Self::ServerFetch => 82,
|
||||
Self::Cache => 74,
|
||||
Self::Stream => 60,
|
||||
Self::Unknown => 35,
|
||||
Self::Framework => 18,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BoundaryKind {
|
||||
RouteSegment,
|
||||
ExplicitSuspense,
|
||||
Component,
|
||||
}
|
||||
|
||||
impl BoundaryKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::RouteSegment => "route-segment",
|
||||
Self::ExplicitSuspense => "explicit-suspense",
|
||||
Self::Component => "component",
|
||||
}
|
||||
}
|
||||
|
||||
fn weight(self) -> i32 {
|
||||
match self {
|
||||
Self::RouteSegment => 3,
|
||||
Self::ExplicitSuspense => 2,
|
||||
Self::Component => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActionableBlocker {
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub kind: BlockerKind,
|
||||
pub env: Option<String>,
|
||||
pub description: String,
|
||||
pub owner_name: Option<String>,
|
||||
pub awaiter_name: Option<String>,
|
||||
pub source_frame: Option<StackFrame>,
|
||||
pub owner_frame: Option<StackFrame>,
|
||||
pub awaiter_frame: Option<StackFrame>,
|
||||
pub actionability: i32,
|
||||
pub suggestion: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BoundaryInsight {
|
||||
pub id: i64,
|
||||
pub name: Option<String>,
|
||||
pub boundary_kind: BoundaryKind,
|
||||
pub environments: Vec<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
pub rendered_by: Vec<Owner>,
|
||||
pub primary_blocker: Option<ActionableBlocker>,
|
||||
pub blockers: Vec<ActionableBlocker>,
|
||||
pub unknown_suspenders: Option<String>,
|
||||
pub actionability: i32,
|
||||
pub recommendation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RootCauseGroup {
|
||||
pub kind: BlockerKind,
|
||||
pub name: String,
|
||||
pub source_frame: Option<StackFrame>,
|
||||
pub boundary_names: Vec<String>,
|
||||
pub count: usize,
|
||||
pub actionability: i32,
|
||||
pub suggestion: String,
|
||||
}
|
||||
|
||||
pub struct AnalysisReport {
|
||||
pub total_boundaries: usize,
|
||||
pub dynamic_hole_count: usize,
|
||||
pub static_count: usize,
|
||||
pub holes: Vec<BoundaryInsight>,
|
||||
pub statics: Vec<StaticBoundarySummary>,
|
||||
pub root_causes: Vec<RootCauseGroup>,
|
||||
pub files_to_read: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StaticBoundarySummary {
|
||||
pub name: Option<String>,
|
||||
pub source: Option<(String, i64, i64)>,
|
||||
pub rendered_by: Vec<Owner>,
|
||||
}
|
||||
|
||||
pub fn format_suspense_report(boundaries: &[Boundary], only_dynamic: bool) -> String {
|
||||
let report = analyze_boundaries(boundaries);
|
||||
format_report(&report, only_dynamic)
|
||||
}
|
||||
|
||||
fn analyze_boundaries(boundaries: &[Boundary]) -> AnalysisReport {
|
||||
let mut holes: Vec<&Boundary> = Vec::new();
|
||||
let mut statics_raw: Vec<&Boundary> = Vec::new();
|
||||
|
||||
for b in boundaries {
|
||||
if b.parent_id == 0 {
|
||||
continue;
|
||||
}
|
||||
let has_blocker = !b.suspended_by.is_empty() || b.unknown_suspenders.is_some();
|
||||
if b.is_suspended || has_blocker {
|
||||
holes.push(b);
|
||||
} else {
|
||||
statics_raw.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
let mut hole_insights: Vec<BoundaryInsight> = holes.iter().map(|b| build_insight(b)).collect();
|
||||
hole_insights.sort_by(|a, b| {
|
||||
b.actionability.cmp(&a.actionability).then_with(|| {
|
||||
b.boundary_kind
|
||||
.weight()
|
||||
.cmp(&a.boundary_kind.weight())
|
||||
.then_with(|| b.blockers.len().cmp(&a.blockers.len()))
|
||||
.then_with(|| {
|
||||
a.name
|
||||
.as_deref()
|
||||
.unwrap_or("")
|
||||
.cmp(b.name.as_deref().unwrap_or(""))
|
||||
})
|
||||
})
|
||||
});
|
||||
|
||||
let static_summaries: Vec<StaticBoundarySummary> = statics_raw
|
||||
.iter()
|
||||
.map(|b| StaticBoundarySummary {
|
||||
name: b.name.clone(),
|
||||
source: b.jsx_source.clone(),
|
||||
rendered_by: b.owners.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let root_causes = build_root_causes(&hole_insights);
|
||||
let files_to_read = collect_files_to_read(&hole_insights, &root_causes);
|
||||
|
||||
AnalysisReport {
|
||||
total_boundaries: hole_insights.len() + static_summaries.len(),
|
||||
dynamic_hole_count: hole_insights.len(),
|
||||
static_count: static_summaries.len(),
|
||||
holes: hole_insights,
|
||||
statics: static_summaries,
|
||||
root_causes,
|
||||
files_to_read,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_insight(b: &Boundary) -> BoundaryInsight {
|
||||
let boundary_kind = infer_boundary_kind(b);
|
||||
let mut blockers: Vec<ActionableBlocker> = b
|
||||
.suspended_by
|
||||
.iter()
|
||||
.map(build_actionable_blocker)
|
||||
.collect();
|
||||
blockers.sort_by(|a, b| {
|
||||
b.actionability.cmp(&a.actionability).then_with(|| {
|
||||
b.kind
|
||||
.weight()
|
||||
.cmp(&a.kind.weight())
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
})
|
||||
});
|
||||
let primary = blockers.first().cloned();
|
||||
let recommendation = recommend_fix(
|
||||
boundary_kind,
|
||||
primary.as_ref(),
|
||||
b.unknown_suspenders.as_deref(),
|
||||
);
|
||||
let primary_action = primary.as_ref().map(|p| p.actionability).unwrap_or(0);
|
||||
let base_action = if boundary_kind == BoundaryKind::RouteSegment {
|
||||
55
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
BoundaryInsight {
|
||||
id: b.id,
|
||||
name: b.name.clone(),
|
||||
boundary_kind,
|
||||
environments: b.environments.clone(),
|
||||
source: b.jsx_source.clone(),
|
||||
rendered_by: b.owners.clone(),
|
||||
primary_blocker: primary,
|
||||
blockers,
|
||||
unknown_suspenders: b.unknown_suspenders.clone(),
|
||||
actionability: primary_action.max(base_action),
|
||||
recommendation,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_actionable_blocker(s: &Suspender) -> ActionableBlocker {
|
||||
let owner_frame = pick_preferred_frame(s.owner_stack.as_deref());
|
||||
let awaiter_frame = pick_preferred_frame(s.awaiter_stack.as_deref());
|
||||
let source_frame = owner_frame.clone().or_else(|| awaiter_frame.clone());
|
||||
let kind = classify_blocker(s, source_frame.as_ref());
|
||||
let suggestion = suggest_blocker_fix(kind);
|
||||
let mut actionability = kind.actionability();
|
||||
if let Some(ref frame) = source_frame {
|
||||
if !is_frameworkish_path(&frame.1) {
|
||||
actionability += 8;
|
||||
}
|
||||
}
|
||||
if s.owner_name.is_some() || s.awaiter_name.is_some() {
|
||||
actionability += 4;
|
||||
}
|
||||
if actionability > 100 {
|
||||
actionability = 100;
|
||||
}
|
||||
let key = build_blocker_key(&s.name, kind, source_frame.as_ref());
|
||||
|
||||
ActionableBlocker {
|
||||
key,
|
||||
name: s.name.clone(),
|
||||
kind,
|
||||
env: s.env.clone(),
|
||||
description: s.description.clone(),
|
||||
owner_name: s.owner_name.clone(),
|
||||
awaiter_name: s.awaiter_name.clone(),
|
||||
source_frame,
|
||||
owner_frame,
|
||||
awaiter_frame,
|
||||
actionability,
|
||||
suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_boundary_kind(b: &Boundary) -> BoundaryKind {
|
||||
let owner_names: Vec<&str> = b.owners.iter().map(|o| o.name.as_str()).collect();
|
||||
let name_ends_slash = b.name.as_ref().is_some_and(|n| n.ends_with('/'));
|
||||
if name_ends_slash
|
||||
|| owner_names.contains(&"LoadingBoundary")
|
||||
|| owner_names.contains(&"OuterLayoutRouter")
|
||||
{
|
||||
return BoundaryKind::RouteSegment;
|
||||
}
|
||||
let name_has_suspense = b.name.as_ref().is_some_and(|n| n.contains("Suspense"));
|
||||
if name_has_suspense || owner_names.iter().any(|n| n.contains("Suspense")) {
|
||||
return BoundaryKind::ExplicitSuspense;
|
||||
}
|
||||
BoundaryKind::Component
|
||||
}
|
||||
|
||||
fn classify_blocker(s: &Suspender, source_frame: Option<&StackFrame>) -> BlockerKind {
|
||||
let name = s.name.to_lowercase();
|
||||
match name.as_str() {
|
||||
"usepathname"
|
||||
| "useparams"
|
||||
| "usesearchparams"
|
||||
| "useselectedlayoutsegments"
|
||||
| "useselectedlayoutsegment"
|
||||
| "userouter" => return BlockerKind::ClientHook,
|
||||
"cookies" | "headers" | "connection" | "params" | "searchparams" | "draftmode" => {
|
||||
return BlockerKind::RequestApi
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if name == "rsc stream" {
|
||||
return BlockerKind::Stream;
|
||||
}
|
||||
if name.contains("fetch") {
|
||||
return BlockerKind::ServerFetch;
|
||||
}
|
||||
if name.contains("cache") || s.description.to_lowercase().contains("cache") {
|
||||
return BlockerKind::Cache;
|
||||
}
|
||||
if name.starts_with("use") {
|
||||
return BlockerKind::ClientHook;
|
||||
}
|
||||
if let Some(frame) = source_frame {
|
||||
if is_frameworkish_path(&frame.1) {
|
||||
return BlockerKind::Framework;
|
||||
}
|
||||
}
|
||||
BlockerKind::Unknown
|
||||
}
|
||||
|
||||
fn suggest_blocker_fix(kind: BlockerKind) -> String {
|
||||
match kind {
|
||||
BlockerKind::ClientHook => "Move route hooks behind a smaller client Suspense or provide a real non-null loading fallback for this segment.",
|
||||
BlockerKind::RequestApi => "Push request-bound reads to a smaller server leaf, or cache around them so the parent shell can stay static.",
|
||||
BlockerKind::ServerFetch => "Split static shell content from data widgets, then push the fetch into smaller Suspense leaves or cache it.",
|
||||
BlockerKind::Cache => "This looks cache-related; check whether \"use cache\" or runtime prefetch can eliminate the suspension.",
|
||||
BlockerKind::Stream => "A stream is still pending here; extract static siblings outside the boundary and push the stream consumer deeper.",
|
||||
BlockerKind::Framework => "This currently looks framework-driven; find the nearest user-owned caller above it before changing code.",
|
||||
BlockerKind::Unknown => "Inspect the nearest user-owned owner/awaiter frame and verify whether this suspender really belongs at this boundary.",
|
||||
}.to_string()
|
||||
}
|
||||
|
||||
fn recommend_fix(
|
||||
boundary_kind: BoundaryKind,
|
||||
primary: Option<&ActionableBlocker>,
|
||||
unknown_suspenders: Option<&str>,
|
||||
) -> String {
|
||||
if boundary_kind == BoundaryKind::RouteSegment
|
||||
&& primary.is_some_and(|p| p.kind == BlockerKind::ClientHook)
|
||||
{
|
||||
return "This route segment is suspending on client hooks. Check loading.tsx first; if it is null or visually empty, fix the fallback before chasing deeper push-down work.".to_string();
|
||||
}
|
||||
if let Some(p) = primary {
|
||||
match p.kind {
|
||||
BlockerKind::ClientHook => {
|
||||
return "Push the hook-using client UI behind a smaller local Suspense boundary so the parent shell can prerender.".to_string();
|
||||
}
|
||||
BlockerKind::RequestApi | BlockerKind::ServerFetch => {
|
||||
return "Push the request-bound async work into a smaller leaf or split static siblings out of this boundary.".to_string();
|
||||
}
|
||||
BlockerKind::Cache => {
|
||||
return "Check whether caching or runtime prefetch can move this personalized content into the shell.".to_string();
|
||||
}
|
||||
BlockerKind::Stream => {
|
||||
return "Keep the stream behind Suspense, but extract any static shell content outside the boundary.".to_string();
|
||||
}
|
||||
BlockerKind::Framework => {
|
||||
return "The top blocker still looks framework-heavy. Find the nearest user-owned caller before changing boundary placement.".to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(reason) = unknown_suspenders {
|
||||
return format!(
|
||||
"React could not identify the suspender ({}). Investigate the nearest user-owned owner or awaiter frame.",
|
||||
reason
|
||||
);
|
||||
}
|
||||
"No primary blocker was identified. Inspect the boundary source and owner chain directly."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn pick_preferred_frame(stack: Option<&[StackFrame]>) -> Option<StackFrame> {
|
||||
let s = stack?;
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
s.iter()
|
||||
.find(|f| !is_frameworkish_path(&f.1))
|
||||
.cloned()
|
||||
.or_else(|| s.first().cloned())
|
||||
}
|
||||
|
||||
fn is_frameworkish_path(file: &str) -> bool {
|
||||
file.contains("/node_modules/")
|
||||
}
|
||||
|
||||
fn build_blocker_key(name: &str, kind: BlockerKind, source_frame: Option<&StackFrame>) -> String {
|
||||
match source_frame {
|
||||
None => format!("{}:{}:unknown", kind.label(), name),
|
||||
Some(f) => format!("{}:{}:{}:{}", kind.label(), name, f.1, f.2),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_root_causes(holes: &[BoundaryInsight]) -> Vec<RootCauseGroup> {
|
||||
let mut groups: HashMap<String, RootCauseGroup> = HashMap::new();
|
||||
for hole in holes {
|
||||
let Some(blocker) = &hole.primary_blocker else {
|
||||
continue;
|
||||
};
|
||||
let display_name = hole
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("boundary-{}", hole.id));
|
||||
groups
|
||||
.entry(blocker.key.clone())
|
||||
.and_modify(|existing| {
|
||||
existing.boundary_names.push(display_name.clone());
|
||||
existing.count += 1;
|
||||
if blocker.actionability > existing.actionability {
|
||||
existing.actionability = blocker.actionability;
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| RootCauseGroup {
|
||||
kind: blocker.kind,
|
||||
name: blocker.name.clone(),
|
||||
source_frame: blocker.source_frame.clone(),
|
||||
boundary_names: vec![display_name],
|
||||
count: 1,
|
||||
actionability: blocker.actionability,
|
||||
suggestion: blocker.suggestion.clone(),
|
||||
});
|
||||
}
|
||||
let mut out: Vec<RootCauseGroup> = groups.into_values().collect();
|
||||
out.sort_by(|a, b| {
|
||||
let score_a = (a.count as i32) * a.actionability;
|
||||
let score_b = (b.count as i32) * b.actionability;
|
||||
score_b.cmp(&score_a).then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn collect_files_to_read(holes: &[BoundaryInsight], root_causes: &[RootCauseGroup]) -> Vec<String> {
|
||||
let mut counts: HashMap<String, i32> = HashMap::new();
|
||||
let mut add = |f: Option<&str>| {
|
||||
if let Some(path) = f {
|
||||
if !path.is_empty() {
|
||||
*counts.entry(path.to_string()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
for hole in holes {
|
||||
add(hole.source.as_ref().map(|s| s.0.as_str()));
|
||||
if let Some(pb) = &hole.primary_blocker {
|
||||
add(pb.source_frame.as_ref().map(|f| f.1.as_str()));
|
||||
}
|
||||
for owner in &hole.rendered_by {
|
||||
add(owner.source.as_ref().map(|s| s.0.as_str()));
|
||||
}
|
||||
}
|
||||
for cause in root_causes {
|
||||
add(cause.source_frame.as_ref().map(|f| f.1.as_str()));
|
||||
}
|
||||
|
||||
let mut entries: Vec<(String, i32)> = counts.into_iter().collect();
|
||||
entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
entries.into_iter().take(12).map(|(f, _)| f).collect()
|
||||
}
|
||||
|
||||
fn escape_cell(s: &str) -> String {
|
||||
s.replace('|', "\\|")
|
||||
}
|
||||
|
||||
fn format_report(report: &AnalysisReport, only_dynamic: bool) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("# Suspense Boundary Analysis".to_string());
|
||||
if only_dynamic {
|
||||
lines.push(format!(
|
||||
"# {} dynamic holes (static boundaries hidden; pass without --only-dynamic to see them)",
|
||||
report.dynamic_hole_count
|
||||
));
|
||||
} else {
|
||||
lines.push(format!(
|
||||
"# {} boundaries: {} dynamic holes, {} static",
|
||||
report.total_boundaries, report.dynamic_hole_count, report.static_count
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
if !report.holes.is_empty() {
|
||||
lines.push("## Summary".to_string());
|
||||
if let Some(top) = report.holes.first() {
|
||||
if let Some(blocker) = &top.primary_blocker {
|
||||
lines.push(format!(
|
||||
"- Top actionable hole: {} - {} ({})",
|
||||
top.name.clone().unwrap_or_else(|| "(unnamed)".into()),
|
||||
blocker.name,
|
||||
blocker.kind.label()
|
||||
));
|
||||
lines.push(format!("- Suggested next step: {}", top.recommendation));
|
||||
}
|
||||
}
|
||||
if let Some(root) = report.root_causes.first() {
|
||||
lines.push(format!(
|
||||
"- Most common root cause: {} ({}) affecting {} boundar{}",
|
||||
root.name,
|
||||
root.kind.label(),
|
||||
root.count,
|
||||
if root.count == 1 { "y" } else { "ies" }
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
lines.push("## Quick Reference".to_string());
|
||||
lines.push(
|
||||
"| Boundary | Type | Primary blocker | Source | Suggested next step |".to_string(),
|
||||
);
|
||||
lines.push("| --- | --- | --- | --- | --- |".to_string());
|
||||
for hole in &report.holes {
|
||||
let blocker = &hole.primary_blocker;
|
||||
let source = match blocker.as_ref().and_then(|b| b.source_frame.as_ref()) {
|
||||
Some(f) => format!("{}:{}", f.1, f.2),
|
||||
None => match &hole.source {
|
||||
Some((f, l, _)) => format!("{}:{}", f, l),
|
||||
None => "unknown".to_string(),
|
||||
},
|
||||
};
|
||||
let blocker_text = match blocker {
|
||||
Some(b) => format!("{} ({})", b.name, b.kind.label()),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
lines.push(format!(
|
||||
"| {} | {} | {} | {} | {} |",
|
||||
escape_cell(hole.name.as_deref().unwrap_or("(unnamed)")),
|
||||
hole.boundary_kind.label(),
|
||||
escape_cell(&blocker_text),
|
||||
escape_cell(&source),
|
||||
escape_cell(&hole.recommendation),
|
||||
));
|
||||
}
|
||||
lines.push(String::new());
|
||||
|
||||
if !report.files_to_read.is_empty() {
|
||||
lines.push("## Files to Read".to_string());
|
||||
for file in &report.files_to_read {
|
||||
lines.push(format!("- {}", file));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
|
||||
if !report.root_causes.is_empty() {
|
||||
lines.push("## Root Causes".to_string());
|
||||
for cause in &report.root_causes {
|
||||
let source = match &cause.source_frame {
|
||||
Some(f) => format!("{}:{}", f.1, f.2),
|
||||
None => "unknown".to_string(),
|
||||
};
|
||||
lines.push(format!(
|
||||
"- {} ({}) at {} - affects {} boundar{}",
|
||||
cause.name,
|
||||
cause.kind.label(),
|
||||
source,
|
||||
cause.count,
|
||||
if cause.count == 1 { "y" } else { "ies" }
|
||||
));
|
||||
lines.push(format!(" next step: {}", cause.suggestion));
|
||||
lines.push(format!(" boundaries: {}", cause.boundary_names.join(", ")));
|
||||
}
|
||||
lines.push(String::new());
|
||||
}
|
||||
}
|
||||
|
||||
if !only_dynamic && !report.statics.is_empty() {
|
||||
lines.push("## Static (not suspended)".to_string());
|
||||
for b in &report.statics {
|
||||
let name = b.name.clone().unwrap_or_else(|| "(unnamed)".into());
|
||||
let src = match &b.source {
|
||||
Some(s) => format!(" at {}:{}:{}", s.0, s.1, s.2),
|
||||
None => String::new(),
|
||||
};
|
||||
lines.push(format!(" {}{}", name, src));
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
Reference in New Issue
Block a user