1use fallow_engine::module_graph::FocusFileFactsPaths;
40pub use fallow_output::{ConfidenceFlag, FocusLabel, FocusMap, FocusScore, FocusUnit};
41
42const REVIEW_HERE_THRESHOLD: u32 = 3;
47
48const FAN_IN_WEIGHT: u32 = 2;
52const FAN_OUT_WEIGHT: u32 = 1;
54const FAN_CAP: u32 = 5;
57const RISK_ZONE_WEIGHT: u32 = 2;
59const CHANGE_SHAPE_WEIGHT: u32 = 2;
61const SECURITY_TAINT_WEIGHT: u32 = 3;
63
64const RUNTIME_HOT_FLOOR: u32 = 3;
68const RUNTIME_HOT_WARM: u32 = 4;
70const RUNTIME_HOT_BLAZING: u32 = 6;
74const RUNTIME_WARM_INVOCATIONS: u64 = 100;
76const RUNTIME_BLAZING_INVOCATIONS: u64 = 1_000;
78
79fn runtime_weight(invocations: u64) -> u32 {
82 if invocations >= RUNTIME_BLAZING_INVOCATIONS {
83 RUNTIME_HOT_BLAZING
84 } else if invocations >= RUNTIME_WARM_INVOCATIONS {
85 RUNTIME_HOT_WARM
86 } else {
87 RUNTIME_HOT_FLOOR
88 }
89}
90
91#[derive(Debug, Clone)]
94pub struct BoundaryZoneFile {
95 pub from_file: String,
97}
98
99#[derive(Debug, Clone)]
102pub struct RuntimeHotFile {
103 pub file: String,
105 pub invocations: u64,
107}
108
109#[derive(Debug, Clone, Default)]
114pub struct RuntimeFocus {
115 pub hot_files: Vec<RuntimeHotFile>,
117 pub cold_files: Vec<String>,
121}
122
123pub struct FocusInputs<'a> {
127 pub graph_facts: &'a [FocusFileFactsPaths],
130 pub boundary_files: &'a [BoundaryZoneFile],
133 pub public_api_added: &'a [String],
137 pub coordination_changed_files: &'a [String],
141 pub taint_touched_files: &'a [String],
146 pub runtime: Option<&'a RuntimeFocus>,
150}
151
152fn file_in_public_api(file: &str, public_api_added: &[String]) -> bool {
155 public_api_added
156 .iter()
157 .any(|key| key.split("::").next() == Some(file))
158}
159
160fn score_unit(facts: &FocusFileFactsPaths, inputs: &FocusInputs<'_>) -> FocusScore {
162 let fan_io =
163 facts.fan_in.min(FAN_CAP) * FAN_IN_WEIGHT + facts.fan_out.min(FAN_CAP) * FAN_OUT_WEIGHT;
164
165 let taint_touched = inputs.taint_touched_files.iter().any(|f| f == &facts.file);
166 let security_taint = if taint_touched {
167 SECURITY_TAINT_WEIGHT
168 } else {
169 0
170 };
171
172 let in_boundary = inputs
173 .boundary_files
174 .iter()
175 .any(|b| b.from_file == facts.file);
176 let in_public_api = file_in_public_api(&facts.file, inputs.public_api_added);
177 let zones = u32::from(in_boundary) + u32::from(in_public_api) + u32::from(taint_touched);
179 let risk_zone = zones * RISK_ZONE_WEIGHT;
180
181 let new_export = in_public_api;
185 let sig_change = inputs
186 .coordination_changed_files
187 .iter()
188 .any(|f| f == &facts.file);
189 let shapes = u32::from(new_export) + u32::from(sig_change);
190 let change_shape = shapes * CHANGE_SHAPE_WEIGHT;
191
192 let runtime = inputs.runtime.map_or(0, |rt| {
198 rt.hot_files
199 .iter()
200 .find(|hot| hot.file == facts.file)
201 .map_or(0, |hot| runtime_weight(hot.invocations))
202 });
203
204 let total = fan_io + security_taint + risk_zone + change_shape + runtime;
205
206 FocusScore {
207 fan_io,
208 security_taint,
209 risk_zone,
210 change_shape,
211 runtime,
212 total,
213 }
214}
215
216fn is_safe_skip(facts: &FocusFileFactsPaths, score: &FocusScore, inputs: &FocusInputs<'_>) -> bool {
225 inputs.runtime.is_some_and(|rt| {
226 score.total == 0
227 && !facts.dynamic_dispatch
228 && !facts.re_export_indirection
229 && rt.cold_files.iter().any(|file| file == &facts.file)
230 })
231}
232
233fn build_reason(
235 facts: &FocusFileFactsPaths,
236 score: &FocusScore,
237 inputs: &FocusInputs<'_>,
238) -> String {
239 let mut parts: Vec<String> = Vec::new();
240 if let Some(rt) = inputs.runtime {
244 if let Some(hot) = rt.hot_files.iter().find(|hot| hot.file == facts.file) {
245 parts.push(format!(
246 "hot path ({} invocation{})",
247 hot.invocations,
248 if hot.invocations == 1 { "" } else { "s" }
249 ));
250 } else if rt.cold_files.iter().any(|file| file == &facts.file) {
251 parts.push("runtime-cold (no hot path)".to_string());
256 }
257 }
258 if facts.fan_in > 0 {
259 parts.push(format!(
260 "high fan-in ({} importer{})",
261 facts.fan_in,
262 if facts.fan_in == 1 { "" } else { "s" }
263 ));
264 }
265 if facts.fan_out > 0 {
266 parts.push(format!("fan-out {}", facts.fan_out));
267 }
268 if score.security_taint > 0 {
269 parts.push("on a security taint path".to_string());
270 }
271 if inputs
272 .boundary_files
273 .iter()
274 .any(|b| b.from_file == facts.file)
275 {
276 parts.push("introduces a cross-zone edge".to_string());
277 }
278 if file_in_public_api(&facts.file, inputs.public_api_added) {
279 parts.push("widens the public API".to_string());
280 }
281 if inputs
282 .coordination_changed_files
283 .iter()
284 .any(|f| f == &facts.file)
285 {
286 parts.push("changes a contract consumed outside the diff".to_string());
287 }
288 if parts.is_empty() {
289 "isolated change, no blast beyond the diff".to_string()
290 } else {
291 parts.join(", ")
292 }
293}
294
295fn confidence_flags(facts: &FocusFileFactsPaths) -> Vec<ConfidenceFlag> {
297 let mut flags: Vec<ConfidenceFlag> = Vec::new();
298 if facts.dynamic_dispatch {
299 flags.push(ConfidenceFlag::DynamicDispatch);
300 }
301 if facts.re_export_indirection {
302 flags.push(ConfidenceFlag::ReExportIndirection);
303 }
304 flags
305}
306
307#[must_use]
318pub fn build_focus_map(inputs: &FocusInputs<'_>) -> FocusMap {
319 let mut units: Vec<FocusUnit> = inputs
320 .graph_facts
321 .iter()
322 .map(|facts| {
323 let score = score_unit(facts, inputs);
324 let label = if is_safe_skip(facts, &score, inputs) {
328 FocusLabel::Skip
329 } else if score.total >= REVIEW_HERE_THRESHOLD {
330 FocusLabel::ReviewHere
331 } else {
332 FocusLabel::NotPrioritized
333 };
334 let reason = build_reason(facts, &score, inputs);
335 FocusUnit {
336 file: facts.file.clone(),
337 score,
338 label,
339 reason,
340 confidence: confidence_flags(facts),
341 }
342 })
343 .collect();
344
345 units.sort_by(|a, b| {
347 b.score
348 .total
349 .cmp(&a.score.total)
350 .then_with(|| a.file.cmp(&b.file))
351 });
352
353 let mut review_here: Vec<FocusUnit> = Vec::new();
354 let mut deprioritized: Vec<FocusUnit> = Vec::new();
355 for unit in units {
356 match unit.label {
357 FocusLabel::ReviewHere => review_here.push(unit),
358 FocusLabel::NotPrioritized | FocusLabel::Skip => deprioritized.push(unit),
361 }
362 }
363 deprioritized.sort_by(|a, b| a.file.cmp(&b.file));
365
366 FocusMap {
367 review_here,
368 deprioritized,
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 fn facts(
377 file: &str,
378 fan_in: u32,
379 fan_out: u32,
380 dynamic: bool,
381 re_export: bool,
382 ) -> FocusFileFactsPaths {
383 FocusFileFactsPaths {
384 file: file.to_string(),
385 fan_in,
386 fan_out,
387 dynamic_dispatch: dynamic,
388 re_export_indirection: re_export,
389 }
390 }
391
392 fn inputs<'a>(
393 graph_facts: &'a [FocusFileFactsPaths],
394 boundary_files: &'a [BoundaryZoneFile],
395 public_api_added: &'a [String],
396 coordination_changed_files: &'a [String],
397 taint_touched_files: &'a [String],
398 ) -> FocusInputs<'a> {
399 FocusInputs {
400 graph_facts,
401 boundary_files,
402 public_api_added,
403 coordination_changed_files,
404 taint_touched_files,
405 runtime: None,
406 }
407 }
408
409 fn runtime(hot: &[(&str, u64)], cold: &[&str]) -> RuntimeFocus {
411 RuntimeFocus {
412 hot_files: hot
413 .iter()
414 .map(|(file, invocations)| RuntimeHotFile {
415 file: (*file).to_string(),
416 invocations: *invocations,
417 })
418 .collect(),
419 cold_files: cold.iter().map(|file| (*file).to_string()).collect(),
420 }
421 }
422
423 fn inputs_rt<'a>(
425 graph_facts: &'a [FocusFileFactsPaths],
426 public_api_added: &'a [String],
427 taint_touched_files: &'a [String],
428 runtime: &'a RuntimeFocus,
429 ) -> FocusInputs<'a> {
430 FocusInputs {
431 graph_facts,
432 boundary_files: &[],
433 public_api_added,
434 coordination_changed_files: &[],
435 taint_touched_files,
436 runtime: Some(runtime),
437 }
438 }
439
440 #[test]
443 fn no_skip_label_ever_emitted_in_free_mode() {
444 let gf = vec![
445 facts("src/hot.ts", 12, 3, false, false), facts("src/iso.ts", 0, 0, false, false), ];
448 let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
449 let all_units: Vec<&FocusUnit> = map
450 .review_here
451 .iter()
452 .chain(map.deprioritized.iter())
453 .collect();
454 assert!(!all_units.is_empty());
455 for unit in all_units {
456 let token = unit.label.token();
457 assert_ne!(token, "skip", "free mode must never emit a skip label");
458 assert!(
459 token == "review-here" || token == "not-prioritized",
460 "unexpected label token {token}"
461 );
462 }
463 let json = serde_json::to_string(&map).expect("serialize");
465 assert!(
466 !json.contains("\"skip\""),
467 "serialized focus map leaked a skip label: {json}"
468 );
469 }
470
471 #[test]
474 fn escape_hatch_enumerates_every_deprioritized_unit() {
475 let gf = vec![
476 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), ];
481 let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
482 assert_eq!(
483 map.total_units(),
484 gf.len(),
485 "every unit must be reachable via review-here OR deprioritized"
486 );
487 assert!(!map.deprioritized.is_empty());
489 for d in &map.deprioritized {
491 assert!(
492 !map.review_here.iter().any(|r| r.file == d.file),
493 "{} is in both lists",
494 d.file
495 );
496 }
497 }
498
499 #[test]
502 fn dynamic_and_re_export_units_carry_low_confidence_flags() {
503 let gf = vec![
504 facts("src/dyn.ts", 0, 0, true, false),
505 facts("src/barrel.ts", 0, 0, false, true),
506 facts("src/both.ts", 0, 0, true, true),
507 ];
508 let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
509 let all: Vec<&FocusUnit> = map
510 .review_here
511 .iter()
512 .chain(map.deprioritized.iter())
513 .collect();
514 let find = |file: &str| all.iter().find(|u| u.file == file).expect("unit present");
515
516 let dyn_unit = find("src/dyn.ts");
517 assert!(
518 dyn_unit
519 .confidence
520 .contains(&ConfidenceFlag::DynamicDispatch),
521 "dynamic unit must carry the dynamic-dispatch flag"
522 );
523 assert_eq!(
524 ConfidenceFlag::DynamicDispatch.message(),
525 "low: dynamic dispatch detected"
526 );
527
528 let barrel = find("src/barrel.ts");
529 assert!(
530 barrel
531 .confidence
532 .contains(&ConfidenceFlag::ReExportIndirection),
533 "barrel unit must carry the re-export-indirection flag"
534 );
535 assert_eq!(
536 ConfidenceFlag::ReExportIndirection.message(),
537 "low: re-export indirection"
538 );
539
540 let both = find("src/both.ts");
541 assert_eq!(both.confidence.len(), 2, "both flags present");
542 }
543
544 #[test]
545 fn confidence_flag_never_lowers_the_score() {
546 let plain = facts("src/plain.ts", 5, 0, false, false);
548 let flagged = facts("src/flagged.ts", 5, 0, true, true);
549 let plain_map = build_focus_map(&inputs(&[plain], &[], &[], &[], &[]));
550 let flagged_map = build_focus_map(&inputs(&[flagged], &[], &[], &[], &[]));
551 let plain_total = plain_map
552 .review_here
553 .iter()
554 .chain(plain_map.deprioritized.iter())
555 .next()
556 .unwrap()
557 .score
558 .total;
559 let flagged_total = flagged_map
560 .review_here
561 .iter()
562 .chain(flagged_map.deprioritized.iter())
563 .next()
564 .unwrap()
565 .score
566 .total;
567 assert_eq!(
568 plain_total, flagged_total,
569 "flags are advisory, not a penalty"
570 );
571 }
572
573 #[test]
574 fn risk_zone_and_change_shape_signals_raise_the_score() {
575 let gf = vec![facts("src/api.ts", 0, 0, false, false)];
576 let public_api = vec!["src/api.ts::Widget".to_string()];
577 let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
578 let unit = map
579 .review_here
580 .iter()
581 .chain(map.deprioritized.iter())
582 .next()
583 .unwrap();
584 assert_eq!(unit.score.risk_zone, RISK_ZONE_WEIGHT);
586 assert_eq!(unit.score.change_shape, CHANGE_SHAPE_WEIGHT);
587 assert_eq!(unit.label, FocusLabel::ReviewHere);
588 assert!(unit.reason.contains("public API"));
589 }
590
591 #[test]
592 fn security_taint_seam_is_zero_with_empty_findings_and_lights_up_with_a_touch() {
593 let gf = vec![facts("src/sink.ts", 0, 0, false, false)];
594 let no_taint = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
596 let no_taint_unit = no_taint
597 .review_here
598 .iter()
599 .chain(no_taint.deprioritized.iter())
600 .next()
601 .unwrap();
602 assert_eq!(no_taint_unit.score.security_taint, 0);
603 assert_eq!(no_taint_unit.label, FocusLabel::NotPrioritized);
604
605 let touched = vec!["src/sink.ts".to_string()];
607 let with_taint = build_focus_map(&inputs(&gf, &[], &[], &[], &touched));
608 let taint_unit = with_taint
609 .review_here
610 .iter()
611 .chain(with_taint.deprioritized.iter())
612 .next()
613 .unwrap();
614 assert_eq!(taint_unit.score.security_taint, SECURITY_TAINT_WEIGHT);
615 assert_eq!(taint_unit.score.risk_zone, RISK_ZONE_WEIGHT);
617 assert_eq!(taint_unit.label, FocusLabel::ReviewHere);
618 }
619
620 #[test]
621 fn coordination_gap_drives_signature_change_shape() {
622 let gf = vec![facts("src/core.ts", 0, 0, false, false)];
623 let coordination = vec!["src/core.ts".to_string()];
624 let map = build_focus_map(&inputs(&gf, &[], &[], &coordination, &[]));
625 let unit = map
626 .review_here
627 .iter()
628 .chain(map.deprioritized.iter())
629 .next()
630 .unwrap();
631 assert_eq!(unit.score.change_shape, CHANGE_SHAPE_WEIGHT);
632 assert!(unit.reason.contains("contract consumed outside the diff"));
633 }
634
635 #[test]
636 fn focus_map_is_byte_identical_across_runs() {
637 let gf = vec![
638 facts("src/a.ts", 5, 2, true, false),
639 facts("src/b.ts", 0, 0, false, true),
640 facts("src/c.ts", 3, 1, false, false),
641 ];
642 let boundary = vec![BoundaryZoneFile {
643 from_file: "src/a.ts".to_string(),
644 }];
645 let public_api = vec!["src/c.ts::Thing".to_string()];
646 let first = build_focus_map(&inputs(&gf, &boundary, &public_api, &[], &[]));
647 let second = build_focus_map(&inputs(&gf, &boundary, &public_api, &[], &[]));
648 let s1 = serde_json::to_string_pretty(&first).unwrap();
649 let s2 = serde_json::to_string_pretty(&second).unwrap();
650 assert_eq!(s1, s2);
651 }
652
653 #[test]
654 fn review_here_is_ranked_by_score_descending() {
655 let gf = vec![
656 facts("src/low.ts", 2, 0, false, false), facts("src/high.ts", 12, 5, false, false), ];
659 let public_api = vec!["src/low.ts::X".to_string()];
660 let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
661 assert_eq!(map.review_here.len(), 2);
663 assert!(map.review_here[0].score.total >= map.review_here[1].score.total);
664 assert_eq!(map.review_here[0].file, "src/high.ts");
665 }
666
667 #[test]
674 fn focus_map_inputs_have_no_symbol_chain_or_trace_field() {
675 let empty_facts: &[FocusFileFactsPaths] = &[];
681 let empty_boundary: &[BoundaryZoneFile] = &[];
682 let empty_strings: &[String] = &[];
683 let FocusInputs {
684 graph_facts: _,
685 boundary_files: _,
686 public_api_added: _,
687 coordination_changed_files: _,
688 taint_touched_files: _,
689 runtime: _,
694 } = inputs(
695 empty_facts,
696 empty_boundary,
697 empty_strings,
698 empty_strings,
699 empty_strings,
700 );
701
702 let gf = vec![facts("src/x.ts", 4, 2, false, false)];
705 let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
706 let unit = map
707 .review_here
708 .iter()
709 .chain(map.deprioritized.iter())
710 .next()
711 .unwrap();
712 let score = &unit.score;
713 assert_eq!(
714 score.total,
715 score.fan_io
716 + score.security_taint
717 + score.risk_zone
718 + score.change_shape
719 + score.runtime,
720 "the focus total must be the documented components only -- no symbol-chain term"
721 );
722 assert_eq!(score.runtime, 0, "free mode adds no runtime weight");
725 }
726
727 #[test]
730 fn hot_unit_outranks_cold_with_runtime_data() {
731 let gf = vec![
732 facts("src/hot.ts", 2, 0, false, false),
733 facts("src/cold.ts", 2, 0, false, false),
734 ];
735 let rt = runtime(&[("src/hot.ts", 500)], &["src/cold.ts"]);
736 let map = build_focus_map(&inputs_rt(&gf, &[], &[], &rt));
737 let find = |file: &str| {
738 map.review_here
739 .iter()
740 .chain(map.deprioritized.iter())
741 .find(|unit| unit.file == file)
742 .unwrap_or_else(|| panic!("{file} present"))
743 };
744 let hot = find("src/hot.ts");
745 let cold = find("src/cold.ts");
746 assert!(hot.score.runtime > 0, "hot unit carries a runtime weight");
747 assert_eq!(cold.score.runtime, 0, "cold unit carries no runtime weight");
748 assert!(
749 hot.score.total > cold.score.total,
750 "hot ({}) must outrank cold ({})",
751 hot.score.total,
752 cold.score.total
753 );
754 assert!(hot.reason.contains("hot path (500 invocations)"));
755 }
756
757 #[test]
759 fn runtime_weight_is_invocation_bucketed() {
760 assert_eq!(runtime_weight(0), RUNTIME_HOT_FLOOR);
761 assert_eq!(runtime_weight(50), RUNTIME_HOT_FLOOR);
762 assert_eq!(runtime_weight(100), RUNTIME_HOT_WARM);
763 assert_eq!(runtime_weight(1_000), RUNTIME_HOT_BLAZING);
764 }
765
766 #[test]
770 fn safe_skip_only_with_runtime_evidence_and_zero_risk() {
771 let isolated = vec![facts("src/dead.ts", 0, 0, false, false)];
773 let rt = runtime(&[], &["src/dead.ts"]);
774 let map = build_focus_map(&inputs_rt(&isolated, &[], &[], &rt));
775 let unit = map
776 .review_here
777 .iter()
778 .chain(map.deprioritized.iter())
779 .next()
780 .expect("unit present");
781 assert_eq!(unit.label, FocusLabel::Skip);
782 assert_eq!(unit.label.token(), "skip");
783 assert!(unit.reason.contains("runtime-cold"));
784 assert!(map.deprioritized.iter().any(|u| u.file == "src/dead.ts"));
786
787 let public_api = vec!["src/dead.ts::Widget".to_string()];
790 let with_risk = build_focus_map(&inputs_rt(&isolated, &public_api, &[], &rt));
791 let risky = with_risk
792 .review_here
793 .iter()
794 .chain(with_risk.deprioritized.iter())
795 .next()
796 .expect("unit present");
797 assert_ne!(
798 risky.label,
799 FocusLabel::Skip,
800 "a risk signal blocks safe-skip"
801 );
802 }
803
804 #[test]
809 fn confidence_flag_blocks_safe_skip_even_when_runtime_cold() {
810 let dyn_cold = vec![facts("src/dyn.ts", 0, 0, true, false)];
811 let rt = runtime(&[], &["src/dyn.ts"]);
812 let map = build_focus_map(&inputs_rt(&dyn_cold, &[], &[], &rt));
813 let unit = map
814 .review_here
815 .iter()
816 .chain(map.deprioritized.iter())
817 .next()
818 .expect("unit present");
819 assert_ne!(
820 unit.label,
821 FocusLabel::Skip,
822 "dynamic-dispatch reachability uncertainty blocks safe-skip"
823 );
824 assert!(unit.confidence.contains(&ConfidenceFlag::DynamicDispatch));
825 }
826
827 #[test]
830 fn no_runtime_data_is_byte_identical_to_e7() {
831 let gf = vec![
832 facts("src/a.ts", 12, 3, false, false),
833 facts("src/b.ts", 0, 0, false, false),
834 facts("src/c.ts", 2, 0, false, true),
835 ];
836 let public_api = vec!["src/c.ts::Thing".to_string()];
837 let e7 = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
838 let json = serde_json::to_string_pretty(&e7).expect("serialize");
839 assert!(!json.contains("\"skip\""), "free mode emits no skip label");
840 assert!(
841 !json.contains("\"runtime\""),
842 "free mode omits the runtime component from the wire"
843 );
844 for unit in e7.review_here.iter().chain(e7.deprioritized.iter()) {
845 assert_eq!(unit.score.runtime, 0);
846 assert_ne!(unit.label, FocusLabel::Skip);
847 }
848 }
849
850 #[test]
852 fn runtime_focus_map_is_byte_identical_across_runs() {
853 let gf = vec![
854 facts("src/hot.ts", 4, 2, false, false),
855 facts("src/cold.ts", 0, 0, false, false),
856 facts("src/warm.ts", 1, 0, false, false),
857 ];
858 let rt = runtime(
859 &[("src/hot.ts", 2_000), ("src/warm.ts", 120)],
860 &["src/cold.ts"],
861 );
862 let first = build_focus_map(&inputs_rt(&gf, &[], &[], &rt));
863 let second = build_focus_map(&inputs_rt(&gf, &[], &[], &rt));
864 assert_eq!(
865 serde_json::to_string_pretty(&first).unwrap(),
866 serde_json::to_string_pretty(&second).unwrap()
867 );
868 }
869}