1use std::collections::BTreeMap;
4use std::path::Path;
5use std::time::Duration;
6
7use fallow_engine::duplicates::DuplicationReport;
8use fallow_output::{
9 CHECK_SCHEMA_VERSION, CheckGroupedEntry, CheckGroupedOutput, CheckOutput, CheckOutputInput,
10 DupesOutput, DupesOutputInput, GroupByMode, RootEnvelopeMode,
11 apply_config_fixable_to_duplicate_exports, build_check_output, build_dupes_output,
12 strip_root_prefix,
13};
14use fallow_types::envelope::{
15 BaselineDeltas, BaselineMatch, ElapsedMs, Meta, RegressionResult, SchemaVersion, ToolVersion,
16};
17use fallow_types::output::NextStep;
18use fallow_types::results::AnalysisResults;
19use fallow_types::workspace::WorkspaceDiagnostic;
20
21use crate::{DupesReportPayload, DuplicationGroup, DuplicationGrouping, ResultGroup};
22
23type SuppressAnchor = (String, u64);
24
25pub struct CheckJsonOutputInput<'a> {
27 pub results: &'a AnalysisResults,
28 pub root: &'a Path,
29 pub elapsed: Duration,
30 pub config_fixable: bool,
31 pub meta: Option<Meta>,
32 pub extras: CheckJsonExtraOutputs,
33 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
34 pub next_steps: Vec<NextStep>,
35 pub envelope_mode: RootEnvelopeMode,
36 pub telemetry_analysis_run_id: Option<&'a str>,
37}
38
39pub struct CheckJsonPayloadInput<'a> {
41 pub results: &'a AnalysisResults,
42 pub root: &'a Path,
43 pub elapsed: Duration,
44 pub config_fixable: bool,
45 pub extras: CheckJsonExtraOutputs,
46 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
47}
48
49#[derive(Debug, Clone, Default)]
54pub struct CheckJsonExtraOutputs {
55 pub baseline_deltas: Option<BaselineDeltas>,
56 pub baseline: Option<BaselineMatch>,
57 pub regression: Option<RegressionResult>,
58}
59
60struct CheckJsonEnvelopeInput<'a> {
61 results: &'a AnalysisResults,
62 elapsed: Duration,
63 config_fixable: bool,
64 meta: Option<Meta>,
65 extras: CheckJsonExtraOutputs,
66 workspace_diagnostics: Vec<WorkspaceDiagnostic>,
67 next_steps: Vec<NextStep>,
68}
69
70pub struct GroupedCheckJsonOutputInput<'a> {
72 pub groups: &'a [ResultGroup],
73 pub original: &'a AnalysisResults,
74 pub root: &'a Path,
75 pub elapsed: Duration,
76 pub grouped_by: GroupByMode,
77 pub config_fixable: bool,
78 pub meta: Option<Meta>,
79 pub next_steps: Vec<NextStep>,
80 pub envelope_mode: RootEnvelopeMode,
81 pub telemetry_analysis_run_id: Option<&'a str>,
82}
83
84pub struct DuplicationJsonOutputInput<'a> {
86 pub report: &'a DuplicationReport,
87 pub root: &'a Path,
88 pub elapsed: Duration,
89 pub meta: Option<Meta>,
90 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
91 pub next_steps: Vec<NextStep>,
92 pub envelope_mode: RootEnvelopeMode,
93 pub telemetry_analysis_run_id: Option<&'a str>,
94}
95
96pub struct GroupedDuplicationJsonOutputInput<'a> {
98 pub report: &'a DuplicationReport,
99 pub grouping: &'a DuplicationGrouping,
100 pub root: &'a Path,
101 pub elapsed: Duration,
102 pub meta: Option<Meta>,
103 pub workspace_diagnostics: Vec<WorkspaceDiagnostic>,
104 pub next_steps: Vec<NextStep>,
105 pub envelope_mode: RootEnvelopeMode,
106 pub telemetry_analysis_run_id: Option<&'a str>,
107}
108
109pub fn serialize_check_json(
115 input: CheckJsonOutputInput<'_>,
116) -> Result<serde_json::Value, serde_json::Error> {
117 let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
118 results: input.results,
119 elapsed: input.elapsed,
120 config_fixable: input.config_fixable,
121 meta: input.meta,
122 extras: input.extras,
123 workspace_diagnostics: input.workspace_diagnostics,
124 next_steps: input.next_steps,
125 });
126 let mut output = fallow_output::serialize_check_json_output(
127 envelope,
128 input.envelope_mode,
129 input.telemetry_analysis_run_id,
130 )?;
131 postprocess_check_json(&mut output, input.root);
132 Ok(output)
133}
134
135pub fn serialize_check_json_payload(
141 input: CheckJsonPayloadInput<'_>,
142) -> Result<serde_json::Value, serde_json::Error> {
143 let envelope = build_check_json_envelope(CheckJsonEnvelopeInput {
144 results: input.results,
145 elapsed: input.elapsed,
146 config_fixable: input.config_fixable,
147 meta: None,
148 extras: input.extras,
149 workspace_diagnostics: input.workspace_diagnostics,
150 next_steps: Vec::new(),
151 });
152 let mut output = serde_json::to_value(envelope)?;
153 postprocess_check_json(&mut output, input.root);
154 Ok(output)
155}
156
157pub fn serialize_grouped_check_json(
163 input: GroupedCheckJsonOutputInput<'_>,
164) -> Result<serde_json::Value, serde_json::Error> {
165 let entries = input
166 .groups
167 .iter()
168 .map(|group| {
169 let mut results = group.results.clone();
170 apply_config_fixable_to_duplicate_exports(&mut results, input.config_fixable);
171 CheckGroupedEntry {
172 key: group.key.clone(),
173 owners: group.owners.clone(),
174 total_issues: results.total_issues(),
175 results,
176 }
177 })
178 .collect();
179
180 let envelope = CheckGroupedOutput {
181 schema_version: SchemaVersion(CHECK_SCHEMA_VERSION),
182 version: ToolVersion(env!("CARGO_PKG_VERSION").to_string()),
183 elapsed_ms: ElapsedMs(input.elapsed.as_millis() as u64),
184 grouped_by: input.grouped_by,
185 total_issues: input.original.total_issues(),
186 groups: entries,
187 meta: input.meta,
188 next_steps: input.next_steps,
189 };
190
191 let mut output = fallow_output::serialize_check_grouped_json_output(
192 envelope,
193 input.envelope_mode,
194 input.telemetry_analysis_run_id,
195 )?;
196 let root_prefix = format!("{}/", input.root.display());
197 if let Some(arr) = output
198 .get_mut("groups")
199 .and_then(serde_json::Value::as_array_mut)
200 {
201 for entry in arr {
202 strip_root_prefix(entry, &root_prefix);
203 harmonize_multi_kind_suppress_line_actions(entry);
204 }
205 }
206 Ok(output)
207}
208
209pub fn serialize_duplication_json(
215 input: DuplicationJsonOutputInput<'_>,
216) -> Result<serde_json::Value, serde_json::Error> {
217 let payload = DupesReportPayload::from_report(input.report);
218 let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
219 build_dupes_output(DupesOutputInput {
220 schema_version: CHECK_SCHEMA_VERSION,
221 version: env!("CARGO_PKG_VERSION").to_string(),
222 elapsed: input.elapsed,
223 report: payload,
224 grouped_by: None,
225 total_issues: None,
226 groups: None,
227 meta: input.meta,
228 workspace_diagnostics: input.workspace_diagnostics,
229 next_steps: input.next_steps,
230 });
231 let mut output = fallow_output::serialize_dupes_json_output(
232 envelope,
233 input.envelope_mode,
234 input.telemetry_analysis_run_id,
235 )?;
236 let root_prefix = format!("{}/", input.root.display());
237 strip_root_prefix(&mut output, &root_prefix);
238 Ok(output)
239}
240
241pub fn serialize_grouped_duplication_json(
247 input: GroupedDuplicationJsonOutputInput<'_>,
248) -> Result<serde_json::Value, serde_json::Error> {
249 let root_prefix = format!("{}/", input.root.display());
250 let payload = DupesReportPayload::from_report(input.report);
251 let envelope: DupesOutput<DupesReportPayload, DuplicationGroup> =
252 build_dupes_output(DupesOutputInput {
253 schema_version: CHECK_SCHEMA_VERSION,
254 version: env!("CARGO_PKG_VERSION").to_string(),
255 elapsed: input.elapsed,
256 report: payload,
257 grouped_by: Some(group_by_mode_from_label(input.grouping.mode)),
258 total_issues: Some(input.report.clone_groups.len()),
259 groups: None,
260 meta: input.meta,
261 workspace_diagnostics: input.workspace_diagnostics,
262 next_steps: input.next_steps,
263 });
264 let mut output = fallow_output::serialize_dupes_json_output(
265 envelope,
266 input.envelope_mode,
267 input.telemetry_analysis_run_id,
268 )?;
269 strip_root_prefix(&mut output, &root_prefix);
270
271 let group_values = input
272 .grouping
273 .groups
274 .iter()
275 .map(|group| {
276 let mut value = serde_json::to_value(group)?;
277 strip_root_prefix(&mut value, &root_prefix);
278 Ok(value)
279 })
280 .collect::<Result<Vec<_>, serde_json::Error>>()?;
281
282 if let serde_json::Value::Object(ref mut map) = output {
283 map.insert("groups".to_string(), serde_json::Value::Array(group_values));
284 }
285
286 Ok(output)
287}
288
289fn build_check_json_envelope(input: CheckJsonEnvelopeInput<'_>) -> CheckOutput {
290 let mut output = build_check_output(CheckOutputInput {
291 schema_version: CHECK_SCHEMA_VERSION,
292 version: env!("CARGO_PKG_VERSION").to_string(),
293 elapsed: input.elapsed,
294 results: input.results.clone(),
295 config_fixable: input.config_fixable,
296 meta: input.meta,
297 workspace_diagnostics: input.workspace_diagnostics,
298 next_steps: input.next_steps,
299 });
300 output.baseline_deltas = input.extras.baseline_deltas;
301 output.baseline = input.extras.baseline;
302 output.regression = input.extras.regression;
303 output
304}
305
306fn postprocess_check_json(output: &mut serde_json::Value, root: &Path) {
307 let root_prefix = format!("{}/", root.display());
308 strip_root_prefix(output, &root_prefix);
309 harmonize_multi_kind_suppress_line_actions(output);
310}
311
312pub fn harmonize_multi_kind_suppress_line_actions(output: &mut serde_json::Value) {
314 let mut anchors: BTreeMap<SuppressAnchor, Vec<String>> = BTreeMap::new();
315 collect_suppress_line_anchors(output, &mut anchors);
316
317 anchors.retain(|_, kinds| {
318 sort_suppression_kinds(kinds);
319 kinds.dedup();
320 kinds.len() > 1
321 });
322 if anchors.is_empty() {
323 return;
324 }
325
326 rewrite_suppress_line_actions(output, &anchors);
327}
328
329fn collect_suppress_line_anchors(
330 value: &serde_json::Value,
331 anchors: &mut BTreeMap<SuppressAnchor, Vec<String>>,
332) {
333 match value {
334 serde_json::Value::Object(map) => {
335 if let Some(anchor) = suppression_anchor(map)
336 && let Some(actions) = map.get("actions").and_then(serde_json::Value::as_array)
337 {
338 for action in actions {
339 if let Some(comment) = suppress_line_comment(action) {
340 for kind in parse_suppress_line_comment(comment) {
341 let kinds = anchors.entry(anchor.clone()).or_default();
342 if !kinds.iter().any(|existing| existing == &kind) {
343 kinds.push(kind);
344 }
345 }
346 }
347 }
348 }
349
350 for child in map.values() {
351 collect_suppress_line_anchors(child, anchors);
352 }
353 }
354 serde_json::Value::Array(items) => {
355 for item in items {
356 collect_suppress_line_anchors(item, anchors);
357 }
358 }
359 _ => {}
360 }
361}
362
363fn rewrite_suppress_line_actions(
364 value: &mut serde_json::Value,
365 anchors: &BTreeMap<SuppressAnchor, Vec<String>>,
366) {
367 match value {
368 serde_json::Value::Object(map) => {
369 if let Some(anchor) = suppression_anchor(map)
370 && let Some(kinds) = anchors.get(&anchor)
371 {
372 let comment = format!("// fallow-ignore-next-line {}", kinds.join(", "));
373 if let Some(actions) = map
374 .get_mut("actions")
375 .and_then(serde_json::Value::as_array_mut)
376 {
377 for action in actions {
378 if suppress_line_comment(action).is_some()
379 && let serde_json::Value::Object(action_map) = action
380 {
381 action_map.insert("comment".to_string(), serde_json::json!(comment));
382 }
383 }
384 }
385 }
386
387 for child in map.values_mut() {
388 rewrite_suppress_line_actions(child, anchors);
389 }
390 }
391 serde_json::Value::Array(items) => {
392 for item in items {
393 rewrite_suppress_line_actions(item, anchors);
394 }
395 }
396 _ => {}
397 }
398}
399
400fn suppression_anchor(map: &serde_json::Map<String, serde_json::Value>) -> Option<SuppressAnchor> {
401 let path = map
402 .get("path")
403 .or_else(|| map.get("from_path"))
404 .and_then(serde_json::Value::as_str)?;
405 let line = map.get("line").and_then(serde_json::Value::as_u64)?;
406 Some((path.to_string(), line))
407}
408
409fn suppress_line_comment(action: &serde_json::Value) -> Option<&str> {
410 (action.get("type").and_then(serde_json::Value::as_str) == Some("suppress-line"))
411 .then_some(())
412 .and_then(|()| action.get("comment").and_then(serde_json::Value::as_str))
413}
414
415fn parse_suppress_line_comment(comment: &str) -> Vec<String> {
416 comment
417 .strip_prefix("// fallow-ignore-next-line ")
418 .map(|rest| {
419 rest.split(|c: char| c == ',' || c.is_whitespace())
420 .filter(|token| !token.is_empty())
421 .map(str::to_string)
422 .collect()
423 })
424 .unwrap_or_default()
425}
426
427fn sort_suppression_kinds(kinds: &mut [String]) {
428 kinds.sort_by_key(|kind| suppression_kind_rank(kind));
429}
430
431fn suppression_kind_rank(kind: &str) -> usize {
432 match kind {
433 "unused-file" => 0,
434 "unused-export" => 1,
435 "unused-type" => 2,
436 "private-type-leak" => 3,
437 "unused-enum-member" => 4,
438 "unused-class-member" => 5,
439 "unused-store-member" => 6,
440 "unresolved-import" => 7,
441 "unlisted-dependency" => 8,
442 "duplicate-export" => 9,
443 "circular-dependency" => 10,
444 "re-export-cycle" => 11,
445 "boundary-violation" => 12,
446 "code-duplication" => 13,
447 "complexity" => 14,
448 "unprovided-inject" => 15,
449 "unrendered-component" => 16,
450 "unused-server-action" => 17,
451 _ => usize::MAX,
452 }
453}
454
455fn group_by_mode_from_label(label: &str) -> GroupByMode {
456 match label {
457 "directory" => GroupByMode::Directory,
458 "package" => GroupByMode::Package,
459 "section" => GroupByMode::Section,
460 _ => GroupByMode::Owner,
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use serde_json::json;
467
468 use super::*;
469
470 #[test]
471 fn harmonize_suppress_actions_merges_same_line_issue_kinds() {
472 let mut output = json!({
473 "unused_exports": [{
474 "path": "src/api.ts",
475 "line": 4,
476 "actions": [{
477 "type": "suppress-line",
478 "comment": "// fallow-ignore-next-line unused-export"
479 }]
480 }],
481 "unused_types": [{
482 "path": "src/api.ts",
483 "line": 4,
484 "actions": [{
485 "type": "suppress-line",
486 "comment": "// fallow-ignore-next-line unused-type"
487 }]
488 }]
489 });
490
491 harmonize_multi_kind_suppress_line_actions(&mut output);
492
493 assert_eq!(
494 output["unused_exports"][0]["actions"][0]["comment"],
495 "// fallow-ignore-next-line unused-export, unused-type"
496 );
497 assert_eq!(
498 output["unused_types"][0]["actions"][0]["comment"],
499 "// fallow-ignore-next-line unused-export, unused-type"
500 );
501 }
502}