| File: | blib/lib/App/Test/Generator/Mutation/BooleanNegation.pm |
| Coverage: | 82.0% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package App::Test::Generator::Mutation::BooleanNegation; | |||||
| 2 | ||||||
| 3 | 10 10 10 | 204507 7 137 | use strict; | |||
| 4 | 10 10 10 | 13 7 202 | use warnings; | |||
| 5 | ||||||
| 6 | 10 10 10 | 189 120 27 | use parent 'App::Test::Generator::Mutation::Base'; | |||
| 7 | 10 10 10 | 1519 11 129 | use App::Test::Generator::Mutant; | |||
| 8 | 10 10 10 | 408 137183 3017 | use PPI; | |||
| 9 | ||||||
| 10 | our $VERSION = '0.36'; | |||||
| 11 | ||||||
| 12 - 58 | =head1 NAME
App::Test::Generator::Mutation::BooleanNegation - Negate boolean return
expressions to expose missing assertion coverage
=head1 VERSION
Version 0.36
=head1 METHODS
=head2 applies_to
Return true if this mutation strategy applies to the given PPI node.
Used by the mutation framework to pre-filter nodes before calling
C<mutate>.
my $applies = $mutation->applies_to($node);
=head3 Arguments
=over 4
=item * C<$node>
A L<PPI::Element> node to test.
=back
=head3 Returns
True if the node is a C<PPI::Statement::Return>, false otherwise.
=head3 API specification
=head4 input
{
self => { type => OBJECT, isa => 'App::Test::Generator::Mutation::BooleanNegation' },
node => { type => OBJECT, isa => 'PPI::Element' },
}
=head4 output
{ type => SCALAR }
=cut | |||||
| 59 | ||||||
| 60 | sub applies_to { | |||||
| 61 | 9 | 11592 | my ($self, $node) = @_; | |||
| 62 | ||||||
| 63 | # PPI >= 1.270 classifies return as PPI::Statement::Break | |||||
| 64 | # rather than PPI::Statement::Return | |||||
| 65 | 9 | 23 | return 0 unless $node->isa('PPI::Statement::Break'); | |||
| 66 | ||||||
| 67 | # Must specifically be a return statement, not last/next/redo | |||||
| 68 | 4 | 6 | my $first = $node->schild(0) or return 0; | |||
| 69 | 4 | 27 | return $first->content eq 'return'; | |||
| 70 | } | |||||
| 71 | ||||||
| 72 - 145 | =head2 mutate
Walk a PPI document and generate one mutant for each return statement
whose expression can be negated. For example, C<return $ok> becomes
C<return !($ok)>.
my $mutation = App::Test::Generator::Mutation::BooleanNegation->new;
my $doc = PPI::Document->new(\$source);
my @mutants = $mutation->mutate($doc);
for my $m (@mutants) {
print $m->id, ': ', $m->description, "\n";
}
=head3 Arguments
=over 4
=item * C<$self>
An instance of C<App::Test::Generator::Mutation::BooleanNegation>.
=item * C<$doc>
A L<PPI::Document> object representing the parsed source to mutate.
The document is not modified by this method.
=back
=head3 Returns
A list of L<App::Test::Generator::Mutant> objects, one per qualifying
return statement found in the document. Returns an empty list if no
return statements with expressions are found.
Each mutant carries a C<transform> closure that when called with a
fresh L<PPI::Document> copy will wrap the targeted return expression
in C<!( )>, negating its boolean value.
=head3 Notes
Mutant IDs include both line and column number to ensure uniqueness
when multiple return statements appear on different lines of the same
source file.
Only return statements that have an expression child (i.e. not bare
C<return;> statements) are mutated.
=head3 API specification
=head4 input
{
self => {
type => OBJECT,
isa => 'App::Test::Generator::Mutation::BooleanNegation',
},
doc => {
type => OBJECT,
isa => 'PPI::Document',
},
}
=head4 output
{
type => ARRAYREF,
elements => {
type => OBJECT,
isa => 'App::Test::Generator::Mutant',
},
}
=cut | |||||
| 146 | ||||||
| 147 | sub mutate { | |||||
| 148 | 69 | 45379 | my ($self, $doc) = @_; | |||
| 149 | ||||||
| 150 | # PPI >= 1.270 classifies return statements as PPI::Statement::Break | |||||
| 151 | # (alongside last/next/redo) rather than PPI::Statement::Return. | |||||
| 152 | # Use a custom predicate to match only 'return' Break nodes. | |||||
| 153 | my $returns = $doc->find(sub { | |||||
| 154 | 4722 | 18486 | my $node = $_[1]; | |||
| 155 | # Must be a Break statement -- the parent class for return in | |||||
| 156 | # newer PPI versions | |||||
| 157 | 4722 | 5551 | return 0 unless $node->isa('PPI::Statement::Break'); | |||
| 158 | # Distinguish return from last/next/redo by checking the | |||||
| 159 | # first significant child token | |||||
| 160 | 160 | 158 | my $first = $node->schild(0) or return 0; | |||
| 161 | 160 | 869 | return $first->content eq 'return'; | |||
| 162 | 69 | 254 | }) || []; | |||
| 163 | ||||||
| 164 | 69 | 459 | my @mutants; | |||
| 165 | ||||||
| 166 | 69 69 | 65 80 | for my $ret (@{$returns}) { | |||
| 167 | # Skip bare return statements with no expression to negate. | |||||
| 168 | # Also skip if the only child after 'return' is a semicolon â | |||||
| 169 | # PPI may include the statement terminator as a significant child | |||||
| 170 | 160 | 162 | my $expr = $ret->schild(1) or next; | |||
| 171 | 160 | 1204 | next if $expr->isa('PPI::Token::Structure') && $expr->content eq ';'; | |||
| 172 | ||||||
| 173 | # Skip structure nodes (e.g. return ($x, $y) gives a | |||||
| 174 | # PPI::Structure::List) â set_content only exists on tokens | |||||
| 175 | 157 | 178 | next unless $expr->isa('PPI::Token'); | |||
| 176 | ||||||
| 177 | # Skip postfix conditionals â wrapping 'unless ...' in !() is invalid syntax | |||||
| 178 | 157 | 227 | next if $expr->isa('PPI::Token::Word') && $expr->content =~ /^(?:if|unless|while|until|for|foreach)$/; | |||
| 179 | ||||||
| 180 | # Capture location so the transform closure targets the | |||||
| 181 | # exact statement rather than the first match on that line | |||||
| 182 | 153 | 205 | my $line = $ret->location->[0]; | |||
| 183 | 153 | 49840 | my $col = $ret->location->[1]; | |||
| 184 | ||||||
| 185 | # Build a unique ID from line and column so multiple return | |||||
| 186 | # statements in the same file never collide | |||||
| 187 | 153 | 1005 | my $id = "BOOL_NEGATE_${line}_${col}"; | |||
| 188 | ||||||
| 189 | 153 | 107 | my $mutant = eval { | |||
| 190 | App::Test::Generator::Mutant->new( | |||||
| 191 | id => $id, | |||||
| 192 | group => "BOOL_NEGATE:$line", | |||||
| 193 | description => 'Negate boolean return expression', | |||||
| 194 | original => $ret->content, | |||||
| 195 | line => $line, | |||||
| 196 | type => 'boolean', | |||||
| 197 | ||||||
| 198 | # The transform closure captures line and col so it | |||||
| 199 | # targets precisely the right return statement in the | |||||
| 200 | # document copy it receives at test time | |||||
| 201 | transform => sub { | |||||
| 202 | 12 | 12 | my $doc = $_[0]; | |||
| 203 | ||||||
| 204 | # Locate all return statements in the fresh document copy using | |||||
| 205 | # the same PPI::Statement::Break predicate as the outer find -- | |||||
| 206 | # PPI >= 1.270 no longer uses PPI::Statement::Return | |||||
| 207 | my $rets = $doc->find(sub { | |||||
| 208 | my $node = $_[1]; | |||||
| 209 | # Match Break nodes only -- covers return/last/next/redo | |||||
| 210 | return 0 unless $node->isa('PPI::Statement::Break'); | |||||
| 211 | # Filter to return specifically by inspecting the first token | |||||
| 212 | my $first = $node->schild(0) or return 0; | |||||
| 213 | return $first->content eq 'return'; | |||||
| 214 | 12 | 35 | }) || []; | |||
| 215 | ||||||
| 216 | 12 12 | 70 13 | for my $ret (@{$rets}) { | |||
| 217 | # Match by line and column to avoid mutating | |||||
| 218 | # the wrong return statement | |||||
| 219 | 14 | 1083 | next unless $ret->line_number == $line; | |||
| 220 | 12 | 5317 | next unless $ret->column_number == $col; | |||
| 221 | ||||||
| 222 | # Skip bare returns with no expression | |||||
| 223 | 12 | 109 | my $expr = $ret->schild(1) or last; | |||
| 224 | ||||||
| 225 | # Skip bare semicolon | |||||
| 226 | 12 | 115 | next if $expr->isa('PPI::Token::Structure') && $expr->content eq ';'; | |||
| 227 | ||||||
| 228 | # Skip structure nodes â set_content only exists on tokens | |||||
| 229 | 12 | 19 | next unless $expr->isa('PPI::Token'); | |||
| 230 | ||||||
| 231 | # Skip postfix conditionals â wrapping 'unless ...' in !() is invalid syntax | |||||
| 232 | 12 | 22 | next if $expr->isa('PPI::Token::Word') && $expr->content =~ /^(?:if|unless|while|until|for|foreach)$/; | |||
| 233 | ||||||
| 234 | 12 | 18 | my $content = $expr->content(); | |||
| 235 | 12 | 50 | $expr->set_content("!($content)"); | |||
| 236 | 12 | 25 | last; | |||
| 237 | } | |||||
| 238 | }, | |||||
| 239 | 153 | 182 | ); | |||
| 240 | }; | |||||
| 241 | ||||||
| 242 | # If the Mutant construction fails, report clearly rather than | |||||
| 243 | # silently dropping the mutant from the results | |||||
| 244 | 153 | 278 | if($@ || !$mutant) { | |||
| 245 | 0 | 0 | warn "Failed to construct mutant $id: $@" if $@; | |||
| 246 | 0 | 0 | next; | |||
| 247 | } | |||||
| 248 | ||||||
| 249 | 153 | 153 | push @mutants, $mutant; | |||
| 250 | } | |||||
| 251 | ||||||
| 252 | 69 | 159 | return @mutants; | |||
| 253 | } | |||||
| 254 | ||||||
| 255 - 277 | =head1 AUTHOR Nigel Horne, C<< <njh at nigelhorne.com> >> =head1 LICENCE AND COPYRIGHT Copyright 2026 Nigel Horne. Usage is subject to licence terms. The licence terms of this software are as follows: =over 4 =item * Personal single user, single computer use: GPL2 =item * All other users (including Commercial, Charity, Educational, Government) must apply in writing for a licence for use from Nigel Horne at the above e-mail. =back =cut | |||||
| 278 | ||||||
| 279 | 1; | |||||