Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[alias]
xtask = "run --package xtask --"

[net]
# Ruff is consumed as a tagged git dependency (see workspace `Cargo.toml`).
# Cargo's bundled libgit2 does not pick up `url.git@github.com:.insteadof`
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions crates/mehen-core/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,69 @@ pub struct MetricContribution {
pub reason: ContributionReason,
}

/// Optional, deterministically ordered sink for analyzer contribution evidence.
///
/// Analyzers can construct this directly from
/// [`crate::AnalysisConfig::emit_contributions`]. When disabled, [`record`](Self::record)
/// is a cheap no-op; when enabled, [`finish`](Self::finish) returns entries in
/// source order so JSON and snapshot output remain stable across parser walk
/// implementations and platforms.
#[derive(Debug)]
pub struct ContributionCollector {
enabled: bool,
entries: Vec<MetricContribution>,
}

impl ContributionCollector {
pub fn new(enabled: bool) -> Self {
Self {
enabled,
entries: Vec::new(),
}
}

pub fn is_enabled(&self) -> bool {
self.enabled
}

pub fn record(
&mut self,
metric: impl Into<MetricKey>,
span: SourceSpan,
amount: f64,
reason: impl Into<String>,
) {
if !self.enabled {
return;
}
self.entries.push(MetricContribution {
metric: metric.into(),
span,
amount,
reason: ContributionReason::new(reason),
});
}

pub fn finish(mut self) -> Vec<MetricContribution> {
self.entries.sort_by(|a, b| {
(
a.span.start_byte,
a.span.end_byte,
&a.metric,
a.reason.as_str(),
)
.cmp(&(
b.span.start_byte,
b.span.end_byte,
&b.metric,
b.reason.as_str(),
))
.then_with(|| a.amount.total_cmp(&b.amount))
});
Comment thread
tinovyatkin marked this conversation as resolved.
self.entries
}
}

/// A namespaced reason code attached to a [`MetricContribution`].
///
/// Stored as a string so language crates can publish their own reason codes
Expand Down Expand Up @@ -177,4 +240,20 @@ mod tests {
let keys: Vec<&str> = set.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["a", "z"]);
}

#[test]
fn contribution_collector_is_gated_and_deterministic() {
let later = SourceSpan::new(20, 24, 3, 3);
let earlier = SourceSpan::new(2, 6, 1, 1);
let mut collector = ContributionCollector::new(true);
collector.record("risk", later, 2.0, "risk.write");
collector.record("risk", earlier, 8.0, "risk.drop");
let entries = collector.finish();
assert_eq!(entries[0].span, earlier);
assert_eq!(entries[1].span, later);

let mut disabled = ContributionCollector::new(false);
disabled.record("risk", earlier, 8.0, "risk.drop");
assert!(disabled.finish().is_empty());
}
}
3 changes: 2 additions & 1 deletion crates/mehen-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ mod span;
mod threshold;

pub use analysis::{
ContributionReason, LanguageAnalysis, MetricContribution, MetricSet, MetricValue,
ContributionCollector, ContributionReason, LanguageAnalysis, MetricContribution, MetricSet,
MetricValue,
};
pub use analyzer::{LanguageAnalyzer, LanguageDispatcher};
pub use backend::AnalysisBackend;
Expand Down
37 changes: 35 additions & 2 deletions crates/mehen-core/src/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ use camino::Utf8PathBuf;
use serde::{Deserialize, Serialize};

use crate::{
AnalysisBackend, AnalysisConfig, Language, LanguageAnalysis, MetricSelector, MetricSpace,
ParseDiagnostic, SourceFile, SourceSpan, SpaceId, SpaceKind, Threshold, ThresholdViolation,
AnalysisBackend, AnalysisConfig, Language, LanguageAnalysis, MetricContribution,
MetricSelector, MetricSpace, ParseDiagnostic, SourceFile, SourceSpan, SpaceId, SpaceKind,
Threshold, ThresholdViolation,
};

/// Inputs to `analyze_metrics`.
Expand All @@ -26,6 +27,10 @@ pub struct MetricsReport {
pub analysis_backend: AnalysisBackend,
pub diagnostics: Vec<ParseDiagnostic>,
pub root: MetricSpace,
/// Source-resolved evidence emitted by the analyzer. Empty for analyzers
/// or profiles that do not request contribution collection.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub contributions: Vec<MetricContribution>,
}

