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