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 manifests = changed_manifests(&resolved.root, &changed_files);
52    let base = compute_base_decision_snapshot(
53        options,
54        &resolved.root,
55        &resolved_base.git_ref,
56        &manifests,
57    )?;
58    let manifest_pairs = manifest_pairs(&resolved.root, &manifests, &base);
59    let dependency_anchors = crate::dependency_deltas::dependency_anchors_from_manifests(
60        &manifest_pairs,
61        head.package_importers.as_ref(),
62    );
63    let deltas = build_decision_deltas(&head, &base, &dependency_anchors);
64    let surface = build_surface(options, &head, &deltas, &dependency_anchors);
65
66    Ok(DecisionSurfaceProgrammaticOutput {
67        surface,
68        elapsed: start.elapsed(),
69        envelope_mode: root_envelope_mode(),
70        telemetry_analysis_run_id: None,
71    })
72}
73
74fn audit_options_for_decision_surface(options: &DecisionSurfaceOptions) -> AuditOptions {
75    AuditOptions {
76        analysis: options.analysis.clone(),
77        base: options.base.clone(),
78        ..AuditOptions::default()
79    }
80}
81
82struct DecisionAnalysis {
83    root: PathBuf,
84    results: fallow_types::results::AnalysisResults,
85    public_api: FxHashSet<String>,
86    impact_closure: Option<fallow_engine::module_graph::ImpactClosurePaths>,
87    export_lines: Option<FxHashMap<String, Vec<(String, u32)>>>,
88    internal_consumers: Option<FxHashMap<String, u64>>,
89    package_importers: Option<FxHashMap<String, fallow_engine::module_graph::PackageImporters>>,
90    routing: fallow_output::RoutingFacts,
91}
92
93struct DecisionGraphSignals {
94    public_api: FxHashSet<String>,
95    impact_closure: Option<fallow_engine::module_graph::ImpactClosurePaths>,
96    export_lines: Option<FxHashMap<String, Vec<(String, u32)>>>,
97    internal_consumers: Option<FxHashMap<String, u64>>,
98    package_importers: Option<FxHashMap<String, fallow_engine::module_graph::PackageImporters>>,
99}
100
101/// Root-relative, forward-slashed paths of the changed `package.json`
102/// manifests, sorted.
103fn changed_manifests(root: &Path, changed_files: &FxHashSet<PathBuf>) -> Vec<String> {
104    let mut manifests: Vec<String> = changed_files
105        .iter()
106        .filter_map(|abs| abs.strip_prefix(root).ok())
107        .map(|rel| rel.to_string_lossy().replace('\\', "/"))
108        .filter(|rel| crate::dependency_deltas::is_manifest_path(rel))
109        .collect();
110    manifests.sort();
111    manifests
112}
113
114/// Pair each changed manifest's head text with the base text captured from the
115/// base worktree. A manifest unreadable at head is skipped; one absent at base
116/// reads as new.
117fn manifest_pairs(
118    root: &Path,
119    manifests: &[String],
120    base: &DecisionSnapshot,
121) -> Vec<crate::dependency_deltas::ManifestPair> {
122    manifests
123        .iter()
124        .filter_map(|manifest| {
125            let head = std::fs::read_to_string(root.join(manifest)).ok()?;
126            Some(crate::dependency_deltas::ManifestPair {
127                manifest: manifest.clone(),
128                base: base.manifests.get(manifest).cloned(),
129                head,
130            })
131        })
132        .collect()
133}
134
135fn run_decision_analysis(
136    resolved: &ProgrammaticAnalysisContext,
137    changed_files: Option<&FxHashSet<PathBuf>>,
138) -> ProgrammaticResult<DecisionAnalysis> {
139    let session = super::dead_code::load_dead_code_session(
140        &super::dead_code::default_dead_code_options_for_context(resolved),
141        resolved,
142    )?;
143    let root = session.root().to_path_buf();
144    let artifacts = session
145        .analyze_dead_code_with_session_artifacts(false, true, changed_files.cloned())
146        .map_err(|err| {
147            ProgrammaticError::new(format!("decision-surface analysis failed: {err}"), 2)
148                .with_code("FALLOW_DECISION_SURFACE_FAILED")
149                .with_context("decision-surface")
150        })?;
151    let fallow_engine::session::AnalysisSessionArtifacts {
152        analysis: mut output,
153        changed_files,
154        ..
155    } = artifacts;
156    let changed_files = changed_files.as_ref();
157
158    let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
159    filter_decision_results(
160        &mut output.results,
161        workspace_roots.as_deref(),
162        changed_files,
163    );
164
165    let graph_signals =
166        decision_graph_signals(output.graph.as_ref(), &session, &root, changed_files);
167    let routing = changed_files.map_or_else(fallow_output::RoutingFacts::default, |files| {
168        crate::routing::compute_routing(&root, session.config(), files)
169    });
170
171    Ok(DecisionAnalysis {
172        root,
173        results: output.results,
174        public_api: graph_signals.public_api,
175        impact_closure: graph_signals.impact_closure,
176        export_lines: graph_signals.export_lines,
177        internal_consumers: graph_signals.internal_consumers,
178        package_importers: graph_signals.package_importers,
179        routing,
180    })
181}
182
183fn decision_graph_signals(
184    graph: Option<&fallow_engine::module_graph::RetainedModuleGraph>,
185    session: &fallow_engine::session::AnalysisSession,
186    root: &Path,
187    changed_files: Option<&FxHashSet<PathBuf>>,
188) -> DecisionGraphSignals {
189    let public_api = graph.map_or_else(FxHashSet::default, |graph| {
190        crate::review_deltas::public_export_keys_for(
191            graph,
192            session.config(),
193            session.workspaces(),
194            root,
195        )
196    });
197    let impact_closure = graph.and_then(|graph| {
198        changed_files.and_then(|files| {
199            fallow_engine::module_graph::impact_closure_for_changed_paths(graph, root, files)
200        })
201    });
202    let export_lines = graph.and_then(|graph| {
203        changed_files.and_then(|files| {
204            fallow_engine::module_graph::export_lines_for_changed_paths(graph, root, files)
205        })
206    });
207    let internal_consumers = graph.and_then(|graph| {
208        changed_files.and_then(|files| {
209            fallow_engine::module_graph::internal_consumers_for_changed_paths(graph, root, files)
210        })
211    });
212    let package_importers = graph.and_then(|graph| {
213        changed_files.and_then(|files| {
214            fallow_engine::module_graph::package_importers_for_changed_paths(graph, files)
215        })
216    });
217
218    DecisionGraphSignals {
219        public_api,
220        impact_closure,
221        export_lines,
222        internal_consumers,
223        package_importers,
224    }
225}
226
227fn filter_decision_results(
228    results: &mut fallow_types::results::AnalysisResults,
229    workspace_roots: Option<&[PathBuf]>,
230    changed_files: Option<&FxHashSet<PathBuf>>,
231) {
232    if let Some(workspace_roots) = workspace_roots {
233        fallow_engine::dead_code::filter_to_workspaces(results, workspace_roots);
234    }
235    if let Some(changed_files) = changed_files {
236        fallow_engine::dead_code::filter_by_changed_files(results, changed_files);
237    }
238}
239
240fn compute_base_decision_snapshot(
241    options: &DecisionSurfaceOptions,
242    current_root: &Path,
243    base_ref: &str,
244    manifests: &[String],
245) -> ProgrammaticResult<DecisionSnapshot> {
246    let worktree = TemporaryBaseWorktree::create(current_root, base_ref).map_err(|err| {
247        ProgrammaticError::new(err.to_string(), 2)
248            .with_code("FALLOW_DECISION_SURFACE_FAILED")
249            .with_context("decisionSurface.base")
250    })?;
251    let base_root = repo_refs::base_analysis_root(current_root, worktree.path());
252    // The base manifests are read while the worktree still exists; a manifest
253    // absent at base stays absent (it is new in this change).
254    let base_manifests: FxHashMap<String, String> = manifests
255        .iter()
256        .filter_map(|manifest| {
257            std::fs::read_to_string(base_root.join(manifest))
258                .ok()
259                .map(|text| (manifest.clone(), text))
260        })
261        .collect();
262    let base_analysis = AnalysisOptions {
263        root: Some(base_root),
264        config_path: options.analysis.config_path.clone(),
265        changed_since: None,
266        explain: false,
267        ..options.analysis.clone()
268    };
269    let resolved = resolve_programmatic_analysis_context_deferred_workspace(&base_analysis)?;
270    let base = run_decision_analysis(&resolved, None)?;
271    let mut snapshot = snapshot_from_decision_analysis(&base);
272    snapshot.manifests = base_manifests;
273    Ok(snapshot)
274}
275
276#[derive(Default)]
277struct DecisionSnapshot {
278    boundary_edges: FxHashSet<String>,
279    cycles: FxHashSet<String>,
280    public_api: FxHashSet<String>,
281    /// Base text of each changed manifest, keyed by root-relative path.
282    manifests: FxHashMap<String, String>,
283}
284
285fn snapshot_from_decision_analysis(analysis: &DecisionAnalysis) -> DecisionSnapshot {
286    DecisionSnapshot {
287        boundary_edges: crate::review_deltas::boundary_edge_keys(
288            &analysis.results.boundary_violations,
289        ),
290        cycles: crate::review_deltas::cycle_keys(
291            &analysis.results.circular_dependencies,
292            &analysis.root,
293        ),
294        public_api: analysis.public_api.clone(),
295        manifests: FxHashMap::default(),
296    }
297}
298
299fn build_decision_deltas(
300    head: &DecisionAnalysis,
301    base: &DecisionSnapshot,
302    dependency_anchors: &[crate::decision_surface::DependencyAnchor],
303) -> ReviewDeltas {
304    let head_snapshot = snapshot_from_decision_analysis(head);
305    let mut deltas = fallow_output::ReviewDeltas {
306        boundary_introduced: crate::review_deltas::introduced_keys(
307            &head_snapshot.boundary_edges,
308            &base.boundary_edges,
309        ),
310        cycle_introduced: crate::review_deltas::introduced_keys(
311            &head_snapshot.cycles,
312            &base.cycles,
313        ),
314        public_api_added: crate::review_deltas::introduced_keys(
315            &head_snapshot.public_api,
316            &base.public_api,
317        ),
318        dependency_added: Vec::new(),
319        dependency_major_bumped: Vec::new(),
320    };
321    crate::dependency_deltas::fill_dependency_delta_keys(&mut deltas, dependency_anchors);
322    deltas
323}
324
325fn build_surface(
326    options: &DecisionSurfaceOptions,
327    head: &DecisionAnalysis,
328    deltas: &ReviewDeltas,
329    dependency_anchors: &[crate::decision_surface::DependencyAnchor],
330) -> fallow_output::DecisionSurface {
331    let boundary_anchors = boundary_anchors(head, deltas);
332    let mut coordination = coordination_anchors(head.impact_closure.as_ref());
333    let resolve_line = export_line_resolver(head.export_lines.as_ref());
334    for anchor in &mut coordination {
335        anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
336    }
337    let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
338        let mut parts = key.splitn(2, "::");
339        let path = parts.next().unwrap_or_default();
340        let name = parts.next().unwrap_or_default();
341        resolve_line(path, &[name.to_string()])
342    });
343    let affected_not_shown = head
344        .impact_closure
345        .as_ref()
346        .map_or(0, |closure| closure.affected_not_shown.len() as u64);
347    let root = head.root.clone();
348    let head_source = move |rel: &str| std::fs::read_to_string(root.join(rel)).ok();
349    let rename_old_path = |_rel: &str| -> Option<String> { None };
350    let internal_consumers_map = head.internal_consumers.as_ref();
351    let internal_consumers = |rel: &str| -> u64 {
352        internal_consumers_map
353            .and_then(|map| map.get(rel))
354            .copied()
355            .unwrap_or(0)
356    };
357    extract_decision_surface(&DecisionInputs {
358        deltas,
359        boundary_anchors: &boundary_anchors,
360        coordination: &coordination,
361        dependency_anchors,
362        public_api_anchor_line,
363        affected_not_shown,
364        routing: &head.routing,
365        head_source: &head_source,
366        rename_old_path: &rename_old_path,
367        internal_consumers: &internal_consumers,
368        cap: options.max_decisions.unwrap_or(DEFAULT_DECISION_CAP),
369    })
370}
371
372fn boundary_anchors(head: &DecisionAnalysis, deltas: &ReviewDeltas) -> Vec<BoundaryAnchor> {
373    let mut boundary_anchors = Vec::new();
374    let mut seen_pairs = FxHashSet::default();
375    for finding in &head.results.boundary_violations {
376        let key = crate::review_deltas::boundary_edge_key(finding);
377        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
378            continue;
379        }
380        boundary_anchors.push(BoundaryAnchor {
381            zone_pair_key: key,
382            from_file: crate::audit_keys::relative_key_path(
383                &finding.violation.from_path,
384                &head.root,
385            ),
386            from_zone: finding.violation.from_zone.clone(),
387            to_zone: finding.violation.to_zone.clone(),
388            line: finding.violation.line,
389        });
390    }
391    boundary_anchors
392}
393
394fn coordination_anchors(
395    closure: Option<&fallow_engine::module_graph::ImpactClosurePaths>,
396) -> Vec<CoordinationAnchor> {
397    let Some(closure) = closure else {
398        return Vec::new();
399    };
400    let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
401    for gap in &closure.coordination_gap {
402        let entry = by_file
403            .entry(gap.changed_file.clone())
404            .or_insert_with(|| (0, FxHashSet::default()));
405        entry.0 += 1;
406        for symbol in &gap.consumed_symbols {
407            entry.1.insert(symbol.clone());
408        }
409    }
410    let mut anchors = by_file
411        .into_iter()
412        .map(|(changed_file, (consumer_count, symbols))| {
413            let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
414            consumed_symbols.sort_unstable();
415            CoordinationAnchor {
416                changed_file,
417                consumed_symbols,
418                consumer_count,
419                line: 0,
420            }
421        })
422        .collect::<Vec<_>>();
423    anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
424    anchors
425}
426
427fn export_line_resolver(
428    export_lines: Option<&FxHashMap<String, Vec<(String, u32)>>>,
429) -> impl Fn(&str, &[String]) -> u32 + '_ {
430    move |rel: &str, symbols: &[String]| -> u32 {
431        let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
432            return 0;
433        };
434        exports
435            .iter()
436            .find(|(name, _)| symbols.iter().any(|symbol| name == symbol))
437            .or_else(|| exports.first())
438            .map_or(0, |(_, line)| *line)
439    }
440}