File Coverage

blib/lib/Test/SPDX/Coverage.pm
Criterion Covered Total %
statement 79 81 97.5
branch 44 54 81.4
condition 11 14 78.5
subroutine 6 6 100.0
pod 1 1 100.0
total 141 156 90.3


line stmt bran cond sub pod time code
1             package Test::SPDX::Coverage;
2 5     5   1132316 use strict;
  5         13  
  5         182  
3 5     5   20 use warnings;
  5         14  
  5         251  
4 5     5   2384 use License::SPDX;
  5         295546  
  5         219  
5 5     5   46 use Test::Builder;
  5         13  
  5         153  
6 5     5   28 use base qw{Exporter};
  5         9  
  5         7457  
7              
8             # SPDX-License-Identifier: MIT
9              
10             our $VERSION = '0.05';
11             our @EXPORT = qw{spdx_coverage_ok};
12              
13             =encoding utf8
14              
15             =head1 NAME
16              
17             Test::SPDX::Coverage - Perl Test Harness to verify all matched files in Manifest have a SPDX-License-Identifier
18              
19             =head1 SYNOPSIS
20              
21             #File: t/spdx-coverage.t
22             use Test::More;
23             eval "use Test::SPDX::Coverage";
24             plan skip_all => "Test::SPDX::Coverage required for testing SPDX-License-Identifier coverage" if $@;
25             spdx_coverage_ok();
26              
27             =head1 DESCRIPTION
28              
29             Test::SPDX::Coverage reads your manifest for .pm, .pl, .cgi files then searches for a SPDX-License-Identifier. Once found, the License specified on the SPDX-License-Identifier line is extracted and verified against the L database.
30              
31             For Perl source code, the SPDX-License-Identifier must be formatted like this:
32              
33             # SPDX-License-Identifier: LICENSE
34              
35             Examples:
36              
37             # SPDX-License-Identifier: Artistic-1.0-Perl OR GPL-1.0-or-later
38             # SPDX-License-Identifier: MIT
39              
40             Essentially, this is a wrapper around License::SPDX->new->check_license($license_string, {check_type => "name"}) for all Perl files in your MANIFEST.
41              
42             =head2 EXPORT
43              
44             =head3 spdx_coverage_ok
45              
46             spdx_coverage_ok();
47             spdx_coverage_ok({diag => 99}); #diag level 0-9
48             spdx_coverage_ok({manifest => "MANIFEST", match=>qr/\.(?:pm|pl|cgi)\Z/, lines=>500, diag => 0}); #defaults
49              
50             =cut
51              
52             sub spdx_coverage_ok {
53 4   100 4 1 193088 my $opt = shift || {};
54 4 50       19 die("Syntax: spdx_coverage_ok() or spdx_coverage_ok({})") unless ref($opt) eq 'HASH';
55              
56 4   100     21 $opt->{'manifest'} ||= "MANIFEST";
57 4 100       208 die(sprintf('Error: option "manifest" invalid. File "%s" not found.' , $opt->{'manifest'})) unless -f $opt->{'manifest'};
58 3 50       71 die(sprintf('Error: option "manifest" invalid. File "%s" not readable.', $opt->{'manifest'})) unless -r $opt->{'manifest'};
59              
60 3   66     19 my $match = $opt->{'match'} ||= qr/\.(?:pm|pl|cgi)\Z/;
61 3 50       19 die(sprintf('Error: option "match" invalid. Value "%s" must be a regular expression (e.g., qr//).', $match)) unless ref($match) eq "Regexp";
62              
63 3   100     19 my $lines = $opt->{'lines'} ||= 500; #the identifier is susposed to be in the "header" comments
64 3         8 $lines += 0;
65 3 50       8 die(sprintf('Error: option "lines" invalid. Value "%s" must be greater than zero.', $lines)) unless $lines > 0;
66              
67 3   100     10 my $diag = $opt->{'diag'} ||= 0; $diag+=0;
  3         5  
68 3   33     32 my $Test = $opt->{'builder'} ||= Test::Builder->new;
69 3 100       56 $Test->diag("Start") if $diag > 1;
70 3         1584 my @filenames = ();
71 3 100       39 $Test->diag(sprintf("Opening manifest file: %s", $opt->{'manifest'})) if $diag > 2;
72             #TODO: Use a package to read MANIFEST e.g. Module::Manifest
73             { #gather files for test plan count
74 3         1096 my $fh;
  3         8  
75 3 50       186 open($fh, '<', $opt->{'manifest'}) or die(sprintf('Error: option "manifest" invalid. File "%s" could not be opened.', $opt->{'manifest'}));
76 3 100       21 $Test->diag(qq{Reading manifest file}) if $diag > 3;
77 3         1114 while (my $entry = <$fh>) {
78 40         6425 $entry =~ s/\A\s*//; #ltrim - is this valid?
79 40 100       136 next if $entry =~ m/\A#/; #comments
80 39         393 $entry =~ s/\s*\Z//; #rtrim - instead of chomp for cross platform file support
81 39         107 $entry =~ s/\s.*\Z//; #strip comments - format is filename {whitespace} comment - #TODO: support quoted filenames with whitespace
82 39 100       153 $Test->diag("Filename: $entry") if $diag > 4;
83 39 100       13155 if ($entry =~ $match) {
84 10 100       65 $Test->diag("Filename: $entry, Action: Adding, Reason: File matches regular expression.") if $diag > 2;
85 10         5711 push @filenames, $entry;
86             } else {
87 29 100       146 $Test->diag("Filename: $entry, File does not match regular expression. Skipping.") if $diag > 5;
88             }
89             }
90 3         677 close($fh);
91             }
92 3 100       44 $Test->diag(sprintf("Files: %s", scalar(@filenames))) if $diag > 3;
93 3         1347 my $test_count = 2;
94 3         27 $Test->plan(tests => $test_count * @filenames);
95 3         3759 my $license_spdx = License::SPDX->new;
96 3         34659 foreach my $filename (@filenames) {
97 10 100       4182 $Test->diag("Filename: $filename") if $diag > 1;
98 10         5324 my $found;
99             { #scope for $fh
100 10         20 my $fh;
  10         17  
101 10 50       630 open($fh, '<', $filename) or die(sprintf('Error: File "%s" could not be opened for read', $filename));
102 10         26 my $line_number = 0;
103 10         623 foreach my $line_text (<$fh>) {
104 35         134 $line_number++;
105 35         141 $line_text =~ s/[\n\r]+\Z//; #chompish
106 35 100       131 if ($line_text =~ m/\A\s*#\s*SPDX-License-Identifier:\s*([a-zA-Z0-9 ()+.-]+)\s*\Z/) { #TODO: add c or xml capability i.e. //, /* */,
107 10         37 my $license_expression = $1;
108 10         84 $found = {filename=>$filename, line_number=>$line_number, line_text=> $line_text , license_expression=> $license_expression};
109 10 100       85 $Test->diag(qq{Filename: $filename, Line Number: $line_number, Line Text: "$line_text", License Expression: "$license_expression"}) if $diag > 0;
110             }
111 35 100       5167 last if $found;
112 25 50       66 last if $line_number >= $lines;
113             }
114 10         226 close($fh);
115             }
116 10 50       33 if ($found) {
117 10         56 $Test->ok(1, "SPDX-License-Identifier Found");
118 10         3660 my $license_expression = $found->{'license_expression'}; #might be as expression
119 10         51 my $separator = qr/ +(?:AND|OR|WITH) +/;
120 10 100       90 my @licenses = $license_expression=~ $separator ? (split $separator, $license_expression) : ($license_expression);
121 10         20 my $license_counter = scalar(@licenses);
122 10         23 foreach my $license (@licenses) {
123 12 50       55 my $check = $license_spdx->check_license($license) ? 1 : 0; #convert Boolean to 1/0
124 12 100       27725 $Test->diag("License: $license, Check: $check") if $diag > 1;
125 12 50       9853 $license_counter-- if $check;
126             }
127 10         52 $Test->ok($license_counter == 0, "SPDX-License-Identifier license expression is valid");
128             } else {
129 0         0 $Test->ok(0, "SPDX-License-Identifier was not found.");
130 0         0 $Test->skip("SPDX-License-Identifier license was not found.");
131             }
132             }
133 3 100       2344 $Test->diag("Finish") if $diag > 1;
134             }
135              
136             =head1 SEE ALSO
137              
138             L
139              
140             =head1 COPYRIGHT AND LICENSE
141              
142             Copyright (C) 2026 by Michael Davis, Michal Josef Špaček
143              
144             MIT
145              
146             =cut
147              
148             1;