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 engine-owned focus fact helpers.
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 layer (paid, built)
29//!
30//! When `FocusInputs::runtime` is `Some` (the paid `--runtime-coverage` path),
31//! a hot file adds an invocation-bucketed `runtime` component to its score so it
32//! amplifies the blast and outranks an otherwise-equal cold unit, and a unit the
33//! runtime proves cold AND that carries no deterministic signal earns the SAFE
34//! explicit-skip label (`FocusLabel::Skip`). When `runtime` is `None` (free
35//! mode) the layer contributes nothing: the `runtime` component is `0`, no unit
36//! can reach the `Skip` arm, and the output is byte-identical to the no-runtime baseline. The free
37//! tier ranks but never skips; safe-skip is runtime-backed only.
38
39use fallow_engine::module_graph::FocusFileFactsPaths;
40pub use fallow_output::{ConfidenceFlag, FocusLabel, FocusMap, FocusScore, FocusUnit};
41
42/// A unit's score at or above this threshold is labeled [`FocusLabel::ReviewHere`];
43/// below it, [`FocusLabel::NotPrioritized`]. Tuned so a unit with any non-trivial
44/// blast or a single risk-zone / change-shape signal lands above the line, while a
45/// fully isolated change (no fan-in, no zone, no change-shape) lands below it.
46const REVIEW_HERE_THRESHOLD: u32 = 3;
47
48/// Fan-in (blast radius) is the stage-4 priority signal; weight it higher than
49/// fan-out. Each is capped at [`FAN_CAP`] so one extreme-fan-in file does not
50/// swamp the bounded zone / change-shape signals.
51const FAN_IN_WEIGHT: u32 = 2;
52/// Fan-out weight (forward-dependency breadth), lower than fan-in.
53const FAN_OUT_WEIGHT: u32 = 1;
54/// Cap on the raw fan-in / fan-out count before weighting, so the blast signal
55/// stays bounded relative to the other three.
56const FAN_CAP: u32 = 5;
57/// Points added per present risk zone (boundary / public-API / security-sensitive).
58const RISK_ZONE_WEIGHT: u32 = 2;
59/// Points added per present change-shape signal (new/widened export, sig change).
60const CHANGE_SHAPE_WEIGHT: u32 = 2;
61/// Points added when a unit sits on a security source -> sink taint trace.
62const SECURITY_TAINT_WEIGHT: u32 = 3;
63
64/// Runtime-weight floor: any hot path adds at least this, so a hot unit
65/// always outranks an otherwise-equal cold one. At/above [`REVIEW_HERE_THRESHOLD`]
66/// so a hot path alone pulls a unit into `review-here`.
67const RUNTIME_HOT_FLOOR: u32 = 3;
68/// Runtime weight for a warm path (>= [`RUNTIME_WARM_INVOCATIONS`]).
69const RUNTIME_HOT_WARM: u32 = 4;
70/// Runtime weight for a blazing path (>= [`RUNTIME_BLAZING_INVOCATIONS`]). Capped
71/// (bucketed, not the raw count) so one extreme-traffic file cannot swamp the
72/// bounded deterministic signals and the score stays deterministic.
73const RUNTIME_HOT_BLAZING: u32 = 6;
74/// Invocation count at/above which a hot path is "warm".
75const RUNTIME_WARM_INVOCATIONS: u64 = 100;
76/// Invocation count at/above which a hot path is "blazing".
77const RUNTIME_BLAZING_INVOCATIONS: u64 = 1_000;
78
79/// The bucketed runtime weight for a hot file's peak invocation count. Bucketed
80/// (three bands) rather than raw so the weight stays bounded and deterministic.
81fn 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/// A boundary-zone signal for a unit: the unit's file introduced a new cross-zone
92/// edge (it is the `from_file` of an introduced boundary edge).
93#[derive(Debug, Clone)]
94pub struct BoundaryZoneFile {
95    /// Root-relative path of the importing file that introduced the edge.
96    pub from_file: String,
97}
98
99/// A runtime-hot file: a changed file with a runtime hot path, paired with the
100/// file's peak invocation count (the max over the file's hot functions).
101#[derive(Debug, Clone)]
102pub struct RuntimeHotFile {
103    /// Root-relative path of the hot file (the brief's canonical path space).
104    pub file: String,
105    /// Peak invocation count across the file's hot functions; drives the band.
106    pub invocations: u64,
107}
108
109/// Per-file runtime evidence for the paid weighting layer, built from the
110/// runtime-coverage health report at the brief path. `None` in free mode; when
111/// present it adds the runtime score component and enables the safe explicit-skip
112/// label. The two lists are disjoint by construction (a hot file is never cold).
113#[derive(Debug, Clone, Default)]
114pub struct RuntimeFocus {
115    /// Files with a runtime hot path; each adds a bucketed runtime weight.
116    pub hot_files: Vec<RuntimeHotFile>,
117    /// Files the runtime proves cold (every retained finding is `safe_to_delete`
118    /// and the file has no hot path). Eligible for safe-skip when also carrying no
119    /// deterministic signal.
120    pub cold_files: Vec<String>,
121}
122
123/// Everything the focus extractor needs, gathered from the assembled brief data.
124/// All path-spaces are root-relative + forward-slashed (the brief's canonical
125/// space), so signal joins are byte-exact.
126pub struct FocusInputs<'a> {
127    /// Per-file graph facts (fan-in/out + confidence-flag signals) from
128    /// Engine focus facts, path-resolved. The unit spine.
129    pub graph_facts: &'a [FocusFileFactsPaths],
130    /// Root-relative `from_file`s of introduced boundary edges. A unit file
131    /// in this set carries the boundary risk-zone signal.
132    pub boundary_files: &'a [BoundaryZoneFile],
133    /// The exports-aware public-API surface delta keys (`<rel_path>::<name>`).
134    /// A unit file that is the `<rel_path>` prefix of any key carries the
135    /// public-API risk-zone AND new/widened-export change-shape signals.
136    pub public_api_added: &'a [String],
137    /// Root-relative changed-file paths that changed a contract consumed outside
138    /// the diff (coordination-gap `changed_file`s). A unit file here carries
139    /// the signature-change change-shape signal (syntactic proxy, ADR-001).
140    pub coordination_changed_files: &'a [String],
141    /// Root-relative file paths a security source -> sink taint trace touches
142    /// (reuse `SecurityFinding.trace`). EMPTY on the brief path today (the taint
143    /// engine is the opt-in `fallow security` command); the seam lights up the
144    /// moment a security pass is threaded, with no focus-map code change.
145    pub taint_touched_files: &'a [String],
146    /// Per-file runtime evidence (paid). `None` in free mode, where the focus
147    /// map degrades to the deterministic no-runtime baseline byte-for-byte; `Some` on the
148    /// `--runtime-coverage` path, where it weights hot files and enables safe-skip.
149    pub runtime: Option<&'a RuntimeFocus>,
150}
151
152/// Whether a unit `file` is the `<rel_path>` prefix of any public-API delta key
153/// (`<rel_path>::<name>`).
154fn 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
160/// Compute one unit's composite score from the present signals.
161fn 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    // SECURITY-SENSITIVE risk zone reuses the taint-touch signal.
178    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    // NEW/WIDENED EXPORT (public-API delta) + SIGNATURE CHANGE (coordination-gap
182    // proxy). DELETED SYMBOL is deferred (no per-symbol deletion delta on the
183    // brief path); it is a future change-shape multiply-in, scores 0 today.
184    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    // Runtime layer (paid): a hot file adds an invocation-bucketed weight so a
193    // hot path amplifies the blast and outranks an otherwise-equal cold unit. The
194    // deterministic components stay on the wire, so this ADDS without recomputing
195    // them. `0` when no runtime input (free mode), keeping `total` the four
196    // deterministic components and the output byte-identical to the no-runtime baseline.
197    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
216/// Whether a unit is the SAFE explicit-skip case: runtime-backed ONLY. All
217/// of: the runtime proves the file cold (in [`RuntimeFocus::cold_files`]); it
218/// carries no deterministic signal (`total` is the runtime component alone, i.e.
219/// `0`, so no fan-in/out, risk-zone, change-shape, or taint); and it carries NO
220/// confidence flag (a dynamically-wired / re-export-heavy unit has uncertain
221/// reachability, so a single runtime capture is not enough to call it safe to
222/// skip). Any of those keeps the unit visible. Returns `false` whenever `runtime`
223/// is `None`, so free mode can never label a unit `skip`.
224fn 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
233/// Build the human reason for a unit from the present signals.
234fn build_reason(
235    facts: &FocusFileFactsPaths,
236    score: &FocusScore,
237    inputs: &FocusInputs<'_>,
238) -> String {
239    let mut parts: Vec<String> = Vec::new();
240    // Runtime evidence first (it amplifies / disarms the static signals). On the
241    // free path `inputs.runtime` is `None`, so no runtime clause is ever added and
242    // the reason string stays byte-identical to the no-runtime baseline.
243    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            // "no hot path" is the honest, file-level-true claim: the cold signal
252            // means no hot path here and the file's tracked findings are all
253            // safe-to-delete. It deliberately does NOT say "0 invocations" -- a
254            // sub-hot-threshold active function is invisible to this signal.
255            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
295/// Collect a unit's confidence flags from its graph facts (sorted, deduped).
296fn 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/// Build the weighted focus map from the assembled brief inputs: score each unit,
308/// label it (`review-here` / `not-prioritized`, NEVER `skip`), attach the reason
309/// and confidence flags, then partition into the ranked `review_here` list and
310/// the FULL `deprioritized` escape-hatch list.
311///
312/// Pure + deterministic: no timestamps, no randomness, integer arithmetic only,
313/// so two runs over the same tree produce a byte-identical focus map. The two
314/// output lists partition the unit set, so the escape-hatch completeness invariant
315/// (`review_here.len() + deprioritized.len() == graph_facts.len()`) holds by
316/// construction.
317#[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            // Safe explicit-skip wins first, but is runtime-backed only:
325            // `is_safe_skip` is always false without runtime input, so free mode
326            // falls through to the no-runtime review-here / not-prioritized split.
327            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    // Rank by score descending, ties broken by path for determinism.
346    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            // Both not-prioritized and the runtime-backed safe-skip land in the
359            // escape hatch: nothing is hidden, a skip is just labelled safe.
360            FocusLabel::NotPrioritized | FocusLabel::Skip => deprioritized.push(unit),
361        }
362    }
363    // The deprioritized escape hatch is path-sorted (stable enumeration order).
364    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    /// Build a `RuntimeFocus` from `(file, invocations)` hot pairs and cold files.
410    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    /// `inputs` with a runtime layer attached (the paid `--runtime-coverage` path).
424    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    // (a) NO `skip` label is ever emitted in free mode. The enum has no Skip
441    // variant; the test pins the serialized strings of every produced label.
442    #[test]
443    fn no_skip_label_ever_emitted_in_free_mode() {
444        let gf = vec![
445            facts("src/hot.ts", 12, 3, false, false), // review-here
446            facts("src/iso.ts", 0, 0, false, false),  // not-prioritized
447        ];
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        // Serialized JSON must not carry the token "skip" anywhere either.
464        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    // (b) Every de-prioritized unit is enumerable via the escape hatch:
472    // count(review_here) + count(deprioritized) == count(all).
473    #[test]
474    fn escape_hatch_enumerates_every_deprioritized_unit() {
475        let gf = vec![
476            facts("src/a.ts", 12, 4, false, false), // review-here
477            facts("src/b.ts", 0, 0, false, false),  // not-prioritized
478            facts("src/c.ts", 1, 0, false, false),  // not-prioritized (score 2 < 3)
479            facts("src/d.ts", 8, 0, false, false),  // review-here
480        ];
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        // The deprioritized list is the escape hatch: nothing is hidden.
488        assert!(!map.deprioritized.is_empty());
489        // No file appears in both lists (a strict partition).
490        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    // (c) A dynamically-wired unit carries the `low: dynamic dispatch detected`
500    // flag; a re-export-indirection unit carries `low: re-export indirection`.
501    #[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        // Two identical-signal units, one with confidence flags: same total.
547        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        // public-API delta -> risk_zone (+2) AND change_shape new-export (+2) = 4.
585        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        // Empty taint slice (the brief-path reality today): seam contributes 0.
595        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        // A future security pass threads the touched file: the seam lights up.
606        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        // taint -> also a security-sensitive risk zone (+2).
616        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),   // score 4
657            facts("src/high.ts", 12, 5, false, false), // score capped high
658        ];
659        let public_api = vec!["src/low.ts::X".to_string()];
660        let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
661        // Both should be review-here; high.ts ranks first.
662        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    // done-condition (c): the symbol-level call chain (`fallow trace`) is
668    // EXPLICITLY OFF the ranked path. The focus-map ranking inputs
669    // (`FocusInputs`) carry NO trace / symbol-chain field, and the composite
670    // `FocusScore.total` is the sum of EXACTLY the four documented components
671    // (no symbol-chain term). This pins the trace as never feeding
672    // de-prioritization.
673    #[test]
674    fn focus_map_inputs_have_no_symbol_chain_or_trace_field() {
675        // FocusInputs is the complete input surface to the focus map. Naming
676        // every field here is exhaustive (the struct is `pub` with no `..`), so
677        // adding a trace/symbol-chain field would force this destructure to be
678        // updated -- a compile-time guard that the trace stays out of the ranking
679        // inputs.
680        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` is the legitimate paid weighting seam, not a ranking
690            // input the free tier reads. NOTE: no `symbol_chain` / `trace` field
691            // exists. If the trace ever wired one in, this destructure would fail
692            // to compile -- the guard that the trace stays OUT of the focus inputs.
693            runtime: _,
694        } = inputs(
695            empty_facts,
696            empty_boundary,
697            empty_strings,
698            empty_strings,
699            empty_strings,
700        );
701
702        // The composite total is the sum of exactly the four documented
703        // components. A symbol-chain term would break this invariant.
704        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        // Free-mode (no runtime input): the runtime component is 0, so the total
723        // is the four deterministic components, byte-identical to the no-runtime baseline.
724        assert_eq!(score.runtime, 0, "free mode adds no runtime weight");
725    }
726
727    // With runtime data, a HOT unit outranks a COLD unit
728    // that is otherwise identical. The hot path's bucketed runtime weight lifts it.
729    #[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    // A blazing path outweighs a merely-warm one (bands).
758    #[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    // The `skip` label is emitted ONLY with runtime
767    // evidence, and ONLY for a runtime-cold unit that carries no deterministic
768    // signal. A cold unit WITH a risk signal stays visible (never skipped).
769    #[test]
770    fn safe_skip_only_with_runtime_evidence_and_zero_risk() {
771        // Fully isolated + runtime-cold -> skip.
772        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        // The skip unit is in the escape hatch (nothing hidden).
785        assert!(map.deprioritized.iter().any(|u| u.file == "src/dead.ts"));
786
787        // Same file but with a risk signal (public-API delta) -> NOT skipped, even
788        // though runtime says cold. A deterministic signal keeps it visible.
789        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    // Safety: a confidence-flagged unit (dynamic dispatch / re-export
805    // indirection) is NOT auto-skipped even when the runtime proves it cold and it
806    // carries no other signal -- its reachability is uncertain, so one runtime
807    // capture is not proof it is safe to skip.
808    #[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    // With NO runtime input, the map is byte-identical to
828    // the no-runtime baseline -- no skip label, no runtime component, same JSON.
829    #[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    // Runtime weighting + safe-skip is deterministic (byte-identical re-runs).
851    #[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}