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