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