Skip to main content

cpd_reporter/deadcode/
ci.rs

1//! Reports a CI system consumes: SARIF, Code Climate, OpenMetrics, a badge,
2//! Xcode diagnostics, and the exit-code gate.
3
4use super::structured::escape_xml;
5use super::{DeadCodeContext, DeadCodeReporter, reasons_clause, severity};
6use crate::reporter::{ReporterError, ReporterOptions};
7use crate::shared::{Style, write_report_file};
8use cpd_core::deadcode::{Category, Finding};
9use serde_json::{Value, json};
10use std::path::Path;
11
12/// SARIF 2.1.0, which GitHub code scanning reads directly.
13pub struct SarifReporter {
14    style: Style,
15    tool_version: String,
16}
17
18impl SarifReporter {
19    pub fn new(options: &ReporterOptions) -> Self {
20        Self {
21            style: Style::new(options.no_colors),
22            tool_version: options.tool_version.clone(),
23        }
24    }
25}
26
27impl DeadCodeReporter for SarifReporter {
28    fn name(&self) -> &str {
29        "sarif"
30    }
31
32    fn report(
33        &self,
34        findings: &[Finding],
35        _ctx: &DeadCodeContext<'_>,
36        output_dir: &Path,
37    ) -> Result<(), ReporterError> {
38        // Every category is declared as a rule whether or not it fired, so a
39        // run that fixes the last finding of a category still resolves that
40        // category's alerts instead of leaving them orphaned.
41        let rules: Vec<Value> = Category::ALL
42            .iter()
43            .map(|category| {
44                json!({
45                    "id": category.as_str(),
46                    "name": category.title(),
47                    "shortDescription": { "text": category.title() },
48                    "fullDescription": { "text": rule_description(*category) },
49                    "defaultConfiguration": { "level": "warning" },
50                    "properties": { "tags": ["dead-code", "maintainability"] }
51                })
52            })
53            .collect();
54
55        let results: Vec<Value> = findings
56            .iter()
57            .map(|finding| {
58                let mut result = json!({
59                    "ruleId": finding.category.as_str(),
60                    "level": severity(finding).sarif(),
61                    "message": { "text": finding.message },
62                    "locations": [{
63                        "physicalLocation": {
64                            "artifactLocation": { "uri": finding.path },
65                            "region": {
66                                "startLine": finding.start.line.max(1),
67                                "startColumn": finding.start.column + 1,
68                                "endLine": finding.end.line.max(finding.start.line).max(1)
69                            }
70                        }
71                    }],
72                    "partialFingerprints": { "bastaFingerprint/v1": finding.fingerprint() },
73                    "properties": {
74                        "confidence": finding.confidence,
75                        "level": finding.level().as_str()
76                    }
77                });
78                let reasons = reasons_clause(finding);
79                if !reasons.is_empty() {
80                    result["properties"]["reasons"] = json!(reasons);
81                }
82                result
83            })
84            .collect();
85
86        let sarif = json!({
87            "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
88            "version": "2.1.0",
89            "runs": [{
90                "tool": { "driver": {
91                    "name": "basta",
92                    "informationUri": "https://jscpd.dev",
93                    "version": self.tool_version,
94                    "rules": rules
95                }},
96                "results": results
97            }]
98        });
99        let json = serde_json::to_string_pretty(&sarif)
100            .map_err(|e| ReporterError::Format(e.to_string()))?;
101        write_report_file(
102            output_dir,
103            "basta-report.sarif",
104            json,
105            &self.style,
106            "Dead code SARIF",
107        )?;
108        Ok(())
109    }
110}
111
112fn rule_description(category: Category) -> &'static str {
113    match category {
114        Category::UnusedFile => {
115            "A file that no entry point reaches through the import graph. Nothing runs it."
116        }
117        Category::UnusedExport => "An exported declaration that no module in the project imports.",
118        Category::UnusedSymbol => {
119            "A module-private declaration that nothing in its own module reaches."
120        }
121        Category::UnusedImport => "An import binding with no references in the file that made it.",
122        Category::UnusedMember => {
123            "A class or enum member whose name is never read anywhere in the project."
124        }
125    }
126}
127
128/// Code Climate / GitLab Code Quality JSON.
129pub struct CodeClimateReporter {
130    style: Style,
131}
132
133impl CodeClimateReporter {
134    pub fn new(options: &ReporterOptions) -> Self {
135        Self {
136            style: Style::new(options.no_colors),
137        }
138    }
139}
140
141impl DeadCodeReporter for CodeClimateReporter {
142    fn name(&self) -> &str {
143        "codeclimate"
144    }
145
146    fn report(
147        &self,
148        findings: &[Finding],
149        _ctx: &DeadCodeContext<'_>,
150        output_dir: &Path,
151    ) -> Result<(), ReporterError> {
152        let issues: Vec<Value> = findings
153            .iter()
154            .map(|finding| {
155                json!({
156                    "type": "issue",
157                    "check_name": format!("basta/{}", finding.category.as_str()),
158                    "description": finding.message,
159                    "categories": ["Complexity"],
160                    "fingerprint": finding.fingerprint(),
161                    "severity": severity(finding).codeclimate(),
162                    "location": {
163                        "path": finding.path,
164                        "lines": {
165                            "begin": finding.start.line.max(1),
166                            "end": finding.end.line.max(finding.start.line).max(1)
167                        }
168                    }
169                })
170            })
171            .collect();
172        let json = serde_json::to_string_pretty(&issues)
173            .map_err(|e| ReporterError::Format(e.to_string()))?;
174        write_report_file(
175            output_dir,
176            "basta-codeclimate.json",
177            json,
178            &self.style,
179            "Dead code Code Climate",
180        )?;
181        Ok(())
182    }
183}
184
185/// OpenMetrics counters, for a metrics pipeline that tracks dead code over time.
186pub struct OpenMetricsReporter {
187    style: Style,
188}
189
190impl OpenMetricsReporter {
191    pub fn new(options: &ReporterOptions) -> Self {
192        Self {
193            style: Style::new(options.no_colors),
194        }
195    }
196}
197
198impl DeadCodeReporter for OpenMetricsReporter {
199    fn name(&self) -> &str {
200        "openmetrics"
201    }
202
203    fn report(
204        &self,
205        findings: &[Finding],
206        ctx: &DeadCodeContext<'_>,
207        output_dir: &Path,
208    ) -> Result<(), ReporterError> {
209        let stats = ctx.stats;
210        let mut out = String::new();
211        out.push_str("# TYPE basta_findings gauge\n");
212        out.push_str("# HELP basta_findings Dead code findings by category.\n");
213        for category in Category::ALL {
214            out.push_str(&format!(
215                "basta_findings{{category=\"{}\"}} {}\n",
216                category.as_str(),
217                stats.count_of(*category)
218            ));
219        }
220        for (name, help, value) in [
221            (
222                "basta_findings_total",
223                "Dead code findings.",
224                findings.len() as f64,
225            ),
226            ("basta_files", "Files analyzed.", f64::from(stats.files)),
227            (
228                "basta_files_reachable",
229                "Files an entry point reaches.",
230                f64::from(stats.reachable_files),
231            ),
232            (
233                "basta_files_unparsed",
234                "Files that could not be parsed.",
235                f64::from(stats.unparsed),
236            ),
237            (
238                "basta_entry_points",
239                "Entry points detected.",
240                f64::from(stats.entry_points),
241            ),
242            (
243                "basta_dead_lines",
244                "Lines covered by findings.",
245                f64::from(stats.dead_lines),
246            ),
247            (
248                "basta_total_lines",
249                "Lines analyzed.",
250                f64::from(stats.total_lines),
251            ),
252            (
253                "basta_dead_percentage",
254                "Dead lines as a percentage of lines analyzed.",
255                stats.percentage,
256            ),
257        ] {
258            out.push_str(&format!(
259                "# TYPE {name} gauge\n# HELP {name} {help}\n{name} {value}\n"
260            ));
261        }
262        out.push_str("# EOF\n");
263        write_report_file(
264            output_dir,
265            "basta-report.txt",
266            out,
267            &self.style,
268            "Dead code OpenMetrics",
269        )?;
270        Ok(())
271    }
272}
273
274/// An SVG badge for a README.
275pub struct BadgeReporter {
276    style: Style,
277}
278
279impl BadgeReporter {
280    pub fn new(options: &ReporterOptions) -> Self {
281        Self {
282            style: Style::new(options.no_colors),
283        }
284    }
285}
286
287impl DeadCodeReporter for BadgeReporter {
288    fn name(&self) -> &str {
289        "badge"
290    }
291
292    fn report(
293        &self,
294        findings: &[Finding],
295        _ctx: &DeadCodeContext<'_>,
296        output_dir: &Path,
297    ) -> Result<(), ReporterError> {
298        let count = findings.len();
299        let value = if count == 0 {
300            "none".to_string()
301        } else {
302            count.to_string()
303        };
304        let color = match count {
305            0 => "#4c1",
306            1..=10 => "#dfb317",
307            _ => "#e05d44",
308        };
309        let label = "dead code";
310        // 6px per character plus padding is the same approximation the clone
311        // badge uses, so the two sit together without one looking cramped.
312        let label_width = label.len() * 6 + 10;
313        let value_width = value.len() * 7 + 10;
314        let total = label_width + value_width;
315        let svg = format!(
316            r##"<svg xmlns="http://www.w3.org/2000/svg" width="{total}" height="20" role="img" aria-label="{label}: {value}">
317  <title>{label}: {value}</title>
318  <linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>
319  <clipPath id="r"><rect width="{total}" height="20" rx="3" fill="#fff"/></clipPath>
320  <g clip-path="url(#r)">
321    <rect width="{label_width}" height="20" fill="#555"/>
322    <rect x="{label_width}" width="{value_width}" height="20" fill="{color}"/>
323    <rect width="{total}" height="20" fill="url(#s)"/>
324  </g>
325  <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
326    <text x="{label_x}" y="15" fill="#010101" fill-opacity=".3">{label}</text>
327    <text x="{label_x}" y="14">{label}</text>
328    <text x="{value_x}" y="15" fill="#010101" fill-opacity=".3">{value}</text>
329    <text x="{value_x}" y="14">{value}</text>
330  </g>
331</svg>
332"##,
333            label = escape_xml(label),
334            value = escape_xml(&value),
335            label_x = label_width / 2,
336            value_x = label_width + value_width / 2,
337        );
338        write_report_file(
339            output_dir,
340            "basta-badge.svg",
341            svg,
342            &self.style,
343            "Dead code badge",
344        )?;
345        Ok(())
346    }
347}
348
349/// Xcode-style diagnostics on stdout, which Xcode and many editors parse.
350pub struct XcodeReporter;
351
352impl XcodeReporter {
353    pub fn new(_options: &ReporterOptions) -> Self {
354        Self
355    }
356}
357
358impl DeadCodeReporter for XcodeReporter {
359    fn name(&self) -> &str {
360        "xcode"
361    }
362
363    fn report(
364        &self,
365        findings: &[Finding],
366        _ctx: &DeadCodeContext<'_>,
367        _output_dir: &Path,
368    ) -> Result<(), ReporterError> {
369        for finding in findings {
370            println!(
371                "{}:{}:{}: {}: {} [basta.{}]",
372                finding.path,
373                finding.start.line.max(1),
374                finding.start.column + 1,
375                severity(finding).xcode(),
376                finding.message,
377                finding.category.as_str(),
378            );
379        }
380        Ok(())
381    }
382}
383
384/// Fails the run when dead code exceeds a percentage of the codebase.
385pub struct ThresholdReporter {
386    threshold: Option<f64>,
387}
388
389impl ThresholdReporter {
390    pub fn new(options: &ReporterOptions) -> Self {
391        Self {
392            threshold: options.threshold,
393        }
394    }
395}
396
397impl DeadCodeReporter for ThresholdReporter {
398    fn name(&self) -> &str {
399        "threshold"
400    }
401
402    fn report(
403        &self,
404        _findings: &[Finding],
405        ctx: &DeadCodeContext<'_>,
406        _output_dir: &Path,
407    ) -> Result<(), ReporterError> {
408        let Some(threshold) = self.threshold else {
409            return Ok(());
410        };
411        if ctx.stats.percentage > threshold {
412            return Err(ReporterError::ThresholdExceeded {
413                actual: ctx.stats.percentage,
414                threshold,
415            });
416        }
417        Ok(())
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::super::fixtures;
424    use super::*;
425    use std::path::PathBuf;
426    use std::time::Duration;
427
428    fn write(name: &str, reporter: &dyn DeadCodeReporter, file: &str) -> String {
429        let dir = fixtures::unique_dir(name);
430        let stats = fixtures::stats();
431        let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
432        reporter
433            .report(&fixtures::findings(), &ctx, &dir)
434            .expect("report");
435        let content = std::fs::read_to_string(dir.join(file)).unwrap();
436        std::fs::remove_dir_all(&dir).ok();
437        content
438    }
439
440    fn options() -> ReporterOptions {
441        ReporterOptions::new(PathBuf::from("/tmp"))
442    }
443
444    #[test]
445    fn sarif_declares_every_rule_and_one_result_per_finding() {
446        let sarif = write(
447            "sarif",
448            &SarifReporter::new(&options()),
449            "basta-report.sarif",
450        );
451        let value: Value = serde_json::from_str(&sarif).expect("valid JSON");
452        assert_eq!(value["version"], "2.1.0");
453        let run = &value["runs"][0];
454        assert_eq!(run["tool"]["driver"]["name"], "basta");
455        assert_eq!(
456            run["tool"]["driver"]["rules"].as_array().unwrap().len(),
457            Category::ALL.len(),
458            "a category with no findings still needs its rule, or its alerts never resolve"
459        );
460        let results = run["results"].as_array().unwrap();
461        assert_eq!(results.len(), 3);
462        assert_eq!(results[0]["ruleId"], "unused-file");
463        assert!(results[0]["partialFingerprints"]["bastaFingerprint/v1"].is_string());
464    }
465
466    #[test]
467    fn sarif_regions_are_one_based_and_never_zero() {
468        let mut finding = fixtures::finding(Category::UnusedFile, "a.ts", "", 95);
469        finding.start = fixtures::location(0);
470        finding.end = fixtures::location(0);
471        let dir = fixtures::unique_dir("sarif-zero");
472        let stats = fixtures::stats();
473        let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
474        SarifReporter::new(&options())
475            .report(&[finding], &ctx, &dir)
476            .unwrap();
477        let value: Value =
478            serde_json::from_str(&std::fs::read_to_string(dir.join("basta-report.sarif")).unwrap())
479                .unwrap();
480        let region = &value["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"];
481        assert_eq!(region["startLine"], 1, "SARIF lines start at 1");
482        assert_eq!(region["startColumn"], 1);
483        std::fs::remove_dir_all(&dir).ok();
484    }
485
486    #[test]
487    fn codeclimate_issues_carry_a_stable_fingerprint() {
488        let json = write(
489            "cc",
490            &CodeClimateReporter::new(&options()),
491            "basta-codeclimate.json",
492        );
493        let issues: Vec<Value> = serde_json::from_str(&json).unwrap();
494        assert_eq!(issues.len(), 3);
495        assert_eq!(issues[0]["type"], "issue");
496        assert!(
497            issues[0]["check_name"]
498                .as_str()
499                .unwrap()
500                .starts_with("basta/")
501        );
502        assert!(issues[0]["fingerprint"].is_string());
503    }
504
505    #[test]
506    fn openmetrics_emits_one_series_per_category_and_ends_with_eof() {
507        let text = write(
508            "om",
509            &OpenMetricsReporter::new(&options()),
510            "basta-report.txt",
511        );
512        for category in Category::ALL {
513            assert!(
514                text.contains(&format!("category=\"{}\"", category.as_str())),
515                "{text}"
516            );
517        }
518        assert!(text.contains("basta_findings_total 3"), "{text}");
519        assert!(
520            text.ends_with("# EOF\n"),
521            "OpenMetrics requires the EOF marker"
522        );
523    }
524
525    #[test]
526    fn the_badge_changes_colour_with_the_count() {
527        let clean = badge_for(&[]);
528        assert!(
529            clean.contains("#4c1") && clean.contains(">none<"),
530            "{clean}"
531        );
532        let some = badge_for(&fixtures::findings());
533        assert!(some.contains("#dfb317") && some.contains(">3<"), "{some}");
534    }
535
536    fn badge_for(findings: &[Finding]) -> String {
537        let dir = fixtures::unique_dir("badge");
538        let stats = fixtures::stats();
539        let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
540        BadgeReporter::new(&options())
541            .report(findings, &ctx, &dir)
542            .unwrap();
543        let svg = std::fs::read_to_string(dir.join("basta-badge.svg")).unwrap();
544        std::fs::remove_dir_all(&dir).ok();
545        svg
546    }
547
548    #[test]
549    fn the_threshold_reporter_fails_only_above_its_limit() {
550        let stats = fixtures::stats(); // 3.4%
551        let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
552        let mut options = options();
553
554        options.threshold = Some(5.0);
555        assert!(
556            ThresholdReporter::new(&options)
557                .report(&[], &ctx, Path::new("/tmp"))
558                .is_ok()
559        );
560
561        options.threshold = Some(1.0);
562        let error = ThresholdReporter::new(&options)
563            .report(&[], &ctx, Path::new("/tmp"))
564            .unwrap_err();
565        assert!(matches!(error, ReporterError::ThresholdExceeded { .. }));
566
567        options.threshold = None;
568        assert!(
569            ThresholdReporter::new(&options)
570                .report(&[], &ctx, Path::new("/tmp"))
571                .is_ok(),
572            "without a threshold there is nothing to exceed"
573        );
574    }
575
576    #[test]
577    fn xcode_output_does_not_panic_on_a_zero_line_finding() {
578        let mut finding = fixtures::finding(Category::UnusedFile, "a.ts", "", 95);
579        finding.start = fixtures::location(0);
580        let stats = fixtures::stats();
581        let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
582        assert!(
583            XcodeReporter
584                .report(&[finding], &ctx, Path::new("/tmp"))
585                .is_ok()
586        );
587    }
588}