File Coverage

blib/lib/SimpleFlow.pm
Criterion Covered Total %
statement 203 221 91.8
branch 54 76 71.0
condition 26 37 70.2
subroutine 20 20 100.0
pod 2 2 100.0
total 305 356 85.6


line stmt bran cond sub pod time code
1             # ABSTRACT: SimpleFlow - easy, simple workflow manager (and logger); for keeping track of and debugging large and complex shell command workflows
2 1     1   146855 use strict;
  1         2  
  1         32  
3 1     1   3 use warnings FATAL => 'all';
  1         1  
  1         53  
4             require 5.010;
5 1     1   3 use feature 'say';
  1         1  
  1         110  
6 1     1   4 use DDP {output => 'STDOUT', array_max => 10, show_memsize => 1};
  1         1  
  1         20  
7 1     1   689 use Devel::Confess 'color';
  1         6318  
  1         3  
8 1     1   95 use Cwd 'getcwd';
  1         1  
  1         55  
9             package SimpleFlow;
10             our $VERSION = 0.14;
11 1     1   4 use Time::HiRes;
  1         1  
  1         7  
12 1     1   779 use Term::ANSIColor;
  1         7149  
  1         121  
13             # Windows portability: the legacy Windows console (cmd.exe) prints raw ANSI
14             # escape sequences as garbage. Disable colouring there unless a terminal that
15             # understands ANSI is in use (Windows Terminal, ConEmu, ANSICON). Unix and
16             # modern Windows terminals are left untouched.
17             BEGIN {
18             $ENV{ANSI_COLORS_DISABLED} = 1
19             if $^O eq 'MSWin32'
20             && !$ENV{WT_SESSION} # Windows Terminal
21             && !$ENV{ConEmuANSI} # ConEmu
22 1 0 33 1   27 && !$ENV{ANSICON}; # ANSICON
      33        
      0        
23             }
24 1     1   5 use Scalar::Util 'openhandle';
  1         1  
  1         55  
25 1     1   5 use DDP {output => 'STDOUT', array_max => 10, show_memsize => 1};
  1         1  
  1         5  
26 1     1   84 use Devel::Confess 'color';
  1         1  
  1         5  
27 1     1   76 use Cwd 'getcwd';
  1         1  
  1         43  
28 1     1   3 use warnings FATAL => 'all';
  1         2  
  1         37  
29 1     1   705 use Capture::Tiny 'capture';
  1         4327  
  1         54  
30 1     1   5 use List::Util 'max';
  1         1  
  1         40  
31 1     1   3 use Exporter 'import';
  1         1  
  1         2141  
