Skip to main content

fallow_api/runtime/
decision_surface.rs

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