1use std::path::Path;
13
14use rustc_hash::FxHashMap;
15use serde::Serialize;
16use serde_json::Value;
17
18use fallow_config::{ResolvedConfig, WorkspaceInfo};
19use fallow_output::HealthReport;
20use fallow_types::discover::DiscoveredFile;
21use fallow_types::duplicates::{CloneInstance, DuplicationReport};
22use fallow_types::extract::{FunctionComplexity, ModuleInfo};
23use fallow_types::results::{AnalysisResults, FeatureFlag, SecurityFinding};
24
25use crate::module_graph::RetainedModuleGraph;
26
27const HOTSPOT_CYCLOMATIC_FLOOR: u16 = 10;
29const CLONE_PREVIEW_MAX_BYTES: usize = 2000;
33const CLONE_PREVIEW_MAX_LINES: usize = 32;
37const CLONE_PREVIEW_CONTEXT: usize = 4;
42const MAX_CLONE_GROUPS: usize = 500;
46const MAX_ANALYSIS_FINDINGS: usize = 1000;
48const MAX_SECURITY_BLIND_SPOT_SAMPLES: usize = 100;
50const MAX_HEALTH_FILES: usize = 2000;
52const EDGE_FLAG_TYPE_ONLY: u32 = 1;
54const NO_RUNTIME_COVERAGE_REASON: &str = "No runtime coverage input was provided";
58
59pub struct VizBuildInput<'a> {
61 pub results: &'a AnalysisResults,
63 pub graph: &'a RetainedModuleGraph,
65 pub modules: Option<&'a [ModuleInfo]>,
67 pub files: &'a [DiscoveredFile],
69 pub duplication: &'a DuplicationReport,
71 pub workspaces: &'a [WorkspaceInfo],
73 pub config: &'a ResolvedConfig,
75 pub feature_flags: &'a [FeatureFlag],
77 pub include_analysis_details: bool,
79}
80
81#[derive(Serialize)]
83pub struct VizData {
84 pub root: String,
86 pub files: Vec<VizFile>,
88 pub edges: Vec<[u32; 3]>,
91 pub summary: VizSummary,
93 pub workspaces: Vec<VizWorkspace>,
95 pub zones: Vec<VizZone>,
97 pub cycles: Vec<Vec<u32>>,
99 pub clones: Vec<VizCloneGroup>,
101 pub violations: Vec<VizViolation>,
103 pub architecture: VizFindingAnalysis,
105 pub dependencies: VizFindingAnalysis,
107 pub health: VizHealthData,
109 pub security: VizSecurityData,
111 pub frameworks: VizFrameworkData,
113 pub styling: VizStylingData,
115 pub feature_flags: VizFindingAnalysis,
117}
118
119#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
121#[serde(rename_all = "camelCase")]
122pub enum VizAvailabilityState {
123 Complete,
125 Disabled,
127 NotApplicable,
129 Unavailable,
131}
132
133#[derive(Serialize)]
140pub struct VizAvailability {
141 pub state: VizAvailabilityState,
143 pub count: usize,
145 pub unit: &'static str,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub reason: Option<String>,
150 #[serde(skip_serializing_if = "Option::is_none")]
153 pub truncated: Option<usize>,
154}
155
156impl VizAvailability {
157 const fn complete(count: usize, unit: &'static str, truncated: Option<usize>) -> Self {
158 Self {
159 state: VizAvailabilityState::Complete,
160 count,
161 unit,
162 reason: None,
163 truncated,
164 }
165 }
166
167 fn unavailable(unit: &'static str, reason: impl Into<String>) -> Self {
168 Self {
169 state: VizAvailabilityState::Unavailable,
170 count: 0,
171 unit,
172 reason: Some(reason.into()),
173 truncated: None,
174 }
175 }
176
177 fn disabled(unit: &'static str, reason: impl Into<String>) -> Self {
178 Self {
179 state: VizAvailabilityState::Disabled,
180 count: 0,
181 unit,
182 reason: Some(reason.into()),
183 truncated: None,
184 }
185 }
186}
187
188#[derive(Serialize)]
190pub struct VizFinding {
191 kind: String,
192 title: String,
193 #[serde(skip_serializing_if = "Option::is_none")]
194 file: Option<u32>,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 path: Option<String>,
197 #[serde(skip_serializing_if = "Option::is_none")]
198 line: Option<u32>,
199 #[serde(skip_serializing_if = "Vec::is_empty")]
200 files: Vec<u32>,
201 #[serde(skip_serializing_if = "Vec::is_empty")]
202 paths: Vec<String>,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 description: Option<String>,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 severity: Option<String>,
207 #[serde(skip_serializing_if = "Vec::is_empty")]
208 facts: Vec<VizFindingFact>,
209 actions: Vec<VizFindingAction>,
210}
211
212#[derive(Serialize)]
214pub struct VizFindingFact {
215 label: String,
216 value: String,
217}
218
219#[derive(Serialize)]
221pub struct VizFindingAction {
222 label: String,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 kind: Option<String>,
225 auto_fixable: bool,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 command: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 comment: Option<String>,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 config_key: Option<String>,
232 #[serde(skip_serializing_if = "Option::is_none")]
233 value: Option<Value>,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 description: Option<String>,
236}
237
238#[derive(Serialize)]
240pub struct VizFindingAnalysis {
241 pub availability: VizAvailability,
243 #[serde(skip_serializing_if = "Option::is_none")]
245 pub findings_truncated: Option<usize>,
246 pub findings: Vec<VizFinding>,
248}
249
250#[derive(Serialize)]
252pub struct VizFrameworkData {
253 pub availability: VizAvailability,
255 pub detector_availability: VizAvailability,
258 #[serde(skip_serializing_if = "Option::is_none")]
260 pub findings_truncated: Option<usize>,
261 pub findings: Vec<VizFinding>,
263 pub detected_frameworks: Vec<String>,
265 pub detectors: Vec<VizFrameworkDetector>,
268}
269
270#[derive(Serialize)]
272pub struct VizFrameworkDetector {
273 id: String,
274 framework: String,
275 status: String,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 reason: Option<String>,
278}
279
280#[derive(Serialize)]
282pub struct VizHealthCapabilities {
283 pub complexity: VizAvailability,
285 pub maintainability: VizAvailability,
287 pub crap: VizAvailability,
289 pub coverage: VizAvailability,
291 pub runtime: VizAvailability,
293 pub churn: VizAvailability,
295 pub hotspots: VizAvailability,
297 pub ownership: VizAvailability,
299}
300
301#[derive(Serialize)]
303pub struct VizHealthFile {
304 file: u32,
305 path: String,
306 maintainability_index: f64,
307 crap_max: f64,
308 complexity_density: f64,
309 fan_in: usize,
310 fan_out: usize,
311 #[serde(skip_serializing_if = "Option::is_none")]
312 hotspot_score: Option<f64>,
313 #[serde(skip_serializing_if = "Option::is_none")]
314 commits: Option<u32>,
315 #[serde(skip_serializing_if = "Option::is_none")]
316 ownership: Option<Value>,
317}
318
319#[derive(Serialize)]
321pub struct VizHealthData {
322 pub availability: VizAvailability,
324 pub capabilities: VizHealthCapabilities,
326 #[serde(skip_serializing_if = "Option::is_none")]
328 pub shared_parse: Option<bool>,
329 #[serde(skip_serializing_if = "Option::is_none")]
331 pub score: Option<f64>,
332 #[serde(skip_serializing_if = "Option::is_none")]
334 pub grade: Option<String>,
335 #[serde(skip_serializing_if = "Option::is_none")]
337 pub average_maintainability: Option<f64>,
338 pub files: Vec<VizHealthFile>,
340 #[serde(skip_serializing_if = "Option::is_none")]
342 pub files_truncated: Option<usize>,
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub findings_truncated: Option<usize>,
346 pub findings: Vec<VizFinding>,
348}
349
350#[derive(Serialize)]
352pub struct VizStylingData {
353 pub availability: VizAvailability,
355 #[serde(skip_serializing_if = "Option::is_none")]
357 pub findings_truncated: Option<usize>,
358 pub findings: Vec<VizFinding>,
360 #[serde(skip_serializing_if = "Option::is_none")]
362 pub score: Option<f64>,
363 #[serde(skip_serializing_if = "Option::is_none")]
365 pub grade: Option<String>,
366 #[serde(skip_serializing_if = "Option::is_none")]
368 pub confidence: Option<String>,
369 #[serde(skip_serializing_if = "Option::is_none")]
371 pub summary: Option<Value>,
372}
373
374#[derive(Serialize)]
376pub struct VizSecurityTraceHop {
377 #[serde(skip_serializing_if = "Option::is_none")]
378 file: Option<u32>,
379 path: String,
380 line: u32,
381 col: u32,
382 role: String,
383}
384
385#[derive(Serialize)]
387pub struct VizSecurityEndpoint {
388 #[serde(skip_serializing_if = "Option::is_none")]
389 file: Option<u32>,
390 path: String,
391 line: u32,
392 col: u32,
393}
394
395#[derive(Serialize)]
397pub struct VizSecurityTaintFlow {
398 source: VizSecurityEndpoint,
399 sink: VizSecurityEndpoint,
400 intra_module: bool,
401 cross_module_hops: u32,
402}
403
404#[derive(Serialize)]
407pub struct VizSecurityCandidate {
408 id: String,
409 kind: String,
410 #[serde(skip_serializing_if = "Option::is_none")]
411 category: Option<String>,
412 #[serde(skip_serializing_if = "Option::is_none")]
413 cwe: Option<u32>,
414 #[serde(skip_serializing_if = "Option::is_none")]
415 file: Option<u32>,
416 path: String,
417 line: u32,
418 col: u32,
419 evidence: String,
420 severity: String,
421 #[serde(skip_serializing_if = "Option::is_none")]
422 taint_confidence: Option<String>,
423 #[serde(skip_serializing_if = "Option::is_none")]
424 source_kind: Option<String>,
425 #[serde(skip_serializing_if = "Option::is_none")]
426 sink: Option<String>,
427 #[serde(skip_serializing_if = "Option::is_none")]
428 url_shape: Option<String>,
429 #[serde(skip_serializing_if = "Option::is_none")]
430 network_destination: Option<String>,
431 #[serde(skip_serializing_if = "Option::is_none")]
432 reachable_from_entry: Option<bool>,
433 #[serde(skip_serializing_if = "Option::is_none")]
434 reachable_from_untrusted_source: Option<bool>,
435 #[serde(skip_serializing_if = "Option::is_none")]
436 blast_radius: Option<u32>,
437 crosses_boundary: bool,
438 client_server_boundary: bool,
439 cross_module_boundary: bool,
440 #[serde(skip_serializing_if = "Option::is_none")]
441 architecture_zone: Option<String>,
442 #[serde(skip_serializing_if = "Option::is_none")]
443 dead_code: Option<Value>,
444 #[serde(skip_serializing_if = "Option::is_none")]
445 runtime: Option<Value>,
446 #[serde(skip_serializing_if = "Option::is_none")]
447 taint_flow: Option<VizSecurityTaintFlow>,
448 #[serde(skip_serializing_if = "Vec::is_empty")]
449 observed_controls: Vec<Value>,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 control_verification_prompt: Option<String>,
452 trace: Vec<VizSecurityTraceHop>,
453 #[serde(skip_serializing_if = "Vec::is_empty")]
454 taint_trace: Vec<VizSecurityTraceHop>,
455 actions: Value,
456}
457
458#[derive(Serialize)]
460pub struct VizSecurityBlindSpot {
461 kind: String,
462 count: usize,
463 #[serde(skip_serializing_if = "Option::is_none")]
464 path: Option<String>,
465 #[serde(skip_serializing_if = "Option::is_none")]
466 file: Option<u32>,
467 #[serde(skip_serializing_if = "Option::is_none")]
468 line: Option<u32>,
469 #[serde(skip_serializing_if = "Option::is_none")]
470 reason: Option<String>,
471}
472
473#[derive(Serialize)]
476pub struct VizSecurityData {
477 pub availability: VizAvailability,
480 pub runtime_availability: VizAvailability,
483 pub candidates: Vec<VizSecurityCandidate>,
485 pub blind_spot_count: usize,
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub blind_spots_truncated: Option<usize>,
490 pub blind_spots: Vec<VizSecurityBlindSpot>,
492}
493
494#[derive(Serialize)]
496pub struct VizFile {
497 pub path: String,
499 pub size: u64,
501 pub status: VizFileStatus,
503 pub export_count: u16,
505 pub unused_export_count: u16,
507 pub is_entry: bool,
509 pub importer_count: u16,
511 pub import_count: u16,
513 #[serde(skip_serializing_if = "Option::is_none")]
515 pub workspace: Option<u16>,
516 #[serde(skip_serializing_if = "Option::is_none")]
518 pub zone: Option<u16>,
519 #[serde(skip_serializing_if = "Vec::is_empty")]
521 pub unused_exports: Vec<String>,
522 pub fn_count: u16,
524 pub max_cyclomatic: u16,
526 pub max_cognitive: u16,
528 pub react_hooks: u16,
530 pub jsx_depth: u16,
532 #[serde(skip_serializing_if = "Vec::is_empty")]
534 pub functions: Vec<VizFunction>,
535 pub dup_lines: u32,
537 #[serde(skip_serializing_if = "Vec::is_empty")]
539 pub clone_groups: Vec<u32>,
540 pub in_cycle: bool,
542}
543
544#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
546#[serde(rename_all = "camelCase")]
547pub enum VizFileStatus {
548 Clean,
550 HasUnusedExports,
552 Unused,
554 EntryPoint,
556}
557
558#[derive(Serialize)]
560pub struct VizFunction {
561 name: String,
563 line: u32,
565 cyclomatic: u16,
567 cognitive: u16,
569 lines: u32,
571 hooks: u16,
573 jsx_depth: u16,
575 props: u16,
577}
578
579#[derive(Serialize)]
581pub struct VizSummary {
582 pub total_files: usize,
584 pub total_size: u64,
586 pub total_edges: usize,
588 pub unused_files: usize,
590 pub unused_exports: usize,
592 pub unused_types: usize,
594 pub unused_deps: usize,
596 pub unresolved_imports: usize,
598 pub circular_deps: usize,
600 pub clone_groups: usize,
602 pub duplicated_lines: usize,
604 pub boundary_violations: usize,
606 pub hotspot_files: usize,
608 #[serde(skip_serializing_if = "Option::is_none")]
611 pub clone_groups_truncated: Option<u32>,
612}
613
614#[derive(Serialize)]
616pub struct VizWorkspace {
617 name: String,
619 root: String,
621}
622
623#[derive(Serialize)]
625pub struct VizZone {
626 name: String,
628 files: u32,
630}
631
632#[derive(Serialize)]
634pub struct VizCloneGroup {
635 lines: usize,
637 tokens: usize,
639 instances: Vec<VizCloneInstance>,
641 preview: String,
645 highlight_start: u32,
648 highlight_lines: u32,
652}
653
654#[derive(Serialize)]
656pub struct VizCloneInstance {
657 file: u32,
659 start_line: u32,
661 end_line: u32,
663}
664
665#[derive(Serialize)]
667pub struct VizViolation {
668 from: u32,
670 to: u32,
672 from_zone: u16,
674 to_zone: u16,
676 line: u32,
678 specifier: String,
680}
681
682#[must_use]
684pub fn build_viz_data(input: &VizBuildInput<'_>) -> VizData {
685 let root = &input.config.root;
686 let index = FileIndex::new(input.files);
687 let workspaces = build_workspaces(input.workspaces, root);
688 let (zones, zone_by_file) = classify_zones(input, &index);
689 let (clones, clone_groups_by_file, dup_lines_by_file, clone_groups_truncated) =
690 build_clones(input.duplication, &index, MAX_CLONE_GROUPS);
691 let cycles = build_cycles(input.results, &index);
692 let violations = build_violations(input.results, &zones, &index);
693 let (architecture, dependencies, security, frameworks, feature_flags) =
694 if input.include_analysis_details {
695 (
696 build_architecture(input.results, &index, root),
697 build_dependencies(input.results, &index, root),
698 build_security(input.results, &index, root),
699 build_frameworks(input.results, &index, root),
700 build_feature_flags(input.feature_flags, &index, root),
701 )
702 } else {
703 skipped_analysis_details()
704 };
705
706 let files = build_files(
707 input,
708 &index,
709 &FilePropertyMaps {
710 zone_by_file: &zone_by_file,
711 clone_groups_by_file: &clone_groups_by_file,
712 dup_lines_by_file: &dup_lines_by_file,
713 cycles: &cycles,
714 },
715 );
716
717 let summary = build_summary(
718 input,
719 &files,
720 &clones,
721 &cycles,
722 &violations,
723 clone_groups_truncated,
724 );
725
726 VizData {
727 root: display_root(root),
728 files,
729 edges: build_edges(input.graph, &index),
730 summary,
731 workspaces,
732 zones,
733 cycles,
734 clones,
735 violations,
736 architecture,
737 dependencies,
738 health: VizHealthData {
739 availability: VizAvailability::unavailable("files", "Health analysis did not complete"),
740 capabilities: unavailable_health_capabilities("Health analysis did not complete"),
741 shared_parse: None,
742 score: None,
743 grade: None,
744 average_maintainability: None,
745 files: Vec::new(),
746 files_truncated: None,
747 findings_truncated: None,
748 findings: Vec::new(),
749 },
750 security,
751 frameworks,
752 styling: VizStylingData {
753 availability: VizAvailability::unavailable(
754 "findings",
755 "Styling analysis did not complete",
756 ),
757 findings_truncated: None,
758 findings: Vec::new(),
759 score: None,
760 grade: None,
761 confidence: None,
762 summary: None,
763 },
764 feature_flags,
765 }
766}
767
768fn skipped_analysis_details() -> (
769 VizFindingAnalysis,
770 VizFindingAnalysis,
771 VizSecurityData,
772 VizFrameworkData,
773 VizFindingAnalysis,
774) {
775 let skipped = |unit| VizFindingAnalysis {
776 availability: VizAvailability::disabled(unit, "Not needed for this Viz output format"),
777 findings_truncated: None,
778 findings: Vec::new(),
779 };
780 (
781 skipped("violations"),
782 skipped("findings"),
783 VizSecurityData {
784 availability: VizAvailability::disabled(
785 "candidates",
786 "Not needed for this Viz output format",
787 ),
788 runtime_availability: VizAvailability::disabled(
789 "observations",
790 "Not needed for this Viz output format",
791 ),
792 candidates: Vec::new(),
793 blind_spot_count: 0,
794 blind_spots_truncated: None,
795 blind_spots: Vec::new(),
796 },
797 VizFrameworkData {
798 availability: VizAvailability::disabled(
799 "findings",
800 "Not needed for this Viz output format",
801 ),
802 detector_availability: VizAvailability::disabled(
803 "detectors",
804 "Not needed for this Viz output format",
805 ),
806 findings_truncated: None,
807 findings: Vec::new(),
808 detected_frameworks: Vec::new(),
809 detectors: Vec::new(),
810 },
811 skipped("flags"),
812 )
813}
814
815struct FileIndex<'a> {
817 ordered: Vec<&'a DiscoveredFile>,
818 by_path: FxHashMap<&'a Path, u32>,
819 by_file_id: FxHashMap<u32, u32>,
820}
821
822impl<'a> FileIndex<'a> {
823 fn new(files: &'a [DiscoveredFile]) -> Self {
824 let mut ordered: Vec<&DiscoveredFile> = files.iter().collect();
825 ordered.sort_by_key(|f| f.id.0);
826 let mut by_path = FxHashMap::default();
827 let mut by_file_id = FxHashMap::default();
828 for (i, f) in ordered.iter().enumerate() {
829 let idx = clamp_u32(i);
830 by_path.insert(f.path.as_path(), idx);
831 by_file_id.insert(f.id.0, idx);
832 }
833 Self {
834 ordered,
835 by_path,
836 by_file_id,
837 }
838 }
839
840 fn index_of_path(&self, path: &Path) -> Option<u32> {
841 self.by_path.get(path).copied()
842 }
843
844 fn index_of_file_id(&self, file_id: u32) -> Option<u32> {
845 self.by_file_id.get(&file_id).copied()
846 }
847}
848
849fn analysis_from_records(
850 mut findings: Vec<VizFinding>,
851 total_findings: usize,
852 primary_count: usize,
853 unit: &'static str,
854) -> VizFindingAnalysis {
855 let findings_truncated = (total_findings > MAX_ANALYSIS_FINDINGS)
856 .then_some(total_findings.saturating_sub(MAX_ANALYSIS_FINDINGS));
857 let primary_truncated = (primary_count > MAX_ANALYSIS_FINDINGS)
858 .then_some(primary_count.saturating_sub(MAX_ANALYSIS_FINDINGS));
859 findings.truncate(MAX_ANALYSIS_FINDINGS);
860 VizFindingAnalysis {
861 availability: VizAvailability::complete(primary_count, unit, primary_truncated),
862 findings_truncated,
863 findings,
864 }
865}
866
867fn push_findings<T: Serialize>(
868 out: &mut Vec<VizFinding>,
869 kind: &str,
870 title: &str,
871 values: &[T],
872 root: &Path,
873 index: &FileIndex<'_>,
874) {
875 let remaining = MAX_ANALYSIS_FINDINGS.saturating_sub(out.len());
876 out.extend(values.iter().take(remaining).filter_map(|value| {
877 serde_json::to_value(value).ok().map(|detail| {
878 finding_from_value(kind, title, detail, root, &|path| index.index_of_path(path))
879 })
880 }));
881}
882
883fn finding_from_value(
884 kind: &str,
885 title: &str,
886 mut detail: Value,
887 root: &Path,
888 resolve_file: &dyn Fn(&Path) -> Option<u32>,
889) -> VizFinding {
890 let raw_path = find_string_key(&detail, &["path", "from_path", "consumer_path", "file"])
891 .map(str::to_owned);
892 let mut raw_paths = Vec::new();
893 collect_path_values(&detail, &mut raw_paths);
894 let mut paths = Vec::new();
895 let mut files = Vec::new();
896 for raw in raw_paths {
897 let raw_path = Path::new(&raw);
898 let absolute_path = if raw_path.has_root() {
902 raw_path.to_path_buf()
903 } else {
904 root.join(raw_path)
905 };
906 let display = relative_path(&absolute_path, root);
907 if !paths.contains(&display) {
908 paths.push(display);
909 }
910 if let Some(file) = resolve_file(&absolute_path)
911 && !files.contains(&file)
912 {
913 files.push(file);
914 }
915 }
916 let absolute = raw_path.as_deref().map(Path::new).map(|path| {
917 if path.has_root() {
918 path.to_path_buf()
919 } else {
920 root.join(path)
921 }
922 });
923 let file = absolute.as_deref().and_then(resolve_file);
924 let path = absolute.as_deref().map(|path| relative_path(path, root));
925 let line = find_u64_key(&detail, &["line", "start_line"])
926 .map(|value| u32::try_from(value).unwrap_or(u32::MAX));
927 relativize_value_paths(&mut detail, root);
928 let description =
929 find_string_key(&detail, &["message", "evidence", "reason"]).map(str::to_owned);
930 let severity = find_string_key(&detail, &["severity"]).map(str::to_owned);
931 let facts = finding_facts(&detail);
932 let actions = finding_actions(&detail);
933 VizFinding {
934 kind: kind.to_string(),
935 title: title.to_string(),
936 file,
937 path,
938 line,
939 files,
940 paths,
941 description,
942 severity,
943 facts,
944 actions,
945 }
946}
947
948fn finding_facts(detail: &Value) -> Vec<VizFindingFact> {
949 const EXCLUDED: &[&str] = &[
950 "path",
951 "from_path",
952 "to_path",
953 "consumer_path",
954 "file",
955 "files",
956 "paths",
957 "line",
958 "start_line",
959 "message",
960 "evidence",
961 "reason",
962 "severity",
963 "actions",
964 ];
965 let Some(fields) = detail.as_object() else {
966 return Vec::new();
967 };
968 fields
969 .iter()
970 .filter(|(label, _)| !EXCLUDED.contains(&label.as_str()))
971 .filter_map(|(label, value)| {
972 scalar_fact_value(value).map(|value| VizFindingFact {
973 label: label.clone(),
974 value,
975 })
976 })
977 .take(12)
978 .collect()
979}
980
981fn scalar_fact_value(value: &Value) -> Option<String> {
982 match value {
983 Value::String(value) => Some(value.clone()),
984 Value::Number(value) => Some(value.to_string()),
985 Value::Bool(value) => Some(value.to_string()),
986 Value::Array(values) if values.iter().all(Value::is_string) => Some(
987 values
988 .iter()
989 .filter_map(Value::as_str)
990 .collect::<Vec<_>>()
991 .join(", "),
992 ),
993 Value::Null | Value::Array(_) | Value::Object(_) => None,
994 }
995}
996
997fn finding_actions(detail: &Value) -> Vec<VizFindingAction> {
998 let mut actions = Vec::new();
999 if let Some(record) = detail.as_object() {
1000 for key in ["verify_command", "trace_command", "command"] {
1001 if let Some(command) = record.get(key).and_then(Value::as_str) {
1002 actions.push(VizFindingAction {
1003 label: "Verify".to_string(),
1004 kind: None,
1005 auto_fixable: false,
1006 command: Some(command.to_string()),
1007 comment: None,
1008 config_key: None,
1009 value: None,
1010 description: None,
1011 });
1012 }
1013 }
1014 if let Some(value) = record.get("actions") {
1015 append_projected_actions(&mut actions, value);
1016 }
1017 }
1018 actions
1019}
1020
1021fn append_projected_actions(actions: &mut Vec<VizFindingAction>, value: &Value) {
1022 match value {
1023 Value::Array(values) => {
1024 for value in values {
1025 if let Some(action) = projected_action(value) {
1026 actions.push(action);
1027 }
1028 }
1029 }
1030 Value::Object(values) => {
1031 for (label, value) in values {
1032 if let Some(command) = value.as_str() {
1033 actions.push(VizFindingAction {
1034 label: label.clone(),
1035 kind: Some(label.clone()),
1036 auto_fixable: false,
1037 command: Some(command.to_string()),
1038 comment: None,
1039 config_key: None,
1040 value: None,
1041 description: None,
1042 });
1043 }
1044 }
1045 }
1046 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
1047 }
1048}
1049
1050fn projected_action(value: &Value) -> Option<VizFindingAction> {
1051 let action = value.as_object()?;
1052 let kind = ["kind", "type"]
1053 .iter()
1054 .find_map(|key| action.get(*key).and_then(Value::as_str))
1055 .map(str::to_owned);
1056 let label = ["label", "title"]
1057 .iter()
1058 .find_map(|key| action.get(*key).and_then(Value::as_str))
1059 .map(str::to_owned)
1060 .or_else(|| kind.clone())
1061 .unwrap_or_else(|| "Review".to_string());
1062 let command = action
1063 .get("command")
1064 .and_then(Value::as_str)
1065 .map(str::to_owned);
1066 let comment = action
1067 .get("comment")
1068 .and_then(Value::as_str)
1069 .map(str::to_owned);
1070 let auto_fixable = action
1071 .get("auto_fixable")
1072 .and_then(Value::as_bool)
1073 .unwrap_or(false);
1074 let config_key = action
1075 .get("config_key")
1076 .and_then(Value::as_str)
1077 .map(str::to_owned);
1078 let projected_value = action.get("value").cloned();
1079 let description = ["description", "note"]
1080 .iter()
1081 .find_map(|key| action.get(*key).and_then(Value::as_str))
1082 .map(str::to_owned);
1083 (command.is_some()
1084 || comment.is_some()
1085 || description.is_some()
1086 || config_key.is_some()
1087 || projected_value.is_some())
1088 .then_some(VizFindingAction {
1089 label,
1090 kind,
1091 auto_fixable,
1092 command,
1093 comment,
1094 config_key,
1095 value: projected_value,
1096 description,
1097 })
1098}
1099
1100fn collect_path_values(value: &Value, out: &mut Vec<String>) {
1101 match value {
1102 Value::Object(map) => {
1103 for (key, value) in map {
1104 if is_path_key(key)
1105 && let Some(path) = value.as_str()
1106 {
1107 out.push(path.to_string());
1108 }
1109 if is_path_collection_key(key)
1110 && let Some(values) = value.as_array()
1111 {
1112 out.extend(values.iter().filter_map(Value::as_str).map(str::to_string));
1113 }
1114 collect_path_values(value, out);
1115 }
1116 }
1117 Value::Array(values) => {
1118 for value in values {
1119 collect_path_values(value, out);
1120 }
1121 }
1122 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
1123 }
1124}
1125
1126fn find_string_key<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a str> {
1127 match value {
1128 Value::Object(map) => {
1129 for key in keys {
1130 if let Some(value) = map.get(*key).and_then(Value::as_str) {
1131 return Some(value);
1132 }
1133 }
1134 map.values().find_map(|value| find_string_key(value, keys))
1135 }
1136 Value::Array(values) => values.iter().find_map(|value| find_string_key(value, keys)),
1137 _ => None,
1138 }
1139}
1140
1141fn find_u64_key(value: &Value, keys: &[&str]) -> Option<u64> {
1142 match value {
1143 Value::Object(map) => {
1144 for key in keys {
1145 if let Some(value) = map.get(*key).and_then(Value::as_u64) {
1146 return Some(value);
1147 }
1148 }
1149 map.values().find_map(|value| find_u64_key(value, keys))
1150 }
1151 Value::Array(values) => values.iter().find_map(|value| find_u64_key(value, keys)),
1152 _ => None,
1153 }
1154}
1155
1156fn relativize_value_paths(value: &mut Value, root: &Path) {
1157 relativize_keyed_paths(value, root, false);
1158}
1159
1160fn relativize_keyed_paths(value: &mut Value, root: &Path, is_path: bool) {
1161 match value {
1162 Value::String(text) if is_path => {
1163 let path = Path::new(text);
1164 if path.has_root() {
1170 *text = relative_path(path, root);
1171 }
1172 }
1173 Value::Array(values) => {
1174 for value in values {
1175 relativize_keyed_paths(value, root, is_path);
1176 }
1177 }
1178 Value::Object(map) => {
1179 for (key, value) in map {
1180 let is_path = is_path_key(key) || is_path_collection_key(key);
1181 relativize_keyed_paths(value, root, is_path);
1182 }
1183 }
1184 Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {}
1185 }
1186}
1187
1188fn is_path_key(key: &str) -> bool {
1189 matches!(
1190 key,
1191 "path"
1192 | "file"
1193 | "from_path"
1194 | "to_path"
1195 | "consumer_path"
1196 | "source_path"
1197 | "definition_path"
1198 | "template_path"
1199 | "inherited_from"
1200 | "reachable_via"
1201 | "new_path"
1202 | "old_path"
1203 | "cycle_path"
1204 | "docs_path"
1205 | "meta_docs_path"
1206 | "full_report_path"
1207 )
1208}
1209
1210fn is_path_collection_key(key: &str) -> bool {
1211 matches!(
1212 key,
1213 "files"
1214 | "paths"
1215 | "conflicting_paths"
1216 | "used_in_workspaces"
1217 | "hardcoded_consumers"
1218 | "hot_paths"
1219 )
1220}
1221
1222fn serialized_label<T: Serialize>(value: &T) -> String {
1223 serde_json::to_value(value)
1224 .ok()
1225 .and_then(|value| value.as_str().map(str::to_owned))
1226 .unwrap_or_else(|| "unknown".to_string())
1227}
1228
1229fn build_architecture(
1230 results: &AnalysisResults,
1231 index: &FileIndex<'_>,
1232 root: &Path,
1233) -> VizFindingAnalysis {
1234 let mut findings = Vec::new();
1235 push_findings(
1236 &mut findings,
1237 "boundary-violation",
1238 "Forbidden import",
1239 &results.boundary_violations,
1240 root,
1241 index,
1242 );
1243 push_findings(
1244 &mut findings,
1245 "boundary-coverage",
1246 "File outside architecture zones",
1247 &results.boundary_coverage_violations,
1248 root,
1249 index,
1250 );
1251 push_findings(
1252 &mut findings,
1253 "boundary-call",
1254 "Forbidden call",
1255 &results.boundary_call_violations,
1256 root,
1257 index,
1258 );
1259 push_findings(
1260 &mut findings,
1261 "policy-violation",
1262 "Policy violation",
1263 &results.policy_violations,
1264 root,
1265 index,
1266 );
1267 push_findings(
1268 &mut findings,
1269 "circular-dependency",
1270 "Import cycle",
1271 &results.circular_dependencies,
1272 root,
1273 index,
1274 );
1275 push_findings(
1276 &mut findings,
1277 "re-export-cycle",
1278 "Re-export cycle",
1279 &results.re_export_cycles,
1280 root,
1281 index,
1282 );
1283 let violation_count = results.boundary_violations.len()
1284 + results.boundary_coverage_violations.len()
1285 + results.boundary_call_violations.len()
1286 + results.policy_violations.len();
1287 let total_findings =
1288 violation_count + results.circular_dependencies.len() + results.re_export_cycles.len();
1289 analysis_from_records(findings, total_findings, violation_count, "violations")
1290}
1291
1292fn build_dependencies(
1293 results: &AnalysisResults,
1294 index: &FileIndex<'_>,
1295 root: &Path,
1296) -> VizFindingAnalysis {
1297 let mut findings = Vec::new();
1298 let mut count = 0;
1299 macro_rules! add {
1300 ($field:ident, $kind:literal, $title:literal) => {
1301 count += results.$field.len();
1302 push_findings(&mut findings, $kind, $title, &results.$field, root, index);
1303 };
1304 }
1305 add!(unresolved_imports, "unresolved-import", "Unresolved import");
1306 add!(
1307 unlisted_dependencies,
1308 "unlisted-dependency",
1309 "Unlisted dependency"
1310 );
1311 add!(
1312 type_only_dependencies,
1313 "type-only-dependency",
1314 "Type-only dependency"
1315 );
1316 add!(
1317 test_only_dependencies,
1318 "test-only-dependency",
1319 "Test-only dependency"
1320 );
1321 add!(
1322 dev_dependencies_in_production,
1323 "dev-dependency-in-production",
1324 "Development dependency used in production"
1325 );
1326 add!(
1327 duplicate_exports,
1328 "duplicate-export",
1329 "Duplicate public export"
1330 );
1331 add!(
1332 private_type_leaks,
1333 "private-type-leak",
1334 "Private type leaked by public API"
1335 );
1336 add!(
1337 deprecated_exports_in_use,
1338 "deprecated-export-in-use",
1339 "Deprecated export still in use"
1340 );
1341 add!(
1342 unused_catalog_entries,
1343 "unused-catalog-entry",
1344 "Unused catalog entry"
1345 );
1346 add!(
1347 empty_catalog_groups,
1348 "empty-catalog-group",
1349 "Empty catalog group"
1350 );
1351 add!(
1352 unresolved_catalog_references,
1353 "unresolved-catalog-reference",
1354 "Unresolved catalog reference"
1355 );
1356 add!(
1357 unused_dependency_overrides,
1358 "unused-dependency-override",
1359 "Unused dependency override"
1360 );
1361 add!(
1362 misconfigured_dependency_overrides,
1363 "misconfigured-dependency-override",
1364 "Misconfigured dependency override"
1365 );
1366 analysis_from_records(findings, count, count, "findings")
1367}
1368
1369fn build_frameworks(
1370 results: &AnalysisResults,
1371 index: &FileIndex<'_>,
1372 root: &Path,
1373) -> VizFrameworkData {
1374 let mut findings = Vec::new();
1375 let mut count = 0;
1376 macro_rules! add {
1377 ($field:ident, $kind:literal, $title:literal) => {
1378 count += results.$field.len();
1379 push_findings(&mut findings, $kind, $title, &results.$field, root, index);
1380 };
1381 }
1382 add!(
1383 invalid_client_exports,
1384 "invalid-client-export",
1385 "Invalid client export"
1386 );
1387 add!(
1388 mixed_client_server_barrels,
1389 "mixed-client-server-barrel",
1390 "Mixed client/server barrel"
1391 );
1392 add!(
1393 misplaced_directives,
1394 "misplaced-directive",
1395 "Misplaced framework directive"
1396 );
1397 add!(
1398 unprovided_injects,
1399 "unprovided-inject",
1400 "Injected value is never provided"
1401 );
1402 add!(
1403 unrendered_components,
1404 "unrendered-component",
1405 "Component is never rendered"
1406 );
1407 add!(route_collisions, "route-collision", "Route collision");
1408 add!(
1409 dynamic_segment_name_conflicts,
1410 "dynamic-segment-conflict",
1411 "Dynamic segment conflict"
1412 );
1413 add!(
1414 unused_component_props,
1415 "unused-component-prop",
1416 "Unused component prop"
1417 );
1418 add!(
1419 unused_component_emits,
1420 "unused-component-emit",
1421 "Unused component event"
1422 );
1423 add!(
1424 unused_component_inputs,
1425 "unused-component-input",
1426 "Unused component input"
1427 );
1428 add!(
1429 unused_component_outputs,
1430 "unused-component-output",
1431 "Unused component output"
1432 );
1433 add!(
1434 unused_svelte_events,
1435 "unused-svelte-event",
1436 "Unused Svelte event"
1437 );
1438 add!(
1439 unused_server_actions,
1440 "unused-server-action",
1441 "Unused server action"
1442 );
1443 add!(
1444 unused_load_data_keys,
1445 "unused-load-data-key",
1446 "Unused load-data key"
1447 );
1448 add!(prop_drilling_chains, "prop-drilling", "Prop-drilling chain");
1449 add!(thin_wrappers, "thin-wrapper", "Thin component wrapper");
1450 add!(
1451 duplicate_prop_shapes,
1452 "duplicate-prop-shape",
1453 "Duplicate prop shape"
1454 );
1455 let mut analysis = analysis_from_records(findings, count, count, "findings");
1456 if results.unused_load_data_keys_global_abstain {
1457 analysis.availability.reason = Some(
1458 "Load-data-key analysis abstained because whole-object page data usage was detected"
1459 .to_string(),
1460 );
1461 }
1462 VizFrameworkData {
1463 availability: analysis.availability,
1464 detector_availability: VizAvailability::unavailable(
1465 "detectors",
1466 "Framework detector coverage did not complete",
1467 ),
1468 findings_truncated: analysis.findings_truncated,
1469 findings: analysis.findings,
1470 detected_frameworks: Vec::new(),
1471 detectors: Vec::new(),
1472 }
1473}
1474
1475fn build_feature_flags(
1476 flags: &[FeatureFlag],
1477 index: &FileIndex<'_>,
1478 root: &Path,
1479) -> VizFindingAnalysis {
1480 let mut findings = Vec::new();
1481 push_findings(
1482 &mut findings,
1483 "feature-flag",
1484 "Feature flag use",
1485 flags,
1486 root,
1487 index,
1488 );
1489 analysis_from_records(findings, flags.len(), flags.len(), "flags")
1490}
1491
1492fn build_security(
1493 results: &AnalysisResults,
1494 index: &FileIndex<'_>,
1495 root: &Path,
1496) -> VizSecurityData {
1497 let total = results.security_findings.len();
1498 let truncated =
1499 (total > MAX_ANALYSIS_FINDINGS).then_some(total.saturating_sub(MAX_ANALYSIS_FINDINGS));
1500 let mut sorted_findings: Vec<&SecurityFinding> = results.security_findings.iter().collect();
1501 sorted_findings.sort_by_key(|finding| {
1502 let severity = serialized_label(&crate::security::derive_security_severity(finding));
1503 let priority = match severity.as_str() {
1504 "high" => 0,
1505 "medium" => 1,
1506 _ => 2,
1507 };
1508 (priority, relative_path(&finding.path, root), finding.line)
1509 });
1510 let candidates = sorted_findings
1511 .into_iter()
1512 .take(MAX_ANALYSIS_FINDINGS)
1513 .map(|finding| build_security_candidate(finding, index, root))
1514 .collect();
1515
1516 let mut blind_spots = Vec::new();
1517 if results.security_unresolved_edge_files > 0 {
1518 blind_spots.push(VizSecurityBlindSpot {
1519 kind: "unresolved-dynamic-imports".to_string(),
1520 count: results.security_unresolved_edge_files,
1521 path: None,
1522 file: None,
1523 line: None,
1524 reason: Some("Dynamic imports prevent complete client/server reachability".to_string()),
1525 });
1526 }
1527 if results.security_unresolved_callee_sites > 0 {
1528 blind_spots.push(VizSecurityBlindSpot {
1529 kind: "unresolved-callee-sites".to_string(),
1530 count: results.security_unresolved_callee_sites,
1531 path: None,
1532 file: None,
1533 line: None,
1534 reason: Some(
1535 "Dynamic or computed callees could not be matched to the sink catalogue"
1536 .to_string(),
1537 ),
1538 });
1539 }
1540 let diagnostic_count = results.security_unresolved_callee_diagnostics.len();
1541 for diagnostic in results
1542 .security_unresolved_callee_diagnostics
1543 .iter()
1544 .take(MAX_SECURITY_BLIND_SPOT_SAMPLES)
1545 {
1546 blind_spots.push(VizSecurityBlindSpot {
1547 kind: "unresolved-callee-sample".to_string(),
1548 count: 1,
1549 path: Some(relative_path(&diagnostic.path, root)),
1550 file: index.index_of_path(&diagnostic.path),
1551 line: Some(diagnostic.line),
1552 reason: Some(serialized_label(&diagnostic.reason)),
1553 });
1554 }
1555
1556 VizSecurityData {
1557 availability: VizAvailability::complete(total, "candidates", truncated),
1558 runtime_availability: VizAvailability::unavailable(
1559 "observations",
1560 NO_RUNTIME_COVERAGE_REASON,
1561 ),
1562 candidates,
1563 blind_spot_count: results.security_unresolved_edge_files
1564 + results.security_unresolved_callee_sites,
1565 blind_spots_truncated: (diagnostic_count > MAX_SECURITY_BLIND_SPOT_SAMPLES)
1566 .then_some(diagnostic_count.saturating_sub(MAX_SECURITY_BLIND_SPOT_SAMPLES)),
1567 blind_spots,
1568 }
1569}
1570
1571fn build_security_candidate(
1572 finding: &SecurityFinding,
1573 index: &FileIndex<'_>,
1574 root: &Path,
1575) -> VizSecurityCandidate {
1576 let kind = serialized_label(&finding.kind);
1577 let path = relative_path(&finding.path, root);
1578 let severity = serialized_label(&crate::security::derive_security_severity(finding));
1579 let id = crate::security::security_finding_id(finding, Path::new(&path));
1580 let reachability = finding.reachability.as_ref();
1581 let architecture_zone = finding
1582 .candidate
1583 .boundary
1584 .architecture_zone
1585 .as_ref()
1586 .map(|zone| format!("{} -> {}", zone.from, zone.to));
1587 let dead_code = serialize_relative(finding.dead_code.as_ref(), root);
1588 let runtime = serialize_relative(finding.runtime.as_ref(), root);
1589 let taint_flow = security_taint_flow(finding, index, root);
1590 let observed_controls = security_controls(finding, root);
1591 let actions = serialize_relative_value(&finding.actions, root)
1592 .unwrap_or_else(|| Value::Array(Vec::new()));
1593 let trace = security_trace(finding, index, root);
1594 let taint_trace = finding
1595 .reachability
1596 .as_ref()
1597 .map_or_else(Vec::new, |reachability| {
1598 trace_hops(&reachability.untrusted_source_trace, index, root)
1599 });
1600 VizSecurityCandidate {
1601 id,
1602 kind,
1603 category: finding.category.clone(),
1604 cwe: finding.cwe,
1605 file: index.index_of_path(&finding.path),
1606 path,
1607 line: finding.line,
1608 col: finding.col,
1609 evidence: finding.evidence.clone(),
1610 severity,
1611 taint_confidence: reachability
1612 .and_then(|reachability| reachability.taint_confidence.as_ref())
1613 .map(serialized_label),
1614 source_kind: finding.candidate.source_kind.clone(),
1615 sink: finding.candidate.sink.callee.clone(),
1616 url_shape: finding
1617 .candidate
1618 .sink
1619 .url_shape
1620 .as_ref()
1621 .map(serialized_label),
1622 network_destination: finding
1623 .candidate
1624 .network
1625 .as_ref()
1626 .and_then(|network| network.destination.clone()),
1627 reachable_from_entry: reachability.map(|value| value.reachable_from_entry),
1628 reachable_from_untrusted_source: reachability
1629 .map(|value| value.reachable_from_untrusted_source),
1630 blast_radius: reachability.map(|value| value.blast_radius),
1631 crosses_boundary: reachability.is_some_and(|value| value.crosses_boundary)
1632 || finding.candidate.boundary.client_server
1633 || finding.candidate.boundary.cross_module
1634 || architecture_zone.is_some(),
1635 client_server_boundary: finding.candidate.boundary.client_server,
1636 cross_module_boundary: finding.candidate.boundary.cross_module,
1637 architecture_zone,
1638 dead_code,
1639 runtime,
1640 taint_flow,
1641 observed_controls,
1642 control_verification_prompt: finding
1643 .attack_surface
1644 .as_ref()
1645 .map(|surface| surface.defensive_boundary.verification_prompt.clone()),
1646 trace,
1647 taint_trace,
1648 actions,
1649 }
1650}
1651
1652fn serialize_relative<T: Serialize>(value: Option<&T>, root: &Path) -> Option<Value> {
1653 value.and_then(|value| serialize_relative_value(value, root))
1654}
1655
1656fn serialize_relative_value<T: Serialize>(value: &T, root: &Path) -> Option<Value> {
1657 let mut serialized = serde_json::to_value(value).ok()?;
1658 relativize_value_paths(&mut serialized, root);
1659 Some(serialized)
1660}
1661
1662fn security_controls(finding: &SecurityFinding, root: &Path) -> Vec<Value> {
1663 finding
1664 .attack_surface
1665 .as_ref()
1666 .map_or_else(Vec::new, |surface| {
1667 surface
1668 .defensive_boundary
1669 .controls
1670 .iter()
1671 .filter_map(|control| serialize_relative_value(control, root))
1672 .collect()
1673 })
1674}
1675
1676fn security_trace(
1677 finding: &SecurityFinding,
1678 index: &FileIndex<'_>,
1679 root: &Path,
1680) -> Vec<VizSecurityTraceHop> {
1681 trace_hops(&finding.trace, index, root)
1682}
1683
1684fn trace_hops(
1685 hops: &[fallow_types::results::TraceHop],
1686 index: &FileIndex<'_>,
1687 root: &Path,
1688) -> Vec<VizSecurityTraceHop> {
1689 hops.iter()
1690 .map(|hop| VizSecurityTraceHop {
1691 file: index.index_of_path(&hop.path),
1692 path: relative_path(&hop.path, root),
1693 line: hop.line,
1694 col: hop.col,
1695 role: serialized_label(&hop.role),
1696 })
1697 .collect()
1698}
1699
1700fn security_taint_flow(
1701 finding: &SecurityFinding,
1702 index: &FileIndex<'_>,
1703 root: &Path,
1704) -> Option<VizSecurityTaintFlow> {
1705 let flow = finding.taint_flow.as_ref()?;
1706 let endpoint = |value: &fallow_types::results::TaintEndpoint| VizSecurityEndpoint {
1707 file: index.index_of_path(&value.path),
1708 path: relative_path(&value.path, root),
1709 line: value.line,
1710 col: value.col,
1711 };
1712 Some(VizSecurityTaintFlow {
1713 source: endpoint(&flow.source),
1714 sink: endpoint(&flow.sink),
1715 intra_module: flow.path.intra_module,
1716 cross_module_hops: flow.path.cross_module_hops,
1717 })
1718}
1719
1720pub fn apply_health_report(data: &mut VizData, report: &HealthReport, root: &Path) {
1723 let by_path: FxHashMap<String, u32> = data
1724 .files
1725 .iter()
1726 .enumerate()
1727 .map(|(index, file)| (file.path.clone(), clamp_u32(index)))
1728 .collect();
1729 apply_health_data(data, report, root, &by_path);
1730 apply_framework_data(data, report);
1731 apply_styling_data(data, report, root, &by_path);
1732}
1733
1734fn apply_health_data(
1735 data: &mut VizData,
1736 report: &HealthReport,
1737 root: &Path,
1738 by_path: &FxHashMap<String, u32>,
1739) {
1740 let resolve = |path: &Path| by_path.get(&relative_path(path, root)).copied();
1741 let hotspot_by_path: FxHashMap<String, &fallow_output::HotspotFinding> = report
1742 .hotspots
1743 .iter()
1744 .map(|hotspot| (relative_path(&hotspot.path, root), hotspot))
1745 .collect();
1746 let files = health_files(report, root, by_path, &hotspot_by_path);
1747 let (findings, total_findings) = health_findings(report, root, &resolve);
1748 let concern_count = health_concern_count(report, root, by_path);
1749 data.health = VizHealthData {
1750 availability: VizAvailability::complete(concern_count, "files", None),
1751 capabilities: health_capabilities(report),
1752 shared_parse: data.health.shared_parse,
1753 score: report.health_score.as_ref().map(|score| score.score),
1754 grade: report
1755 .health_score
1756 .as_ref()
1757 .map(|score| score.grade.to_string()),
1758 average_maintainability: report.summary.average_maintainability,
1759 files_truncated: report
1760 .file_scores
1761 .len()
1762 .checked_sub(MAX_HEALTH_FILES)
1763 .filter(|count| *count > 0),
1764 findings_truncated: total_findings
1765 .checked_sub(findings.len())
1766 .filter(|count| *count > 0),
1767 files,
1768 findings,
1769 };
1770}
1771
1772fn health_concern_count(
1773 report: &HealthReport,
1774 root: &Path,
1775 by_path: &FxHashMap<String, u32>,
1776) -> usize {
1777 let mut files = rustc_hash::FxHashSet::default();
1778 let mut add = |path: &Path| {
1779 if let Some(file) = by_path.get(&relative_path(path, root)) {
1780 files.insert(*file);
1781 }
1782 };
1783 for finding in &report.findings {
1784 add(&finding.path);
1785 }
1786 for hotspot in &report.hotspots {
1787 add(&hotspot.path);
1788 }
1789 if let Some(gaps) = &report.coverage_gaps {
1790 for finding in &gaps.files {
1791 add(&finding.file.path);
1792 }
1793 for finding in &gaps.exports {
1794 add(&finding.export.path);
1795 }
1796 }
1797 files.len()
1798}
1799
1800fn health_files(
1801 report: &HealthReport,
1802 root: &Path,
1803 by_path: &FxHashMap<String, u32>,
1804 hotspots: &FxHashMap<String, &fallow_output::HotspotFinding>,
1805) -> Vec<VizHealthFile> {
1806 report
1807 .file_scores
1808 .iter()
1809 .take(MAX_HEALTH_FILES)
1810 .filter_map(|score| {
1811 let path = relative_path(&score.path, root);
1812 let file = by_path.get(&path).copied()?;
1813 let hotspot = hotspots.get(&path).copied();
1814 Some(VizHealthFile {
1815 file,
1816 path,
1817 maintainability_index: score.maintainability_index,
1818 crap_max: score.crap_max,
1819 complexity_density: score.complexity_density,
1820 fan_in: score.fan_in,
1821 fan_out: score.fan_out,
1822 hotspot_score: hotspot.map(|entry| entry.score),
1823 commits: hotspot.map(|entry| entry.commits),
1824 ownership: hotspot
1825 .and_then(|entry| serialize_relative(entry.ownership.as_ref(), root)),
1826 })
1827 })
1828 .collect()
1829}
1830
1831fn health_findings(
1832 report: &HealthReport,
1833 root: &Path,
1834 resolve: &dyn Fn(&Path) -> Option<u32>,
1835) -> (Vec<VizFinding>, usize) {
1836 let coverage_count = report
1837 .coverage_gaps
1838 .as_ref()
1839 .map_or(0, |gaps| gaps.files.len() + gaps.exports.len());
1840 let total = report.findings.len() + report.hotspots.len() + coverage_count;
1841 let mut findings = Vec::with_capacity(total.min(MAX_ANALYSIS_FINDINGS));
1842 append_findings(
1843 &mut findings,
1844 &report.findings,
1845 "health-finding",
1846 "Health threshold exceeded",
1847 root,
1848 resolve,
1849 );
1850 append_findings(
1851 &mut findings,
1852 &report.hotspots,
1853 "git-hotspot",
1854 "Complex and frequently changed file",
1855 root,
1856 resolve,
1857 );
1858 if let Some(gaps) = &report.coverage_gaps {
1859 append_findings(
1860 &mut findings,
1861 &gaps.files,
1862 "coverage-gap-file",
1863 "File has no test path",
1864 root,
1865 resolve,
1866 );
1867 append_findings(
1868 &mut findings,
1869 &gaps.exports,
1870 "coverage-gap-export",
1871 "Export has no test path",
1872 root,
1873 resolve,
1874 );
1875 }
1876 (findings, total)
1877}
1878
1879fn append_findings<T: Serialize>(
1880 out: &mut Vec<VizFinding>,
1881 values: &[T],
1882 kind: &str,
1883 title: &str,
1884 root: &Path,
1885 resolve: &dyn Fn(&Path) -> Option<u32>,
1886) {
1887 let remaining = MAX_ANALYSIS_FINDINGS.saturating_sub(out.len());
1888 out.extend(values.iter().take(remaining).filter_map(|value| {
1889 serde_json::to_value(value)
1890 .ok()
1891 .map(|detail| finding_from_value(kind, title, detail, root, resolve))
1892 }));
1893}
1894
1895fn health_capabilities(report: &HealthReport) -> VizHealthCapabilities {
1896 let file_count = report.file_scores.len();
1897 let coverage = report.coverage_gaps.as_ref().map_or_else(
1898 || VizAvailability::unavailable("gaps", "Coverage gap analysis did not produce a result"),
1899 |gaps| VizAvailability::complete(gaps.files.len() + gaps.exports.len(), "gaps", None),
1900 );
1901 let runtime = report.runtime_coverage.as_ref().map_or_else(
1902 || VizAvailability::unavailable("observations", NO_RUNTIME_COVERAGE_REASON),
1903 |runtime| {
1904 VizAvailability::complete(runtime.summary.functions_tracked, "observations", None)
1905 },
1906 );
1907 VizHealthCapabilities {
1908 complexity: VizAvailability::complete(report.findings.len(), "findings", None),
1909 maintainability: VizAvailability::complete(file_count, "files", None),
1910 crap: VizAvailability::complete(file_count, "files", None),
1911 coverage,
1912 runtime,
1913 churn: VizAvailability::disabled("files", "Git history is not loaded by Viz"),
1914 hotspots: VizAvailability::disabled("files", "Git history is not loaded by Viz"),
1915 ownership: VizAvailability::disabled("files", "Git history is not loaded by Viz"),
1916 }
1917}
1918
1919fn unavailable_health_capabilities(reason: &str) -> VizHealthCapabilities {
1920 VizHealthCapabilities {
1921 complexity: VizAvailability::unavailable("findings", reason),
1922 maintainability: VizAvailability::unavailable("files", reason),
1923 crap: VizAvailability::unavailable("files", reason),
1924 coverage: VizAvailability::unavailable("gaps", reason),
1925 runtime: VizAvailability::unavailable("observations", NO_RUNTIME_COVERAGE_REASON),
1926 churn: VizAvailability::unavailable("files", reason),
1927 hotspots: VizAvailability::unavailable("files", reason),
1928 ownership: VizAvailability::unavailable("files", reason),
1929 }
1930}
1931
1932fn apply_framework_data(data: &mut VizData, report: &HealthReport) {
1933 let count = data.frameworks.availability.count;
1934 let Some(diagnostics) = &report.framework_health else {
1935 if count == 0 {
1936 data.frameworks.detector_availability = VizAvailability {
1937 state: VizAvailabilityState::NotApplicable,
1938 count: 0,
1939 unit: "detectors",
1940 reason: Some("No supported framework was detected".to_string()),
1941 truncated: None,
1942 };
1943 data.frameworks.availability.state = VizAvailabilityState::NotApplicable;
1944 data.frameworks.availability.reason =
1945 Some("No supported framework was detected".to_string());
1946 } else {
1947 data.frameworks.detector_availability = VizAvailability::unavailable(
1948 "detectors",
1949 "Framework detector metadata was not produced",
1950 );
1951 }
1952 return;
1953 };
1954 data.frameworks
1955 .detected_frameworks
1956 .clone_from(&diagnostics.detected_frameworks);
1957 data.frameworks.detectors = diagnostics
1958 .detectors
1959 .iter()
1960 .map(|detector| VizFrameworkDetector {
1961 id: detector.id.clone(),
1962 framework: detector.framework.clone(),
1963 status: serialized_label(&detector.status),
1964 reason: detector.reason.clone(),
1965 })
1966 .collect();
1967 data.frameworks.detector_availability =
1968 VizAvailability::complete(diagnostics.detectors.len(), "detectors", None);
1969 if diagnostics.detected_frameworks.is_empty() && count == 0 {
1970 data.frameworks.availability.state = VizAvailabilityState::NotApplicable;
1971 data.frameworks.availability.reason =
1972 Some("No supported framework was detected".to_string());
1973 data.frameworks.detector_availability.state = VizAvailabilityState::NotApplicable;
1974 data.frameworks.detector_availability.reason =
1975 Some("No supported framework was detected".to_string());
1976 }
1977}
1978
1979fn apply_styling_data(
1980 data: &mut VizData,
1981 report: &HealthReport,
1982 root: &Path,
1983 by_path: &FxHashMap<String, u32>,
1984) {
1985 let resolve = |path: &Path| by_path.get(&relative_path(path, root)).copied();
1986 let count = report.styling_findings.len();
1987 let mut findings = Vec::with_capacity(count.min(MAX_ANALYSIS_FINDINGS));
1988 append_findings(
1989 &mut findings,
1990 &report.styling_findings,
1991 "styling-finding",
1992 "Styling health finding",
1993 root,
1994 &resolve,
1995 );
1996 let truncated = count.checked_sub(findings.len()).filter(|value| *value > 0);
1997 let styling = report.styling_health.as_ref();
1998 data.styling = VizStylingData {
1999 availability: report.css_analytics.as_ref().map_or_else(
2000 || VizAvailability {
2001 state: VizAvailabilityState::NotApplicable,
2002 count: 0,
2003 unit: "findings",
2004 reason: Some("No supported CSS or component styling was detected".to_string()),
2005 truncated: None,
2006 },
2007 |_| VizAvailability::complete(count, "findings", truncated),
2008 ),
2009 findings_truncated: truncated,
2010 findings,
2011 score: styling.map(|health| health.score),
2012 grade: styling.map(|health| health.grade.to_string()),
2013 confidence: styling.map(|health| serialized_label(&health.confidence)),
2014 summary: report
2015 .css_analytics
2016 .as_ref()
2017 .and_then(|analytics| serde_json::to_value(&analytics.summary).ok()),
2018 };
2019}
2020
2021struct FilePropertyMaps<'a> {
2023 zone_by_file: &'a FxHashMap<u32, u16>,
2024 clone_groups_by_file: &'a FxHashMap<u32, Vec<u32>>,
2025 dup_lines_by_file: &'a FxHashMap<u32, u32>,
2026 cycles: &'a [Vec<u32>],
2027}
2028
2029fn display_root(root: &Path) -> String {
2030 root.file_name().map_or_else(
2031 || root.to_string_lossy().into_owned(),
2032 |n| n.to_string_lossy().into_owned(),
2033 )
2034}
2035
2036fn relative_path(path: &Path, root: &Path) -> String {
2037 if let Ok(relative) = path.strip_prefix(root) {
2038 return relative.to_string_lossy().replace('\\', "/");
2039 }
2040 if path.has_root() {
2045 let name = path
2046 .file_name()
2047 .map_or_else(|| "path".into(), |name| name.to_string_lossy());
2048 return format!("<external>/{name}");
2049 }
2050 path.to_string_lossy().replace('\\', "/")
2051}
2052
2053fn build_workspaces(workspaces: &[WorkspaceInfo], root: &Path) -> Vec<VizWorkspace> {
2054 workspaces
2055 .iter()
2056 .map(|ws| VizWorkspace {
2057 name: ws.name.clone(),
2058 root: relative_path(&ws.root, root),
2059 })
2060 .collect()
2061}
2062
2063fn workspace_index_for(path: &Path, workspaces: &[WorkspaceInfo]) -> Option<u16> {
2064 let mut best: Option<(usize, usize)> = None;
2065 for (i, ws) in workspaces.iter().enumerate() {
2066 if path.starts_with(&ws.root) {
2067 let depth = ws.root.components().count();
2068 if best.is_none_or(|(_, d)| depth > d) {
2069 best = Some((i, depth));
2070 }
2071 }
2072 }
2073 best.map(|(i, _)| clamp_u16(i))
2074}
2075
2076fn classify_zones(
2077 input: &VizBuildInput<'_>,
2078 index: &FileIndex<'_>,
2079) -> (Vec<VizZone>, FxHashMap<u32, u16>) {
2080 let boundaries = &input.config.boundaries;
2081 let mut zones: Vec<VizZone> = boundaries
2082 .zones
2083 .iter()
2084 .map(|z| VizZone {
2085 name: z.name.clone(),
2086 files: 0,
2087 })
2088 .collect();
2089 let name_to_index: FxHashMap<&str, u16> = boundaries
2090 .zones
2091 .iter()
2092 .enumerate()
2093 .map(|(i, z)| (z.name.as_str(), clamp_u16(i)))
2094 .collect();
2095
2096 let mut zone_by_file = FxHashMap::default();
2097 if zones.is_empty() {
2098 return (zones, zone_by_file);
2099 }
2100
2101 for (i, file) in index.ordered.iter().enumerate() {
2102 let rel = relative_path(&file.path, &input.config.root);
2103 if let Some(zone_name) = boundaries.classify_zone(&rel)
2104 && let Some(&zone_idx) = name_to_index.get(zone_name)
2105 {
2106 zone_by_file.insert(clamp_u32(i), zone_idx);
2107 zones[zone_idx as usize].files += 1;
2108 }
2109 }
2110
2111 (zones, zone_by_file)
2112}
2113
2114type CloneMaps = (
2117 Vec<VizCloneGroup>,
2118 FxHashMap<u32, Vec<u32>>,
2119 FxHashMap<u32, u32>,
2120 u32,
2121);
2122
2123fn build_clones(
2124 duplication: &DuplicationReport,
2125 index: &FileIndex<'_>,
2126 max_groups: usize,
2127) -> CloneMaps {
2128 let mut clones = Vec::new();
2129 let mut groups_by_file: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
2130 let mut dup_lines_by_file: FxHashMap<u32, u32> = FxHashMap::default();
2131 let mut truncated: usize = 0;
2132
2133 for group in &duplication.clone_groups {
2134 let instances: Vec<VizCloneInstance> = group
2135 .instances
2136 .iter()
2137 .filter_map(|inst| {
2138 index
2139 .index_of_path(&inst.file)
2140 .map(|file| VizCloneInstance {
2141 file,
2142 start_line: clamp_u32(inst.start_line),
2143 end_line: clamp_u32(inst.end_line),
2144 })
2145 })
2146 .collect();
2147 if instances.len() < 2 {
2148 continue;
2149 }
2150 if clones.len() >= max_groups {
2151 truncated += 1;
2152 continue;
2153 }
2154
2155 let group_idx = clamp_u32(clones.len());
2156 for inst in &instances {
2157 let entry = groups_by_file.entry(inst.file).or_default();
2158 if entry.last() != Some(&group_idx) {
2159 entry.push(group_idx);
2160 }
2161 *dup_lines_by_file.entry(inst.file).or_default() +=
2162 inst.end_line.saturating_sub(inst.start_line) + 1;
2163 }
2164
2165 let (preview, highlight_start, highlight_lines) = group
2166 .instances
2167 .first()
2168 .map(build_clone_preview)
2169 .unwrap_or_default();
2170
2171 clones.push(VizCloneGroup {
2172 lines: group.line_count,
2173 tokens: group.token_count,
2174 instances,
2175 preview,
2176 highlight_start,
2177 highlight_lines,
2178 });
2179 }
2180
2181 (
2182 clones,
2183 groups_by_file,
2184 dup_lines_by_file,
2185 clamp_u32(truncated),
2186 )
2187}
2188
2189fn truncate_preview(fragment: &str) -> String {
2190 let mut out = String::new();
2191 for (i, line) in fragment.lines().enumerate() {
2192 if i >= CLONE_PREVIEW_MAX_LINES || out.len() + line.len() > CLONE_PREVIEW_MAX_BYTES {
2193 out.push('\u{2026}');
2194 break;
2195 }
2196 if i > 0 {
2197 out.push('\n');
2198 }
2199 out.push_str(line);
2200 }
2201 out
2202}
2203
2204fn build_clone_preview(inst: &CloneInstance) -> (String, u32, u32) {
2214 let Ok(source) = std::fs::read_to_string(&inst.file) else {
2215 return fragment_fallback(&inst.fragment);
2216 };
2217 let lines: Vec<&str> = source.lines().collect();
2218 let total = lines.len();
2219 if total == 0 || inst.start_line == 0 || inst.start_line > total {
2220 return fragment_fallback(&inst.fragment);
2221 }
2222
2223 let block_start = inst.start_line - 1;
2226 let block_end = inst.end_line.min(total).max(inst.start_line);
2227 let mut block_lines = block_end - block_start;
2228 let mut before = block_start.min(CLONE_PREVIEW_CONTEXT);
2229 let mut after = (total - block_end).min(CLONE_PREVIEW_CONTEXT);
2230
2231 if before + block_lines + after > CLONE_PREVIEW_MAX_LINES {
2236 if before + block_lines >= CLONE_PREVIEW_MAX_LINES {
2237 after = 0;
2238 block_lines = CLONE_PREVIEW_MAX_LINES.saturating_sub(before).max(1);
2239 } else {
2240 trim_context(
2241 &mut before,
2242 &mut after,
2243 CLONE_PREVIEW_MAX_LINES - block_lines,
2244 );
2245 }
2246 }
2247
2248 enforce_byte_cap(
2249 &lines,
2250 block_start,
2251 &mut before,
2252 &mut after,
2253 &mut block_lines,
2254 );
2255
2256 let win_start = block_start - before;
2257 let win_end = win_start + before + block_lines + after;
2258 let preview = lines[win_start..win_end].join("\n");
2259 (preview, clamp_u32(before), clamp_u32(block_lines))
2260}
2261
2262fn fragment_fallback(fragment: &str) -> (String, u32, u32) {
2265 let preview = truncate_preview(fragment);
2266 let highlight_lines = if preview.is_empty() {
2267 0
2268 } else {
2269 preview.lines().count()
2270 };
2271 (preview, 0, clamp_u32(highlight_lines))
2272}
2273
2274fn trim_context(before: &mut usize, after: &mut usize, budget: usize) {
2278 while *before + *after > budget {
2279 if *before >= *after {
2280 *before -= 1;
2281 } else {
2282 *after -= 1;
2283 }
2284 }
2285}
2286
2287fn enforce_byte_cap(
2292 lines: &[&str],
2293 block_start: usize,
2294 before: &mut usize,
2295 after: &mut usize,
2296 block_lines: &mut usize,
2297) {
2298 let window_bytes = |before: usize, after: usize, block_lines: usize| -> usize {
2299 let start = block_start - before;
2300 let end = start + before + block_lines + after;
2301 let separators = (end - start).saturating_sub(1);
2302 lines[start..end].iter().map(|l| l.len()).sum::<usize>() + separators
2303 };
2304 while window_bytes(*before, *after, *block_lines) > CLONE_PREVIEW_MAX_BYTES {
2305 if *before + *after > 0 {
2306 if *before >= *after {
2307 *before -= 1;
2308 } else {
2309 *after -= 1;
2310 }
2311 } else if *block_lines > 1 {
2312 *block_lines -= 1;
2313 } else {
2314 break;
2315 }
2316 }
2317}
2318
2319fn build_cycles(results: &AnalysisResults, index: &FileIndex<'_>) -> Vec<Vec<u32>> {
2320 results
2321 .circular_dependencies
2322 .iter()
2323 .filter_map(|cd| {
2324 let ids: Vec<u32> = cd
2325 .cycle
2326 .files
2327 .iter()
2328 .filter_map(|p| index.index_of_path(p))
2329 .collect();
2330 (ids.len() == cd.cycle.files.len()).then_some(ids)
2331 })
2332 .collect()
2333}
2334
2335fn build_violations(
2336 results: &AnalysisResults,
2337 zones: &[VizZone],
2338 index: &FileIndex<'_>,
2339) -> Vec<VizViolation> {
2340 let name_to_index: FxHashMap<&str, u16> = zones
2341 .iter()
2342 .enumerate()
2343 .map(|(i, z)| (z.name.as_str(), clamp_u16(i)))
2344 .collect();
2345
2346 results
2347 .boundary_violations
2348 .iter()
2349 .filter_map(|finding| {
2350 let v = &finding.violation;
2351 let from = index.index_of_path(&v.from_path)?;
2352 let to = index.index_of_path(&v.to_path)?;
2353 let from_zone = *name_to_index.get(v.from_zone.as_str())?;
2354 let to_zone = *name_to_index.get(v.to_zone.as_str())?;
2355 Some(VizViolation {
2356 from,
2357 to,
2358 from_zone,
2359 to_zone,
2360 line: v.line,
2361 specifier: v.import_specifier.clone(),
2362 })
2363 })
2364 .collect()
2365}
2366
2367fn build_edges(graph: &RetainedModuleGraph, index: &FileIndex<'_>) -> Vec<[u32; 3]> {
2368 let graph = graph.as_graph();
2369 let mut edges = Vec::with_capacity(graph.edge_count());
2370 for node in &graph.modules {
2371 let Some(source) = index.index_of_file_id(node.file_id.0) else {
2372 continue;
2373 };
2374 for (target_id, all_type_only, _span) in graph.outgoing_edge_summaries(node.file_id) {
2375 let Some(target) = index.index_of_file_id(target_id.0) else {
2376 continue;
2377 };
2378 let flags = if all_type_only {
2379 EDGE_FLAG_TYPE_ONLY
2380 } else {
2381 0
2382 };
2383 edges.push([source, target, flags]);
2384 }
2385 }
2386 edges
2387}
2388
2389#[derive(Default)]
2391struct ComplexityRollup {
2392 fn_count: u16,
2393 max_cyclomatic: u16,
2394 max_cognitive: u16,
2395 react_hooks: u16,
2396 jsx_depth: u16,
2397 functions: Vec<VizFunction>,
2398}
2399
2400fn rollup_complexity(functions: &[FunctionComplexity]) -> ComplexityRollup {
2401 let mut rollup = ComplexityRollup {
2402 fn_count: clamp_u16(functions.len()),
2403 ..ComplexityRollup::default()
2404 };
2405 for f in functions {
2406 rollup.max_cyclomatic = rollup.max_cyclomatic.max(f.cyclomatic);
2407 rollup.max_cognitive = rollup.max_cognitive.max(f.cognitive);
2408 rollup.react_hooks = rollup.react_hooks.saturating_add(f.react_hook_count);
2409 rollup.jsx_depth = rollup.jsx_depth.max(f.react_jsx_max_depth);
2410 }
2411
2412 let mut named: Vec<&FunctionComplexity> = functions
2417 .iter()
2418 .filter(|f| !f.name.starts_with('<'))
2419 .collect();
2420 named.sort_by(|a, b| {
2421 b.cyclomatic
2422 .cmp(&a.cyclomatic)
2423 .then(b.cognitive.cmp(&a.cognitive))
2424 });
2425 rollup.functions = named
2426 .into_iter()
2427 .map(|f| VizFunction {
2428 name: f.name.clone(),
2429 line: f.line,
2430 cyclomatic: f.cyclomatic,
2431 cognitive: f.cognitive,
2432 lines: f.line_count,
2433 hooks: f.react_hook_count,
2434 jsx_depth: f.react_jsx_max_depth,
2435 props: f.react_prop_count,
2436 })
2437 .collect();
2438 rollup
2439}
2440
2441fn build_files(
2442 input: &VizBuildInput<'_>,
2443 index: &FileIndex<'_>,
2444 maps: &FilePropertyMaps<'_>,
2445) -> Vec<VizFile> {
2446 let graph = input.graph.as_graph();
2447 let unused_file_paths: rustc_hash::FxHashSet<&Path> = input
2448 .results
2449 .unused_files
2450 .iter()
2451 .map(|f| f.file.path.as_path())
2452 .collect();
2453
2454 let mut unused_exports_by_file: FxHashMap<&Path, Vec<String>> = FxHashMap::default();
2455 for export in &input.results.unused_exports {
2456 unused_exports_by_file
2457 .entry(export.export.path.as_path())
2458 .or_default()
2459 .push(export.export.export_name.clone());
2460 }
2461 for export in &input.results.unused_types {
2462 unused_exports_by_file
2463 .entry(export.export.path.as_path())
2464 .or_default()
2465 .push(export.export.export_name.clone());
2466 }
2467
2468 let mut complexity_by_file_id: FxHashMap<u32, ComplexityRollup> = FxHashMap::default();
2469 if let Some(modules) = input.modules {
2470 for module in modules {
2471 if !module.complexity.is_empty() {
2472 complexity_by_file_id
2473 .insert(module.file_id.0, rollup_complexity(&module.complexity));
2474 }
2475 }
2476 }
2477
2478 let mut in_cycle = vec![false; index.ordered.len()];
2479 for cycle in maps.cycles {
2480 for &idx in cycle {
2481 if let Some(slot) = in_cycle.get_mut(idx as usize) {
2482 *slot = true;
2483 }
2484 }
2485 }
2486
2487 index
2488 .ordered
2489 .iter()
2490 .enumerate()
2491 .map(|(i, file)| {
2492 let viz_idx = clamp_u32(i);
2493 let node_idx = file.id.0 as usize;
2494 let node = graph.modules.get(node_idx);
2495 let is_entry = node.is_some_and(|n| n.is_entry_point());
2496 let export_count = node.map_or(0, |n| clamp_u16(n.exports.len()));
2497 let import_count = clamp_u16(graph.edges_for(file.id).len());
2498 let importer_count = clamp_u16(input.graph.direct_importer_count(file.id));
2499
2500 let unused_export_names = unused_exports_by_file
2501 .remove(file.path.as_path())
2502 .unwrap_or_default();
2503 let unused_export_count = clamp_u16(unused_export_names.len());
2504
2505 let status = if unused_file_paths.contains(file.path.as_path()) {
2506 VizFileStatus::Unused
2507 } else if unused_export_count > 0 {
2508 VizFileStatus::HasUnusedExports
2509 } else if is_entry {
2510 VizFileStatus::EntryPoint
2511 } else {
2512 VizFileStatus::Clean
2513 };
2514
2515 let complexity = complexity_by_file_id.remove(&file.id.0).unwrap_or_default();
2516
2517 VizFile {
2518 path: relative_path(&file.path, &input.config.root),
2519 size: file.size_bytes,
2520 status,
2521 export_count,
2522 unused_export_count,
2523 is_entry,
2524 importer_count,
2525 import_count,
2526 workspace: workspace_index_for(&file.path, input.workspaces),
2527 zone: maps.zone_by_file.get(&viz_idx).copied(),
2528 unused_exports: unused_export_names,
2529 fn_count: complexity.fn_count,
2530 max_cyclomatic: complexity.max_cyclomatic,
2531 max_cognitive: complexity.max_cognitive,
2532 react_hooks: complexity.react_hooks,
2533 jsx_depth: complexity.jsx_depth,
2534 functions: complexity.functions,
2535 dup_lines: maps.dup_lines_by_file.get(&viz_idx).copied().unwrap_or(0),
2536 clone_groups: maps
2537 .clone_groups_by_file
2538 .get(&viz_idx)
2539 .cloned()
2540 .unwrap_or_default(),
2541 in_cycle: in_cycle[i],
2542 }
2543 })
2544 .collect()
2545}
2546
2547fn build_summary(
2548 input: &VizBuildInput<'_>,
2549 files: &[VizFile],
2550 clones: &[VizCloneGroup],
2551 cycles: &[Vec<u32>],
2552 violations: &[VizViolation],
2553 clone_groups_truncated: u32,
2554) -> VizSummary {
2555 let results = input.results;
2556 VizSummary {
2557 total_files: files.len(),
2558 total_size: files.iter().map(|f| f.size).sum(),
2559 total_edges: input.graph.edge_count(),
2560 unused_files: results.unused_files.len(),
2561 unused_exports: results.unused_exports.len() + results.unused_types.len(),
2562 unused_types: results.unused_types.len(),
2563 unused_deps: results.unused_dependencies.len()
2564 + results.unused_dev_dependencies.len()
2565 + results.unused_optional_dependencies.len(),
2566 unresolved_imports: results.unresolved_imports.len(),
2567 circular_deps: cycles.len(),
2568 clone_groups: clones.len(),
2569 duplicated_lines: clones.iter().map(|c| c.lines * c.instances.len()).sum(),
2570 boundary_violations: violations.len(),
2571 hotspot_files: files
2572 .iter()
2573 .filter(|f| f.max_cyclomatic >= HOTSPOT_CYCLOMATIC_FLOOR)
2574 .count(),
2575 clone_groups_truncated: (clone_groups_truncated > 0).then_some(clone_groups_truncated),
2576 }
2577}
2578
2579fn clamp_u16(value: usize) -> u16 {
2580 u16::try_from(value).unwrap_or(u16::MAX)
2581}
2582
2583fn clamp_u32(value: usize) -> u32 {
2584 u32::try_from(value).unwrap_or(u32::MAX)
2585}
2586
2587#[cfg(test)]
2588mod tests {
2589 use std::path::PathBuf;
2590
2591 use fallow_config::{BoundaryConfig, BoundaryZone, FallowConfig};
2592 use fallow_graph::graph::ModuleGraph;
2593 use fallow_graph::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
2594 use fallow_types::duplicates::{CloneGroup, CloneInstance};
2595 use fallow_types::extract::{ImportInfo, ImportedName};
2596 use fallow_types::output_dead_code::{BoundaryViolationFinding, CircularDependencyFinding};
2597 use fallow_types::output_format::OutputFormat;
2598 use fallow_types::results::{BoundaryViolation, CircularDependency};
2599
2600 use super::*;
2601 use crate::discover::{EntryPoint, EntryPointSource, FileId};
2602
2603 struct Fixture {
2605 config: ResolvedConfig,
2606 files: Vec<DiscoveredFile>,
2607 results: AnalysisResults,
2608 graph: crate::module_graph::RetainedModuleGraph,
2609 duplication: DuplicationReport,
2610 workspaces: Vec<WorkspaceInfo>,
2611 }
2612
2613 impl Fixture {
2614 fn input(&self) -> VizBuildInput<'_> {
2615 VizBuildInput {
2616 results: &self.results,
2617 graph: &self.graph,
2618 modules: None,
2619 files: &self.files,
2620 duplication: &self.duplication,
2621 workspaces: &self.workspaces,
2622 config: &self.config,
2623 feature_flags: &[],
2624 include_analysis_details: true,
2625 }
2626 }
2627 }
2628
2629 fn project_root() -> PathBuf {
2630 PathBuf::from("/viz-project")
2631 }
2632
2633 fn discovered(id: u32, path: PathBuf, size_bytes: u64) -> DiscoveredFile {
2634 DiscoveredFile {
2635 id: FileId(id),
2636 path,
2637 size_bytes,
2638 }
2639 }
2640
2641 fn import_of(target: FileId, specifier: &str) -> ResolvedImport {
2642 ResolvedImport {
2643 info: ImportInfo {
2644 source: specifier.to_owned(),
2645 imported_name: ImportedName::Named("value".to_owned()),
2646 local_name: "value".to_owned(),
2647 is_type_only: false,
2648 is_type_only_star: false,
2649 from_style: false,
2650 span: oxc_span::Span::new(0, 0),
2651 source_span: oxc_span::Span::new(0, 0),
2652 },
2653 target: ResolveResult::InternalModule(target),
2654 }
2655 }
2656
2657 fn zone(name: &str, pattern: &str) -> BoundaryZone {
2658 BoundaryZone {
2659 name: name.to_owned(),
2660 patterns: vec![pattern.to_owned()],
2661 auto_discover: Vec::new(),
2662 root: None,
2663 }
2664 }
2665
2666 fn resolved_config(root: &Path) -> ResolvedConfig {
2667 let config = FallowConfig {
2668 boundaries: BoundaryConfig {
2669 zones: vec![zone("app", "src/**"), zone("shared", "lib/**")],
2670 ..BoundaryConfig::default()
2671 },
2672 ..FallowConfig::default()
2673 };
2674 config.resolve(root.to_path_buf(), OutputFormat::Json, 1, false, true, None)
2675 }
2676
2677 fn cycle_finding(files: Vec<PathBuf>) -> CircularDependencyFinding {
2678 let length = files.len();
2679 CircularDependencyFinding::with_actions(CircularDependency {
2680 files,
2681 length,
2682 line: 1,
2683 col: 0,
2684 edges: Vec::new(),
2685 is_cross_package: false,
2686 })
2687 }
2688
2689 fn violation_finding(from_path: PathBuf, to_path: PathBuf) -> BoundaryViolationFinding {
2690 BoundaryViolationFinding::with_actions(BoundaryViolation {
2691 from_path,
2692 to_path,
2693 from_zone: "app".to_owned(),
2694 to_zone: "shared".to_owned(),
2695 import_specifier: "../lib/c".to_owned(),
2696 line: 2,
2697 col: 0,
2698 })
2699 }
2700
2701 fn clone_instance(file: PathBuf, start_line: usize, end_line: usize) -> CloneInstance {
2702 CloneInstance {
2703 file,
2704 start_line,
2705 end_line,
2706 start_col: 0,
2707 end_col: 0,
2708 fragment: "const shared = 1;\nconst repeated = 2;\nconst block = 3;".to_owned(),
2709 }
2710 }
2711
2712 fn clone_group(instances: Vec<CloneInstance>) -> CloneGroup {
2713 CloneGroup {
2714 instances,
2715 token_count: 12,
2716 line_count: 3,
2717 similarity: None,
2718 }
2719 }
2720
2721 fn fixture_with(extra_graph_file: bool) -> Fixture {
2726 let root = project_root();
2727 let a = root.join("src/a.ts");
2728 let b = root.join("src/b.ts");
2729 let c = root.join("lib/c.ts");
2730 let missing = root.join("src/missing.ts");
2731
2732 let files = vec![
2733 discovered(0, a.clone(), 100),
2734 discovered(1, b.clone(), 50),
2735 discovered(2, c.clone(), 25),
2736 ];
2737
2738 let mut graph_files = files.clone();
2739 let mut imports = vec![import_of(FileId(1), "./b")];
2740 if extra_graph_file {
2741 graph_files.push(discovered(3, root.join("src/d.ts"), 10));
2742 imports.push(import_of(FileId(3), "./d"));
2743 }
2744 let resolved = vec![ResolvedModule {
2745 file_id: FileId(0),
2746 path: a.clone(),
2747 resolved_imports: imports,
2748 ..ResolvedModule::default()
2749 }];
2750 let entry_points = vec![EntryPoint {
2751 path: a.clone(),
2752 source: EntryPointSource::PackageJsonMain,
2753 }];
2754 let graph = crate::module_graph::RetainedModuleGraph::from(ModuleGraph::build(
2755 &resolved,
2756 &entry_points,
2757 &graph_files,
2758 ));
2759
2760 let results = AnalysisResults {
2761 circular_dependencies: vec![
2762 cycle_finding(vec![a.clone(), b]),
2763 cycle_finding(vec![a.clone(), missing.clone()]),
2764 ],
2765 boundary_violations: vec![
2766 violation_finding(a.clone(), c.clone()),
2767 violation_finding(a.clone(), missing),
2768 ],
2769 ..AnalysisResults::default()
2770 };
2771
2772 let duplication = DuplicationReport {
2773 clone_groups: vec![
2774 clone_group(vec![
2775 clone_instance(a.clone(), 1, 3),
2776 clone_instance(c, 10, 12),
2777 ]),
2778 clone_group(vec![
2779 clone_instance(a.clone(), 20, 22),
2780 clone_instance(root.join("outside.ts"), 1, 3),
2781 ]),
2782 clone_group(vec![
2783 clone_instance(a.clone(), 30, 32),
2784 clone_instance(a, 40, 42),
2785 ]),
2786 ],
2787 ..DuplicationReport::default()
2788 };
2789
2790 let workspaces = vec![WorkspaceInfo {
2791 root: root.join("lib"),
2792 name: "shared-lib".to_owned(),
2793 is_internal_dependency: false,
2794 }];
2795
2796 Fixture {
2797 config: resolved_config(&root),
2798 files,
2799 results,
2800 graph,
2801 duplication,
2802 workspaces,
2803 }
2804 }
2805
2806 fn fixture() -> Fixture {
2807 fixture_with(false)
2808 }
2809
2810 #[test]
2811 fn files_and_edges_use_stable_indices() {
2812 let fx = fixture();
2813 let data = build_viz_data(&fx.input());
2814
2815 let paths: Vec<&str> = data.files.iter().map(|f| f.path.as_str()).collect();
2816 assert_eq!(paths, ["src/a.ts", "src/b.ts", "lib/c.ts"]);
2817 assert_eq!(data.edges, vec![[0, 1, 0]]);
2818 assert!(data.files[0].is_entry);
2819 assert!(matches!(data.files[0].status, VizFileStatus::EntryPoint));
2820 assert!(matches!(data.files[1].status, VizFileStatus::Clean));
2821 assert_eq!(data.files[0].import_count, 1);
2822 assert_eq!(data.files[1].importer_count, 1);
2823 assert_eq!(data.files[0].workspace, None);
2824 assert_eq!(data.files[2].workspace, Some(0));
2825 assert_eq!(data.workspaces.len(), 1);
2826 assert_eq!(data.workspaces[0].root, "lib");
2827 }
2828
2829 #[test]
2830 fn edges_to_files_missing_from_input_are_dropped() {
2831 let fx = fixture_with(true);
2832 let data = build_viz_data(&fx.input());
2833
2834 assert_eq!(fx.graph.edge_count(), 2);
2838 assert_eq!(data.edges, vec![[0, 1, 0]]);
2839 }
2840
2841 #[test]
2842 fn clone_groups_drop_unresolvable_and_dedup_per_file() {
2843 let fx = fixture();
2844 let data = build_viz_data(&fx.input());
2845
2846 assert_eq!(data.clones.len(), 2);
2849 assert_eq!(data.clones[0].instances.len(), 2);
2850 assert_eq!(data.clones[0].instances[0].file, 0);
2851 assert_eq!(data.clones[0].instances[1].file, 2);
2852 assert_eq!(data.clones[0].lines, 3);
2853 assert_eq!(data.clones[0].tokens, 12);
2854 assert_eq!(data.files[0].clone_groups, vec![0, 1]);
2856 assert_eq!(data.files[2].clone_groups, vec![0]);
2857 assert_eq!(data.files[0].dup_lines, 9);
2859 assert_eq!(data.files[2].dup_lines, 3);
2860 assert_eq!(data.files[1].dup_lines, 0);
2861 }
2862
2863 #[test]
2864 fn truncate_preview_caps_lines_and_bytes() {
2865 let last_kept = CLONE_PREVIEW_MAX_LINES - 1;
2868 let many_lines = (0..CLONE_PREVIEW_MAX_LINES + 5)
2869 .map(|i| format!("line {i}"))
2870 .collect::<Vec<_>>();
2871 let out = truncate_preview(&many_lines.join("\n"));
2872 assert_eq!(out.matches('\n').count(), CLONE_PREVIEW_MAX_LINES - 1);
2873 assert!(out.contains(&format!("line {last_kept}")));
2874 assert!(!out.contains(&format!("line {CLONE_PREVIEW_MAX_LINES}")));
2875 assert!(out.ends_with('\u{2026}'));
2876
2877 let big = CLONE_PREVIEW_MAX_BYTES * 3 / 4;
2880 let two_long_lines = format!("{}\n{}", "a".repeat(big), "b".repeat(big));
2881 let out = truncate_preview(&two_long_lines);
2882 assert_eq!(out, format!("{}\u{2026}", "a".repeat(big)));
2883
2884 let emoji_line = "\u{1f389}".repeat(CLONE_PREVIEW_MAX_BYTES);
2887 let out = truncate_preview(&emoji_line);
2888 assert_eq!(out, "\u{2026}");
2889 }
2890
2891 #[test]
2892 fn clone_preview_windows_context_around_the_block() {
2893 use std::io::Write as _;
2894
2895 let mut file = tempfile::NamedTempFile::new().expect("temp file");
2897 let body = (1..=20)
2898 .map(|i| format!("line {i}"))
2899 .collect::<Vec<_>>()
2900 .join("\n");
2901 file.write_all(body.as_bytes()).expect("write source");
2902 let inst = clone_instance(file.path().to_path_buf(), 8, 11);
2903
2904 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
2905 let preview_lines: Vec<&str> = preview.lines().collect();
2906
2907 assert_eq!(preview_lines.len(), 12);
2910 assert_eq!(highlight_start, 4);
2911 assert_eq!(highlight_lines, 4);
2912 assert_eq!(preview_lines.first(), Some(&"line 4"));
2913 let start = highlight_start as usize;
2914 let end = start + highlight_lines as usize;
2915 assert_eq!(
2916 &preview_lines[start..end],
2917 ["line 8", "line 9", "line 10", "line 11"],
2918 );
2919 assert_eq!(preview_lines[start - 1], "line 7");
2921 }
2922
2923 #[test]
2924 fn clone_preview_keeps_leading_context_when_the_block_fills_the_cap() {
2925 use std::io::Write as _;
2926
2927 let mut file = tempfile::NamedTempFile::new().expect("temp file");
2931 let body = (1..=200)
2932 .map(|i| format!("line {i}"))
2933 .collect::<Vec<_>>()
2934 .join("\n");
2935 file.write_all(body.as_bytes()).expect("write source");
2936 let inst = clone_instance(file.path().to_path_buf(), 50, 150);
2937
2938 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
2939 let preview_lines: Vec<&str> = preview.lines().collect();
2940
2941 assert_eq!(highlight_start, CLONE_PREVIEW_CONTEXT as u32);
2942 assert!(
2943 highlight_start > 0,
2944 "leading context must survive a huge block"
2945 );
2946 assert_eq!(preview_lines.len(), CLONE_PREVIEW_MAX_LINES);
2947 assert_eq!(
2948 highlight_lines as usize,
2949 CLONE_PREVIEW_MAX_LINES - CLONE_PREVIEW_CONTEXT,
2950 );
2951 assert_eq!(preview_lines[highlight_start as usize - 1], "line 49");
2952 assert_eq!(preview_lines[highlight_start as usize], "line 50");
2953 }
2954
2955 #[test]
2956 fn clone_preview_clamps_context_at_file_start() {
2957 use std::io::Write as _;
2958
2959 let mut file = tempfile::NamedTempFile::new().expect("temp file");
2960 file.write_all(b"line 1\nline 2\nline 3\nline 4\nline 5")
2961 .expect("write source");
2962 let inst = clone_instance(file.path().to_path_buf(), 1, 2);
2965
2966 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
2967 assert_eq!(highlight_start, 0);
2968 assert_eq!(highlight_lines, 2);
2969 assert_eq!(preview, "line 1\nline 2\nline 3\nline 4\nline 5");
2970 }
2971
2972 #[test]
2973 fn clone_preview_falls_back_when_source_is_unreadable() {
2974 let inst = clone_instance(project_root().join("does-not-exist.ts"), 1, 3);
2977 let (preview, highlight_start, highlight_lines) = build_clone_preview(&inst);
2978 assert_eq!(preview, inst.fragment);
2979 assert_eq!(highlight_start, 0);
2980 assert_eq!(highlight_lines as usize, preview.lines().count());
2981 }
2982
2983 #[test]
2984 fn cycles_drop_when_any_member_unresolved() {
2985 let fx = fixture();
2986 let data = build_viz_data(&fx.input());
2987
2988 assert_eq!(data.cycles, vec![vec![0, 1]]);
2991 assert!(data.files[0].in_cycle);
2992 assert!(data.files[1].in_cycle);
2993 assert!(!data.files[2].in_cycle);
2994 assert_eq!(data.summary.circular_deps, data.cycles.len());
2997 }
2998
2999 #[test]
3000 fn violations_resolve_zone_and_file_indices() {
3001 let fx = fixture();
3002 let data = build_viz_data(&fx.input());
3003
3004 assert_eq!(data.zones.len(), 2);
3005 assert_eq!(data.zones[0].name, "app");
3006 assert_eq!(data.zones[0].files, 2);
3007 assert_eq!(data.zones[1].name, "shared");
3008 assert_eq!(data.zones[1].files, 1);
3009 assert_eq!(data.files[0].zone, Some(0));
3010 assert_eq!(data.files[1].zone, Some(0));
3011 assert_eq!(data.files[2].zone, Some(1));
3012
3013 assert_eq!(data.violations.len(), 1);
3015 let v = &data.violations[0];
3016 assert_eq!((v.from, v.to), (0, 2));
3017 assert_eq!((v.from_zone, v.to_zone), (0, 1));
3018 assert_eq!(v.line, 2);
3019 assert_eq!(v.specifier, "../lib/c");
3020 }
3021
3022 #[test]
3023 fn clone_group_cap_counts_truncated_groups() {
3024 let fx = fixture();
3025 let index = FileIndex::new(&fx.files);
3026
3027 let (clones, groups_by_file, _dup_lines, truncated) =
3032 build_clones(&fx.duplication, &index, 1);
3033 assert_eq!(clones.len(), 1);
3034 assert_eq!(truncated, 1);
3035 assert!(
3036 groups_by_file
3037 .values()
3038 .all(|ids| ids.iter().all(|&id| (id as usize) < clones.len()))
3039 );
3040
3041 let data = build_viz_data(&fx.input());
3043 assert_eq!(data.clones.len(), 2);
3044 assert_eq!(data.summary.clone_groups_truncated, None);
3045 }
3046
3047 #[test]
3048 fn summary_flags_clone_truncation_only_when_nonzero() {
3049 let fx = fixture();
3050 let data = build_viz_data(&fx.input());
3051
3052 let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 3);
3053 assert_eq!(summary.clone_groups_truncated, Some(3));
3054 let summary = build_summary(&fx.input(), &data.files, &data.clones, &[], &[], 0);
3055 assert_eq!(summary.clone_groups_truncated, None);
3056 }
3057
3058 #[test]
3059 fn summary_counts_match_rendered_arrays() {
3060 let fx = fixture();
3061 let data = build_viz_data(&fx.input());
3062 let s = &data.summary;
3063
3064 assert_eq!(s.total_files, data.files.len());
3065 assert_eq!(s.total_size, 175);
3066 assert_eq!(s.total_edges, data.edges.len());
3067 assert_eq!(s.clone_groups, data.clones.len());
3068 assert_eq!(s.duplicated_lines, 12);
3069 assert_eq!(s.hotspot_files, 0);
3070 assert_eq!(s.unused_files, 0);
3071 assert_eq!(s.unused_exports, 0);
3072 assert_eq!(s.circular_deps, data.cycles.len());
3075 assert_eq!(s.circular_deps, 1);
3076 assert_eq!(s.boundary_violations, data.violations.len());
3077 assert_eq!(s.boundary_violations, 1);
3078 }
3079
3080 #[test]
3081 fn payload_keeps_counts_and_availability_explicit() {
3082 let fx = fixture();
3083 let data = build_viz_data(&fx.input());
3084 let value = serde_json::to_value(&data).expect("viz data serializes");
3085
3086 assert_eq!(value["architecture"]["availability"]["unit"], "violations");
3087 assert_eq!(value["dependencies"]["availability"]["unit"], "findings");
3088 assert_eq!(value["security"]["availability"]["unit"], "candidates");
3089 assert_eq!(value["security"]["availability"]["state"], "complete");
3090 assert_eq!(
3091 value["security"]["runtime_availability"]["state"],
3092 "unavailable"
3093 );
3094 assert_eq!(value["health"]["availability"]["state"], "unavailable");
3095 assert_eq!(
3096 value["health"]["capabilities"]["coverage"]["state"],
3097 "unavailable"
3098 );
3099 assert!(value["frameworks"]["detectors"].is_array());
3100 assert_eq!(
3101 value["frameworks"]["detector_availability"]["state"],
3102 "unavailable"
3103 );
3104 assert!(value["styling"].get("score").is_none());
3105 }
3106
3107 #[test]
3111 fn health_reports_runtime_evidence_as_unavailable_without_a_runtime_input() {
3112 let fx = fixture();
3113 let mut data = build_viz_data(&fx.input());
3114 apply_health_report(&mut data, &HealthReport::default(), Path::new("/project"));
3115 let value = serde_json::to_value(&data).expect("viz data serializes");
3116
3117 assert_eq!(value["health"]["availability"]["state"], "complete");
3118 let runtime = &value["health"]["capabilities"]["runtime"];
3119 assert_eq!(runtime["state"], "unavailable");
3120 assert_eq!(runtime["unit"], "observations");
3121 assert_eq!(runtime["reason"], NO_RUNTIME_COVERAGE_REASON);
3122 assert_eq!(runtime["count"], 0);
3123 assert_eq!(
3124 value["security"]["runtime_availability"]["reason"],
3125 NO_RUNTIME_COVERAGE_REASON
3126 );
3127 }
3128
3129 #[test]
3133 fn rooted_paths_without_a_drive_are_redacted() {
3134 let root = Path::new("/project");
3135 assert_eq!(
3136 relative_path(Path::new("/Users/private/secret.ts"), root),
3137 "<external>/secret.ts"
3138 );
3139 assert_eq!(
3140 relative_path(Path::new("/etc/passwd"), root),
3141 "<external>/passwd"
3142 );
3143 assert_eq!(relative_path(Path::new("src/a.ts"), root), "src/a.ts");
3145
3146 let mut detail = serde_json::json!({ "path": "/Users/private/secret.ts" });
3148 relativize_value_paths(&mut detail, root);
3149 assert_eq!(detail["path"], "<external>/secret.ts");
3150
3151 let mut route = serde_json::json!({ "specifier": "/api/v1" });
3154 relativize_value_paths(&mut route, root);
3155 assert_eq!(route["specifier"], "/api/v1");
3156 }
3157
3158 #[test]
3159 fn external_absolute_paths_are_redacted() {
3160 let root = Path::new("/project");
3161 assert_eq!(
3162 relative_path(Path::new("/project/src/a.ts"), root),
3163 "src/a.ts"
3164 );
3165 assert_eq!(
3166 relative_path(Path::new("/Users/private/secret.ts"), root),
3167 "<external>/secret.ts"
3168 );
3169
3170 let mut detail = serde_json::json!({ "path": "/Users/private/secret.ts" });
3171 relativize_value_paths(&mut detail, root);
3172 assert_eq!(detail["path"], "<external>/secret.ts");
3173
3174 let mut route = serde_json::json!({ "specifier": "/api/v1" });
3175 relativize_value_paths(&mut route, root);
3176 assert_eq!(route["specifier"], "/api/v1");
3177
3178 let mut conflicts = serde_json::json!({
3179 "conflicting_paths": ["/project/app/a.ts", "/project/app/b.ts"]
3180 });
3181 relativize_value_paths(&mut conflicts, root);
3182 assert_eq!(conflicts["conflicting_paths"][0], "app/a.ts");
3183 assert_eq!(conflicts["conflicting_paths"][1], "app/b.ts");
3184 }
3185
3186 #[test]
3187 fn config_action_values_are_not_rendered_as_commands() {
3188 let finding = finding_from_value(
3189 "dependency",
3190 "Dependency finding",
3191 serde_json::json!({
3192 "actions": [{
3193 "kind": "add-to-config",
3194 "auto_fixable": false,
3195 "config_key": "entry",
3196 "value": "./errors",
3197 "description": "Add the entry to configuration"
3198 }]
3199 }),
3200 Path::new("/project"),
3201 &|_| None,
3202 );
3203 assert_eq!(finding.actions.len(), 1);
3204 let action = &finding.actions[0];
3205 assert_eq!(action.kind.as_deref(), Some("add-to-config"));
3206 assert!(!action.auto_fixable);
3207 assert_eq!(action.config_key.as_deref(), Some("entry"));
3208 assert_eq!(action.value, Some(Value::String("./errors".to_string())));
3209 assert!(action.command.is_none());
3210 }
3211}