Skip to main content

fallow_engine/
viz.rs

1//! Typed data contract and builder for `fallow viz`.
2//!
3//! The CLI runs one project analysis (dead code + duplication + complexity)
4//! through [`crate::session::AnalysisSession`] and hands the retained
5//! artifacts to [`build_viz_data`]. The resulting [`VizData`] is embedded as
6//! JSON in the self-contained interactive HTML the `viz` command writes.
7//!
8//! The contract is engine-owned so the graph internals never leak past the
9//! engine boundary: everything the frontend needs is resolved to file
10//! indices, relative paths, and plain counts here.
11
12use 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
27/// A file counts as a complexity hotspot at or above this cyclomatic score.
28const HOTSPOT_CYCLOMATIC_FLOOR: u16 = 10;
29/// Maximum bytes of clone-fragment preview shipped per clone group. The
30/// budget is measured in bytes, not characters; truncation only ever cuts
31/// at a line boundary, so multi-byte source cannot be sliced mid-character.
32const CLONE_PREVIEW_MAX_BYTES: usize = 2000;
33/// Maximum lines of clone-fragment preview shipped per clone group. The
34/// preview grows to its content in the panel (no inner scroll), so this can
35/// be generous; big blocks still truncate, keeping the leading context.
36const CLONE_PREVIEW_MAX_LINES: usize = 32;
37/// Source lines of context included on each side of the duplicated block
38/// in a clone preview. A fixed window is universal: clones are frequently
39/// not functions (interface fields, object literals, type aliases), so no
40/// enclosing-scope detection is attempted.
41const CLONE_PREVIEW_CONTEXT: usize = 4;
42/// Maximum clone groups serialized into the payload. Far above any
43/// legitimate report; a guardrail against multi-MB HTML on monorepos.
44/// Groups keep the detector's report order, so the cap keeps the first N.
45const MAX_CLONE_GROUPS: usize = 500;
46/// Maximum specialized finding records serialized per analysis family.
47const MAX_ANALYSIS_FINDINGS: usize = 1000;
48/// Maximum located security blind-spot samples beyond the aggregate rows.
49const MAX_SECURITY_BLIND_SPOT_SAMPLES: usize = 100;
50/// Maximum file-health rows serialized into the browser payload.
51const MAX_HEALTH_FILES: usize = 2000;
52/// Edge flag bit: every import of this edge is type-only.
53const EDGE_FLAG_TYPE_ONLY: u32 = 1;
54/// Edge flag bit: the edge carries a runtime value but no static one, so the
55/// target loads only on demand (`import()`, a lazy pattern) or on another
56/// thread (a worker, a fork). The graph view draws it dashed.
57const EDGE_FLAG_DYNAMIC: u32 = 2;
58/// Reason reported by every family that needs runtime evidence viz was not
59/// given. Viz takes no runtime coverage input, so the Health and Security
60/// lenses say so instead of presenting a static-only answer as the whole one.
61const NO_RUNTIME_COVERAGE_REASON: &str = "No runtime coverage input was provided";
62
63/// Everything [`build_viz_data`] needs from one project analysis run.
64pub struct VizBuildInput<'a> {
65    /// Dead-code analysis results (unused files/exports, cycles, boundaries).
66    pub results: &'a AnalysisResults,
67    /// Retained module graph for edges, entry points, and export counts.
68    pub graph: &'a RetainedModuleGraph,
69    /// Parsed modules with complexity data, when retained.
70    pub modules: Option<&'a [ModuleInfo]>,
71    /// Discovered source files, in `FileId` order.
72    pub files: &'a [DiscoveredFile],
73    /// Duplication report from the same session.
74    pub duplication: &'a DuplicationReport,
75    /// Discovered monorepo workspaces.
76    pub workspaces: &'a [WorkspaceInfo],
77    /// Resolved config (project root + boundary zones).
78    pub config: &'a ResolvedConfig,
79    /// Feature flag records derived from the same parsed session.
80    pub feature_flags: &'a [FeatureFlag],
81    /// Whether to project the HTML-only lens detail payloads.
82    pub include_analysis_details: bool,
83}
84
85/// Serialized payload embedded in the viz HTML.
86#[derive(Serialize)]
87pub struct VizData {
88    /// Project display name (root directory basename).
89    pub root: String,
90    /// One entry per analyzed source file, indexed by position.
91    pub files: Vec<VizFile>,
92    /// Import edges as `[from, to, flags]` file-index pairs.
93    /// `flags` bit 0 marks an edge whose imports are all type-only; bit 1
94    /// marks an edge that loads its target only on demand or on another
95    /// thread (`import()`, a lazy pattern, a worker, a fork).
96    pub edges: Vec<[u32; 3]>,
97    /// Project-wide totals for the header stat boxes.
98    pub summary: VizSummary,
99    /// Discovered workspaces; `VizFile.workspace` indexes into this.
100    pub workspaces: Vec<VizWorkspace>,
101    /// Boundary zones; `VizFile.zone` and violations index into this.
102    pub zones: Vec<VizZone>,
103    /// Circular-dependency cycles as file-index lists.
104    pub cycles: Vec<Vec<u32>>,
105    /// Clone groups; `VizFile.clone_groups` indexes into this.
106    pub clones: Vec<VizCloneGroup>,
107    /// Boundary violations resolved to file indices.
108    pub violations: Vec<VizViolation>,
109    /// Architecture findings that do not fit the legacy graph overlays alone.
110    pub architecture: VizFindingAnalysis,
111    /// Dependency and public-API findings, excluding unused dependencies.
112    pub dependencies: VizFindingAnalysis,
113    /// Real health scoring and hotspot data from the shared analysis session.
114    pub health: VizHealthData,
115    /// Static security candidates and explicit blind spots.
116    pub security: VizSecurityData,
117    /// Framework-specific findings and detector diagnostics.
118    pub frameworks: VizFrameworkData,
119    /// CSS and design-system findings from health analysis.
120    pub styling: VizStylingData,
121    /// Detected feature flag use sites.
122    pub feature_flags: VizFindingAnalysis,
123}
124
125/// Honest availability state for one Viz analysis family.
126#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
127#[serde(rename_all = "camelCase")]
128pub enum VizAvailabilityState {
129    /// The analysis ran and its count is the whole answer.
130    Complete,
131    /// The analysis is switched off by configuration.
132    Disabled,
133    /// The analysis has nothing to say about this project.
134    NotApplicable,
135    /// The analysis could not run, so no count can be claimed.
136    Unavailable,
137}
138
139/// Count contract and availability for one analysis family.
140///
141/// A count is meaningful only when `state` is
142/// [`VizAvailabilityState::Complete`]. Every other state carries a count of
143/// zero that the frontend must render as missing data rather than as zero
144/// findings.
145#[derive(Serialize)]
146pub struct VizAvailability {
147    /// Whether the count below can be read as a result.
148    pub state: VizAvailabilityState,
149    /// Number of items in `unit`, valid only in the `Complete` state.
150    pub count: usize,
151    /// What `count` counts, such as `findings` or `files`.
152    pub unit: &'static str,
153    /// Why the analysis is not complete, for every non-complete state.
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub reason: Option<String>,
156    /// Total before payload truncation, when the payload carries fewer items
157    /// than `count`.
158    #[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/// Stable presentation record shared by finding-oriented Viz families.
195#[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/// One stable scalar fact from an analyzer-specific record.
219#[derive(Serialize)]
220pub struct VizFindingFact {
221    label: String,
222    value: String,
223}
224
225/// One stable action projected from an analyzer-specific record.
226#[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/// One finding-oriented analysis family.
245#[derive(Serialize)]
246pub struct VizFindingAnalysis {
247    /// Whether this family ran, and how many findings it stands behind.
248    pub availability: VizAvailability,
249    /// Total finding count before the payload was capped.
250    #[serde(skip_serializing_if = "Option::is_none")]
251    pub findings_truncated: Option<usize>,
252    /// The findings carried in the payload.
253    pub findings: Vec<VizFinding>,
254}
255
256/// Framework findings plus detector capability metadata.
257#[derive(Serialize)]
258pub struct VizFrameworkData {
259    /// Whether framework analysis ran, and how many findings it produced.
260    pub availability: VizAvailability,
261    /// Whether the per-detector capability list is trustworthy. A detector
262    /// can be unavailable while findings from other detectors are complete.
263    pub detector_availability: VizAvailability,
264    /// Total finding count before the payload was capped.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub findings_truncated: Option<usize>,
267    /// The findings carried in the payload.
268    pub findings: Vec<VizFinding>,
269    /// Frameworks detected in the project.
270    pub detected_frameworks: Vec<String>,
271    /// Per-detector status, so a silent detector is distinguishable from a
272    /// detector that ran and found nothing.
273    pub detectors: Vec<VizFrameworkDetector>,
274}
275
276/// Status of one framework-specific detector.
277#[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/// Availability of the individual Health signal families.
287#[derive(Serialize)]
288pub struct VizHealthCapabilities {
289    /// Cyclomatic and cognitive complexity findings.
290    pub complexity: VizAvailability,
291    /// Per-file maintainability index scores.
292    pub maintainability: VizAvailability,
293    /// CRAP risk scores, which need coverage to be meaningful.
294    pub crap: VizAvailability,
295    /// Istanbul coverage ingestion.
296    pub coverage: VizAvailability,
297    /// Runtime execution evidence, which needs a runtime coverage input.
298    pub runtime: VizAvailability,
299    /// Git churn, which needs a history walk viz does not perform.
300    pub churn: VizAvailability,
301    /// Churn-weighted complexity hotspots, gated on churn.
302    pub hotspots: VizAvailability,
303    /// Ownership attribution, which needs a history walk viz does not perform.
304    pub ownership: VizAvailability,
305}
306
307/// Real file-health metrics.
308#[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/// Health lens payload populated after the shared health runner completes.
326#[derive(Serialize)]
327pub struct VizHealthData {
328    /// Whether the health runner completed, and how many files it scored.
329    pub availability: VizAvailability,
330    /// Per-signal availability, so the lens can dim what did not run.
331    pub capabilities: VizHealthCapabilities,
332    /// Whether the run reused the viz session's parse instead of reparsing.
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub shared_parse: Option<bool>,
335    /// Overall health score.
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub score: Option<f64>,
338    /// Letter grade derived from `score`.
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub grade: Option<String>,
341    /// Mean maintainability index across scored files.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub average_maintainability: Option<f64>,
344    /// Per-file metrics carried in the payload.
345    pub files: Vec<VizHealthFile>,
346    /// Total scored-file count before the payload was capped.
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub files_truncated: Option<usize>,
349    /// Total finding count before the payload was capped.
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub findings_truncated: Option<usize>,
352    /// The health findings carried in the payload.
353    pub findings: Vec<VizFinding>,
354}
355
356/// Styling findings plus the project-level CSS analytics and score.
357#[derive(Serialize)]
358pub struct VizStylingData {
359    /// Whether styling analysis ran, and how many findings it produced.
360    pub availability: VizAvailability,
361    /// Total finding count before the payload was capped.
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub findings_truncated: Option<usize>,
364    /// The styling findings carried in the payload.
365    pub findings: Vec<VizFinding>,
366    /// Project-level styling score.
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub score: Option<f64>,
369    /// Letter grade derived from `score`.
370    #[serde(skip_serializing_if = "Option::is_none")]
371    pub grade: Option<String>,
372    /// How much evidence the score rests on.
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub confidence: Option<String>,
375    /// Project-level CSS analytics, rendered as-is by the lens.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub summary: Option<Value>,
378}
379
380/// One hop in a static security trace.
381#[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/// One endpoint in a typed Security taint flow.
392#[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/// Typed source-to-sink flow summary.
402#[derive(Serialize)]
403pub struct VizSecurityTaintFlow {
404    source: VizSecurityEndpoint,
405    sink: VizSecurityEndpoint,
406    intra_module: bool,
407    cross_module_hops: u32,
408}
409
410/// Static security candidate. The record deliberately contains no
411/// exploitability verdict.
412#[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/// Explicitly counted security blind spot.
465#[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/// Security lens payload. Runtime evidence is a separate capability from the
480/// always-local static candidate pass.
481#[derive(Serialize)]
482pub struct VizSecurityData {
483    /// Whether the static candidate pass ran, and how many candidates it
484    /// surfaced. Candidates are unverified, never vulnerability verdicts.
485    pub availability: VizAvailability,
486    /// Runtime evidence availability. Without a runtime coverage input this
487    /// stays `Unavailable`, never a complete count of zero.
488    pub runtime_availability: VizAvailability,
489    /// The static candidates carried in the payload.
490    pub candidates: Vec<VizSecurityCandidate>,
491    /// How many places the static pass could not see into.
492    pub blind_spot_count: usize,
493    /// Total blind-spot count before the payload was capped.
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub blind_spots_truncated: Option<usize>,
496    /// The blind spots carried in the payload.
497    pub blind_spots: Vec<VizSecurityBlindSpot>,
498}
499
500/// One analyzed source file.
501#[derive(Serialize)]
502pub struct VizFile {
503    /// Root-relative path with forward slashes.
504    pub path: String,
505    /// File size in bytes (treemap area).
506    pub size: u64,
507    /// Dead-code status classification.
508    pub status: VizFileStatus,
509    /// Number of exports declared by the file.
510    pub export_count: u16,
511    /// Number of exports (values + types) reported unused.
512    pub unused_export_count: u16,
513    /// Whether the file is an entry point.
514    pub is_entry: bool,
515    /// Number of files importing this file.
516    pub importer_count: u16,
517    /// Number of files this file imports.
518    pub import_count: u16,
519    /// Index into `VizData.workspaces`, if the file belongs to one.
520    #[serde(skip_serializing_if = "Option::is_none")]
521    pub workspace: Option<u16>,
522    /// Index into `VizData.zones`, if the file matches a boundary zone.
523    #[serde(skip_serializing_if = "Option::is_none")]
524    pub zone: Option<u16>,
525    /// Names of unused exports (for actionable tooltips).
526    #[serde(skip_serializing_if = "Vec::is_empty")]
527    pub unused_exports: Vec<String>,
528    /// Number of functions parsed in the file.
529    pub fn_count: u16,
530    /// Highest cyclomatic complexity of any function in the file.
531    pub max_cyclomatic: u16,
532    /// Highest cognitive complexity of any function in the file.
533    pub max_cognitive: u16,
534    /// Total React hook calls across the file's functions.
535    pub react_hooks: u16,
536    /// Deepest JSX nesting across the file's functions.
537    pub jsx_depth: u16,
538    /// Every function in the file, sorted hardest-first.
539    #[serde(skip_serializing_if = "Vec::is_empty")]
540    pub functions: Vec<VizFunction>,
541    /// Duplicated lines in this file across all clone groups.
542    pub dup_lines: u32,
543    /// Indices into `VizData.clones` this file participates in.
544    #[serde(skip_serializing_if = "Vec::is_empty")]
545    pub clone_groups: Vec<u32>,
546    /// Whether the file participates in any circular dependency.
547    pub in_cycle: bool,
548}
549
550/// Dead-code status of a file, ordered by severity in the frontend.
551#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
552#[serde(rename_all = "camelCase")]
553pub enum VizFileStatus {
554    /// No findings.
555    Clean,
556    /// Live file with one or more unused exports.
557    HasUnusedExports,
558    /// Entire file is unreachable.
559    Unused,
560    /// Configured or detected entry point.
561    EntryPoint,
562}
563
564/// One function inside a file, with its complexity metrics.
565#[derive(Serialize)]
566pub struct VizFunction {
567    /// Function name, or `<anonymous>`.
568    name: String,
569    /// 1-based start line.
570    line: u32,
571    /// McCabe cyclomatic complexity.
572    cyclomatic: u16,
573    /// SonarSource cognitive complexity.
574    cognitive: u16,
575    /// Body line count.
576    lines: u32,
577    /// React hook calls made directly in the body.
578    hooks: u16,
579    /// Deepest JSX nesting in the body.
580    jsx_depth: u16,
581    /// Props destructured from the first parameter.
582    props: u16,
583}
584
585/// Project-wide totals for the header stat boxes.
586#[derive(Serialize)]
587pub struct VizSummary {
588    /// Total analyzed files.
589    pub total_files: usize,
590    /// Total bytes across analyzed files.
591    pub total_size: u64,
592    /// Total import edges.
593    pub total_edges: usize,
594    /// Fully unused files.
595    pub unused_files: usize,
596    /// Unused exports (values + types).
597    pub unused_exports: usize,
598    /// Unused exported types.
599    pub unused_types: usize,
600    /// Unused dependencies (prod + dev + optional).
601    pub unused_deps: usize,
602    /// Imports that resolve to nothing.
603    pub unresolved_imports: usize,
604    /// Circular dependency cycles.
605    pub circular_deps: usize,
606    /// Clone groups detected.
607    pub clone_groups: usize,
608    /// Total duplicated lines across clone groups.
609    pub duplicated_lines: usize,
610    /// Boundary violations.
611    pub boundary_violations: usize,
612    /// Files at or above the complexity hotspot floor.
613    pub hotspot_files: usize,
614    /// Kept clone groups dropped by the `MAX_CLONE_GROUPS` payload cap.
615    /// Present only when the clone payload was truncated.
616    #[serde(skip_serializing_if = "Option::is_none")]
617    pub clone_groups_truncated: Option<u32>,
618}
619
620/// One discovered workspace.
621#[derive(Serialize)]
622pub struct VizWorkspace {
623    /// Package name.
624    name: String,
625    /// Root-relative workspace root.
626    root: String,
627}
628
629/// One configured boundary zone.
630#[derive(Serialize)]
631pub struct VizZone {
632    /// Zone name from the boundaries config.
633    name: String,
634    /// Number of files classified into this zone.
635    files: u32,
636}
637
638/// One clone group resolved to file indices.
639#[derive(Serialize)]
640pub struct VizCloneGroup {
641    /// Lines per duplicated block.
642    lines: usize,
643    /// Tokens per duplicated block.
644    tokens: usize,
645    /// Where the duplicated block appears.
646    instances: Vec<VizCloneInstance>,
647    /// Source preview: a context window around the duplicated block, the
648    /// copied lines flanked by up to `CLONE_PREVIEW_CONTEXT` surrounding
649    /// source lines on each side.
650    preview: String,
651    /// 0-based index, among the lines of `preview`, of the first copied
652    /// line. Lines before it are dimmed context.
653    highlight_start: u32,
654    /// Number of copied lines present in `preview`. The frontend highlights
655    /// `preview` lines `[highlight_start, highlight_start + highlight_lines)`
656    /// and dims the rest.
657    highlight_lines: u32,
658}
659
660/// One location of a duplicated block.
661#[derive(Serialize)]
662pub struct VizCloneInstance {
663    /// File index into `VizData.files`.
664    file: u32,
665    /// 1-based start line.
666    start_line: u32,
667    /// 1-based end line.
668    end_line: u32,
669}
670
671/// One boundary violation resolved to file indices.
672#[derive(Serialize)]
673pub struct VizViolation {
674    /// Importing file index.
675    from: u32,
676    /// Imported file index.
677    to: u32,
678    /// Index into `VizData.zones` for the importing file's zone.
679    from_zone: u16,
680    /// Index into `VizData.zones` for the imported file's zone.
681    to_zone: u16,
682    /// 1-based line of the offending import.
683    line: u32,
684    /// Raw import specifier.
685    specifier: String,
686}
687
688/// Build the viz payload from one project analysis run.
689#[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
821/// Maps absolute paths to dense viz file indices in `FileId` order.
822struct 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        // has_root, not is_absolute: joining a Windows rooted path that carries
905        // no drive letter onto root would reinterpret an external path as
906        // project-relative and expose its components instead of redacting it.
907        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            // has_root, not is_absolute, for the same reason as relative_path:
1171            // a Windows rooted path without a drive letter is not absolute, so
1172            // gating on is_absolute left it unredacted in the payload. Only
1173            // values under a path key reach this arm, so a route specifier such
1174            // as `/api/v1` is excluded by the key gate rather than by this test.
1175            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
1404/// Push the Effect framework findings and return how many were seen.
1405fn 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    // The engine session stamps `finding_id` right after detection, so viz
1647    // reads the same id as the JSON and SARIF output.
1648    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
1789/// Populate Health, Framework diagnostics, and Styling from the health runner
1790/// that consumed the same session artifacts.
1791pub 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
2090/// Per-file lookup maps threaded into [`build_files`].
2091struct 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    // has_root, not is_absolute: on Windows a drive-less rooted path such as
2110    // `\\Users\\private\\secret.ts` is rooted but NOT absolute, so gating on
2111    // is_absolute let it skip redaction and leak the full path into the payload.
2112    // has_root is a strict superset and covers `C:\\...` and `/...` alike.
2113    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
2183/// Clone payload maps: kept groups, per-file group ids, per-file duplicated
2184/// lines, and how many kept-groups the payload cap dropped.
2185type 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
2273/// Build the representative clone preview: a context window around the
2274/// duplicated block, with the highlight range located within it. Returns
2275/// `(preview, highlight_start, highlight_lines)` where `highlight_start`
2276/// is the 0-based index of the first copied line among the preview lines
2277/// and `highlight_lines` is the copied line count present in `preview`.
2278///
2279/// Falls back to the bare fragment with the whole block highlighted on
2280/// any read failure, empty source, or an out-of-range line span. Never
2281/// panics.
2282fn 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    // Block bounds as a 0-based `[block_start, block_end)` range, clamped
2293    // to the file and guaranteed to hold at least one line.
2294    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    // Line cap: when the block plus its context fits, trim context
2301    // symmetrically to fit. When the block alone fills the cap, keep the
2302    // leading context (so the highlight always reads against some dimmed
2303    // lines) and truncate the block's tail, always keeping >= 1 block line.
2304    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
2331/// Fallback preview: the bare fragment, capped, with the whole block
2332/// highlighted (nothing dimmed).
2333fn 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
2343/// Reduce `before`/`after` so their sum fits `budget`, dropping from the
2344/// larger side first (ties favor keeping `after`) so the two flanks stay
2345/// balanced. Deterministic.
2346fn 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
2356/// Trim the preview window to `CLONE_PREVIEW_MAX_BYTES`, dropping context
2357/// lines (larger side first) before ever cutting into the highlighted
2358/// block. If the block alone still overflows, its tail lines are dropped,
2359/// but at least one line is always kept.
2360fn 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/// Complexity aggregates for one file, folded from its parsed functions.
2462#[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    // Named functions only, hardest-first: the panel lists these and folds the
2485    // (often many) anonymous arrow/callback functions into a single count via
2486    // `fn_count`. Placeholder names for unnamed functions are `<arrow>` /
2487    // `<anonymous>`, so a leading `<` marks the ones to fold away.
2488    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    /// Owned fixture parts backing one [`VizBuildInput`].
2676    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    /// Synthetic project: 3 files, one import edge a to b, one resolvable
2796    /// cycle (a, b) plus one unresolvable, one clone group over (a, c) plus a
2797    /// dropped and a same-file group, one resolvable boundary violation a to
2798    /// c plus one unresolvable, two zones, one workspace over `lib/`.
2799    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        // The graph carries a to b AND a to d, but d is not in `input.files`,
2943        // so build_edges drops the second edge instead of emitting a
2944        // dangling index.
2945        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        // The group whose second instance lives outside `input.files` keeps
2955        // only 1 resolvable instance and is dropped entirely.
2956        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        // Two same-file instances in one group dedup to a single group id.
2963        assert_eq!(data.files[0].clone_groups, vec![0, 1]);
2964        assert_eq!(data.files[2].clone_groups, vec![0]);
2965        // dup_lines sums (end minus start plus 1) per resolvable instance.
2966        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        // Line cap: more lines than the cap in, CLONE_PREVIEW_MAX_LINES out
2974        // plus the ellipsis appended directly after the last kept line.
2975        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        // Byte budget: the second big line would exceed CLONE_PREVIEW_MAX_BYTES,
2986        // so output stops after the first line.
2987        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        // Multi-byte content over budget truncates at a line boundary and
2993        // never slices inside a character (4 bytes per emoji, well over budget).
2994        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        // 20 numbered source lines; the copied block covers lines 8..=11.
3004        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        // Block (4 lines) plus 4 lines of context each side fits the cap, so
3016        // the full window is kept: 4 dimmed + 4 highlighted + 4 dimmed.
3017        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        // The line directly above the block is dimmed context, not copied.
3028        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        // A block far larger than the cap. The old logic zeroed the context
3036        // and highlighted the whole (truncated) window; the fix keeps the
3037        // leading context dimmed so the highlight still reads against it.
3038        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        // Block at the very top: no context fits above it, so the highlight
3071        // starts at index 0 and the trailing lines are dimmed context.
3072        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        // A missing file forces the fragment fallback: the whole block is
3083        // highlighted so nothing is dimmed.
3084        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        // The a/b cycle resolves fully; the cycle referencing the missing
3097        // file yields no entry at all (not a partial one).
3098        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        // The summary counts the rendered cycles, not the raw results, so
3103        // the dropped cycle does not inflate the header number.
3104        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        // The violation whose to_path is not in `input.files` is dropped.
3122        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        // The fixture report has two keepable groups plus one dropped for
3136        // unresolvable instances; a cap of 1 keeps the first keepable group
3137        // and counts only the second as truncated (the unresolvable drop is
3138        // not a truncation).
3139        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        // The default cap leaves a small report untouched and unflagged.
3150        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        // The raw results carry one unresolvable cycle and one unresolvable
3181        // violation; the header counts only what the arrays render.
3182        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    /// A completed Health run still knows nothing about production execution
3216    /// unless a runtime coverage input was supplied. The lens must say that
3217    /// instead of letting a complete static answer imply a complete one.
3218    #[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    /// A drive-less rooted path is rooted but NOT absolute on Windows, so a
3238    /// redaction gated on `is_absolute` skipped it there and leaked the full
3239    /// path. Pinned on every platform because the predicate must not regress.
3240    #[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        // A genuinely relative path is not redacted; it is project-relative.
3252        assert_eq!(relative_path(Path::new("src/a.ts"), root), "src/a.ts");
3253
3254        // The JSON layer gates on the same predicate and must agree.
3255        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        // A value that is not under a path key is left alone regardless, so a
3260        // route specifier does not get treated as a filesystem path.
3261        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}