Skip to main content

fallow_api/runtime/
decision_surface.rs

1use std::path::{Path, PathBuf};
2use std::time::Instant;
3
4use fallow_engine::repo_refs::{self, TemporaryBaseWorktree};
5use fallow_output::ReviewDeltas;
6use rustc_hash::{FxHashMap, FxHashSet};
7
8use crate::{
9    AnalysisOptions, AuditOptions, DecisionSurfaceOptions, DecisionSurfaceProgrammaticOutput,
10    ProgrammaticError,
11    analysis_context::{
12        ProgrammaticAnalysisContext, changed_files_for_run,
13        resolve_programmatic_analysis_context_deferred_workspace, workspace_roots_for_session,
14    },
15    decision_surface::{
16        BoundaryAnchor, CoordinationAnchor, DEFAULT_DECISION_CAP, DecisionInputs,
17        extract_decision_surface,
18    },
19};
20
21use super::{ProgrammaticResult, root_envelope_mode};
22
23/// Run changed-code decision-surface analysis through the typed programmatic API.
24///
25/// # Errors
26///
27/// Returns a structured error for invalid options, base-ref discovery failures,
28/// git changed-file failures, or analysis failures.
29pub fn run_decision_surface(
30    options: &DecisionSurfaceOptions,
31) -> ProgrammaticResult<DecisionSurfaceProgrammaticOutput> {
32    let start = Instant::now();
33    let audit_options = audit_options_for_decision_surface(options);
34    let resolved_base = super::audit::resolve_audit_base_ref(&audit_options)?;
35    let analysis = AnalysisOptions {
36        changed_since: Some(resolved_base.git_ref.clone()),
37        ..options.analysis.clone()
38    };
39    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&analysis)?;
40    let changed_files = changed_files_for_run(&resolved)?.unwrap_or_default();
41    if changed_files.is_empty() {
42        return Ok(DecisionSurfaceProgrammaticOutput {
43            surface: fallow_output::DecisionSurface::default(),
44            elapsed: start.elapsed(),
45            envelope_mode: root_envelope_mode(),
46            telemetry_analysis_run_id: None,
47        });
48    }
49
50    let head = run_decision_analysis(&resolved, Some(&changed_files))?;
51    let base = compute_base_decision_snapshot(options, &resolved.root, &resolved_base.git_ref)?;
52    let deltas = build_decision_deltas(&head, &base);
53    let surface = build_surface(options, &head, &deltas);
54
55    Ok(DecisionSurfaceProgrammaticOutput {
56        surface,
57        elapsed: start.elapsed(),
58        envelope_mode: root_envelope_mode(),
59        telemetry_analysis_run_id: None,
60    })
61}
62
63fn audit_options_for_decision_surface(options: &DecisionSurfaceOptions) -> AuditOptions {
64    AuditOptions {
65        analysis: options.analysis.clone(),
66        base: options.base.clone(),
67        ..AuditOptions::default()
68    }
69}
70
71struct DecisionAnalysis {
72    root: PathBuf,
73    results: fallow_types::results::AnalysisResults,
74    public_api: FxHashSet<String>,
75    impact_closure: Option<fallow_engine::module_graph::ImpactClosurePaths>,
76    export_lines: Option<FxHashMap<String, Vec<(String, u32)>>>,
77    internal_consumers: Option<FxHashMap<String, u64>>,
78    routing: fallow_output::RoutingFacts,
79}
80
81struct DecisionGraphSignals {
82    public_api: FxHashSet<String>,
83    impact_closure: Option<fallow_engine::module_graph::ImpactClosurePaths>,
84    export_lines: Option<FxHashMap<String, Vec<(String, u32)>>>,
85    internal_consumers: Option<FxHashMap<String, u64>>,
86}
87
88fn run_decision_analysis(
89    resolved: &ProgrammaticAnalysisContext,
90    changed_files: Option<&FxHashSet<PathBuf>>,
91) -> ProgrammaticResult<DecisionAnalysis> {
92    let session = super::dead_code::load_dead_code_session(
93        &super::dead_code::default_dead_code_options_for_context(resolved),
94        resolved,
95    )?;
96    let root = session.root().to_path_buf();
97    let artifacts = session
98        .analyze_dead_code_with_session_artifacts(false, true, changed_files.cloned())
99        .map_err(|err| {
100            ProgrammaticError::new(format!("decision-surface analysis failed: {err}"), 2)
101                .with_code("FALLOW_DECISION_SURFACE_FAILED")
102                .with_context("decision-surface")
103        })?;
104    let fallow_engine::session::AnalysisSessionArtifacts {
105        analysis: mut output,
106        changed_files,
107        ..
108    } = artifacts;
109    let changed_files = changed_files.as_ref();
110
111    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
112    filter_decision_results(
113        &mut output.results,
114        workspace_roots.as_deref(),
115        changed_files,
116    );
117
118    let graph_signals =
119        decision_graph_signals(output.graph.as_ref(), &session, &root, changed_files);
120    let routing = changed_files.map_or_else(fallow_output::RoutingFacts::default, |files| {
121        crate::routing::compute_routing(&root, session.config(), files)
122    });
123
124    Ok(DecisionAnalysis {
125        root,
126        results: output.results,
127        public_api: graph_signals.public_api,
128        impact_closure: graph_signals.impact_closure,
129        export_lines: graph_signals.export_lines,
130        internal_consumers: graph_signals.internal_consumers,
131        routing,
132    })
133}
134
135fn decision_graph_signals(
136    graph: Option<&fallow_engine::module_graph::RetainedModuleGraph>,
137    session: &fallow_engine::session::AnalysisSession,
138    root: &Path,
139    changed_files: Option<&FxHashSet<PathBuf>>,
140) -> DecisionGraphSignals {
141    let public_api = graph.map_or_else(FxHashSet::default, |graph| {
142        crate::review_deltas::public_export_keys_for(
143            graph,
144            session.config(),
145            session.workspaces(),
146            root,
147        )
148    });
149    let impact_closure = graph.and_then(|graph| {
150        changed_files.and_then(|files| {
151            fallow_engine::module_graph::impact_closure_for_changed_paths(graph, root, files)
152        })
153    });
154    let export_lines = graph.and_then(|graph| {
155        changed_files.and_then(|files| {
156            fallow_engine::module_graph::export_lines_for_changed_paths(graph, root, files)
157        })
158    });
159    let internal_consumers = graph.and_then(|graph| {
160        changed_files.and_then(|files| {
161            fallow_engine::module_graph::internal_consumers_for_changed_paths(graph, root, files)
162        })
163    });
164
165    DecisionGraphSignals {
166        public_api,
167        impact_closure,
168        export_lines,
169        internal_consumers,
170    }
171}
172
173fn filter_decision_results(
174    results: &mut fallow_types::results::AnalysisResults,
175    workspace_roots: Option<&[PathBuf]>,
176    changed_files: Option<&FxHashSet<PathBuf>>,
177) {
178    if let Some(workspace_roots) = workspace_roots {
179        fallow_engine::dead_code::filter_to_workspaces(results, workspace_roots);
180    }
181    if let Some(changed_files) = changed_files {
182        fallow_engine::dead_code::filter_by_changed_files(results, changed_files);
183    }
184}
185
186fn compute_base_decision_snapshot(
187    options: &DecisionSurfaceOptions,
188    current_root: &Path,
189    base_ref: &str,
190) -> ProgrammaticResult<DecisionSnapshot> {
191    let worktree = TemporaryBaseWorktree::create(current_root, base_ref).map_err(|err| {
192        ProgrammaticError::new(err.to_string(), 2)
193            .with_code("FALLOW_DECISION_SURFACE_FAILED")
194            .with_context("decisionSurface.base")
195    })?;
196    let base_root = repo_refs::base_analysis_root(current_root, worktree.path());
197    let base_analysis = AnalysisOptions {
198        root: Some(base_root),
199        config_path: options.analysis.config_path.clone(),
200        changed_since: None,
201        explain: false,
202        ..options.analysis.clone()
203    };
204    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&base_analysis)?;
205    let base = run_decision_analysis(&resolved, None)?;
206    Ok(snapshot_from_decision_analysis(&base))
207}
208
209#[derive(Default)]
210struct DecisionSnapshot {
211    boundary_edges: FxHashSet<String>,
212    cycles: FxHashSet<String>,
213    public_api: FxHashSet<String>,
214}
215
216fn snapshot_from_decision_analysis(analysis: &DecisionAnalysis) -> DecisionSnapshot {
217    DecisionSnapshot {
218        boundary_edges: crate::review_deltas::boundary_edge_keys(
219            &analysis.results.boundary_violations,
220        ),
221        cycles: crate::review_deltas::cycle_keys(
222            &analysis.results.circular_dependencies,
223            &analysis.root,
224        ),
225        public_api: analysis.public_api.clone(),
226    }
227}
228
229fn build_decision_deltas(head: &DecisionAnalysis, base: &DecisionSnapshot) -> ReviewDeltas {
230    let head_snapshot = snapshot_from_decision_analysis(head);
231    fallow_output::ReviewDeltas {
232        boundary_introduced: crate::review_deltas::introduced_keys(
233            &head_snapshot.boundary_edges,
234            &base.boundary_edges,
235        ),
236        cycle_introduced: crate::review_deltas::introduced_keys(
237            &head_snapshot.cycles,
238            &base.cycles,
239        ),
240        public_api_added: crate::review_deltas::introduced_keys(
241            &head_snapshot.public_api,
242            &base.public_api,
243        ),
244    }
245}
246
247fn build_surface(
248    options: &DecisionSurfaceOptions,
249    head: &DecisionAnalysis,
250    deltas: &ReviewDeltas,
251) -> fallow_output::DecisionSurface {
252    let boundary_anchors = boundary_anchors(head, deltas);
253    let mut coordination = coordination_anchors(head.impact_closure.as_ref());
254    let resolve_line = export_line_resolver(head.export_lines.as_ref());
255    for anchor in &mut coordination {
256        anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
257    }
258    let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
259        let mut parts = key.splitn(2, "::");
260        let path = parts.next().unwrap_or_default();
261        let name = parts.next().unwrap_or_default();
262        resolve_line(path, &[name.to_string()])
263    });
264    let affected_not_shown = head
265        .impact_closure
266        .as_ref()
267        .map_or(0, |closure| closure.affected_not_shown.len() as u64);
268    let root = head.root.clone();
269    let head_source = move |rel: &str| std::fs::read_to_string(root.join(rel)).ok();
270    let rename_old_path = |_rel: &str| -> Option<String> { None };
271    let internal_consumers_map = head.internal_consumers.as_ref();
272    let internal_consumers = |rel: &str| -> u64 {
273        internal_consumers_map
274            .and_then(|map| map.get(rel))
275            .copied()
276            .unwrap_or(0)
277    };
278    extract_decision_surface(&DecisionInputs {
279        deltas,
280        boundary_anchors: &boundary_anchors,
281        coordination: &coordination,
282        public_api_anchor_line,
283        affected_not_shown,
284        routing: &head.routing,
285        head_source: &head_source,
286        rename_old_path: &rename_old_path,
287        internal_consumers: &internal_consumers,
288        cap: options.max_decisions.unwrap_or(DEFAULT_DECISION_CAP),
289    })
290}
291
292fn boundary_anchors(head: &DecisionAnalysis, deltas: &ReviewDeltas) -> Vec<BoundaryAnchor> {
293    let mut boundary_anchors = Vec::new();
294    let mut seen_pairs = FxHashSet::default();
295    for finding in &head.results.boundary_violations {
296        let key = crate::review_deltas::boundary_edge_key(finding);
297        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
298            continue;
299        }
300        boundary_anchors.push(BoundaryAnchor {
301            zone_pair_key: key,
302            from_file: crate::audit_keys::relative_key_path(
303                &finding.violation.from_path,
304                &head.root,
305            ),
306            from_zone: finding.violation.from_zone.clone(),
307            to_zone: finding.violation.to_zone.clone(),
308            line: finding.violation.line,
309        });
310    }
311    boundary_anchors
312}
313
314fn coordination_anchors(
315    closure: Option<&fallow_engine::module_graph::ImpactClosurePaths>,
316) -> Vec<CoordinationAnchor> {
317    let Some(closure) = closure else {
318        return Vec::new();
319    };
320    let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
321    for gap in &closure.coordination_gap {
322        let entry = by_file
323            .entry(gap.changed_file.clone())
324            .or_insert_with(|| (0, FxHashSet::default()));
325        entry.0 += 1;
326        for symbol in &gap.consumed_symbols {
327            entry.1.insert(symbol.clone());
328        }
329    }
330    let mut anchors = by_file
331        .into_iter()
332        .map(|(changed_file, (consumer_count, symbols))| {
333            let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
334            consumed_symbols.sort_unstable();
335            CoordinationAnchor {
336                changed_file,
337                consumed_symbols,
338                consumer_count,
339                line: 0,
340            }
341        })
342        .collect::<Vec<_>>();
343    anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
344    anchors
345}
346
347fn export_line_resolver(
348    export_lines: Option<&FxHashMap<String, Vec<(String, u32)>>>,
349) -> impl Fn(&str, &[String]) -> u32 + '_ {
350    move |rel: &str, symbols: &[String]| -> u32 {
351        let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
352            return 0;
353        };
354        exports
355            .iter()
356            .find(|(name, _)| symbols.iter().any(|symbol| name == symbol))
357            .or_else(|| exports.first())
358            .map_or(0, |(_, line)| *line)
359    }
360}