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