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}