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
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 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 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
103fn 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
116fn 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
137pub(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 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 repo_refs::BaseAnalysisRoot::NewInHead(_) => return Ok(DecisionSnapshot::default()),
272 };
273 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 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}