impl MetricsReport {
Expand All @@ -40,6 +45,7 @@ impl MetricsReport {
analysis_backend: AnalysisBackend::TreeSitter,
diagnostics: Vec::new(),
root: MetricSpace::new(SpaceId(0), SpaceKind::Unit, SourceSpan::empty()),
contributions: Vec::new(),
}
}
}
Expand All @@ -54,10 +60,37 @@ impl From<LanguageAnalysis> for MetricsReport {
analysis_backend: analysis.backend,
diagnostics: analysis.diagnostics,
root: analysis.root,
contributions: analysis.contributions,
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{ContributionReason, MetricContribution, MetricKey};

#[test]
fn language_analysis_conversion_preserves_contributions() {
let contribution = MetricContribution {
metric: MetricKey::new("sql.change_risk_score"),
span: SourceSpan::new(0, 4, 1, 1),
amount: 8.0,
reason: ContributionReason::new("sql.change_risk.drop"),
};
let analysis = LanguageAnalysis {
language: Language::Sql,
backend: AnalysisBackend::Sqruff,
diagnostics: Vec::new(),
root: MetricSpace::new(SpaceId(0), SpaceKind::Unit, SourceSpan::empty()),
contributions: vec![contribution.clone()],
};

let report = MetricsReport::from(analysis);
assert_eq!(report.contributions, vec![contribution]);
}
}

/// Inputs to `analyze_diff`.
///
/// Phase 1 ships the type so that later phases can fill in the orchestration
Expand Down
26 changes: 25 additions & 1 deletion crates/mehen-report/src/json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ pub fn render_diff_json(report: &DiffReport, pretty: bool) -> serde_json::Result
mod tests {
use super::*;
use mehen_core::{
AnalysisBackend, MetricKey, MetricSpace, MetricsReport, SourceSpan, SpaceId, SpaceKind,
AnalysisBackend, ContributionReason, MetricContribution, MetricKey, MetricSpace,
MetricsReport, SourceSpan, SpaceId, SpaceKind,
};

fn report_with(language: Language, key: &str, value: f64) -> MetricsReport {
Expand All @@ -74,6 +75,7 @@ mod tests {
analysis_backend: AnalysisBackend::Sqruff,
diagnostics: Vec::new(),
root,
contributions: Vec::new(),
}
}

Expand Down Expand Up @@ -109,4 +111,26 @@ mod tests {
"source-code report must carry the pivoted `metrics` family block"
);
}

#[test]
fn contributions_are_serialized_only_when_present() {
let empty = report_with(Language::Sql, "sql.change_risk_score", 0.0);
let empty_json: serde_json::Value =
serde_json::from_str(&render_metrics_json(&empty, false).unwrap()).unwrap();
assert!(empty_json.get("contributions").is_none());

let mut explained = report_with(Language::Sql, "sql.change_risk_score", 8.0);
explained.contributions.push(MetricContribution {
metric: MetricKey::new("sql.change_risk_score"),
span: SourceSpan::new(0, 12, 1, 1),
amount: 8.0,
reason: ContributionReason::new("sql.change_risk.drop"),
});
let json: serde_json::Value =
serde_json::from_str(&render_metrics_json(&explained, false).unwrap()).unwrap();
assert_eq!(json["contributions"][0]["metric"], "sql.change_risk_score");
assert_eq!(json["contributions"][0]["amount"], 8.0);
assert_eq!(json["contributions"][0]["reason"], "sql.change_risk.drop");
assert_eq!(json["contributions"][0]["span"]["start_line"], 1);
}
}
61 changes: 57 additions & 4 deletions crates/mehen-report/src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
use std::fmt::Write;

use mehen_core::{
DiagnosticSeverity, DiffReport, Language, MetricKey, MetricSet, MetricSpace, MetricsReport,
ParseDiagnostic, SpaceKind,
DiagnosticSeverity, DiffReport, Language, MetricContribution, MetricKey, MetricSet,
MetricSpace, MetricsReport, ParseDiagnostic, SpaceKind,
};

