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 serde::Serialize;
37
38use fallow_core::graph::FocusFileFactsPaths;
39
40/// A unit's score at or above this threshold is labeled [`FocusLabel::ReviewHere`];
41/// below it, [`FocusLabel::NotPrioritized`]. Tuned so a unit with any non-trivial
42/// blast or a single risk-zone / change-shape signal lands above the line, while a
43/// fully isolated change (no fan-in, no zone, no change-shape) lands below it.
44const REVIEW_HERE_THRESHOLD: u32 = 3;
45
46/// Fan-in (blast radius) is the stage-4 priority signal; weight it higher than
47/// fan-out. Each is capped at [`FAN_CAP`] so one extreme-fan-in file does not
48/// swamp the bounded zone / change-shape signals.
49const FAN_IN_WEIGHT: u32 = 2;
50/// Fan-out weight (forward-dependency breadth), lower than fan-in.
51const FAN_OUT_WEIGHT: u32 = 1;
52/// Cap on the raw fan-in / fan-out count before weighting, so the blast signal
53/// stays bounded relative to the other three.
54const FAN_CAP: u32 = 5;
55/// Points added per present risk zone (boundary / public-API / security-sensitive).
56const RISK_ZONE_WEIGHT: u32 = 2;
57/// Points added per present change-shape signal (new/widened export, sig change).
58const CHANGE_SHAPE_WEIGHT: u32 = 2;
59/// Points added when a unit sits on a security source -> sink taint trace.
60const SECURITY_TAINT_WEIGHT: u32 = 3;
61
62/// The focus label for a review unit. EXACTLY two variants: `Skip` is NOT
63/// representable, so the type system is the guarantee that free mode never emits
64/// a `skip` label (safe explicit-skip is paid, runtime-backed only). Mirrors
65/// the decision surface's "cut category not representable" structural posture.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
67#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
68#[serde(rename_all = "kebab-case")]
69pub enum FocusLabel {
70    /// Review this unit: its composite attention score is at or above the line.
71    ReviewHere,
72    /// Not prioritized: below the line. NEVER "skip" (the reviewer is free to
73    /// review it anyway; the escape hatch enumerates every such unit).
74    NotPrioritized,
75}
76
77impl FocusLabel {
78    /// The wire token. The no-skip done-condition test asserts no label's token
79    /// is ever `"skip"`.
80    #[must_use]
81    pub const fn token(self) -> &'static str {
82        match self {
83            Self::ReviewHere => "review-here",
84            Self::NotPrioritized => "not-prioritized",
85        }
86    }
87}
88
89/// A per-unit confidence flag. The EXACT panel-decided strings: a dynamically-
90/// wired or re-export-heavy unit carries one so its static-reachability signal is
91/// not trusted as complete (the anti-silent-de-prioritization guard). The flag
92/// NEVER lowers the score; it is advisory provenance.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95#[serde(rename_all = "kebab-case")]
96pub enum ConfidenceFlag {
97    /// The unit is dynamically wired (DI / decorators / plugin-loader / lazy
98    /// patterns the static graph cannot fully resolve).
99    DynamicDispatch,
100    /// The unit's reachability runs through re-export barrels.
101    ReExportIndirection,
102}
103
104impl ConfidenceFlag {
105    /// The EXACT panel-decided wire string for this flag.
106    #[must_use]
107    pub const fn message(self) -> &'static str {
108        match self {
109            Self::DynamicDispatch => "low: dynamic dispatch detected",
110            Self::ReExportIndirection => "low: re-export indirection",
111        }
112    }
113}
114
115/// The composite attention score, with the four deterministic component
116/// sub-scores kept on the wire so the runtime seam can re-weight `total`
117/// without recomputing the signals.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct FocusScore {
121    /// Fan-in/out blast-radius component.
122    pub fan_io: u32,
123    /// Security source -> sink taint-touch component (0 until a security pass is
124    /// threaded onto the brief path; the seam is built and tested).
125    pub security_taint: u32,
126    /// Risk-zone component (boundary / public-API / security-sensitive).
127    pub risk_zone: u32,
128    /// Change-shape component (new/widened export, signature change proxy).
129    pub change_shape: u32,
130    /// The summed total. The paid runtime layer multiplies a runtime hot/cold weight in here.
131    pub total: u32,
132}
133
134/// One review unit on the focus map: its file, composite score, label, human
135/// reason, and any confidence flags.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
138pub struct FocusUnit {
139    /// Root-relative path of the changed file this unit covers.
140    pub file: String,
141    /// The composite attention score and its component breakdown.
142    pub score: FocusScore,
143    /// The focus label (`review-here` / `not-prioritized`; NEVER `skip`).
144    pub label: FocusLabel,
145    /// A human-readable reason for the label, built from the present signals.
146    pub reason: String,
147    /// Confidence flags (advisory; never lower the score). Sorted, deduped.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub confidence: Vec<ConfidenceFlag>,
150}
151
152/// The weighted focus map: the ranked `review-here` units plus the FULL
153/// `deprioritized` escape-hatch list, so nothing is hidden.
154///
155/// Completeness invariant (the escape-hatch done-condition): the two lists
156/// partition the unit set, so `review_here.len() + deprioritized.len()` equals
157/// the total unit count by construction.
158#[derive(Debug, Clone, Default, Serialize)]
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160pub struct FocusMap {
161    /// Units labeled `review-here`, ranked by composite score (descending), ties
162    /// broken by path for determinism.
163    pub review_here: Vec<FocusUnit>,
164    /// EVERY `not-prioritized` unit (the escape hatch). Always present and fully
165    /// enumerated so a reviewer can always "show me what you de-prioritized"; the
166    /// human brief collapses it by default and re-expands under
167    /// `--show-deprioritized`.
168    pub deprioritized: Vec<FocusUnit>,
169}
170
171impl FocusMap {
172    /// Total number of units (review-here + deprioritized). The escape-hatch
173    /// completeness invariant: this equals the input unit count.
174    #[must_use]
175    pub fn total_units(&self) -> usize {
176        self.review_here.len() + self.deprioritized.len()
177    }
178}
179
180/// A boundary-zone signal for a unit: the unit's file introduced a new cross-zone
181/// edge (it is the `from_file` of an introduced boundary edge).
182#[derive(Debug, Clone)]
183pub struct BoundaryZoneFile {
184    /// Root-relative path of the importing file that introduced the edge.
185    pub from_file: String,
186}
187
188/// Everything the focus extractor needs, gathered from the assembled brief data.
189/// All path-spaces are root-relative + forward-slashed (the brief's canonical
190/// space), so signal joins are byte-exact.
191pub struct FocusInputs<'a> {
192    /// Per-file graph facts (fan-in/out + confidence-flag signals) from
193    /// `ModuleGraph::focus_file_facts`, path-resolved. The unit spine.
194    pub graph_facts: &'a [FocusFileFactsPaths],
195    /// Root-relative `from_file`s of introduced boundary edges. A unit file
196    /// in this set carries the boundary risk-zone signal.
197    pub boundary_files: &'a [BoundaryZoneFile],
198    /// The exports-aware public-API surface delta keys (`<rel_path>::<name>`).
199    /// A unit file that is the `<rel_path>` prefix of any key carries the
200    /// public-API risk-zone AND new/widened-export change-shape signals.
201    pub public_api_added: &'a [String],
202    /// Root-relative changed-file paths that changed a contract consumed outside
203    /// the diff (coordination-gap `changed_file`s). A unit file here carries
204    /// the signature-change change-shape signal (syntactic proxy, ADR-001).
205    pub coordination_changed_files: &'a [String],
206    /// Root-relative file paths a security source -> sink taint trace touches
207    /// (reuse `SecurityFinding.trace`). EMPTY on the brief path today (the taint
208    /// engine is the opt-in `fallow security` command); the seam lights up the
209    /// moment a security pass is threaded, with no focus-map code change.
210    pub taint_touched_files: &'a [String],
211}
212
213/// Whether a unit `file` is the `<rel_path>` prefix of any public-API delta key
214/// (`<rel_path>::<name>`).
215fn file_in_public_api(file: &str, public_api_added: &[String]) -> bool {
216    public_api_added
217        .iter()
218        .any(|key| key.split("::").next() == Some(file))
219}
220
221/// Compute one unit's composite score from the present signals.
222fn score_unit(facts: &FocusFileFactsPaths, inputs: &FocusInputs<'_>) -> FocusScore {
223    let fan_io =
224        facts.fan_in.min(FAN_CAP) * FAN_IN_WEIGHT + facts.fan_out.min(FAN_CAP) * FAN_OUT_WEIGHT;
225
226    let taint_touched = inputs.taint_touched_files.iter().any(|f| f == &facts.file);
227    let security_taint = if taint_touched {
228        SECURITY_TAINT_WEIGHT
229    } else {
230        0
231    };
232
233    let in_boundary = inputs
234        .boundary_files
235        .iter()
236        .any(|b| b.from_file == facts.file);
237    let in_public_api = file_in_public_api(&facts.file, inputs.public_api_added);
238    // SECURITY-SENSITIVE risk zone reuses the taint-touch signal.
239    let zones = u32::from(in_boundary) + u32::from(in_public_api) + u32::from(taint_touched);
240    let risk_zone = zones * RISK_ZONE_WEIGHT;
241
242    // NEW/WIDENED EXPORT (public-API delta) + SIGNATURE CHANGE (coordination-gap
243    // proxy). DELETED SYMBOL is deferred (no per-symbol deletion delta on the
244    // brief path); it is a future change-shape multiply-in, scores 0 today.
245    let new_export = in_public_api;
246    let sig_change = inputs
247        .coordination_changed_files
248        .iter()
249        .any(|f| f == &facts.file);
250    let shapes = u32::from(new_export) + u32::from(sig_change);
251    let change_shape = shapes * CHANGE_SHAPE_WEIGHT;
252
253    // runtime seam: the paid runtime layer multiplies a runtime hot/cold weight into `total` here,
254    // reading the per-unit runtime coverage and scaling the deterministic sum so a
255    // hot path amplifies the blast. The four component sub-scores stay on the wire
256    // so it re-weights WITHOUT recomputing the signals. Free mode is the
257    // complete surface; the paid layer degrades cleanly to it when no runtime data.
258    let total = fan_io + security_taint + risk_zone + change_shape;
259
260    FocusScore {
261        fan_io,
262        security_taint,
263        risk_zone,
264        change_shape,
265        total,
266    }
267}
268
269/// Build the human reason for a unit from the present signals.
270fn build_reason(
271    facts: &FocusFileFactsPaths,
272    score: &FocusScore,
273    inputs: &FocusInputs<'_>,
274) -> String {
275    let mut parts: Vec<String> = Vec::new();
276    if facts.fan_in > 0 {
277        parts.push(format!(
278            "high fan-in ({} importer{})",
279            facts.fan_in,
280            if facts.fan_in == 1 { "" } else { "s" }
281        ));
282    }
283    if facts.fan_out > 0 {
284        parts.push(format!("fan-out {}", facts.fan_out));
285    }
286    if score.security_taint > 0 {
287        parts.push("on a security taint path".to_string());
288    }
289    if inputs
290        .boundary_files
291        .iter()
292        .any(|b| b.from_file == facts.file)
293    {
294        parts.push("introduces a cross-zone edge".to_string());
295    }
296    if file_in_public_api(&facts.file, inputs.public_api_added) {
297        parts.push("widens the public API".to_string());
298    }
299    if inputs
300        .coordination_changed_files
301        .iter()
302        .any(|f| f == &facts.file)
303    {
304        parts.push("changes a contract consumed outside the diff".to_string());
305    }
306    if parts.is_empty() {
307        "isolated change, no blast beyond the diff".to_string()
308    } else {
309        parts.join(", ")
310    }
311}
312
313/// Collect a unit's confidence flags from its graph facts (sorted, deduped).
314fn confidence_flags(facts: &FocusFileFactsPaths) -> Vec<ConfidenceFlag> {
315    let mut flags: Vec<ConfidenceFlag> = Vec::new();
316    if facts.dynamic_dispatch {
317        flags.push(ConfidenceFlag::DynamicDispatch);
318    }
319    if facts.re_export_indirection {
320        flags.push(ConfidenceFlag::ReExportIndirection);
321    }
322    flags
323}
324
325/// Build the weighted focus map from the assembled brief inputs: score each unit,
326/// label it (`review-here` / `not-prioritized`, NEVER `skip`), attach the reason
327/// and confidence flags, then partition into the ranked `review_here` list and
328/// the FULL `deprioritized` escape-hatch list.
329///
330/// Pure + deterministic: no timestamps, no randomness, integer arithmetic only,
331/// so two runs over the same tree produce a byte-identical focus map. The two
332/// output lists partition the unit set, so the escape-hatch completeness invariant
333/// (`review_here.len() + deprioritized.len() == graph_facts.len()`) holds by
334/// construction.
335#[must_use]
336pub fn build_focus_map(inputs: &FocusInputs<'_>) -> FocusMap {
337    let mut units: Vec<FocusUnit> = inputs
338        .graph_facts
339        .iter()
340        .map(|facts| {
341            let score = score_unit(facts, inputs);
342            let label = if score.total >= REVIEW_HERE_THRESHOLD {
343                FocusLabel::ReviewHere
344            } else {
345                FocusLabel::NotPrioritized
346            };
347            let reason = build_reason(facts, &score, inputs);
348            FocusUnit {
349                file: facts.file.clone(),
350                score,
351                label,
352                reason,
353                confidence: confidence_flags(facts),
354            }
355        })
356        .collect();
357
358    // Rank by score descending, ties broken by path for determinism.
359    units.sort_by(|a, b| {
360        b.score
361            .total
362            .cmp(&a.score.total)
363            .then_with(|| a.file.cmp(&b.file))
364    });
365
366    let mut review_here: Vec<FocusUnit> = Vec::new();
367    let mut deprioritized: Vec<FocusUnit> = Vec::new();
368    for unit in units {
369        match unit.label {
370            FocusLabel::ReviewHere => review_here.push(unit),
371            FocusLabel::NotPrioritized => deprioritized.push(unit),
372        }
373    }
374    // The deprioritized escape hatch is path-sorted (stable enumeration order).
375    deprioritized.sort_by(|a, b| a.file.cmp(&b.file));
376
377    FocusMap {
378        review_here,
379        deprioritized,
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    fn facts(
388        file: &str,
389        fan_in: u32,
390        fan_out: u32,
391        dynamic: bool,
392        re_export: bool,
393    ) -> FocusFileFactsPaths {
394        FocusFileFactsPaths {
395            file: file.to_string(),
396            fan_in,
397            fan_out,
398            dynamic_dispatch: dynamic,
399            re_export_indirection: re_export,
400        }
401    }
402
403    fn inputs<'a>(
404        graph_facts: &'a [FocusFileFactsPaths],
405        boundary_files: &'a [BoundaryZoneFile],
406        public_api_added: &'a [String],
407        coordination_changed_files: &'a [String],
408        taint_touched_files: &'a [String],
409    ) -> FocusInputs<'a> {
410        FocusInputs {
411            graph_facts,
412            boundary_files,
413            public_api_added,
414            coordination_changed_files,
415            taint_touched_files,
416        }
417    }
418
419    // (a) NO `skip` label is ever emitted in free mode. The enum has no Skip
420    // variant; the test pins the serialized strings of every produced label.
421    #[test]
422    fn no_skip_label_ever_emitted_in_free_mode() {
423        let gf = vec![
424            facts("src/hot.ts", 12, 3, false, false), // review-here
425            facts("src/iso.ts", 0, 0, false, false),  // not-prioritized
426        ];
427        let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
428        let all_units: Vec<&FocusUnit> = map
429            .review_here
430            .iter()
431            .chain(map.deprioritized.iter())
432            .collect();
433        assert!(!all_units.is_empty());
434        for unit in all_units {
435            let token = unit.label.token();
436            assert_ne!(token, "skip", "free mode must never emit a skip label");
437            assert!(
438                token == "review-here" || token == "not-prioritized",
439                "unexpected label token {token}"
440            );
441        }
442        // Serialized JSON must not carry the token "skip" anywhere either.
443        let json = serde_json::to_string(&map).expect("serialize");
444        assert!(
445            !json.contains("\"skip\""),
446            "serialized focus map leaked a skip label: {json}"
447        );
448    }
449
450    // (b) Every de-prioritized unit is enumerable via the escape hatch:
451    // count(review_here) + count(deprioritized) == count(all).
452    #[test]
453    fn escape_hatch_enumerates_every_deprioritized_unit() {
454        let gf = vec![
455            facts("src/a.ts", 12, 4, false, false), // review-here
456            facts("src/b.ts", 0, 0, false, false),  // not-prioritized
457            facts("src/c.ts", 1, 0, false, false),  // not-prioritized (score 2 < 3)
458            facts("src/d.ts", 8, 0, false, false),  // review-here
459        ];
460        let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
461        assert_eq!(
462            map.total_units(),
463            gf.len(),
464            "every unit must be reachable via review-here OR deprioritized"
465        );
466        // The deprioritized list is the escape hatch: nothing is hidden.
467        assert!(!map.deprioritized.is_empty());
468        // No file appears in both lists (a strict partition).
469        for d in &map.deprioritized {
470            assert!(
471                !map.review_here.iter().any(|r| r.file == d.file),
472                "{} is in both lists",
473                d.file
474            );
475        }
476    }
477
478    // (c) A dynamically-wired unit carries the `low: dynamic dispatch detected`
479    // flag; a re-export-indirection unit carries `low: re-export indirection`.
480    #[test]
481    fn dynamic_and_re_export_units_carry_low_confidence_flags() {
482        let gf = vec![
483            facts("src/dyn.ts", 0, 0, true, false),
484            facts("src/barrel.ts", 0, 0, false, true),
485            facts("src/both.ts", 0, 0, true, true),
486        ];
487        let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
488        let all: Vec<&FocusUnit> = map
489            .review_here
490            .iter()
491            .chain(map.deprioritized.iter())
492            .collect();
493        let find = |file: &str| all.iter().find(|u| u.file == file).expect("unit present");
494
495        let dyn_unit = find("src/dyn.ts");
496        assert!(
497            dyn_unit
498                .confidence
499                .contains(&ConfidenceFlag::DynamicDispatch),
500            "dynamic unit must carry the dynamic-dispatch flag"
501        );
502        assert_eq!(
503            ConfidenceFlag::DynamicDispatch.message(),
504            "low: dynamic dispatch detected"
505        );
506
507        let barrel = find("src/barrel.ts");
508        assert!(
509            barrel
510                .confidence
511                .contains(&ConfidenceFlag::ReExportIndirection),
512            "barrel unit must carry the re-export-indirection flag"
513        );
514        assert_eq!(
515            ConfidenceFlag::ReExportIndirection.message(),
516            "low: re-export indirection"
517        );
518
519        let both = find("src/both.ts");
520        assert_eq!(both.confidence.len(), 2, "both flags present");
521    }
522
523    #[test]
524    fn confidence_flag_never_lowers_the_score() {
525        // Two identical-signal units, one with confidence flags: same total.
526        let plain = facts("src/plain.ts", 5, 0, false, false);
527        let flagged = facts("src/flagged.ts", 5, 0, true, true);
528        let plain_map = build_focus_map(&inputs(&[plain], &[], &[], &[], &[]));
529        let flagged_map = build_focus_map(&inputs(&[flagged], &[], &[], &[], &[]));
530        let plain_total = plain_map
531            .review_here
532            .iter()
533            .chain(plain_map.deprioritized.iter())
534            .next()
535            .unwrap()
536            .score
537            .total;
538        let flagged_total = flagged_map
539            .review_here
540            .iter()
541            .chain(flagged_map.deprioritized.iter())
542            .next()
543            .unwrap()
544            .score
545            .total;
546        assert_eq!(
547            plain_total, flagged_total,
548            "flags are advisory, not a penalty"
549        );
550    }
551
552    #[test]
553    fn risk_zone_and_change_shape_signals_raise_the_score() {
554        let gf = vec![facts("src/api.ts", 0, 0, false, false)];
555        let public_api = vec!["src/api.ts::Widget".to_string()];
556        let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
557        let unit = map
558            .review_here
559            .iter()
560            .chain(map.deprioritized.iter())
561            .next()
562            .unwrap();
563        // public-API delta -> risk_zone (+2) AND change_shape new-export (+2) = 4.
564        assert_eq!(unit.score.risk_zone, RISK_ZONE_WEIGHT);
565        assert_eq!(unit.score.change_shape, CHANGE_SHAPE_WEIGHT);
566        assert_eq!(unit.label, FocusLabel::ReviewHere);
567        assert!(unit.reason.contains("public API"));
568    }
569
570    #[test]
571    fn security_taint_seam_is_zero_with_empty_findings_and_lights_up_with_a_touch() {
572        let gf = vec![facts("src/sink.ts", 0, 0, false, false)];
573        // Empty taint slice (the brief-path reality today): seam contributes 0.
574        let no_taint = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
575        let no_taint_unit = no_taint
576            .review_here
577            .iter()
578            .chain(no_taint.deprioritized.iter())
579            .next()
580            .unwrap();
581        assert_eq!(no_taint_unit.score.security_taint, 0);
582        assert_eq!(no_taint_unit.label, FocusLabel::NotPrioritized);
583
584        // A future security pass threads the touched file: the seam lights up.
585        let touched = vec!["src/sink.ts".to_string()];
586        let with_taint = build_focus_map(&inputs(&gf, &[], &[], &[], &touched));
587        let taint_unit = with_taint
588            .review_here
589            .iter()
590            .chain(with_taint.deprioritized.iter())
591            .next()
592            .unwrap();
593        assert_eq!(taint_unit.score.security_taint, SECURITY_TAINT_WEIGHT);
594        // taint -> also a security-sensitive risk zone (+2).
595        assert_eq!(taint_unit.score.risk_zone, RISK_ZONE_WEIGHT);
596        assert_eq!(taint_unit.label, FocusLabel::ReviewHere);
597    }
598
599    #[test]
600    fn coordination_gap_drives_signature_change_shape() {
601        let gf = vec![facts("src/core.ts", 0, 0, false, false)];
602        let coordination = vec!["src/core.ts".to_string()];
603        let map = build_focus_map(&inputs(&gf, &[], &[], &coordination, &[]));
604        let unit = map
605            .review_here
606            .iter()
607            .chain(map.deprioritized.iter())
608            .next()
609            .unwrap();
610        assert_eq!(unit.score.change_shape, CHANGE_SHAPE_WEIGHT);
611        assert!(unit.reason.contains("contract consumed outside the diff"));
612    }
613
614    #[test]
615    fn focus_map_is_byte_identical_across_runs() {
616        let gf = vec![
617            facts("src/a.ts", 5, 2, true, false),
618            facts("src/b.ts", 0, 0, false, true),
619            facts("src/c.ts", 3, 1, false, false),
620        ];
621        let boundary = vec![BoundaryZoneFile {
622            from_file: "src/a.ts".to_string(),
623        }];
624        let public_api = vec!["src/c.ts::Thing".to_string()];
625        let first = build_focus_map(&inputs(&gf, &boundary, &public_api, &[], &[]));
626        let second = build_focus_map(&inputs(&gf, &boundary, &public_api, &[], &[]));
627        let s1 = serde_json::to_string_pretty(&first).unwrap();
628        let s2 = serde_json::to_string_pretty(&second).unwrap();
629        assert_eq!(s1, s2);
630    }
631
632    #[test]
633    fn review_here_is_ranked_by_score_descending() {
634        let gf = vec![
635            facts("src/low.ts", 2, 0, false, false),   // score 4
636            facts("src/high.ts", 12, 5, false, false), // score capped high
637        ];
638        let public_api = vec!["src/low.ts::X".to_string()];
639        let map = build_focus_map(&inputs(&gf, &[], &public_api, &[], &[]));
640        // Both should be review-here; high.ts ranks first.
641        assert_eq!(map.review_here.len(), 2);
642        assert!(map.review_here[0].score.total >= map.review_here[1].score.total);
643        assert_eq!(map.review_here[0].file, "src/high.ts");
644    }
645
646    // done-condition (c): the symbol-level call chain (`fallow trace`) is
647    // EXPLICITLY OFF the ranked path. The focus-map ranking inputs
648    // (`FocusInputs`) carry NO trace / symbol-chain field, and the composite
649    // `FocusScore.total` is the sum of EXACTLY the four documented components
650    // (no symbol-chain term). This pins the trace as never feeding
651    // de-prioritization.
652    #[test]
653    fn focus_map_inputs_have_no_symbol_chain_or_trace_field() {
654        // FocusInputs is the complete input surface to the focus map. Naming
655        // every field here is exhaustive (the struct is `pub` with no `..`), so
656        // adding a trace/symbol-chain field would force this destructure to be
657        // updated -- a compile-time guard that the trace stays out of the ranking
658        // inputs.
659        let empty_facts: &[FocusFileFactsPaths] = &[];
660        let empty_boundary: &[BoundaryZoneFile] = &[];
661        let empty_strings: &[String] = &[];
662        let FocusInputs {
663            graph_facts: _,
664            boundary_files: _,
665            public_api_added: _,
666            coordination_changed_files: _,
667            taint_touched_files: _,
668            // NOTE: no `symbol_chain` / `trace` field exists. If the trace ever wired
669            // one in, this destructure would fail to compile.
670        } = inputs(
671            empty_facts,
672            empty_boundary,
673            empty_strings,
674            empty_strings,
675            empty_strings,
676        );
677
678        // The composite total is the sum of exactly the four documented
679        // components. A symbol-chain term would break this invariant.
680        let gf = vec![facts("src/x.ts", 4, 2, false, false)];
681        let map = build_focus_map(&inputs(&gf, &[], &[], &[], &[]));
682        let unit = map
683            .review_here
684            .iter()
685            .chain(map.deprioritized.iter())
686            .next()
687            .unwrap();
688        let score = &unit.score;
689        assert_eq!(
690            score.total,
691            score.fan_io + score.security_taint + score.risk_zone + score.change_shape,
692            "the focus total must be the four documented components only -- no symbol-chain term"
693        );
694    }
695}