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