Revision history for SimpleFlow


0.17 2026-09-14 (Claude Opus 5 helped)

 [Fixed]

 - **A command that prompted hung for ever.** `Capture::Tiny::capture`
     redirects file descriptors 1 and 2 and nothing else, so the command
     inherited the caller's descriptor 0. A command that stops to ask a
     question — `rm` over a write-protected file, `cp -i`, `git` asking for
     credentials — wrote its prompt into the captured stderr, where nobody
     could see it, and then blocked on the terminal waiting for an answer the
     user did not know was wanted. Nothing was printed and, with `timeout` at
     its default of 0, nothing ever returned; with a `timeout` set the process
     group was killed and the record then said `timed.out => 1, signal => 9`,
     blaming the clock for what was really an unanswered question. The command
     now runs with descriptor 0 on the null device. Both execution paths were
     affected and both are fixed: the `timeout` path forks and execs, and its
     child inherited descriptor 0 across the fork just as `system()`'s did.
     Found while debugging a pipeline that hung on `rm -r` over a read-only
     file.

 [Added]

 - **`stdin`**: `'devnull'` (the default) or `'inherit'`, saying what the
     command sees on its standard input. `'inherit'` restores the behaviour of
     0.162 and earlier for a step that really does read the data the calling
     script was given, with the hazards that implies: it consumes input the
     caller can then no longer read, and a command that prompts hangs exactly
     as it used to. The caller's standard input is saved and restored around
     every run either way — including when the run dies, so a caller that traps
     the exception is not left without it — and a caller that had closed it
     keeps it closed.

 [Changed]

 - The result record carries `stdin`, the resolved value of that option.
 - `File::Spec` (core) is now a dependency, for the name of the null device:
     `/dev/null` on Unix, `nul` on Windows.
 - Callers relying on the old behaviour are affected: a command that read the
     calling script's standard input now reads end-of-file instead, and
     succeeds while doing nothing. `stdin => 'inherit'` is the one-word repair.

 [Packaging]

 - **`SECURITY.md`**, giving an address to report a vulnerability to privately
     and saying what is in scope. SimpleFlow runs the command it is given, so a
     `cmd` string built out of untrusted data is a shell injection in the
     *calling* program; the array-ref form of `cmd` runs without a shell and is
     the way to avoid that.
 - **`CONTRIBUTING.md`** now ships too. Both files are gathered by `[@Basic]`
     without a `dist.ini` entry, and both are what the CPANTS experimental
     metrics `has_security_doc`, `security_doc_contains_contact` and
     `has_contributing_doc` look for. Checked by running the contact half of
     `Module::CPANTS::SiteKwalitee::Security` over the built tarball: the
     address it extracts is `dec986@gmail.com`.
 - **`autodie` is no longer a prerequisite.** The only file that ever loaded it
     is `md2pod.pl`, which `MANIFEST.SKIP` keeps out of the distribution, so
     every installer was being asked for a module the shipped code never loads.
     `Exporter` is declared instead, since the module does load it.
 - **The test-only prerequisites are declared as such.** `Test::More`,
     `Test::Exception` and `File::Temp` are used by `t/` and by nothing that is
     installed, so they moved from `requires` to `test_requires`. `Test::More`
     is pinned at 0.96 for the first time: every test file uses `subtest`,
     which arrived in Test::Simple 0.94, and perl 5.10.1 shipped 0.92 — a
     smoker with nothing beyond core could not have run the suite at all, and
     nothing said so. The `Makefile.PL` folds `TEST_REQUIRES` back into
     `PREREQ_PM` on ExtUtils::MakeMaker older than 6.63_03, so 5.10's own
     toolchain still sees them.
 - **`cover_db/`, `cover.sh` and `dzil.sh` no longer ship.** The committed
     Devel::Cover report is stale by design — it predates `t/02.fixes.t` — and
     was 39 files of HTML in the tarball; the two scripts are author-only, like
     `md2pod.pl`. The distribution is 17 files, and passes its own suite (60
     tests) when the tests are run inside the built tree.

