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, resolve_programmatic_analysis_context,
13        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(&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    // The caller can be a 2 MiB Tokio blocking thread. Parse on the worker
50    // stack of the analysis pool instead.
51    let head = resolved.install(|| run_decision_analysis(&resolved, Some(&changed_files), None))?;
52    let manifests = changed_manifests(&resolved.root, &changed_files);
53    let base = compute_base_decision_snapshot(
54        options,
55        &resolved.root,
56        &resolved_base.git_ref,
57        &manifests,
58        &head.config,
59    )?;
60    let manifest_pairs = manifest_pairs(&resolved.root, &manifests, &base);
61    let dependency_anchors = crate::dependency_deltas::dependency_anchors_from_manifests(
62        &manifest_pairs,
63        head.package_importers.as_ref(),
64    );
65    let deltas = build_decision_deltas(&head, &base, &dependency_anchors);
66    let surface = build_surface(options, &head, &deltas, &dependency_anchors);
67
68    Ok(DecisionSurfaceProgrammaticOutput {
69        surface,
70        elapsed: start.elapsed(),
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 =
264        TemporaryBaseWorktree::create(current_root, base_ref, &head_config.workspace_patterns)
265            .map_err(|err| {
266                ProgrammaticError::new(err.to_string(), 2)
267                    .with_code("FALLOW_DECISION_SURFACE_FAILED")
268                    .with_context("decisionSurface.base")
269            })?;
270    let base_root = match repo_refs::resolve_base_analysis_root(current_root, worktree.path()) {
271        repo_refs::BaseAnalysisRoot::Present(root) => root,
272        // A root the base commit does not contain has an empty base snapshot:
273        // no boundary edges, no cycles, no public API and no manifests, so the
274        // whole surface reads as new in this change.
275        repo_refs::BaseAnalysisRoot::NewInHead(_) => return Ok(DecisionSnapshot::default()),
276    };
277    // The base manifests are read while the worktree still exists; a manifest
278    // absent at base stays absent (it is new in this change).
279    let base_manifests: FxHashMap<String, String> = manifests
280        .iter()
281        .filter_map(|manifest| {
282            std::fs::read_to_string(base_root.join(manifest))
283                .ok()
284                .map(|text| (manifest.clone(), text))
285        })
286        .collect();
287    let base_analysis = AnalysisOptions {
288        root: Some(base_root),
289        config_path: options.analysis.config_path.clone(),
290        changed_since: None,
291        explain: false,
292        ..options.analysis.clone()
293    };
294    let resolved = resolve_programmatic_analysis_context(&base_analysis)?;
295    let base = resolved.install(|| run_decision_analysis(&resolved, None, Some(head_config)))?;
296    let mut snapshot = snapshot_from_decision_analysis(&base);
297    snapshot.manifests = base_manifests;
298    Ok(snapshot)
299}
300
301#[derive(Default)]
302struct DecisionSnapshot {
303    boundary_edges: FxHashSet<String>,
304    cycles: FxHashSet<String>,
305    public_api: FxHashSet<String>,
306    /// Base text of each changed manifest, keyed by root-relative path.
307    manifests: FxHashMap<String, String>,
308}
309
310fn snapshot_from_decision_analysis(analysis: &DecisionAnalysis) -> DecisionSnapshot {
311    DecisionSnapshot {
312        boundary_edges: crate::review_deltas::boundary_edge_keys(
313            &analysis.results.boundary_violations,
314        ),
315        cycles: crate::review_deltas::cycle_keys(
316            &analysis.results.circular_dependencies,
317            &analysis.root,
318        ),
319        public_api: analysis.public_api.clone(),
320        manifests: FxHashMap::default(),
321    }
322}
323
324fn build_decision_deltas(
325    head: &DecisionAnalysis,
326    base: &DecisionSnapshot,
327    dependency_anchors: &[crate::decision_surface::DependencyAnchor],
328) -> ReviewDeltas {
329    let head_snapshot = snapshot_from_decision_analysis(head);
330    let mut deltas = fallow_output::ReviewDeltas {
331        boundary_introduced: crate::review_deltas::introduced_keys(
332            &head_snapshot.boundary_edges,
333            &base.boundary_edges,
334        ),
335        cycle_introduced: crate::review_deltas::introduced_keys(
336            &head_snapshot.cycles,
337            &base.cycles,
338        ),
339        public_api_added: crate::review_deltas::introduced_keys(
340            &head_snapshot.public_api,
341            &base.public_api,
342        ),
343        dependency_added: Vec::new(),
344        dependency_major_bumped: Vec::new(),
345    };
346    crate::dependency_deltas::fill_dependency_delta_keys(&mut deltas, dependency_anchors);
347    deltas
348}
349
350fn build_surface(
351    options: &DecisionSurfaceOptions,
352    head: &DecisionAnalysis,
353    deltas: &ReviewDeltas,
354    dependency_anchors: &[crate::decision_surface::DependencyAnchor],
355) -> fallow_output::DecisionSurface {
356    let boundary_anchors = boundary_anchors(head, deltas);
357    let mut coordination = coordination_anchors(head.impact_closure.as_ref());
358    let resolve_line = export_line_resolver(head.export_lines.as_ref());
359    for anchor in &mut coordination {
360        anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
361    }
362    let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
363        let mut parts = key.splitn(2, "::");
364        let path = parts.next().unwrap_or_default();
365        let name = parts.next().unwrap_or_default();
366        resolve_line(path, &[name.to_string()])
367    });
368    let affected_not_shown = head
369        .impact_closure
370        .as_ref()
371        .map_or(0, |closure| closure.affected_not_shown.len() as u64);
372    let root = head.root.clone();
373    let head_source = move |rel: &str| std::fs::read_to_string(root.join(rel)).ok();
374    let rename_old_path = |_rel: &str| -> Option<String> { None };
375    let internal_consumers_map = head.internal_consumers.as_ref();
376    let internal_consumers = |rel: &str| -> u64 {
377        internal_consumers_map
378            .and_then(|map| map.get(rel))
379            .copied()
380            .unwrap_or(0)
381    };
382    extract_decision_surface(&DecisionInputs {
383        deltas,
384        boundary_anchors: &boundary_anchors,
385        coordination: &coordination,
386        dependency_anchors,
387        public_api_anchor_line,
388        affected_not_shown,
389        routing: &head.routing,
390        head_source: &head_source,
391        rename_old_path: &rename_old_path,
392        internal_consumers: &internal_consumers,
393        cap: options.max_decisions.unwrap_or(DEFAULT_DECISION_CAP),
394    })
395}
396
397fn boundary_anchors(head: &DecisionAnalysis, deltas: &ReviewDeltas) -> Vec<BoundaryAnchor> {
398    let mut boundary_anchors = Vec::new();
399    let mut seen_pairs = FxHashSet::default();
400    for finding in &head.results.boundary_violations {
401        let key = crate::review_deltas::boundary_edge_key(finding);
402        if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
403            continue;
404        }
405        boundary_anchors.push(BoundaryAnchor {
406            zone_pair_key: key,
407            from_file: crate::audit_keys::relative_key_path(
408                &finding.violation.from_path,
409                &head.root,
410            ),
411            from_zone: finding.violation.from_zone.clone(),
412            to_zone: finding.violation.to_zone.clone(),
413            line: finding.violation.line,
414        });
415    }
416    boundary_anchors
417}
418
419fn coordination_anchors(
420    closure: Option<&fallow_engine::module_graph::ImpactClosurePaths>,
421) -> Vec<CoordinationAnchor> {
422    let Some(closure) = closure else {
423        return Vec::new();
424    };
425    let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
426    for gap in &closure.coordination_gap {
427        let entry = by_file
428            .entry(gap.changed_file.clone())
429            .or_insert_with(|| (0, FxHashSet::default()));
430        entry.0 += 1;
431        for symbol in &gap.consumed_symbols {
432            entry.1.insert(symbol.clone());
433        }
434    }
435    let mut anchors = by_file
436        .into_iter()
437        .map(|(changed_file, (consumer_count, symbols))| {
438            let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
439            consumed_symbols.sort_unstable();
440            CoordinationAnchor {
441                changed_file,
442                consumed_symbols,
443                consumer_count,
444                line: 0,
445            }
446        })
447        .collect::<Vec<_>>();
448    anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
449    anchors
450}
451
452fn export_line_resolver(
453    export_lines: Option<&FxHashMap<String, Vec<(String, u32)>>>,
454) -> impl Fn(&str, &[String]) -> u32 + '_ {
455    move |rel: &str, symbols: &[String]| -> u32 {
456        let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
457            return 0;
458        };
459        exports
460            .iter()
461            .find(|(name, _)| symbols.iter().any(|symbol| name == symbol))
462            .or_else(|| exports.first())
463            .map_or(0, |(_, line)| *line)
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use std::path::Path;
470    use std::process::Command;
471
472    use crate::{AnalysisOptions, DecisionSurfaceOptions};
473
474    const STACK_PROBE_ENV: &str = "FALLOW_DECISION_SURFACE_STACK_PROBE_CHILD";
475    const STACK_PROBE_TEST: &str =
476        "runtime::decision_surface::tests::decision_surface_parses_on_the_worker_stack";
477    /// Deep enough to overflow a 2 MiB thread in a release build and to fit
478    /// the 16 MiB worker stack in a debug build. An assignment chain has no
479    /// brackets, so the pre-parse bracket guard does not stop it.
480    const ASSIGNMENT_CHAIN_DEPTH: usize = 4_000;
481
482    // A stack overflow aborts the process, so the probe re-runs this test
483    // binary as a child without RUST_MIN_STACK (pinned to 16 MiB in
484    // .cargo/config.toml). The child test thread then has the 2 MiB default
485    // stack of an MCP blocking thread.
486    #[test]
487    fn decision_surface_parses_on_the_worker_stack() {
488        if std::env::var_os(STACK_PROBE_ENV).is_some() {
489            run_stack_probe_child();
490            return;
491        }
492
493        let current_exe = std::env::current_exe().expect("current test binary should be known");
494        let output = Command::new(current_exe)
495            .arg("--exact")
496            .arg(STACK_PROBE_TEST)
497            .arg("--nocapture")
498            .env(STACK_PROBE_ENV, "1")
499            .env_remove("RUST_MIN_STACK")
500            .output()
501            .expect("stack probe child should start");
502
503        assert!(
504            output.status.success(),
505            "stack probe child failed with status {:?}\nstdout:\n{}\nstderr:\n{}",
506            output.status.code(),
507            String::from_utf8_lossy(&output.stdout),
508            String::from_utf8_lossy(&output.stderr)
509        );
510    }
511
512    fn run_stack_probe_child() {
513        let project = tempfile::tempdir().expect("temp dir");
514        let root = project.path();
515        std::fs::write(root.join("index.ts"), "export const used = 1;\n").expect("write entry");
516        git(root, &["init", "-b", "main"]);
517        git(root, &["add", "."]);
518        commit(root, "initial");
519        git(root, &["checkout", "-b", "feature"]);
520        let deep = format!(
521            "export let a = 0;\n{}1;\n",
522            "a=".repeat(ASSIGNMENT_CHAIN_DEPTH)
523        );
524        std::fs::write(root.join("deep.ts"), deep).expect("write deep file");
525        git(root, &["add", "."]);
526        commit(root, "deep");
527
528        let options = DecisionSurfaceOptions {
529            analysis: AnalysisOptions {
530                root: Some(root.to_path_buf()),
531                threads: Some(1),
532                no_cache: true,
533                ..AnalysisOptions::default()
534            },
535            base: Some("main".to_owned()),
536            max_decisions: None,
537        };
538        super::super::run_decision_surface(&options).expect("decision surface should run");
539    }
540
541    fn commit(root: &Path, message: &str) {
542        git(
543            root,
544            &[
545                "-c",
546                "user.email=test@example.com",
547                "-c",
548                "user.name=Test",
549                "-c",
550                "commit.gpgsign=false",
551                "commit",
552                "-m",
553                message,
554            ],
555        );
556    }
557
558    fn git(root: &Path, args: &[&str]) {
559        let status = fallow_engine::changed_files::clear_ambient_git_env(&mut Command::new("git"))
560            .args(args)
561            .current_dir(root)
562            .status()
563            .expect("git command");
564        assert!(status.success(), "git {args:?} failed");
565    }
566}