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