use super::parsers::cargo_test::extract_failed_test_names;
use super::signal::BreakingKind;
use super::{
DashboardContext, breaking_change_breakdown, build_merge_decision_view, build_review_caveats,
cargo_audit_review_caveats,
};
use crate::checks::{CheckResult, CheckStatus};
use crate::config::Config;
use crate::git::{Diff, FileStatus, ResolvedRef};
use anyhow::Result;
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use super::signal::{
ArtifactCounters, HOTSPOT_THRESHOLD, compute_risk_heatmap, detect_orphaned_resource_delete,
find_per_file_patch, read_disk_artifact_counters,
};
const GENERATED_PATH_PREFIXES: &[&str] = &[
"target/",
"dist/",
"build/",
"coverage/",
"node_modules/",
".next/",
".nuxt/",
"__pycache__/",
".tox/",
"vendor/",
".venv/",
"venv/",
];
pub struct ReportInput<'a> {
pub dir: &'a Path,
pub config: &'a Config,
pub diffs: &'a [Diff],
pub checks: &'a [CheckResult],
pub resolved_target: &'a ResolvedRef,
pub resolved_bases: &'a [ResolvedRef],
pub ctx: &'a DashboardContext,
pub run_started_at: &'a str,
pub heuristics: Option<&'a crate::heuristics::HeuristicsResult>,
pub regression: Option<&'a crate::regression::RegressionReport>,
}
pub fn generate(input: &ReportInput<'_>) -> Result<()> {
let report = build_report(input);
let json = serde_json::to_string_pretty(&report)?;
std::fs::write(input.dir.join("report.json"), json)?;
Ok(())
}
#[derive(Serialize)]
struct Report {
schema_version: &'static str,
meta: Meta,
gate: Gate,
checks: Vec<CheckEntry>,
#[serde(skip_serializing_if = "Vec::is_empty")]
checks_skipped: Vec<SkippedCheckEntry>,
diff: DiffSection,
quality: Quality,
#[serde(skip_serializing_if = "Vec::is_empty")]
ownership: Vec<OwnershipMapEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
regression: Option<crate::regression::RegressionReport>,
artifacts: Artifacts,
#[serde(skip_serializing_if = "Option::is_none")]
ui_hints: Option<UiHints>,
}
#[derive(Serialize)]
struct OwnershipMapEntry {
path: String,
owner: String,
}
#[derive(Serialize)]
struct SkippedCheckEntry {
id: String,
name: String,
reason: String,
}
#[derive(Serialize)]
struct Meta {
generated_at: String,
tool: ToolMeta,
#[serde(skip_serializing_if = "Option::is_none")]
repo: Option<RepoMeta>,
#[serde(skip_serializing_if = "Option::is_none")]
pr: Option<PrMeta>,
range: RangeMeta,
}
#[derive(Serialize)]
struct ToolMeta {
name: &'static str,
version: String,
}
#[derive(Serialize)]
struct RepoMeta {
full_name: String,
}
#[derive(Serialize)]
struct PrMeta {
number: u64,
#[serde(skip_serializing_if = "Option::is_none")]
url: Option<String>,
}
#[derive(Serialize)]
struct RangeMeta {
#[serde(skip_serializing_if = "Option::is_none")]
base: Option<String>,
head: String,
#[serde(skip_serializing_if = "Option::is_none")]
merge_base: Option<String>,
}
#[derive(Serialize)]
struct Gate {
verdict: &'static str,
analysis_status: crate::policy::engine::AnalysisStatus,
merge_recommendation: crate::policy::engine::MergeRecommendation,
allow_merge: bool,
policy_allow_merge: bool,
quality_pass: bool,
recommended_merge: bool,
recommended_label: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
quality_failures: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
introduced_quality_failures: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
preexisting_quality_failures: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
mixed_quality_failures: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
unclassified_quality_failures: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
quality_failure_details: Vec<GateQualityFailureDetail>,
policy_mode: String,
status: &'static str,
summary: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
review_caveats: Vec<String>,
reasons: Vec<GateReason>,
source: &'static str,
}
#[derive(Serialize)]
struct GateReason {
kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
check_id: Option<String>,
blocking: bool,
message: String,
}
#[derive(Serialize)]
struct GateQualityFailureDetail {
name: String,
classification: &'static str,
}
#[derive(Serialize)]
struct CheckEntry {
id: String,
name: String,
status: &'static str,
blocking: bool,
cached: bool,
duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error_excerpt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
signatures: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
finding_stats: Option<FindingStats>,
#[serde(skip_serializing_if = "Vec::is_empty")]
failed_tests: Vec<String>,
artifacts: CheckArtifacts,
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
struct FindingStats {
total_findings: usize,
real_findings: usize,
generated_path_findings: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
generated_paths_matched: Vec<String>,
}
#[derive(Serialize)]
struct CheckArtifacts {
#[serde(skip_serializing_if = "Option::is_none")]
log_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
result_json_path: Option<String>,
}
#[derive(Serialize)]
struct DiffSection {
stats: ReportDiffStats,
full_patch_path: &'static str,
#[serde(skip_serializing_if = "Vec::is_empty")]
directories: Vec<DirEntry>,
files: Vec<FileEntry>,
}
#[derive(Serialize, Debug, Clone, PartialEq, Eq)]
struct DirEntry {
path: String,
files_changed: usize,
insertions: usize,
deletions: usize,
churn: usize,
}
#[derive(Serialize)]
struct ReportDiffStats {
files_changed: usize,
insertions: usize,
deletions: usize,
commits: usize,
renames: usize,
added: usize,
modified: usize,
deleted: usize,
copied: usize,
}
#[derive(Serialize)]
struct FileEntry {
path: String,
status: &'static str,
additions: usize,
deletions: usize,
churn: usize,
is_hotspot: bool,
#[serde(skip_serializing_if = "Option::is_none")]
patch_path: Option<String>,
}
fn aggregate_directory_entries(file_entries: &[FileEntry]) -> Vec<DirEntry> {
let mut directories: BTreeMap<String, DirEntry> = BTreeMap::new();
for file_entry in file_entries {
let dir = file_entry
.path
.rsplit_once('/')
.map(|(parent, _)| parent)
.unwrap_or(".")
.to_string();
let entry = directories.entry(dir.clone()).or_insert_with(|| DirEntry {
path: dir,
files_changed: 0,
insertions: 0,
deletions: 0,
churn: 0,
});
entry.files_changed += 1;
entry.insertions += file_entry.additions;
entry.deletions += file_entry.deletions;
entry.churn += file_entry.churn;
}
let mut entries: Vec<DirEntry> = directories.into_values().collect();
entries.sort_by(|a, b| b.churn.cmp(&a.churn).then_with(|| a.path.cmp(&b.path)));
entries
}
fn finding_stats_from_output(output: &str) -> Option<FindingStats> {
let mut total_findings = 0;
let mut generated_path_findings = 0;
let mut generated_paths_matched = BTreeSet::new();
for line in output.lines() {
let trimmed = line.trim();
if !looks_like_finding_line(trimmed) {
continue;
}
total_findings += 1;
for prefix in GENERATED_PATH_PREFIXES {
if trimmed.contains(prefix) {
generated_path_findings += 1;
generated_paths_matched.insert((*prefix).to_string());
break;
}
}
}
if total_findings == 0 {
None
} else {
Some(FindingStats {
total_findings,
real_findings: total_findings.saturating_sub(generated_path_findings),
generated_path_findings,
generated_paths_matched: {
let mut v: Vec<String> = generated_paths_matched.into_iter().collect();
v.sort();
v
},
})
}
}
fn semgrep_finding_stats_from_output(output: &str) -> Option<FindingStats> {
let start = output.find('{')?;
let parsed = serde_json::Deserializer::from_str(&output[start..])
.into_iter::<serde_json::Value>()
.next()?
.ok()?;
let results = parsed.get("results")?.as_array()?;
if results.is_empty() {
return None;
}
let mut generated_path_findings = 0;
let mut generated_paths_matched = BTreeSet::new();
for result in results {
if let Some(path) = result.get("path").and_then(|p| p.as_str()) {
for prefix in GENERATED_PATH_PREFIXES {
if path.contains(prefix) {
generated_path_findings += 1;
generated_paths_matched.insert((*prefix).to_string());
break;
}
}
}
}
let total_findings = results.len();
Some(FindingStats {
total_findings,
real_findings: total_findings.saturating_sub(generated_path_findings),
generated_path_findings,
generated_paths_matched: {
let mut v: Vec<String> = generated_paths_matched.into_iter().collect();
v.sort();
v
},
})
}
fn looks_like_finding_line(line: &str) -> bool {
if line.is_empty()
|| line.starts_with('✓')
|| line.starts_with('─')
|| line.starts_with('=')
|| line.starts_with("note:")
{
return false;
}
let lower = line.to_ascii_lowercase();
let noisy_prefixes = [
"running ",
"checking ",
"compiling ",
"finished ",
"downloading ",
"updating ",
"warning: ",
"error: could not compile ",
];
if noisy_prefixes
.iter()
.any(|prefix| lower.starts_with(prefix))
{
return false;
}
if !line.contains('/') {
return false;
}
lower.contains(" warning ")
|| lower.contains(" error ")
|| lower.contains(" failed ")
|| lower.contains(" fail ")
|| lower.contains(" panic")
|| lower.contains(" panicked")
|| has_path_with_line_number(line)
|| lower.contains("line ") && lower.contains("col ")
}
fn has_path_with_line_number(line: &str) -> bool {
line.match_indices(':').any(|(idx, _)| {
line[idx + 1..]
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_digit())
})
}
#[derive(Serialize)]
struct Quality {
breaking_changes: BreakingSection,
coverage: CoverageSection,
sarif: SarifSection,
heuristics: HeuristicsSection,
risk_heatmap: RiskHeatmapSection,
semantic_findings: SemanticSection,
consistency: ConsistencySection,
}
#[derive(Serialize)]
struct HeuristicsSection {
available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
dead_exports: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
cycles: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
twins: Option<usize>,
#[serde(rename = "unused_symbols")]
#[serde(skip_serializing_if = "Option::is_none")]
dead_parrots: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
log_path: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
analysis_root: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
regression: Option<crate::heuristics::HeuristicsRegression>,
}
#[derive(Serialize)]
struct BreakingSection {
has_breaking: bool,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
md_path: &'static str,
removed_public_symbols_count: usize,
signature_changes_count: usize,
new_env_vars_count: usize,
}
#[derive(Serialize)]
struct CoverageSection {
heuristic_ratio: f64,
matched: usize,
total: usize,
txt_path: &'static str,
changed_tests_path: &'static str,
}
#[derive(Serialize)]
struct RiskHeatmapSection {
risk_level: &'static str,
total_risk_score: u32,
#[serde(skip_serializing_if = "Vec::is_empty")]
hot_zones: Vec<RiskZoneEntry>,
}
#[derive(Serialize)]
struct RiskZoneEntry {
name: String,
files_touched: usize,
total_churn: usize,
max_file_risk: u32,
}
#[derive(Serialize)]
struct SarifSection {
findings_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
sarif_path: Option<&'static str>,
}
#[derive(Serialize)]
struct SemanticSection {
findings_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
findings: Vec<SemanticFindingEntry>,
}
#[derive(Serialize)]
struct SemanticFindingEntry {
rule_id: &'static str,
severity: &'static str,
confidence: &'static str,
title: String,
description: String,
evidence_count: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
evidence: Vec<SemanticEvidenceEntry>,
}
#[derive(Serialize)]
struct SemanticEvidenceEntry {
file: String,
#[serde(skip_serializing_if = "Option::is_none")]
line: Option<usize>,
role: &'static str,
snippet: String,
}
#[derive(Serialize)]
struct ConsistencySection {
consistent: bool,
checked_fields: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
warnings: Vec<ConsistencyWarningEntry>,
}
#[derive(Serialize)]
struct ConsistencyWarningEntry {
field: String,
message: String,
sources: Vec<ConsistencySourceEntry>,
}
#[derive(Serialize)]
struct ConsistencySourceEntry {
artifact: String,
value: String,
}
#[derive(Serialize)]
struct Artifacts {
zip_path: &'static str,
manifest_path: &'static str,
}
#[derive(Serialize)]
struct UiHints {
default_hide_globs: Vec<&'static str>,
default_sort: &'static str,
}
fn build_report(input: &ReportInput<'_>) -> Report {
let ReportInput {
config,
diffs,
checks,
resolved_target,
resolved_bases,
ctx,
run_started_at,
..
} = input;
let meta = Meta {
generated_at: run_started_at.to_string(),
tool: ToolMeta {
name: env!("CARGO_PKG_NAME"),
version: env!("CARGO_PKG_VERSION").to_string(),
},
repo: config.gh_repo.as_ref().map(|r| RepoMeta {
full_name: r.clone(),
}),
pr: config.pr_number.map(|n| PrMeta {
number: n,
url: config.pr_url.clone(),
}),
range: RangeMeta {
base: resolved_bases.first().map(|b| b.name.clone()),
head: resolved_target.name.clone(),
merge_base: resolved_bases.first().map(|b| b.commit_id.clone()),
},
};
let review_caveats = if ctx.review_caveats.is_empty() {
let mut generated = build_review_caveats(&ctx.breaking, &ctx.coverage, ctx.findings.len());
generated.extend(cargo_audit_review_caveats(input.checks));
generated
} else {
ctx.review_caveats.clone()
};
let decision = build_merge_decision_view(
ctx.policy_allow_merge,
ctx.quality_pass,
ctx.recommended_merge,
&ctx.quality_failures,
&ctx.quality_failure_details,
&ctx.blocking_issues,
review_caveats.clone(),
);
let status = if ctx.allow_merge { "ALLOW" } else { "BLOCK" };
let summary = decision.reason.clone();
let mut reasons: Vec<GateReason> = Vec::new();
for entry in &ctx.check_gates {
if entry.class == "FAIL" || entry.class == "INFO" {
let kind = if entry.class == "FAIL" {
"CHECK_FAIL"
} else {
"CHECK_WARN"
};
reasons.push(GateReason {
kind,
check_id: Some(entry.id.clone()),
blocking: entry.blocking,
message: format!("{} ({})", entry.name, entry.class),
});
}
}
for caveat in &decision.review_caveats {
reasons.push(GateReason {
kind: "REVIEW_SIGNAL",
check_id: None,
blocking: false,
message: caveat.clone(),
});
}
let gate = Gate {
verdict: ctx.verdict,
analysis_status: ctx.analysis_status,
merge_recommendation: ctx.merge_recommendation,
allow_merge: ctx.allow_merge,
policy_allow_merge: ctx.policy_allow_merge,
quality_pass: ctx.quality_pass,
recommended_merge: ctx.recommended_merge,
recommended_label: decision.state.gate_label().to_string(),
quality_failures: ctx.quality_failures.clone(),
introduced_quality_failures: ctx.introduced_quality_failures.clone(),
preexisting_quality_failures: ctx.preexisting_quality_failures.clone(),
mixed_quality_failures: ctx.mixed_quality_failures.clone(),
unclassified_quality_failures: ctx.unclassified_quality_failures.clone(),
quality_failure_details: ctx
.quality_failure_details
.iter()
.map(|detail| GateQualityFailureDetail {
name: detail.name.clone(),
classification: detail.classification.as_str(),
})
.collect(),
policy_mode: ctx.policy_mode.to_string(),
status,
summary,
review_caveats,
reasons,
source: "00_summary/MERGE_GATE.json",
};
let check_entries: Vec<CheckEntry> = checks
.iter()
.map(|c| {
let gate_entry = ctx.check_gates.iter().find(|g| g.name == c.name);
let id = gate_entry
.map(|g| g.id.clone())
.unwrap_or_else(|| c.name.to_lowercase().replace(' ', "_"));
let blocking = gate_entry.map(|g| g.blocking).unwrap_or(false);
let status_str = match c.status {
CheckStatus::Passed => "PASS",
CheckStatus::Failed => "FAIL",
CheckStatus::Error => "ERROR",
CheckStatus::Skipped => "SKIP",
CheckStatus::Warnings => "WARN",
};
let error_excerpt = if matches!(c.status, CheckStatus::Failed | CheckStatus::Error) {
c.output.lines().find(|l| !l.trim().is_empty()).map(|l| {
let end = l.floor_char_boundary(200);
l[..end].to_string()
})
} else {
None
};
let (command, cwd, signatures) = if let Some(ref prov) = c.provenance {
(
Some(prov.command.clone()),
Some(prov.cwd.clone()),
if prov.hard_fail_signatures.is_empty() {
None
} else {
Some(prov.hard_fail_signatures.clone())
},
)
} else {
(None, None, None)
};
let failed_tests = if id == "cargo_test"
&& matches!(c.status, CheckStatus::Failed | CheckStatus::Error)
{
extract_failed_test_names(&c.output)
} else {
Vec::new()
};
CheckEntry {
id: id.clone(),
name: c.name.clone(),
status: status_str,
blocking,
cached: c.cached,
duration_ms: c.duration.as_millis() as u64,
command,
cwd,
error_excerpt,
signatures,
finding_stats: if id == "semgrep_scan" {
semgrep_finding_stats_from_output(&c.output)
} else {
finding_stats_from_output(&c.output)
},
failed_tests,
artifacts: CheckArtifacts {
log_path: Some(format!("20_quality/{}.log", id)),
result_json_path: Some(format!("20_quality/{}.result.json", id)),
},
}
})
.collect();
let all_files: Vec<_> = diffs.iter().flat_map(|d| &d.files).collect();
let total_commits: usize = diffs.iter().map(|d| d.commits.len()).sum();
let total_adds: usize = all_files.iter().map(|f| f.additions).sum();
let total_dels: usize = all_files.iter().map(|f| f.deletions).sum();
let count_renames = all_files
.iter()
.filter(|f| f.status == FileStatus::Renamed)
.count();
let count_added = all_files
.iter()
.filter(|f| f.status == FileStatus::Added)
.count();
let count_modified = all_files
.iter()
.filter(|f| f.status == FileStatus::Modified)
.count();
let count_deleted = all_files
.iter()
.filter(|f| f.status == FileStatus::Deleted)
.count();
let count_copied = all_files
.iter()
.filter(|f| f.status == FileStatus::Copied)
.count();
let file_entries: Vec<FileEntry> = all_files
.iter()
.map(|f| {
let churn = f.additions + f.deletions;
let is_hotspot = churn >= HOTSPOT_THRESHOLD;
let matched_patch = find_per_file_patch(&f.path, &ctx.per_file_diff_files);
FileEntry {
path: f.path.clone(),
status: match f.status {
FileStatus::Added => "A",
FileStatus::Modified => "M",
FileStatus::Deleted => "D",
FileStatus::Renamed => "R",
FileStatus::Copied => "C",
},
additions: f.additions,
deletions: f.deletions,
churn,
is_hotspot,
patch_path: matched_patch.map(|name| format!("10_diff/per-file-diffs/{name}")),
}
})
.collect();
let diff_section = DiffSection {
stats: ReportDiffStats {
files_changed: all_files.len(),
insertions: total_adds,
deletions: total_dels,
commits: total_commits,
renames: count_renames,
added: count_added,
modified: count_modified,
deleted: count_deleted,
copied: count_copied,
},
full_patch_path: "10_diff/full.patch",
directories: aggregate_directory_entries(&file_entries),
files: file_entries,
};
let removed_symbols = ctx
.breaking
.iter()
.filter(|b| matches!(b.kind, BreakingKind::RemovedSymbol { .. }))
.count();
let new_env_vars = ctx
.breaking
.iter()
.filter(|b| matches!(b.kind, BreakingKind::NewEnvRequirement { .. }))
.count();
let signature_changes = ctx
.breaking
.iter()
.filter(|b| matches!(b.kind, BreakingKind::ChangedSignature { .. }))
.count();
let heuristics_section = match input.heuristics {
Some(h) => {
let loctree = h.loctree.as_ref();
HeuristicsSection {
available: loctree.map(|l| l.available).unwrap_or(false),
dead_exports: loctree.map(|l| l.dead_exports.len()),
cycles: loctree.map(|l| l.cycles.len()),
twins: loctree.map(|l| l.twins.exact_twins.len()),
dead_parrots: loctree.map(|l| l.twins.dead_parrots.len()),
log_path: Some("20_quality/heuristics_loctree.log"),
analysis_root: h.analysis_root.clone(),
regression: h.regression.clone(),
}
}
None => HeuristicsSection {
available: false,
dead_exports: None,
cycles: None,
twins: None,
dead_parrots: None,
log_path: None,
analysis_root: None,
regression: None,
},
};
let breaking_breakdown = breaking_change_breakdown(&ctx.breaking);
let quality = Quality {
breaking_changes: BreakingSection {
has_breaking: !ctx.breaking.is_empty(),
summary: breaking_breakdown.summary().or_else(|| {
(!ctx.breaking.is_empty())
.then(|| format!("{} breaking findings", ctx.breaking.len()))
}),
md_path: "20_quality/BREAKING_CHANGES.md",
removed_public_symbols_count: removed_symbols,
signature_changes_count: signature_changes,
new_env_vars_count: new_env_vars,
},
coverage: CoverageSection {
heuristic_ratio: if ctx.coverage.total_source > 0 {
ctx.coverage.covered_count as f64 / ctx.coverage.total_source as f64
} else {
1.0
},
matched: ctx.coverage.covered_count,
total: ctx.coverage.total_source,
txt_path: "20_quality/coverage-delta.txt",
changed_tests_path: "30_context/changed-tests.txt",
},
sarif: SarifSection {
findings_count: ctx.findings.len(),
sarif_path: if ctx.findings.is_empty() {
None
} else {
Some("30_context/INLINE_FINDINGS.sarif")
},
},
heuristics: heuristics_section,
risk_heatmap: {
let heatmap = compute_risk_heatmap(diffs, &ctx.risk_scores);
RiskHeatmapSection {
risk_level: heatmap.risk_level,
total_risk_score: heatmap.total_risk_score,
hot_zones: heatmap
.zones
.into_iter()
.map(|zone| RiskZoneEntry {
name: zone.name.to_string(),
files_touched: zone.files_touched,
total_churn: zone.total_churn,
max_file_risk: zone.max_file_risk,
})
.collect(),
}
},
semantic_findings: {
let findings = detect_orphaned_resource_delete(diffs);
SemanticSection {
findings_count: findings.len(),
findings: findings
.into_iter()
.map(|finding| SemanticFindingEntry {
rule_id: finding.rule_id,
severity: finding.severity,
confidence: finding.confidence,
title: finding.title,
description: finding.description,
evidence_count: finding.evidence.len(),
evidence: finding
.evidence
.into_iter()
.map(|item| SemanticEvidenceEntry {
file: item.file,
line: item.line,
role: item.role,
snippet: item.snippet,
})
.collect(),
})
.collect(),
}
},
consistency: {
let disk = read_disk_artifact_counters(input.dir);
let consistency = ArtifactCounters {
files_changed_diff: Some(all_files.len()),
files_changed_report: None,
findings_count_sarif: disk.findings_count_sarif,
findings_count_gate: disk.findings_count_gate,
findings_count_report: Some(ctx.findings.len()),
breaking_count_signal: None,
breaking_count_report: None,
skipped_checks_gate: None,
skipped_checks_report: None,
commit_count_diff: None,
commit_count_report: None,
coverage_pct_signal: None,
coverage_pct_report: None,
verdict_gate: disk.verdict_gate,
verdict_report: Some(ctx.verdict.to_string()),
}
.check_consistency();
ConsistencySection {
consistent: consistency.consistent,
checked_fields: consistency.checked_fields,
warnings: consistency
.warnings
.into_iter()
.map(|warning| ConsistencyWarningEntry {
field: warning.field,
message: warning.message,
sources: warning
.sources
.into_iter()
.map(|source| ConsistencySourceEntry {
artifact: source.artifact,
value: source.value,
})
.collect(),
})
.collect(),
}
},
};
let artifacts = Artifacts {
zip_path: "artifacts.zip",
manifest_path: "00_summary/MANIFEST.json",
};
let ui_hints = Some(UiHints {
default_hide_globs: vec![
"**/Cargo.lock",
"**/package-lock.json",
"**/yarn.lock",
"**/pnpm-lock.yaml",
],
default_sort: "CHURN_DESC",
});
let checks_skipped: Vec<SkippedCheckEntry> = ctx
.skipped_checks
.iter()
.map(|s| SkippedCheckEntry {
id: s.id.clone(),
name: s.name.clone(),
reason: s.reason.clone(),
})
.collect();
let ownership: Vec<OwnershipMapEntry> = ctx
.ownership_map
.iter()
.map(|(path, owner)| OwnershipMapEntry {
path: path.clone(),
owner: owner.clone(),
})
.collect();
Report {
schema_version: "1.0",
meta,
gate,
checks: check_entries,
checks_skipped,
diff: diff_section,
quality,
ownership,
regression: input.regression.cloned(),
artifacts,
ui_hints,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::artifacts::signal::{BreakingFinding, BreakingKind, BreakingRisk};
fn file_entry(path: &str, additions: usize, deletions: usize) -> FileEntry {
FileEntry {
path: path.to_string(),
status: "M",
additions,
deletions,
churn: additions + deletions,
is_hotspot: additions + deletions >= HOTSPOT_THRESHOLD,
patch_path: None,
}
}
#[test]
fn aggregates_diff_stats_per_directory_and_sorts_by_churn() {
let entries = vec![
file_entry("src/services/audio/chunker.rs", 10, 3),
file_entry("src/services/audio/pipeline.rs", 4, 2),
file_entry("src/ui/mod.rs", 6, 1),
file_entry("Cargo.toml", 2, 0),
];
let directories = aggregate_directory_entries(&entries);
assert_eq!(
directories,
vec![
DirEntry {
path: "src/services/audio".to_string(),
files_changed: 2,
insertions: 14,
deletions: 5,
churn: 19,
},
DirEntry {
path: "src/ui".to_string(),
files_changed: 1,
insertions: 6,
deletions: 1,
churn: 7,
},
DirEntry {
path: ".".to_string(),
files_changed: 1,
insertions: 2,
deletions: 0,
churn: 2,
},
]
);
}
#[test]
fn annotates_generated_path_findings_without_filtering_real_ones() {
let output = "\
src/lib.rs:10:5 warning real finding\n\
target/debug/build/foo.rs:1:1 warning generated\n\
dist/app.js:3:1 error bundled\n\
Compiling prview v0.1.0\n";
let stats = finding_stats_from_output(output).expect("expected finding stats");
assert_eq!(
stats,
FindingStats {
total_findings: 3,
real_findings: 1,
generated_path_findings: 2,
generated_paths_matched: vec!["dist/".to_string(), "target/".to_string()],
}
);
}
#[test]
fn semgrep_stats_count_results_array_not_json_noise() {
let output = r#"{"version":"1.135.0","results":[],"errors":[{"code":3,"level":"warn","type":["PartialParsing",[{"path":"app/x.rs","start":{"line":394,"col":19,"offset":0}}]],"message":"Syntax error"}]}
"#;
assert_eq!(semgrep_finding_stats_from_output(output), None);
assert!(finding_stats_from_output(output).is_some());
}
#[test]
fn semgrep_stats_count_real_results() {
let output = r#"{"results":[{"check_id":"r1","path":"src/a.rs"},{"check_id":"r2","path":"target/gen.rs"}],"errors":[]}"#;
let stats = semgrep_finding_stats_from_output(output).expect("expected stats");
assert_eq!(stats.total_findings, 2);
assert_eq!(stats.real_findings, 1);
assert_eq!(stats.generated_path_findings, 1);
}
#[test]
fn semgrep_stats_survive_trailing_stderr_after_json() {
let output = "{\"results\":[{\"check_id\":\"r1\",\"path\":\"src/a.rs\"}],\"errors\":[]}\n\
A new version of Semgrep is available. See https://semgrep.dev for details.\n";
let stats = semgrep_finding_stats_from_output(output).expect("expected stats");
assert_eq!(stats.total_findings, 1);
assert_eq!(stats.real_findings, 1);
}
#[test]
fn skips_annotation_when_output_has_no_path_like_findings() {
let output = "\
Running cargo check\n\
Finished dev [unoptimized + debuginfo] target(s) in 0.34s\n";
assert_eq!(finding_stats_from_output(output), None);
}
#[test]
fn ignores_path_lines_without_finding_signal() {
let output = "\
cwd: /Users/dev/Git/myapp\n\
log file: /tmp/eslint.log\n";
assert_eq!(finding_stats_from_output(output), None);
}
#[test]
fn report_includes_failed_tests_for_cargo_test_entries() {
use crate::artifacts::signal::CoverageDelta;
use crate::artifacts::{CheckGateEntry, DashboardContext};
use crate::checks::CheckResult;
use crate::cli::ExecutionMode;
use crate::config::test_config;
use crate::git::ResolvedRef;
use std::time::Duration;
let mut config = test_config();
config.execution_mode = ExecutionMode::Standard;
let checks = vec![CheckResult {
name: "Cargo test".to_string(),
status: CheckStatus::Failed,
duration: Duration::from_secs(1),
output: "\
thread 'tests::bad' panicked at src/lib.rs:42:5:
assertion failed
test tests::bad ... FAILED
failures:
---- tests::bad stdout ----
failures:
tests::bad
test result: FAILED. 0 passed; 1 failed
"
.to_string(),
cached: false,
provenance: None,
}];
let ctx = DashboardContext {
verdict: "BLOCK",
analysis_status: crate::policy::engine::AnalysisStatus::Complete,
merge_recommendation: crate::policy::engine::MergeRecommendation::Block,
allow_merge: false,
quality_pass: false,
policy_allow_merge: true,
recommended_merge: false,
review_caveats: vec![],
quality_failures: vec!["Cargo test".to_string()],
introduced_quality_failures: vec![],
preexisting_quality_failures: vec![],
mixed_quality_failures: vec![],
unclassified_quality_failures: vec![],
quality_failure_details: vec![],
policy_mode: "warn",
blocking_issues: vec![],
check_gates: vec![CheckGateEntry {
name: "Cargo test".to_string(),
id: "cargo_test".to_string(),
blocking: false,
class: "FAIL",
severity: "warn",
}],
breaking: vec![],
coverage: CoverageDelta {
total_source: 0,
covered_count: 0,
pct: 0,
uncovered: vec![],
covered: vec![],
non_code_count: 0,
ghost_tests: vec![],
},
findings: vec![],
per_file_diff_files: vec![],
skipped_checks: vec![],
previous_run: None,
run_history: vec![],
flaky_scores: vec![],
lint_metrics: vec![],
ownership_map: vec![],
risk_scores: vec![],
i18n_delta: None,
};
let target = ResolvedRef {
name: "feature/report".to_string(),
commit_id: "deadbeef".to_string(),
is_remote: false,
};
let base = ResolvedRef {
name: "main".to_string(),
commit_id: "cafebabe".to_string(),
is_remote: false,
};
let bases = vec![base];
let tmp = tempfile::tempdir().expect("tempdir");
let input = ReportInput {
dir: tmp.path(),
config: &config,
diffs: &[],
checks: &checks,
resolved_target: &target,
resolved_bases: &bases,
ctx: &ctx,
run_started_at: "2026-03-09T00:00:00Z",
heuristics: None,
regression: None,
};
let report = build_report(&input);
let json = serde_json::to_value(&report).expect("serialize report");
let entry = json["checks"]
.as_array()
.and_then(|checks| checks.first())
.expect("first check");
assert_eq!(
entry["failed_tests"].as_array(),
Some(&vec![serde_json::Value::String("tests::bad".to_string())])
);
}
#[test]
fn report_gate_serializes_preexisting_quality_failures() {
use crate::artifacts::signal::CoverageDelta;
use crate::artifacts::{
CheckGateEntry, DashboardContext, QualityFailureClass, QualityFailureDetail,
};
use crate::cli::ExecutionMode;
use crate::config::test_config;
use crate::git::ResolvedRef;
let mut config = test_config();
config.execution_mode = ExecutionMode::Standard;
let ctx = DashboardContext {
verdict: "PASS",
analysis_status: crate::policy::engine::AnalysisStatus::Complete,
merge_recommendation: crate::policy::engine::MergeRecommendation::Approve,
allow_merge: true,
quality_pass: true,
policy_allow_merge: true,
recommended_merge: true,
review_caveats: vec![
"Pre-existing quality failures (not from this diff): ESLint".to_string(),
],
quality_failures: vec!["ESLint".to_string()],
introduced_quality_failures: vec![],
preexisting_quality_failures: vec!["ESLint".to_string()],
mixed_quality_failures: vec![],
unclassified_quality_failures: vec![],
quality_failure_details: vec![QualityFailureDetail {
name: "ESLint".to_string(),
classification: QualityFailureClass::Preexisting,
}],
policy_mode: "warn",
blocking_issues: vec![],
check_gates: vec![CheckGateEntry {
name: "ESLint".to_string(),
id: "eslint".to_string(),
blocking: false,
class: "FAIL",
severity: "warn",
}],
breaking: vec![],
coverage: CoverageDelta {
total_source: 0,
covered_count: 0,
pct: 0,
uncovered: vec![],
covered: vec![],
non_code_count: 0,
ghost_tests: vec![],
},
findings: vec![],
per_file_diff_files: vec![],
skipped_checks: vec![],
previous_run: None,
run_history: vec![],
flaky_scores: vec![],
lint_metrics: vec![],
ownership_map: vec![],
risk_scores: vec![],
i18n_delta: None,
};
let target = ResolvedRef {
name: "feature/report".to_string(),
commit_id: "deadbeef".to_string(),
is_remote: false,
};
let base = ResolvedRef {
name: "main".to_string(),
commit_id: "cafebabe".to_string(),
is_remote: false,
};
let bases = vec![base];
let tmp = tempfile::tempdir().expect("tempdir");
let input = ReportInput {
dir: tmp.path(),
config: &config,
diffs: &[],
checks: &[],
resolved_target: &target,
resolved_bases: &bases,
ctx: &ctx,
run_started_at: "2026-03-09T00:00:00Z",
heuristics: None,
regression: None,
};
let report = build_report(&input);
let json = serde_json::to_value(&report).expect("serialize report");
assert_eq!(
json["gate"]["preexisting_quality_failures"],
serde_json::json!(["ESLint"])
);
assert_eq!(
json["gate"]["quality_failure_details"][0]["classification"].as_str(),
Some("pre-existing")
);
assert_eq!(json["gate"]["quality_pass"].as_bool(), Some(true));
assert!(
json["gate"]["summary"]
.as_str()
.is_some_and(|summary| summary.contains("1 pre-existing"))
);
}
#[test]
fn report_tracks_signature_changes_in_breaking_summary_and_counts() {
use crate::artifacts::signal::CoverageDelta;
use crate::artifacts::{CheckGateEntry, DashboardContext};
use crate::cli::ExecutionMode;
use crate::config::test_config;
use crate::git::ResolvedRef;
let mut config = test_config();
config.execution_mode = ExecutionMode::Standard;
let ctx = DashboardContext {
verdict: "BLOCK",
analysis_status: crate::policy::engine::AnalysisStatus::Complete,
merge_recommendation: crate::policy::engine::MergeRecommendation::Block,
allow_merge: false,
quality_pass: false,
policy_allow_merge: true,
recommended_merge: false,
review_caveats: vec![],
quality_failures: vec![],
introduced_quality_failures: vec![],
preexisting_quality_failures: vec![],
mixed_quality_failures: vec![],
unclassified_quality_failures: vec![],
quality_failure_details: vec![],
policy_mode: "warn",
blocking_issues: vec![],
check_gates: vec![CheckGateEntry {
name: "cargo test".to_string(),
id: "cargo_test".to_string(),
blocking: false,
class: "PASS",
severity: "warn",
}],
breaking: vec![
BreakingFinding {
file: "src/lib.rs".to_string(),
kind: BreakingKind::RemovedSymbol {
symbol_type: "function".to_string(),
},
line: "- pub fn old_api()".to_string(),
risk_level: BreakingRisk::High,
},
BreakingFinding {
file: "src/lib.rs".to_string(),
kind: BreakingKind::ChangedSignature {
before: "fn parse(input: &str)".to_string(),
after: "fn parse(input: &str, strict: bool)".to_string(),
},
line: "- fn parse(input: &str)".to_string(),
risk_level: BreakingRisk::High,
},
BreakingFinding {
file: ".env.example".to_string(),
kind: BreakingKind::NewEnvRequirement {
variable: "NEW_TOKEN".to_string(),
},
line: "+ NEW_TOKEN=".to_string(),
risk_level: BreakingRisk::Medium,
},
],
coverage: CoverageDelta {
total_source: 0,
covered_count: 0,
pct: 0,
uncovered: vec![],
covered: vec![],
non_code_count: 0,
ghost_tests: vec![],
},
findings: vec![],
per_file_diff_files: vec![],
skipped_checks: vec![],
previous_run: None,
run_history: vec![],
flaky_scores: vec![],
lint_metrics: vec![],
ownership_map: vec![],
risk_scores: vec![],
i18n_delta: None,
};
let target = ResolvedRef {
name: "feature/report".to_string(),
commit_id: "deadbeef".to_string(),
is_remote: false,
};
let base = ResolvedRef {
name: "main".to_string(),
commit_id: "cafebabe".to_string(),
is_remote: false,
};
let bases = vec![base];
let tmp = tempfile::tempdir().expect("tempdir");
let input = ReportInput {
dir: tmp.path(),
config: &config,
diffs: &[],
checks: &[],
resolved_target: &target,
resolved_bases: &bases,
ctx: &ctx,
run_started_at: "2026-03-12T00:00:00Z",
heuristics: None,
regression: None,
};
let report = build_report(&input);
let json = serde_json::to_value(&report).expect("serialize report");
let breaking = &json["quality"]["breaking_changes"];
assert_eq!(breaking["removed_public_symbols_count"].as_u64(), Some(1));
assert_eq!(breaking["signature_changes_count"].as_u64(), Some(1));
assert_eq!(breaking["new_env_vars_count"].as_u64(), Some(1));
assert_eq!(
breaking["summary"].as_str(),
Some("1 removed public symbol, 1 signature change, 1 new env requirement")
);
}
#[test]
fn report_uses_unused_symbols_key_for_heuristics_summary() {
use crate::artifacts::{CheckGateEntry, DashboardContext};
use crate::cli::ExecutionMode;
use crate::config::test_config;
use crate::git::ResolvedRef;
use crate::heuristics::{
DeadParrot, HeuristicsResult, HeuristicsSummary, LoctreeAnalysis, TwinsAnalysis,
};
let mut config = test_config();
config.execution_mode = ExecutionMode::Standard;
let ctx = DashboardContext {
verdict: "PASS",
analysis_status: crate::policy::engine::AnalysisStatus::Complete,
merge_recommendation: crate::policy::engine::MergeRecommendation::Approve,
allow_merge: true,
quality_pass: true,
policy_allow_merge: true,
recommended_merge: true,
review_caveats: vec![],
quality_failures: vec![],
introduced_quality_failures: vec![],
preexisting_quality_failures: vec![],
mixed_quality_failures: vec![],
unclassified_quality_failures: vec![],
quality_failure_details: vec![],
policy_mode: "warn",
blocking_issues: vec![],
check_gates: vec![CheckGateEntry {
name: "cargo check".to_string(),
id: "cargo_check".to_string(),
blocking: false,
class: "PASS",
severity: "warn",
}],
breaking: vec![],
coverage: crate::artifacts::signal::CoverageDelta {
total_source: 0,
covered_count: 0,
pct: 0,
uncovered: vec![],
covered: vec![],
non_code_count: 0,
ghost_tests: vec![],
},
findings: vec![],
per_file_diff_files: vec![],
skipped_checks: vec![],
previous_run: None,
run_history: vec![],
flaky_scores: vec![],
lint_metrics: vec![],
ownership_map: vec![],
risk_scores: vec![],
i18n_delta: None,
};
let heuristics = HeuristicsResult {
loctree: Some(LoctreeAnalysis {
available: true,
twins: TwinsAnalysis {
dead_parrots: vec![
DeadParrot {
file: "src/lib.rs".to_string(),
symbol: "unused_one".to_string(),
kind: "function".to_string(),
line: 10,
},
DeadParrot {
file: "src/lib.rs".to_string(),
symbol: "unused_two".to_string(),
kind: "function".to_string(),
line: 20,
},
DeadParrot {
file: "src/lib.rs".to_string(),
symbol: "unused_three".to_string(),
kind: "function".to_string(),
line: 30,
},
],
exact_twins: vec![],
total_symbols: 3,
},
..Default::default()
}),
summary: HeuristicsSummary {
dead_exports: 2,
circular_imports: 1,
dead_parrots: 3,
exact_twins: 4,
total_files: 10,
total_loc: 100,
},
..Default::default()
};
let target = ResolvedRef {
name: "feature/report".to_string(),
commit_id: "deadbeef".to_string(),
is_remote: false,
};
let base = ResolvedRef {
name: "main".to_string(),
commit_id: "cafebabe".to_string(),
is_remote: false,
};
let bases = vec![base];
let tmp = tempfile::tempdir().expect("tempdir");
let input = ReportInput {
dir: tmp.path(),
config: &config,
diffs: &[],
checks: &[],
resolved_target: &target,
resolved_bases: &bases,
ctx: &ctx,
run_started_at: "2026-03-12T00:00:00Z",
heuristics: Some(&heuristics),
regression: None,
};
let report = build_report(&input);
let json = serde_json::to_value(&report).expect("serialize report");
let heuristics_json = &json["quality"]["heuristics"];
assert_eq!(heuristics_json["unused_symbols"].as_u64(), Some(3));
assert!(heuristics_json.get("dead_parrots").is_none());
}
#[test]
fn report_includes_cargo_audit_informational_caveat_when_context_has_none() {
use crate::artifacts::{CheckGateEntry, DashboardContext};
use crate::cli::ExecutionMode;
use crate::config::test_config;
use crate::git::ResolvedRef;
use std::time::Duration;
let mut config = test_config();
config.execution_mode = ExecutionMode::Standard;
let checks = vec![CheckResult {
name: "Cargo audit".to_string(),
status: CheckStatus::Passed,
duration: Duration::from_secs(1),
output: r#"{"vulnerabilities":{"found":false,"count":0,"list":[]},"warnings":{"unmaintained":[{"kind":"unmaintained","package":{"name":"paste","version":"1.0.15"},"advisory":{"id":"RUSTSEC-2024-0436"}}]}}"#.to_string(),
cached: false,
provenance: None,
}];
let ctx = DashboardContext {
verdict: "PASS",
analysis_status: crate::policy::engine::AnalysisStatus::Complete,
merge_recommendation: crate::policy::engine::MergeRecommendation::Approve,
allow_merge: true,
quality_pass: true,
policy_allow_merge: true,
recommended_merge: true,
review_caveats: vec![],
quality_failures: vec![],
introduced_quality_failures: vec![],
preexisting_quality_failures: vec![],
mixed_quality_failures: vec![],
unclassified_quality_failures: vec![],
quality_failure_details: vec![],
policy_mode: "warn",
blocking_issues: vec![],
check_gates: vec![CheckGateEntry {
name: "cargo audit".to_string(),
id: "cargo_audit".to_string(),
blocking: false,
class: "PASS",
severity: "warn",
}],
breaking: vec![],
coverage: crate::artifacts::signal::CoverageDelta {
total_source: 0,
covered_count: 0,
pct: 0,
uncovered: vec![],
covered: vec![],
non_code_count: 0,
ghost_tests: vec![],
},
findings: vec![],
per_file_diff_files: vec![],
skipped_checks: vec![],
previous_run: None,
run_history: vec![],
flaky_scores: vec![],
lint_metrics: vec![],
ownership_map: vec![],
risk_scores: vec![],
i18n_delta: None,
};
let target = ResolvedRef {
name: "feature/report".to_string(),
commit_id: "deadbeef".to_string(),
is_remote: false,
};
let base = ResolvedRef {
name: "main".to_string(),
commit_id: "cafebabe".to_string(),
is_remote: false,
};
let bases = vec![base];
let tmp = tempfile::tempdir().expect("tempdir");
let input = ReportInput {
dir: tmp.path(),
config: &config,
diffs: &[],
checks: &checks,
resolved_target: &target,
resolved_bases: &bases,
ctx: &ctx,
run_started_at: "2026-03-12T00:00:00Z",
heuristics: None,
regression: None,
};
let report = build_report(&input);
let json = serde_json::to_value(&report).expect("serialize report");
let caveats = json["gate"]["review_caveats"]
.as_array()
.expect("review caveats");
assert!(caveats.iter().any(|value| {
value
.as_str()
.is_some_and(|text| text.contains("Cargo audit note: 1 informational advisory"))
}));
assert!(caveats.iter().any(|value| {
value
.as_str()
.is_some_and(|text| text.contains("paste (unmaintained)"))
}));
}
}