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
21pub 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}