| File: | blib/lib/Devel/App/Test/Generator/LCSAJ/Runtime.pm |
| Coverage: | 85.1% |
| line | stmt | bran | cond | sub | time | code |
|---|---|---|---|---|---|---|
| 1 | package Devel::App::Test::Generator::LCSAJ::Runtime; | |||||
| 2 | ||||||
| 3 | 2 2 2 | 65414 1 23 | use strict; | |||
| 4 | 2 2 2 | 4 2 59 | use warnings; | |||
| 5 | 2 2 2 | 167 5949 4 | use autodie qw(open close); | |||
| 6 | 2 2 2 | 362 1 37 | use Carp qw(croak); | |||
| 7 | 2 2 2 | 6 2 32 | use Cwd qw(abs_path); | |||
| 8 | 2 2 2 | 3 1 39 | use JSON::MaybeXS; | |||
| 9 | 2 2 2 | 2 2 31 | use File::Path qw(make_path); | |||
| 10 | 2 2 2 | 252 1692 207 | use Readonly; | |||
| 11 | ||||||
| 12 | # -------------------------------------------------- | |||||
| 13 | # Output directory for per-process hit JSON files. | |||||
| 14 | # One file is written per process (PID) so parallel | |||||
| 15 | # test runs do not overwrite each other's output. | |||||
| 16 | # -------------------------------------------------- | |||||
| 17 | Readonly my $OUT_DIR => 'cover_html/lcsaj_hits'; | |||||
| 18 | ||||||
| 19 - 29 | =head1 NAME Devel::App::Test::Generator::LCSAJ::Runtime - Debugger backend for LCSAJ coverage =encoding UTF-8 =head1 VERSION Version 0.41 =cut | |||||
| 30 | ||||||
| 31 | our $VERSION = '0.41'; | |||||
| 32 | ||||||
| 33 - 64 | =head1 SYNOPSIS PERL5OPT='-d:App::Test::Generator::LCSAJ::Runtime -Mblib' prove -l t =head1 DESCRIPTION This module is loaded as a Perl debugger backend using the C<-d:Module> flag. When Perl sees C<-d:App::Test::Generator::LCSAJ::Runtime> it prepends C<Devel::> and loads C<Devel/App/Test/Generator/LCSAJ/Runtime.pm> from C<@INC>. The file must therefore live at that path - typically C<lib/Devel/App/Test/Generator/LCSAJ/Runtime.pm>. Perl automatically calls C<DB::DB> before executing each statement while the debugger is active. We record (file, line) pairs to build runtime hit data for later LCSAJ analysis. Results are written to C<cover_html/lcsaj_hits/hits_PID.json> at process exit, one file per process so that parallel test runs do not overwrite each other. =head1 ENVIRONMENT =over 4 =item LCSAJ_TARGETS Optional colon-separated list of B<absolute> paths (as produced by C<realpath>) to restrict recording to specific source files. When empty or unset every non-internal file is recorded. =back =cut | |||||
| 65 | ||||||
| 66 | # -------------------------------------------------- | |||||
| 67 | # %HITS - { normalised_path => { line_number => hit_count } } | |||||
| 68 | # %TARGET - set of normalised paths to record (empty means record everything) | |||||
| 69 | # %NORM_CACHE - { raw_file => normalised_path }, memoises abs_path() | |||||
| 70 | # since DB::DB sees the same $file on every consecutive | |||||
| 71 | # statement within a source file | |||||
| 72 | # | |||||
| 73 | # These must be package globals (our) rather than lexicals because DB::DB | |||||
| 74 | # is called by the Perl debugger infrastructure and needs to access them | |||||
| 75 | # without a closure. Lexical vars would not be visible in DB::DB. | |||||
| 76 | # -------------------------------------------------- | |||||
| 77 | our %HITS; | |||||
| 78 | our %TARGET; | |||||
| 79 | our %NORM_CACHE; | |||||
| 80 | ||||||
| 81 | # -------------------------------------------------- | |||||
| 82 | # Populate %TARGET from LCSAJ_TARGETS at compile time. | |||||
| 83 | # The env var contains absolute realpath() output | |||||
| 84 | # separated by colons. Stray newlines from broken | |||||
| 85 | # shell pipelines are stripped defensively. | |||||
| 86 | # -------------------------------------------------- | |||||
| 87 | BEGIN { | |||||
| 88 | 2 | 7 | my $targets_env = $ENV{LCSAJ_TARGETS} // ''; | |||
| 89 | 2 | 2 | $targets_env =~ s/\n//g; | |||
| 90 | ||||||
| 91 | 2 | 316 | for my $t (split /:/, $targets_env) { | |||
| 92 | 0 | 0 | next unless length $t; | |||
| 93 | ||||||
| 94 | # Inline normalisation â cannot call _normalize here since | |||||
| 95 | # BEGIN runs before named subs are compiled when BEGIN | |||||
| 96 | # appears at the top of the file | |||||
| 97 | 0 | 0 | my $f = $t; | |||
| 98 | 0 | 0 | $f =~ s{^.*/blib/lib/}{lib/}; | |||
| 99 | 0 | 0 | $f =~ s{^.*/lib/}{lib/}; | |||
| 100 | 0 | 0 | $TARGET{$f} = 1; | |||
| 101 | } | |||||
| 102 | } | |||||
| 103 | ||||||
| 104 | END { | |||||
| 105 | 2 | 3042 | _write_results(); | |||
| 106 | } | |||||
| 107 | ||||||
| 108 | # -------------------------------------------------- | |||||
| 109 | # _normalize | |||||
| 110 | # | |||||
| 111 | # Purpose: Convert an absolute or build-tree path | |||||
| 112 | # to a canonical lib-relative form so that | |||||
| 113 | # paths recorded at runtime match the | |||||
| 114 | # targets derived from LCSAJ_TARGETS. | |||||
| 115 | # | |||||
| 116 | # Entry: $path - an absolute or relative file path. | |||||
| 117 | # | |||||
| 118 | # Exit: Returns a lib-relative path string, | |||||
| 119 | # e.g. lib/Foo/Bar.pm | |||||
| 120 | # | |||||
| 121 | # Side effects: None. | |||||
| 122 | # | |||||
| 123 | # Notes: Must be defined before the BEGIN block | |||||
| 124 | # that calls it, since BEGIN runs at compile | |||||
| 125 | # time and later subs may not yet be compiled. | |||||
| 126 | # | |||||
| 127 | # Examples: | |||||
| 128 | # /home/user/proj/blib/lib/Foo/Bar.pm -> lib/Foo/Bar.pm | |||||
| 129 | # /home/user/proj/lib/Foo/Bar.pm -> lib/Foo/Bar.pm | |||||
| 130 | # -------------------------------------------------- | |||||
| 131 | sub _normalize { | |||||
| 132 | 8 | 1312 | my $f = $_[0]; | |||
| 133 | ||||||
| 134 | # Strip everything up to and including blib/lib/ or lib/ | |||||
| 135 | 8 | 13 | $f =~ s{^.*/blib/lib/}{lib/}; | |||
| 136 | 8 | 14 | $f =~ s{^.*/lib/}{lib/}; | |||
| 137 | 8 | 11 | return $f; | |||
| 138 | } | |||||
| 139 | ||||||
| 140 | # -------------------------------------------------- | |||||
| 141 | # DB::DB | |||||
| 142 | # | |||||
| 143 | # Purpose: Called by the Perl debugger before every | |||||
| 144 | # statement. Records (file, line) hits for | |||||
| 145 | # later LCSAJ coverage analysis. | |||||
| 146 | # | |||||
| 147 | # Entry: No arguments â caller(0) is used to get | |||||
| 148 | # the current file and line number. | |||||
| 149 | # | |||||
| 150 | # Exit: Returns nothing. Updates %HITS in place. | |||||
| 151 | # | |||||
| 152 | # Side effects: Increments %HITS{$norm}{$line}. | |||||
| 153 | # | |||||
| 154 | # Notes: This sub lives in the DB:: package as | |||||
| 155 | # required by Perl's debugger protocol. | |||||
| 156 | # It is called for every statement executed | |||||
| 157 | # while the debugger is active, so it must | |||||
| 158 | # be as fast as possible. | |||||
| 159 | # Internal files and out-of-target files | |||||
| 160 | # are skipped immediately. | |||||
| 161 | # abs_path() resolution is memoised in | |||||
| 162 | # %NORM_CACHE per raw $file, since the same | |||||
| 163 | # file is seen on every consecutive statement. | |||||
| 164 | # -------------------------------------------------- | |||||
| 165 - 221 | =head2 DB::DB
Perl debugger hook, automatically invoked by the interpreter before every
statement while this module is active as a C<-d:> debugger backend.
Records a per-(file, line) hit count used later for LCSAJ coverage
analysis.
=head3 Arguments
None. Perl calls this sub directly; the current execution location is
obtained internally via C<caller(0)>.
=head3 Returns
Nothing meaningful â this is a void debugger callback.
=head3 Side effects
Increments C<%HITS{$norm}{$line}> for the normalised path and line number
of the statement about to execute. Resolves each distinct raw filename
via C<Cwd::abs_path> once, memoising the result in C<%NORM_CACHE>.
=head3 Usage example
Not called directly â activated via the Perl debugger flag:
PERL5OPT='-d:App::Test::Generator::LCSAJ::Runtime -Mblib' prove -l t
=head3 API specification
=head4 input
{ }
=head4 output
{ type => UNDEF }
=head3 Formal specification
Let H be the hits relation (file x line) â â, T be the target-file set,
and I be the internal-file predicate (true only for this module's own
source path).
â DB_DB ââââââââââââââââââââââââââââââââââââââââââ
â ÎH
â file? : FilePath
â line? : â
ââââââââââââââââââââââââââââââââââââââââââââââââââ
â norm == normalize(file?)
â ¬I(norm) â§ (T = â
⨠norm â T)
â â¹ Hâ²(norm, line?) = H(norm, line?) + 1
â I(norm) ⨠(T â â
â§ norm â T)
â â¹ Hâ² = H
ââââââââââââââââââââââââââââââââââââââââââââââââââ
=cut | |||||
| 222 | ||||||
| 223 | sub DB::DB { | |||||
| 224 | 7 | 83989 | my (undef, $file, $line) = caller(0); | |||
| 225 | ||||||
| 226 | 7 | 60 | return unless defined $file && defined $line; | |||
| 227 | ||||||
| 228 | # Resolve symlinks and relative components to a stable absolute path, | |||||
| 229 | # cached per raw $file to avoid a stat() on every statement | |||||
| 230 | 7 | 23 | my $norm = $NORM_CACHE{$file} //= _normalize(abs_path($file) // $file); | |||
| 231 | ||||||
| 232 | # Never record hits inside this module itself â suffix match is used | |||||
| 233 | # so it works regardless of CWD or install prefix | |||||
| 234 | 7 | 16 | return if $norm =~ m{(?:^|/)Devel/App/Test/Generator/LCSAJ/Runtime\.pm$}; | |||
| 235 | ||||||
| 236 | # If a target list was provided, skip files not in it | |||||
| 237 | 6 | 6 | if(%TARGET) { | |||
| 238 | 2 | 3 | return unless $TARGET{$norm}; | |||
| 239 | } | |||||
| 240 | ||||||
| 241 | 5 | 10 | $HITS{$norm}{$line}++; | |||
| 242 | } | |||||
| 243 | ||||||
| 244 | # -------------------------------------------------- | |||||
| 245 | # _write_results | |||||
| 246 | # | |||||
| 247 | # Purpose: Serialise %HITS to a per-process JSON | |||||
| 248 | # file in the output directory. | |||||
| 249 | # | |||||
| 250 | # Entry: None. Reads %HITS and $OUT_DIR. | |||||
| 251 | # | |||||
| 252 | # Exit: Returns nothing. Writes a JSON file. | |||||
| 253 | # Returns immediately if %HITS is empty. | |||||
| 254 | # | |||||
| 255 | # Side effects: Creates $OUT_DIR if absent. | |||||
| 256 | # Writes cover_html/lcsaj_hits/hits_PID.json | |||||
| 257 | # | |||||
| 258 | # Notes: Called from END so it runs even when | |||||
| 259 | # prove exits non-zero â mutation tests | |||||
| 260 | # are expected to fail. PID is included | |||||
| 261 | # in the filename so parallel test runs | |||||
| 262 | # produce separate files without collision. | |||||
| 263 | # -------------------------------------------------- | |||||
| 264 | sub _write_results { | |||||
| 265 | 5 | 5911 | return unless %HITS; | |||
| 266 | ||||||
| 267 | # Include PID in filename to support parallel test runs | |||||
| 268 | 2 | 6 | my $out_file = "$OUT_DIR/hits_$$.json"; | |||
| 269 | ||||||
| 270 | 2 | 7 | make_path($OUT_DIR) unless -d $OUT_DIR; | |||
| 271 | ||||||
| 272 | # autodie is disabled for this open -- under "use autodie qw(open)" | |||||
| 273 | # open() never returns false on failure, it throws its own exception | |||||
| 274 | # instead, which would silently make the "or croak" below dead code | |||||
| 275 | 2 2 2 | 6 2 4 | no autodie qw(open); | |||
| 276 | 2 | 220 | open my $fh, '>', $out_file or croak "Cannot write $out_file: $!"; | |||
| 277 | ||||||
| 278 | 1 | 8 | print $fh encode_json(\%HITS); | |||
| 279 | 1 | 4 | close $fh; | |||
| 280 | } | |||||
| 281 | ||||||
| 282 | 1; | |||||
| 283 | ||||||