pub use fallow_output::{
Decision, DecisionCategory, DecisionSurface, TruncationNote, build_decision_surface_output,
};
use xxhash_rust::xxh3::xxh3_64;
use fallow_output::{ReviewDeltas, RoutingFacts};
pub const DEFAULT_DECISION_CAP: usize = 4;
pub const MIN_DECISION_CAP: usize = 3;
pub const MAX_DECISION_CAP: usize = 5;
#[must_use]
pub fn derive_signal_id(category: DecisionCategory, candidate_key: &str) -> String {
let mut bytes = Vec::with_capacity(category.tag().len() + 1 + candidate_key.len());
bytes.extend_from_slice(category.tag().as_bytes());
bytes.push(0);
bytes.extend_from_slice(candidate_key.as_bytes());
format!("sig:{:016x}", xxh3_64(&bytes))
}
#[derive(Debug, Clone)]
pub struct BoundaryAnchor {
pub zone_pair_key: String,
pub from_file: String,
pub from_zone: String,
pub to_zone: String,
pub line: u32,
}
#[derive(Debug, Clone)]
pub struct CoordinationAnchor {
pub changed_file: String,
pub consumed_symbols: Vec<String>,
pub consumer_count: u64,
pub line: u32,
}
pub struct DecisionInputs<'a> {
pub deltas: &'a ReviewDeltas,
pub boundary_anchors: &'a [BoundaryAnchor],
pub coordination: &'a [CoordinationAnchor],
pub public_api_anchor_line: u32,
pub affected_not_shown: u64,
pub routing: &'a RoutingFacts,
pub head_source: &'a dyn Fn(&str) -> Option<String>,
pub rename_old_path: &'a dyn Fn(&str) -> Option<String>,
pub internal_consumers: &'a dyn Fn(&str) -> u64,
pub cap: usize,
}
fn route_for(routing: &RoutingFacts, anchor_file: &str) -> (Vec<String>, bool) {
routing
.units
.iter()
.find(|unit| unit.file == anchor_file)
.map_or((Vec::new(), false), |unit| {
(unit.expert.clone(), unit.bus_factor_one)
})
}
fn is_decision_suppressed(
head_source: Option<&str>,
category: DecisionCategory,
line: u32,
) -> bool {
let Some(source) = head_source else {
return false;
};
let lines: Vec<&str> = source.lines().collect();
let token_matches = |comment: &str| {
if !comment.contains("fallow-ignore") {
return false;
}
let after = comment
.split_once("fallow-ignore-file")
.or_else(|| comment.split_once("fallow-ignore-next-line"))
.map(|(_, rest)| rest.trim());
match after {
None => false,
Some("") => true,
Some(rest) => {
rest.contains("decision-surface")
|| rest.contains("decision-surfaces")
|| rest.contains(category.tag())
}
}
};
if lines
.iter()
.any(|l| l.contains("fallow-ignore-file") && token_matches(l))
{
return true;
}
if line >= 2
&& let Some(prev) = lines.get((line - 2) as usize)
&& prev.contains("fallow-ignore-next-line")
&& token_matches(prev)
{
return true;
}
false
}
fn boundary_question(from_zone: &str, to_zone: &str) -> String {
format!(
"`{from_zone}` now imports `{to_zone}` for the first time. Intended coupling, or should this edge not exist?"
)
}
fn public_api_question(count: usize) -> String {
format!(
"This change adds {count} export{} to the public API surface. Intended as maintained contracts, or should they stay internal?",
if count == 1 { "" } else { "s" }
)
}
fn coordination_question(changed_file: &str, symbols: &[String], consumers: u64) -> String {
format!(
"`{changed_file}` changes {} ({}) imported by {consumers} {} outside this PR. Does this change break or alter what those callers expect?",
if symbols.len() == 1 {
"export"
} else {
"exports"
},
symbols.join(", "),
if consumers == 1 { "file" } else { "files" }
)
}
fn modules_word(n: u64) -> &'static str {
if n == 1 { "module" } else { "modules" }
}
fn agrees(verb_plural: &str, n: u64) -> String {
if n == 1 {
format!("{verb_plural}s")
} else {
verb_plural.to_string()
}
}
fn boundary_tradeoff(from_zone: &str, to_zone: &str, consumers: u64) -> String {
format!(
"Couples `{from_zone}` to `{to_zone}`; {consumers} in-repo {} already {} on this anchor.",
modules_word(consumers),
agrees("depend", consumers)
)
}
fn public_api_tradeoff(count: usize, consumers: u64) -> String {
format!(
"Adds {count} maintained contract{}; {consumers} in-repo {} already {} this surface, and any external consumers become a contract you cannot remove without a breaking change.",
if count == 1 { "" } else { "s" },
modules_word(consumers),
agrees("consume", consumers)
)
}
fn coordination_tradeoff(consumers: u64) -> String {
format!(
"{consumers} {} outside the diff {} this contract; changing its shape requires coordinating them.",
modules_word(consumers),
agrees("consume", consumers)
)
}
struct DecisionSpec {
category: DecisionCategory,
candidate_key: String,
question: String,
anchor_file: String,
anchor_line: u32,
blast: u64,
internal_consumer_count: u64,
tradeoff: String,
}
fn build_decision(spec: DecisionSpec, inputs: &DecisionInputs<'_>) -> Decision {
let DecisionSpec {
category,
candidate_key,
question,
anchor_file,
anchor_line,
blast,
internal_consumer_count,
tradeoff,
} = spec;
let signal_id = derive_signal_id(category, &candidate_key);
let previous_signal_id = remap_key_paths(&candidate_key, inputs.rename_old_path)
.map(|old_key| derive_signal_id(category, &old_key));
let (expert, bus_factor_one) = route_for(inputs.routing, &anchor_file);
let consequence = blast.saturating_mul(category.reversibility_weight());
Decision {
signal_id,
category,
question,
anchor_file,
anchor_line,
signal_key: candidate_key,
previous_signal_id,
blast,
consequence,
expert,
bus_factor_one,
internal_consumer_count,
tradeoff,
}
}
fn remap_key_paths(key: &str, rename_old_path: &dyn Fn(&str) -> Option<String>) -> Option<String> {
let mut moved = false;
let mut parts: Vec<String> = key
.split('|')
.map(|segment| {
if let Some(path) = segment.strip_prefix("contract:")
&& let Some(old) = rename_old_path(path)
{
moved = true;
return format!("contract:{old}");
} else if let Some((path, name)) = segment.split_once("::")
&& let Some(old) = rename_old_path(path)
{
moved = true;
return format!("{old}::{name}");
}
segment.to_string()
})
.collect();
if !moved {
return None;
}
parts.sort();
Some(parts.join("|"))
}
fn classify_candidates(inputs: &DecisionInputs<'_>) -> Vec<Decision> {
let mut decisions: Vec<Decision> = Vec::new();
for key in &inputs.deltas.boundary_introduced {
let anchor = inputs
.boundary_anchors
.iter()
.find(|a| &a.zone_pair_key == key);
let (anchor_file, anchor_line, from_zone, to_zone) = anchor.map_or_else(
|| (String::new(), 0, key.clone(), String::new()),
|a| {
(
a.from_file.clone(),
a.line,
a.from_zone.clone(),
a.to_zone.clone(),
)
},
);
let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
decisions.push(build_decision(
DecisionSpec {
category: DecisionCategory::CouplingBoundary,
candidate_key: key.clone(),
question: boundary_question(&from_zone, &to_zone),
tradeoff: boundary_tradeoff(&from_zone, &to_zone, internal_consumer_count),
anchor_file,
anchor_line,
blast: inputs.affected_not_shown,
internal_consumer_count,
},
inputs,
));
}
if !inputs.deltas.public_api_added.is_empty() {
let key = inputs.deltas.public_api_added.join("|");
let anchor_file = inputs
.deltas
.public_api_added
.first()
.and_then(|k| k.split("::").next())
.map(str::to_string)
.unwrap_or_default();
let internal_consumer_count = (inputs.internal_consumers)(&anchor_file);
decisions.push(build_decision(
DecisionSpec {
category: DecisionCategory::PublicApiContract,
candidate_key: key,
question: public_api_question(inputs.deltas.public_api_added.len()),
tradeoff: public_api_tradeoff(
inputs.deltas.public_api_added.len(),
internal_consumer_count,
),
anchor_file,
anchor_line: inputs.public_api_anchor_line,
blast: inputs.affected_not_shown,
internal_consumer_count,
},
inputs,
));
}
for gap in inputs.coordination {
let key = format!("contract:{}", gap.changed_file);
decisions.push(build_decision(
DecisionSpec {
category: DecisionCategory::PublicApiContract,
candidate_key: key,
question: coordination_question(
&gap.changed_file,
&gap.consumed_symbols,
gap.consumer_count,
),
tradeoff: coordination_tradeoff(gap.consumer_count),
anchor_file: gap.changed_file.clone(),
anchor_line: gap.line,
blast: gap.consumer_count,
internal_consumer_count: gap.consumer_count,
},
inputs,
));
}
decisions
}
#[must_use]
pub fn extract_decision_surface(inputs: &DecisionInputs<'_>) -> DecisionSurface {
let cap = inputs.cap.clamp(MIN_DECISION_CAP, MAX_DECISION_CAP);
let mut classified = classify_candidates(inputs);
let emitted_signal_ids: Vec<String> = classified.iter().map(|d| d.signal_id.clone()).collect();
classified.retain(|d| {
let source = (inputs.head_source)(&d.anchor_file);
!is_decision_suppressed(source.as_deref(), d.category, d.anchor_line)
});
classified.sort_by(|a, b| {
b.consequence
.cmp(&a.consequence)
.then_with(|| a.signal_id.cmp(&b.signal_id))
});
let total = classified.len();
let truncated = if total > cap {
let collapsed = total - cap;
classified.truncate(cap);
Some(TruncationNote {
collapsed,
reason: format!(
"{collapsed} more structural decision{} collapsed below the cap of {cap}",
if collapsed == 1 { "" } else { "s" }
),
})
} else {
None
};
DecisionSurface {
decisions: classified,
truncated,
emitted_signal_ids,
}
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_output::RoutingUnit;
fn deltas(boundary: &[&str], public_api: &[&str]) -> ReviewDeltas {
ReviewDeltas {
boundary_introduced: boundary.iter().map(|s| (*s).to_string()).collect(),
cycle_introduced: Vec::new(),
public_api_added: public_api.iter().map(|s| (*s).to_string()).collect(),
}
}
fn no_source(_: &str) -> Option<String> {
None
}
fn no_consumers(_: &str) -> u64 {
0
}
fn inputs<'a>(
deltas: &'a ReviewDeltas,
boundary_anchors: &'a [BoundaryAnchor],
coordination: &'a [CoordinationAnchor],
routing: &'a RoutingFacts,
head_source: &'a dyn Fn(&str) -> Option<String>,
cap: usize,
) -> DecisionInputs<'a> {
DecisionInputs {
deltas,
boundary_anchors,
coordination,
public_api_anchor_line: 0,
affected_not_shown: 3,
routing,
head_source,
rename_old_path: &no_source,
internal_consumers: &no_consumers,
cap,
}
}
fn empty_routing() -> RoutingFacts {
RoutingFacts::default()
}
#[test]
fn only_three_categories_exist_no_cut_category_representable() {
let all = [
DecisionCategory::CouplingBoundary,
DecisionCategory::PublicApiContract,
DecisionCategory::Dependency,
];
assert_eq!(all.len(), 3);
for c in all {
let tag = c.tag();
for cut in ["abstraction", "deletion", "convention", "irreversib"] {
assert!(!tag.contains(cut), "cut category {cut} leaked into {tag}");
}
}
}
#[test]
fn every_decision_signal_id_resolves_to_an_emitted_candidate() {
let d = deltas(&["ui->-db"], &["src/api.ts::Widget"]);
let anchors = vec![BoundaryAnchor {
zone_pair_key: "ui->-db".to_string(),
from_file: "src/ui/page.ts".to_string(),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
line: 4,
}];
let routing = empty_routing();
let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
assert!(!surface.decisions.is_empty());
for decision in &surface.decisions {
assert!(
surface.accept_signal_id(&decision.signal_id),
"decision {} has an unanchored signal_id",
decision.question
);
}
}
#[test]
fn injected_unanchored_signal_id_is_rejected() {
let d = deltas(&["ui->-db"], &[]);
let anchors = vec![BoundaryAnchor {
zone_pair_key: "ui->-db".to_string(),
from_file: "src/ui/page.ts".to_string(),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
line: 1,
}];
let routing = empty_routing();
let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
assert!(!surface.accept_signal_id("sig:deadbeefdeadbeef"));
assert!(!surface.accept_signal_id("sig:0000000000000000"));
let real = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
assert!(surface.accept_signal_id(&real));
}
#[test]
fn over_cap_input_is_capped_with_truncation_reason() {
let d = deltas(&["a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x"], &[]);
let routing = empty_routing();
let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
assert_eq!(surface.decisions.len(), 4, "capped to default 4");
let note = surface.truncated.expect("truncation note present");
assert_eq!(note.collapsed, 2);
assert!(note.reason.contains("collapsed"));
assert!(note.reason.contains('2'));
}
#[test]
fn cap_is_clamped_to_the_4_plus_minus_1_band() {
let d = deltas(
&[
"a->-x", "b->-x", "c->-x", "d->-x", "e->-x", "f->-x", "g->-x",
],
&[],
);
let routing = empty_routing();
let high = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 10));
assert_eq!(high.decisions.len(), MAX_DECISION_CAP);
let low = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 1));
assert_eq!(low.decisions.len(), MIN_DECISION_CAP);
}
#[test]
fn fallow_ignore_suppresses_a_flagged_decision() {
let d = deltas(&["ui->-db"], &[]);
let anchors = vec![BoundaryAnchor {
zone_pair_key: "ui->-db".to_string(),
from_file: "src/ui/page.ts".to_string(),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
line: 3,
}];
let routing = empty_routing();
let unsuppressed =
extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
assert_eq!(unsuppressed.decisions.len(), 1);
let file_src = |f: &str| {
(f == "src/ui/page.ts").then(|| {
"// fallow-ignore-file decision-surface\nimport db from 'db';\n".to_string()
})
};
let suppressed =
extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &file_src, 4));
assert!(
suppressed.decisions.is_empty(),
"file-level ignore hides it"
);
let id = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
assert!(suppressed.accept_signal_id(&id));
let line_src = |f: &str| {
(f == "src/ui/page.ts").then(|| {
"line1\n// fallow-ignore-next-line decision-surface\nimport db from 'db';\n"
.to_string()
})
};
let line_suppressed =
extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &line_src, 4));
assert!(
line_suppressed.decisions.is_empty(),
"line-level ignore hides it"
);
}
#[test]
fn bare_blanket_ignore_suppresses_without_a_kind() {
let d = deltas(&["ui->-db"], &[]);
let anchors = vec![BoundaryAnchor {
zone_pair_key: "ui->-db".to_string(),
from_file: "src/ui/page.ts".to_string(),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
line: 2,
}];
let routing = empty_routing();
let bare = |f: &str| {
(f == "src/ui/page.ts")
.then(|| "// fallow-ignore-next-line\nimport db from 'db';\n".to_string())
};
let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &bare, 4));
assert!(surface.decisions.is_empty(), "bare blanket ignore hides it");
}
#[test]
fn unrelated_kind_ignore_does_not_suppress() {
let d = deltas(&["ui->-db"], &[]);
let anchors = vec![BoundaryAnchor {
zone_pair_key: "ui->-db".to_string(),
from_file: "src/ui/page.ts".to_string(),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
line: 2,
}];
let routing = empty_routing();
let other = |f: &str| {
(f == "src/ui/page.ts").then(|| {
"// fallow-ignore-next-line unused-export\nimport db from 'db';\n".to_string()
})
};
let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &other, 4));
assert_eq!(
surface.decisions.len(),
1,
"an ignore naming a different kind must not suppress a decision"
);
}
#[test]
fn routed_expert_is_paired_with_a_decision() {
let d = deltas(&["ui->-db"], &[]);
let anchors = vec![BoundaryAnchor {
zone_pair_key: "ui->-db".to_string(),
from_file: "src/ui/page.ts".to_string(),
from_zone: "ui".to_string(),
to_zone: "db".to_string(),
line: 1,
}];
let routing = RoutingFacts {
units: vec![RoutingUnit {
file: "src/ui/page.ts".to_string(),
expert: vec!["@team/ui".to_string()],
bus_factor_one: true,
}],
};
let surface = extract_decision_surface(&inputs(&d, &anchors, &[], &routing, &no_source, 4));
assert_eq!(surface.decisions.len(), 1);
assert_eq!(surface.decisions[0].expert, vec!["@team/ui".to_string()]);
assert!(surface.decisions[0].bus_factor_one);
}
#[test]
fn public_api_is_batch_consolidated_to_one_decision_r1() {
let keys: Vec<String> = (0..111).map(|i| format!("src/ui/index.ts::C{i}")).collect();
let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect();
let d = deltas(&[], &key_refs);
let routing = empty_routing();
let surface = extract_decision_surface(&inputs(&d, &[], &[], &routing, &no_source, 4));
let public_api_count = surface
.decisions
.iter()
.filter(|dec| dec.category == DecisionCategory::PublicApiContract)
.count();
assert_eq!(
public_api_count, 1,
"R1: one public-API decision per change"
);
assert!(surface.decisions[0].question.contains("111"));
}
#[test]
fn public_api_decision_carries_honest_consumer_count_and_tradeoff() {
let d = deltas(&[], &["src/ui/index.ts::Widget"]);
let routing = empty_routing();
let seven = |_: &str| 7u64;
let surface = extract_decision_surface(&DecisionInputs {
deltas: &d,
boundary_anchors: &[],
coordination: &[],
public_api_anchor_line: 0,
affected_not_shown: 99,
routing: &routing,
head_source: &no_source,
rename_old_path: &no_source,
internal_consumers: &seven,
cap: 4,
});
let dec = surface
.decisions
.iter()
.find(|dec| dec.category == DecisionCategory::PublicApiContract)
.expect("a public-API decision");
assert_eq!(dec.internal_consumer_count, 7, "honest per-anchor count");
assert_ne!(
dec.internal_consumer_count, dec.blast,
"display number must stay distinct from the ranking proxy"
);
assert!(
dec.tradeoff.contains("7 in-repo"),
"trade-off clause states the count as a fact: {}",
dec.tradeoff
);
assert!(
dec.question.ends_with('?'),
"the decision stays a question (taste ownership)"
);
}
#[test]
fn coordination_gap_becomes_a_public_api_contract_decision() {
let d = deltas(&[], &[]);
let coordination = vec![CoordinationAnchor {
changed_file: "src/core.ts".to_string(),
consumed_symbols: vec!["compute".to_string()],
consumer_count: 4,
line: 7,
}];
let routing = empty_routing();
let surface =
extract_decision_surface(&inputs(&d, &[], &coordination, &routing, &no_source, 4));
assert_eq!(surface.decisions.len(), 1);
assert_eq!(
surface.decisions[0].category,
DecisionCategory::PublicApiContract
);
assert_eq!(surface.decisions[0].blast, 4);
assert_eq!(surface.decisions[0].anchor_line, 7);
assert!(surface.decisions[0].previous_signal_id.is_none());
}
#[test]
fn renamed_anchor_carries_a_previous_signal_id_for_review_memory() {
let d = deltas(&[], &[]);
let coordination = vec![CoordinationAnchor {
changed_file: "src/new.ts".to_string(),
consumed_symbols: vec!["compute".to_string()],
consumer_count: 2,
line: 0,
}];
let routing = empty_routing();
let rename = |rel: &str| -> Option<String> {
(rel == "src/new.ts").then(|| "src/old.ts".to_string())
};
let surface = extract_decision_surface(&DecisionInputs {
deltas: &d,
boundary_anchors: &[],
coordination: &coordination,
public_api_anchor_line: 0,
affected_not_shown: 2,
routing: &routing,
head_source: &no_source,
rename_old_path: &rename,
internal_consumers: &no_consumers,
cap: 4,
});
assert_eq!(surface.decisions.len(), 1);
let decision = &surface.decisions[0];
assert_eq!(
decision.signal_id,
derive_signal_id(DecisionCategory::PublicApiContract, "contract:src/new.ts")
);
assert_eq!(
decision.previous_signal_id,
Some(derive_signal_id(
DecisionCategory::PublicApiContract,
"contract:src/old.ts"
))
);
}
#[test]
fn signal_id_is_deterministic_and_namespaced_by_category() {
let a = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
let b = derive_signal_id(DecisionCategory::CouplingBoundary, "ui->-db");
assert_eq!(a, b, "deterministic");
let c = derive_signal_id(DecisionCategory::PublicApiContract, "ui->-db");
assert_ne!(a, c, "category namespaces the hash");
assert!(a.starts_with("sig:"));
}
#[test]
fn consequence_ranks_less_reversible_categories_higher() {
let dep = DecisionCategory::Dependency.reversibility_weight();
let api = DecisionCategory::PublicApiContract.reversibility_weight();
let coupling = DecisionCategory::CouplingBoundary.reversibility_weight();
assert!(dep > api && api > coupling);
}
}