use crate::metrics_json::{
Expand Down Expand Up @@ -42,12 +42,46 @@ pub fn render_metrics_markdown(report: &MetricsReport) -> String {
let _ = writeln!(out, "- schema: `{}`", report.schema_version);

write_diagnostics(&mut out, &report.diagnostics);
write_contributions(&mut out, &report.contributions);
write_unit_metrics(&mut out, &report.root.metrics, report.language);
write_nested_spaces(&mut out, &report.root.spaces, 0, report.language);

out
}

fn write_contributions(out: &mut String, contributions: &[MetricContribution]) {
if contributions.is_empty() {
return;
}
let _ = writeln!(out);
let _ = writeln!(out, "## Contributions");
let _ = writeln!(out);
let _ = writeln!(out, "| metric | amount | reason | lines |");
let _ = writeln!(out, "|---|---:|---|---:|");
for contribution in contributions {
let sign = if contribution.amount.is_sign_negative() {
"-"
} else {
"+"
};
let amount = fmt_metric(contribution.amount.abs());
let lines = if contribution.span.start_line == contribution.span.end_line {
format!("L{}", contribution.span.start_line)
} else {
format!(
"L{}–L{}",
contribution.span.start_line, contribution.span.end_line
)
};
let _ = writeln!(
out,
"| `{}` | {sign}{amount} | `{}` | {lines} |",
contribution.metric,
contribution.reason.as_str(),
);
}
}

fn write_diagnostics(out: &mut String, diagnostics: &[ParseDiagnostic]) {
if diagnostics.is_empty() {
return;
Expand Down Expand Up @@ -727,8 +761,8 @@ pub fn render_diff_github_markdown(report: &DiffReport) -> String {
mod tests {
use super::*;
use mehen_core::{
AnalysisBackend, Language, MetricKey, MetricSpace, ParseDiagnostic, SourceSpan, SpaceId,
SpaceKind,
AnalysisBackend, ContributionReason, Language, MetricContribution, MetricKey, MetricSpace,
ParseDiagnostic, SourceSpan, SpaceId, SpaceKind,
};

fn report_with_metrics(pairs: &[(&str, f64)]) -> MetricsReport {
Expand All @@ -744,6 +778,7 @@ mod tests {
analysis_backend: AnalysisBackend::PythonRuff,
diagnostics: Vec::new(),
root,
contributions: Vec::new(),
}
}

Expand Down Expand Up @@ -816,6 +851,21 @@ mod tests {
assert!(!md.contains("## Diagnostics"));
}

#[test]
fn renders_contribution_evidence_when_present() {
let mut report = report_with_metrics(&[("cyclomatic.sum", 1.0)]);
report.contributions.push(MetricContribution {
metric: MetricKey::new("sql.change_risk_score"),
span: SourceSpan::new(10, 30, 2, 3),
amount: 8.0,
reason: ContributionReason::new("sql.change_risk.drop"),
});
let md = render_metrics_markdown(&report);
assert!(md.contains("## Contributions"));
assert!(md.contains("| `sql.change_risk_score` | +8 |"));
assert!(md.contains("`sql.change_risk.drop` | L2–L3"));
}

#[test]
fn renders_markdown_family_when_language_is_markdown() {
// Regression: previously `write_unit_metrics` always pivoted
Expand Down Expand Up @@ -847,6 +897,7 @@ mod tests {
analysis_backend: AnalysisBackend::PulldownCmark,
diagnostics: Vec::new(),
root,
contributions: Vec::new(),
};
let md = render_metrics_markdown(&report);

Expand Down Expand Up @@ -914,6 +965,7 @@ mod tests {
analysis_backend: AnalysisBackend::PythonRuff,
diagnostics: Vec::new(),
root,
contributions: Vec::new(),
};
let md = render_metrics_markdown(&report);
assert!(md.contains("## Spaces"));
Expand Down Expand Up @@ -950,6 +1002,7 @@ mod tests {
analysis_backend: AnalysisBackend::Sqruff,
diagnostics: Vec::new(),
root,
contributions: Vec::new(),
};
let md = render_metrics_markdown(&report);
let space_section = md.split("## Spaces").nth(1).expect("Spaces section");
Expand Down
24 changes: 12 additions & 12 deletions crates/mehen-sql/src/composite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//! judgments — the analyzer publishes the raw metrics first and these second.

use crate::dialect::DialectResolution;
use crate::facts::SqlFileFacts;
use crate::facts::{ChangeRiskFactor, SqlFileFacts};
use crate::loc::SqlLoc;

/// The six composite scores published per file.
Expand Down Expand Up @@ -182,17 +182,17 @@ fn modularization_credit(f: &SqlFileFacts) -> f64 {
/// `+ 5 * dynamic_sql_count` term here.
fn change_risk(f: &SqlFileFacts) -> f64 {
let o = &f.objects;
8.0 * o.drop_count as f64
+ 8.0 * o.truncate_count as f64
+ 6.0 * o.alter_count as f64
+ 6.0 * o.delete_without_where_count as f64
+ 6.0 * o.update_without_where_count as f64
+ 5.0 * o.grant_revoke_count as f64
+ 4.0 * o.merge_count as f64
+ 4.0 * o.create_or_replace_count as f64
+ 3.0 * o.transaction_control_count as f64
+ 2.0 * o.write_object_count as f64
+ 1.0 * o.read_object_count as f64
ChangeRiskFactor::Drop.amount() * o.drop_count as f64
+ ChangeRiskFactor::Truncate.amount() * o.truncate_count as f64
+ ChangeRiskFactor::Alter.amount() * o.alter_count as f64
+ ChangeRiskFactor::DeleteWithoutWhere.amount() * o.delete_without_where_count as f64
+ ChangeRiskFactor::UpdateWithoutWhere.amount() * o.update_without_where_count as f64
+ ChangeRiskFactor::GrantRevoke.amount() * o.grant_revoke_count as f64
+ ChangeRiskFactor::Merge.amount() * o.merge_count as f64
+ ChangeRiskFactor::CreateOrReplace.amount() * o.create_or_replace_count as f64
+ ChangeRiskFactor::TransactionControl.amount() * o.transaction_control_count as f64
+ ChangeRiskFactor::WriteObject.amount() * o.write_object_count as f64
+ ChangeRiskFactor::ReadObject.amount() * o.read_object_count as f64
}

/// SQL Review Burden Index (research foundation §8.3), 0..100.
Expand Down
Loading
Loading