Skip to main content

fallow_cli/
audit_focus.rs

1//! Weighted focus map (stage 4): a COMPOSITE attention score per review unit
2//! that ranks where scarce reviewer attention goes.
3//!
4//! A 40-file diff becomes a handful of `review-here` pieces plus an enumerable
5//! `not-prioritized` remainder. The free tier RANKS but NEVER says "skip" (safe
6//! explicit-skip is paid, runtime-backed only); each unit carries a human
7//! reason; a per-unit confidence flag protects dynamically-wired / re-export-heavy
8//! code from a silent static-reachability de-prioritization; and the
9//! `deprioritized` escape-hatch list makes EVERY de-prioritized piece reachable.
10//!
11//! ## The composite score (deterministic, no runtime input)
12//!
13//! `score = fan_io + security_taint + risk_zone + change_shape`, an integer sum
14//! (no floats, matching the partition + order engine's determinism posture) of four deterministic signals,
15//! each derived from data the brief already retains:
16//!
17//! 1. **fan-in / fan-out** (graph blast): from `ModuleGraph::focus_file_facts`.
18//! 2. **security taint touch**: a source -> sink taint trace touches the unit
19//!    (reuse `SecurityFinding.trace`). Built as a pure function of a security-
20//!    finding slice; the brief path carries an EMPTY slice today (security is the
21//!    opt-in `fallow security` command, not the bare dead-code analysis), so this
22//!    contributes 0 until a future epic threads a security pass. The seam is wired
23//!    and tested; no taint engine runs here.
24//! 3. **risk zone**: boundary / public-API / security-sensitive.
25//! 4. **change shape**: new export / widened visibility / signature change (the
26//!    coordination-gap proxy, ADR-001 syntactic).
27//!
28//! ## The runtime seam (documented, NOT built)
29//!
30//! `FocusScore` keeps the four component sub-scores on the wire so the paid
31//! runtime layer can multiply a runtime hot/cold weight into `total` WITHOUT recomputing the
32//! deterministic signals. The single `// runtime seam` marker sits at the point the
33//! components are summed. No runtime field, no runtime read, no runtime gate here:
34//! free mode is the complete surface.
35
36use fallow_engine::graph::FocusFileFactsPaths;
37pub use fallow_output::{ConfidenceFlag, FocusLabel, FocusMap, FocusScore, FocusUnit};
38
39/// A unit's score at or above this threshold is labeled [`FocusLabel::ReviewHere`];
40/// below it, [`FocusLabel::NotPrioritized`]. Tuned so a unit with any non-trivial
41/// blast or a single risk-zone / change-shape signal lands above the line, while a
42/// fully isolated change (no fan-in, no zone, no change-shape) lands below it.
43const REVIEW_HERE_THRESHOLD: u32 = 3;
44
45/// Fan-in (blast radius) is the stage-4 priority signal; weight it higher than
46/// fan-out. Each is capped at [`FAN_CAP`] so one extreme-fan-in file does not
47/// swamp the bounded zone / change-shape signals.
48const FAN_IN_WEIGHT: u32 = 2;
49/// Fan-out weight (forward-dependency breadth), lower than fan-in.
50const FAN_OUT_WEIGHT: u32 = 1;
51/// Cap on the raw fan-in / fan-out count before weighting, so the blast signal
52/// stays bounded relative to the other three.
53const FAN_CAP: u32 = 5;
54/// Points added per present risk zone (boundary / public-API / security-sensitive).
55const RISK_ZONE_WEIGHT: u32 = 2;
56/// Points added per present change-shape signal (new/widened export, sig change).
57const CHANGE_SHAPE_WEIGHT: u32 = 2;
58/// Points added when a unit sits on a security source -> sink taint trace.
59const SECURITY_TAINT_WEIGHT: u32 = 3;
60
61/// A boundary-zone signal for a unit: the unit's file introduced a new cross-zone
62/// edge (it is the `from_file` of an introduced boundary edge).
63#[derive(Debug, Clone)]
64pub struct BoundaryZoneFile {
65    /// Root-relative path of the importing file that introduced the edge.
66    pub from_file: String,
67}
68
69/// Everything the focus extractor needs, gathered from the assembled brief data.
70/// All path-spaces are root-relative + forward-slashed (the brief's canonical
71/// space), so signal joins are byte-exact.
72pub struct FocusInputs<'a> {
73    /// Per-file graph facts (fan-in/out + confidence-flag signals) from
74    /// `ModuleGraph::focus_file_facts`, path-resolved. The unit spine.
75    pub graph_facts: &'a [FocusFileFactsPaths],
76    /// Root-relative `from_file`s of introduced boundary edges. A unit file
77    /// in this set carries the boundary risk-zone signal.
78    pub boundary_files: &'a [BoundaryZoneFile],
79    /// The exports-aware public-API surface delta keys (`<rel_path>::<name>`).
80    /// A unit file that is the `<rel_path>` prefix of any key carries the
81    /// public-API risk-zone AND new/widened-export change-shape signals.
82    pub public_api_added: &'a [String],
83    /// Root-relative changed-file paths that changed a contract consumed outside
84    /// the diff (coordination-gap `changed_file`s). A unit file here carries
85    /// the signature-change change-shape signal (syntactic proxy, ADR-001).
86    pub coordination_changed_files: &'a [String],
87    /// Root-relative file paths a security source -> sink taint trace touches
88    /// (reuse `SecurityFinding.trace`). EMPTY on the brief path today (the taint
89    /// engine is the opt-in `fallow security` command); the seam lights up the
90    /// moment a security pass is threaded, with no focus-map code change.
91    pub taint_touched_files: &'a [String],
92}
93
94/// Whether a unit `file` is the `<rel_path>` prefix of any public-API delta key
95/// (`<rel_path>::<name>`).
96fn 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
102/// Compute one unit's composite score from the present signals.
103fn 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    // SECURITY-SENSITIVE risk zone reuses the taint-touch signal.
120    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    // NEW/WIDENED EXPORT (public-API delta) + SIGNATURE CHANGE (coordination-gap
124    // proxy). DELETED SYMBOL is deferred (no per-symbol deletion delta on the
125    // brief path); it is a future change-shape multiply-in, scores 0 today.
126    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    // runtime seam: the paid runtime layer multiplies a runtime hot/cold weight into `total` here,
135    // reading the per-unit runtime coverage and scaling the deterministic sum so a
136    // hot path amplifies the blast. The four component sub-scores stay on the wire
137    // so it re-weights WITHOUT recomputing the signals. Free mode is the
138    // complete surface; the paid layer degrades cleanly to it when no runtime data.
139    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
150/// Build the human reason for a unit from the present signals.
151fn 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
194/// Collect a unit's confidence flags from its graph facts (sorted, deduped).
195fn 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/// Build the weighted focus map from the assembled brief inputs: score each unit,
207/// label it (`review-here` / `not-prioritized`, NEVER `skip`), attach the reason
208/// and confidence flags, then partition into the ranked `review_here` list and
209/// the FULL `deprioritized` escape-hatch list.
210///
211/// Pure + deterministic: no timestamps, no randomness, integer arithmetic only,
212/// so two runs over the same tree produce a byte-identical focus map. The two
213/// output lists partition the unit set, so the escape-hatch completeness invariant
214/// (`review_here.len() + deprioritized.len() == graph_facts.len()`) holds by
215/// construction.
216#[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    // Rank by score descending, ties broken by path for determinism.
240    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    // The deprioritized escape hatch is path-sorted (stable enumeration order).
256    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    // (a) NO `skip` label is ever emitted in free mode. The enum has no Skip
301    // variant; the test pins the serialized strings of every produced label.
302    #[test]
303    fn no_skip_label_ever_emitted_in_free_mode() {
304        let gf = vec![
305            facts("src/hot.ts", 12, 3, false, false), // review-here
306            facts("src/iso.ts", 0, 0, false, false),  // not-prioritized
307        ];
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        // Serialized JSON must not carry the token "skip" anywhere either.
324        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    // (b) Every de-prioritized unit is enumerable via the escape hatch:
332    // count(review_here) + count(deprioritized) == count(all).
333    #[test]
334    fn escape_hatch_enumerates_every_deprioritized_unit() {
335        let gf = vec![
336            facts("src/a.ts", 12, 4, false, false), // review-here
337            facts("src/b.ts", 0, 0, false, false),  // not-prioritized
338            facts("src/c.ts", 1, 0, false, false),  // not-prioritized (score 2 < 3)
339            facts("src/d.ts", 8, 0, false, false),  // review-here
340        ];
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        // The deprioritized list is the escape hatch: nothing is hidden.
348        assert!(!map.deprioritized.is_empty());
349        // No file appears in both lists (a strict partition).
350        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    // (c) A dynamically-wired unit carries the `low: dynamic dispatch detected`
360    // flag; a re-export-indirection unit carries `low: re-export indirection`.
361    #[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        // Two identical-signal units, one with confidence flags: same total.
407        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        // public-API delta -> risk_zone (+2) AND change_shape new-export (+2) = 4.
445        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        // Empty taint slice (the brief-path reality today): seam contributes 0.
455        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        // A future security pass threads the touched file: the seam lights up.
466        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        // taint -> also a security-sensitive risk zone (+2).
476        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),   // score 4
517            facts("src/high.ts", 12, 5, false, false), // score capped high
518        ];
519        let public_api = vec!["src/low.ts::X".to_string()];
520        let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
521        // Both should be review-here; high.ts ranks first.
522        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    // done-condition (c): the symbol-level call chain (`fallow trace`) is
528    // EXPLICITLY OFF the ranked path. The focus-map ranking inputs
529    // (`FocusInputs`) carry NO trace / symbol-chain field, and the composite
530    // `FocusScore.total` is the sum of EXACTLY the four documented components
531    // (no symbol-chain term). This pins the trace as never feeding
532    // de-prioritization.
533    #[test]
534    fn focus_map_inputs_have_no_symbol_chain_or_trace_field() {
535        // FocusInputs is the complete input surface to the focus map. Naming
536        // every field here is exhaustive (the struct is `pub` with no `..`), so
537        // adding a trace/symbol-chain field would force this destructure to be
538        // updated -- a compile-time guard that the trace stays out of the ranking
539        // inputs.
540        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            // NOTE: no `symbol_chain` / `trace` field exists. If the trace ever wired
550            // one in, this destructure would fail to compile.
551        } = inputs(
552            empty_facts,
553            empty_boundary,
554            empty_strings,
555            empty_strings,
556            empty_strings,
557        );
558
559        // The composite total is the sum of exactly the four documented
560        // components. A symbol-chain term would break this invariant.
561        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}