0.162 2026-09-12 (Claude Opus 5 helped)

 [Tests]

 - **Block 14 of `t/02.fixes.t` failed on Data::Printer before 1.x.** A CPAN
     tester on perl 5.20.0 with Data::Printer 0.38 reported it against 0.161.
     The block redirects STDOUT to an in-memory handle to check that
     `quiet => 1` silences the terminal, and asserted that exactly the
     command's own output -- and nothing else -- escaped that redirect to the
     real file descriptor 1. On Data::Printer 0.38 the record escapes too:
     `use DDP {output => 'STDOUT'}` binds the handle as the property is
     parsed, at import, while 1.002001 resolves it at print time, so a later
     `local *STDOUT` cannot reach the older release. The block now counts how
     many times the command ran instead of demanding that nothing else
     escaped, and its command prints an upper-cased sentinel so that the
     record, which quotes the command it ran, cannot be counted as a second
     copy. Confirmed against Data::Printer 0.38 and 1.002001, on perl 5.10.1,
     5.12.5 and 5.44.0. No module behaviour changed, and nothing about
     `quiet` was wrong: on every version the record goes to the terminal
     unless `quiet => 1` and to the log either way.

0.161 2026-09-07 (Claude Opus 5 helped)

 [Fixed]

 - **The printed record's length cap did nothing on Data::Printer before
     0.99_001.** 0.16 capped each field of the record at 4096 characters by
     handing `string_max` to Data::Printer and leaving the clipping to it, but
     that property only arrived in Data::Printer 0.99_001 (2018-04-21) and
     every earlier release ignores a property it does not know, in silence. A
     CPAN tester on perl 5.20.0 with Data::Printer 0.38 therefore had a
     200,000-character capture printed whole: 200,927 bytes to the terminal and
     200,914 bytes to the log. SimpleFlow now clips the strings itself before
     printing them, and marks what it dropped in Data::Printer's own wording,
     so the ceiling holds on every version. The full capture is still on the
     result hash, and the output on Data::Printer 1.x is unchanged.

 [Tests]

 - Added a regression test for the above (block 17 of `t/02.fixes.t`), confirmed
     to fail against 0.16 first. Its probe loads a stub `DDP` that ignores every
     property it is handed, which is what those Data::Printer releases did, and
     is the only way to reproduce the flood on a machine whose Data::Printer is
     current; it needs no network and nothing installed.

 - Block 10 asked that `$VERSION` have exactly two decimal places, which this
     release does not: 0.161 is a point release on 0.16 and keeps three. The
     assertion now takes two or more, and a new one compares `$VERSION` against
     the literal in the source digit for digit -- the check that actually
     catches an unquoted version, since with the quotes off 0.161 the module
     still reports "0.161" and any pattern on the digits passes.

 - A passing run of the suite no longer looks like a crash. task() prints the
     result record, and its failure paths dump the arguments with `p` and then
     warn or die on the terminal -- all as documented -- so the three test
     files together printed some 1,180 lines of record dumps and eight
     backtraces on a clean, wholly successful run. Every call whose printing is
     not itself under test now goes through a `quietly` helper that captures
     both streams with `Capture::Tiny`, and the expected diagnostics are
     asserted on rather than discarded: `prove -Ilib t/` prints TAP and nothing
     else. Capturing was chosen over passing `quiet => 1` because it leaves the
     arguments handed to task() unchanged, which is what keeps blocks 1-11 of
     `t/02.fixes.t` runnable against 0.15 (re-checked: they still fail there).
     No module behaviour changed.

 [Documentation]

 - README.md no longer carries the release notes, so they are no longer copied
     into `read.me.pod` and the module's POD either. `Changes` is the only copy
     now, and `md2pod.pl` checks it with `changes_file_ok()` rather than
     generating it.


