use serde::Serialize;
use fallow_core::graph::FocusFileFactsPaths;
const REVIEW_HERE_THRESHOLD: u32 = 3;
const FAN_IN_WEIGHT: u32 = 2;
const FAN_OUT_WEIGHT: u32 = 1;
const FAN_CAP: u32 = 5;
const RISK_ZONE_WEIGHT: u32 = 2;
const CHANGE_SHAPE_WEIGHT: u32 = 2;
const SECURITY_TAINT_WEIGHT: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum FocusLabel {
ReviewHere,
NotPrioritized,
}
impl FocusLabel {
#[must_use]
pub const fn token(self) -> &'static str {
match self {
Self::ReviewHere => "review-here",
Self::NotPrioritized => "not-prioritized",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
pub enum ConfidenceFlag {
DynamicDispatch,
ReExportIndirection,
}
impl ConfidenceFlag {
#[must_use]
pub const fn message(self) -> &'static str {
match self {
Self::DynamicDispatch => "low: dynamic dispatch detected",
Self::ReExportIndirection => "low: re-export indirection",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct FocusScore {
pub fan_io: u32,
pub security_taint: u32,
pub risk_zone: u32,
pub change_shape: u32,
pub total: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct FocusUnit {
pub file: String,
pub score: FocusScore,
pub label: FocusLabel,
pub reason: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub confidence: Vec<ConfidenceFlag>,
}
#[derive(Debug, Clone, Default, Serialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct FocusMap {
pub review_here: Vec<FocusUnit>,
pub deprioritized: Vec<FocusUnit>,
}
impl FocusMap {
#[must_use]
pub fn total_units(&self) -> usize {
self.review_here.len() + self.deprioritized.len()
}
}
#[derive(Debug, Clone)]
pub struct BoundaryZoneFile {
pub from_file: String,
}
pub struct FocusInputs<'a> {
pub graph_facts: &'a [FocusFileFactsPaths],
pub boundary_files: &'a [BoundaryZoneFile],
pub public_api_added: &'a [String],
pub coordination_changed_files: &'a [String],
pub taint_touched_files: &'a [String],
}
fn file_in_public_api(file: &str, public_api_added: &[String]) -> bool {
public_api_added
.iter()
.any(|key| key.split("::").next() == Some(file))
}
fn score_unit(facts: &FocusFileFactsPaths, inputs: &FocusInputs<'_>) -> FocusScore {
let fan_io =
facts.fan_in.min(FAN_CAP) * FAN_IN_WEIGHT + facts.fan_out.min(FAN_CAP) * FAN_OUT_WEIGHT;
let taint_touched = inputs.taint_touched_files.iter().any(|f| f == &facts.file);
let security_taint = if taint_touched {
SECURITY_TAINT_WEIGHT
} else {
0
};
let in_boundary = inputs
.boundary_files
.iter()
.any(|b| b.from_file == facts.file);
let in_public_api = file_in_public_api(&facts.file, inputs.public_api_added);
let zones = u32::from(in_boundary) + u32::from(in_public_api) + u32::from(taint_touched);
let risk_zone = zones * RISK_ZONE_WEIGHT;
let new_export = in_public_api;
let sig_change = inputs
.coordination_changed_files
.iter()
.any(|f| f == &facts.file);
let shapes = u32::from(new_export) + u32::from(sig_change);
let change_shape = shapes * CHANGE_SHAPE_WEIGHT;
let total = fan_io + security_taint + risk_zone + change_shape;
FocusScore {
fan_io,
security_taint,
risk_zone,
change_shape,
total,
}
}
fn build_reason(
facts: &FocusFileFactsPaths,
score: &FocusScore,
inputs: &FocusInputs<'_>,
) -> String {
let mut parts: Vec<String> = Vec::new();
if facts.fan_in > 0 {
parts.push(format!(
"high fan-in ({} importer{})",
facts.fan_in,
if facts.fan_in == 1 { "" } else { "s" }
));
}
if facts.fan_out > 0 {
parts.push(format!("fan-out {}", facts.fan_out));
}
if score.security_taint > 0 {
parts.push("on a security taint path".to_string());
}
if inputs
.boundary_files
.iter()
.any(|b| b.from_file == facts.file)
{
parts.push("introduces a cross-zone edge".to_string());
}
if file_in_public_api(&facts.file, inputs.public_api_added) {
parts.push("widens the public API".to_string());
}
if inputs
.coordination_changed_files
.iter()
.any(|f| f == &facts.file)
{
parts.push("changes a contract consumed outside the diff".to_string());
}
if parts.is_empty() {
"isolated change, no blast beyond the diff".to_string()
} else {
parts.join(", ")
}
}
fn confidence_flags(facts: &FocusFileFactsPaths) -> Vec<ConfidenceFlag> {
let mut flags: Vec<ConfidenceFlag> = Vec::new();
if facts.dynamic_dispatch {
flags.push(ConfidenceFlag::DynamicDispatch);
}
if facts.re_export_indirection {
flags.push(ConfidenceFlag::ReExportIndirection);
}
flags
}
#[must_use]
pub fn build_focus_map(inputs: &FocusInputs<'_>) -> FocusMap {
let mut units: Vec<FocusUnit> = inputs
.graph_facts
.iter()
.map(|facts| {
let score = score_unit(facts, inputs);
let label = if score.total >= REVIEW_HERE_THRESHOLD {
FocusLabel::ReviewHere
} else {
FocusLabel::NotPrioritized
};
let reason = build_reason(facts, &score, inputs);
FocusUnit {
file: facts.file.clone(),
score,
label,
reason,
confidence: confidence_flags(facts),
}
})
.collect();
units.sort_by(|a, b| {
b.score
.total
.cmp(&a.score.total)
.then_with(|| a.file.cmp(&b.file))
});
let mut review_here: Vec<FocusUnit> = Vec::new();
let mut deprioritized: Vec<FocusUnit> = Vec::new();
for unit in units {
match unit.label {
FocusLabel::ReviewHere => review_here.push(unit),
FocusLabel::NotPrioritized => deprioritized.push(unit),
}
}
deprioritized.sort_by(|a, b| a.file.cmp(&b.file));
FocusMap {
review_here,
deprioritized,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn facts(
file: &str,
fan_in: u32,
fan_out: u32,
dynamic: bool,
re_export: bool,
) -> FocusFileFactsPaths {
FocusFileFactsPaths {
file: file.to_string(),
fan_in,
fan_out,
dynamic_dispatch: dynamic,
re_export_indirection: re_export,
}
}
fn inputs<'a>(
graph_facts: &'a [FocusFileFactsPaths],
boundary_files: &'a [BoundaryZoneFile],
public_api_added: &'a [String],
coordination_changed_files: &'a [String],
taint_touched_files: &'a [String],
) -> FocusInputs<'a> {
FocusInputs {
graph_facts,
boundary_files,
public_api_added,
coordination_changed_files,
taint_touched_files,
}
}
#[test]
fn no_skip_label_ever_emitted_in_free_mode() {
let gf = vec![
facts("src/hot.ts", 12, 3, false, false), facts("src/iso.ts", 0, 0, false, false), ];
let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
let all_units: Vec<&FocusUnit> = map
.review_here
.iter()
.chain(map.deprioritized.iter())
.collect();
assert!(!all_units.is_empty());
for unit in all_units {
let token = unit.label.token();
assert_ne!(token, "skip", "free mode must never emit a skip label");
assert!(
token == "review-here" || token == "not-prioritized",
"unexpected label token {token}"
);
}
let json = serde_json::to_string(&map).expect("serialize");
assert!(
!json.contains("\"skip\""),
"serialized focus map leaked a skip label: {json}"
);
}
#[test]
fn escape_hatch_enumerates_every_deprioritized_unit() {
let gf = vec![
facts("src/a.ts", 12, 4, false, false), facts("src/b.ts", 0, 0, false, false), facts("src/c.ts", 1, 0, false, false), facts("src/d.ts", 8, 0, false, false), ];
let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
assert_eq!(
map.total_units(),
gf.len(),
"every unit must be reachable via review-here OR deprioritized"
);
assert!(!map.deprioritized.is_empty());
for d in &map.deprioritized {
assert!(
!map.review_here.iter().any(|r| r.file == d.file),
"{} is in both lists",
d.file
);
}
}
#[test]
fn dynamic_and_re_export_units_carry_low_confidence_flags() {
let gf = vec![
facts("src/dyn.ts", 0, 0, true, false),
facts("src/barrel.ts", 0, 0, false, true),
facts("src/both.ts", 0, 0, true, true),
];
let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
let all: Vec<&FocusUnit> = map
.review_here
.iter()
.chain(map.deprioritized.iter())
.collect();
let find = |file: &str| all.iter().find(|u| u.file == file).expect("unit present");
let dyn_unit = find("src/dyn.ts");
assert!(
dyn_unit
.confidence
.contains(&ConfidenceFlag::DynamicDispatch),
"dynamic unit must carry the dynamic-dispatch flag"
);
assert_eq!(
ConfidenceFlag::DynamicDispatch.message(),
"low: dynamic dispatch detected"
);
let barrel = find("src/barrel.ts");
assert!(
barrel
.confidence
.contains(&ConfidenceFlag::ReExportIndirection),
"barrel unit must carry the re-export-indirection flag"
);
assert_eq!(
ConfidenceFlag::ReExportIndirection.message(),
"low: re-export indirection"
);
let both = find("src/both.ts");
assert_eq!(both.confidence.len(), 2, "both flags present");
}
#[test]
fn confidence_flag_never_lowers_the_score() {
let plain = facts("src/plain.ts", 5, 0, false, false);
let flagged = facts("src/flagged.ts", 5, 0, true, true);
let plain_map = build_focus_map(&inputs(&[plain], &[], &[], &[], &[]));
let flagged_map = build_focus_map(&inputs(&[flagged], &[], &[], &[], &[]));
let plain_total = plain_map
.review_here
.iter()
.chain(plain_map.deprioritized.iter())
.next()
.unwrap()
.score
.total;
let flagged_total = flagged_map
.review_here
.iter()
.chain(flagged_map.deprioritized.iter())
.next()
.unwrap()
.score
.total;
assert_eq!(
plain_total, flagged_total,
"flags are advisory, not a penalty"
);
}
#[test]
fn risk_zone_and_change_shape_signals_raise_the_score() {
let gf = vec![facts("src/api.ts", 0, 0, false, false)];
let public_api = vec!["src/api.ts::Widget".to_string()];
let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
let unit = map
.review_here
.iter()
.chain(map.deprioritized.iter())
.next()
.unwrap();
assert_eq!(unit.score.risk_zone, RISK_ZONE_WEIGHT);
assert_eq!(unit.score.change_shape, CHANGE_SHAPE_WEIGHT);
assert_eq!(unit.label, FocusLabel::ReviewHere);
assert!(unit.reason.contains("public API"));
}
#[test]
fn security_taint_seam_is_zero_with_empty_findings_and_lights_up_with_a_touch() {
let gf = vec![facts("src/sink.ts", 0, 0, false, false)];
let no_taint = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
let no_taint_unit = no_taint
.review_here
.iter()
.chain(no_taint.deprioritized.iter())
.next()
.unwrap();
assert_eq!(no_taint_unit.score.security_taint, 0);
assert_eq!(no_taint_unit.label, FocusLabel::NotPrioritized);
let touched = vec!["src/sink.ts".to_string()];
let with_taint = build_focus_map(&inputs(&gf, &[], &[], &[], &touched));
let taint_unit = with_taint
.review_here
.iter()
.chain(with_taint.deprioritized.iter())
.next()
.unwrap();
assert_eq!(taint_unit.score.security_taint, SECURITY_TAINT_WEIGHT);
assert_eq!(taint_unit.score.risk_zone, RISK_ZONE_WEIGHT);
assert_eq!(taint_unit.label, FocusLabel::ReviewHere);
}
#[test]
fn coordination_gap_drives_signature_change_shape() {
let gf = vec![facts("src/core.ts", 0, 0, false, false)];
let coordination = vec!["src/core.ts".to_string()];
let map = build_focus_map(&inputs(&gf, &[], &[], &coordination, &[]));
let unit = map
.review_here
.iter()
.chain(map.deprioritized.iter())
.next()
.unwrap();
assert_eq!(unit.score.change_shape, CHANGE_SHAPE_WEIGHT);
assert!(unit.reason.contains("contract consumed outside the diff"));
}
#[test]
fn focus_map_is_byte_identical_across_runs() {
let gf = vec![
facts("src/a.ts", 5, 2, true, false),
facts("src/b.ts", 0, 0, false, true),
facts("src/c.ts", 3, 1, false, false),
];
let boundary = vec![BoundaryZoneFile {
from_file: "src/a.ts".to_string(),
}];
let public_api = vec!["src/c.ts::Thing".to_string()];
let first = build_focus_map(&inputs(&gf, &boundary, &public_api, &[], &[]));
let second = build_focus_map(&inputs(&gf, &boundary, &public_api, &[], &[]));
let s1 = serde_json::to_string_pretty(&first).unwrap();
let s2 = serde_json::to_string_pretty(&second).unwrap();
assert_eq!(s1, s2);
}
#[test]
fn review_here_is_ranked_by_score_descending() {
let gf = vec![
facts("src/low.ts", 2, 0, false, false), facts("src/high.ts", 12, 5, false, false), ];
let public_api = vec!["src/low.ts::X".to_string()];
let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
assert_eq!(map.review_here.len(), 2);
assert!(map.review_here[0].score.total >= map.review_here[1].score.total);
assert_eq!(map.review_here[0].file, "src/high.ts");
}
#[test]
fn focus_map_inputs_have_no_symbol_chain_or_trace_field() {
let empty_facts: &[FocusFileFactsPaths] = &[];
let empty_boundary: &[BoundaryZoneFile] = &[];
let empty_strings: &[String] = &[];
let FocusInputs {
graph_facts: _,
boundary_files: _,
public_api_added: _,
coordination_changed_files: _,
taint_touched_files: _,
} = inputs(
empty_facts,
empty_boundary,
empty_strings,
empty_strings,
empty_strings,
);
let gf = vec![facts("src/x.ts", 4, 2, false, false)];
let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
let unit = map
.review_here
.iter()
.chain(map.deprioritized.iter())
.next()
.unwrap();
let score = &unit.score;
assert_eq!(
score.total,
score.fan_io + score.security_taint + score.risk_zone + score.change_shape,
"the focus total must be the four documented components only -- no symbol-chain term"
);
}
}