Skip to main content

fallow_cli/
audit_brief.rs

1//! `fallow audit --brief` (alias `fallow review`): a deterministic rendering
2//! mode layered over the existing audit analysis.
3//!
4//! The brief answers "where do I look?" rather than "will CI block this?". It
5//! is composition + rendering over the same [`crate::audit::AuditResult`] that
6//! drives `fallow audit`; it runs no new analysis and, critically, ALWAYS exits
7//! 0 so a reviewer or agent can read the orientation even when the underlying
8//! audit verdict is `fail`. The verdict is still computed and carried in the
9//! brief JSON informationally, but it never drives the exit code on this path.
10//!
11//! The JSON envelope is independently versioned and tagged as
12//! `kind: "audit-brief"` so the brief shape evolves on its own cadence without
13//! bumping the main `--format json` contract.
14
15use std::process::ExitCode;
16
17pub use fallow_output::{
18    CoordinationGapFact, DiffTriage, GraphFacts, ImpactClosureFacts, PartitionFacts,
19    ReviewBriefSchemaVersion, ReviewBriefSubtractSections, ReviewDeltas, ReviewEffort,
20    ReviewUnitFact, RiskClass,
21};
22use fallow_types::results::AnalysisResults;
23use rustc_hash::FxHashSet;
24
25use crate::audit::AuditResult;
26use crate::report::sink::outln;
27
28pub type ReviewBriefOutput = fallow_output::StandardReviewBriefOutput;
29
30/// A file count at or above which a changeset is classified [`RiskClass::High`].
31const RISK_HIGH_FILES: usize = 20;
32/// A net-line count at or above which a changeset is classified
33/// [`RiskClass::High`].
34const RISK_HIGH_LINES: i64 = 500;
35/// A file count at or above which a changeset is classified
36/// [`RiskClass::Medium`].
37const RISK_MEDIUM_FILES: usize = 5;
38/// A net-line count at or above which a changeset is classified
39/// [`RiskClass::Medium`].
40const RISK_MEDIUM_LINES: i64 = 100;
41
42/// The honest-scope note stamped on every coordination-gap entry (ADR-001).
43const COORDINATION_GAP_NOTE: &str = "syntactic attention pointer, not a correctness proof";
44
45/// Build the deltas from head sets vs a base set, sorted for determinism.
46#[must_use]
47#[allow(
48    clippy::implicit_hasher,
49    reason = "callers always pass the audit FxHashSet key sets; generalizing the hasher adds noise"
50)]
51pub fn build_review_deltas(
52    head_boundary: &FxHashSet<String>,
53    base_boundary: &FxHashSet<String>,
54    head_cycles: &FxHashSet<String>,
55    base_cycles: &FxHashSet<String>,
56    head_public_api: &FxHashSet<String>,
57    base_public_api: &FxHashSet<String>,
58) -> ReviewDeltas {
59    use crate::audit::review_deltas::introduced_keys;
60    ReviewDeltas {
61        boundary_introduced: introduced_keys(head_boundary, base_boundary),
62        cycle_introduced: introduced_keys(head_cycles, base_cycles),
63        public_api_added: introduced_keys(head_public_api, base_public_api),
64    }
65}
66
67/// Classify a changeset's risk purely from its size. `net_lines` is consulted
68/// when diff evidence is available.
69#[must_use]
70pub fn classify_risk(files: usize, net_lines: Option<i64>) -> RiskClass {
71    let lines = net_lines.unwrap_or(0).abs();
72    if files >= RISK_HIGH_FILES || lines >= RISK_HIGH_LINES {
73        RiskClass::High
74    } else if files >= RISK_MEDIUM_FILES || lines >= RISK_MEDIUM_LINES {
75        RiskClass::Medium
76    } else {
77        RiskClass::Low
78    }
79}
80
81/// Map a [`RiskClass`] to the suggested reviewer effort.
82#[must_use]
83pub fn review_effort_for(risk: RiskClass) -> ReviewEffort {
84    match risk {
85        RiskClass::Low => ReviewEffort::Glance,
86        RiskClass::Medium => ReviewEffort::Review,
87        RiskClass::High => ReviewEffort::DeepDive,
88    }
89}
90
91/// Build the Stage 0 triage facts from the audit result.
92#[must_use]
93pub fn build_triage(
94    result: &AuditResult,
95    diff_index: Option<&fallow_output::DiffIndex>,
96) -> DiffTriage {
97    let files = result.changed_files_count;
98    let hunks = diff_index.map(fallow_output::DiffIndex::hunk_count);
99    let net_lines = diff_index.map(fallow_output::DiffIndex::net_lines);
100    let risk_class = classify_risk(files, net_lines);
101    DiffTriage {
102        files,
103        hunks,
104        net_lines,
105        risk_class,
106        review_effort: review_effort_for(risk_class),
107    }
108}
109
110/// Derive the Stage 1 graph facts from the analysis results plus the impact
111/// closure.
112///
113/// `boundaries_touched` is the deduped, sorted boundary-violation zone set;
114/// `reachable_from` is the impact closure's affected-not-shown set (modules the
115/// changed code reaches / affects, none in the diff). `exports_added` /
116/// `api_width_delta` stay stubbed until the export-surface delta.
117#[must_use]
118pub fn derive_graph_facts(
119    results: &AnalysisResults,
120    closure: Option<&fallow_engine::module_graph::ImpactClosurePaths>,
121) -> GraphFacts {
122    let mut zones: FxHashSet<String> = FxHashSet::default();
123    for finding in &results.boundary_violations {
124        zones.insert(finding.violation.from_zone.clone());
125        zones.insert(finding.violation.to_zone.clone());
126    }
127    let mut boundaries_touched: Vec<String> = zones.into_iter().collect();
128    boundaries_touched.sort();
129
130    let reachable_from = closure
131        .map(|c| c.affected_not_shown.clone())
132        .unwrap_or_default();
133
134    GraphFacts {
135        exports_added: 0,
136        api_width_delta: 0,
137        reachable_from,
138        boundaries_touched,
139    }
140}
141
142/// Build the Stage 3 impact-closure facts from the audit result's retained
143/// closure (computed on the brief path). Returns an empty closure when no graph
144/// was retained (the closure is `None`).
145#[must_use]
146fn build_impact_closure_facts(result: &AuditResult) -> ImpactClosureFacts {
147    let Some(closure) = result
148        .check
149        .as_ref()
150        .and_then(|c| c.impact_closure.as_ref())
151    else {
152        return ImpactClosureFacts::default();
153    };
154    let coordination_gap = closure
155        .coordination_gap
156        .iter()
157        .map(|gap| CoordinationGapFact {
158            changed_file: gap.changed_file.clone(),
159            consumer_file: gap.consumer_file.clone(),
160            consumed_symbols: gap.consumed_symbols.clone(),
161            note: COORDINATION_GAP_NOTE.to_string(),
162        })
163        .collect();
164    ImpactClosureFacts {
165        affected_not_shown: closure.affected_not_shown.clone(),
166        coordination_gap,
167    }
168}
169
170/// Build the Stage 2 partition facts from the audit result's retained
171/// partition+order (computed on the brief path). Returns an empty partition when
172/// no graph was retained (the partition is `None`).
173#[must_use]
174fn build_partition_facts(result: &AuditResult) -> PartitionFacts {
175    let Some(partition) = result
176        .check
177        .as_ref()
178        .and_then(|c| c.partition_order.as_ref())
179    else {
180        return PartitionFacts::default();
181    };
182    let units = partition
183        .units
184        .iter()
185        .map(|unit| ReviewUnitFact {
186            module_dir: unit.module_dir.clone(),
187            files: unit.files.clone(),
188        })
189        .collect();
190    PartitionFacts {
191        units,
192        order: partition.order.clone(),
193    }
194}
195
196/// Build the Stage 4 weighted focus map from the audit result's retained
197/// per-file graph facts plus the deltas / coordination signals. Returns an
198/// empty focus map when no graph facts were retained (off the brief path or no
199/// changed file mapped to a module).
200///
201/// The boundary risk-zone signal reuses the `from_path` of boundary violations
202/// whose introduced edge is in `deltas.boundary_introduced` (the same surface
203/// the decision surface reads). The security taint signal is wired as an EMPTY
204/// slice today: the brief path runs the bare dead-code analysis, not the opt-in
205/// `fallow security` taint engine, so `results.security_findings` is empty. The
206/// seam lights up the moment a security pass is threaded onto the brief, with no
207/// focus-map code change.
208#[must_use]
209fn build_focus_map(result: &AuditResult, deltas: &ReviewDeltas) -> crate::audit_focus::FocusMap {
210    use crate::audit_focus::{BoundaryZoneFile, FocusInputs, build_focus_map};
211
212    let Some(check) = result.check.as_ref() else {
213        return crate::audit_focus::FocusMap::default();
214    };
215    let Some(graph_facts) = check.focus_facts.as_ref() else {
216        return crate::audit_focus::FocusMap::default();
217    };
218    let root = &check.config.root;
219
220    // Boundary risk-zone files: the importing `from_path` of each boundary
221    // violation whose introduced zone-pair edge is in the delta set, deduped.
222    let mut seen_pairs: FxHashSet<String> = FxHashSet::default();
223    let mut boundary_files: Vec<BoundaryZoneFile> = Vec::new();
224    for finding in &check.results.boundary_violations {
225        let key = crate::audit::review_deltas::boundary_edge_key(finding);
226        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key) {
227            continue;
228        }
229        boundary_files.push(BoundaryZoneFile {
230            from_file: crate::audit::keys::relative_key_path(&finding.violation.from_path, root),
231        });
232    }
233
234    // Coordination-gap changed files (the signature-change change-shape proxy):
235    // the changed files whose contract is consumed outside the diff.
236    let coordination_changed_files: Vec<String> = check
237        .impact_closure
238        .as_ref()
239        .map(|c| {
240            let mut files: Vec<String> = c
241                .coordination_gap
242                .iter()
243                .map(|gap| gap.changed_file.clone())
244                .collect();
245            files.sort_unstable();
246            files.dedup();
247            files
248        })
249        .unwrap_or_default();
250
251    // Security taint touch: the brief path carries no security findings (the taint
252    // engine is the opt-in `fallow security` command), so this is empty today. The
253    // seam is a pure function of this slice; it lights up when a security pass is
254    // threaded onto the brief.
255    let taint_touched_files = taint_touched_files(result.check.as_ref());
256
257    // Runtime evidence (paid): `Some` only on the `--runtime-coverage` path.
258    // It weights hot files and enables safe-skip; `None` in free mode, where the
259    // focus map degrades to the deterministic no-runtime baseline byte-for-byte.
260    let runtime_focus = build_runtime_focus(result, root);
261
262    build_focus_map(&FocusInputs {
263        graph_facts,
264        boundary_files: &boundary_files,
265        public_api_added: &deltas.public_api_added,
266        coordination_changed_files: &coordination_changed_files,
267        taint_touched_files: &taint_touched_files,
268        runtime: runtime_focus.as_ref(),
269    })
270}
271
272/// Build the per-file [`crate::audit_focus::RuntimeFocus`] from the
273/// runtime-coverage health report, or `None` when the run carried no
274/// `--runtime-coverage` data (free mode, where the focus map stays byte-identical
275/// to the no-runtime baseline).
276///
277/// Hot files come from the report's `hot_paths` (peak invocation per file). Cold
278/// files are the runtime-proven-unused ones: a file with at least one
279/// `safe_to_delete` finding, NO finding of any other verdict, and no hot path.
280///
281/// Honest boundary: the report's `findings` omit `active` functions, so a file
282/// can carry a live, executed function this signal never sees. `hot_paths` only
283/// surfaces functions at/above the configured hot threshold (`min_invocations_hot`,
284/// default 100, raisable via `--min-invocations-hot`), so an `active` function in
285/// the `[low_traffic .. hot)` band shows up in NEITHER list:
286/// such a file, if its only retained finding is `safe_to_delete`, is classified
287/// cold here despite having run. This is why the cold signal is never trusted on
288/// its own: the safe-skip label additionally requires zero static risk and no
289/// confidence flag, applies only to a file already in the diff, and is always
290/// advisory (the skip stays in the escape-hatch list, never hidden). Paths are
291/// normalized to the brief's root-relative forward-slashed space so the
292/// focus-map joins are byte-exact.
293fn build_runtime_focus(
294    result: &AuditResult,
295    root: &std::path::Path,
296) -> Option<crate::audit_focus::RuntimeFocus> {
297    let report = result.health.as_ref()?.report.runtime_coverage.as_ref()?;
298
299    let hot_pairs: Vec<(String, u64)> = report
300        .hot_paths
301        .iter()
302        .map(|hot| {
303            (
304                crate::audit::keys::relative_key_path(&hot.path, root),
305                hot.invocations,
306            )
307        })
308        .collect();
309
310    // Partition findings into safe_to_delete (cold candidate) vs any other verdict
311    // (the disqualifier that keeps a mixed-verdict file out of the cold set).
312    let mut safe_to_delete: FxHashSet<String> = FxHashSet::default();
313    let mut other_verdict: FxHashSet<String> = FxHashSet::default();
314    for finding in &report.findings {
315        let file = crate::audit::keys::relative_key_path(&finding.path, root);
316        if matches!(
317            finding.verdict,
318            fallow_output::RuntimeCoverageVerdict::SafeToDelete
319        ) {
320            safe_to_delete.insert(file);
321        } else {
322            other_verdict.insert(file);
323        }
324    }
325
326    reconcile_runtime_focus(hot_pairs, &safe_to_delete, &other_verdict)
327}
328
329/// Reconcile the projected runtime signals into a [`crate::audit_focus::RuntimeFocus`]:
330/// peak-aggregate hot invocations per file, and keep a file cold only when it has
331/// a `safe_to_delete` finding, no other-verdict finding, and no hot path (so the
332/// hot and cold lists are disjoint by construction). Returns `None` when both
333/// lists are empty. Pure (no I/O), so the mixed-verdict exclusion, the
334/// hot-excludes-cold filter, and the peak aggregation are unit-tested without
335/// constructing a full health report.
336fn reconcile_runtime_focus(
337    hot_pairs: Vec<(String, u64)>,
338    safe_to_delete: &FxHashSet<String>,
339    other_verdict: &FxHashSet<String>,
340) -> Option<crate::audit_focus::RuntimeFocus> {
341    use crate::audit_focus::{RuntimeFocus, RuntimeHotFile};
342
343    // Peak invocation per hot file (max across the file's hot functions).
344    let mut hot_by_file: rustc_hash::FxHashMap<String, u64> = rustc_hash::FxHashMap::default();
345    for (file, invocations) in hot_pairs {
346        let entry = hot_by_file.entry(file).or_insert(0);
347        *entry = (*entry).max(invocations);
348    }
349
350    let mut hot_files: Vec<RuntimeHotFile> = hot_by_file
351        .into_iter()
352        .map(|(file, invocations)| RuntimeHotFile { file, invocations })
353        .collect();
354    hot_files.sort_by(|a, b| a.file.cmp(&b.file));
355    let hot_set: FxHashSet<&str> = hot_files.iter().map(|hot| hot.file.as_str()).collect();
356
357    let mut cold_files: Vec<String> = safe_to_delete
358        .iter()
359        .filter(|file| !other_verdict.contains(*file) && !hot_set.contains(file.as_str()))
360        .cloned()
361        .collect();
362    cold_files.sort();
363
364    if hot_files.is_empty() && cold_files.is_empty() {
365        return None;
366    }
367    Some(RuntimeFocus {
368        hot_files,
369        cold_files,
370    })
371}
372
373/// Collect the root-relative file paths a security source -> sink taint trace
374/// touches, from any retained `security_findings` (anchor + every trace hop).
375///
376/// Today the brief path runs the bare dead-code analysis, so `security_findings`
377/// is empty and this returns an empty Vec (the security-taint seam contributes
378/// 0). The function is a pure projection over the findings slice, so the moment a
379/// future epic threads a security pass onto the brief, the focus map's taint
380/// signal lights up with no focus-map code change.
381fn taint_touched_files(check: Option<&crate::check::CheckResult>) -> Vec<String> {
382    let Some(check) = check else {
383        return Vec::new();
384    };
385    let root = &check.config.root;
386    let mut touched: FxHashSet<String> = FxHashSet::default();
387    for finding in &check.results.security_findings {
388        touched.insert(crate::audit::keys::relative_key_path(&finding.path, root));
389        for hop in &finding.trace {
390            touched.insert(crate::audit::keys::relative_key_path(&hop.path, root));
391        }
392    }
393    let mut files: Vec<String> = touched.into_iter().collect();
394    files.sort();
395    files
396}
397
398/// Assemble the structured [`ReviewBriefOutput`] for an audit result. Pure: no
399/// timestamps, no randomness, so two runs over the same tree serialize
400/// byte-identically.
401#[must_use]
402pub fn build_brief_output(result: &AuditResult) -> ReviewBriefOutput {
403    build_brief_output_with_diff(result, result.diff_index.as_ref())
404}
405
406/// Assemble the structured [`ReviewBriefOutput`] with optional diff evidence.
407/// The caller owns diff discovery so this reusable builder stays pure.
408#[must_use]
409pub fn build_brief_output_with_diff(
410    result: &AuditResult,
411    diff_index: Option<&fallow_output::DiffIndex>,
412) -> ReviewBriefOutput {
413    let triage = build_triage(result, diff_index);
414    let closure = result
415        .check
416        .as_ref()
417        .and_then(|c| c.impact_closure.as_ref());
418    let deltas = result.review_deltas.clone().unwrap_or_default();
419    let mut graph_facts = result.check.as_ref().map_or_else(
420        || GraphFacts {
421            exports_added: 0,
422            api_width_delta: 0,
423            reachable_from: Vec::new(),
424            boundaries_touched: Vec::new(),
425        },
426        |check| derive_graph_facts(&check.results, closure),
427    );
428    // The exports-aware delta fills the previously-stubbed export facts:
429    // `exports_added` / `api_width_delta` count the public-API surface the change
430    // widened, not raw internal churn.
431    let added = deltas.public_api_added.len();
432    graph_facts.exports_added = added;
433    graph_facts.api_width_delta = i64::try_from(added).unwrap_or(i64::MAX);
434    let partition = build_partition_facts(result);
435    let impact_closure = build_impact_closure_facts(result);
436    let focus = build_focus_map(result, &deltas);
437    ReviewBriefOutput {
438        schema_version: ReviewBriefSchemaVersion::default(),
439        version: env!("CARGO_PKG_VERSION").to_string(),
440        command: "audit-brief".to_string(),
441        triage,
442        graph_facts,
443        partition,
444        impact_closure,
445        focus,
446        deltas,
447        weakening: result.weakening_signals.clone(),
448        routing: result.routing.clone().unwrap_or_default(),
449        decisions: result.decision_surface.clone().unwrap_or_default(),
450    }
451}
452
453/// Build the reused "subtract" section (dead-code / duplication / complexity)
454/// for the brief JSON value, mirroring `fallow audit --format json`.
455fn build_brief_subtract_sections(
456    result: &AuditResult,
457) -> Result<ReviewBriefSubtractSections, ExitCode> {
458    let mut obj = serde_json::Map::new();
459    if let Some(ref check) = result.check {
460        crate::audit::insert_audit_dead_code_json(&mut obj, result, check)?;
461    }
462    if let Some(ref dupes) = result.dupes {
463        crate::audit::insert_audit_duplication_json(&mut obj, result, dupes)?;
464    }
465    if let Some(ref health) = result.health {
466        crate::audit::insert_audit_health_json(&mut obj, result, health)?;
467    }
468    Ok(ReviewBriefSubtractSections {
469        dead_code: obj.remove("dead_code"),
470        duplication: obj.remove("duplication"),
471        complexity: obj.remove("complexity"),
472    })
473}
474
475/// Build the complete brief JSON value: the versioned brief header, the
476/// informational audit verdict header, the triage + graph-facts stages, and the
477/// reused subtract section.
478fn build_brief_json(
479    result: &AuditResult,
480    diff_index: Option<&fallow_output::DiffIndex>,
481) -> Result<serde_json::Value, ExitCode> {
482    let brief = build_brief_output_with_diff(result, diff_index);
483    let audit_header =
484        fallow_api::build_review_brief_header(crate::audit::audit_json_header_input(result));
485    let subtract = build_brief_subtract_sections(result)?;
486    fallow_output::build_review_brief_json_output(brief, audit_header, subtract).map_err(|err| {
487        crate::error::emit_error(
488            &format!("JSON serialization error: {err}"),
489            2,
490            fallow_config::OutputFormat::Json,
491        )
492    })
493}
494
495/// Render the brief as JSON. Always returns `SUCCESS`; a serialization failure
496/// surfaces the error but the brief contract still exits 0.
497fn print_brief_json(
498    result: &AuditResult,
499    diff_index: Option<&fallow_output::DiffIndex>,
500) -> ExitCode {
501    match build_brief_json(result, diff_index) {
502        Ok(output) => {
503            let Ok(output) = fallow_output::serialize_review_brief_json_output(
504                output,
505                crate::output_runtime::current_root_envelope_mode(),
506                crate::output_runtime::telemetry_analysis_run_id().as_deref(),
507            ) else {
508                return ExitCode::SUCCESS;
509            };
510            let _ = crate::report::emit_json(&output, "audit-brief");
511            ExitCode::SUCCESS
512        }
513        Err(_) => ExitCode::SUCCESS,
514    }
515}
516
517/// Render the brief in human / compact / markdown form: a short orientation
518/// header (scope, risk, effort, boundaries) followed by the same findings
519/// sections `fallow audit` prints.
520fn print_brief_human(
521    result: &AuditResult,
522    diff_index: Option<&fallow_output::DiffIndex>,
523    quiet: bool,
524    explain: bool,
525    show_deprioritized: bool,
526) {
527    let brief = build_brief_output_with_diff(result, diff_index);
528
529    if !quiet {
530        eprintln!();
531        // The decision surface is the apex; it LEADS (collapse-by-default).
532        print_decision_surface_human(&brief.decisions);
533        // The upstream stages are the decision surface's drill-down derivation.
534        eprintln!(
535            "Review brief (drill-down): {} changed file{} vs {} \u{00b7} risk {} \u{00b7} effort {}",
536            result.changed_files_count,
537            crate::report::plural(result.changed_files_count),
538            result.base_ref,
539            risk_label(brief.triage.risk_class),
540            effort_label(brief.triage.review_effort),
541        );
542        if !brief.graph_facts.boundaries_touched.is_empty() {
543            eprintln!(
544                "  boundaries touched: {}",
545                brief.graph_facts.boundaries_touched.join(", ")
546            );
547        }
548        print_partition_human(&brief.partition);
549        print_impact_closure_human(&brief.impact_closure);
550        print_focus_human(&brief.focus, show_deprioritized);
551        print_deltas_human(&brief.deltas);
552        print_weakening_human(&brief.weakening);
553        print_routing_human(&brief.routing);
554    }
555
556    // Always render the findings sections so the brief shows WHERE to look, even
557    // when the underlying verdict is a fail. Headers stay off (the brief owns its
558    // own header line above).
559    crate::audit::print_audit_findings(result, quiet, explain, false);
560}
561
562/// Print the Stage 2 partition + order on the human brief: the by-module units
563/// and the dependency-sensible review order (definitions before consumers).
564/// Caller has already gated on `!quiet`. Renders nothing when no unit was
565/// computed (no graph retained, or every changed file is non-source).
566fn print_partition_human(partition: &PartitionFacts) {
567    if partition.units.is_empty() {
568        return;
569    }
570    eprintln!(
571        "  partition: {} unit{} (by module)",
572        partition.units.len(),
573        crate::report::plural(partition.units.len()),
574    );
575    if !partition.order.is_empty() {
576        let labeled: Vec<String> = partition.order.iter().map(|dir| unit_label(dir)).collect();
577        eprintln!("  review order: {}", labeled.join(" \u{2192} "));
578    }
579}
580
581/// Label a unit's module directory for human output; the empty root-group key
582/// renders as `<root>` so it is not a blank token.
583fn unit_label(module_dir: &str) -> String {
584    if module_dir.is_empty() {
585        "<root>".to_string()
586    } else {
587        module_dir.to_string()
588    }
589}
590
591/// Print the Stage 3 impact-closure summary on the human brief: the count of
592/// affected-but-not-shown files and each coordination gap (the precise
593/// inter-module attention pointer). Caller has already gated on `!quiet`.
594fn print_impact_closure_human(closure: &ImpactClosureFacts) {
595    if !closure.affected_not_shown.is_empty() {
596        eprintln!(
597            "  impact closure: {} file{} affected beyond the diff",
598            closure.affected_not_shown.len(),
599            crate::report::plural(closure.affected_not_shown.len()),
600        );
601    }
602    for gap in &closure.coordination_gap {
603        eprintln!(
604            "  coordination gap: {} consumes {} from {} (not in this diff)",
605            gap.consumer_file,
606            gap.consumed_symbols.join(", "),
607            gap.changed_file,
608        );
609    }
610}
611
612/// Print the Stage 4 weighted focus map on the human brief: the ranked
613/// `review-here` units (with reason + any low-confidence flag), then the
614/// de-prioritized count as a collapsed escape hatch. `--show-deprioritized`
615/// re-expands the full de-prioritized list ("show me what you de-prioritized").
616/// Caller has already gated on `!quiet`. Renders nothing when no unit was scored.
617fn print_focus_human(focus: &crate::audit_focus::FocusMap, show_deprioritized: bool) {
618    if focus.total_units() == 0 {
619        return;
620    }
621    if !focus.review_here.is_empty() {
622        eprintln!(
623            "  focus: {} unit{} to review here (of {} changed)",
624            focus.review_here.len(),
625            crate::report::plural(focus.review_here.len()),
626            focus.total_units(),
627        );
628        for unit in &focus.review_here {
629            eprintln!(
630                "    [{}] {}: {}",
631                unit.label.token(),
632                unit.file,
633                unit.reason
634            );
635            for flag in &unit.confidence {
636                eprintln!("      confidence {}", flag.message());
637            }
638        }
639    }
640    if focus.deprioritized.is_empty() {
641        return;
642    }
643    if show_deprioritized {
644        eprintln!("  de-prioritized ({}):", focus.deprioritized.len());
645        for unit in &focus.deprioritized {
646            eprintln!(
647                "    [{}] {}: {}",
648                unit.label.token(),
649                unit.file,
650                unit.reason
651            );
652            for flag in &unit.confidence {
653                eprintln!("      confidence {}", flag.message());
654            }
655        }
656    } else {
657        eprintln!(
658            "  de-prioritized: {} unit{} (run with --show-deprioritized to list)",
659            focus.deprioritized.len(),
660            crate::report::plural(focus.deprioritized.len()),
661        );
662    }
663}
664
665/// Print the diff-aware deltas (6.A): boundary/cycle introduced and the
666/// exports-aware public-API surface delta (batch-consolidated per R1). Caller
667/// has already gated on `!quiet`.
668fn print_deltas_human(deltas: &ReviewDeltas) {
669    for edge in &deltas.boundary_introduced {
670        eprintln!("  new boundary edge: {edge} (not present at base)");
671    }
672    for cycle in &deltas.cycle_introduced {
673        eprintln!("  new circular dependency: {cycle} (not present at base)");
674    }
675    if !deltas.public_api_added.is_empty() {
676        eprintln!(
677            "  public API surface widened by {} export{} (exports-aware)",
678            deltas.public_api_added.len(),
679            crate::report::plural(deltas.public_api_added.len()),
680        );
681    }
682}
683
684/// Print the weakening signals (6.F headline). Advisory, reviewer-private.
685fn print_weakening_human(signals: &[crate::audit::weakening::WeakeningSignal]) {
686    if signals.is_empty() {
687        return;
688    }
689    eprintln!(
690        "  weakening signals ({}, reviewer-private, advisory):",
691        signals.len()
692    );
693    for signal in signals {
694        eprintln!(
695            "    {}: {} in {}",
696            weakening_label(signal.kind),
697            signal.evidence,
698            signal.file,
699        );
700    }
701}
702
703/// Print the ownership routing (6.D): per-unit expert + bus-factor flag.
704fn print_routing_human(routing: &crate::audit::routing::RoutingFacts) {
705    for unit in &routing.units {
706        if unit.expert.is_empty() {
707            continue;
708        }
709        let bus = if unit.bus_factor_one {
710            " (bus-factor 1)"
711        } else {
712            ""
713        };
714        eprintln!(
715            "  review {}: ask {}{bus}",
716            unit.file,
717            unit.expert.join(", "),
718        );
719    }
720}
721
722/// Print the decision surface (the apex, 6.G): the ranked, capped set of
723/// consequential structural decisions, each as a framed judgment question with
724/// its routed expert. Caller has already gated on `!quiet`. Leads the brief.
725fn print_decision_surface_human(surface: &crate::audit_decision_surface::DecisionSurface) {
726    if surface.decisions.is_empty() {
727        eprintln!("Decisions: none (no consequential structural decision in this change)");
728        eprintln!();
729        return;
730    }
731    eprintln!("Decisions to make ({}):", surface.decisions.len());
732    for (i, decision) in surface.decisions.iter().enumerate() {
733        // Taste ownership: the question first (never an answer), then the honest
734        // graph fact, then the named trade-off. The human reads reversibility from
735        // the count; the tool never labels the door or recommends a choice.
736        eprintln!(
737            "  {}. [{}] {}",
738            i + 1,
739            decision.category.tag(),
740            decision.question
741        );
742        if !decision.tradeoff.is_empty() {
743            eprintln!("     trade-off: {}", decision.tradeoff);
744        }
745        if !decision.expert.is_empty() {
746            let bus = if decision.bus_factor_one {
747                " (bus-factor 1)"
748            } else {
749                ""
750            };
751            eprintln!("     ask: {}{bus}", decision.expert.join(", "));
752        }
753    }
754    if let Some(note) = &surface.truncated {
755        eprintln!("  ... {}", note.reason);
756    }
757    eprintln!();
758}
759
760fn weakening_label(kind: crate::audit::weakening::WeakeningKind) -> &'static str {
761    use crate::audit::weakening::WeakeningKind;
762    match kind {
763        WeakeningKind::TestWeakened => "test weakened",
764        WeakeningKind::ThresholdLowered => "threshold lowered",
765        WeakeningKind::SuppressionAdded => "suppression added",
766        WeakeningKind::SecurityCheckRemoved => "security check removed",
767    }
768}
769
770fn risk_label(risk: RiskClass) -> &'static str {
771    match risk {
772        RiskClass::Low => "low",
773        RiskClass::Medium => "medium",
774        RiskClass::High => "high",
775    }
776}
777
778fn effort_label(effort: ReviewEffort) -> &'static str {
779    match effort {
780        ReviewEffort::Glance => "glance",
781        ReviewEffort::Review => "review",
782        ReviewEffort::DeepDive => "deep-dive",
783    }
784}
785
786/// Print the brief and return an exit code that is ALWAYS `SUCCESS`.
787///
788/// This is the exit-0 seam: `fallow review` (and `fallow audit --brief`) never
789/// gate on the audit verdict. The verdict is still carried in the JSON output
790/// informationally. Format dispatch mirrors `print_audit_result`, but every arm
791/// forces success: JSON renders the brief envelope; human / compact / markdown
792/// render the brief orientation header plus findings; any other format
793/// (SARIF, CodeClimate, PR/review envelopes, badge) is rendered through the
794/// standard audit path and then forced to success so the format stays usable
795/// without re-implementing it for the brief.
796#[must_use]
797pub fn print_brief_result(
798    result: &AuditResult,
799    diff_index: Option<&fallow_output::DiffIndex>,
800    quiet: bool,
801    explain: bool,
802    show_deprioritized: bool,
803) -> ExitCode {
804    use fallow_config::OutputFormat;
805
806    match result.output {
807        OutputFormat::Json => print_brief_json(result, diff_index),
808        OutputFormat::Human | OutputFormat::Compact | OutputFormat::Markdown => {
809            print_brief_human(result, diff_index, quiet, explain, show_deprioritized);
810            ExitCode::SUCCESS
811        }
812        _ => {
813            // For machine/CI formats not specific to the brief, delegate to the
814            // standard audit renderer for the body, then force success: the
815            // brief invariant is exit-0 regardless of verdict.
816            let _ = crate::audit::print_audit_result(result, quiet, explain);
817            ExitCode::SUCCESS
818        }
819    }
820}
821
822/// Render the SEPARABLE decision-surface envelope (the `decision_surface` MCP
823/// tool's output + `fallow decision-surface`). Emits ONLY the ranked, capped
824/// decisions with structured `actions[]`, never the full brief. Always exit 0.
825///
826/// JSON renders the typed decision-surface envelope (`kind:
827/// "decision-surface"`); human / compact / markdown render the apex header.
828#[must_use]
829pub fn print_decision_surface_result(result: &AuditResult, quiet: bool) -> ExitCode {
830    use fallow_config::OutputFormat;
831
832    let surface = result.decision_surface.clone().unwrap_or_default();
833    match result.output {
834        OutputFormat::Json => {
835            let output = crate::audit_decision_surface::build_decision_surface_output(&surface);
836            match fallow_output::serialize_decision_surface_json_output(
837                output,
838                crate::output_runtime::current_root_envelope_mode(),
839                crate::output_runtime::telemetry_analysis_run_id().as_deref(),
840            ) {
841                Ok(value) => {
842                    let _ = crate::report::emit_json(&value, "decision-surface");
843                    ExitCode::SUCCESS
844                }
845                Err(_) => ExitCode::SUCCESS,
846            }
847        }
848        _ => {
849            if !quiet {
850                print_decision_surface_human(&surface);
851            }
852            ExitCode::SUCCESS
853        }
854    }
855}
856
857/// Render the agent-contract WALKTHROUGH GUIDE: the digest (brief +
858/// decision surface), the review direction, the JSON schema the agent returns,
859/// and the deterministic graph-snapshot pin. JSON renders the typed guide
860/// envelope (`kind: "review-walkthrough-guide"`). Every format emits the guide
861/// as JSON: the guide is an agent-facing contract, not a human walkthrough.
862/// Always exit 0.
863#[must_use]
864pub fn print_walkthrough_guide_result(result: &AuditResult) -> ExitCode {
865    let guide = crate::audit_walkthrough::build_guide_from_result(result);
866    if let Ok(value) = fallow_output::serialize_walkthrough_guide_json_output(
867        guide,
868        crate::output_runtime::current_root_envelope_mode(),
869        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
870    ) {
871        let _ = crate::report::emit_json(&value, "review-walkthrough-guide");
872    }
873    ExitCode::SUCCESS
874}
875
876/// Ingest the agent's judgment JSON from `path` and POST-VALIDATE it against
877/// the live graph: reject unanchored signal_ids (anti-hallucination), refuse the
878/// whole payload when the echoed graph-snapshot hash is stale (the tree moved).
879/// JSON renders the typed walkthrough-validation envelope (`kind:
880/// "review-walkthrough-validation"`). Always exit 0 (advisory).
881///
882/// A path that cannot be read yields an empty agent payload (default `""` hash),
883/// which never matches the current hash, so it is refused as stale, the safe
884/// direction: a missing or garbled agent file never accepts a judgment.
885#[must_use]
886pub fn print_walkthrough_file_result(result: &AuditResult, path: &std::path::Path) -> ExitCode {
887    let contents = std::fs::read_to_string(path).unwrap_or_default();
888    let agent = crate::audit_walkthrough::parse_agent_walkthrough(&contents);
889    let surface = result.decision_surface.clone().unwrap_or_default();
890    let current_hash = result.graph_snapshot_hash.clone().unwrap_or_default();
891    let change_anchor_ids =
892        crate::audit_walkthrough::change_anchor_allowlist(&result.change_anchors);
893    let validation = crate::audit_walkthrough::validate_walkthrough(
894        &agent,
895        &surface,
896        &change_anchor_ids,
897        &current_hash,
898    );
899    if let Ok(value) = fallow_output::serialize_walkthrough_validation_json_output(
900        validation,
901        crate::output_runtime::current_root_envelope_mode(),
902        crate::output_runtime::telemetry_analysis_run_id().as_deref(),
903    ) {
904        let _ = crate::report::emit_json(&value, "review-walkthrough-validation");
905    }
906    ExitCode::SUCCESS
907}
908
909/// Render the EXISTING walkthrough guide as a HUMAN terminal tour (or markdown
910/// with `--format markdown`). The guide data is built unchanged by
911/// [`crate::audit_walkthrough::build_guide_from_result`]; this only renders it.
912///
913/// Format dispatch on `result.output`:
914/// - `Json` delegates verbatim to [`print_walkthrough_guide_result`], so
915///   `--walkthrough --format json` is byte-identical to `--walkthrough-guide
916///   --format json` (the json-reuse seam, zero duplication).
917/// - `Markdown` emits a paste-into-PR markdown tour to STDOUT.
918/// - every other format (Human / Compact / the CI envelopes) emits the colored
919///   staged terminal tour: the Review Focus header + final status to stderr, the
920///   tour body to stdout. The guide is advisory, never a CI gate envelope, so
921///   SARIF / CodeClimate / PR-comment formats fall through to the human tour
922///   rather than implying gate semantics.
923///
924/// `root` and `cache_dir` are threaded from `AuditOptions` because `AuditResult`
925/// carries neither: `root` displays paths, `cache_dir` locates the local
926/// viewed-state ledger. `mark_viewed` records files as viewed BEFORE rendering;
927/// the render itself is read-only. Always exit 0, even when the verdict is Fail.
928#[must_use]
929pub fn print_walkthrough_human_result(
930    result: &AuditResult,
931    root: &std::path::Path,
932    cache_dir: &std::path::Path,
933    mark_viewed: &[std::path::PathBuf],
934    show_cleared: bool,
935    quiet: bool,
936) -> ExitCode {
937    use fallow_config::OutputFormat;
938
939    // JSON reuses the single guide JSON path verbatim (no second serializer).
940    if matches!(result.output, OutputFormat::Json) {
941        return print_walkthrough_guide_result(result);
942    }
943
944    let guide = crate::audit_walkthrough::build_guide_from_result(result);
945    record_walkthrough_marks(&guide, root, cache_dir, mark_viewed);
946
947    // Load the viewed-state ledger ONCE and share it across both surfaces, so the
948    // markdown render honors `--mark-viewed` the same way the human render does
949    // (the two formats agree on the same on-disk state instead of markdown
950    // silently ignoring it).
951    let viewed = crate::walkthrough_state::load_viewed_state(cache_dir);
952
953    if matches!(result.output, OutputFormat::Markdown) {
954        let viewed_files = crate::report::walkthrough_viewed_files(&guide, &viewed);
955        let markdown = fallow_api::build_walkthrough_markdown(&guide, root, &viewed_files);
956        outln!("{markdown}");
957        return ExitCode::SUCCESS;
958    }
959
960    let render = crate::report::build_walkthrough_human(&guide, &viewed, show_cleared);
961    if !quiet {
962        for line in &render.header {
963            eprintln!("{line}");
964        }
965    }
966    for line in &render.body {
967        outln!("{line}");
968    }
969    if !quiet {
970        eprintln!("{}", render.status);
971    }
972    ExitCode::SUCCESS
973}
974
975/// Record each `--mark-viewed` path as viewed against the current guide hash.
976///
977/// Paths are normalized to the guide's root-relative VIEW key (the guide stores
978/// root-relative paths in `direction.order`). IO failures are swallowed: the
979/// viewed-state is a local convenience and must never change the exit code.
980fn record_walkthrough_marks(
981    guide: &crate::audit_walkthrough::WalkthroughGuide,
982    root: &std::path::Path,
983    cache_dir: &std::path::Path,
984    mark_viewed: &[std::path::PathBuf],
985) {
986    if mark_viewed.is_empty() {
987        return;
988    }
989    let keys: Vec<String> = mark_viewed
990        .iter()
991        .map(|path| walkthrough_view_key(path, root))
992        .collect();
993    let _ = crate::walkthrough_state::mark_viewed(cache_dir, &keys, &guide.graph_snapshot_hash);
994}
995
996/// Normalize a `--mark-viewed` path to the guide's root-relative, forward-slashed
997/// VIEW key, so a user can pass either an absolute or a relative path.
998fn walkthrough_view_key(path: &std::path::Path, root: &std::path::Path) -> String {
999    let rel = path.strip_prefix(root).unwrap_or(path);
1000    rel.to_string_lossy().replace('\\', "/")
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005    use std::time::Duration;
1006
1007    use fallow_config::{AuditGate, OutputFormat};
1008    use fallow_output::REVIEW_BRIEF_SCHEMA_VERSION;
1009    use rustc_hash::FxHashSet;
1010
1011    use crate::audit::{AuditAttribution, AuditResult, AuditSummary, AuditVerdict};
1012
1013    fn str_set(files: &[&str]) -> FxHashSet<String> {
1014        files.iter().map(|file| (*file).to_string()).collect()
1015    }
1016
1017    // Producer: the runtime hot/cold reconciliation. A file with a peak hot
1018    // path is hot (peak = max over its functions); a file with only a
1019    // safe_to_delete finding is cold; a mixed-verdict file is excluded; a file
1020    // that is both safe_to_delete AND hot stays hot (disjoint lists).
1021    #[test]
1022    fn reconcile_runtime_focus_classifies_hot_cold_and_excludes_mixed() {
1023        let hot_pairs = vec![
1024            ("src/hot.ts".to_string(), 120),
1025            ("src/hot.ts".to_string(), 900),  // peak wins
1026            ("src/both.ts".to_string(), 300), // also safe_to_delete below -> stays hot
1027        ];
1028        let safe = str_set(&["src/cold.ts", "src/mixed.ts", "src/both.ts"]);
1029        let other = str_set(&["src/mixed.ts"]); // mixed.ts also has a review_required -> not cold
1030        let focus =
1031            super::reconcile_runtime_focus(hot_pairs, &safe, &other).expect("non-empty focus");
1032
1033        // Hot files: peak-aggregated, sorted.
1034        let hot: Vec<(&str, u64)> = focus
1035            .hot_files
1036            .iter()
1037            .map(|hot| (hot.file.as_str(), hot.invocations))
1038            .collect();
1039        assert_eq!(hot, vec![("src/both.ts", 300), ("src/hot.ts", 900)]);
1040
1041        // Cold = safe_to_delete minus other-verdict minus hot. Only `cold.ts`.
1042        assert_eq!(focus.cold_files, vec!["src/cold.ts".to_string()]);
1043    }
1044
1045    // Producer: no signal at all -> None (free-mode fall-through).
1046    #[test]
1047    fn reconcile_runtime_focus_is_none_when_empty() {
1048        assert!(super::reconcile_runtime_focus(Vec::new(), &str_set(&[]), &str_set(&[])).is_none());
1049    }
1050
1051    use super::*;
1052
1053    fn audit_result(verdict: AuditVerdict, output: OutputFormat) -> AuditResult {
1054        AuditResult {
1055            verdict,
1056            summary: AuditSummary {
1057                dead_code_issues: 0,
1058                dead_code_has_errors: false,
1059                complexity_findings: 0,
1060                max_cyclomatic: None,
1061                duplication_clone_groups: 0,
1062            },
1063            attribution: AuditAttribution {
1064                gate: AuditGate::NewOnly,
1065                ..AuditAttribution::default()
1066            },
1067            base_snapshot: None,
1068            base_snapshot_skipped: false,
1069            changed_files_count: 0,
1070            changed_files: Vec::new(),
1071            base_ref: "origin/main".to_string(),
1072            base_description: None,
1073            head_sha: None,
1074            output,
1075            performance: false,
1076            check: None,
1077            dupes: None,
1078            health: None,
1079            elapsed: Duration::ZERO,
1080            review_deltas: None,
1081            weakening_signals: Vec::new(),
1082            routing: None,
1083            decision_surface: None,
1084            graph_snapshot_hash: None,
1085            change_anchors: Vec::new(),
1086            diff_index: None,
1087        }
1088    }
1089
1090    #[test]
1091    fn brief_mode_always_returns_success_even_when_verdict_is_fail() {
1092        // Human path.
1093        let human = audit_result(AuditVerdict::Fail, OutputFormat::Human);
1094        assert_eq!(
1095            print_brief_result(&human, None, true, false, false),
1096            ExitCode::SUCCESS
1097        );
1098
1099        // JSON path.
1100        let json = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1101        assert_eq!(
1102            print_brief_result(&json, None, true, false, false),
1103            ExitCode::SUCCESS
1104        );
1105    }
1106
1107    #[test]
1108    fn brief_json_validates_against_audit_brief_schema_variant() {
1109        let result = audit_result(AuditVerdict::Fail, OutputFormat::Json);
1110        let value = fallow_output::serialize_review_brief_json_output(
1111            build_brief_json(&result, None).expect("brief json must build"),
1112            crate::output_runtime::current_root_envelope_mode(),
1113            crate::output_runtime::telemetry_analysis_run_id().as_deref(),
1114        )
1115        .expect("brief json must serialize");
1116
1117        assert_eq!(value["kind"], "audit-brief");
1118        assert_eq!(value["command"], "audit-brief");
1119        assert_eq!(value["schema_version"], REVIEW_BRIEF_SCHEMA_VERSION);
1120    }
1121
1122    #[test]
1123    fn brief_json_is_byte_identical_on_repeated_serialization() {
1124        // `elapsed: Duration::ZERO` and no telemetry: the brief JSON carries no
1125        // timestamps or randomness, so two builds serialize byte-identically.
1126        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1127        let first = build_brief_json(&result, None).expect("first build");
1128        let second = build_brief_json(&result, None).expect("second build");
1129        let first_str = serde_json::to_string_pretty(&first).expect("serialize first");
1130        let second_str = serde_json::to_string_pretty(&second).expect("serialize second");
1131        assert_eq!(first_str, second_str);
1132    }
1133
1134    #[test]
1135    fn risk_class_thresholds_are_pure_functions_of_size() {
1136        assert_eq!(classify_risk(0, None), RiskClass::Low);
1137        assert_eq!(classify_risk(RISK_MEDIUM_FILES, None), RiskClass::Medium);
1138        assert_eq!(classify_risk(RISK_HIGH_FILES, None), RiskClass::High);
1139        assert_eq!(classify_risk(1, Some(RISK_HIGH_LINES)), RiskClass::High);
1140        assert_eq!(review_effort_for(RiskClass::High), ReviewEffort::DeepDive);
1141    }
1142
1143    #[test]
1144    fn triage_uses_supplied_diff_metrics_and_existing_risk_thresholds() {
1145        let mut result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1146        result.changed_files_count = 1;
1147        let mut diff = String::from(
1148            "diff --git a/src/a.ts b/src/a.ts\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -0,0 +1,100 @@\n",
1149        );
1150        for _ in 0..RISK_MEDIUM_LINES {
1151            diff.push_str("+added\n");
1152        }
1153        let index = fallow_output::DiffIndex::from_unified_diff(&diff);
1154        result.diff_index = Some(index);
1155
1156        let triage = build_brief_output(&result).triage;
1157
1158        assert_eq!(triage.hunks, Some(1));
1159        assert_eq!(triage.net_lines, Some(RISK_MEDIUM_LINES));
1160        assert_eq!(triage.risk_class, RiskClass::Medium);
1161        assert_eq!(triage.review_effort, ReviewEffort::Review);
1162    }
1163
1164    #[test]
1165    fn no_diff_brief_keeps_optional_triage_fields_absent() {
1166        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1167        let value = serde_json::to_value(build_brief_output_with_diff(&result, None))
1168            .expect("brief serializes");
1169
1170        assert!(value["triage"].get("hunks").is_none());
1171        assert!(value["triage"].get("net_lines").is_none());
1172    }
1173
1174    #[test]
1175    fn brief_json_includes_empty_impact_closure_when_no_graph_retained() {
1176        // check: None -> no closure; the impact_closure object must still be
1177        // present and empty so consumers can rely on its presence.
1178        let result = audit_result(AuditVerdict::Warn, OutputFormat::Json);
1179        let value = build_brief_json(&result, None).expect("brief json must build");
1180        assert!(value.get("impact_closure").is_some(), "{value}");
1181        assert_eq!(
1182            value["impact_closure"]["affected_not_shown"],
1183            serde_json::json!([])
1184        );
1185        assert_eq!(
1186            value["impact_closure"]["coordination_gap"],
1187            serde_json::json!([])
1188        );
1189    }
1190
1191    #[test]
1192    fn derive_graph_facts_populates_reachable_from_from_closure() {
1193        use fallow_engine::module_graph::{CoordinationGapPaths, ImpactClosurePaths};
1194        let results = AnalysisResults::default();
1195        let closure = ImpactClosurePaths {
1196            in_diff: vec!["src/core.ts".to_string()],
1197            affected_not_shown: vec!["src/app.ts".to_string(), "src/mid.ts".to_string()],
1198            coordination_gap: vec![CoordinationGapPaths {
1199                changed_file: "src/core.ts".to_string(),
1200                consumer_file: "src/mid.ts".to_string(),
1201                consumed_symbols: vec!["compute".to_string()],
1202            }],
1203        };
1204        let facts = derive_graph_facts(&results, Some(&closure));
1205        assert_eq!(
1206            facts.reachable_from,
1207            vec!["src/app.ts".to_string(), "src/mid.ts".to_string()]
1208        );
1209    }
1210
1211    #[test]
1212    fn coordination_gap_fact_carries_honest_scope_note() {
1213        let gap = CoordinationGapFact {
1214            changed_file: "src/core.ts".to_string(),
1215            consumer_file: "src/mid.ts".to_string(),
1216            consumed_symbols: vec!["compute".to_string()],
1217            note: COORDINATION_GAP_NOTE.to_string(),
1218        };
1219        assert!(gap.note.contains("attention pointer"));
1220        assert!(gap.note.contains("not a correctness proof"));
1221    }
1222}