0.16 2026-08-28 (Claude Opus 5 helped)

 [Fixed]

 - **`die => 0` never reported a failure.** The `will.do => "FAILED"` assignment
     sat inside the `if ($r{die})` branch, so it could only run on the path that
     immediately died. Under `die => 0` — the mode in which the caller is meant
     to read `will.do` — a command that exited non-zero was reported as `"done"`,
     and nothing warned. `will.do` is now `"FAILED"` for a non-zero exit, a
     timeout, or a missing output file regardless of `die`, and `die => 0` emits
     a warning naming the exit code.
 - **The log lost the record of the task that killed the run.** The log
     filehandle was never autoflushed. Measured with a `SIGKILL` part-way through
     a pipeline (the shape of an OOM kill or a scheduler eviction), a log holding
     862 bytes on a clean exit held 139 bytes after the kill: everything written
     after the last command started — its exit code, duration and captured output
     — was still in stdio's buffer. `task` and `say2` now switch the handle to
     autoflush.
 - **An undefined filename still crashed.** 0.14 added a `defined` guard to the
     0-length check, but the `-f -r` filetest ran first, so an `undef` element of
     an `input.files` array died as `Use of uninitialized value $_ in -r` under
     `warnings FATAL => 'all'`. Names are now validated before anything is
     filetested.
 - **The 0-length `input.files` check was unreachable.** `''` fails `-f`, so an
     empty input filename was reported as `"missing or unreadable"` and the
     0-length check below it could never fire. Both undefined and 0-length names
     are now reported as what they are, and the message names the offending index.
 - **`cmd` was not type-checked.** Only definedness was checked, so any reference
     was stringified straight into the shell: `task(cmd => ['echo','hi'])` ran the
     literal command `ARRAY(0x5ed9d076e618)`. `cmd` must now be a non-empty string
     or a non-empty array ref of defined values.
 - **Skip detection and the post-run check disagreed.** Skipping tested a bare
     `-f` while the post-run check tested `-f -r`, so an output file that existed
     but could not be read counted as already done. Both use `-f -r` now.
 - **The result record changed shape between paths.** `exit`, `signal`, `stdout`
     and `stderr` were absent after a skip or a dry run, so a caller running under
     the `warnings FATAL => 'all'` this module recommends died just by reading
     `$t->{'exit'}`. They are now always present, holding their empty values.
 - **`string_max` was uncapped**, so a chatty command had its whole capture echoed
     to the terminal and written to the log — a measured 3 MB stdout wrote
     3,002,832 bytes to each. It is now capped at 4096 characters; Data::Printer
     marks what it drops. The full capture is still on the result hash.
 - **Loading SimpleFlow polluted `main::`.** `use DDP` and `use Cwd 'getcwd'` sat
     above the `package` statement, so `p`, `np` and `getcwd` were imported into
     every program that loaded the module. The `package` statement now comes
     first, and the duplicated `use` lines are gone.
 - **Unbalanced parenthesis** in the 0-length `output.files` error message.

 [Added]

 - **`stale`**: also re-run when an input file is newer than an output file, the
     rule `make` and `snakemake` use. Off by default, so existing pipelines are
     unaffected. The result carries `out.of.date`.
 - **`timeout`**: a wall-clock budget in whole seconds. The command runs in its
     own process group and the whole group is killed if the budget is exceeded,
     so a wedged pipeline does not leave orphans behind. The result carries
     `timed.out`. POSIX only.
 - **An array-ref `cmd`** runs the command without a shell, so arguments coming
     from data need no quoting.
 - **`quiet`**: suppress the record printed to the terminal without silencing the
     log or `STDERR`.
 - **`input.file`**, the single-file convenience form of `input.files`, matching
     `output.file`.

 [Changed]

 - `$VERSION` is now a quoted string. As a bare number it was stringified through
     `%g`, so a future `0.20` would have become `"0.2"` and compared as older than
     `"0.15"` on CPAN.
 - **Incompatible:** `input.files` on the result is now always an array ref, as
     `output.files` always was. A scalar argument used to be stored raw.
 - `POSIX` (core) is now a dependency, for `_exit` in the timeout child.

0.15 2026-07-17 (Claude Opus 4.8 helped)

 - addition of `output.file`, a single-file convenience form of `output.files`. It
   takes one plain filename, cannot be combined with `output.files`, and dies if
   given a reference or an empty name.

 - removal of Term::ANSIColor dependency

 - improved coverage testing