32             our @EXPORT = qw(say2 task);
33             our @EXPORT_OK = @EXPORT;
34              
35             sub say2 { # say to both command line and log file
36 1     1 1 76 my ($msg, $fh) = @_;
37 1         10 my $current_sub = (split(/::/,(caller(0))[3]))[-1]; # https://stackoverflow.com/questions/2559792/how-can-i-get-the-name-of-the-current-subroutine-in-perl
38 1         26 my @c = caller;
39 1 50       18 if (not openhandle($fh)) {
40 0         0 die "the filehandle given to $current_sub with \"$msg\" from $c[1] line $c[2] isn't actually a filehandle";
41             }
42 1         3 $msg = "\@ $c[1] line $c[2] $msg";
43 1         7 say $msg;
44 1         3 say $fh $msg;
45 1         3 return $msg;
46             }
47              
48             sub task {
49 26     26 1 1206444 my $current_sub = (split(/::/,(caller(0))[3]))[-1];
50             # Accept either a single hash ref -- task({ cmd => ... }) -- or a flat
51             # key/value list -- task(cmd => ...). A lone non-hashref scalar, or an
52             # odd-length list, can't be read either way and is fatal.
53 26         860 my $args;
54 26 50 33     364 if (@_ == 1 && ref $_[0] eq 'HASH') {
    0          
55 26         43 $args = $_[0];
56             } elsif (@_ % 2 == 0) {
57 0         0 $args = { @_ };
58             } else {
59 0         0 die "args to $current_sub must be a hash ref (e.g. $current_sub({ cmd => ... })) or a flat key/value list (e.g. $current_sub(cmd => ...)); got an odd-length list";
60             }
61 26         67 my @c = caller;
62 26         538 my @reqd_args = (
63             'cmd', # the shell command
64             );
65 26         57 my @undef_args = grep { !defined $args->{$_}} @reqd_args;
  26         121  
66 26 100       79 if (scalar @undef_args > 0) {
67 1         39 p @undef_args;
68 1         3713 die 'the above args are necessary, but were not defined.';
69             }
70 25         90 my @defined_args = ( @reqd_args,
71             'die', # die if not successful; 0 or 1
72             'dry.run', # dry run or not
73             'input.files', # check for input files; SCALAR or ARRAY
74             'log.fh',
75             'note', # a note for the log
76             'overwrite', #
77             'output.files' # product files that need to be checked; can be scalar or array
78             );
79 25         93 my @bad_args = grep { my $key = $_; not grep {$_ eq $key} @defined_args} keys %{ $args };
  52         69  
  52         67  
  416         572  
  25         73  
80 25 100       126 if (scalar @bad_args > 0) {
81 1         6 p @bad_args, array_max => scalar @bad_args;
82 1         3448 say "the above arguments are not recognized by $current_sub";
83 1         4 p @defined_args, array_max => scalar @defined_args;
84 1         5296 die "The above args are accepted by $current_sub";
85             }
86 24 100 100     155 if (
87             (defined $args->{'log.fh'}) &&
88             (not openhandle($args->{'log.fh'}))
89             ) {
90 1         5 p $args;
91 1         4057 die "the filehandle given to $current_sub isn't actually a filehandle";
92             }
93 23         40 my (%input_file_size, @existing_files, @output_files, @empty_filenames);
94 23 100       61 if (defined $args->{'input.files'}) {
95 4         10 my $ref = ref $args->{'input.files'};
96 4         7 my @missing_files;
97 4 100       22 if ($ref eq 'ARRAY') {
    50          
98 1         8 @missing_files = grep {not -f -r $_ } @{ $args->{'input.files'} };
  2         33  
  1         2  
99 1         1 %input_file_size = map { $_ => -s $_ } @{ $args->{'input.files'} };
  2         15  
  1         5  
100 1 50       2 @empty_filenames = grep {(defined $_) && (length $_ == 0)} @{ $args->{'input.files'} };
  2         11  
  1         2  
101             } elsif ($ref eq '') { # scalar
102 3         9 @missing_files = grep {not -f -r $_ } ($args->{'input.files'});
  3         124  
103 3         10 %input_file_size = map { $_ => -s $_ } ($args->{'input.files'} );
  3         25  
104 3 50       8 @empty_filenames = grep {(defined $_) && (length $_ == 0)} ($args->{'input.files'});
  3         32  
105             } else {
106 0         0 p $args;
107 0         0 die 'ref type "' . $ref . '" is not allowed for "input.files"';
108             }
109 4 100       14 if (scalar @missing_files > 0) {
110 2         33 say STDERR 'this list of arguments:';
111 2         11 p $args;
112 2         8211 say STDERR 'Cannot run because these files are either missing or unreadable in: ' . getcwd();
113 2         29 p @missing_files;
114 2         6849 die 'the above files are missing or are not readable';
115             }
116             }
117 21 50       46 if (scalar @empty_filenames > 0) {
118 0         0 p $args;
119 0         0 die '0-length filenames are not allowed (found in "input.files")';
120             }
121 21         249 my $msg = "\@ $c[1] line $c[2] The command is:\n" . colored(['blue on_bright_red'], $args->{cmd});
122 21         1775 say $msg;
123 21 100       81 say {$args->{'log.fh'}} "\@ $c[1] line $c[2] The command is:\n$args->{cmd}" if defined $args->{'log.fh'};
  1         8  
124 21 100       105 if (defined $args->{'output.files'}) { # avoid "uninitialized value" warning
125 6         16 my $ref = ref $args->{'output.files'};
126 6 50       34 if ($ref eq 'ARRAY') {
    50          
127 0         0 @output_files = @{ $args->{'output.files'} };
  0         0  
128             } elsif ($ref eq '') { # a scalar
129 6         21 @output_files = $args->{'output.files'};
130             } else {
131 0         0 p $args;
132 0         0 die "$ref isn't allowed for \"output.files\"";
133             }
134             }
135 21 50       45 @empty_filenames = grep {(defined $_) && (length $_ == 0)} @output_files; # 0-length filenames aren't allowed
  6         53  
136 21 100       61 if (scalar @empty_filenames > 0) {
137 1         5 p $args;
138 1         4458 die '0-length filenames are not allowed (found in "output.files"';
139             }
140 20 100       145 if (scalar @output_files > 0) {
141 5         13 @existing_files = grep {-f $_} @output_files;
  5         115  
142             }
143             my %r = (
144             cmd => $args->{cmd},
145 20         353 dir => getcwd(),
146             'source.file' => $c[1],
147             'source.line' => $c[2],
148             'output.files' => [@output_files],
149             );
150 20   100     137 $r{'die'} = $args->{'die'} // 1; # by default, true
151 20   100     126 $r{'dry.run'} = $args->{'dry.run'} // 0; # by default, false
152 20   100     93 $r{note} = $args->{note} // '';# by default, false
153 20   100     112 $r{overwrite} = $args->{overwrite} // 0; # by default, false
154 20         48 $r{'will.do'} = 'yes';
155 20 100       53 $r{'will.do'} = 'no: dry run' if $args->{'dry.run'};
156 20         33 my $string_max = 0;
157 20 100       40 if (defined $args->{'input.files'}) {
158 2         11 $r{'input.files'} = $args->{'input.files'};
159 2         7 $r{'input.file.size'} = \%input_file_size;
160             }
161 20         44 my %output_file_size = map {$_ => -s $_} @output_files;
  5         59  
162 20         66 foreach my $val (grep {ref $r{$_} eq ''} keys %r) {
  204         330  
163 181         325 $string_max = max($string_max, length $r{$val});
164             }
165 20 100 100     148 if ((!$args->{overwrite}) && (scalar @output_files > 0) && (scalar @existing_files == scalar @output_files)) { # this has been done before
      100        
166 1         3 $r{done} = 'before';
167 1         2 $r{'will.do'} = 'no';
168 1         5 say colored(['black on_green'], "\"$args->{cmd}\"\n") . ' has been done before';
169 1         38 $r{'output.file.size'} = \%output_file_size;
170 1         2 $r{duration} = 0;
171 1 50       5 p(%r, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
172 1         4 p %r, string_max => $string_max;
173 1         7864 return \%r;
174             } else {
175 19         43 $r{done} = 'not yet';
176             }
177 19 100       49 if ($r{'dry.run'}) {
178 1         7 say "\@ $c[1] line $c[2] in $r{dir} the command was going to be:";
179 1         5 say colored(['red on_black'], "\"$args->{cmd}\"");
180 1         34 say 'But this is a dry run';
181 1         3 say '-------------';
182 1         3 $r{duration} = 0;
183 1         7 return \%r;
184             }
185 18         69 my $t0 = Time::HiRes::time();
186 18         28 my $status;
187             ($r{stdout}, $r{stderr}, $status) = capture {
188 18     18   6435379 system( $args->{cmd} );
189 18         723 };
190 18         31574 my $t1 = Time::HiRes::time();
191 18         116 $r{duration} = $t1-$t0;
192             # Decode the raw wait status. On Unix the low 7 bits hold the death
193             # signal and the high byte holds the exit code. The signal MUST be read
194             # from the raw status *before* shifting -- the old code shifted first and
195             # then did ($exit & 127), so $r{signal} was always 0 and could never
196             # detect a kill by signal 9/15. Windows has no POSIX signals, and a -1
197             # return from system() means the command never launched.
198 18 50 33     290 if (!defined $status || $status == -1) {
    50          
199 0         0 $r{'exit'} = -1;
200 0         0 $r{signal} = 0;
201             } elsif ($^O eq 'MSWin32') {
202 0         0 $r{signal} = 0;
203 0         0 $r{'exit'} = $status >> 8;
204             } else {
205 18         137 $r{signal} = $status & 127; # FIX: taken from raw status, not from $exit
206 18         101 $r{'exit'} = $status >> 8;
207             }
208 18         132 foreach my $std ('stderr', 'stdout') {
209 36         204 $r{$std} =~ s/\s+$//; # remove trailing whitespace/newline
210 36         170 $string_max = max($string_max, length $r{$std});
211             }
212 18         127 $r{done} = 'now';
213 18         112 $r{'will.do'} = 'done';
214 18         84 my @missing_output_files = grep {not -f -r $_} @output_files;
  4         110  
215 18 100       147 if (scalar @missing_output_files > 0) {
216 1         12 $r{'will.do'} = 'FAILED';
217 1         31 say STDERR "this input to $current_sub:";
218 1         25 p $args;
219 1 50       5725 say {$args->{'log.fh'}} "this input to $current_sub:" if defined $args->{'log.fh'};
  0         0  
220 1 50       5 p($args, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
221 1         5 say STDERR 'has these output files missing:';
222 1 50       6 say {$args->{'log.fh'}} 'has these output files missing:' if defined $args->{'log.fh'};
  0         0  
223 1         4 p @missing_output_files;
224 1 50       3568 p(@missing_output_files, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
225 1         9 p %r, string_max => $string_max;
226 1 50       8735 p(%r, output => $args->{'log.fh'}, string_max => $string_max) if defined $args->{'log.fh'};
227 1 50       6 if ($r{'die'}) { # use the resolved value (defaults to 1), not the raw arg
228 0         0 die 'those above files should have been made but are missing';
229             } else {
230 1         6 say STDERR 'those above files should have been made but are missing';
231             }
232             }
233 18         71 %output_file_size = map {$_ => -s $_} @output_files;
  4         74  
234 18         116 $r{'output.file.size'} = \%output_file_size;
235             # p %output_file_size;
236 18   100     42 my @files_with_zero_size = grep { ($output_file_size{$_} // 0) == 0 } @output_files;
  4         62  
237 18 100       50 if (scalar @files_with_zero_size > 0) {
238 1         3 p @files_with_zero_size;
239 1         3357 warn 'the above output files have 0 size.';
240             }
241 18 100       1005 p(%r, output => $args->{'log.fh'}) if defined $args->{'log.fh'};
242 18 100 100     9791 if (($r{'die'}) && ($r{'exit'} != 0)) {
243 1         6 $r{'will.do'} = 'FAILED';
244 1         19 p %r, string_max => $string_max;
245 1         9944 die "\"$args->{cmd}\" failed from $c[1] line $c[2]"
246             }
247 17         216 p %r, string_max => $string_max;
248 17         189017 return \%r;
249             }
250             1;
251              
252             =encoding utf8
253              
254             A tiny workflow manager and logger for Perl, like SnakeMake or NextFlow, but in pure Perl and aimed at making long, error-prone shell pipelines easy to B and B.
255              
256             Every step is a single C call. SimpleFlow checks the inputs before a
257             command runs and the outputs after, times the command, captures its C,
258             C, exit code and signal, optionally logs a full structured record, and
259             skips work that has already been done.
260              
261             Two subroutines are exported by default: L and L.
262              
263             =head1 Install
264              
265             With a CPAN client:
266              
267             cpanm SimpleFlow
268              
269             Or from a checkout:
270              
271             perl Makefile.PL
272             make
273             make test
274             make install
275              
276             =head1 Synopsis
277              
278             The simplest useful case: run a command and confirm it produced its output:
279              
280             use SimpleFlow qw(task say2);
281            
282             my $t = task({
283             cmd => 'which ls',
284             'output.files' => '/tmp/AFK3mnEK8L.log',
285             });
286              
287             C returns a hash reference describing exactly what happened:
288              
289             {
290             cmd "which ls",
291             die 1,
292             dir "/home/con/Scripts/SimpleFlow",
293             done "now",
294             dry.run 0,
295             duration 0.00191903114318848,
296             exit 0,
297             note "",
298             output.files [
299             [0] "/tmp/AFK3mnEK8L.log"
300             ],
301             overwrite 1,
302             signal 0,
303             source.file "t/01.t",
304             source.line 29,
305             stderr "",
306             stdout "/usr/bin/ls",
307             will.do "done"
308             }
309              
310             > B SimpleFlow runs whatever shell command you give it via
311             > C, so the I are your responsibility to keep
312             > cross-platform (e.g. C is Unix-only). SimpleFlow's own behaviour
313             > exit/signal decoding and coloured output is cross-platform; see the
314             > L.
315              
316             =head1 C
317              
318             my $result = task(\%args);
319              
320             Runs one shell command with checking, timing, capture and logging. Takes a
321             B; the only required key is C.
322              
323             =head2 Arguments
324              
325              
326              
327             =begin html
328              
329            
330            
331            
332             Key
333             Type
334             Default
335             Description
336            
337            
338            
339            
340             cmd
341             scalar
342             undef
343             Required. The shell command to run.
344            
345            
346             die
347             bool (0/1)
348             1
349             Die if the command fails (non-zero exit) or an output file is missing. Set to 0 to warn and continue instead.
350            
351            
352             dry.run
353             bool
354             0
355             Print the command (and log it) but do not execute it.
356            
357            
358             input.files
359             scalar or array
360             undef
361             File(s) that must exist and be readable before running; otherwise task dies.
362            
363            
364             output.files
365             scalar or array
366             undef
367             File(s) expected to exist after running; used both for the missing-output check and for skip detection.
368            
369            
370             log.fh
371             open filehandle
372             undef
373             If given, the full result record is also written here. Must be a real, open filehandle.
374            
375            
376             note
377             scalar
378             ''
379             Free-text note copied into the result and the log.
380            
381            
382             overwrite
383             bool
384             0
385             If false and all output.files already exist, the command is skipped. Set true to always run.
386            
387            
388            
389              
390             =end html
391              
392              
393              
394             Passing an unrecognised key, an empty filename, or a non-filehandle C
395             causes C to die: these are usually mistakes worth catching early.
396              
397             =head2 Return value
398              
399             C always returns a hash reference. The fields below are present after a
400             normal run; the L and L paths
401             omit the execution-only fields (C, C, C, C).
402              
403              
404              
405             =begin html
406              
407            
408            
409            
410             Field
411             Meaning
412            
413            
414            
415            
416             cmd
417             The command that was run.
418            
419            
420             dir
421             Working directory at execution time.
422            
423            
424             done
425             "now" (just ran), "before" (skipped, outputs already existed), or "not yet" (dry run).
426            
427            
428             will.do
429             "done", "no" (skipped), "no: dry run", or "FAILED".
430            
431            
432             duration
433             Wall-clock seconds the command took (0 for skips/dry runs).
434            
435            
436             exit
437             Exit code of the command (-1 if it could not be launched).
438            
439            
440             signal
441             Signal number if the command process was killed by a signal, else 0. Always 0 on Windows (no POSIX signals).
442            
443            
444             stdout, stderr
445             Captured output, with trailing whitespace stripped.
446            
447            
448             die, dry.run, overwrite, note
449             The (defaulted) argument values used.
450            
451            
452             output.files
453             Array ref of the output files (a scalar argument is normalised to a one-element array).
454            
455            
456             output.file.size
457             Hash of filename => size in bytes for the outputs.
458            
459            
460             input.files
461             The input argument, as given (present only if you passed input.files).
462            
463            
464             input.file.size
465             Hash of filename => size in bytes for the inputs (present only if you passed input.files).
466            
467            
468             source.file, source.line
469             Where in your code the task was called: handy when debugging a long pipeline.
470            
471            
472            
473              
474             =end html
475              
476              
477              
478             =head2 Skipping completed work
479              
480             If C is false (the default) and every file in C already
481             exists, C does B re-run the command. This makes pipelines
482             restartable: re-running the script picks up where it left off.
483              
484             open my $log, '>', 'logfile.txt';
485             my $t = task({
486             cmd => 'gmx grompp -f em.mdp -c box.gro -p topol.top -o em.tpr',
487             'input.files' => ['em.mdp', 'box.gro', 'topol.top'],
488             'output.files' => 'em.tpr',
489             'log.fh' => $log,
490             });
491             close $log;
492              
493             On the first run C is C<"now">; on a re-run (with C present) C
494             is C<"before"> and C is C<"no">. Pass C<< overwrite =E 1 >> to force it.
495              
496             =head2 Dry runs
497              
498             Useful for inspecting a pipeline without executing anything expensive:
499              
500             my $t = task({
501             cmd => 'a long-running, time-consuming command',
502             'dry.run' => 1,
503             'log.fh' => $fh,
504             });
505              
506             The command is printed (and logged) but not run; C is C<"no: dry run">.
507              
508             =head2 Failure behaviour
509              
510             By default (C<< die =E 1 >>) C dies if the command exits non-zero or if any
511             declared C are missing afterwards, so a broken step stops the
512             pipeline immediately. With C<< die =E 0 >>, C instead warns and returns its
513             result hash (with C<< will.do =E "FAILED" >>), letting you decide what to do.
514              
515             =head2 C
516              
517             say2($message, $filehandle);
518              
519             "Say to two places": prints C<$message> to standard output B to the given
520             log filehandle, prefixed with the calling file and line number so log entries
521             are traceable. The filehandle must be open, or C dies.
522              
523             open my $log, '>', 'run.log';
524             say2('starting equilibration', $log); # -> STDOUT and run.log
525             close $log;
526              
527             =head1 Dependencies
528              
529             Core/runtime modules used by SimpleFlow:
530              
531             =over
532              
533             =item * L captures C/C
534              
535             =item * L (C) pretty result/record printing
536              
537             =item * L better backtraces on death
538              
539             =item * L coloured terminal output
540              
541             =item * C, C, C, C core utilities
542              
543             =back
544              
545             The test suite additionally uses C and
546             L.
547              
548             =head1 Change log
549              
550             =head2 0.14 (2026-06-29) (Claude Opus 4.8 helped)
551              
552             =head3 C
553              
554             =over
555              
556             =item * B accepts a flat key/value list as well as a hash ref —
557             C<< task(cmd =E ...) >> and C<< task({ cmd =E ... }) >> are now equivalent. A lone
558             non-hashref scalar or any odd-length argument list is fatal.
559              
560             =item * B the default C<< die =E 1 >> was ignored when checking for missing
561             C. The block tested the raw C<< $args-E{'die'} >> (undef when the
562             caller omitted it) instead of the resolved C<$r{'die'}>, so a command that
563             failed to produce its declared outputs only warned instead of dying. Now
564             consistent with the exit-code check.
565              
566             =item * B removed a stray C<)> (and an extraneous leading space) from the
567             "command is" line written to the log file; it now matches the on-screen form.
568              
569             =item * B C could throw a fatal uninitialized-value warning
570             (under C<< warnings FATAL =E 'all' >>) on an undef element of the C
571             array branch and the C empty-name check. Both now guard with
572             C<(defined $_) && (length $_ == 0)>, matching the C scalar branch.
573              
574             =back
575              
576             =head2 0.13 (2026-06-11)
577              
578             =head3 Fixed (Claude Opus 4.8 helped)
579              
580             =over
581              
582             =item * B C previously
583             computed the exit code (C<< $status EE 8 >>) and I derived the signal as
584             C<$exit & 127>. Because the signal lives in the low byte of the raw wait
585             status, which C<< EE 8 >> discards the C field was always wrong: a clean
586             C was reported as C, and a process actually killed by a
587             signal reported C. The signal is now read from the raw status before
588             shifting, so C and C are independent and accurate.
589              
590             =item * B<< No longer dies on a missing output file when C<< die =E 0 >>. >> The zero-size
591             check did C<(-s $file) == 0>, which is C when a declared output file
592             is absent. Under C<< use warnings FATAL =E 'all' >> that "uninitialized value"
593             warning was fatal, so a task that was meant to I about missing output
594             (with C<< die =E 0 >>) crashed instead. Missing sizes are now treated as C<0>, so
595             the task warns and returns its result hash as intended.
596              
597             =item * B<< The "already done" result is now logged with its C. >> In the
598             short-circuit path (output files already exist), C was set I
599             the record was written to the log, so the logged hash was missing it; the
600             duplicate C<< done =E 'before' >> assignment was also removed.
601              
602             =back
603              
604             =head3 Changed / Windows support
605              
606             =over
607              
608             =item * B Decoding now branches on C<$^O>: Windows has
609             no POSIX signals (C is reported as C<0> there), and a C that
610             fails to launch the command (C<-1>) yields C<< exit =E -1 >> instead of a garbage
611             value from shifting C<-1>.
612              
613             =item * B C
614             output is suppressed on C unless an ANSI-capable terminal is detected
615             (Windows Terminal, ConEmu, or ANSICON), so C no longer prints raw
616             escape sequences and redirected logs stay clean. Unix and modern Windows
617             terminals are unaffected.
618              
619             =back
620              
621             =head3 Tests
622              
623             =over
624              
625             =item * Rewrote C to be cross-platform: shell commands now invoke the running
626             Perl interpreter (C<"$^X" -e ...>) instead of Unix-only tools (C, C,
627             C, C), and temp files use the system temp directory instead of a
628             hard-coded C.
629              
630             =item * Added regression tests for both fixed bugs (exit/signal decoding; surviving a
631             missing output file with C<< die =E 0 >>).
632              
633             =item * Added coverage for the C field, the C / C
634             hashes, scalar-vs-array normalisation of C / C, the
635             C / C / C metadata, captured C / C
636             (including trailing-whitespace stripping), and argument validation (missing
637             C, unknown keys, bad C, missing input files).
638              
639             =back
640              
641             =head2 0.12
642              
643             exit code now matches what shell would show it as; signal now appears
644              
645             =head2 0.11
646              
647             max string length now corresponds to max of output strings, no more truncated output
648             added List::Util dependency for string length maxes
649             memory size now shows when output
650             directory is now output during dry runs