use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use monochange_analysis::AnalysisConfig;
use monochange_analysis::AnalysisSession;
use monochange_analysis::ChangeAnalysis;
use monochange_analysis::ChangeFrame;
use monochange_core::ApiConfidence;
use monochange_core::BumpSeverity;
use monochange_core::DependencyKind;
use monochange_core::DetectionLevel;
use monochange_core::EffectiveReleaseIdentity;
use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use monochange_core::PackageRecord;
use monochange_core::ReleaseOwnerKind;
use monochange_core::SemanticAnalysisCompleteness as CoreAnalysisCompleteness;
use monochange_core::SemanticAnalysisOutcome;
use monochange_core::SemanticAnalyzerCheck;
use monochange_core::SemanticAnalyzerCheckStatus;
use monochange_core::SemanticChange;
use monochange_core::SemanticChangeCategory;
use monochange_core::SemanticChangeKind;
use monochange_core::VersionFormat;
use serde::Deserialize;
use serde::Serialize;
use crate::SCHEMA_VERSION;
const DEFAULT_HEAD_REF: &str = "HEAD";
pub const SKIP_CLI_SNAPSHOTS_ENV: &str = "MONOCHANGE_SKIP_CLI_SNAPSHOTS";
pub const CLI_SURFACE_ANALYZER_ID: &str = "monochange/cli-surface";
const ANALYZER_VERSION: &str = "1";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClassificationFormat {
Text,
Markdown,
Json,
JsonMin,
}
impl ClassificationFormat {
pub fn parse(value: &str) -> Result<Self, String> {
match value {
"markdown" | "md" => Ok(Self::Markdown),
"json" => Ok(Self::Json),
"json-min" => Ok(Self::JsonMin),
"text" => Ok(Self::Text),
other => {
Err(format!(
"unsupported classification format `{other}`; expected markdown, json, json-min, or text"
))
}
}
}
pub fn render_json_value<T>(&self, value: &T, context: &str) -> Result<String, String>
where
T: Serialize,
{
match self {
Self::JsonMin => serde_json::to_string(value),
_ => serde_json::to_string_pretty(value),
}
.map_err(|error| format!("failed to render {context} as json: {error}"))
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ClassifyOptions {
pub base: Option<String>,
pub head: String,
pub release: Option<String>,
pub packages: Vec<String>,
pub detection_level: DetectionLevel,
pub include_unchanged: bool,
pub strict: bool,
pub skip_cli_snapshots: bool,
pub format: ClassificationFormat,
pub output: Option<PathBuf>,
pub labels: Vec<String>,
pub dependency_propagation: DependencyPropagation,
}
#[derive(Debug, Clone, Copy, Default, Eq, PartialEq)]
pub enum DependencyPropagation {
#[default]
None,
Public,
}
impl Default for ClassifyOptions {
fn default() -> Self {
Self {
base: None,
head: DEFAULT_HEAD_REF.to_string(),
release: None,
packages: Vec::new(),
detection_level: DetectionLevel::Signature,
include_unchanged: false,
strict: false,
skip_cli_snapshots: false,
format: ClassificationFormat::Text,
output: None,
labels: Vec::new(),
dependency_propagation: DependencyPropagation::None,
}
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComparisonKind {
PullRequest,
Release,
ReleaseToDefault,
SourceDelta,
WorkingTree,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ComparisonStatus {
Analyzed,
Unavailable,
Conflicted,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ResolvedComparison {
pub kind: ComparisonKind,
pub base: Option<String>,
pub head: String,
pub status: ComparisonStatus,
pub note: Option<String>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompatibilityImpact {
Unmodeled,
Compatible,
Additive,
Breaking,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Eq, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClassificationConfidence {
Low,
Medium,
High,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnalysisCompleteness {
Complete,
Partial,
Unsupported,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct FindingAnalyzer {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub engine: Option<String>,
pub version: String,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct FindingCoverage {
pub detection_level: DetectionLevel,
pub completeness: AnalysisCompleteness,
pub note: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub fallback_reason: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub checks: Vec<SemanticAnalyzerCheck>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ClassificationFinding {
pub id: String,
pub rule_id: String,
pub surface: String,
pub change: String,
pub impact: CompatibilityImpact,
pub bump: BumpSeverity,
pub confidence: ClassificationConfidence,
pub analyzer: FindingAnalyzer,
pub coverage: FindingCoverage,
pub before: Option<String>,
pub after: Option<String>,
pub location: PathBuf,
pub comparisons: BTreeSet<ComparisonKind>,
pub summary: String,
}
fn default_classification_enforced() -> bool {
true
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ChangeRecommendation {
pub compatibility_impact: CompatibilityImpact,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub release_impact: Option<CompatibilityImpact>,
pub pull_request_changes: bool,
pub proposed_changeset_bump: BumpSeverity,
pub enforceable_minimum: BumpSeverity,
pub release_floor: BumpSeverity,
pub confidence: ClassificationConfidence,
pub completeness: AnalysisCompleteness,
pub review_required: bool,
pub finding_ids: Vec<String>,
#[serde(default = "default_classification_enforced")]
pub classification_enforced: bool,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangesetAction {
Create,
Update,
Keep,
Review,
NoChangeset,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ExistingChangeset {
pub path: PathBuf,
pub bump: Option<BumpSeverity>,
pub change_type: Option<String>,
}
type ExistingChangesetsByPackage = BTreeMap<String, Vec<ExistingChangeset>>;
type ExistingChangesetInventory = (ExistingChangesetsByPackage, Vec<String>);
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ReleaseOwner {
pub kind: String,
pub id: String,
pub latest_release: Option<String>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct ChangeClassificationReport {
pub schema_version: String,
pub default_branch: String,
pub candidate: String,
pub comparisons: Vec<ResolvedComparison>,
pub recommendation: BumpSeverity,
pub packages: Vec<PackageClassification>,
pub warnings: Vec<String>,
pub skipped: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub matched_skip_labels: Vec<String>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct PackageClassification {
pub package_id: String,
pub package_name: String,
pub ecosystem: monochange_core::Ecosystem,
pub release_owner: Option<ReleaseOwner>,
pub comparisons: Vec<ResolvedComparison>,
pub recommendation: BumpSeverity,
pub decision: ChangeRecommendation,
pub summary: String,
pub findings: Vec<ClassificationFinding>,
pub existing_changesets: Vec<ExistingChangeset>,
pub action: ChangesetAction,
pub warnings: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cli: Option<PackageCliClassification>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct PackageCliClassification {
pub name: String,
pub status: CliSnapshotStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub recommendation: Option<BumpSeverity>,
#[serde(skip_serializing_if = "Option::is_none")]
pub finding_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub baseline: Option<String>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliSnapshotStatus {
Diffed,
MissingBaseline,
StaleBaseline,
Failed,
Skipped,
}
#[coverage(off)]
pub fn render_changeset_api_validation(
root: &Path,
options: &ClassifyOptions,
) -> MonochangeResult<String> {
let report = build_change_classification_report(root, options)?;
let mismatches = changeset_validation_mismatches(&report, options.strict);
if !mismatches.is_empty() {
return Err(MonochangeError::Config(format!(
"changeset severity does not satisfy classification:\n- {}",
mismatches.join("\n- ")
)));
}
let output = match options.format {
ClassificationFormat::Json | ClassificationFormat::JsonMin => {
options
.format
.render_json_value(&report, "changeset API validation")
.map_err(MonochangeError::Config)?
}
ClassificationFormat::Markdown => {
format!(
"# Changeset API validation\n\nPending changesets satisfy the enforceable minimum. Medium- and low-confidence findings remain advisory unless `--strict` is set.\n\n{}",
render_markdown_report(&report)
)
}
ClassificationFormat::Text => {
format!(
"Changeset API validation\n\nPending changesets satisfy the enforceable minimum. Medium- and low-confidence findings remain advisory unless --strict is set.\n\n{}",
render_text_report(&report)
)
}
};
if let Some(path) = &options.output {
std::fs::write(path, &output).map_err(|error| {
MonochangeError::Io(format!("failed to write {}: {error}", path.display()))
})?;
}
Ok(output)
}
fn changeset_validation_mismatches(
report: &ChangeClassificationReport,
strict: bool,
) -> Vec<String> {
report
.packages
.iter()
.filter(|package| package.decision.classification_enforced)
.filter_map(|package| {
let required = if strict {
package.decision.proposed_changeset_bump
} else {
package.decision.enforceable_minimum
};
if required == BumpSeverity::None {
return None;
}
let declared = package
.existing_changesets
.iter()
.filter_map(|changeset| changeset.bump)
.max()
.unwrap_or(BumpSeverity::None);
(declared < required).then(|| {
format!(
"package `{}` declares `{declared}` but requires at least `{required}`{}",
package.package_id,
if strict { " in strict mode" } else { "" }
)
})
})
.collect()
}
#[coverage(off)]
pub fn render_change_classification(
root: &Path,
options: &ClassifyOptions,
) -> MonochangeResult<String> {
let report = build_change_classification_report(root, options)?;
let output = match options.format {
ClassificationFormat::Json | ClassificationFormat::JsonMin => {
options
.format
.render_json_value(&report, "change classification")
.map_err(MonochangeError::Config)?
}
ClassificationFormat::Markdown => render_markdown_report(&report),
ClassificationFormat::Text => render_text_report(&report),
};
if let Some(path) = &options.output {
std::fs::write(path, &output).map_err(|error| {
MonochangeError::Io(format!("failed to write {}: {error}", path.display()))
})?;
}
Ok(output)
}
struct CandidateResolution {
reference: String,
display: String,
source_reference: String,
source_display: String,
includes_working_tree: bool,
status: ComparisonStatus,
note: Option<String>,
}
struct PackageEvidence<'a> {
kind: ComparisonKind,
analysis: &'a ChangeAnalysis,
}
#[derive(Debug, Clone)]
struct PublicDependencyImpact {
dependent: PackageRecord,
upstream_name: String,
}
pub fn build_change_classification_report(
root: &Path,
options: &ClassifyOptions,
) -> MonochangeResult<ChangeClassificationReport> {
let configuration = monochange_config::load_workspace_configuration(root)?;
if let Some(skipped) = skipped_classification(root, &configuration, options)? {
return Ok(skipped);
}
let default_branch = options
.base
.clone()
.map_or_else(|| resolve_default_branch_ref(root), Ok)?;
let candidate = resolve_candidate(root, &default_branch, &options.head)?;
let analysis_config = AnalysisConfig {
detection_level: options.detection_level,
..AnalysisConfig::default()
};
let analysis_session = AnalysisSession::new(root, analysis_config)?;
let pull_request = analyze_range(&analysis_session, &default_branch, &candidate.reference)?;
let source_base = resolve_merge_base(root, &default_branch, &candidate.source_reference)
.unwrap_or_else(|| default_branch.clone());
let source_delta = analyze_range(&analysis_session, &source_base, &candidate.source_reference)?;
let working_tree = working_tree_analysis(root, &options.head, &analysis_session)?;
let packages = pull_request.packages.clone();
let mut warnings = pull_request
.warnings
.iter()
.chain(source_delta.warnings.iter())
.cloned()
.collect::<Vec<_>>();
if let Some(working_tree) = &working_tree {
warnings.extend(working_tree.warnings.iter().cloned());
}
if let Some(note) = &candidate.note {
warnings.push(note.clone());
}
let mut selected_ids = selected_package_ids(root, &packages, &pull_request, options)?;
if matches!(
options.dependency_propagation,
DependencyPropagation::Public
) {
selected_ids.extend(
public_dependency_impacts(&pull_request)
.into_iter()
.map(|impact| preferred_report_package_id(&impact.dependent)),
);
}
let (existing_changesets, changeset_warnings) = existing_changesets_by_package(
root,
&configuration,
&packages,
&source_delta,
working_tree.as_ref(),
)?;
warnings.extend(changeset_warnings);
if options.packages.is_empty() {
selected_ids.extend(existing_changesets.keys().cloned());
}
let mut release_analyses = BTreeMap::<String, (ChangeAnalysis, ChangeAnalysis)>::new();
let mut package_reports = Vec::new();
for package in &packages {
let package_id = preferred_report_package_id(package);
if !selected_ids.remove(&package_id) {
continue;
}
let release_identity = pull_request
.package_analyses
.get(&package_id)
.and_then(|analysis| analysis.release_identity.clone())
.or_else(|| configuration.effective_release_identity(&package_id));
let latest_release = match &options.release {
Some(release) => Some(release.clone()),
None => {
latest_release_tag(
root,
&default_branch,
release_identity.as_ref(),
package.ecosystem.as_str(),
)?
}
};
let mut comparisons = vec![
ResolvedComparison {
kind: ComparisonKind::PullRequest,
base: Some(default_branch.clone()),
head: candidate.display.clone(),
status: candidate.status,
note: candidate.note.clone(),
},
ResolvedComparison {
kind: ComparisonKind::SourceDelta,
base: Some(source_base.clone()),
head: candidate.source_display.clone(),
status: ComparisonStatus::Analyzed,
note: candidate.includes_working_tree.then(|| {
"the source comparison includes staged, unstaged, deleted, and untracked files"
.to_string()
}),
},
];
let mut evidence = vec![
PackageEvidence {
kind: ComparisonKind::PullRequest,
analysis: &pull_request,
},
PackageEvidence {
kind: ComparisonKind::SourceDelta,
analysis: &source_delta,
},
];
if let Some(working_tree) = &working_tree {
comparisons.push(ResolvedComparison {
kind: ComparisonKind::WorkingTree,
base: Some(options.head.clone()),
head: "working tree".to_string(),
status: ComparisonStatus::Analyzed,
note: Some(
"staged, unstaged, deleted, and untracked files are included".to_string(),
),
});
evidence.push(PackageEvidence {
kind: ComparisonKind::WorkingTree,
analysis: working_tree,
});
}
if let Some(release) = &latest_release {
if !release_analyses.contains_key(release) {
let release_to_candidate =
analyze_range(&analysis_session, release, &candidate.reference)?;
let release_to_default =
analyze_range(&analysis_session, release, &default_branch)?;
release_analyses
.insert(release.clone(), (release_to_candidate, release_to_default));
}
let (release_to_candidate, release_to_default) = release_analyses
.get(release)
.expect("release analysis was inserted before lookup");
comparisons.extend([
ResolvedComparison {
kind: ComparisonKind::Release,
base: Some(release.clone()),
head: candidate.display.clone(),
status: candidate.status,
note: candidate.note.clone(),
},
ResolvedComparison {
kind: ComparisonKind::ReleaseToDefault,
base: Some(release.clone()),
head: default_branch.clone(),
status: ComparisonStatus::Analyzed,
note: None,
},
]);
evidence.extend([
PackageEvidence {
kind: ComparisonKind::Release,
analysis: release_to_candidate,
},
PackageEvidence {
kind: ComparisonKind::ReleaseToDefault,
analysis: release_to_default,
},
]);
} else {
comparisons.extend([
ResolvedComparison {
kind: ComparisonKind::Release,
base: None,
head: candidate.display.clone(),
status: ComparisonStatus::Unavailable,
note: Some("no reachable release tag matched this package".to_string()),
},
ResolvedComparison {
kind: ComparisonKind::ReleaseToDefault,
base: None,
head: default_branch.clone(),
status: ComparisonStatus::Unavailable,
note: Some("no reachable release tag matched this package".to_string()),
},
]);
}
let changed_files = pull_request_changed_files(&package_id, &pull_request);
let pull_request_in_scope = !changed_files.is_empty()
|| working_tree.as_ref().is_some_and(|analysis| {
!pull_request_changed_files(&package_id, analysis).is_empty()
});
let mut findings = collect_findings(&package_id, &evidence, options.detection_level);
let mut cli_warnings = Vec::new();
let cli_classification = collect_cli_surface_classification(
root,
&configuration,
&package_id,
&changed_files,
options,
&mut findings,
&mut cli_warnings,
);
warnings.extend(cli_warnings);
ensure_unclassified_finding(
&package_id,
package.ecosystem,
options.detection_level,
&changed_files,
&mut findings,
);
let package_changesets = existing_changesets
.get(&package_id)
.cloned()
.unwrap_or_default();
let bump_ceiling = release_identity
.as_ref()
.and_then(|identity| identity.bump_ceiling);
let classification_enforced = release_identity
.as_ref()
.is_none_or(|identity| identity.classification_enforced);
let decision = build_recommendation(
&findings,
pull_request_in_scope,
latest_release.is_some(),
bump_ceiling,
classification_enforced,
);
let action = changeset_action(&decision, &package_changesets);
let summary = recommendation_summary(&decision, &findings, package_changesets.len());
let package_warnings = package_warnings(&package_id, &evidence);
package_reports.push(PackageClassification {
package_id,
package_name: package.name.clone(),
ecosystem: package.ecosystem,
release_owner: release_owner(release_identity.as_ref(), latest_release),
comparisons,
recommendation: decision.proposed_changeset_bump,
decision,
summary,
findings,
existing_changesets: package_changesets,
action,
warnings: package_warnings,
cli: cli_classification,
});
}
if !selected_ids.is_empty() {
return Err(MonochangeError::Config(format!(
"package selection did not match a discovered package: {}",
selected_ids.into_iter().collect::<Vec<_>>().join(", ")
)));
}
if matches!(
options.dependency_propagation,
DependencyPropagation::Public
) {
let mut recommendation = package_reports
.iter()
.map(|package| package.recommendation)
.max()
.unwrap_or(BumpSeverity::None);
propagate_public_dependency_impacts(
&pull_request,
&mut package_reports,
&mut recommendation,
);
}
package_reports.sort_by(|left, right| left.package_id.cmp(&right.package_id));
let recommendation = package_reports
.iter()
.map(|package| package.recommendation)
.max()
.unwrap_or(BumpSeverity::None);
let mut comparisons = vec![
ResolvedComparison {
kind: ComparisonKind::PullRequest,
base: Some(default_branch.clone()),
head: candidate.display.clone(),
status: candidate.status,
note: candidate.note.clone(),
},
ResolvedComparison {
kind: ComparisonKind::SourceDelta,
base: Some(source_base),
head: candidate.source_display.clone(),
status: ComparisonStatus::Analyzed,
note: candidate.includes_working_tree.then(|| {
"the source comparison includes staged, unstaged, deleted, and untracked files"
.to_string()
}),
},
];
if working_tree.is_some() {
comparisons.push(ResolvedComparison {
kind: ComparisonKind::WorkingTree,
base: Some(options.head.clone()),
head: "working tree".to_string(),
status: ComparisonStatus::Analyzed,
note: Some(
"local changes are reported separately and are also materialized into the final candidate"
.to_string(),
),
});
}
warnings.sort();
warnings.dedup();
Ok(ChangeClassificationReport {
schema_version: SCHEMA_VERSION.to_string(),
default_branch,
candidate: candidate.display,
comparisons,
recommendation,
packages: package_reports,
warnings,
skipped: false,
summary: String::new(),
matched_skip_labels: Vec::new(),
})
}
fn skipped_classification(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
options: &ClassifyOptions,
) -> MonochangeResult<Option<ChangeClassificationReport>> {
let configured = &configuration.changesets.classification.skip_labels;
let matched = options
.labels
.iter()
.filter(|label| configured.iter().any(|candidate| candidate == *label))
.cloned()
.collect::<Vec<_>>();
if matched.is_empty() {
return Ok(None);
}
let default_branch = options
.base
.clone()
.map_or_else(|| resolve_default_branch_ref(root), Ok)?;
let summary = format!(
"change classification skipped because the pull request has an allowed label: {}",
matched.join(", ")
);
Ok(Some(ChangeClassificationReport {
schema_version: SCHEMA_VERSION.to_string(),
default_branch,
candidate: options.head.clone(),
comparisons: Vec::new(),
recommendation: BumpSeverity::None,
packages: Vec::new(),
warnings: vec![summary.clone()],
skipped: true,
summary,
matched_skip_labels: matched,
}))
}
pub fn classification_report(
analysis: &ChangeAnalysis,
dependency_propagation: DependencyPropagation,
) -> ChangeClassificationReport {
let mut warnings = analysis.warnings.clone();
let mut packages = Vec::new();
let mut recommendation = BumpSeverity::None;
for package in analysis.package_analyses.values() {
let evidence = [PackageEvidence {
kind: ComparisonKind::PullRequest,
analysis,
}];
let mut findings =
collect_findings(&package.package_id, &evidence, analysis.detection_level);
ensure_unclassified_finding(
&package.package_id,
package.ecosystem,
analysis.detection_level,
&package.changed_files,
&mut findings,
);
let release_identity = package.release_identity.clone();
let bump_ceiling = release_identity
.as_ref()
.and_then(|identity| identity.bump_ceiling);
let classification_enforced = release_identity
.as_ref()
.is_none_or(|identity| identity.classification_enforced);
let decision = build_recommendation(
&findings,
!package.changed_files.is_empty(),
false,
bump_ceiling,
classification_enforced,
);
let package_recommendation = decision.proposed_changeset_bump;
if package_recommendation > recommendation {
recommendation = package_recommendation;
}
let summary = recommendation_summary(&decision, &findings, 0);
warnings.extend(package.warnings.iter().cloned());
packages.push(PackageClassification {
package_id: package.package_id.clone(),
package_name: package.package_name.clone(),
ecosystem: package.ecosystem,
release_owner: None,
comparisons: Vec::new(),
recommendation: package_recommendation,
decision,
summary,
findings,
existing_changesets: Vec::new(),
action: ChangesetAction::Create,
warnings: package.warnings.clone(),
cli: None,
});
}
if matches!(dependency_propagation, DependencyPropagation::Public) {
propagate_public_dependency_impacts(analysis, &mut packages, &mut recommendation);
}
ChangeClassificationReport {
schema_version: SCHEMA_VERSION.to_string(),
default_branch: analysis.frame.base_revision().unwrap_or("HEAD").to_string(),
candidate: analysis
.frame
.head_revision()
.unwrap_or("working tree")
.to_string(),
comparisons: vec![ResolvedComparison {
kind: ComparisonKind::PullRequest,
base: analysis.frame.base_revision().map(ToString::to_string),
head: analysis
.frame
.head_revision()
.unwrap_or("working tree")
.to_string(),
status: ComparisonStatus::Analyzed,
note: None,
}],
recommendation,
packages,
warnings,
skipped: false,
summary: String::new(),
matched_skip_labels: Vec::new(),
}
}
fn analyze_range(
session: &AnalysisSession,
base: &str,
head: &str,
) -> MonochangeResult<ChangeAnalysis> {
session.analyze(&ChangeFrame::CustomRange {
base: base.to_string(),
head: head.to_string(),
})
}
fn resolve_default_branch_ref(root: &Path) -> MonochangeResult<String> {
let symbolic = run_git(root, &["rev-parse", "--abbrev-ref", "origin/HEAD"])
.ok()
.map(|value| value.trim().to_string())
.filter(|value| value != "origin/HEAD" && !value.is_empty());
if let Some(symbolic) = symbolic {
return Ok(symbolic);
}
for branch in ["origin/main", "main", "origin/master", "master"] {
if git_revision_exists(root, branch) {
return Ok(branch.to_string());
}
}
Err(MonochangeError::Discovery(
"could not determine the default branch ref".to_string(),
))
}
fn resolve_candidate(root: &Path, base: &str, head: &str) -> MonochangeResult<CandidateResolution> {
let (source_reference, source_display, includes_working_tree) = if head == DEFAULT_HEAD_REF
&& !ChangeFrame::WorkingDirectory
.changed_files(root)?
.is_empty()
{
let reference = materialize_working_tree_commit(root, head)?;
let short_reference = short_revision(&reference);
(reference, format!("working-tree:{short_reference}"), true)
} else {
(head.to_string(), head.to_string(), false)
};
let merge_tree = run_git(
root,
&["merge-tree", "--write-tree", base, &source_reference],
);
match merge_tree {
Ok(output) => {
let tree = output
.lines()
.next()
.unwrap_or(&source_reference)
.trim()
.to_string();
let short_tree = short_revision(&tree);
Ok(CandidateResolution {
reference: tree,
display: format!("merge-tree:{short_tree}"),
source_reference,
source_display,
includes_working_tree,
status: ComparisonStatus::Analyzed,
note: includes_working_tree.then(|| {
"the merge candidate includes staged, unstaged, deleted, and untracked files"
.to_string()
}),
})
}
Err(error) => {
Ok(CandidateResolution {
reference: source_reference.clone(),
display: source_display.clone(),
source_reference,
source_display,
includes_working_tree,
status: ComparisonStatus::Conflicted,
note: Some(format!(
"could not build a merge candidate for `{base}` and `{head}`; the pull request comparison falls back to the source candidate: {error}"
)),
})
}
}
}
fn materialize_working_tree_commit(root: &Path, head: &str) -> MonochangeResult<String> {
let temporary = tempfile::tempdir().map_err(|error| {
MonochangeError::Io(format!(
"failed to create a temporary index for change classification: {error}"
))
})?;
let index_path = temporary.path().join("index");
run_git_with_index(root, &index_path, &["read-tree", head])?;
run_git_with_index(root, &index_path, &["add", "--all", "--", "."])?;
let tree = run_git_with_index(root, &index_path, &["write-tree"])?;
let tree = tree.trim();
let commit = Command::new("git")
.current_dir(root)
.env("GIT_INDEX_FILE", &index_path)
.env("GIT_AUTHOR_NAME", "monochange")
.env("GIT_AUTHOR_EMAIL", "monochange@localhost")
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
.env("GIT_COMMITTER_NAME", "monochange")
.env("GIT_COMMITTER_EMAIL", "monochange@localhost")
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
.args([
"commit-tree",
tree,
"-p",
head,
"-m",
"monochange change-classification candidate",
])
.output()
.map_err(|error| {
MonochangeError::Io(format!(
"failed to materialize the working tree for change classification: {error}"
))
})?;
if !commit.status.success() {
return Err(MonochangeError::Discovery(format!(
"could not materialize the working tree for change classification: {}",
String::from_utf8_lossy(&commit.stderr).trim()
)));
}
String::from_utf8(commit.stdout)
.map(|value| value.trim().to_string())
.map_err(|error| {
MonochangeError::Discovery(format!(
"git commit-tree returned invalid utf-8 while materializing the working tree: {error}"
))
})
}
fn run_git_with_index(root: &Path, index_path: &Path, args: &[&str]) -> MonochangeResult<String> {
let output = Command::new("git")
.current_dir(root)
.env("GIT_INDEX_FILE", index_path)
.args(args)
.output()
.map_err(|error| MonochangeError::Io(format!("failed to run git {args:?}: {error}")))?;
if !output.status.success() {
return Err(MonochangeError::Discovery(format!(
"git {args:?} failed while materializing the working tree: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
String::from_utf8(output.stdout).map_err(|error| {
MonochangeError::Discovery(format!("git {args:?} returned invalid utf-8: {error}"))
})
}
fn short_revision(revision: &str) -> String {
revision.chars().take(12).collect()
}
fn resolve_merge_base(root: &Path, base: &str, head: &str) -> Option<String> {
run_git(root, &["merge-base", base, head])
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn git_revision_exists(root: &Path, revision: &str) -> bool {
Command::new("git")
.current_dir(root)
.args(["rev-parse", "--verify", revision])
.output()
.is_ok_and(|output| output.status.success())
}
fn run_git(root: &Path, args: &[&str]) -> MonochangeResult<String> {
let output = Command::new("git")
.current_dir(root)
.args(args)
.output()
.map_err(|error| MonochangeError::Io(format!("failed to run git {args:?}: {error}")))?;
if !output.status.success() {
return Err(MonochangeError::Discovery(format!(
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
)));
}
String::from_utf8(output.stdout).map_err(|error| {
MonochangeError::Discovery(format!("git {args:?} returned invalid utf-8: {error}"))
})
}
fn working_tree_analysis(
root: &Path,
head: &str,
session: &AnalysisSession,
) -> MonochangeResult<Option<ChangeAnalysis>> {
if head != DEFAULT_HEAD_REF {
return Ok(None);
}
let frame = ChangeFrame::WorkingDirectory;
if frame.changed_files(root)?.is_empty() {
return Ok(None);
}
session.analyze(&frame).map(Some)
}
fn selected_package_ids(
root: &Path,
packages: &[PackageRecord],
pull_request: &ChangeAnalysis,
options: &ClassifyOptions,
) -> MonochangeResult<BTreeSet<String>> {
if !options.packages.is_empty() {
let mut selected = BTreeSet::new();
for package_reference in &options.packages {
let record_id =
monochange_config::resolve_package_reference(package_reference, root, packages)?;
let package = packages
.iter()
.find(|package| package.id == record_id)
.expect("resolved package record should exist");
selected.insert(preferred_report_package_id(package));
}
return Ok(selected);
}
if options.include_unchanged {
return Ok(packages.iter().map(preferred_report_package_id).collect());
}
let selected = pull_request
.package_analyses
.keys()
.cloned()
.collect::<BTreeSet<_>>();
Ok(selected)
}
fn existing_changesets_by_package(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
packages: &[PackageRecord],
source_delta: &ChangeAnalysis,
working_tree: Option<&ChangeAnalysis>,
) -> MonochangeResult<ExistingChangesetInventory> {
let mut paths = source_delta
.frame
.changed_files(root)?
.into_iter()
.collect::<BTreeSet<_>>();
if let Some(working_tree) = working_tree {
paths.extend(working_tree.frame.changed_files(root)?);
}
let context = monochange_config::build_changeset_load_context(configuration, packages);
let mut changesets = BTreeMap::<String, Vec<ExistingChangeset>>::new();
let mut warnings = Vec::new();
for path in paths {
if !is_changeset_path(&path) || !root.join(&path).exists() {
continue;
}
match monochange_config::load_changeset_file_with_context(&root.join(&path), &context) {
Ok(loaded) => {
for signal in loaded.signals {
let package_id = report_package_id_for_signal(packages, signal.package_id);
changesets
.entry(package_id)
.or_default()
.push(ExistingChangeset {
path: path.clone(),
bump: signal.requested_bump,
change_type: signal.change_type,
});
}
}
Err(error) => {
warnings.push(format!(
"could not inspect pending changeset `{}`: {}",
path.display(),
error.render()
));
}
}
}
for package_changesets in changesets.values_mut() {
package_changesets.sort_by(|left, right| left.path.cmp(&right.path));
package_changesets.dedup();
}
Ok((changesets, warnings))
}
fn report_package_id_for_signal(packages: &[PackageRecord], signal_package_id: String) -> String {
packages
.iter()
.find(|package| {
package.id == signal_package_id
|| preferred_report_package_id(package) == signal_package_id
})
.map(preferred_report_package_id)
.unwrap_or(signal_package_id)
}
fn is_changeset_path(path: &Path) -> bool {
path.starts_with(".changeset")
&& path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("md"))
}
fn latest_release_tag(
root: &Path,
reachable_from: &str,
release_identity: Option<&EffectiveReleaseIdentity>,
ecosystem: &str,
) -> MonochangeResult<Option<String>> {
let Some(release_identity) = release_identity else {
return Ok(None);
};
if !release_identity.tag {
return Ok(None);
}
let output = run_git(
root,
&[
"tag",
"--merged",
reachable_from,
"--list",
"--sort=-v:refname",
],
)?; let latest = output
.lines()
.map(str::trim)
.filter_map(|tag| {
release_tag_version(
tag,
&release_identity.version_format,
&release_identity.owner_id,
ecosystem,
)
.map(|version| (version, tag.to_string()))
})
.max_by(|(left, _), (right, _)| left.cmp(right))
.map(|(_, tag)| tag);
Ok(latest)
}
fn release_tag_version(
tag: &str,
version_format: &VersionFormat,
owner_id: &str,
ecosystem: &str,
) -> Option<semver::Version> {
let mut boundaries = tag
.char_indices()
.map(|(index, _)| index)
.collect::<Vec<_>>();
boundaries.push(tag.len());
for (start_index, start) in boundaries.iter().copied().enumerate() {
if !tag[start..].starts_with(|character: char| character.is_ascii_digit()) {
continue;
}
for end in boundaries.iter().copied().skip(start_index + 1) {
let candidate = &tag[start..end];
let Ok(version) = semver::Version::parse(candidate) else {
continue;
};
if version_format
.render_tag(owner_id, candidate, ecosystem)
.is_ok_and(|rendered| rendered == tag)
{
return Some(version);
}
}
}
None
}
fn release_owner(
release_identity: Option<&EffectiveReleaseIdentity>,
latest_release: Option<String>,
) -> Option<ReleaseOwner> {
let release_identity = release_identity?;
let kind = match release_identity.owner_kind {
ReleaseOwnerKind::Group => "group",
ReleaseOwnerKind::Package => "package",
};
Some(ReleaseOwner {
kind: kind.to_string(),
id: release_identity.owner_id.clone(),
latest_release,
})
}
fn pull_request_changed_files(package_id: &str, pull_request: &ChangeAnalysis) -> Vec<PathBuf> {
pull_request
.package_analyses
.get(package_id)
.into_iter()
.flat_map(|package| package.changed_files.iter().cloned())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
fn collect_findings(
package_id: &str,
evidence: &[PackageEvidence<'_>],
detection_level: DetectionLevel,
) -> Vec<ClassificationFinding> {
let mut grouped =
BTreeMap::<String, BTreeMap<FindingEvidenceKey, PendingClassificationFinding>>::new();
for frame in evidence {
let Some(package) = frame.analysis.package_analyses.get(package_id) else {
continue;
};
let package_analyzer_id = package
.analyzer_id
.as_deref()
.unwrap_or("monochange/unknown-analyzer");
for change in &package.semantic_changes {
let analyzer_id = if change.category == SemanticChangeCategory::Package {
"monochange/package-lifecycle"
} else {
change
.assessment
.as_ref()
.map_or(package_analyzer_id, |assessment| {
assessment.evidence.analyzer_id.as_str()
})
};
let base_id = finding_id(analyzer_id, change);
let pending = grouped
.entry(base_id)
.or_default()
.entry(FindingEvidenceKey::from(change))
.or_insert_with(|| {
PendingClassificationFinding {
analyzer_id: analyzer_id.to_string(),
change: change.clone(),
comparisons: BTreeSet::new(),
}
});
pending.comparisons.insert(frame.kind);
}
}
grouped
.into_iter()
.flat_map(|(base_id, variants)| {
let has_multiple_variants = variants.len() > 1;
variants.into_iter().map(move |(evidence_key, pending)| {
let id = if has_multiple_variants {
format!("{base_id}@{:016x}", evidence_key.stable_fingerprint())
} else {
base_id.clone()
};
let mut finding = finding_from_semantic_change(
id,
&pending.analyzer_id,
&pending.change,
detection_level,
);
finding.comparisons = pending.comparisons;
finding
})
})
.collect()
}
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
struct FindingEvidenceKey {
before: Option<String>,
after: Option<String>,
location: PathBuf,
summary: String,
assessment: Option<monochange_core::SemanticChangeAssessment>,
}
impl From<&SemanticChange> for FindingEvidenceKey {
fn from(change: &SemanticChange) -> Self {
Self {
before: change.before_signature.clone(),
after: change.after_signature.clone(),
location: change.file_path.clone(),
summary: change.summary.clone(),
assessment: change.assessment.clone(),
}
}
}
impl FindingEvidenceKey {
fn stable_fingerprint(&self) -> u64 {
const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = FNV_OFFSET_BASIS;
let location = self.location.to_string_lossy();
let assessment = self.assessment.as_ref().map(|assessment| {
serde_json::to_string(assessment).unwrap_or_else(|error| {
panic!("semantic assessment serialization failed: {error}")
})
});
for value in [
self.before.as_deref(),
self.after.as_deref(),
Some(location.as_ref()),
Some(self.summary.as_str()),
assessment.as_deref(),
] {
for byte in value.unwrap_or("\0").as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(FNV_PRIME);
}
hash ^= 0xff;
hash = hash.wrapping_mul(FNV_PRIME);
}
hash
}
}
struct PendingClassificationFinding {
analyzer_id: String,
change: SemanticChange,
comparisons: BTreeSet<ComparisonKind>,
}
fn finding_id(analyzer_id: &str, change: &SemanticChange) -> String {
format!(
"{analyzer_id}/{}/{}/{}/{}",
semantic_category_name(change.category),
semantic_kind_name(change.kind),
change.item_kind,
change.item_path
)
}
fn finding_from_semantic_change(
id: String,
analyzer_id: &str,
change: &SemanticChange,
detection_level: DetectionLevel,
) -> ClassificationFinding {
let bump = monochange_semver::semantic_change_severity(change);
let assessment = change.assessment.as_ref();
let analyzer_id = assessment.map_or(analyzer_id, |assessment| {
assessment.evidence.analyzer_id.as_str()
});
let impact = assessment.map_or_else(
|| compatibility_impact(change.category, change.kind),
|assessment| compatibility_impact_from_outcome(assessment.outcome),
);
let package_lifecycle = change.category == SemanticChangeCategory::Package;
let surface = semantic_category_name(change.category).to_string();
let change_name = semantic_kind_name(change.kind).to_string();
let rule_id = format!("{analyzer_id}/{surface}/{change_name}/{}", change.item_kind);
ClassificationFinding {
id,
rule_id,
surface,
change: change_name,
impact,
bump,
confidence: assessment.map_or_else(
|| {
if package_lifecycle {
ClassificationConfidence::High
} else {
ClassificationConfidence::Medium
}
},
|assessment| classification_confidence(assessment.confidence),
),
analyzer: FindingAnalyzer {
id: analyzer_id.to_string(),
engine: assessment.map(|assessment| assessment.evidence.engine.clone()),
version: assessment
.and_then(|assessment| assessment.evidence.version.clone())
.unwrap_or_else(|| ANALYZER_VERSION.to_string()),
},
coverage: FindingCoverage {
detection_level,
completeness: assessment.map_or_else(
|| {
if package_lifecycle {
AnalysisCompleteness::Complete
} else {
AnalysisCompleteness::Partial
}
},
|assessment| analysis_completeness(assessment.evidence.completeness),
),
note: assessment.map_or_else(
|| analyzer_coverage_note(analyzer_id).to_string(),
|assessment| assessment.evidence.coverage.clone(),
),
fallback_reason: assessment
.and_then(|assessment| assessment.evidence.fallback_reason.clone()),
checks: assessment
.map_or_else(Vec::new, |assessment| assessment.evidence.checks.clone()),
},
before: change.before_signature.clone(),
after: change.after_signature.clone(),
location: change.file_path.clone(),
comparisons: BTreeSet::new(),
summary: change.summary.clone(),
}
}
fn compatibility_impact_from_outcome(outcome: SemanticAnalysisOutcome) -> CompatibilityImpact {
match outcome {
SemanticAnalysisOutcome::Compatible => CompatibilityImpact::Compatible,
SemanticAnalysisOutcome::Additive => CompatibilityImpact::Additive,
SemanticAnalysisOutcome::Breaking => CompatibilityImpact::Breaking,
_ => CompatibilityImpact::Unmodeled,
}
}
fn classification_confidence(confidence: ApiConfidence) -> ClassificationConfidence {
match confidence {
ApiConfidence::Medium => ClassificationConfidence::Medium,
ApiConfidence::High => ClassificationConfidence::High,
_ => ClassificationConfidence::Low,
}
}
fn analysis_completeness(completeness: CoreAnalysisCompleteness) -> AnalysisCompleteness {
match completeness {
CoreAnalysisCompleteness::Complete => AnalysisCompleteness::Complete,
CoreAnalysisCompleteness::Partial => AnalysisCompleteness::Partial,
_ => AnalysisCompleteness::Unsupported,
}
}
fn compatibility_impact(
category: SemanticChangeCategory,
kind: SemanticChangeKind,
) -> CompatibilityImpact {
match (category, kind) {
(
SemanticChangeCategory::Package
| SemanticChangeCategory::PublicApi
| SemanticChangeCategory::Export,
SemanticChangeKind::Removed | SemanticChangeKind::Modified,
) => CompatibilityImpact::Breaking,
(
SemanticChangeCategory::Package
| SemanticChangeCategory::PublicApi
| SemanticChangeCategory::Export,
SemanticChangeKind::Added,
) => CompatibilityImpact::Additive,
(SemanticChangeCategory::Dependency | SemanticChangeCategory::Metadata, _) => {
CompatibilityImpact::Compatible
}
_ => CompatibilityImpact::Unmodeled,
}
}
fn semantic_category_name(category: SemanticChangeCategory) -> &'static str {
match category {
SemanticChangeCategory::Package => "package",
SemanticChangeCategory::PublicApi => "public_api",
SemanticChangeCategory::Export => "export",
SemanticChangeCategory::Dependency => "dependency",
SemanticChangeCategory::Metadata => "metadata",
_ => "unknown",
}
}
fn semantic_kind_name(kind: SemanticChangeKind) -> &'static str {
match kind {
SemanticChangeKind::Added => "added",
SemanticChangeKind::Removed => "removed",
SemanticChangeKind::Modified => "modified",
_ => "unknown",
}
}
fn analyzer_coverage_note(analyzer_id: &str) -> &'static str {
if analyzer_id == "monochange/package-lifecycle" {
return "package manifest presence was compared at both endpoints";
}
if analyzer_id.starts_with("cargo/") {
return "syntax-level Rust surface; module reachability, cfg and feature matrices, trait compatibility, and downstream witnesses are not complete";
}
if analyzer_id.starts_with("npm/") || analyzer_id.starts_with("deno/") {
return "package metadata and export syntax; entrypoint reachability and TypeScript assignability are not complete";
}
if analyzer_id.starts_with("dart/") {
return "Dart declaration syntax; library reachability and analyzer-level type compatibility are not complete";
}
"the analyzer does not declare complete compatibility coverage"
}
fn ensure_unclassified_finding(
package_id: &str,
ecosystem: monochange_core::Ecosystem,
detection_level: DetectionLevel,
changed_files: &[PathBuf],
findings: &mut Vec<ClassificationFinding>,
) {
let has_pull_request_finding = findings
.iter()
.any(|finding| finding.comparisons.contains(&ComparisonKind::PullRequest));
if changed_files.is_empty() || has_pull_request_finding {
return;
}
let location = changed_files
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from("."));
findings.push(ClassificationFinding {
id: format!("monochange/unclassified-source/{package_id}"),
rule_id: "monochange/unclassified-source".to_string(),
surface: "source".to_string(),
change: "modified".to_string(),
impact: CompatibilityImpact::Unmodeled,
bump: BumpSeverity::Patch,
confidence: ClassificationConfidence::Low,
analyzer: FindingAnalyzer {
id: format!("{}/fallback", ecosystem.as_str()),
engine: None,
version: ANALYZER_VERSION.to_string(),
},
coverage: FindingCoverage {
detection_level,
completeness: AnalysisCompleteness::Partial,
note: "changed package files produced no modeled compatibility finding".to_string(),
fallback_reason: None,
checks: Vec::new(),
},
before: None,
after: None,
location,
comparisons: [ComparisonKind::PullRequest].into_iter().collect(),
summary: "package files changed outside the analyzer's modeled public surface".to_string(),
});
}
fn collect_cli_surface_classification(
root: &Path,
configuration: &monochange_core::WorkspaceConfiguration,
package_id: &str,
changed_files: &[PathBuf],
options: &ClassifyOptions,
findings: &mut Vec<ClassificationFinding>,
warnings: &mut Vec<String>,
) -> Option<PackageCliClassification> {
let definition = configuration.package_by_id(package_id)?;
let cli = definition.cli.as_ref()?;
let baseline_path = crate::cli_surface::cli_snapshot_root_relative(root, &cli.name)
.display()
.to_string();
if options.skip_cli_snapshots {
return Some(PackageCliClassification {
name: cli.name.clone(),
status: CliSnapshotStatus::Skipped,
recommendation: None,
finding_count: None,
baseline: None,
});
}
if changed_files.is_empty() {
return None;
}
let captured = match crate::cli_surface::capture_cli_snapshot(root, package_id, cli) {
Ok(captured) => captured,
Err(error) => {
warnings.push(format!("cli snapshot for `{package_id}` failed: {error}"));
return Some(PackageCliClassification {
name: cli.name.clone(),
status: CliSnapshotStatus::Failed,
recommendation: None,
finding_count: None,
baseline: Some(baseline_path),
});
}
};
let baseline = crate::cli_surface::read_cli_snapshot_baseline(root, &cli.name);
let before = match &baseline {
crate::cli_surface::CliSnapshotBaseline::Current(snapshot) => snapshot,
other => {
warnings.push(format!(
"cli snapshot for `{package_id}` was not compared: {}",
crate::cli_surface::describe_cli_snapshot_baseline(other)
));
let status = match other {
crate::cli_surface::CliSnapshotBaseline::Missing => {
CliSnapshotStatus::MissingBaseline
}
_ => CliSnapshotStatus::StaleBaseline,
};
return Some(PackageCliClassification {
name: cli.name.clone(),
status,
recommendation: None,
finding_count: None,
baseline: Some(baseline_path),
});
}
};
let report = monochange_snapshot::diff_command_snapshots(before, &captured.snapshot);
let recommendation = report
.changes
.iter()
.map(|change| bump_for_snapshot_severity(change.severity))
.max()
.unwrap_or(BumpSeverity::None);
let finding_count = report.changes.len();
findings.extend(cli_surface_findings(options.detection_level, &report));
Some(PackageCliClassification {
name: cli.name.clone(),
status: CliSnapshotStatus::Diffed,
recommendation: Some(recommendation),
finding_count: Some(finding_count),
baseline: Some(baseline_path),
})
}
fn cli_surface_findings(
detection_level: DetectionLevel,
report: &monochange_snapshot::SnapshotDiffReport,
) -> Vec<ClassificationFinding> {
let mut id_counts = BTreeMap::<String, usize>::new();
report
.changes
.iter()
.map(|change| {
let kind_name = snapshot_change_kind_name(&change.kind);
let command_path = change.path.join("/");
let base_id = format!("{CLI_SURFACE_ANALYZER_ID}/{kind_name}/{command_path}");
let occurrence = id_counts.entry(base_id.clone()).or_insert(0);
*occurrence += 1;
let id = if *occurrence == 1 {
base_id
} else {
format!("{base_id}/{occurrence}")
};
let location = if change.path.is_empty() {
PathBuf::from(".")
} else {
PathBuf::from(&command_path)
};
ClassificationFinding {
id,
rule_id: format!("{CLI_SURFACE_ANALYZER_ID}/{kind_name}"),
surface: "cli".to_string(),
change: kind_name.to_string(),
impact: cli_surface_impact(&change.kind),
bump: bump_for_snapshot_severity(change.severity),
confidence: ClassificationConfidence::High,
analyzer: FindingAnalyzer {
id: CLI_SURFACE_ANALYZER_ID.to_string(),
engine: None,
version: ANALYZER_VERSION.to_string(),
},
coverage: FindingCoverage {
detection_level,
completeness: AnalysisCompleteness::Complete,
note: "command surface compared against the committed release baseline"
.to_string(),
fallback_reason: None,
checks: Vec::new(),
},
before: None,
after: None,
location,
comparisons: [ComparisonKind::PullRequest].into_iter().collect(),
summary: change.summary.clone(),
}
})
.collect()
}
fn cli_surface_impact(kind: &monochange_snapshot::SnapshotChangeKind) -> CompatibilityImpact {
use monochange_snapshot::SnapshotChangeKind;
match kind {
SnapshotChangeKind::CommandRemoved
| SnapshotChangeKind::OptionRemoved
| SnapshotChangeKind::PositionalRemoved
| SnapshotChangeKind::OptionValueNarrowed => CompatibilityImpact::Breaking,
SnapshotChangeKind::CommandAdded
| SnapshotChangeKind::OptionAdded
| SnapshotChangeKind::PositionalAdded
| SnapshotChangeKind::OptionValueWidened => CompatibilityImpact::Additive,
SnapshotChangeKind::CommandDescriptionChanged
| SnapshotChangeKind::OptionDescriptionChanged
| SnapshotChangeKind::PositionalChanged => CompatibilityImpact::Compatible,
}
}
fn bump_for_snapshot_severity(severity: monochange_snapshot::SnapshotSeverity) -> BumpSeverity {
match severity {
monochange_snapshot::SnapshotSeverity::None => BumpSeverity::None,
monochange_snapshot::SnapshotSeverity::Patch => BumpSeverity::Patch,
monochange_snapshot::SnapshotSeverity::Minor => BumpSeverity::Minor,
monochange_snapshot::SnapshotSeverity::Major => BumpSeverity::Major,
}
}
fn snapshot_change_kind_name(kind: &monochange_snapshot::SnapshotChangeKind) -> &'static str {
use monochange_snapshot::SnapshotChangeKind;
match kind {
SnapshotChangeKind::CommandAdded => "command-added",
SnapshotChangeKind::CommandRemoved => "command-removed",
SnapshotChangeKind::CommandDescriptionChanged => "command-description-changed",
SnapshotChangeKind::OptionAdded => "option-added",
SnapshotChangeKind::OptionRemoved => "option-removed",
SnapshotChangeKind::OptionDescriptionChanged => "option-description-changed",
SnapshotChangeKind::OptionValueWidened => "option-value-widened",
SnapshotChangeKind::OptionValueNarrowed => "option-value-narrowed",
SnapshotChangeKind::PositionalAdded => "positional-added",
SnapshotChangeKind::PositionalRemoved => "positional-removed",
SnapshotChangeKind::PositionalChanged => "positional-changed",
}
}
fn cli_classification_description(cli: &PackageCliClassification) -> String {
match cli.status {
CliSnapshotStatus::Diffed => {
format!(
"diffed against `{}`, recommendation `{}`, {} finding(s)",
cli.baseline.as_deref().unwrap_or("baseline"),
cli.recommendation
.map_or("none", |bump| bump_severity_name(bump)),
cli.finding_count.unwrap_or(0)
)
}
CliSnapshotStatus::MissingBaseline => "no committed baseline to diff against".to_string(),
CliSnapshotStatus::StaleBaseline => "committed baseline is stale or unparsable".to_string(),
CliSnapshotStatus::Failed => "snapshot capture failed".to_string(),
CliSnapshotStatus::Skipped => "snapshot capture skipped".to_string(),
}
}
fn build_recommendation(
findings: &[ClassificationFinding],
has_pull_request_changes: bool,
has_release: bool,
bump_ceiling: Option<BumpSeverity>,
classification_enforced: bool,
) -> ChangeRecommendation {
let current = findings
.iter()
.filter(|finding| {
has_pull_request_changes && finding.comparisons.contains(&ComparisonKind::PullRequest)
})
.collect::<Vec<_>>();
let release = findings
.iter()
.filter(|finding| finding.comparisons.contains(&ComparisonKind::Release))
.collect::<Vec<_>>();
let release_bump = release
.iter()
.map(|finding| finding.bump)
.max()
.unwrap_or(BumpSeverity::None);
let effective_bump = |finding: &ClassificationFinding| {
if has_release && finding.impact != CompatibilityImpact::Unmodeled {
finding.bump.min(release_bump)
} else {
finding.bump
}
};
let proposed_changeset_bump = current
.iter()
.map(|finding| effective_bump(finding))
.max()
.unwrap_or(BumpSeverity::None);
let enforceable_minimum = current
.iter()
.filter(|finding| finding.confidence == ClassificationConfidence::High)
.map(|finding| effective_bump(finding))
.max()
.unwrap_or(BumpSeverity::None);
let release_floor = if has_release {
std::cmp::max(release_bump, proposed_changeset_bump)
} else {
proposed_changeset_bump
};
let compatibility_impact = highest_compatibility_impact(¤t);
let release_impact = has_release.then(|| highest_compatibility_impact(&release));
let confidence = current
.iter()
.filter(|finding| effective_bump(finding) == proposed_changeset_bump)
.map(|finding| finding.confidence)
.max()
.unwrap_or(ClassificationConfidence::High);
let has_conclusive_major = current.iter().any(|finding| {
effective_bump(finding) == BumpSeverity::Major
&& finding.confidence == ClassificationConfidence::High
&& finding.coverage.completeness == AnalysisCompleteness::Complete
});
let all_current_findings_complete = !current.is_empty()
&& current
.iter()
.all(|finding| finding.coverage.completeness == AnalysisCompleteness::Complete);
let completeness =
if !has_pull_request_changes || has_conclusive_major || all_current_findings_complete {
AnalysisCompleteness::Complete
} else {
AnalysisCompleteness::Partial
};
let review_required = completeness != AnalysisCompleteness::Complete
|| compatibility_impact == CompatibilityImpact::Unmodeled;
let finding_ids = current
.iter()
.filter(|finding| effective_bump(finding) == proposed_changeset_bump)
.map(|finding| finding.id.clone())
.collect();
let (proposed_changeset_bump, enforceable_minimum, release_floor) = apply_classification_policy(
(proposed_changeset_bump, enforceable_minimum, release_floor),
bump_ceiling,
classification_enforced,
);
ChangeRecommendation {
compatibility_impact,
release_impact,
pull_request_changes: has_pull_request_changes,
proposed_changeset_bump,
enforceable_minimum,
release_floor,
confidence,
completeness,
review_required,
finding_ids,
classification_enforced,
}
}
fn apply_classification_policy(
severity: (BumpSeverity, BumpSeverity, BumpSeverity),
bump_ceiling: Option<BumpSeverity>,
classification_enforced: bool,
) -> (BumpSeverity, BumpSeverity, BumpSeverity) {
let (proposed, enforceable, release_floor) = severity;
let clamp = |value: BumpSeverity| bump_ceiling.map_or(value, |ceiling| value.min(ceiling));
let proposed = clamp(proposed);
let mut enforceable = clamp(enforceable);
let release_floor = clamp(release_floor);
if !classification_enforced {
enforceable = BumpSeverity::None;
}
(proposed, enforceable, release_floor)
}
fn highest_compatibility_impact(findings: &[&ClassificationFinding]) -> CompatibilityImpact {
if findings
.iter()
.any(|finding| finding.impact == CompatibilityImpact::Breaking)
{
return CompatibilityImpact::Breaking;
}
if findings
.iter()
.any(|finding| finding.impact == CompatibilityImpact::Additive)
{
return CompatibilityImpact::Additive;
}
if findings
.iter()
.any(|finding| finding.impact == CompatibilityImpact::Unmodeled)
{
return CompatibilityImpact::Unmodeled;
}
CompatibilityImpact::Compatible
}
fn changeset_action(
decision: &ChangeRecommendation,
existing: &[ExistingChangeset],
) -> ChangesetAction {
if decision.proposed_changeset_bump == BumpSeverity::None {
return if existing.is_empty() {
ChangesetAction::NoChangeset
} else {
ChangesetAction::Review
};
}
if existing.is_empty() {
return ChangesetAction::Create;
}
let existing_bump = existing
.iter()
.filter_map(|changeset| changeset.bump)
.max()
.unwrap_or(BumpSeverity::None);
if existing_bump < decision.proposed_changeset_bump {
ChangesetAction::Update
} else {
ChangesetAction::Keep
}
}
fn recommendation_summary(
decision: &ChangeRecommendation,
findings: &[ClassificationFinding],
pending_changesets: usize,
) -> String {
let count = decision.finding_ids.len();
let release_cap_note = if decision.compatibility_impact == CompatibilityImpact::Breaking
&& decision
.release_impact
.is_some_and(|impact| impact != CompatibilityImpact::Breaking)
{
"; the break applies to the default branch, not the latest release"
} else {
""
};
match decision.proposed_changeset_bump {
BumpSeverity::Major => format!("{count} breaking finding(s) propose a major changeset"),
BumpSeverity::Minor if release_cap_note.is_empty() => {
format!("{count} additive finding(s) propose a minor changeset")
}
BumpSeverity::Minor => {
format!("{count} finding(s) propose a minor changeset{release_cap_note}")
}
BumpSeverity::Patch
if findings
.iter()
.any(|finding| finding.impact == CompatibilityImpact::Unmodeled) =>
{
"unclassified package changes propose a patch changeset and require review".to_string()
}
BumpSeverity::Patch => {
format!("{count} compatible finding(s) propose a patch changeset{release_cap_note}")
}
BumpSeverity::None if !decision.pull_request_changes && pending_changesets > 0 => {
"the pull request does not change this package; the pending changeset intent needs review"
.to_string()
}
BumpSeverity::None if decision.review_required => {
"pending changeset intent has no matching package change and requires review".to_string()
}
BumpSeverity::None => {
format!("no package change requires a changeset{release_cap_note}")
}
_ => "the package change requires review".to_string(),
}
}
fn package_warnings(package_id: &str, evidence: &[PackageEvidence<'_>]) -> Vec<String> {
evidence
.iter()
.flat_map(|frame| {
frame
.analysis
.package_analyses
.get(package_id)
.into_iter()
.flat_map(|package| package.warnings.iter().cloned())
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
pub fn skip_cli_snapshots_for(flag: bool, env_value: Option<&str>) -> bool {
flag || env_value.is_some_and(|value| value != "0")
}
pub fn parse_detection_level(value: &str) -> MonochangeResult<DetectionLevel> {
match value {
"basic" => Ok(DetectionLevel::Basic),
"signature" => Ok(DetectionLevel::Signature),
"semantic" => Ok(DetectionLevel::Semantic),
other => {
Err(MonochangeError::Config(format!(
"unsupported detection level `{other}`; expected basic, signature, or semantic"
)))
}
}
}
fn propagate_public_dependency_impacts(
analysis: &ChangeAnalysis,
packages: &mut Vec<PackageClassification>,
recommendation: &mut BumpSeverity,
) {
for impact in public_dependency_impacts(analysis) {
let dependent_id = preferred_report_package_id(&impact.dependent);
let upstream_name = impact.upstream_name;
let finding_id = format!("monochange/public-dependency/{upstream_name}");
let finding = ClassificationFinding {
id: finding_id.clone(),
rule_id: "monochange/public-dependency".to_string(),
surface: "dependency".to_string(),
change: "propagated".to_string(),
impact: CompatibilityImpact::Compatible,
bump: BumpSeverity::Patch,
confidence: ClassificationConfidence::Medium,
analyzer: FindingAnalyzer {
id: "monochange/dependency-propagation".to_string(),
engine: None,
version: ANALYZER_VERSION.to_string(),
},
coverage: FindingCoverage {
detection_level: analysis.detection_level,
completeness: AnalysisCompleteness::Partial,
note: "direct runtime dependency propagation".to_string(),
fallback_reason: None,
checks: Vec::new(),
},
before: None,
after: None,
location: impact
.dependent
.relative_manifest_path(&impact.dependent.workspace_root)
.unwrap_or_else(|| impact.dependent.manifest_path.clone()),
comparisons: [ComparisonKind::PullRequest].into_iter().collect(),
summary: format!(
"public dependency `{upstream_name}` changed; verify re-exports and constraints"
),
};
if let Some(package) = packages
.iter_mut()
.find(|package| package.package_id == dependent_id)
{
if !package
.findings
.iter()
.any(|existing| existing.id == finding.id)
{
package.findings.push(finding);
}
if package.decision.proposed_changeset_bump < BumpSeverity::Patch {
package.decision.compatibility_impact = CompatibilityImpact::Compatible;
package.decision.proposed_changeset_bump = BumpSeverity::Patch;
package.decision.confidence = ClassificationConfidence::Medium;
package.decision.finding_ids = vec![finding_id];
package.recommendation = BumpSeverity::Patch;
package.summary = format!(
"public dependency `{upstream_name}` changed; verify the dependent package still re-exports or constrains it correctly"
);
} else if package.decision.proposed_changeset_bump == BumpSeverity::Patch
&& !package.decision.finding_ids.contains(&finding_id)
{
package.decision.finding_ids.push(finding_id);
}
package.decision.release_floor =
std::cmp::max(package.decision.release_floor, BumpSeverity::Patch);
package.decision.pull_request_changes = true;
package.decision.completeness = AnalysisCompleteness::Partial;
package.decision.review_required = true;
package.action = changeset_action(&package.decision, &package.existing_changesets);
*recommendation = std::cmp::max(*recommendation, package.recommendation);
continue;
}
let decision = ChangeRecommendation {
compatibility_impact: CompatibilityImpact::Compatible,
release_impact: None,
pull_request_changes: true,
proposed_changeset_bump: BumpSeverity::Patch,
enforceable_minimum: BumpSeverity::None,
release_floor: BumpSeverity::Patch,
confidence: ClassificationConfidence::Medium,
completeness: AnalysisCompleteness::Partial,
review_required: true,
finding_ids: vec![finding_id],
classification_enforced: true,
};
packages.push(PackageClassification {
package_id: dependent_id,
package_name: impact.dependent.name,
ecosystem: impact.dependent.ecosystem,
release_owner: None,
comparisons: Vec::new(),
recommendation: BumpSeverity::Patch,
decision,
summary: format!(
"public dependency `{upstream_name}` changed; verify the dependent package still re-exports or constrains it correctly"
),
findings: vec![finding],
existing_changesets: Vec::new(),
action: ChangesetAction::Create,
warnings: Vec::new(),
cli: None,
});
*recommendation = std::cmp::max(*recommendation, BumpSeverity::Patch);
}
packages.sort_by(|left, right| left.package_id.cmp(&right.package_id));
}
fn public_dependency_impacts(analysis: &ChangeAnalysis) -> Vec<PublicDependencyImpact> {
let mut impacted_record_ids = BTreeMap::new();
for package in analysis.package_analyses.values() {
let recommendation = package
.semantic_changes
.iter()
.map(monochange_semver::semantic_change_severity)
.max()
.unwrap_or(BumpSeverity::None);
if recommendation >= BumpSeverity::Minor {
impacted_record_ids.insert(
package.package_record_id.clone(),
package.package_name.clone(),
);
}
}
if impacted_record_ids.is_empty() || analysis.packages.is_empty() {
return Vec::new();
}
let package_by_record_id = analysis
.packages
.iter()
.map(|package| (package.id.clone(), package))
.collect::<BTreeMap<_, _>>();
let mut impacts = Vec::new();
for edge in monochange_core::materialize_dependency_edges(&analysis.packages) {
if edge.dependency_kind != DependencyKind::Runtime
|| !impacted_record_ids.contains_key(&edge.to_package_id)
{
continue;
}
let Some(dependent) = package_by_record_id.get(&edge.from_package_id) else {
continue;
};
let upstream_name = impacted_record_ids
.get(&edge.to_package_id)
.cloned()
.unwrap_or(edge.to_package_id.clone());
impacts.push(PublicDependencyImpact {
dependent: (*dependent).clone(),
upstream_name,
});
}
impacts.sort_by(|left, right| {
preferred_report_package_id(&left.dependent)
.cmp(&preferred_report_package_id(&right.dependent))
.then_with(|| left.upstream_name.cmp(&right.upstream_name))
});
impacts.dedup_by(|left, right| {
preferred_report_package_id(&left.dependent)
== preferred_report_package_id(&right.dependent)
&& left.upstream_name == right.upstream_name
});
impacts
}
fn preferred_report_package_id(package: &PackageRecord) -> String {
package
.metadata
.get("config_id")
.cloned()
.unwrap_or_else(|| package.id.clone())
}
#[coverage(off)]
fn render_markdown_report(report: &ChangeClassificationReport) -> String {
let mut lines = vec!["# Change classification".to_string(), String::new()];
if report.skipped {
lines.push(report.summary.clone());
lines.push(String::new());
lines.push(format!(
"Matched skip labels: {}",
report.matched_skip_labels.join(", ")
));
return lines.join("\n");
}
lines.push(format!("- Schema version: `{}`", report.schema_version));
lines.push(format!("- Default branch: `{}`", report.default_branch));
lines.push(format!("- Candidate: `{}`", report.candidate));
lines.push(format!("- Recommended bump: `{}`", report.recommendation));
lines.push(format!("- Packages analyzed: {}", report.packages.len()));
lines.push(String::new());
lines.push("## Comparisons".to_string());
lines.push(String::new());
for comparison in &report.comparisons {
let base = comparison.base.as_deref().unwrap_or("unavailable");
lines.push(format!(
"- `{}`: `{base}` to `{}` ({})",
comparison_kind_name(comparison.kind),
comparison.head,
comparison_status_name(comparison.status)
));
}
lines.push(String::new());
if report.packages.is_empty() {
lines.push("No package changes were detected for the pull request comparison.".to_string());
} else {
lines.push("## Packages".to_string());
lines.push(String::new());
for package in &report.packages {
lines.push(format!("### `{}`", package.package_id));
lines.push(String::new());
lines.push(format!("- Ecosystem: `{}`", package.ecosystem));
lines.push(format!(
"- Proposed changeset bump: `{}`",
package.decision.proposed_changeset_bump
));
lines.push(format!(
"- Enforceable minimum: `{}`",
package.decision.enforceable_minimum
));
lines.push(format!(
"- Release floor: `{}`",
package.decision.release_floor
));
lines.push(
format!(
"- Compatibility impact: `{:?}`",
package.decision.compatibility_impact
)
.to_lowercase(),
);
if let Some(release_impact) = package.decision.release_impact {
lines.push(format!("- Release impact: `{release_impact:?}`").to_lowercase());
}
lines.push(format!("- Confidence: `{:?}`", package.decision.confidence).to_lowercase());
lines.push(
format!("- Completeness: `{:?}`", package.decision.completeness).to_lowercase(),
);
lines.push(format!(
"- Review required: `{}`",
package.decision.review_required
));
lines.push(format!("- Changeset action: `{:?}`", package.action).to_lowercase());
lines.push(format!("- Summary: {}", package.summary));
if let Some(cli) = &package.cli {
lines.push(format!(
"- CLI: `{}` ({})",
cli.name,
cli_classification_description(cli)
));
}
if let Some(owner) = &package.release_owner {
lines.push(format!("- Release owner: `{}` `{}`", owner.kind, owner.id));
lines.push(format!(
"- Latest release: `{}`",
owner.latest_release.as_deref().unwrap_or("none")
));
}
if !package.existing_changesets.is_empty() {
lines.push("- Existing changesets:".to_string());
for changeset in &package.existing_changesets {
lines.push(format!(
" - `{}`: `{}`",
changeset.path.display(),
changeset
.bump
.map_or_else(|| "custom".to_string(), |bump| bump.to_string())
));
}
}
lines.push(format!("- Findings: {}", package.findings.len()));
if !package.findings.is_empty() {
lines.push(String::new());
for finding in package.findings.iter().take(10) {
let comparisons = finding
.comparisons
.iter()
.map(|comparison| comparison_kind_name(*comparison))
.collect::<Vec<_>>()
.join(", ");
lines.push(format!(
"- `{}`: {} (`{}`, impact `{}`, bump `{}`, confidence `{}`, comparisons `{comparisons}`)",
finding.id,
finding.summary,
finding.location.display(),
compatibility_impact_name(finding.impact),
finding.bump,
classification_confidence_name(finding.confidence)
));
lines.push(format!(
" - Evidence: {}",
markdown_finding_evidence(finding)
));
}
}
if package.findings.len() > 10 {
lines.push(format!("- {} more findings", package.findings.len() - 10));
}
lines.push(String::new());
}
}
if !report.warnings.is_empty() {
lines.push("## Warnings".to_string());
lines.push(String::new());
for warning in &report.warnings {
lines.push(format!("- {warning}"));
}
}
lines.join("\n")
}
#[coverage(off)]
fn render_text_report(report: &ChangeClassificationReport) -> String {
let mut lines = vec!["Change classification".to_string(), String::new()];
if report.skipped {
lines.push(report.summary.clone());
lines.push(String::new());
lines.push(format!(
"Matched skip labels: {}",
report.matched_skip_labels.join(", ")
));
return lines.join("\n");
}
lines.push(format!("Schema version: {}", report.schema_version));
lines.push(format!("Default branch: {}", report.default_branch));
lines.push(format!("Candidate: {}", report.candidate));
lines.push(format!("Recommended bump: {}", report.recommendation));
lines.push(format!("Packages analyzed: {}", report.packages.len()));
lines.push(String::new());
lines.push("Comparisons".to_string());
for comparison in &report.comparisons {
let base = comparison.base.as_deref().unwrap_or("unavailable");
lines.push(format!(
" {}: {base} to {} ({})",
comparison_kind_name(comparison.kind),
comparison.head,
comparison_status_name(comparison.status)
));
}
if report.packages.is_empty() {
lines.push(String::new());
lines.push("No package changes were detected for the pull request comparison.".to_string());
} else {
lines.push(String::new());
lines.push("Packages".to_string());
for package in &report.packages {
lines.push(String::new());
lines.push(package.package_id.clone());
lines.push(format!(" Ecosystem: {}", package.ecosystem));
lines.push(format!(
" Proposed changeset bump: {}",
package.decision.proposed_changeset_bump
));
lines.push(format!(
" Enforceable minimum: {}",
package.decision.enforceable_minimum
));
lines.push(format!(
" Release floor: {}",
package.decision.release_floor
));
lines.push(format!(
" Compatibility impact: {}",
compatibility_impact_name(package.decision.compatibility_impact)
));
if let Some(release_impact) = package.decision.release_impact {
lines.push(format!(
" Release impact: {}",
compatibility_impact_name(release_impact)
));
}
lines.push(format!(
" Confidence: {}",
classification_confidence_name(package.decision.confidence)
));
lines.push(
format!(" Completeness: {:?}", package.decision.completeness).to_lowercase(),
);
lines.push(format!(
" Review required: {}",
package.decision.review_required
));
lines.push(format!(" Changeset action: {:?}", package.action).to_lowercase());
lines.push(format!(
" Summary: {}",
plain_text_fragment(&package.summary)
));
if let Some(cli) = &package.cli {
lines.push(format!(
" CLI: {} ({})",
cli.name,
cli_classification_description(cli)
));
}
if let Some(owner) = &package.release_owner {
lines.push(format!(" Release owner: {} {}", owner.kind, owner.id));
lines.push(format!(
" Latest release: {}",
owner.latest_release.as_deref().unwrap_or("none")
));
}
if !package.existing_changesets.is_empty() {
lines.push(" Existing changesets:".to_string());
for changeset in &package.existing_changesets {
lines.push(format!(
" {}: {}",
changeset.path.display(),
changeset
.bump
.map_or_else(|| "custom".to_string(), |bump| bump.to_string())
));
}
}
lines.push(format!(" Findings: {}", package.findings.len()));
for finding in package.findings.iter().take(10) {
let comparisons = finding
.comparisons
.iter()
.map(|comparison| comparison_kind_name(*comparison))
.collect::<Vec<_>>()
.join(", ");
lines.push(format!(
" - {}: {} ({}, impact {}, bump {}, confidence {}, comparisons {comparisons})",
finding.id,
plain_text_fragment(&finding.summary),
finding.location.display(),
compatibility_impact_name(finding.impact),
finding.bump,
classification_confidence_name(finding.confidence)
));
lines.push(format!(
" Evidence: {}",
plain_text_fragment(&finding_evidence(finding))
));
}
if package.findings.len() > 10 {
lines.push(format!(" - {} more findings", package.findings.len() - 10));
}
}
}
if !report.warnings.is_empty() {
lines.push(String::new());
lines.push("Warnings".to_string());
lines.extend(
report
.warnings
.iter()
.map(|warning| format!("- {}", plain_text_fragment(warning))),
);
}
lines.join("\n")
}
fn finding_evidence(finding: &ClassificationFinding) -> String {
let engine = finding
.analyzer
.engine
.as_deref()
.map_or_else(String::new, |engine| format!(" via {engine}"));
let fallback = finding
.coverage
.fallback_reason
.as_deref()
.map_or_else(String::new, |reason| format!("; fallback: {reason}"));
let checks = finding_checks_evidence(&finding.coverage.checks);
format!(
"{}{} {}; coverage {}: {}{}{}",
finding.analyzer.id,
engine,
finding.analyzer.version,
analysis_completeness_name(finding.coverage.completeness),
finding.coverage.note,
fallback,
checks
)
}
fn finding_checks_evidence(checks: &[SemanticAnalyzerCheck]) -> String {
if checks.is_empty() {
return String::new();
}
let checks = checks
.iter()
.map(|check| {
let result = match check.status {
SemanticAnalyzerCheckStatus::Checked => {
check.suggested_bump.map_or_else(
|| "checked".to_string(),
|bump| format!("checked/{}", bump_severity_name(bump)),
)
}
SemanticAnalyzerCheckStatus::Skipped => "skipped".to_string(),
SemanticAnalyzerCheckStatus::Failed => "failed".to_string(),
_ => "unknown".to_string(),
};
let diagnostics = check
.diagnostics
.iter()
.take(5)
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
.collect::<Vec<_>>();
let omitted = check.diagnostics.len().saturating_sub(diagnostics.len());
if diagnostics.is_empty() {
format!("{}={result}", check.name)
} else {
let omitted = if omitted == 0 {
String::new()
} else {
format!(", +{omitted} more")
};
format!(
"{}={result} [{}{}]",
check.name,
diagnostics.join(", "),
omitted
)
}
})
.collect::<Vec<_>>()
.join("; ");
format!("; checks: {checks}")
}
fn bump_severity_name(bump: BumpSeverity) -> &'static str {
match bump {
BumpSeverity::None => "none",
BumpSeverity::Patch => "patch",
BumpSeverity::Minor => "minor",
BumpSeverity::Major => "major",
_ => "unknown",
}
}
fn markdown_finding_evidence(finding: &ClassificationFinding) -> String {
finding_evidence(finding)
.split('`')
.map(markdown_text_fragment)
.collect::<Vec<_>>()
.join("\\`")
}
fn markdown_text_fragment(value: &str) -> String {
value
.replace('\\', "\\\\")
.replace('*', "\\*")
.replace('_', "\\_")
}
fn plain_text_fragment(value: &str) -> String {
value.replace('`', "")
}
fn analysis_completeness_name(completeness: AnalysisCompleteness) -> &'static str {
match completeness {
AnalysisCompleteness::Complete => "complete",
AnalysisCompleteness::Partial => "partial",
AnalysisCompleteness::Unsupported => "unsupported",
}
}
fn comparison_kind_name(kind: ComparisonKind) -> &'static str {
match kind {
ComparisonKind::PullRequest => "pullRequest",
ComparisonKind::Release => "release",
ComparisonKind::ReleaseToDefault => "releaseToDefault",
ComparisonKind::SourceDelta => "sourceDelta",
ComparisonKind::WorkingTree => "workingTree",
}
}
fn comparison_status_name(status: ComparisonStatus) -> &'static str {
match status {
ComparisonStatus::Analyzed => "analyzed",
ComparisonStatus::Unavailable => "unavailable",
ComparisonStatus::Conflicted => "conflicted",
}
}
fn compatibility_impact_name(impact: CompatibilityImpact) -> &'static str {
match impact {
CompatibilityImpact::Unmodeled => "unmodeled",
CompatibilityImpact::Compatible => "compatible",
CompatibilityImpact::Additive => "additive",
CompatibilityImpact::Breaking => "breaking",
}
}
fn classification_confidence_name(confidence: ClassificationConfidence) -> &'static str {
match confidence {
ClassificationConfidence::Low => "low",
ClassificationConfidence::Medium => "medium",
ClassificationConfidence::High => "high",
}
}
pub fn parse_dependency_propagation(value: &str) -> MonochangeResult<DependencyPropagation> {
match value {
"none" => Ok(DependencyPropagation::None),
"public" => Ok(DependencyPropagation::Public),
other => {
Err(MonochangeError::Config(format!(
"unsupported dependency propagation `{other}`; expected none or public"
)))
}
}
}
#[cfg(test)]
#[path = "__tests__/classification_tests.rs"]
mod tests;