0.14 2026-06-29 (Claude Opus 4.8 helped)

 [`task`]
 - **New:** accepts a flat key/value list as well as a hash ref —
     `task(cmd => ...)` and `task( cmd => ... )` are now equivalent. A lone
     non-hashref scalar or any odd-length argument list is fatal.
 - **Bug fix:** the default `die => 1` was ignored when checking for missing
     `output.files`. The block tested the raw `$args->{'die'}` (undef when the
     caller omitted it) instead of the resolved `$r{'die'}`, so a command that
     failed to produce its declared outputs only warned instead of dying. Now
     consistent with the exit-code check.
 - **Bug fix:** removed a stray `)` (and an extraneous leading space) from the
     "command is" line written to the log file; it now matches the on-screen form.
 - **Bug fix:** `length $_ == 0` could throw a fatal uninitialized-value warning
     (under `warnings FATAL => 'all'`) on an undef element of the `input.files`
     array branch and the `output.files` empty-name check. Both now guard with
     `(defined $_) && (length $_ == 0)`, matching the `input.files` scalar branch.

0.13 2026-06-11

 [Fixed (Claude Opus 4.8 helped)]

 - **Exit status and signal are now decoded correctly.** `task()` previously
     computed the exit code (`$status >> 8`) and *then* derived the signal as
     `$exit & 127`. Because the signal lives in the low byte of the raw wait
     status, which `>> 8` discards the `signal` field was always wrong: a clean
     `exit 42` was reported as `signal 42`, and a process actually killed by a
     signal reported `signal 0`. The signal is now read from the raw status before
     shifting, so `exit` and `signal` are independent and accurate.

 - **No longer dies on a missing output file when `die => 0`.** The zero-size
     check did `(-s $file) == 0`, which is `undef == 0` when a declared output file
     is absent. Under `use warnings FATAL => 'all'` that "uninitialized value"
     warning was fatal, so a task that was meant to *warn* about missing output
     (with `die => 0`) crashed instead. Missing sizes are now treated as `0`, so
     the task warns and returns its result hash as intended.

 - **The "already done" result is now logged with its `duration`.** In the
     short-circuit path (output files already exist), `duration` was set *after*
     the record was written to the log, so the logged hash was missing it; the
     duplicate `done => 'before'` assignment was also removed.

 [Changed / Windows support]

 - **Portable exit-status handling.** Decoding now branches on `$^O`: Windows has
     no POSIX signals (`signal` is reported as `0` there), and a `system()` that
     fails to launch the command (`-1`) yields `exit => -1` instead of a garbage
     value from shifting `-1`.

 - **ANSI colour is disabled on the legacy Windows console.** `Term::ANSIColor`
     output is suppressed on `MSWin32` unless an ANSI-capable terminal is detected
     (Windows Terminal, ConEmu, or ANSICON), so `cmd.exe` no longer prints raw
     escape sequences and redirected logs stay clean. Unix and modern Windows
     terminals are unaffected.

 [Tests]

 - Rewrote `t/01.t` to be cross-platform: shell commands now invoke the running
     Perl interpreter (`"$^X" -e ...`) instead of Unix-only tools (`which`, `ls`,
     `ln`, `cp`), and temp files use the system temp directory instead of a
     hard-coded `/tmp`.
 - Added regression tests for both fixed bugs (exit/signal decoding; surviving a
     missing output file with `die => 0`).
 - Added coverage for the `note` field, the `input.file.size` / `output.file.size`
     hashes, scalar-vs-array normalisation of `input.files` / `output.files`, the
     `dir` / `source.file` / `source.line` metadata, captured `stdout` / `stderr`
     (including trailing-whitespace stripping), and argument validation (missing
     `cmd`, unknown keys, bad `log.fh`, missing input files).

0.12 2026-02-14

 - exit code now matches what shell would show it as; signal now appears

0.11 2026-01-13

 - max string length now corresponds to max of output strings, no more truncated output
   added List::Util dependency for string length maxes
   memory size now shows when output
   directory is now output during dry runs