1use fallow_engine::graph::FocusFileFactsPaths;
37pub use fallow_output::{ConfidenceFlag, FocusLabel, FocusMap, FocusScore, FocusUnit};
38
39const REVIEW_HERE_THRESHOLD: u32 = 3;
44
45const FAN_IN_WEIGHT: u32 = 2;
49const FAN_OUT_WEIGHT: u32 = 1;
51const FAN_CAP: u32 = 5;
54const RISK_ZONE_WEIGHT: u32 = 2;
56const CHANGE_SHAPE_WEIGHT: u32 = 2;
58const SECURITY_TAINT_WEIGHT: u32 = 3;
60
61#[derive(Debug, Clone)]
64pub struct BoundaryZoneFile {
65 pub from_file: String,
67}
68
69pub struct FocusInputs<'a> {
73 pub graph_facts: &'a [FocusFileFactsPaths],
76 pub boundary_files: &'a [BoundaryZoneFile],
79 pub public_api_added: &'a [String],
83 pub coordination_changed_files: &'a [String],
87 pub taint_touched_files: &'a [String],
92}
93
94fn file_in_public_api(file: &str, public_api_added: &[String]) -> bool {
97 public_api_added
98 .iter()
99 .any(|key| key.split("::").next() == Some(file))
100}
101
102fn score_unit(facts: &FocusFileFactsPaths, inputs: &FocusInputs<'_>) -> FocusScore {
104 let fan_io =
105 facts.fan_in.min(FAN_CAP) * FAN_IN_WEIGHT + facts.fan_out.min(FAN_CAP) * FAN_OUT_WEIGHT;
106
107 let taint_touched = inputs.taint_touched_files.iter().any(|f| f == &facts.file);
108 let security_taint = if taint_touched {
109 SECURITY_TAINT_WEIGHT
110 } else {
111 0
112 };
113
114 let in_boundary = inputs
115 .boundary_files
116 .iter()
117 .any(|b| b.from_file == facts.file);
118 let in_public_api = file_in_public_api(&facts.file, inputs.public_api_added);
119 let zones = u32::from(in_boundary) + u32::from(in_public_api) + u32::from(taint_touched);
121 let risk_zone = zones * RISK_ZONE_WEIGHT;
122
123 let new_export = in_public_api;
127 let sig_change = inputs
128 .coordination_changed_files
129 .iter()
130 .any(|f| f == &facts.file);
131 let shapes = u32::from(new_export) + u32::from(sig_change);
132 let change_shape = shapes * CHANGE_SHAPE_WEIGHT;
133
134 let total = fan_io + security_taint + risk_zone + change_shape;
140
141 FocusScore {
142 fan_io,
143 security_taint,
144 risk_zone,
145 change_shape,
146 total,
147 }
148}
149
150fn build_reason(
152 facts: &FocusFileFactsPaths,
153 score: &FocusScore,
154 inputs: &FocusInputs<'_>,
155) -> String {
156 let mut parts: Vec<String> = Vec::new();
157 if facts.fan_in > 0 {
158 parts.push(format!(
159 "high fan-in ({} importer{})",
160 facts.fan_in,
161 if facts.fan_in == 1 { "" } else { "s" }
162 ));
163 }
164 if facts.fan_out > 0 {
165 parts.push(format!("fan-out {}", facts.fan_out));
166 }
167 if score.security_taint > 0 {
168 parts.push("on a security taint path".to_string());
169 }
170 if inputs
171 .boundary_files
172 .iter()
173 .any(|b| b.from_file == facts.file)
174 {
175 parts.push("introduces a cross-zone edge".to_string());
176 }
177 if file_in_public_api(&facts.file, inputs.public_api_added) {
178 parts.push("widens the public API".to_string());
179 }
180 if inputs
181 .coordination_changed_files
182 .iter()
183 .any(|f| f == &facts.file)
184 {
185 parts.push("changes a contract consumed outside the diff".to_string());
186 }
187 if parts.is_empty() {
188 "isolated change, no blast beyond the diff".to_string()
189 } else {
190 parts.join(", ")
191 }
192}
193
194fn confidence_flags(facts: &FocusFileFactsPaths) -> Vec<ConfidenceFlag> {
196 let mut flags: Vec<ConfidenceFlag> = Vec::new();
197 if facts.dynamic_dispatch {
198 flags.push(ConfidenceFlag::DynamicDispatch);
199 }
200 if facts.re_export_indirection {
201 flags.push(ConfidenceFlag::ReExportIndirection);
202 }
203 flags
204}
205
206#[must_use]
217pub fn build_focus_map(inputs: &FocusInputs<'_>) -> FocusMap {
218 let mut units: Vec<FocusUnit> = inputs
219 .graph_facts
220 .iter()
221 .map(|facts| {
222 let score = score_unit(facts, inputs);
223 let label = if score.total >= REVIEW_HERE_THRESHOLD {
224 FocusLabel::ReviewHere
225 } else {
226 FocusLabel::NotPrioritized
227 };
228 let reason = build_reason(facts, &score, inputs);
229 FocusUnit {
230 file: facts.file.clone(),
231 score,
232 label,
233 reason,
234 confidence: confidence_flags(facts),
235 }
236 })
237 .collect();
238
239 units.sort_by(|a, b| {
241 b.score
242 .total
243 .cmp(&a.score.total)
244 .then_with(|| a.file.cmp(&b.file))
245 });
246
247 let mut review_here: Vec<FocusUnit> = Vec::new();
248 let mut deprioritized: Vec<FocusUnit> = Vec::new();
249 for unit in units {
250 match unit.label {
251 FocusLabel::ReviewHere => review_here.push(unit),
252 FocusLabel::NotPrioritized => deprioritized.push(unit),
253 }
254 }
255 deprioritized.sort_by(|a, b| a.file.cmp(&b.file));
257
258 FocusMap {
259 review_here,
260 deprioritized,
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 fn facts(
269 file: &str,
270 fan_in: u32,
271 fan_out: u32,
272 dynamic: bool,
273 re_export: bool,
274 ) -> FocusFileFactsPaths {
275 FocusFileFactsPaths {
276 file: file.to_string(),
277 fan_in,
278 fan_out,
279 dynamic_dispatch: dynamic,
280 re_export_indirection: re_export,
281 }
282 }
283
284 fn inputs<'a>(
285 graph_facts: &'a [FocusFileFactsPaths],
286 boundary_files: &'a [BoundaryZoneFile],
287 public_api_added: &'a [String],
288 coordination_changed_files: &'a [String],
289 taint_touched_files: &'a [String],
290 ) -> FocusInputs<'a> {
291 FocusInputs {
292 graph_facts,
293 boundary_files,
294 public_api_added,
295 coordination_changed_files,
296 taint_touched_files,
297 }
298 }
299
300 #[test]
303 fn no_skip_label_ever_emitted_in_free_mode() {
304 let gf = vec![
305 facts("src/hot.ts", 12, 3, false, false), facts("src/iso.ts", 0, 0, false, false), ];
308 let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
309 let all_units: Vec<&FocusUnit> = map
310 .review_here
311 .iter()
312 .chain(map.deprioritized.iter())
313 .collect();
314 assert!(!all_units.is_empty());
315 for unit in all_units {
316 let token = unit.label.token();
317 assert_ne!(token, "skip", "free mode must never emit a skip label");
318 assert!(
319 token == "review-here" || token == "not-prioritized",
320 "unexpected label token {token}"
321 );
322 }
323 let json = serde_json::to_string(&map).expect("serialize");
325 assert!(
326 !json.contains("\"skip\""),
327 "serialized focus map leaked a skip label: {json}"
328 );
329 }
330
331 #[test]
334 fn escape_hatch_enumerates_every_deprioritized_unit() {
335 let gf = vec![
336 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), ];
341 let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
342 assert_eq!(
343 map.total_units(),
344 gf.len(),
345 "every unit must be reachable via review-here OR deprioritized"
346 );
347 assert!(!map.deprioritized.is_empty());
349 for d in &map.deprioritized {
351 assert!(
352 !map.review_here.iter().any(|r| r.file == d.file),
353 "{} is in both lists",
354 d.file
355 );
356 }
357 }
358
359 #[test]
362 fn dynamic_and_re_export_units_carry_low_confidence_flags() {
363 let gf = vec![
364 facts("src/dyn.ts", 0, 0, true, false),
365 facts("src/barrel.ts", 0, 0, false, true),
366 facts("src/both.ts", 0, 0, true, true),
367 ];
368 let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
369 let all: Vec<&FocusUnit> = map
370 .review_here
371 .iter()
372 .chain(map.deprioritized.iter())
373 .collect();
374 let find = |file: &str| all.iter().find(|u| u.file == file).expect("unit present");
375
376 let dyn_unit = find("src/dyn.ts");
377 assert!(
378 dyn_unit
379 .confidence
380 .contains(&ConfidenceFlag::DynamicDispatch),
381 "dynamic unit must carry the dynamic-dispatch flag"
382 );
383 assert_eq!(
384 ConfidenceFlag::DynamicDispatch.message(),
385 "low: dynamic dispatch detected"
386 );
387
388 let barrel = find("src/barrel.ts");
389 assert!(
390 barrel
391 .confidence
392 .contains(&ConfidenceFlag::ReExportIndirection),
393 "barrel unit must carry the re-export-indirection flag"
394 );
395 assert_eq!(
396 ConfidenceFlag::ReExportIndirection.message(),
397 "low: re-export indirection"
398 );
399
400 let both = find("src/both.ts");
401 assert_eq!(both.confidence.len(), 2, "both flags present");
402 }
403
404 #[test]
405 fn confidence_flag_never_lowers_the_score() {
406 let plain = facts("src/plain.ts", 5, 0, false, false);
408 let flagged = facts("src/flagged.ts", 5, 0, true, true);
409 let plain_map = build_focus_map(&inputs(&[plain], &[], &[], &[], &[]));
410 let flagged_map = build_focus_map(&inputs(&[flagged], &[], &[], &[], &[]));
411 let plain_total = plain_map
412 .review_here
413 .iter()
414 .chain(plain_map.deprioritized.iter())
415 .next()
416 .unwrap()
417 .score
418 .total;
419 let flagged_total = flagged_map
420 .review_here
421 .iter()
422 .chain(flagged_map.deprioritized.iter())
423 .next()
424 .unwrap()
425 .score
426 .total;
427 assert_eq!(
428 plain_total, flagged_total,
429 "flags are advisory, not a penalty"
430 );
431 }
432
433 #[test]
434 fn risk_zone_and_change_shape_signals_raise_the_score() {
435 let gf = vec![facts("src/api.ts", 0, 0, false, false)];
436 let public_api = vec!["src/api.ts::Widget".to_string()];
437 let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
438 let unit = map
439 .review_here
440 .iter()
441 .chain(map.deprioritized.iter())
442 .next()
443 .unwrap();
444 assert_eq!(unit.score.risk_zone, RISK_ZONE_WEIGHT);
446 assert_eq!(unit.score.change_shape, CHANGE_SHAPE_WEIGHT);
447 assert_eq!(unit.label, FocusLabel::ReviewHere);
448 assert!(unit.reason.contains("public API"));
449 }
450
451 #[test]
452 fn security_taint_seam_is_zero_with_empty_findings_and_lights_up_with_a_touch() {
453 let gf = vec![facts("src/sink.ts", 0, 0, false, false)];
454 let no_taint = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
456 let no_taint_unit = no_taint
457 .review_here
458 .iter()
459 .chain(no_taint.deprioritized.iter())
460 .next()
461 .unwrap();
462 assert_eq!(no_taint_unit.score.security_taint, 0);
463 assert_eq!(no_taint_unit.label, FocusLabel::NotPrioritized);
464
465 let touched = vec!["src/sink.ts".to_string()];
467 let with_taint = build_focus_map(&inputs(&gf, &[], &[], &[], &touched));
468 let taint_unit = with_taint
469 .review_here
470 .iter()
471 .chain(with_taint.deprioritized.iter())
472 .next()
473 .unwrap();
474 assert_eq!(taint_unit.score.security_taint, SECURITY_TAINT_WEIGHT);
475 assert_eq!(taint_unit.score.risk_zone, RISK_ZONE_WEIGHT);
477 assert_eq!(taint_unit.label, FocusLabel::ReviewHere);
478 }
479
480 #[test]
481 fn coordination_gap_drives_signature_change_shape() {
482 let gf = vec![facts("src/core.ts", 0, 0, false, false)];
483 let coordination = vec!["src/core.ts".to_string()];
484 let map = build_focus_map(&inputs(&gf, &[], &[], &coordination, &[]));
485 let unit = map
486 .review_here
487 .iter()
488 .chain(map.deprioritized.iter())
489 .next()
490 .unwrap();
491 assert_eq!(unit.score.change_shape, CHANGE_SHAPE_WEIGHT);
492 assert!(unit.reason.contains("contract consumed outside the diff"));
493 }
494
495 #[test]
496 fn focus_map_is_byte_identical_across_runs() {
497 let gf = vec![
498 facts("src/a.ts", 5, 2, true, false),
499 facts("src/b.ts", 0, 0, false, true),
500 facts("src/c.ts", 3, 1, false, false),
501 ];
502 let boundary = vec![BoundaryZoneFile {
503 from_file: "src/a.ts".to_string(),
504 }];
505 let public_api = vec!["src/c.ts::Thing".to_string()];
506 let first = build_focus_map(&inputs(&gf, &boundary, &public_api, &[], &[]));
507 let second = build_focus_map(&inputs(&gf, &boundary, &public_api, &[], &[]));
508 let s1 = serde_json::to_string_pretty(&first).unwrap();
509 let s2 = serde_json::to_string_pretty(&second).unwrap();
510 assert_eq!(s1, s2);
511 }
512
513 #[test]
514 fn review_here_is_ranked_by_score_descending() {
515 let gf = vec![
516 facts("src/low.ts", 2, 0, false, false), facts("src/high.ts", 12, 5, false, false), ];
519 let public_api = vec!["src/low.ts::X".to_string()];
520 let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
521 assert_eq!(map.review_here.len(), 2);
523 assert!(map.review_here[0].score.total >= map.review_here[1].score.total);
524 assert_eq!(map.review_here[0].file, "src/high.ts");
525 }
526
527 #[test]
534 fn focus_map_inputs_have_no_symbol_chain_or_trace_field() {
535 let empty_facts: &[FocusFileFactsPaths] = &[];
541 let empty_boundary: &[BoundaryZoneFile] = &[];
542 let empty_strings: &[String] = &[];
543 let FocusInputs {
544 graph_facts: _,
545 boundary_files: _,
546 public_api_added: _,
547 coordination_changed_files: _,
548 taint_touched_files: _,
549 } = inputs(
552 empty_facts,
553 empty_boundary,
554 empty_strings,
555 empty_strings,
556 empty_strings,
557 );
558
559 let gf = vec![facts("src/x.ts", 4, 2, false, false)];
562 let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
563 let unit = map
564 .review_here
565 .iter()
566 .chain(map.deprioritized.iter())
567 .next()
568 .unwrap();
569 let score = &unit.score;
570 assert_eq!(
571 score.total,
572 score.fan_io + score.security_taint + score.risk_zone + score.change_shape,
573 "the focus total must be the four documented components only -- no symbol-chain term"
574 );
575 }
576}