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,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DependencyChangeKind {
Added,
MajorBump,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyEntry {
pub name: String,
pub section: String,
pub from: Option<String>,
pub to: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DependencyAnchor {
pub manifest: String,
pub kind: DependencyChangeKind,
pub entries: Vec<DependencyEntry>,
pub importers: u64,
pub out_of_diff_importers: u64,
pub line: u32,
}
pub struct DecisionInputs<'a> {
pub deltas: &'a ReviewDeltas,
pub boundary_anchors: &'a [BoundaryAnchor],
pub coordination: &'a [CoordinationAnchor],
pub dependency_anchors: &'a [DependencyAnchor],
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,
reversibility_weight: Option<u64>,
}
const MAJOR_BUMP_REVERSIBILITY_WEIGHT: u64 = 3;
fn build_decision(spec: DecisionSpec, inputs: &DecisionInputs<'_>) -> Decision {
let DecisionSpec {
category,
candidate_key,
question,
anchor_file,
anchor_line,
blast,
internal_consumer_count,
reversibility_weight,
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(reversibility_weight.unwrap_or_else(|| 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();
append_boundary_decisions(&mut decisions, inputs);
append_public_api_decision(&mut decisions, inputs);
append_coordination_decisions(&mut decisions, inputs);
append_dependency_decisions(&mut decisions, inputs);
decisions
}
fn dependency_candidate_key(anchor: &DependencyAnchor) -> String {
let keys: Vec<String> = anchor
.entries
.iter()
.map(|entry| {
crate::dependency_deltas::dependency_delta_key(&anchor.manifest, anchor.kind, entry)
})
.collect();
keys.join("|")
}
fn section_tag(section: &str) -> &'static str {
match section {
"devDependencies" => " (dev)",
"optionalDependencies" => " (optional)",
"peerDependencies" => " (peer)",
_ => "",
}
}
fn dependency_names(anchor: &DependencyAnchor) -> String {
anchor
.entries
.iter()
.map(|entry| {
let tag = section_tag(&entry.section);
match (&anchor.kind, &entry.from) {
(DependencyChangeKind::MajorBump, Some(from)) => {
format!("`{}`{tag} {from} -> {}", entry.name, entry.to)
}
_ => format!("`{}`{tag}", entry.name),
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn dependency_question(anchor: &DependencyAnchor) -> String {
let count = anchor.entries.len();
let names = dependency_names(anchor);
let plural = if count == 1 { "y" } else { "ies" };
let reach = if anchor.importers == 0 {
"not imported by any in-repo module".to_string()
} else {
format!(
"imported by {} in-repo {}",
anchor.importers,
modules_word(anchor.importers)
)
};
match anchor.kind {
DependencyChangeKind::Added => format!(
"`{}` adds {count} third-party dependenc{plural} ({names}), {reach}. What does each replace, and who owns the new surface?",
anchor.manifest,
),
DependencyChangeKind::MajorBump if anchor.importers == 0 => format!(
"`{}` moves {count} dependenc{plural} across a major version ({names}), {reach}. Which changelog-listed changes reach the build, config, or types?",
anchor.manifest,
),
DependencyChangeKind::MajorBump => format!(
"`{}` moves {count} dependenc{plural} across a major version ({names}), {reach}. Which changelog-listed behavior changes reach those importers?",
anchor.manifest,
),
}
}
fn dependency_tradeoff(anchor: &DependencyAnchor) -> String {
let count = anchor.entries.len();
match anchor.kind {
DependencyChangeKind::Added => format!(
"Takes on {count} new maintenance and supply-chain surface{}; {} in-repo {} outside this diff already {} the added packages.",
if count == 1 { "" } else { "s" },
anchor.out_of_diff_importers,
modules_word(anchor.out_of_diff_importers),
agrees("import", anchor.out_of_diff_importers),
),
DependencyChangeKind::MajorBump => format!(
"A major bump is a behavior change nobody in this diff wrote; {} in-repo {} outside this diff {} the bumped packages and {} not in the review.",
anchor.out_of_diff_importers,
modules_word(anchor.out_of_diff_importers),
agrees("import", anchor.out_of_diff_importers),
if anchor.out_of_diff_importers == 1 {
"is"
} else {
"are"
},
),
}
}
fn append_dependency_decisions(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
for anchor in inputs.dependency_anchors {
if anchor.entries.is_empty() {
continue;
}
decisions.push(build_decision(
DecisionSpec {
category: DecisionCategory::Dependency,
candidate_key: dependency_candidate_key(anchor),
question: dependency_question(anchor),
tradeoff: dependency_tradeoff(anchor),
anchor_file: anchor.manifest.clone(),
anchor_line: anchor.line,
blast: anchor.importers,
internal_consumer_count: anchor.out_of_diff_importers,
reversibility_weight: match anchor.kind {
DependencyChangeKind::Added => None,
DependencyChangeKind::MajorBump => Some(MAJOR_BUMP_REVERSIBILITY_WEIGHT),
},
},
inputs,
));
}
}
fn append_boundary_decisions(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
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,
reversibility_weight: None,
},
inputs,
));
}
}
fn append_public_api_decision(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
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,
reversibility_weight: None,
},
inputs,
));
}
}
fn append_coordination_decisions(decisions: &mut Vec<Decision>, inputs: &DecisionInputs<'_>) {
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,
reversibility_weight: None,
},
inputs,
));
}
}
#[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(|| {
b.category
.reversibility_weight()
.cmp(&a.category.reversibility_weight())
})
.then_with(|| a.anchor_file.cmp(&b.anchor_file))
.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(),
dependency_added: Vec::new(),
dependency_major_bumped: Vec::new(),
}
}
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,
dependency_anchors: &[],
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 dependency_anchor_becomes_one_batched_dependency_decision() {
let deltas = deltas(&[], &[]);
let routing = empty_routing();
let anchors = vec![
DependencyAnchor {
manifest: "package.json".to_string(),
kind: DependencyChangeKind::MajorBump,
entries: vec![
DependencyEntry {
name: "react".to_string(),
section: "dependencies".to_string(),
from: Some("^18.2.0".to_string()),
to: "^19.0.0".to_string(),
},
DependencyEntry {
name: "zod".to_string(),
section: "dependencies".to_string(),
from: Some("^3.0.0".to_string()),
to: "^4.0.0".to_string(),
},
],
importers: 12,
out_of_diff_importers: 9,
line: 14,
},
DependencyAnchor {
manifest: "package.json".to_string(),
kind: DependencyChangeKind::Added,
entries: vec![DependencyEntry {
name: "dayjs".to_string(),
section: "dependencies".to_string(),
from: None,
to: "^1.11.0".to_string(),
}],
importers: 0,
out_of_diff_importers: 0,
line: 9,
},
];
let surface = extract_decision_surface(&DecisionInputs {
deltas: &deltas,
boundary_anchors: &[],
coordination: &[],
dependency_anchors: &anchors,
public_api_anchor_line: 0,
affected_not_shown: 0,
routing: &routing,
head_source: &no_source,
rename_old_path: &no_source,
internal_consumers: &no_consumers,
cap: 4,
});
assert_eq!(
surface.decisions.len(),
2,
"one decision per manifest per kind"
);
let bump = &surface.decisions[0];
assert_eq!(bump.category, DecisionCategory::Dependency);
assert_eq!(
bump.signal_key,
"package.json::react@^18.2.0->^19.0.0|package.json::zod@^3.0.0->^4.0.0"
);
assert_eq!(bump.anchor_file, "package.json");
assert_eq!(bump.anchor_line, 14);
assert_eq!(bump.blast, 12);
assert_eq!(bump.internal_consumer_count, 9);
assert_eq!(
bump.consequence,
12 * 3,
"a major bump ranks with a public-API change, not above it"
);
assert!(bump.question.contains("`react` ^18.2.0 -> ^19.0.0"));
assert!(bump.question.ends_with('?'));
assert!(
bump.tradeoff
.contains("9 in-repo modules outside this diff import")
);
let added = &surface.decisions[1];
assert_eq!(added.signal_key, "package.json::dayjs");
assert!(
added
.question
.contains("adds 1 third-party dependency (`dayjs`)")
);
assert!(
added
.question
.contains("not imported by any in-repo module"),
"zero importers points at build, config, or types, not at modules"
);
assert!(surface.accept_signal_id(&added.signal_id));
}
#[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: &[],
dependency_anchors: &[],
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,
dependency_anchors: &[],
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);
}
}