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
23pub 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 base = compute_base_decision_snapshot(options, &resolved.root, &resolved_base.git_ref)?;
52 let deltas = build_decision_deltas(&head, &base);
53 let surface = build_surface(options, &head, &deltas);
54
55 Ok(DecisionSurfaceProgrammaticOutput {
56 surface,
57 elapsed: start.elapsed(),
58 envelope_mode: root_envelope_mode(),
59 telemetry_analysis_run_id: None,
60 })
61}
62
63fn audit_options_for_decision_surface(options: &DecisionSurfaceOptions) -> AuditOptions {
64 AuditOptions {
65 analysis: options.analysis.clone(),
66 base: options.base.clone(),
67 ..AuditOptions::default()
68 }
69}
70
71struct DecisionAnalysis {
72 root: PathBuf,
73 results: fallow_types::results::AnalysisResults,
74 public_api: FxHashSet<String>,
75 impact_closure: Option<fallow_engine::module_graph::ImpactClosurePaths>,
76 export_lines: Option<FxHashMap<String, Vec<(String, u32)>>>,
77 internal_consumers: Option<FxHashMap<String, u64>>,
78 routing: fallow_output::RoutingFacts,
79}
80
81struct DecisionGraphSignals {
82 public_api: FxHashSet<String>,
83 impact_closure: Option<fallow_engine::module_graph::ImpactClosurePaths>,
84 export_lines: Option<FxHashMap<String, Vec<(String, u32)>>>,
85 internal_consumers: Option<FxHashMap<String, u64>>,
86}
87
88fn run_decision_analysis(
89 resolved: &ProgrammaticAnalysisContext,
90 changed_files: Option<&FxHashSet<PathBuf>>,
91) -> ProgrammaticResult<DecisionAnalysis> {
92 let session = super::dead_code::load_dead_code_session(
93 &super::dead_code::default_dead_code_options_for_context(resolved),
94 resolved,
95 )?;
96 let root = session.root().to_path_buf();
97 let artifacts = session
98 .analyze_dead_code_with_session_artifacts(false, true, changed_files.cloned())
99 .map_err(|err| {
100 ProgrammaticError::new(format!("decision-surface analysis failed: {err}"), 2)
101 .with_code("FALLOW_DECISION_SURFACE_FAILED")
102 .with_context("decision-surface")
103 })?;
104 let fallow_engine::session::AnalysisSessionArtifacts {
105 analysis: mut output,
106 changed_files,
107 ..
108 } = artifacts;
109 let changed_files = changed_files.as_ref();
110
111 let workspace_roots = workspace_roots_for_session(resolved, session.workspaces())?;
112 filter_decision_results(
113 &mut output.results,
114 workspace_roots.as_deref(),
115 changed_files,
116 );
117
118 let graph_signals =
119 decision_graph_signals(output.graph.as_ref(), &session, &root, changed_files);
120 let routing = changed_files.map_or_else(fallow_output::RoutingFacts::default, |files| {
121 crate::routing::compute_routing(&root, session.config(), files)
122 });
123
124 Ok(DecisionAnalysis {
125 root,
126 results: output.results,
127 public_api: graph_signals.public_api,
128 impact_closure: graph_signals.impact_closure,
129 export_lines: graph_signals.export_lines,
130 internal_consumers: graph_signals.internal_consumers,
131 routing,
132 })
133}
134
135fn decision_graph_signals(
136 graph: Option<&fallow_engine::module_graph::RetainedModuleGraph>,
137 session: &fallow_engine::session::AnalysisSession,
138 root: &Path,
139 changed_files: Option<&FxHashSet<PathBuf>>,
140) -> DecisionGraphSignals {
141 let public_api = graph.map_or_else(FxHashSet::default, |graph| {
142 crate::review_deltas::public_export_keys_for(
143 graph,
144 session.config(),
145 session.workspaces(),
146 root,
147 )
148 });
149 let impact_closure = graph.and_then(|graph| {
150 changed_files.and_then(|files| {
151 fallow_engine::module_graph::impact_closure_for_changed_paths(graph, root, files)
152 })
153 });
154 let export_lines = graph.and_then(|graph| {
155 changed_files.and_then(|files| {
156 fallow_engine::module_graph::export_lines_for_changed_paths(graph, root, files)
157 })
158 });
159 let internal_consumers = graph.and_then(|graph| {
160 changed_files.and_then(|files| {
161 fallow_engine::module_graph::internal_consumers_for_changed_paths(graph, root, files)
162 })
163 });
164
165 DecisionGraphSignals {
166 public_api,
167 impact_closure,
168 export_lines,
169 internal_consumers,
170 }
171}
172
173fn filter_decision_results(
174 results: &mut fallow_types::results::AnalysisResults,
175 workspace_roots: Option<&[PathBuf]>,
176 changed_files: Option<&FxHashSet<PathBuf>>,
177) {
178 if let Some(workspace_roots) = workspace_roots {
179 fallow_engine::dead_code::filter_to_workspaces(results, workspace_roots);
180 }
181 if let Some(changed_files) = changed_files {
182 fallow_engine::dead_code::filter_by_changed_files(results, changed_files);
183 }
184}
185
186fn compute_base_decision_snapshot(
187 options: &DecisionSurfaceOptions,
188 current_root: &Path,
189 base_ref: &str,
190) -> ProgrammaticResult<DecisionSnapshot> {
191 let worktree = TemporaryBaseWorktree::create(current_root, base_ref).map_err(|err| {
192 ProgrammaticError::new(err.to_string(), 2)
193 .with_code("FALLOW_DECISION_SURFACE_FAILED")
194 .with_context("decisionSurface.base")
195 })?;
196 let base_root = repo_refs::base_analysis_root(current_root, worktree.path());
197 let base_analysis = AnalysisOptions {
198 root: Some(base_root),
199 config_path: options.analysis.config_path.clone(),
200 changed_since: None,
201 explain: false,
202 ..options.analysis.clone()
203 };
204 let resolved = resolve_programmatic_analysis_context_deferred_workspace(&base_analysis)?;
205 let base = run_decision_analysis(&resolved, None)?;
206 Ok(snapshot_from_decision_analysis(&base))
207}
208
209#[derive(Default)]
210struct DecisionSnapshot {
211 boundary_edges: FxHashSet<String>,
212 cycles: FxHashSet<String>,
213 public_api: FxHashSet<String>,
214}
215
216fn snapshot_from_decision_analysis(analysis: &DecisionAnalysis) -> DecisionSnapshot {
217 DecisionSnapshot {
218 boundary_edges: crate::review_deltas::boundary_edge_keys(
219 &analysis.results.boundary_violations,
220 ),
221 cycles: crate::review_deltas::cycle_keys(
222 &analysis.results.circular_dependencies,
223 &analysis.root,
224 ),
225 public_api: analysis.public_api.clone(),
226 }
227}
228
229fn build_decision_deltas(head: &DecisionAnalysis, base: &DecisionSnapshot) -> ReviewDeltas {
230 let head_snapshot = snapshot_from_decision_analysis(head);
231 fallow_output::ReviewDeltas {
232 boundary_introduced: crate::review_deltas::introduced_keys(
233 &head_snapshot.boundary_edges,
234 &base.boundary_edges,
235 ),
236 cycle_introduced: crate::review_deltas::introduced_keys(
237 &head_snapshot.cycles,
238 &base.cycles,
239 ),
240 public_api_added: crate::review_deltas::introduced_keys(
241 &head_snapshot.public_api,
242 &base.public_api,
243 ),
244 }
245}
246
247fn build_surface(
248 options: &DecisionSurfaceOptions,
249 head: &DecisionAnalysis,
250 deltas: &ReviewDeltas,
251) -> fallow_output::DecisionSurface {
252 let boundary_anchors = boundary_anchors(head, deltas);
253 let mut coordination = coordination_anchors(head.impact_closure.as_ref());
254 let resolve_line = export_line_resolver(head.export_lines.as_ref());
255 for anchor in &mut coordination {
256 anchor.line = resolve_line(&anchor.changed_file, &anchor.consumed_symbols);
257 }
258 let public_api_anchor_line = deltas.public_api_added.first().map_or(0, |key| {
259 let mut parts = key.splitn(2, "::");
260 let path = parts.next().unwrap_or_default();
261 let name = parts.next().unwrap_or_default();
262 resolve_line(path, &[name.to_string()])
263 });
264 let affected_not_shown = head
265 .impact_closure
266 .as_ref()
267 .map_or(0, |closure| closure.affected_not_shown.len() as u64);
268 let root = head.root.clone();
269 let head_source = move |rel: &str| std::fs::read_to_string(root.join(rel)).ok();
270 let rename_old_path = |_rel: &str| -> Option<String> { None };
271 let internal_consumers_map = head.internal_consumers.as_ref();
272 let internal_consumers = |rel: &str| -> u64 {
273 internal_consumers_map
274 .and_then(|map| map.get(rel))
275 .copied()
276 .unwrap_or(0)
277 };
278 extract_decision_surface(&DecisionInputs {
279 deltas,
280 boundary_anchors: &boundary_anchors,
281 coordination: &coordination,
282 public_api_anchor_line,
283 affected_not_shown,
284 routing: &head.routing,
285 head_source: &head_source,
286 rename_old_path: &rename_old_path,
287 internal_consumers: &internal_consumers,
288 cap: options.max_decisions.unwrap_or(DEFAULT_DECISION_CAP),
289 })
290}
291
292fn boundary_anchors(head: &DecisionAnalysis, deltas: &ReviewDeltas) -> Vec<BoundaryAnchor> {
293 let mut boundary_anchors = Vec::new();
294 let mut seen_pairs = FxHashSet::default();
295 for finding in &head.results.boundary_violations {
296 let key = crate::review_deltas::boundary_edge_key(finding);
297 if !deltas.boundary_introduced.contains(&key) || !seen_pairs.insert(key.clone()) {
298 continue;
299 }
300 boundary_anchors.push(BoundaryAnchor {
301 zone_pair_key: key,
302 from_file: crate::audit_keys::relative_key_path(
303 &finding.violation.from_path,
304 &head.root,
305 ),
306 from_zone: finding.violation.from_zone.clone(),
307 to_zone: finding.violation.to_zone.clone(),
308 line: finding.violation.line,
309 });
310 }
311 boundary_anchors
312}
313
314fn coordination_anchors(
315 closure: Option<&fallow_engine::module_graph::ImpactClosurePaths>,
316) -> Vec<CoordinationAnchor> {
317 let Some(closure) = closure else {
318 return Vec::new();
319 };
320 let mut by_file: FxHashMap<String, (u64, FxHashSet<String>)> = FxHashMap::default();
321 for gap in &closure.coordination_gap {
322 let entry = by_file
323 .entry(gap.changed_file.clone())
324 .or_insert_with(|| (0, FxHashSet::default()));
325 entry.0 += 1;
326 for symbol in &gap.consumed_symbols {
327 entry.1.insert(symbol.clone());
328 }
329 }
330 let mut anchors = by_file
331 .into_iter()
332 .map(|(changed_file, (consumer_count, symbols))| {
333 let mut consumed_symbols: Vec<String> = symbols.into_iter().collect();
334 consumed_symbols.sort_unstable();
335 CoordinationAnchor {
336 changed_file,
337 consumed_symbols,
338 consumer_count,
339 line: 0,
340 }
341 })
342 .collect::<Vec<_>>();
343 anchors.sort_by(|a, b| a.changed_file.cmp(&b.changed_file));
344 anchors
345}
346
347fn export_line_resolver(
348 export_lines: Option<&FxHashMap<String, Vec<(String, u32)>>>,
349) -> impl Fn(&str, &[String]) -> u32 + '_ {
350 move |rel: &str, symbols: &[String]| -> u32 {
351 let Some(exports) = export_lines.and_then(|map| map.get(rel)) else {
352 return 0;
353 };
354 exports
355 .iter()
356 .find(|(name, _)| symbols.iter().any(|symbol| name == symbol))
357 .or_else(|| exports.first())
358 .map_or(0, |(_, line)| *line)
359 }
360}