1use std::path::{Path, PathBuf};
4use std::time::Instant;
5
6use fallow_config::{
7 DetectionMode, DuplicatesConfig, OutputFormat, ProductionAnalysis, WorkspaceInfo,
8};
9use fallow_engine::duplicates::{CloneInstance, DuplicationReport, DuplicationStats};
10use fallow_engine::health::{
11 HealthPipelineInputs, HealthScopeInputs, HealthSeams, RuntimeCoverageSeamInput,
12 execute_health_inner, validate_health_churn_file,
13};
14use fallow_engine::{AnalysisResults, AnalysisSession, ProjectConfig, ProjectConfigOptions};
15use fallow_output::{
16 CHECK_SCHEMA_VERSION, CheckOutput, CheckOutputInput, DeadCodeNextStepsInput, DiffIndex,
17 DupesNextStepsInput, DupesOutput, DupesOutputInput, GroupByMode, HealthGroup, HealthGrouping,
18 HealthJsonOutputInput, HealthOutputInput, HealthReport, MAX_DIFF_BYTES, RootEnvelopeMode,
19 build_check_output, build_dead_code_next_steps, build_dupes_next_steps, build_dupes_output,
20 check_meta, dupes_meta, health_meta, relative_to_diff_path, serialize_check_json_output,
21 serialize_dupes_json_output, strip_root_prefix,
22};
23use fallow_types::workspace::WorkspaceDiagnostic;
24use fallow_types::{output::NextStep, path_util::is_absolute_path_any_platform};
25use globset::Glob;
26use rustc_hash::{FxHashMap, FxHashSet};
27
28use crate::{
29 AnalysisOptions, ComplexityOptions, DeadCodeFilters, DeadCodeOptions, DupesReportPayload,
30 DuplicationMode, DuplicationOptions, ProgrammaticError,
31};
32
33const SCHEMA_VERSION: u32 = 1;
34const HEALTH_SCHEMA_VERSION: u32 = 7;
35
36type ProgrammaticResult<T> = Result<T, ProgrammaticError>;
37
38pub struct HealthJsonReportInput<'a> {
40 pub report: HealthReport,
41 pub root: &'a Path,
42 pub elapsed: std::time::Duration,
43 pub explain: bool,
44 pub grouped_by: Option<GroupByMode>,
45 pub groups: Option<Vec<HealthGroup>>,
46 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
47 pub next_steps: Vec<NextStep>,
48 pub envelope_mode: RootEnvelopeMode,
49 pub telemetry_analysis_run_id: Option<&'a str>,
50}
51
52pub struct ProgrammaticHealthNextStepFacts {
57 pub suggestions_enabled: bool,
58 pub offer_setup: bool,
59 pub impact_digest: Option<fallow_output::ImpactDigestCounts>,
60 pub audit_changed: bool,
61}
62
63pub struct ProgrammaticHealthRun {
69 pub analysis: fallow_engine::HealthAnalysisResult,
70 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
71 pub next_step_facts: ProgrammaticHealthNextStepFacts,
72 pub telemetry_analysis_run_id: Option<String>,
73}
74
75pub trait ProgrammaticHealthRunner {
78 fn run_programmatic_health(
85 &self,
86 options: &ComplexityOptions,
87 ) -> Result<ProgrammaticHealthRun, ProgrammaticError>;
88}
89
90#[derive(Debug, Clone, Copy, Default)]
99pub struct EngineHealthRunner;
100
101impl ProgrammaticHealthRunner for EngineHealthRunner {
102 fn run_programmatic_health(
103 &self,
104 options: &ComplexityOptions,
105 ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
106 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
107 resolved.install(|| run_programmatic_health_on_engine(&resolved, options))
108 }
109}
110
111fn programmatic_runtime_coverage_seam(
115 _options: &fallow_engine::RuntimeCoverageOptions,
116 _input: RuntimeCoverageSeamInput<'_>,
117) -> Result<fallow_output::RuntimeCoverageReport, std::process::ExitCode> {
118 Err(std::process::ExitCode::from(2))
119}
120
121fn run_programmatic_health_on_engine(
122 resolved: &ProgrammaticAnalysisContext,
123 options: &ComplexityOptions,
124) -> ProgrammaticResult<ProgrammaticHealthRun> {
125 let health_options = derive_programmatic_health_execution_options(resolved, options);
126
127 validate_health_churn_file(&health_options).map_err(|_| generic_health_error("health"))?;
128
129 let start = Instant::now();
130 let project_config = fallow_engine::config_for_project_analysis(
131 &resolved.root,
132 resolved.config_path.as_deref(),
133 ProjectConfigOptions {
134 output: OutputFormat::Human,
135 no_cache: resolved.no_cache,
136 threads: resolved.threads,
137 production_override: resolved.production_override,
138 quiet: true,
139 analysis: ProductionAnalysis::Health,
140 },
141 )
142 .map_err(|err| {
143 ProgrammaticError::new(format!("failed to load config: {err}"), 2)
144 .with_code("FALLOW_CONFIG_LOAD_FAILED")
145 .with_context("analysis.configPath")
146 })?;
147 let config_ms = start.elapsed().as_secs_f64() * 1000.0;
148
149 let session = AnalysisSession::from_config(project_config);
150 stash_workspace_diagnostics_for_session(&session);
151 let parts = session.into_parts();
152 let config = parts.config;
153 let files = parts.files;
154
155 let parse_start = Instant::now();
156 let cache = if config.no_cache {
157 None
158 } else {
159 fallow_engine::cache::CacheStore::load(
160 &config.cache_dir,
161 config.cache_config_hash,
162 fallow_engine::resolve_cache_max_size_bytes(&config),
163 )
164 };
165 let parse_result = fallow_engine::extract::parse_all_files(&files, cache.as_ref(), true);
166 let parse_ms = parse_start.elapsed().as_secs_f64() * 1000.0;
167 let parse_cpu_ms = parse_result.parse_cpu_ms;
168
169 let scope_inputs = HealthScopeInputs::<fallow_engine::health::NoGroupResolver> {
170 changed_files: resolved
171 .changed_since
172 .as_deref()
173 .and_then(|git_ref| fallow_engine::changed_files(&resolved.root, git_ref).ok()),
174 diff_index: resolved.diff.as_ref(),
175 ws_roots: resolved.workspace_roots.clone(),
176 group_resolver: None,
177 };
178 let seams = HealthSeams {
179 runtime_coverage_analyzer: &programmatic_runtime_coverage_seam,
180 note_graph_structure: &|_module_count, _edge_count| {},
181 };
182
183 let result = execute_health_inner(
184 &health_options,
185 HealthPipelineInputs {
186 config,
187 files,
188 modules: parse_result.modules,
189 config_ms,
190 discover_ms: 0.0,
191 parse_ms,
192 parse_cpu_ms,
193 shared_parse: false,
194 pre_computed_analysis: None,
195 },
196 scope_inputs,
197 &seams,
198 )
199 .map_err(|_| generic_health_error("health"))?;
200
201 let root = result.config.root.clone();
202 let next_step_facts = ProgrammaticHealthNextStepFacts {
203 suggestions_enabled: suggestions_enabled(),
204 offer_setup: setup_pointer_applicable(&root),
205 impact_digest: None,
206 audit_changed: fallow_engine::churn::is_git_repo(&root),
207 };
208 Ok(ProgrammaticHealthRun {
209 analysis: result.without_group_resolver(),
210 workspace_diagnostics: fallow_config::workspace_diagnostics_for(&root),
211 next_step_facts,
212 telemetry_analysis_run_id: None,
213 })
214}
215
216fn generic_health_error(command: &str) -> ProgrammaticError {
217 let code = format!(
218 "FALLOW_{}_FAILED",
219 command.replace('-', "_").to_ascii_uppercase()
220 );
221 ProgrammaticError::new(format!("{command} failed"), 2)
222 .with_code(code)
223 .with_context(format!("fallow {command}"))
224 .with_help(format!(
225 "Re-run `fallow {command} --format json --quiet` in the target project for CLI diagnostics"
226 ))
227}
228
229pub fn run_health(options: &ComplexityOptions) -> ProgrammaticResult<HealthProgrammaticOutput> {
236 run_health_with_runner(options, &EngineHealthRunner)
237}
238
239pub fn compute_health(options: &ComplexityOptions) -> ProgrammaticResult<serde_json::Value> {
246 run_health(options)?.into_json()
247}
248
249#[must_use]
255pub fn derive_programmatic_health_execution_options<'a>(
256 resolved: &'a ProgrammaticAnalysisContext,
257 options: &'a ComplexityOptions,
258) -> fallow_engine::HealthExecutionOptions<'a> {
259 let run = crate::derive_complexity_run_options(options);
260
261 fallow_engine::HealthExecutionOptions {
262 root: resolved.root(),
263 config_path: resolved.config_path(),
264 output: OutputFormat::Human,
265 no_cache: resolved.no_cache(),
266 threads: resolved.threads(),
267 quiet: true,
268 complexity_breakdown: false,
269 thresholds: run.thresholds,
270 top: run.top,
271 sort: run.sort,
272 production: resolved.production_override().unwrap_or(false),
273 production_override: resolved.production_override(),
274 changed_since: resolved.changed_since(),
275 diff_index: resolved.diff_index(),
276 use_shared_diff_index: false,
277 workspace: resolved.workspace(),
278 changed_workspaces: resolved.changed_workspaces(),
279 baseline: None,
280 save_baseline: None,
281 complexity: run.sections.complexity,
282 file_scores: run.sections.file_scores,
283 coverage_gaps: run.sections.coverage_gaps,
284 config_activates_coverage_gaps: !run.sections.any_section,
285 hotspots: run.sections.hotspots,
286 ownership: run.sections.ownership,
287 ownership_emails: run.ownership_emails,
288 targets: run.sections.targets,
289 css: run.css,
290 force_full: run.sections.force_full,
291 score_only_output: run.sections.score_only_output,
292 enforce_coverage_gap_gate: true,
293 effort: run.effort,
294 score: run.sections.score,
295 gates: fallow_engine::HealthGateOptions::default(),
296 since: run.since,
297 min_commits: run.min_commits,
298 explain: resolved.explain_enabled(),
299 summary: false,
300 save_snapshot: None,
301 trend: false,
302 coverage_inputs: run.coverage_inputs,
303 performance: false,
304 runtime_coverage: None,
305 churn_file: None,
306 group_by: None,
307 }
308}
309
310pub struct ProgrammaticAnalysisContext {
316 root: PathBuf,
317 config_path: Option<PathBuf>,
318 no_cache: bool,
319 threads: usize,
320 pool: rayon::ThreadPool,
321 diff: Option<DiffIndex>,
322 production_override: Option<bool>,
323 changed_since: Option<String>,
324 workspace: Option<Vec<String>>,
325 changed_workspaces: Option<String>,
326 workspace_roots: Option<Vec<PathBuf>>,
327 legacy_envelope: bool,
328 explain: bool,
329}
330
331#[derive(Debug, Clone)]
337pub struct DeadCodeProgrammaticOutput {
338 pub output: CheckOutput,
339 pub root: PathBuf,
340 pub envelope_mode: RootEnvelopeMode,
341 pub telemetry_analysis_run_id: Option<String>,
342}
343
344impl DeadCodeProgrammaticOutput {
345 pub fn into_json(self) -> ProgrammaticResult<serde_json::Value> {
351 let Self {
352 output,
353 root,
354 envelope_mode,
355 telemetry_analysis_run_id,
356 } = self;
357 let mut json = serialize_check_json_output(
358 output,
359 envelope_mode,
360 telemetry_analysis_run_id.as_deref(),
361 )
362 .map_err(|err| {
363 ProgrammaticError::new(format!("failed to serialize dead-code report: {err}"), 2)
364 .with_code("FALLOW_SERIALIZE_DEAD_CODE_REPORT")
365 .with_context("dead-code")
366 })?;
367 let root_prefix = format!("{}/", root.display());
368 strip_root_prefix(&mut json, &root_prefix);
369 Ok(json)
370 }
371}
372
373#[derive(Debug, Clone)]
375pub struct DuplicationProgrammaticOutput {
376 pub output: DupesOutput<DupesReportPayload, serde_json::Value>,
377 pub root: PathBuf,
378 pub envelope_mode: RootEnvelopeMode,
379 pub telemetry_analysis_run_id: Option<String>,
380}
381
382impl DuplicationProgrammaticOutput {
383 pub fn into_json(self) -> ProgrammaticResult<serde_json::Value> {
389 let Self {
390 output,
391 root,
392 envelope_mode,
393 telemetry_analysis_run_id,
394 } = self;
395 let mut json = serialize_dupes_json_output(
396 output,
397 envelope_mode,
398 telemetry_analysis_run_id.as_deref(),
399 )
400 .map_err(|err| {
401 ProgrammaticError::new(format!("failed to serialize duplication report: {err}"), 2)
402 .with_code("FALLOW_SERIALIZE_DUPLICATION_REPORT")
403 .with_context("dupes")
404 })?;
405 let root_prefix = format!("{}/", root.display());
406 strip_root_prefix(&mut json, &root_prefix);
407 Ok(json)
408 }
409}
410
411#[derive(Debug, Clone)]
413pub struct HealthProgrammaticOutput {
414 pub report: HealthReport,
415 pub grouping: Option<HealthGrouping>,
416 pub root: PathBuf,
417 pub elapsed: std::time::Duration,
418 pub explain: bool,
419 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
420 pub next_steps: Vec<NextStep>,
421 pub envelope_mode: RootEnvelopeMode,
422 pub telemetry_analysis_run_id: Option<String>,
423}
424
425impl HealthProgrammaticOutput {
426 pub fn into_json(self) -> ProgrammaticResult<serde_json::Value> {
433 let Self {
434 report,
435 grouping,
436 root,
437 elapsed,
438 explain,
439 workspace_diagnostics,
440 next_steps,
441 envelope_mode,
442 telemetry_analysis_run_id,
443 } = self;
444 let (grouped_by, groups) = grouping.map_or((None, None), |grouping| {
445 (
446 group_by_mode_from_label(grouping.mode),
447 Some(grouping.groups),
448 )
449 });
450 serialize_health_report_json(HealthJsonReportInput {
451 report,
452 root: &root,
453 elapsed,
454 explain,
455 grouped_by,
456 groups,
457 workspace_diagnostics,
458 next_steps,
459 envelope_mode,
460 telemetry_analysis_run_id: telemetry_analysis_run_id.as_deref(),
461 })
462 .map_err(|err| {
463 ProgrammaticError::new(format!("failed to serialize health report: {err}"), 2)
464 .with_code("FALLOW_SERIALIZE_HEALTH_REPORT")
465 .with_context("health")
466 })
467 }
468}
469
470pub fn detect_duplication(options: &DuplicationOptions) -> ProgrammaticResult<serde_json::Value> {
481 run_duplication(options)?.into_json()
482}
483
484pub fn run_duplication(
491 options: &DuplicationOptions,
492) -> ProgrammaticResult<DuplicationProgrammaticOutput> {
493 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
494 resolved.install(|| detect_duplication_inner(options, &resolved))
495}
496
497pub fn detect_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<serde_json::Value> {
508 run_dead_code(options)?.into_json()
509}
510
511pub fn run_dead_code(options: &DeadCodeOptions) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
519 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
520 resolved.install(|| detect_dead_code_inner(options, &resolved, |_| {}))
521}
522
523pub fn detect_circular_dependencies(
532 options: &DeadCodeOptions,
533) -> ProgrammaticResult<serde_json::Value> {
534 run_circular_dependencies(options)?.into_json()
535}
536
537pub fn run_circular_dependencies(
543 options: &DeadCodeOptions,
544) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
545 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
546 resolved.install(|| detect_dead_code_inner(options, &resolved, keep_circular_dependencies))
547}
548
549pub fn detect_boundary_violations(
559 options: &DeadCodeOptions,
560) -> ProgrammaticResult<serde_json::Value> {
561 run_boundary_violations(options)?.into_json()
562}
563
564pub fn run_boundary_violations(
570 options: &DeadCodeOptions,
571) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
572 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
573 resolved.install(|| detect_dead_code_inner(options, &resolved, keep_boundary_violations))
574}
575
576pub fn serialize_health_report_json(
586 input: HealthJsonReportInput<'_>,
587) -> Result<serde_json::Value, serde_json::Error> {
588 let root_prefix = format!("{}/", input.root.display());
589 fallow_output::serialize_health_json_output(HealthJsonOutputInput {
590 output: HealthOutputInput {
591 schema_version: HEALTH_SCHEMA_VERSION,
592 version: env!("CARGO_PKG_VERSION").to_string(),
593 elapsed: input.elapsed,
594 report: input.report,
595 grouped_by: input.grouped_by,
596 groups: input.groups,
597 meta: input.explain.then(health_meta),
598 workspace_diagnostics: input.workspace_diagnostics,
599 next_steps: input.next_steps,
600 },
601 root_prefix: Some(&root_prefix),
602 envelope_mode: input.envelope_mode,
603 analysis_run_id: input.telemetry_analysis_run_id,
604 })
605}
606
607pub fn compute_complexity_with_runner(
619 options: &ComplexityOptions,
620 runner: &impl ProgrammaticHealthRunner,
621) -> ProgrammaticResult<serde_json::Value> {
622 run_complexity_with_runner(options, runner)?.into_json()
623}
624
625pub fn run_complexity_with_runner(
637 options: &ComplexityOptions,
638 runner: &impl ProgrammaticHealthRunner,
639) -> ProgrammaticResult<HealthProgrammaticOutput> {
640 crate::validate_complexity_options(options)?;
641 let ProgrammaticHealthRun {
642 analysis,
643 workspace_diagnostics,
644 next_step_facts,
645 telemetry_analysis_run_id,
646 } = runner.run_programmatic_health(options)?;
647 let root = analysis.config.root.clone();
648 let next_steps =
649 fallow_output::build_health_next_steps(fallow_output::build_health_next_steps_input(
650 &analysis.report,
651 next_step_facts.suggestions_enabled,
652 next_step_facts.offer_setup,
653 next_step_facts.impact_digest,
654 next_step_facts.audit_changed,
655 ));
656 Ok(HealthProgrammaticOutput {
657 report: analysis.report,
658 grouping: analysis.grouping,
659 root,
660 elapsed: analysis.elapsed,
661 explain: options.analysis.explain,
662 workspace_diagnostics,
663 next_steps,
664 envelope_mode: root_envelope_mode(options.analysis.legacy_envelope),
665 telemetry_analysis_run_id,
666 })
667}
668
669pub fn compute_health_with_runner(
675 options: &ComplexityOptions,
676 runner: &impl ProgrammaticHealthRunner,
677) -> ProgrammaticResult<serde_json::Value> {
678 run_health_with_runner(options, runner)?.into_json()
679}
680
681pub fn run_health_with_runner(
687 options: &ComplexityOptions,
688 runner: &impl ProgrammaticHealthRunner,
689) -> ProgrammaticResult<HealthProgrammaticOutput> {
690 run_complexity_with_runner(options, runner)
691}
692
693fn group_by_mode_from_label(label: &str) -> Option<GroupByMode> {
694 match label {
695 "owner" => Some(GroupByMode::Owner),
696 "directory" => Some(GroupByMode::Directory),
697 "package" => Some(GroupByMode::Package),
698 "section" => Some(GroupByMode::Section),
699 _ => None,
700 }
701}
702
703fn suggestions_enabled() -> bool {
716 match std::env::var("FALLOW_SUGGESTIONS").ok().as_deref() {
717 Some(raw) => !matches!(
718 raw.trim().to_ascii_lowercase().as_str(),
719 "off" | "0" | "false" | "no" | "disabled"
720 ),
721 None => true,
722 }
723}
724
725fn is_ci() -> bool {
726 std::env::var_os("CI").is_some()
727 || std::env::var_os("GITHUB_ACTIONS").is_some()
728 || std::env::var_os("GITLAB_CI").is_some()
729}
730
731fn setup_pointer_applicable(root: &Path) -> bool {
736 root.exists() && fallow_config::FallowConfig::find_config_path(root).is_none() && !is_ci()
737}
738
739fn default_workspace_ref(root: &Path) -> Option<String> {
743 if fallow_config::discover_workspaces(root).is_empty() {
744 return None;
745 }
746 if let Some(reference) = run_git(
747 root,
748 &[
749 "symbolic-ref",
750 "--quiet",
751 "--short",
752 "refs/remotes/origin/HEAD",
753 ],
754 ) {
755 let reference = reference.trim();
756 if !reference.is_empty() {
757 return Some(reference.to_string());
758 }
759 }
760 ["origin/main", "origin/master"]
761 .into_iter()
762 .find(|candidate| git_ref_exists(root, candidate))
763 .map(str::to_string)
764}
765
766fn git_ref_exists(root: &Path, reference: &str) -> bool {
767 std::process::Command::new("git")
768 .arg("-C")
769 .arg(root)
770 .args(["rev-parse", "--verify", "--quiet", reference])
771 .output()
772 .is_ok_and(|output| output.status.success())
773}
774
775fn run_git(root: &Path, args: &[&str]) -> Option<String> {
776 let output = std::process::Command::new("git")
777 .arg("-C")
778 .arg(root)
779 .args(args)
780 .output()
781 .ok()?;
782 if !output.status.success() {
783 return None;
784 }
785 String::from_utf8(output.stdout).ok()
786}
787
788fn stash_workspace_diagnostics_for_session(session: &AnalysisSession) {
797 let root = session.root();
798 if let Ok((_, diagnostics)) =
799 fallow_config::discover_workspaces_with_diagnostics(root, &session.config().ignore_patterns)
800 {
801 fallow_config::stash_workspace_diagnostics(root, diagnostics);
802 }
803}
804
805fn detect_dead_code_inner(
806 options: &DeadCodeOptions,
807 resolved: &ProgrammaticAnalysisContext,
808 post_filter: impl FnOnce(&mut AnalysisResults),
809) -> ProgrammaticResult<DeadCodeProgrammaticOutput> {
810 let start = Instant::now();
811 let session = load_dead_code_session(options, resolved)?;
812 stash_workspace_diagnostics_for_session(&session);
813 let analysis = session.analyze_dead_code().map_err(|err| {
814 ProgrammaticError::new(format!("dead-code analysis failed: {err}"), 2)
815 .with_code("FALLOW_DEAD_CODE_FAILED")
816 .with_context("dead-code")
817 })?;
818 let mut results = analysis.results;
819
820 apply_dead_code_scope(options, resolved, &session, &mut results)?;
821 apply_dead_code_filters(&options.filters, &mut results);
822 post_filter(&mut results);
823
824 let root = session.root();
825 let next_steps = build_dead_code_next_steps(DeadCodeNextStepsInput {
826 suggestions_enabled: suggestions_enabled(),
827 results: &results,
828 root,
829 offer_setup: setup_pointer_applicable(root),
830 impact_digest: None,
831 workspace_ref: default_workspace_ref(root).as_deref(),
832 audit_changed: fallow_engine::churn::is_git_repo(root),
833 });
834 let output = build_check_output(CheckOutputInput {
835 schema_version: CHECK_SCHEMA_VERSION,
836 version: env!("CARGO_PKG_VERSION").to_string(),
837 elapsed: start.elapsed(),
838 results,
839 config_fixable: fallow_config::is_config_fixable(
840 &resolved.root,
841 resolved.config_path.as_ref(),
842 ),
843 meta: options.analysis.explain.then(check_meta),
844 workspace_diagnostics: fallow_config::workspace_diagnostics_for(root),
845 next_steps,
846 });
847 Ok(DeadCodeProgrammaticOutput {
848 output,
849 root: session.root().to_path_buf(),
850 envelope_mode: root_envelope_mode(resolved.legacy_envelope),
851 telemetry_analysis_run_id: None,
852 })
853}
854
855fn keep_circular_dependencies(results: &mut AnalysisResults) {
856 let entry_point_summary = results.entry_point_summary.take();
857 let circular_dependencies = std::mem::take(&mut results.circular_dependencies);
858 *results = AnalysisResults::default();
859 results.entry_point_summary = entry_point_summary;
860 results.circular_dependencies = circular_dependencies;
861}
862
863fn keep_boundary_violations(results: &mut AnalysisResults) {
864 let entry_point_summary = results.entry_point_summary.take();
865 let boundary_violations = std::mem::take(&mut results.boundary_violations);
866 let boundary_coverage_violations = std::mem::take(&mut results.boundary_coverage_violations);
867 let boundary_call_violations = std::mem::take(&mut results.boundary_call_violations);
868 *results = AnalysisResults::default();
869 results.entry_point_summary = entry_point_summary;
870 results.boundary_violations = boundary_violations;
871 results.boundary_coverage_violations = boundary_coverage_violations;
872 results.boundary_call_violations = boundary_call_violations;
873}
874
875fn load_dead_code_session(
876 options: &DeadCodeOptions,
877 resolved: &ProgrammaticAnalysisContext,
878) -> ProgrammaticResult<AnalysisSession> {
879 let project_config = fallow_engine::config_for_project_analysis(
880 &resolved.root,
881 resolved.config_path.as_deref(),
882 ProjectConfigOptions {
883 output: OutputFormat::Json,
884 no_cache: resolved.no_cache,
885 threads: resolved.threads,
886 production_override: resolved.production_override,
887 quiet: true,
888 analysis: ProductionAnalysis::DeadCode,
889 },
890 )
891 .map_err(|err| {
892 ProgrammaticError::new(format!("failed to load config: {err}"), 2)
893 .with_code("FALLOW_CONFIG_LOAD_FAILED")
894 .with_context("analysis.configPath")
895 })?;
896 let project_config = configure_project_for_dead_code(project_config, options);
897 Ok(AnalysisSession::from_config(project_config))
898}
899
900fn configure_project_for_dead_code(
901 mut project_config: ProjectConfig,
902 options: &DeadCodeOptions,
903) -> ProjectConfig {
904 if options.include_entry_exports {
905 project_config.config.include_entry_exports = true;
906 }
907 activate_explicit_dead_code_opt_ins(&options.filters, &mut project_config.config.rules);
908 project_config
909}
910
911fn activate_explicit_dead_code_opt_ins(
912 filters: &DeadCodeFilters,
913 rules: &mut fallow_config::RulesConfig,
914) {
915 if filters.private_type_leaks && rules.private_type_leaks == fallow_config::Severity::Off {
916 rules.private_type_leaks = fallow_config::Severity::Warn;
917 }
918}
919
920fn apply_dead_code_scope(
921 options: &DeadCodeOptions,
922 resolved: &ProgrammaticAnalysisContext,
923 session: &AnalysisSession,
924 results: &mut AnalysisResults,
925) -> ProgrammaticResult<()> {
926 if let Some(workspace_roots) = resolved.workspace_roots.as_ref() {
927 fallow_engine::dead_code::filter_to_workspaces(results, workspace_roots);
928 }
929 if let Some(changed_files) = changed_files_for_run(resolved)? {
930 fallow_engine::dead_code::filter_by_changed_files(results, &changed_files);
931 }
932 if let Some(diff) = resolved.diff.as_ref() {
933 filter_dead_code_by_diff(results, diff, session.root());
934 }
935 apply_dead_code_file_filter(options, session.root(), results);
936 Ok(())
937}
938
939fn filter_dead_code_by_diff(results: &mut AnalysisResults, diff: &DiffIndex, root: &Path) {
940 let touches_file = |path: &Path| -> bool {
941 relative_to_diff_path(path, root).is_none_or(|rel| diff.touches_file(&rel))
942 };
943 let line_in_diff = |path: &Path, line: u32| -> bool {
944 relative_to_diff_path(path, root)
945 .is_none_or(|rel| diff.line_is_added(&rel, u64::from(line)))
946 };
947
948 filter_dead_code_source_findings(results, &touches_file, &line_in_diff);
949 filter_dead_code_security_findings(results, &touches_file, &line_in_diff);
950 filter_dead_code_dependency_findings(results, &line_in_diff);
951 filter_dead_code_graph_findings(results, &touches_file, &line_in_diff);
952 filter_dead_code_framework_findings(results, &line_in_diff);
953}
954
955fn filter_dead_code_source_findings(
956 results: &mut AnalysisResults,
957 touches_file: &dyn Fn(&Path) -> bool,
958 line_in_diff: &dyn Fn(&Path, u32) -> bool,
959) {
960 results
961 .unused_files
962 .retain(|finding| touches_file(&finding.file.path));
963 results
964 .unused_exports
965 .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
966 results
967 .unused_types
968 .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
969 results
970 .private_type_leaks
971 .retain(|finding| line_in_diff(&finding.leak.path, finding.leak.line));
972 results
973 .unused_enum_members
974 .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
975 results
976 .unused_class_members
977 .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
978 results
979 .unused_store_members
980 .retain(|finding| line_in_diff(&finding.member.path, finding.member.line));
981 results
982 .unprovided_injects
983 .retain(|finding| line_in_diff(&finding.inject.path, finding.inject.line));
984 results
985 .unrendered_components
986 .retain(|finding| line_in_diff(&finding.component.path, finding.component.line));
987 results
988 .unused_component_props
989 .retain(|finding| line_in_diff(&finding.prop.path, finding.prop.line));
990 results
991 .unused_component_emits
992 .retain(|finding| line_in_diff(&finding.emit.path, finding.emit.line));
993 results
994 .unused_component_inputs
995 .retain(|finding| line_in_diff(&finding.input.path, finding.input.line));
996 results
997 .unused_component_outputs
998 .retain(|finding| line_in_diff(&finding.output.path, finding.output.line));
999 results
1000 .unused_svelte_events
1001 .retain(|finding| line_in_diff(&finding.event.path, finding.event.line));
1002 results
1003 .unused_server_actions
1004 .retain(|finding| line_in_diff(&finding.action.path, finding.action.line));
1005 results
1006 .unused_load_data_keys
1007 .retain(|finding| line_in_diff(&finding.key.path, finding.key.line));
1008 results
1009 .unresolved_imports
1010 .retain(|finding| line_in_diff(&finding.import.path, finding.import.line));
1011}
1012
1013fn filter_dead_code_security_findings(
1014 results: &mut AnalysisResults,
1015 touches_file: &dyn Fn(&Path) -> bool,
1016 line_in_diff: &dyn Fn(&Path, u32) -> bool,
1017) {
1018 results.security_findings.retain(|finding| {
1019 line_in_diff(&finding.path, finding.line)
1020 || finding.trace.iter().any(|hop| {
1021 line_in_diff(&hop.path, hop.line)
1022 || (matches!(hop.role, fallow_engine::results::TraceHopRole::SecretSource)
1023 && touches_file(&hop.path))
1024 })
1025 || finding.reachability.as_ref().is_some_and(|reachability| {
1026 reachability
1027 .untrusted_source_trace
1028 .iter()
1029 .any(|hop| line_in_diff(&hop.path, hop.line))
1030 })
1031 });
1032 results
1033 .security_unresolved_callee_diagnostics
1034 .retain(|finding| line_in_diff(&finding.path, finding.line));
1035}
1036
1037fn filter_dead_code_dependency_findings(
1038 results: &mut AnalysisResults,
1039 line_in_diff: &dyn Fn(&Path, u32) -> bool,
1040) {
1041 for finding in &mut results.unlisted_dependencies {
1042 finding
1043 .dep
1044 .imported_from
1045 .retain(|source| line_in_diff(&source.path, source.line));
1046 }
1047 results
1048 .unlisted_dependencies
1049 .retain(|finding| !finding.dep.imported_from.is_empty());
1050}
1051
1052fn filter_dead_code_graph_findings(
1053 results: &mut AnalysisResults,
1054 touches_file: &dyn Fn(&Path) -> bool,
1055 line_in_diff: &dyn Fn(&Path, u32) -> bool,
1056) {
1057 results.duplicate_exports.retain(|finding| {
1058 finding
1059 .export
1060 .locations
1061 .iter()
1062 .any(|location| line_in_diff(&location.path, location.line))
1063 });
1064 results
1065 .circular_dependencies
1066 .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
1067 results
1068 .re_export_cycles
1069 .retain(|cycle| cycle.cycle.files.iter().any(|path| touches_file(path)));
1070 results
1071 .boundary_violations
1072 .retain(|finding| line_in_diff(&finding.violation.from_path, finding.violation.line));
1073 results
1074 .stale_suppressions
1075 .retain(|finding| line_in_diff(&finding.path, finding.line));
1076}
1077
1078fn filter_dead_code_framework_findings(
1079 results: &mut AnalysisResults,
1080 line_in_diff: &dyn Fn(&Path, u32) -> bool,
1081) {
1082 results
1083 .invalid_client_exports
1084 .retain(|finding| line_in_diff(&finding.export.path, finding.export.line));
1085 results
1086 .mixed_client_server_barrels
1087 .retain(|finding| line_in_diff(&finding.barrel.path, finding.barrel.line));
1088 results
1089 .misplaced_directives
1090 .retain(|finding| line_in_diff(&finding.directive_site.path, finding.directive_site.line));
1091 results
1092 .route_collisions
1093 .retain(|finding| line_in_diff(&finding.collision.path, finding.collision.line));
1094 results
1095 .dynamic_segment_name_conflicts
1096 .retain(|finding| line_in_diff(&finding.conflict.path, finding.conflict.line));
1097}
1098
1099fn apply_dead_code_file_filter(
1100 options: &DeadCodeOptions,
1101 root: &Path,
1102 results: &mut AnalysisResults,
1103) {
1104 if options.files.is_empty() {
1105 return;
1106 }
1107 let file_set = options
1108 .files
1109 .iter()
1110 .map(|path| {
1111 if is_absolute_path_any_platform(path) {
1112 path.clone()
1113 } else {
1114 root.join(path)
1115 }
1116 })
1117 .collect::<FxHashSet<_>>();
1118 fallow_engine::dead_code::filter_by_changed_files(results, &file_set);
1119 clear_dead_code_dependency_findings(results);
1120}
1121
1122fn apply_dead_code_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1123 if !dead_code_filters_active(filters) {
1124 return;
1125 }
1126 apply_dead_code_core_filters(filters, results);
1127 apply_dead_code_component_filters(filters, results);
1128 apply_dead_code_graph_filters(filters, results);
1129 apply_dead_code_policy_filters(filters, results);
1130 apply_dead_code_catalog_filters(filters, results);
1131}
1132
1133fn dead_code_filters_active(filters: &DeadCodeFilters) -> bool {
1134 filters.unused_files
1135 || filters.unused_exports
1136 || filters.unused_deps
1137 || filters.unused_types
1138 || filters.private_type_leaks
1139 || filters.unused_enum_members
1140 || filters.unused_class_members
1141 || filters.unused_store_members
1142 || filters.unprovided_injects
1143 || filters.unrendered_components
1144 || filters.unused_component_props
1145 || filters.unused_component_emits
1146 || filters.unused_component_inputs
1147 || filters.unused_component_outputs
1148 || filters.unused_svelte_events
1149 || filters.unused_server_actions
1150 || filters.unused_load_data_keys
1151 || filters.unresolved_imports
1152 || filters.unlisted_deps
1153 || filters.duplicate_exports
1154 || filters.circular_deps
1155 || filters.re_export_cycles
1156 || filters.boundary_violations
1157 || filters.policy_violations
1158 || filters.stale_suppressions
1159 || filters.unused_catalog_entries
1160 || filters.empty_catalog_groups
1161 || filters.unresolved_catalog_references
1162 || filters.unused_dependency_overrides
1163 || filters.misconfigured_dependency_overrides
1164}
1165
1166fn apply_dead_code_core_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1167 if !filters.unused_files {
1168 results.unused_files.clear();
1169 }
1170 if !filters.unused_exports {
1171 results.unused_exports.clear();
1172 }
1173 if !filters.unused_types {
1174 results.unused_types.clear();
1175 }
1176 if !filters.private_type_leaks {
1177 results.private_type_leaks.clear();
1178 }
1179 if !filters.unused_deps {
1180 clear_dead_code_dependency_findings(results);
1181 }
1182 if !filters.unused_enum_members {
1183 results.unused_enum_members.clear();
1184 }
1185 if !filters.unused_class_members {
1186 results.unused_class_members.clear();
1187 }
1188 if !filters.unused_store_members {
1189 results.unused_store_members.clear();
1190 }
1191 if !filters.unlisted_deps {
1192 results.unlisted_dependencies.clear();
1193 }
1194}
1195
1196fn clear_dead_code_dependency_findings(results: &mut AnalysisResults) {
1197 results.unused_dependencies.clear();
1198 results.unused_dev_dependencies.clear();
1199 results.unused_optional_dependencies.clear();
1200 results.type_only_dependencies.clear();
1201 results.test_only_dependencies.clear();
1202}
1203
1204fn apply_dead_code_component_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1205 if !filters.unprovided_injects {
1206 results.unprovided_injects.clear();
1207 }
1208 if !filters.unrendered_components {
1209 results.unrendered_components.clear();
1210 }
1211 if !filters.unused_component_props {
1212 results.unused_component_props.clear();
1213 }
1214 if !filters.unused_component_emits {
1215 results.unused_component_emits.clear();
1216 }
1217 if !filters.unused_component_inputs {
1218 results.unused_component_inputs.clear();
1219 }
1220 if !filters.unused_component_outputs {
1221 results.unused_component_outputs.clear();
1222 }
1223 if !filters.unused_svelte_events {
1224 results.unused_svelte_events.clear();
1225 }
1226 if !filters.unused_server_actions {
1227 results.unused_server_actions.clear();
1228 }
1229 if !filters.unused_load_data_keys {
1230 results.unused_load_data_keys.clear();
1231 }
1232 if !filters.unresolved_imports {
1233 results.unresolved_imports.clear();
1234 }
1235}
1236
1237fn apply_dead_code_graph_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1238 if !filters.duplicate_exports {
1239 results.duplicate_exports.clear();
1240 }
1241 if !filters.circular_deps {
1242 results.circular_dependencies.clear();
1243 }
1244 if !filters.re_export_cycles {
1245 results.re_export_cycles.clear();
1246 }
1247 if !filters.boundary_violations {
1248 results.boundary_violations.clear();
1249 results.boundary_coverage_violations.clear();
1250 results.boundary_call_violations.clear();
1251 }
1252}
1253
1254fn apply_dead_code_policy_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1255 if !filters.policy_violations {
1256 results.policy_violations.clear();
1257 }
1258 if !filters.stale_suppressions {
1259 results.stale_suppressions.clear();
1260 }
1261}
1262
1263fn apply_dead_code_catalog_filters(filters: &DeadCodeFilters, results: &mut AnalysisResults) {
1264 if !filters.unused_catalog_entries {
1265 results.unused_catalog_entries.clear();
1266 }
1267 if !filters.empty_catalog_groups {
1268 results.empty_catalog_groups.clear();
1269 }
1270 if !filters.unresolved_catalog_references {
1271 results.unresolved_catalog_references.clear();
1272 }
1273 if !filters.unused_dependency_overrides {
1274 results.unused_dependency_overrides.clear();
1275 }
1276 if !filters.misconfigured_dependency_overrides {
1277 results.misconfigured_dependency_overrides.clear();
1278 }
1279}
1280
1281fn detect_duplication_inner(
1282 options: &DuplicationOptions,
1283 resolved: &ProgrammaticAnalysisContext,
1284) -> ProgrammaticResult<DuplicationProgrammaticOutput> {
1285 let start = Instant::now();
1286 let session = load_duplication_session(options, resolved)?;
1287 stash_workspace_diagnostics_for_session(&session);
1288 let dupes_config = build_dupes_config(options, &session.config().duplicates);
1289 let changed_files = changed_files_for_run(resolved)?;
1290 let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
1291 let mut report = if let Some(changed_files) = changed_files.as_ref() {
1292 let changed_files = changed_files.iter().cloned().collect::<Vec<_>>();
1293 session
1294 .find_duplicates_touching_files_with_defaults(&dupes_config, &changed_files, cache_dir)
1295 .report
1296 } else {
1297 session
1298 .find_duplicates_with_defaults(&dupes_config, cache_dir)
1299 .report
1300 };
1301
1302 if let Some(diff) = resolved.diff.as_ref() {
1303 filter_by_diff(&mut report, diff, session.root());
1304 }
1305 if let Some(workspace_roots) = resolved.workspace_roots.as_ref() {
1306 filter_by_workspaces(&mut report, workspace_roots, session.root());
1307 }
1308 if let Some(top) = options.top {
1309 apply_top(&mut report, top, session.root());
1310 }
1311
1312 let root = session.root();
1313 let payload = DupesReportPayload::from_report(&report);
1314 let clone_fingerprints = payload
1315 .clone_groups
1316 .iter()
1317 .map(|group| group.fingerprint.as_str())
1318 .collect::<Vec<_>>();
1319 let next_steps = build_dupes_next_steps(DupesNextStepsInput {
1320 suggestions_enabled: suggestions_enabled(),
1321 clone_fingerprints: &clone_fingerprints,
1322 offer_setup: setup_pointer_applicable(root),
1323 impact_digest: None,
1324 audit_changed: fallow_engine::churn::is_git_repo(root),
1325 });
1326 let output: DupesOutput<DupesReportPayload, serde_json::Value> =
1327 build_dupes_output(DupesOutputInput {
1328 schema_version: SCHEMA_VERSION,
1329 version: env!("CARGO_PKG_VERSION").to_string(),
1330 elapsed: start.elapsed(),
1331 report: payload,
1332 grouped_by: None,
1333 total_issues: None,
1334 groups: None,
1335 meta: resolved.explain_enabled().then(dupes_meta),
1336 workspace_diagnostics: fallow_config::workspace_diagnostics_for(root),
1337 next_steps,
1338 });
1339 Ok(DuplicationProgrammaticOutput {
1340 output,
1341 root: session.root().to_path_buf(),
1342 envelope_mode: root_envelope_mode(resolved.legacy_envelope),
1343 telemetry_analysis_run_id: None,
1344 })
1345}
1346
1347fn load_duplication_session(
1348 options: &DuplicationOptions,
1349 resolved: &ProgrammaticAnalysisContext,
1350) -> ProgrammaticResult<AnalysisSession> {
1351 let project_config =
1352 fallow_engine::config_for_project(&resolved.root, resolved.config_path.as_deref())
1353 .map_err(|err| {
1354 ProgrammaticError::new(format!("failed to load config: {err}"), 2)
1355 .with_code("FALLOW_CONFIG_LOAD_FAILED")
1356 .with_context("analysis.configPath")
1357 })?;
1358 let project_config = configure_project_for_duplication(project_config, options, resolved);
1359 Ok(AnalysisSession::from_config(project_config))
1360}
1361
1362fn configure_project_for_duplication(
1363 mut project_config: ProjectConfig,
1364 options: &DuplicationOptions,
1365 resolved: &ProgrammaticAnalysisContext,
1366) -> ProjectConfig {
1367 let production = resolved
1368 .production_override
1369 .unwrap_or(project_config.config.production);
1370 project_config.config.production = production;
1371 project_config.config.output = OutputFormat::Json;
1372 project_config.config.threads = resolved.threads;
1373 project_config.config.no_cache = resolved.no_cache;
1374 project_config.config.duplicates =
1375 build_dupes_config(options, &project_config.config.duplicates);
1376 project_config
1377}
1378
1379fn build_dupes_config(options: &DuplicationOptions, config: &DuplicatesConfig) -> DuplicatesConfig {
1380 DuplicatesConfig {
1381 enabled: true,
1382 mode: duplication_mode_to_config(options.mode),
1383 min_tokens: options.min_tokens,
1384 min_lines: options.min_lines,
1385 min_occurrences: options.min_occurrences,
1386 threshold: options.threshold,
1387 ignore: config.ignore.clone(),
1388 ignore_defaults: config.ignore_defaults,
1389 skip_local: options.skip_local || config.skip_local,
1390 cross_language: options.cross_language || config.cross_language,
1391 ignore_imports: options.ignore_imports.unwrap_or(config.ignore_imports),
1392 normalization: config.normalization.clone(),
1393 min_corpus_size_for_shingle_filter: config.min_corpus_size_for_shingle_filter,
1394 min_corpus_size_for_token_cache: config.min_corpus_size_for_token_cache,
1395 }
1396}
1397
1398const fn duplication_mode_to_config(mode: DuplicationMode) -> DetectionMode {
1399 match mode {
1400 DuplicationMode::Strict => DetectionMode::Strict,
1401 DuplicationMode::Mild => DetectionMode::Mild,
1402 DuplicationMode::Weak => DetectionMode::Weak,
1403 DuplicationMode::Semantic => DetectionMode::Semantic,
1404 }
1405}
1406
1407pub fn resolve_programmatic_analysis_context(
1414 options: &AnalysisOptions,
1415) -> ProgrammaticResult<ProgrammaticAnalysisContext> {
1416 validate_analysis_option_shape(options)?;
1417 let root = resolve_analysis_root(options.root.as_deref())?;
1418 validate_analysis_config_path(options.config_path.as_deref())?;
1419 let threads = options.threads.unwrap_or_else(default_threads);
1420 let pool = rayon::ThreadPoolBuilder::new()
1421 .num_threads(threads)
1422 .build()
1423 .map_err(|err| {
1424 ProgrammaticError::new(format!("failed to build analysis thread pool: {err}"), 2)
1425 .with_code("FALLOW_THREAD_POOL_INIT_FAILED")
1426 .with_context("analysis.threads")
1427 })?;
1428 let diff = options
1429 .diff_file
1430 .as_deref()
1431 .map(|path| load_explicit_diff_file(path, &root))
1432 .transpose()?;
1433 let workspace_roots = resolve_workspace_scope(
1434 &root,
1435 options.workspace.as_deref(),
1436 options.changed_workspaces.as_deref(),
1437 )?;
1438 Ok(ProgrammaticAnalysisContext {
1439 root,
1440 config_path: options.config_path.clone(),
1441 no_cache: options.no_cache,
1442 threads,
1443 pool,
1444 diff,
1445 production_override: options
1446 .production_override
1447 .or_else(|| options.production.then_some(true)),
1448 changed_since: options.changed_since.clone(),
1449 workspace: options.workspace.clone(),
1450 changed_workspaces: options.changed_workspaces.clone(),
1451 workspace_roots,
1452 legacy_envelope: options.legacy_envelope,
1453 explain: options.explain,
1454 })
1455}
1456
1457fn validate_analysis_option_shape(options: &AnalysisOptions) -> ProgrammaticResult<()> {
1458 if options.threads == Some(0) {
1459 return Err(
1460 ProgrammaticError::new("`threads` must be greater than 0", 2)
1461 .with_code("FALLOW_INVALID_THREADS")
1462 .with_context("analysis.threads"),
1463 );
1464 }
1465 if options.workspace.is_some() && options.changed_workspaces.is_some() {
1466 return Err(ProgrammaticError::new(
1467 "`workspace` and `changed_workspaces` are mutually exclusive",
1468 2,
1469 )
1470 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
1471 .with_context("analysis.workspace"));
1472 }
1473 Ok(())
1474}
1475
1476fn resolve_analysis_root(root: Option<&Path>) -> ProgrammaticResult<PathBuf> {
1477 let root = match root {
1478 Some(root) => root.to_path_buf(),
1479 None => std::env::current_dir().map_err(|err| {
1480 ProgrammaticError::new(
1481 format!("failed to resolve current working directory: {err}"),
1482 2,
1483 )
1484 .with_code("FALLOW_CWD_UNAVAILABLE")
1485 .with_context("analysis.root")
1486 })?,
1487 };
1488 if !root.exists() {
1489 return Err(ProgrammaticError::new(
1490 format!("analysis root does not exist: {}", root.display()),
1491 2,
1492 )
1493 .with_code("FALLOW_INVALID_ROOT")
1494 .with_context("analysis.root"));
1495 }
1496 if !root.is_dir() {
1497 return Err(ProgrammaticError::new(
1498 format!("analysis root is not a directory: {}", root.display()),
1499 2,
1500 )
1501 .with_code("FALLOW_INVALID_ROOT")
1502 .with_context("analysis.root"));
1503 }
1504 Ok(root)
1505}
1506
1507fn validate_analysis_config_path(config_path: Option<&Path>) -> ProgrammaticResult<()> {
1508 if let Some(config_path) = config_path
1509 && !config_path.exists()
1510 {
1511 return Err(ProgrammaticError::new(
1512 format!("config file does not exist: {}", config_path.display()),
1513 2,
1514 )
1515 .with_code("FALLOW_INVALID_CONFIG_PATH")
1516 .with_context("analysis.configPath"));
1517 }
1518 Ok(())
1519}
1520
1521impl ProgrammaticAnalysisContext {
1522 pub fn install<R: Send>(&self, f: impl FnOnce() -> R + Send) -> R {
1524 self.pool.install(f)
1525 }
1526
1527 #[must_use]
1529 pub fn root(&self) -> &Path {
1530 &self.root
1531 }
1532
1533 #[must_use]
1535 pub fn config_path(&self) -> &Option<PathBuf> {
1536 &self.config_path
1537 }
1538
1539 #[must_use]
1541 pub const fn no_cache(&self) -> bool {
1542 self.no_cache
1543 }
1544
1545 #[must_use]
1547 pub const fn threads(&self) -> usize {
1548 self.threads
1549 }
1550
1551 #[must_use]
1553 pub const fn diff_index(&self) -> Option<&DiffIndex> {
1554 self.diff.as_ref()
1555 }
1556
1557 #[must_use]
1559 pub const fn production_override(&self) -> Option<bool> {
1560 self.production_override
1561 }
1562
1563 #[must_use]
1565 pub fn changed_since(&self) -> Option<&str> {
1566 self.changed_since.as_deref()
1567 }
1568
1569 #[must_use]
1571 pub fn workspace(&self) -> Option<&[String]> {
1572 self.workspace.as_deref()
1573 }
1574
1575 #[must_use]
1577 pub fn changed_workspaces(&self) -> Option<&str> {
1578 self.changed_workspaces.as_deref()
1579 }
1580
1581 #[must_use]
1583 pub const fn explain_enabled(&self) -> bool {
1584 self.explain
1585 }
1586}
1587
1588fn default_threads() -> usize {
1589 std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get)
1590}
1591
1592fn load_explicit_diff_file(path: &Path, root: &Path) -> ProgrammaticResult<DiffIndex> {
1593 if path == Path::new("-") {
1594 return Err(ProgrammaticError::new(
1595 "`diff_file` does not support stdin; pass a file path",
1596 2,
1597 )
1598 .with_code("FALLOW_INVALID_DIFF_FILE")
1599 .with_context("analysis.diffFile"));
1600 }
1601 let abs = if is_absolute_path_any_platform(path) {
1602 path.to_path_buf()
1603 } else {
1604 root.join(path)
1605 };
1606 let meta = std::fs::metadata(&abs).map_err(|err| {
1607 ProgrammaticError::new(
1608 format!(
1609 "diff file does not exist or cannot be read: {} ({err})",
1610 abs.display()
1611 ),
1612 2,
1613 )
1614 .with_code("FALLOW_INVALID_DIFF_FILE")
1615 .with_context("analysis.diffFile")
1616 })?;
1617 if !meta.is_file() {
1618 return Err(ProgrammaticError::new(
1619 format!("diff path is not a file: {}", abs.display()),
1620 2,
1621 )
1622 .with_code("FALLOW_INVALID_DIFF_FILE")
1623 .with_context("analysis.diffFile"));
1624 }
1625 if meta.len() > MAX_DIFF_BYTES {
1626 return Err(ProgrammaticError::new(
1627 format!(
1628 "diff file is {} bytes, above the {MAX_DIFF_BYTES} byte limit: {}",
1629 meta.len(),
1630 abs.display()
1631 ),
1632 2,
1633 )
1634 .with_code("FALLOW_INVALID_DIFF_FILE")
1635 .with_context("analysis.diffFile"));
1636 }
1637 let text = std::fs::read_to_string(&abs).map_err(|err| {
1638 ProgrammaticError::new(
1639 format!("failed to read diff file {}: {err}", abs.display()),
1640 2,
1641 )
1642 .with_code("FALLOW_INVALID_DIFF_FILE")
1643 .with_context("analysis.diffFile")
1644 })?;
1645 Ok(DiffIndex::from_unified_diff(&text))
1646}
1647
1648fn changed_files_for_run(
1649 resolved: &ProgrammaticAnalysisContext,
1650) -> ProgrammaticResult<Option<FxHashSet<PathBuf>>> {
1651 let Some(git_ref) = resolved.changed_since.as_deref() else {
1652 return Ok(None);
1653 };
1654 fallow_engine::changed_files(&resolved.root, git_ref)
1655 .map(Some)
1656 .map_err(|err| {
1657 ProgrammaticError::new(
1658 format!(
1659 "failed to resolve changed files for ref `{git_ref}`: {}",
1660 err.describe()
1661 ),
1662 2,
1663 )
1664 .with_code("FALLOW_CHANGED_FILES_FAILED")
1665 .with_context("analysis.changedSince")
1666 })
1667}
1668
1669fn resolve_workspace_scope(
1670 root: &Path,
1671 workspace: Option<&[String]>,
1672 changed_workspaces: Option<&str>,
1673) -> ProgrammaticResult<Option<Vec<PathBuf>>> {
1674 match (workspace, changed_workspaces) {
1675 (Some(patterns), None) => resolve_workspace_filters(root, patterns).map(Some),
1676 (None, Some(git_ref)) => resolve_changed_workspaces(root, git_ref).map(Some),
1677 (None, None) => Ok(None),
1678 (Some(_), Some(_)) => Err(ProgrammaticError::new(
1679 "`workspace` and `changed_workspaces` are mutually exclusive",
1680 2,
1681 )
1682 .with_code("FALLOW_MUTUALLY_EXCLUSIVE_SCOPE")
1683 .with_context("analysis.workspace")),
1684 }
1685}
1686
1687fn resolve_workspace_filters(root: &Path, patterns: &[String]) -> ProgrammaticResult<Vec<PathBuf>> {
1688 let workspaces = fallow_config::discover_workspaces(root);
1689 if workspaces.is_empty() {
1690 let joined = patterns
1691 .iter()
1692 .map(|pattern| format!("'{pattern}'"))
1693 .collect::<Vec<_>>()
1694 .join(", ");
1695 return Err(ProgrammaticError::new(
1696 format!(
1697 "`workspace` {joined} specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
1698 ),
1699 2,
1700 )
1701 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
1702 .with_context("analysis.workspace"));
1703 }
1704
1705 let rel_paths = workspaces
1706 .iter()
1707 .map(|workspace| relative_workspace_path(&workspace.root, root))
1708 .collect::<Vec<_>>();
1709 let (positive, negative) = split_workspace_patterns(patterns);
1710 let mut matched = match_positive_workspace_patterns(&positive, &workspaces, &rel_paths)?;
1711
1712 for pattern in &negative {
1713 for index in find_workspace_matches(pattern, &workspaces, &rel_paths)? {
1714 matched.remove(&index);
1715 }
1716 }
1717
1718 if matched.is_empty() {
1719 return Err(
1720 ProgrammaticError::new("`workspace` excluded every discovered workspace", 2)
1721 .with_code("FALLOW_WORKSPACE_SCOPE_EMPTY")
1722 .with_context("analysis.workspace"),
1723 );
1724 }
1725
1726 let mut roots = matched
1727 .into_iter()
1728 .map(|index| workspaces[index].root.clone())
1729 .collect::<Vec<_>>();
1730 roots.sort();
1731 Ok(roots)
1732}
1733
1734fn resolve_changed_workspaces(root: &Path, git_ref: &str) -> ProgrammaticResult<Vec<PathBuf>> {
1735 let workspaces = fallow_config::discover_workspaces(root);
1736 if workspaces.is_empty() {
1737 return Err(ProgrammaticError::new(
1738 format!(
1739 "`changed_workspaces` '{git_ref}' specified but no workspaces found. Ensure root package.json has a \"workspaces\" field, pnpm-workspace.yaml exists, or tsconfig.json has \"references\"."
1740 ),
1741 2,
1742 )
1743 .with_code("FALLOW_WORKSPACES_NOT_FOUND")
1744 .with_context("analysis.changedWorkspaces"));
1745 }
1746 let changed_files = fallow_engine::changed_files(root, git_ref).map_err(|err| {
1747 ProgrammaticError::new(
1748 format!(
1749 "failed to resolve changed workspaces for ref `{git_ref}`: {}",
1750 err.describe()
1751 ),
1752 2,
1753 )
1754 .with_code("FALLOW_CHANGED_WORKSPACES_FAILED")
1755 .with_context("analysis.changedWorkspaces")
1756 })?;
1757 let mut roots = workspaces
1758 .into_iter()
1759 .filter(|workspace| {
1760 changed_files
1761 .iter()
1762 .any(|file| file.starts_with(&workspace.root))
1763 })
1764 .map(|workspace| workspace.root)
1765 .collect::<Vec<_>>();
1766 roots.sort();
1767 Ok(roots)
1768}
1769
1770fn match_positive_workspace_patterns(
1771 positive: &[&str],
1772 workspaces: &[WorkspaceInfo],
1773 rel_paths: &[String],
1774) -> ProgrammaticResult<FxHashSet<usize>> {
1775 let mut matched = FxHashSet::default();
1776 let mut unmatched = Vec::new();
1777
1778 if positive.is_empty() {
1779 matched.extend(0..workspaces.len());
1780 } else {
1781 for pattern in positive {
1782 let hits = find_workspace_matches(pattern, workspaces, rel_paths)?;
1783 if hits.is_empty() {
1784 unmatched.push((*pattern).to_string());
1785 }
1786 matched.extend(hits);
1787 }
1788 }
1789
1790 if !unmatched.is_empty() {
1791 return Err(ProgrammaticError::new(
1792 format!(
1793 "`workspace` matched no workspace for pattern{}: {}. Available: {}",
1794 if unmatched.len() == 1 { "" } else { "s" },
1795 unmatched
1796 .iter()
1797 .map(|pattern| format!("'{pattern}'"))
1798 .collect::<Vec<_>>()
1799 .join(", "),
1800 format_available_workspaces(workspaces),
1801 ),
1802 2,
1803 )
1804 .with_code("FALLOW_WORKSPACE_PATTERN_UNMATCHED")
1805 .with_context("analysis.workspace"));
1806 }
1807
1808 Ok(matched)
1809}
1810
1811fn find_workspace_matches(
1812 pattern: &str,
1813 workspaces: &[WorkspaceInfo],
1814 rel_paths: &[String],
1815) -> ProgrammaticResult<Vec<usize>> {
1816 if let Some(index) = workspaces
1817 .iter()
1818 .position(|workspace| workspace.name == pattern)
1819 {
1820 return Ok(vec![index]);
1821 }
1822 if let Some(index) = rel_paths.iter().position(|path| path == pattern) {
1823 return Ok(vec![index]);
1824 }
1825
1826 let glob = Glob::new(pattern).map_err(|err| {
1827 ProgrammaticError::new(format!("invalid `workspace` pattern '{pattern}': {err}"), 2)
1828 .with_code("FALLOW_INVALID_WORKSPACE_PATTERN")
1829 .with_context("analysis.workspace")
1830 })?;
1831 let matcher = glob.compile_matcher();
1832 let hits = workspaces
1833 .iter()
1834 .enumerate()
1835 .filter_map(|(index, workspace)| {
1836 (matcher.is_match(&workspace.name) || matcher.is_match(&rel_paths[index]))
1837 .then_some(index)
1838 })
1839 .collect();
1840 Ok(hits)
1841}
1842
1843fn split_workspace_patterns(patterns: &[String]) -> (Vec<&str>, Vec<&str>) {
1844 let mut positive = Vec::new();
1845 let mut negative = Vec::new();
1846 for pattern in patterns {
1847 let trimmed = pattern.trim();
1848 if trimmed.is_empty() {
1849 continue;
1850 }
1851 if let Some(negative_pattern) = trimmed.strip_prefix('!') {
1852 let negative_pattern = negative_pattern.trim();
1853 if !negative_pattern.is_empty() {
1854 negative.push(negative_pattern);
1855 }
1856 } else {
1857 positive.push(trimmed);
1858 }
1859 }
1860 (positive, negative)
1861}
1862
1863fn format_available_workspaces(workspaces: &[WorkspaceInfo]) -> String {
1864 const MAX_SHOWN: usize = 10;
1865 let total = workspaces.len();
1866 if total <= MAX_SHOWN {
1867 return workspaces
1868 .iter()
1869 .map(|workspace| workspace.name.as_str())
1870 .collect::<Vec<_>>()
1871 .join(", ");
1872 }
1873 let shown = workspaces
1874 .iter()
1875 .take(MAX_SHOWN)
1876 .map(|workspace| workspace.name.as_str())
1877 .collect::<Vec<_>>()
1878 .join(", ");
1879 format!(
1880 "{shown}, ... and {} more ({total} total)",
1881 total - MAX_SHOWN
1882 )
1883}
1884
1885fn relative_workspace_path(workspace_root: &Path, root: &Path) -> String {
1886 workspace_root
1887 .strip_prefix(root)
1888 .unwrap_or(workspace_root)
1889 .to_string_lossy()
1890 .replace('\\', "/")
1891}
1892
1893fn filter_by_diff(report: &mut DuplicationReport, diff_index: &DiffIndex, root: &Path) {
1894 let instance_overlaps = |instance: &CloneInstance| -> bool {
1895 let Some(rel) = relative_to_diff_path(&instance.file, root) else {
1896 return true;
1897 };
1898 let start = u64::try_from(instance.start_line).unwrap_or(u64::MAX);
1899 let end = u64::try_from(instance.end_line).unwrap_or(u64::MAX);
1900 diff_index.range_overlaps_added(&rel, start, end)
1901 };
1902 report
1903 .clone_groups
1904 .retain(|g| g.instances.iter().any(instance_overlaps));
1905 rebuild_duplication_derived_fields(report, root);
1906}
1907
1908fn filter_by_workspaces(report: &mut DuplicationReport, workspace_roots: &[PathBuf], root: &Path) {
1909 report.clone_groups.retain(|group| {
1910 group.instances.iter().any(|instance| {
1911 workspace_roots
1912 .iter()
1913 .any(|workspace_root| instance.file.starts_with(workspace_root))
1914 })
1915 });
1916 rebuild_duplication_derived_fields(report, root);
1917}
1918
1919fn apply_top(report: &mut DuplicationReport, n: usize, root: &Path) {
1920 report.clone_groups.sort_by(|a, b| {
1921 b.instances
1922 .len()
1923 .cmp(&a.instances.len())
1924 .then(b.line_count.cmp(&a.line_count))
1925 .then_with(|| match (a.instances.first(), b.instances.first()) {
1926 (Some(ai), Some(bi)) => ai
1927 .file
1928 .cmp(&bi.file)
1929 .then(ai.start_line.cmp(&bi.start_line)),
1930 (Some(_), None) => std::cmp::Ordering::Less,
1931 (None, Some(_)) => std::cmp::Ordering::Greater,
1932 (None, None) => std::cmp::Ordering::Equal,
1933 })
1934 });
1935 report.clone_groups.truncate(n);
1936 rebuild_duplication_derived_fields(report, root);
1937 report.sort();
1938}
1939
1940fn rebuild_duplication_derived_fields(report: &mut DuplicationReport, root: &Path) {
1941 report.clone_families =
1942 fallow_engine::duplicates::families::group_into_families(&report.clone_groups, root);
1943 report.mirrored_directories = fallow_engine::duplicates::families::detect_mirrored_directories(
1944 &report.clone_families,
1945 root,
1946 );
1947 report.stats = recompute_stats(report);
1948}
1949
1950fn recompute_stats(report: &DuplicationReport) -> DuplicationStats {
1951 let mut files_with_clones: FxHashSet<&Path> = FxHashSet::default();
1952 let mut line_ranges: FxHashMap<&Path, Vec<(usize, usize)>> = FxHashMap::default();
1953 let mut clone_instances = 0_usize;
1954 let mut duplicated_tokens = 0_usize;
1955 for group in &report.clone_groups {
1956 duplicated_tokens += group.token_count * group.instances.len();
1957 for instance in &group.instances {
1958 files_with_clones.insert(&instance.file);
1959 clone_instances += 1;
1960 line_ranges
1961 .entry(&instance.file)
1962 .or_default()
1963 .push((instance.start_line, instance.end_line));
1964 }
1965 }
1966 let duplicated_lines = line_ranges
1967 .into_values()
1968 .map(count_merged_lines)
1969 .sum::<usize>();
1970 let duplication_percentage = if report.stats.total_lines == 0 {
1971 0.0
1972 } else {
1973 (duplicated_lines as f64 / report.stats.total_lines as f64) * 100.0
1974 };
1975 DuplicationStats {
1976 total_files: report.stats.total_files,
1977 files_with_clones: files_with_clones.len(),
1978 total_lines: report.stats.total_lines,
1979 duplicated_lines,
1980 total_tokens: report.stats.total_tokens,
1981 duplicated_tokens,
1982 clone_groups: report.clone_groups.len(),
1983 clone_instances,
1984 duplication_percentage,
1985 clone_groups_below_min_occurrences: report.stats.clone_groups_below_min_occurrences,
1986 }
1987}
1988
1989fn count_merged_lines(mut ranges: Vec<(usize, usize)>) -> usize {
1990 if ranges.is_empty() {
1991 return 0;
1992 }
1993 ranges.sort_unstable();
1994 let mut total = 0_usize;
1995 let mut current = ranges[0];
1996 for (start, end) in ranges.into_iter().skip(1) {
1997 if start <= current.1.saturating_add(1) {
1998 current.1 = current.1.max(end);
1999 } else {
2000 total += current.1.saturating_sub(current.0).saturating_add(1);
2001 current = (start, end);
2002 }
2003 }
2004 total + current.1.saturating_sub(current.0).saturating_add(1)
2005}
2006
2007const fn root_envelope_mode(legacy_envelope: bool) -> RootEnvelopeMode {
2008 RootEnvelopeMode::from_legacy(legacy_envelope)
2009}
2010
2011#[cfg(test)]
2012mod tests {
2013 use std::path::PathBuf;
2014
2015 use super::*;
2016
2017 struct FakeHealthRunner {
2018 root: PathBuf,
2019 telemetry_analysis_run_id: Option<String>,
2020 }
2021
2022 impl ProgrammaticHealthRunner for FakeHealthRunner {
2023 fn run_programmatic_health(
2024 &self,
2025 _options: &ComplexityOptions,
2026 ) -> Result<ProgrammaticHealthRun, ProgrammaticError> {
2027 let project_config = fallow_engine::config_for_project_analysis(
2028 &self.root,
2029 None,
2030 ProjectConfigOptions {
2031 output: OutputFormat::Json,
2032 no_cache: true,
2033 threads: 1,
2034 production_override: None,
2035 quiet: true,
2036 analysis: ProductionAnalysis::Health,
2037 },
2038 )
2039 .expect("test config loads");
2040
2041 Ok(ProgrammaticHealthRun {
2042 analysis: fallow_engine::HealthAnalysisResult {
2043 report: HealthReport::default(),
2044 grouping: None,
2045 group_resolver: None,
2046 config: project_config.config,
2047 elapsed: std::time::Duration::ZERO,
2048 timings: None,
2049 coverage_gaps_has_findings: false,
2050 should_fail_on_coverage_gaps: false,
2051 },
2052 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
2053 &self.root,
2054 self.root.join("package.json"),
2055 fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
2056 )],
2057 next_step_facts: ProgrammaticHealthNextStepFacts {
2058 suggestions_enabled: true,
2059 offer_setup: false,
2060 impact_digest: Some(fallow_output::ImpactDigestCounts {
2061 containment_count: 1,
2062 resolved_total: 0,
2063 }),
2064 audit_changed: false,
2065 },
2066 telemetry_analysis_run_id: self.telemetry_analysis_run_id.clone(),
2067 })
2068 }
2069 }
2070
2071 fn analysis_at(root: &Path) -> AnalysisOptions {
2072 AnalysisOptions {
2073 root: Some(root.to_path_buf()),
2074 ..AnalysisOptions::default()
2075 }
2076 }
2077
2078 #[test]
2079 fn derives_programmatic_health_execution_options_from_api_contracts() {
2080 let project = tempfile::tempdir().expect("temp dir");
2081 let root = project.path();
2082 let options = ComplexityOptions {
2083 analysis: AnalysisOptions {
2084 root: Some(root.to_path_buf()),
2085 no_cache: true,
2086 threads: Some(2),
2087 production_override: Some(true),
2088 explain: true,
2089 ..AnalysisOptions::default()
2090 },
2091 max_cyclomatic: Some(12),
2092 top: Some(5),
2093 complexity: true,
2094 ownership: true,
2095 score: true,
2096 min_commits: Some(3),
2097 ..ComplexityOptions::default()
2098 };
2099 let resolved = resolve_programmatic_analysis_context(&options.analysis)
2100 .expect("programmatic context resolves");
2101
2102 let execution = derive_programmatic_health_execution_options(&resolved, &options);
2103
2104 assert_eq!(execution.root, root);
2105 assert!(matches!(execution.output, OutputFormat::Human));
2106 assert!(execution.no_cache);
2107 assert_eq!(execution.threads, 2);
2108 assert!(execution.quiet);
2109 assert!(!execution.complexity_breakdown);
2110 assert_eq!(execution.thresholds.max_cyclomatic, Some(12));
2111 assert_eq!(execution.top, Some(5));
2112 assert!(execution.production);
2113 assert_eq!(execution.production_override, Some(true));
2114 assert!(execution.complexity);
2115 assert!(execution.hotspots);
2116 assert!(execution.ownership);
2117 assert!(execution.score);
2118 assert_eq!(execution.min_commits, Some(3));
2119 assert!(execution.explain);
2120 assert!(execution.enforce_coverage_gap_gate);
2121 assert!(!execution.performance);
2122 assert!(execution.runtime_coverage.is_none());
2123 assert!(execution.group_by.is_none());
2124 }
2125
2126 #[test]
2127 fn serialize_health_report_json_tags_meta_and_strips_paths() {
2128 let root = Path::new("/repo");
2129 let json = serialize_health_report_json(HealthJsonReportInput {
2130 report: HealthReport::default(),
2131 root,
2132 elapsed: std::time::Duration::ZERO,
2133 explain: true,
2134 grouped_by: None,
2135 groups: None,
2136 workspace_diagnostics: vec![WorkspaceDiagnostic::new(
2137 Path::new("/repo"),
2138 PathBuf::from("/repo/package.json"),
2139 fallow_types::workspace::WorkspaceDiagnosticKind::UndeclaredWorkspace,
2140 )],
2141 next_steps: vec![NextStep {
2142 id: "inspect-health".to_string(),
2143 command: "fallow health --format json".to_string(),
2144 reason: "inspect health details".to_string(),
2145 }],
2146 envelope_mode: RootEnvelopeMode::Tagged,
2147 telemetry_analysis_run_id: Some("run-api-health"),
2148 })
2149 .expect("health JSON serializes");
2150
2151 assert_eq!(json["kind"], "health");
2152 assert_eq!(json["schema_version"], HEALTH_SCHEMA_VERSION);
2153 assert!(json["_meta"].is_object());
2154 assert_eq!(
2155 json["_meta"]["telemetry"]["analysis_run_id"],
2156 "run-api-health"
2157 );
2158 assert_eq!(json["workspace_diagnostics"][0]["path"], "package.json");
2159 assert_eq!(json["next_steps"][0]["id"], "inspect-health");
2160 }
2161
2162 #[test]
2163 fn serialize_health_report_json_respects_legacy_envelope() {
2164 let json = serialize_health_report_json(HealthJsonReportInput {
2165 report: HealthReport::default(),
2166 root: Path::new("/repo"),
2167 elapsed: std::time::Duration::ZERO,
2168 explain: false,
2169 grouped_by: None,
2170 groups: None,
2171 workspace_diagnostics: Vec::new(),
2172 next_steps: Vec::new(),
2173 envelope_mode: RootEnvelopeMode::Legacy,
2174 telemetry_analysis_run_id: None,
2175 })
2176 .expect("health JSON serializes");
2177
2178 assert!(json.get("kind").is_none());
2179 }
2180
2181 #[test]
2182 fn programmatic_health_runner_serializes_api_owned_output() {
2183 let project = tempfile::tempdir().expect("temp dir");
2184 let root = project.path().to_path_buf();
2185 let json = compute_health_with_runner(
2186 &ComplexityOptions {
2187 analysis: AnalysisOptions {
2188 explain: true,
2189 ..AnalysisOptions::default()
2190 },
2191 ..ComplexityOptions::default()
2192 },
2193 &FakeHealthRunner {
2194 root,
2195 telemetry_analysis_run_id: Some("run-123".to_string()),
2196 },
2197 )
2198 .expect("programmatic health should serialize");
2199
2200 assert_eq!(json["kind"], "health");
2201 assert_eq!(json["workspace_diagnostics"][0]["path"], "package.json");
2202 assert_eq!(json["next_steps"][0]["id"], "impact-report");
2203 assert_eq!(
2204 json["_meta"]["telemetry"]["analysis_run_id"],
2205 serde_json::Value::from("run-123")
2206 );
2207 }
2208
2209 #[test]
2210 fn detect_duplication_returns_dupes_envelope() {
2211 let project = tempfile::tempdir().expect("temp dir");
2212 let root = project.path();
2213 std::fs::create_dir(root.join("src")).expect("src dir");
2214 let code = "export function repeated() {\n return ['a', 'b', 'c'].join(',');\n}\n";
2215 std::fs::write(root.join("src/a.ts"), code).expect("file");
2216 std::fs::write(root.join("src/b.ts"), code).expect("file");
2217
2218 let json = detect_duplication(&DuplicationOptions {
2219 analysis: analysis_at(root),
2220 min_tokens: 1,
2221 min_lines: 1,
2222 ..DuplicationOptions::default()
2223 })
2224 .expect("duplication succeeds");
2225
2226 assert_eq!(json["kind"], "dupes");
2227 assert!(json["clone_groups"].is_array());
2228 assert!(json["stats"].is_object());
2229 }
2230
2231 fn enriched_project() -> tempfile::TempDir {
2237 let project = tempfile::tempdir().expect("temp dir");
2238 let root = project.path();
2239 std::fs::create_dir_all(root.join("packages/empty")).expect("empty pkg dir");
2241 std::fs::write(
2242 root.join("packages/empty/note.txt"),
2243 "no package.json here\n",
2244 )
2245 .expect("note");
2246 write_json(
2247 root.join("package.json"),
2248 r#"{"name":"api-enriched","main":"src/index.ts","workspaces":["packages/*"]}"#,
2249 );
2250 std::fs::create_dir(root.join("src")).expect("src dir");
2251 std::fs::write(
2252 root.join("src/index.ts"),
2253 "import './a';\nimport './b';\nexport const entry = 1;\nconsole.log(entry);\n",
2254 )
2255 .expect("entry");
2256 let clone = "export function repeated() {\n return ['x', 'y', 'z'].join(',');\n}\n";
2259 std::fs::write(root.join("src/a.ts"), clone).expect("a");
2260 std::fs::write(root.join("src/b.ts"), clone).expect("b");
2261 project
2262 }
2263
2264 fn has_glob_no_package_json(diagnostics: &serde_json::Value) -> bool {
2265 diagnostics
2266 .as_array()
2267 .into_iter()
2268 .flatten()
2269 .any(|diag| diag["kind"] == "glob-matched-no-package-json")
2270 }
2271
2272 #[test]
2277 fn detect_dead_code_carries_workspace_diagnostics_and_next_steps() {
2278 let project = enriched_project();
2279 let root = project.path();
2280
2281 let json = detect_dead_code(&DeadCodeOptions {
2282 analysis: analysis_at(root),
2283 filters: DeadCodeFilters {
2284 unused_exports: true,
2285 ..DeadCodeFilters::default()
2286 },
2287 ..DeadCodeOptions::default()
2288 })
2289 .expect("dead-code succeeds");
2290
2291 assert!(
2294 !json["unused_exports"].as_array().expect("array").is_empty(),
2295 "fixture must produce unused exports to drive next_steps"
2296 );
2297 assert!(
2298 has_glob_no_package_json(&json["workspace_diagnostics"]),
2299 "workspace_diagnostics must carry the glob-no-package-json diagnostic, got {:?}",
2300 json["workspace_diagnostics"]
2301 );
2302 assert!(
2303 json["next_steps"]
2304 .as_array()
2305 .is_some_and(|steps| !steps.is_empty()),
2306 "next_steps must be populated for a run with findings, got {:?}",
2307 json["next_steps"]
2308 );
2309 }
2310
2311 #[test]
2316 fn detect_duplication_carries_meta_diagnostics_and_next_steps() {
2317 let project = enriched_project();
2318 let root = project.path();
2319
2320 let json = detect_duplication(&DuplicationOptions {
2321 analysis: AnalysisOptions {
2322 explain: true,
2323 ..analysis_at(root)
2324 },
2325 min_tokens: 1,
2326 min_lines: 1,
2327 ..DuplicationOptions::default()
2328 })
2329 .expect("duplication succeeds");
2330
2331 assert!(
2332 !json["clone_groups"].as_array().expect("array").is_empty(),
2333 "fixture must produce a clone to drive trace-clone next step"
2334 );
2335 assert!(
2336 json["_meta"].is_object(),
2337 "explain mode must emit the dupes _meta block, got {:?}",
2338 json["_meta"]
2339 );
2340 assert!(
2341 has_glob_no_package_json(&json["workspace_diagnostics"]),
2342 "workspace_diagnostics must carry the glob-no-package-json diagnostic, got {:?}",
2343 json["workspace_diagnostics"]
2344 );
2345 assert!(
2346 json["next_steps"]
2347 .as_array()
2348 .is_some_and(|steps| !steps.is_empty()),
2349 "next_steps must be populated for a run with clones, got {:?}",
2350 json["next_steps"]
2351 );
2352 }
2353
2354 #[test]
2355 fn run_duplication_returns_typed_output_before_json() {
2356 let project = tempfile::tempdir().expect("temp dir");
2357 let root = project.path();
2358 std::fs::create_dir(root.join("src")).expect("src dir");
2359 std::fs::write(root.join("src/a.ts"), "export const a = 1;\n").expect("file");
2360
2361 let run = run_duplication(&DuplicationOptions {
2362 analysis: analysis_at(root),
2363 ..DuplicationOptions::default()
2364 })
2365 .expect("duplication succeeds");
2366
2367 assert_eq!(run.output.schema_version.0, SCHEMA_VERSION);
2368 assert_eq!(run.root, root);
2369 assert_eq!(run.envelope_mode, RootEnvelopeMode::Tagged);
2370
2371 let json = run
2372 .into_json()
2373 .expect("typed duplication output serializes");
2374 assert_eq!(json["kind"], "dupes");
2375 }
2376
2377 #[test]
2378 fn detect_duplication_legacy_envelope_removes_root_kind() {
2379 let project = tempfile::tempdir().expect("temp dir");
2380 let root = project.path();
2381 std::fs::create_dir(root.join("src")).expect("src dir");
2382 std::fs::write(root.join("src/a.ts"), "export const a = 1;\n").expect("file");
2383
2384 let json = detect_duplication(&DuplicationOptions {
2385 analysis: AnalysisOptions {
2386 legacy_envelope: true,
2387 ..analysis_at(root)
2388 },
2389 ..DuplicationOptions::default()
2390 })
2391 .expect("duplication succeeds");
2392
2393 assert!(json.get("kind").is_none());
2394 }
2395
2396 #[test]
2397 fn detect_dead_code_returns_dead_code_envelope() {
2398 let project = dead_code_project();
2399 let root = project.path();
2400
2401 let json = detect_dead_code(&DeadCodeOptions {
2402 analysis: analysis_at(root),
2403 filters: DeadCodeFilters {
2404 unused_exports: true,
2405 ..DeadCodeFilters::default()
2406 },
2407 ..DeadCodeOptions::default()
2408 })
2409 .expect("dead-code succeeds");
2410
2411 assert_eq!(json["kind"], "dead-code");
2412 assert_eq!(json["schema_version"], CHECK_SCHEMA_VERSION);
2413 assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
2414 }
2415
2416 #[test]
2417 fn run_dead_code_returns_typed_output_before_json() {
2418 let project = dead_code_project();
2419 let root = project.path();
2420
2421 let run = run_dead_code(&DeadCodeOptions {
2422 analysis: analysis_at(root),
2423 filters: DeadCodeFilters {
2424 unused_exports: true,
2425 ..DeadCodeFilters::default()
2426 },
2427 ..DeadCodeOptions::default()
2428 })
2429 .expect("dead-code succeeds");
2430
2431 assert_eq!(run.output.schema_version.0, CHECK_SCHEMA_VERSION);
2432 assert_eq!(run.output.results.unused_exports.len(), 2);
2433 assert_eq!(run.root, root);
2434 assert_eq!(run.envelope_mode, RootEnvelopeMode::Tagged);
2435
2436 let json = run.into_json().expect("typed dead-code output serializes");
2437 assert_eq!(unused_export_names(&json), vec!["deadA", "deadB"]);
2438 }
2439
2440 #[test]
2441 fn run_dead_code_family_helpers_return_typed_filtered_output() {
2442 let project = dead_code_project();
2443 let root = project.path();
2444 let options = DeadCodeOptions {
2445 analysis: analysis_at(root),
2446 ..DeadCodeOptions::default()
2447 };
2448
2449 let circular = run_circular_dependencies(&options).expect("circular helper");
2450 let boundary = run_boundary_violations(&options).expect("boundary helper");
2451
2452 assert!(circular.output.results.unused_exports.is_empty());
2453 assert!(boundary.output.results.unused_exports.is_empty());
2454 assert_eq!(circular.output.total_issues, 0);
2455 assert_eq!(boundary.output.total_issues, 0);
2456 }
2457
2458 #[test]
2459 fn detect_dead_code_legacy_envelope_removes_root_kind() {
2460 let project = dead_code_project();
2461 let root = project.path();
2462
2463 let json = detect_dead_code(&DeadCodeOptions {
2464 analysis: AnalysisOptions {
2465 legacy_envelope: true,
2466 ..analysis_at(root)
2467 },
2468 filters: DeadCodeFilters {
2469 unused_exports: true,
2470 ..DeadCodeFilters::default()
2471 },
2472 ..DeadCodeOptions::default()
2473 })
2474 .expect("dead-code succeeds");
2475
2476 assert!(json.get("kind").is_none());
2477 }
2478
2479 #[test]
2480 fn detect_dead_code_explain_includes_output_owned_meta() {
2481 let project = dead_code_project();
2482 let root = project.path();
2483
2484 let json = detect_dead_code(&DeadCodeOptions {
2485 analysis: AnalysisOptions {
2486 explain: true,
2487 ..analysis_at(root)
2488 },
2489 filters: DeadCodeFilters {
2490 unused_exports: true,
2491 ..DeadCodeFilters::default()
2492 },
2493 ..DeadCodeOptions::default()
2494 })
2495 .expect("dead-code succeeds");
2496
2497 assert_eq!(json["kind"], "dead-code");
2498 assert_eq!(
2499 json["_meta"]["docs"].as_str(),
2500 Some(fallow_output::CHECK_DOCS)
2501 );
2502 assert!(json["_meta"]["rules"]["unused-export"].is_object());
2503 }
2504
2505 #[test]
2506 fn detect_dead_code_marks_duplicate_export_config_action_fixable() {
2507 let project = duplicate_export_project();
2508 let root = project.path();
2509
2510 let json = detect_dead_code(&DeadCodeOptions {
2511 analysis: analysis_at(root),
2512 filters: DeadCodeFilters {
2513 duplicate_exports: true,
2514 ..DeadCodeFilters::default()
2515 },
2516 ..DeadCodeOptions::default()
2517 })
2518 .expect("dead-code succeeds");
2519
2520 let action = &json["duplicate_exports"][0]["actions"][0];
2521 assert_eq!(action["type"], "add-to-config");
2522 assert_eq!(action["auto_fixable"], true);
2523 }
2524
2525 #[test]
2526 fn detect_dead_code_keeps_duplicate_export_config_action_blocked_in_subpackage() {
2527 let workspace = tempfile::tempdir().expect("temp dir");
2528 std::fs::write(
2529 workspace.path().join("pnpm-workspace.yaml"),
2530 "packages:\n - packages/*\n",
2531 )
2532 .expect("workspace");
2533 let root = workspace.path().join("packages/app");
2534 duplicate_export_project_at(&root);
2535
2536 let json = detect_dead_code(&DeadCodeOptions {
2537 analysis: analysis_at(&root),
2538 filters: DeadCodeFilters {
2539 duplicate_exports: true,
2540 ..DeadCodeFilters::default()
2541 },
2542 ..DeadCodeOptions::default()
2543 })
2544 .expect("dead-code succeeds");
2545
2546 let action = &json["duplicate_exports"][0]["actions"][0];
2547 assert_eq!(action["type"], "add-to-config");
2548 assert_eq!(action["auto_fixable"], false);
2549 }
2550
2551 #[test]
2552 fn detect_dead_code_file_filter_scopes_source_findings() {
2553 let project = dead_code_project();
2554 let root = project.path();
2555
2556 let json = detect_dead_code(&DeadCodeOptions {
2557 analysis: analysis_at(root),
2558 filters: DeadCodeFilters {
2559 unused_exports: true,
2560 ..DeadCodeFilters::default()
2561 },
2562 files: vec![PathBuf::from("src/a.ts")],
2563 ..DeadCodeOptions::default()
2564 })
2565 .expect("dead-code succeeds");
2566
2567 assert_eq!(unused_export_names(&json), vec!["deadA"]);
2568 }
2569
2570 #[test]
2571 fn detect_dead_code_diff_file_filters_source_findings() {
2572 let project = dead_code_project();
2573 let root = project.path();
2574 std::fs::write(
2575 root.join("a.diff"),
2576 "diff --git a/src/a.ts b/src/a.ts\n+++ b/src/a.ts\n@@ -1 +1 @@\n+export const deadA = 1;\n",
2577 )
2578 .expect("diff");
2579
2580 let json = detect_dead_code(&DeadCodeOptions {
2581 analysis: AnalysisOptions {
2582 diff_file: Some(PathBuf::from("a.diff")),
2583 ..analysis_at(root)
2584 },
2585 filters: DeadCodeFilters {
2586 unused_exports: true,
2587 ..DeadCodeFilters::default()
2588 },
2589 ..DeadCodeOptions::default()
2590 })
2591 .expect("dead-code succeeds");
2592
2593 assert_eq!(unused_export_names(&json), vec!["deadA"]);
2594 }
2595
2596 #[test]
2597 fn detect_circular_dependencies_keeps_dead_code_envelope_but_filters_other_findings() {
2598 let project = dead_code_project();
2599 let root = project.path();
2600
2601 let json = detect_circular_dependencies(&DeadCodeOptions {
2602 analysis: analysis_at(root),
2603 ..DeadCodeOptions::default()
2604 })
2605 .expect("circular helper succeeds");
2606
2607 assert_eq!(json["kind"], "dead-code");
2608 assert_eq!(json["total_issues"], 0);
2609 assert!(json["circular_dependencies"].as_array().is_some());
2610 assert!(json["unused_exports"].as_array().is_none_or(Vec::is_empty));
2611 }
2612
2613 #[test]
2614 fn detect_boundary_violations_keeps_only_boundary_family() {
2615 let project = dead_code_project();
2616 let root = project.path();
2617
2618 let json = detect_boundary_violations(&DeadCodeOptions {
2619 analysis: analysis_at(root),
2620 ..DeadCodeOptions::default()
2621 })
2622 .expect("boundary helper succeeds");
2623
2624 assert_eq!(json["kind"], "dead-code");
2625 assert_eq!(json["total_issues"], 0);
2626 assert!(json["boundary_violations"].as_array().is_some());
2627 assert!(json["unused_exports"].as_array().is_none_or(Vec::is_empty));
2628 }
2629
2630 #[test]
2631 fn diff_file_filters_clone_groups() {
2632 let root = PathBuf::from("/repo");
2633 let mut report = DuplicationReport {
2634 clone_groups: vec![
2635 group(vec![
2636 instance("/repo/src/a.ts", 1, 3),
2637 instance("/repo/src/b.ts", 1, 3),
2638 ]),
2639 group(vec![
2640 instance("/repo/src/c.ts", 10, 12),
2641 instance("/repo/src/d.ts", 1, 3),
2642 ]),
2643 ],
2644 stats: DuplicationStats {
2645 total_files: 4,
2646 total_lines: 100,
2647 total_tokens: 100,
2648 clone_groups: 2,
2649 clone_instances: 4,
2650 ..DuplicationStats::default()
2651 },
2652 ..DuplicationReport::default()
2653 };
2654 let diff = DiffIndex::from_unified_diff(
2655 "diff --git a/src/a.ts b/src/a.ts\n+++ b/src/a.ts\n@@ -1,3 +1,3 @@\n+added\n context\n",
2656 );
2657
2658 filter_by_diff(&mut report, &diff, &root);
2659
2660 assert_eq!(report.clone_groups.len(), 1);
2661 assert_eq!(
2662 report.clone_groups[0].instances[0].file,
2663 root.join("src/a.ts")
2664 );
2665 }
2666
2667 #[test]
2668 fn workspace_scope_filters_clone_groups() {
2669 let root = PathBuf::from("/repo");
2670 let mut report = DuplicationReport {
2671 clone_groups: vec![
2672 group(vec![
2673 instance("/repo/packages/app/a.ts", 1, 3),
2674 instance("/repo/packages/shared/b.ts", 1, 3),
2675 ]),
2676 group(vec![
2677 instance("/repo/packages/docs/c.ts", 1, 3),
2678 instance("/repo/packages/docs/d.ts", 1, 3),
2679 ]),
2680 ],
2681 stats: DuplicationStats {
2682 total_files: 4,
2683 total_lines: 100,
2684 total_tokens: 100,
2685 clone_groups: 2,
2686 clone_instances: 4,
2687 ..DuplicationStats::default()
2688 },
2689 ..DuplicationReport::default()
2690 };
2691
2692 filter_by_workspaces(&mut report, &[root.join("packages/app")], &root);
2693
2694 assert_eq!(report.clone_groups.len(), 1);
2695 assert_eq!(
2696 report.clone_groups[0].instances[0].file,
2697 root.join("packages/app/a.ts")
2698 );
2699 }
2700
2701 #[test]
2702 fn workspace_patterns_match_names_paths_and_negation() {
2703 let project = tempfile::tempdir().expect("temp dir");
2704 let root = project.path();
2705 write_json(
2706 root.join("package.json"),
2707 r#"{"workspaces":["packages/*"]}"#,
2708 );
2709 write_workspace(root, "packages/app", "@scope/app");
2710 write_workspace(root, "packages/docs", "docs");
2711
2712 let roots =
2713 resolve_workspace_filters(root, &["packages/*".to_string(), "!docs".to_string()])
2714 .expect("workspace filters resolve");
2715
2716 assert_eq!(roots, vec![root.join("packages/app")]);
2717 }
2718
2719 fn instance(path: &str, start_line: usize, end_line: usize) -> CloneInstance {
2720 CloneInstance {
2721 file: PathBuf::from(path),
2722 start_line,
2723 end_line,
2724 start_col: 0,
2725 end_col: 0,
2726 fragment: String::new(),
2727 }
2728 }
2729
2730 fn group(instances: Vec<CloneInstance>) -> fallow_engine::duplicates::CloneGroup {
2731 fallow_engine::duplicates::CloneGroup {
2732 instances,
2733 token_count: 10,
2734 line_count: 3,
2735 }
2736 }
2737
2738 fn dead_code_project() -> tempfile::TempDir {
2739 let project = tempfile::tempdir().expect("temp dir");
2740 let root = project.path();
2741 std::fs::create_dir(root.join("src")).expect("src dir");
2742 write_json(
2743 root.join("package.json"),
2744 r#"{"name":"api-dead-code","main":"src/index.ts"}"#,
2745 );
2746 std::fs::write(
2747 root.join("src/index.ts"),
2748 "import './a';\nimport './b';\nexport const entry = 1;\nconsole.log(entry);\n",
2749 )
2750 .expect("entry");
2751 std::fs::write(root.join("src/a.ts"), "export const deadA = 1;\n").expect("a");
2752 std::fs::write(root.join("src/b.ts"), "export const deadB = 1;\n").expect("b");
2753 project
2754 }
2755
2756 fn duplicate_export_project() -> tempfile::TempDir {
2757 let project = tempfile::tempdir().expect("temp dir");
2758 duplicate_export_project_at(project.path());
2759 project
2760 }
2761
2762 fn duplicate_export_project_at(root: &Path) {
2763 std::fs::create_dir_all(root.join("src")).expect("src dir");
2764 write_json(
2765 root.join("package.json"),
2766 r#"{"name":"api-duplicate-export","main":"src/index.ts"}"#,
2767 );
2768 std::fs::write(root.join("src/index.ts"), "import './a';\nimport './b';\n").expect("entry");
2769 std::fs::write(root.join("src/a.ts"), "export const Button = 1;\n").expect("a");
2770 std::fs::write(root.join("src/b.ts"), "export const Button = 2;\n").expect("b");
2771 }
2772
2773 fn unused_export_names(json: &serde_json::Value) -> Vec<&str> {
2774 json["unused_exports"]
2775 .as_array()
2776 .expect("unused exports array")
2777 .iter()
2778 .map(|item| {
2779 item["name"]
2780 .as_str()
2781 .or_else(|| item["export_name"].as_str())
2782 .expect("unused export name")
2783 })
2784 .collect()
2785 }
2786
2787 fn write_workspace(root: &Path, relative: &str, name: &str) {
2788 let dir = root.join(relative);
2789 std::fs::create_dir_all(&dir).expect("workspace dir");
2790 write_json(dir.join("package.json"), &format!(r#"{{"name":"{name}"}}"#));
2791 }
2792
2793 fn write_json(path: PathBuf, json: &str) {
2794 std::fs::write(path, json).expect("json file");
2795 }
2796}