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