use crate::root_envelopes::{attach_telemetry_meta, serialize_named_json_output};
use fallow_types::envelope::{ElapsedMs, Meta, ToolVersion};
use serde::Serialize;
use serde_json::Value;
pub const REVIEW_BRIEF_SCHEMA_VERSION: u32 = 11;
pub const AFFECTED_SAMPLE_CAP: usize = 10;
pub const AFFECTED_DIR_CAP: usize = 25;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schema",
schemars(extend("const" = REVIEW_BRIEF_SCHEMA_VERSION))
)]
pub struct ReviewBriefSchemaVersion(pub u32);
impl Default for ReviewBriefSchemaVersion {
fn default() -> Self {
Self(REVIEW_BRIEF_SCHEMA_VERSION)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum RiskClass {
Low,
Medium,
High,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum ReviewEffort {
Glance,
Review,
DeepDive,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct DiffTriage {
pub files: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hunks: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub net_lines: Option<i64>,
pub risk_class: RiskClass,
pub review_effort: ReviewEffort,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct GraphFacts {
pub exports_added: usize,
pub api_width_delta: i64,
pub boundaries_touched: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ImpactClosureFacts {
pub affected_count: usize,
pub affected_not_shown: Vec<String>,
pub affected_by_dir: Vec<AffectedDirectory>,
pub affected_by_dir_omitted: usize,
pub coordination_gap: Vec<CoordinationGapFact>,
}
impl ImpactClosureFacts {
#[must_use]
pub fn new(affected: &[String], coordination_gap: Vec<CoordinationGapFact>) -> Self {
let (affected_by_dir, affected_by_dir_omitted) = roll_up_by_directory(affected);
Self {
affected_count: affected.len(),
affected_not_shown: affected.iter().take(AFFECTED_SAMPLE_CAP).cloned().collect(),
affected_by_dir,
affected_by_dir_omitted,
coordination_gap,
}
}
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AffectedDirectory {
pub dir: String,
pub count: usize,
}
fn roll_up_by_directory(affected: &[String]) -> (Vec<AffectedDirectory>, usize) {
let mut counts: rustc_hash::FxHashMap<&str, usize> = rustc_hash::FxHashMap::default();
for path in affected {
let dir = path.rsplit_once('/').map_or("", |(head, _)| head);
*counts.entry(dir).or_default() += 1;
}
let mut rows: Vec<AffectedDirectory> = counts
.into_iter()
.map(|(dir, count)| AffectedDirectory {
dir: dir.to_string(),
count,
})
.collect();
rows.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.dir.cmp(&b.dir)));
let omitted = rows.len().saturating_sub(AFFECTED_DIR_CAP);
rows.truncate(AFFECTED_DIR_CAP);
(rows, omitted)
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct CoordinationGapFact {
pub changed_file: String,
pub consumer_file: String,
pub consumed_symbols: Vec<String>,
pub note: String,
}
#[derive(Debug, Clone, Default, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct PartitionFacts {
pub units: Vec<ReviewUnitFact>,
pub order: Vec<String>,
#[serde(default, skip_serializing_if = "fewer_than_two_slices")]
pub independent_slices: Vec<Vec<String>>,
}
fn fewer_than_two_slices(slices: &[Vec<String>]) -> bool {
slices.len() < 2
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ReviewUnitFact {
pub module_dir: String,
pub files: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ReviewDeltas {
pub boundary_introduced: Vec<String>,
pub cycle_introduced: Vec<String>,
pub public_api_added: Vec<String>,
pub dependency_added: Vec<String>,
pub dependency_major_bumped: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schema",
schemars(title = "fallow audit --brief --format json")
)]
pub struct ReviewBriefOutput<Focus, Weakening, Routing, Decisions> {
pub schema_version: ReviewBriefSchemaVersion,
pub version: String,
pub command: String,
pub triage: DiffTriage,
pub graph_facts: GraphFacts,
pub partition: PartitionFacts,
pub impact_closure: ImpactClosureFacts,
pub focus: Focus,
pub deltas: ReviewDeltas,
pub weakening: Vec<Weakening>,
pub routing: Routing,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ownership: Option<crate::OwnershipFacts>,
pub decisions: Decisions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branching: Option<crate::BranchingReport>,
}
pub type StandardReviewBriefOutput = ReviewBriefOutput<
crate::audit_focus::FocusMap,
crate::audit_weakening::WeakeningSignal,
crate::audit_routing::RoutingFacts,
crate::audit_decision_surface::DecisionSurface,
>;
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ReviewBriefHeader<Verdict, Summary, Attribution> {
pub version: ToolVersion,
pub verdict: Verdict,
pub changed_files_count: u32,
pub base_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head_sha: Option<String>,
pub elapsed_ms: ElapsedMs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_snapshot_skipped: Option<bool>,
pub summary: Summary,
pub attribution: Attribution,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[cfg_attr(
feature = "schema",
schemars(title = "fallow audit --brief --format json")
)]
pub struct ReviewBriefWireOutput<
Focus,
Weakening,
Routing,
Decisions,
Verdict,
Summary,
Attribution,
DeadCode,
Duplication,
Complexity,
> {
pub schema_version: ReviewBriefSchemaVersion,
pub version: ToolVersion,
pub command: String,
pub verdict: Verdict,
pub changed_files_count: u32,
pub base_ref: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head_sha: Option<String>,
pub elapsed_ms: ElapsedMs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_snapshot_skipped: Option<bool>,
pub summary: Summary,
pub attribution: Attribution,
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
pub decisions: Decisions,
pub triage: DiffTriage,
pub graph_facts: GraphFacts,
pub partition: PartitionFacts,
pub impact_closure: ImpactClosureFacts,
pub focus: Focus,
pub deltas: ReviewDeltas,
pub weakening: Vec<Weakening>,
pub routing: Routing,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ownership: Option<crate::OwnershipFacts>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dead_code: Option<DeadCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duplication: Option<Duplication>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub complexity: Option<Complexity>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branching: Option<crate::BranchingReport>,
}
#[derive(Debug, Clone, Default)]
pub struct ReviewBriefSubtractSections<DeadCode = Value, Duplication = Value, Complexity = Value> {
pub dead_code: Option<DeadCode>,
pub duplication: Option<Duplication>,
pub complexity: Option<Complexity>,
}
pub fn build_review_brief_json_output<
Focus,
Weakening,
Routing,
Decisions,
Verdict,
Summary,
Attribution,
DeadCode,
Duplication,
Complexity,
>(
brief: ReviewBriefOutput<Focus, Weakening, Routing, Decisions>,
header: ReviewBriefHeader<Verdict, Summary, Attribution>,
subtract: ReviewBriefSubtractSections<DeadCode, Duplication, Complexity>,
) -> Result<Value, serde_json::Error>
where
Focus: Serialize,
Weakening: Serialize,
Routing: Serialize,
Decisions: Serialize,
Verdict: Serialize,
Summary: Serialize,
Attribution: Serialize,
DeadCode: Serialize,
Duplication: Serialize,
Complexity: Serialize,
{
serde_json::to_value(ReviewBriefWireOutput {
schema_version: brief.schema_version,
version: header.version,
command: brief.command,
verdict: header.verdict,
changed_files_count: header.changed_files_count,
base_ref: header.base_ref,
base_description: header.base_description,
head_sha: header.head_sha,
elapsed_ms: header.elapsed_ms,
base_snapshot_skipped: header.base_snapshot_skipped,
summary: header.summary,
attribution: header.attribution,
meta: None,
decisions: brief.decisions,
triage: brief.triage,
graph_facts: brief.graph_facts,
partition: brief.partition,
impact_closure: brief.impact_closure,
focus: brief.focus,
deltas: brief.deltas,
weakening: brief.weakening,
routing: brief.routing,
ownership: brief.ownership,
dead_code: subtract.dead_code,
duplication: subtract.duplication,
complexity: subtract.complexity,
branching: brief.branching,
})
}
fn serialize_agent_contract_json_output<T: Serialize>(
output: T,
kind: &'static str,
analysis_run_id: Option<&str>,
) -> Result<Value, serde_json::Error> {
let mut value = serialize_named_json_output(output, kind)?;
attach_telemetry_meta(&mut value, analysis_run_id);
Ok(value)
}
pub fn serialize_review_brief_json_output<T: Serialize>(
output: T,
analysis_run_id: Option<&str>,
) -> Result<Value, serde_json::Error> {
serialize_agent_contract_json_output(output, "audit-brief", analysis_run_id)
}
pub fn serialize_decision_surface_json_output<T: Serialize>(
output: T,
analysis_run_id: Option<&str>,
) -> Result<Value, serde_json::Error> {
serialize_agent_contract_json_output(output, "decision-surface", analysis_run_id)
}
pub fn serialize_walkthrough_guide_json_output<T: Serialize>(
output: T,
analysis_run_id: Option<&str>,
) -> Result<Value, serde_json::Error> {
serialize_agent_contract_json_output(output, "review-walkthrough-guide", analysis_run_id)
}
pub fn serialize_walkthrough_validation_json_output<T: Serialize>(
output: T,
analysis_run_id: Option<&str>,
) -> Result<Value, serde_json::Error> {
serialize_agent_contract_json_output(output, "review-walkthrough-validation", analysis_run_id)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn brief_wire_value(branching: Option<crate::BranchingReport>) -> Value {
let brief = ReviewBriefOutput {
branching,
schema_version: ReviewBriefSchemaVersion::default(),
version: "1.2.3".to_string(),
command: "audit-brief".to_string(),
triage: DiffTriage {
files: 1,
hunks: None,
net_lines: None,
risk_class: RiskClass::Low,
review_effort: ReviewEffort::Glance,
},
graph_facts: GraphFacts {
exports_added: 0,
api_width_delta: 0,
boundaries_touched: Vec::new(),
},
partition: PartitionFacts::default(),
impact_closure: ImpactClosureFacts::default(),
focus: json!({"units": []}),
deltas: ReviewDeltas::default(),
weakening: Vec::<Value>::new(),
routing: json!({"units": []}),
ownership: None,
decisions: json!({"decisions": []}),
};
let header = ReviewBriefHeader {
version: ToolVersion("1.2.3".to_string()),
verdict: json!("fail"),
changed_files_count: 1,
base_ref: "main".to_string(),
base_description: Some("merge base".to_string()),
head_sha: Some("abc123".to_string()),
elapsed_ms: ElapsedMs(12),
base_snapshot_skipped: Some(false),
summary: json!({"dead_code_issues": 0}),
attribution: json!({"gate": "new_only"}),
};
build_review_brief_json_output(
brief,
header,
ReviewBriefSubtractSections::<Value, Value, Value> {
dead_code: Some(json!({"issues": []})),
duplication: None,
complexity: None,
},
)
.expect("brief output should serialize")
}
#[test]
fn review_brief_json_output_assembles_typed_wire_contract() {
let value = brief_wire_value(None);
assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
assert_eq!(value["command"], "audit-brief");
assert_eq!(value["verdict"], "fail");
assert_eq!(value["base_ref"], "main");
assert_eq!(value["summary"]["dead_code_issues"], 0);
assert_eq!(value["attribution"]["gate"], "new_only");
assert_eq!(value["dead_code"]["issues"], json!([]));
assert!(
value.get("branching").is_none(),
"absent when no base comparison ran, so the wire shape is unchanged for a consumer that never had a base snapshot"
);
}
#[test]
fn review_brief_json_output_carries_the_branching_block() {
let base: crate::BranchingSnapshot = std::iter::once((
"src/a.ts".to_string(),
fallow_types::extract::FileBranching {
branch_points: 12,
functions: 1,
peak_cyclomatic: 13,
cognitive: 12,
cognitive_nesting_weight: 6,
has_module_unit: false,
has_synthetic_units: false,
},
))
.collect();
let head: crate::BranchingSnapshot = std::iter::once((
"src/a.ts".to_string(),
fallow_types::extract::FileBranching {
branch_points: 12,
functions: 5,
peak_cyclomatic: 4,
cognitive: 6,
cognitive_nesting_weight: 0,
has_module_unit: false,
has_synthetic_units: false,
},
))
.collect();
let report = crate::BranchingReport::compare(
&base,
&head,
crate::DEFAULT_BRANCHING_TOLERANCE,
&|_| false,
);
let value = brief_wire_value(Some(report));
assert_eq!(
value["branching"]["split_in_place"][0]["path"], "src/a.ts",
"the local claim reaches the wire, not only the digest"
);
assert_eq!(
value["branching"]["split_in_place"][0]["functions_after"],
5
);
assert!(
!value["branching"]
.as_object()
.expect("branching is an object")
.contains_key("verdict"),
"there is no changeset-level verdict to publish"
);
assert_eq!(value["branching"]["branch_points"]["delta"], 0);
assert_eq!(value["branching"]["functions"]["delta"], 4);
assert_eq!(value["branching"]["peak_unit_cyclomatic"]["delta"], -9);
assert_eq!(
value["branching"]["cognitive"]["attributed_to"],
"nesting-reset"
);
assert_eq!(value["branching"]["tolerance"], 2);
assert_eq!(value["branching"]["by_file"][0]["path"], "src/a.ts");
}
#[test]
fn review_brief_serializer_owns_root_contract() {
let value = serialize_review_brief_json_output(
json!({"command": "audit-brief"}),
Some("run-brief"),
)
.expect("brief output should serialize");
assert_eq!(value["kind"], "audit-brief");
assert_eq!(value["_meta"]["telemetry"]["analysis_run_id"], "run-brief");
}
#[test]
fn decision_surface_serializer_owns_root_contract() {
let value =
serialize_decision_surface_json_output(json!({"decisions": []}), Some("run-decision"))
.expect("decision surface should serialize");
assert_eq!(value["kind"], "decision-surface");
assert_eq!(
value["_meta"]["telemetry"]["analysis_run_id"],
"run-decision"
);
}
fn affected(dirs: usize, per_dir: usize) -> Vec<String> {
let mut paths: Vec<String> = (0..dirs)
.flat_map(|d| (0..per_dir).map(move |f| format!("src/zone{d:03}/file{f:03}.ts")))
.collect();
paths.sort();
paths
}
#[test]
fn a_closure_within_the_caps_is_reported_whole() {
let paths = affected(2, 3);
let facts = ImpactClosureFacts::new(&paths, Vec::new());
assert_eq!(facts.affected_count, 6);
assert_eq!(facts.affected_not_shown, paths);
assert_eq!(facts.affected_by_dir_omitted, 0);
assert_eq!(
facts
.affected_by_dir
.iter()
.map(|row| (row.dir.as_str(), row.count))
.collect::<Vec<_>>(),
vec![("src/zone000", 3), ("src/zone001", 3)]
);
}
#[test]
fn the_count_survives_capping_the_sample() {
let paths = affected(4, 40);
let facts = ImpactClosureFacts::new(&paths, Vec::new());
assert_eq!(
facts.affected_count, 160,
"the magnitude is computed before the sample is capped"
);
assert_eq!(facts.affected_not_shown.len(), AFFECTED_SAMPLE_CAP);
assert_eq!(
facts
.affected_by_dir
.iter()
.map(|row| row.count)
.sum::<usize>(),
facts.affected_count,
"an uncapped rollup accounts for every affected file"
);
}
#[test]
fn the_rollup_carries_weight_the_sample_cannot() {
let mut paths = affected(1, 12);
paths.extend((0..90).map(|f| format!("src/zzz_heavy/file{f:03}.ts")));
paths.sort();
let facts = ImpactClosureFacts::new(&paths, Vec::new());
assert!(
facts
.affected_not_shown
.iter()
.all(|path| path.starts_with("src/zone000/")),
"the fixture must produce a one-directory sample: {:?}",
facts.affected_not_shown
);
let heaviest = facts.affected_by_dir.first().expect("a rollup row");
assert_eq!(
(heaviest.dir.as_str(), heaviest.count),
("src/zzz_heavy", 90)
);
}
#[test]
fn rollup_rows_beyond_the_cap_are_counted_not_dropped_silently() {
let paths = affected(AFFECTED_DIR_CAP + 7, 1);
let facts = ImpactClosureFacts::new(&paths, Vec::new());
assert_eq!(facts.affected_by_dir.len(), AFFECTED_DIR_CAP);
assert_eq!(facts.affected_by_dir_omitted, 7);
assert_eq!(facts.affected_count, AFFECTED_DIR_CAP + 7);
}
#[test]
fn equal_weight_directories_are_ordered_by_path() {
let facts = ImpactClosureFacts::new(&affected(3, 2), Vec::new());
let dirs: Vec<&str> = facts
.affected_by_dir
.iter()
.map(|row| row.dir.as_str())
.collect();
assert_eq!(
dirs,
vec!["src/zone000", "src/zone001", "src/zone002"],
"the path is the tie-break, so the order is total across runs"
);
}
#[test]
fn the_coordination_gap_is_never_capped() {
let symbols: Vec<String> = (0..40).map(|i| format!("symbol{i:02}")).collect();
let gaps: Vec<CoordinationGapFact> = (0..60)
.map(|i| CoordinationGapFact {
changed_file: "src/core.ts".to_string(),
consumer_file: format!("src/consumer{i:02}.ts"),
consumed_symbols: symbols.clone(),
note: String::new(),
})
.collect();
let facts = ImpactClosureFacts::new(&affected(40, 3), gaps);
assert_eq!(facts.coordination_gap.len(), 60);
assert!(
facts
.coordination_gap
.iter()
.all(|gap| gap.consumed_symbols.len() == 40),
"every consumed symbol survives, or the brief's json route is a lie"
);
assert!(
facts.affected_not_shown.len() < facts.affected_count
&& facts.affected_by_dir_omitted > 0,
"the fixture must show the sibling fields really are capped"
);
}
#[test]
fn root_level_files_roll_up_under_the_empty_directory() {
let facts =
ImpactClosureFacts::new(&["play.ts".to_string(), "setup.ts".to_string()], Vec::new());
assert_eq!(facts.affected_by_dir.len(), 1);
assert_eq!(facts.affected_by_dir[0].dir, "");
assert_eq!(facts.affected_by_dir[0].count, 2);
}
}