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
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(&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 = 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 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
105fn 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
118fn 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
139pub(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 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 repo_refs::BaseAnalysisRoot::NewInHead(_) => return Ok(DecisionSnapshot::default()),
276 };
277 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 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 const ASSIGNMENT_CHAIN_DEPTH: usize = 4_000;
481
482 #[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}