From a00c76de794e44530a6668eb9b58032f2e3e0ae0 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 10:58:16 -0700 Subject: [PATCH 01/12] Document cronic's bash 3.2 requirement; fix the tense of a test comment --- cronic | 2 ++ tests/cronic-test/cronic-test.sh | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/cronic b/cronic index b26d786..8d964ab 100755 --- a/cronic +++ b/cronic @@ -14,6 +14,8 @@ # --duplicate-stderr options, which may be given in any order. # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. +# This script runs under bash 3.2, which is /bin/bash on macOS, so it uses a +# feature of a later bash only after checking the version. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 7b62509..a0ca67e 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -6,9 +6,9 @@ # # `cronic` filters trace lines out of stderr with `grep -v`, which exits with # status 1 when it selects no lines. `cronic` runs under `set -e`, so an -# unguarded `grep -v` aborted the script at that point: no report was printed, -# the exit status was grep's 1 rather than the wrapped command's, and all of -# the temporary files under /tmp were left behind. +# unguarded `grep -v` would abort the script at that point: no report would be +# printed, the exit status would be grep's 1 rather than the wrapped command's, +# and the temporary files would be left behind. set -eu From 74832afe34a13b333f7523308eefae8f227ae749 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 11:57:59 -0700 Subject: [PATCH 02/12] State correctly that cronic's EXIT trap removes its temporary files after a set -e abort --- cronic | 4 ++-- tests/cronic-test/cronic-test.sh | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cronic b/cronic index 8d964ab..34f5294 100755 --- a/cronic +++ b/cronic @@ -202,8 +202,8 @@ cronic_pattern=$(cronic_trace_pattern) if [ -n "$cronic_pattern" ] && grep -q "$cronic_pattern" "$cronic_trace"; then # `|| true` because `grep -v` exits with status 1 when it selects no lines, # which happens whenever stderr consists only of trace lines. Without it, - # `set -e` would abort the script here, before it produces its report, - # exits with the wrapped command's status, and removes its temporary files. + # `set -e` would abort the script here, before it produces its report, and + # it would exit with status 1 rather than with the wrapped command's status. grep -v "$cronic_pattern" "$cronic_trace" > "$cronic_err" || true else cronic_err=$cronic_trace diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index a0ca67e..2b91289 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -7,8 +7,9 @@ # `cronic` filters trace lines out of stderr with `grep -v`, which exits with # status 1 when it selects no lines. `cronic` runs under `set -e`, so an # unguarded `grep -v` would abort the script at that point: no report would be -# printed, the exit status would be grep's 1 rather than the wrapped command's, -# and the temporary files would be left behind. +# printed, and the exit status would be grep's 1 rather than the wrapped +# command's. (The temporary files would still be removed, by `cronic`'s EXIT +# trap, so the left-behind-files check in `check` does not detect that bug.) set -eu From c69af58cd6611909565fac32aafb3d9efde4105d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 11:58:16 -0700 Subject: [PATCH 03/12] Make cronic portable to macOS's BSD grep and cmp, and test it on macOS --- .github/workflows/tests.yaml | 13 +++++++++++++ cronic | 16 ++++++++++------ tests/cronic-test/cronic-test.sh | 25 +++++++++++++++++++------ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 929afad..f68df14 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -34,3 +34,16 @@ jobs: command -v xxd command -v bc - run: make -C tests test + # `cronic` promises to run under macOS's /bin/bash, which is bash 3.2, and + # with macOS's BSD utilities. + cronic-macos: + runs-on: macos-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 1 + show-progress: false + persist-credentials: false + - run: /bin/bash --version + - run: make -C tests/cronic-test test diff --git a/cronic b/cronic index 34f5294..1a9d4d7 100755 --- a/cronic +++ b/cronic @@ -14,8 +14,9 @@ # --duplicate-stderr options, which may be given in any order. # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. -# This script runs under bash 3.2, which is /bin/bash on macOS, so it uses a -# feature of a later bash only after checking the version. +# This script runs on macOS, whose /bin/bash is bash 3.2, so it uses a feature +# of a later bash only after checking the version, and it uses only the POSIX +# features of utilities such as `grep` and `cmp`. CI runs its tests on macOS. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -194,8 +195,11 @@ cronic_trace_pattern() { # multibyte character is repeated as a whole, whatever the locale. local LC_ALL=C [[ $PS4 =~ ^([$'\xc0'-$'\xff'][$'\x80'-$'\xbf']*|.) ]] - local first=${BASH_REMATCH[0]} - printf '^\\(%s\\)\\+%s\n' "$(cronic_bre_quote "$first")" "$(cronic_bre_quote "${PS4#"$first"}")" + local first + first=$(cronic_bre_quote "${BASH_REMATCH[0]}") + # `\(...\)*` rather than GNU's `\+`, which is not a POSIX basic regular + # expression. + printf '^%s\\(%s\\)*%s\n' "$first" "$first" "$(cronic_bre_quote "${PS4#"${BASH_REMATCH[0]}"}")" } cronic_pattern=$(cronic_trace_pattern) @@ -216,7 +220,7 @@ fi # TODO: Expand this. cat "$cronic_err" \ | grep -v "^Everything up-to-date$" \ - | grep -v '^make\(\[[0-9]\+\]\)\?: \(Entering\|Leaving\) directory '"'"'.*'"'"'$' \ + | grep -E -v '^make(\[[0-9]+\])?: (Entering|Leaving) directory '"'"'.*'"'"'$' \ > "$cronic_reduced_err" || true if [ $cronic_result -ne "$cronic_expected" ] || { [ $cronic_permitstderr -eq 0 ] && [ -s "$cronic_reduced_err" ]; }; then @@ -237,7 +241,7 @@ if [ $cronic_result -ne "$cronic_expected" ] || { [ $cronic_permitstderr -eq 0 ] cat "$cronic_err" echo if [ "$cronic_debug" != "true" ]; then - if ! cmp --silent "$cronic_err" "$cronic_reduced_err"; then + if ! cmp -s "$cronic_err" "$cronic_reduced_err"; then echo "REDUCED ERROR OUTPUT:" cat "$cronic_reduced_err" echo diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 2b91289..ffdc163 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -300,20 +300,33 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n # shellcheck disable=SC2086 # each name is a separate argument. unset $exported_names -# Bash writes its trace lines to a separate file, so they are not error -# output, whatever PS4 is. +# Bash 4.1 or later writes its trace lines to a separate file, so they are not +# error output, whatever PS4 is. An earlier bash, such as macOS's /bin/bash, +# writes them to stderr, where `cronic` cannot recognize them when PS4 contains +# an expansion or an escape, so the "silent" checks for such a PS4 are skipped. +if /bin/bash -c '((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1)))'; then + bash_has_xtracefd=true +else + bash_has_xtracefd=false + echo "SKIP: bash, trace-only stderr with a PS4 that contains an expansion or" \ + "an escape, because /bin/bash is earlier than 4.1" +fi export PS4 # shellcheck disable=SC2016 # The expansion is for the traced command to do. PS4='+${LINENO}: ' -check "bash, PS4 with expansions, trace-only stderr" 0 silent \ - "$work/trace-only" 0 +if [ "$bash_has_xtracefd" = true ]; then + check "bash, PS4 with expansions, trace-only stderr" 0 silent \ + "$work/trace-only" 0 +fi check "bash, PS4 with expansions, trace in the report" 3 \ "report:echo 'the standard output'$" "$work/trace-only" 3 check "bash, PS4 with expansions, trace lines and real stderr" 0 \ "report:^a real error$" "$work/trace-and-stderr" 0 PS4='+\D{%H}: ' -check "bash, PS4 with an escape, trace-only stderr" 0 silent \ - "$work/trace-only" 0 +if [ "$bash_has_xtracefd" = true ]; then + check "bash, PS4 with an escape, trace-only stderr" 0 silent \ + "$work/trace-only" 0 +fi PS4='[trace] ' check "bash, PS4 with metacharacters, trace-only stderr" 0 silent \ "$work/trace-only" 0 From 032c798bcf6e85646dbfd5f63f8a4b3dd683bad6 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 12:33:35 -0700 Subject: [PATCH 04/12] Say which checks catch a missing || true, and point to cronic's bash version check --- cronic | 3 ++- tests/cronic-test/cronic-test.sh | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cronic b/cronic index 34f5294..b1bd8fd 100755 --- a/cronic +++ b/cronic @@ -15,7 +15,8 @@ # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. # This script runs under bash 3.2, which is /bin/bash on macOS, so it uses a -# feature of a later bash only after checking the version. +# feature of a later bash only after checking the version. For example, +# BASH_XTRACEFD requires bash 4.1; see the BASH_VERSINFO check below. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 2b91289..7220617 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -10,6 +10,11 @@ # printed, and the exit status would be grep's 1 rather than the wrapped # command's. (The temporary files would still be removed, by `cronic`'s EXIT # trap, so the left-behind-files check in `check` does not detect that bug.) +# The checks that catch that bug are the "trace-only stderr" checks that use +# `stderr-lines` with a literal PS4, which run only when the test is not run as +# root. The checks that use `trace-only`, a bash script, catch it only under a +# bash earlier than 4.1: a later bash writes its trace lines to BASH_XTRACEFD, +# so `cronic` sees no trace lines in stderr and does not run `grep -v`. set -eu From 1aa6f89694d87b74cb12d0b0d68cbdfb760b84a5 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 12:33:53 -0700 Subject: [PATCH 05/12] Clarify cronic's portability comment --- cronic | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/cronic b/cronic index 16fae27..59be284 100755 --- a/cronic +++ b/cronic @@ -14,11 +14,10 @@ # --duplicate-stderr options, which may be given in any order. # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. -# This script runs on macOS, whose /bin/bash is bash 3.2, so it uses a feature -# of a later bash only after checking the version, and it uses only the POSIX -# features of utilities such as `grep` and `cmp`. For example, BASH_XTRACEFD -# requires bash 4.1; see the BASH_VERSINFO check below. CI runs its tests on -# macOS. +# This script runs on macOS, whose /bin/bash is bash 3.2. So it uses a feature +# of a later bash, such as BASH_XTRACEFD (bash 4.1), only after checking the +# version; see the BASH_VERSINFO check below. And it uses only the POSIX +# features of utilities such as `grep` and `cmp`. CI runs its tests on macOS. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. From a10235784240c1c809c9df5c5f7ad20fce0a50f8 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 12:44:04 -0700 Subject: [PATCH 06/12] Filter macOS make's directory notices, test old-bash and nested-trace behavior, and detect BASH_XTRACEFD by feature Accept GNU make 3.81's `dir' quoting and the name gmake in cronic's make filter; state precisely which utilities cronic uses portably. In the tests, check cronic's behavior under a bash earlier than 4.1 instead of skipping it, run a real bash trace nested 3 deep through the stderr path, and test /bin/bash for BASH_XTRACEFD support rather than copying cronic's version test. --- cronic | 10 ++++-- tests/cronic-test/cronic-test.sh | 57 +++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/cronic b/cronic index 59be284..34221c1 100755 --- a/cronic +++ b/cronic @@ -16,8 +16,9 @@ # `cronic_help`; run `cronic --help`. # This script runs on macOS, whose /bin/bash is bash 3.2. So it uses a feature # of a later bash, such as BASH_XTRACEFD (bash 4.1), only after checking the -# version; see the BASH_VERSINFO check below. And it uses only the POSIX -# features of utilities such as `grep` and `cmp`. CI runs its tests on macOS. +# version; see the BASH_VERSINFO check below. Its uses of `grep`, `sed`, and +# `cmp` are POSIX. `mktemp -d` with a template is not POSIX, but macOS, the +# BSDs, and GNU support it. CI runs its tests on macOS. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -218,10 +219,13 @@ fi # For example, git does this, and the POSIX standard requires it for make. # The full stderr output is shown, but file cronic_reduced_err is checked for # emptiness below. +# GNU make names itself as invoked, such as `make` or `gmake`. It quotes the +# directory as 'dir' from version 4.0, and as `dir' before then; macOS's +# /usr/bin/make is GNU make 3.81. # TODO: Expand this. cat "$cronic_err" \ | grep -v "^Everything up-to-date$" \ - | grep -E -v '^make(\[[0-9]+\])?: (Entering|Leaving) directory '"'"'.*'"'"'$' \ + | grep -E -v '^g?make(\[[0-9]+\])?: (Entering|Leaving) directory '"[\`']"'.*'"'"'$' \ > "$cronic_reduced_err" || true if [ $cronic_result -ne "$cronic_expected" ] || { [ $cronic_permitstderr -eq 0 ] && [ -s "$cronic_reduced_err" ]; }; then diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 84fc834..ceb18f1 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -52,12 +52,17 @@ EOF chmod +x "$work/trace-and-stderr" # A command whose stderr is nothing but `make` directory-change notices. Both -# the top-level form (`make:`) and the recursive form (`make[1]:`) appear; both -# must be filtered out of the reduced error output. +# the top-level form (`make:`) and the recursive form (`make[1]:`) appear, as do +# the quoting of GNU make 3.81 (macOS's /usr/bin/make) and the name `gmake`; +# all must be filtered out of the reduced error output. cat > "$work/make-noise" << 'EOF' #!/bin/sh echo "make: Entering directory '/tmp/x'" 1>&2 echo "make[1]: Entering directory '/tmp/x/sub'" 1>&2 +echo 'make[2]: Entering directory `/tmp/x/sub/old'"'" 1>&2 +echo 'make[2]: Leaving directory `/tmp/x/sub/old'"'" 1>&2 +echo "gmake[2]: Entering directory '/tmp/x/sub/g'" 1>&2 +echo "gmake[2]: Leaving directory '/tmp/x/sub/g'" 1>&2 echo "make[1]: Leaving directory '/tmp/x/sub'" 1>&2 echo "make: Leaving directory '/tmp/x'" 1>&2 exit "$1" @@ -76,6 +81,18 @@ exit "$1" EOF chmod +x "$work/make-noise-and-stderr" +# A bash command whose trace lines go to stderr, as under a bash earlier than +# 4.1, whatever the bash version. Its trace lines are nested 3 deep. +cat > "$work/nested-trace" << 'EOF' +#!/bin/bash +unset BASH_XTRACEFD +set -x +outer=$(inner=$(echo deepest); echo "$inner") +echo "$outer" +exit "$1" +EOF +chmod +x "$work/nested-trace" + # A command that writes, to stderr, lines that start with `+` but are not trace # lines. cat > "$work/plus-stderr" << 'EOF' @@ -305,16 +322,17 @@ check "caller's exported variables" 0 silent "$work/check-variables" $exported_n # shellcheck disable=SC2086 # each name is a separate argument. unset $exported_names -# Bash 4.1 or later writes its trace lines to a separate file, so they are not -# error output, whatever PS4 is. An earlier bash, such as macOS's /bin/bash, -# writes them to stderr, where `cronic` cannot recognize them when PS4 contains -# an expansion or an escape, so the "silent" checks for such a PS4 are skipped. -if /bin/bash -c '((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1)))'; then +# A bash that supports BASH_XTRACEFD and the allocation of a file descriptor +# (bash 4.1 or later) writes its trace lines to a separate file, so they are +# not error output, whatever PS4 is. An earlier bash, such as macOS's +# /bin/bash, writes them to stderr, where `cronic` cannot recognize them when +# PS4 contains an expansion or an escape, so it reports them as error output. +# This tests for the features themselves, rather than for the bash version +# that `cronic` tests for, so that it detects a wrong version test in `cronic`. +if [ -z "$(/bin/bash -c 'exec {fd}> /dev/null && BASH_XTRACEFD=$fd && set -x && :' 2>&1)" ]; then bash_has_xtracefd=true else bash_has_xtracefd=false - echo "SKIP: bash, trace-only stderr with a PS4 that contains an expansion or" \ - "an escape, because /bin/bash is earlier than 4.1" fi export PS4 # shellcheck disable=SC2016 # The expansion is for the traced command to do. @@ -322,15 +340,24 @@ PS4='+${LINENO}: ' if [ "$bash_has_xtracefd" = true ]; then check "bash, PS4 with expansions, trace-only stderr" 0 silent \ "$work/trace-only" 0 + # The trace lines are not error output, so the real error is what is reported. + check "bash, PS4 with expansions, trace lines and real stderr" 0 \ + "report:^a real error$" "$work/trace-and-stderr" 0 +else + check "bash before 4.1, PS4 with expansions, trace-only stderr" 0 \ + "report:^+3: echo 'the standard output'$" "$work/trace-only" 0 + echo "SKIP: bash, PS4 with expansions, trace lines and real stderr," \ + "because /bin/bash is earlier than 4.1, so the trace lines are error output" fi check "bash, PS4 with expansions, trace in the report" 3 \ "report:echo 'the standard output'$" "$work/trace-only" 3 -check "bash, PS4 with expansions, trace lines and real stderr" 0 \ - "report:^a real error$" "$work/trace-and-stderr" 0 PS4='+\D{%H}: ' if [ "$bash_has_xtracefd" = true ]; then check "bash, PS4 with an escape, trace-only stderr" 0 silent \ "$work/trace-only" 0 +else + check "bash before 4.1, PS4 with an escape, trace-only stderr" 0 \ + "report:^+[0-9][0-9]: echo 'the standard output'$" "$work/trace-only" 0 fi PS4='[trace] ' check "bash, PS4 with metacharacters, trace-only stderr" 0 silent \ @@ -350,7 +377,13 @@ fi # metacharacters or quotes, and the metacharacters do not match other lines. PS4='[trace] ' check "PS4 with metacharacters, trace-only stderr" 0 silent \ - "$work/stderr-lines" "[trace] echo hi" "[[trace] echo nested" + "$work/stderr-lines" "[trace] echo hi" "[[trace] echo nested" \ + "[[[trace] echo nested more" +# The same, for the trace lines that bash writes to stderr. +check "bash, PS4 with metacharacters, nested trace in stderr" 0 silent \ + "$work/nested-trace" 0 +check "bash, PS4 with metacharacters, nested trace in the report" 3 \ + "report:^\[\[\[trace\] echo deepest$" "$work/nested-trace" 3 check "PS4 with metacharacters, trace lines and real stderr" 0 \ "report:^a real error$" \ "$work/stderr-lines" "[trace] echo hi" "a real error" From ef2d5974cd8c873a6c9bada9e23e41e4200f556c Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 13:01:42 -0700 Subject: [PATCH 07/12] Run cronic under the first bash in PATH, and test old bash and macOS more thoroughly Simplify cronic's trace pattern and stderr filter. Run the trace-and-stderr check on every bash, run the PS4-dependent old-bash checks only when not root, make temp-file-test search through macOS's /tmp symlink, and put /bin first in PATH in the macOS CI job so that it tests bash 3.2. --- .github/workflows/tests.yaml | 7 +++-- cronic | 26 +++++++++-------- tests/cronic-test/cronic-test.sh | 44 ++++++++++++++++------------- tests/cronic-test/temp-file-test.sh | 6 ++-- 4 files changed, 47 insertions(+), 36 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f68df14..ad8b60e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -35,7 +35,9 @@ jobs: command -v bc - run: make -C tests test # `cronic` promises to run under macOS's /bin/bash, which is bash 3.2, and - # with macOS's BSD utilities. + # with macOS's BSD utilities. `cronic` and the bash scripts in its tests run + # under the first `bash` in PATH, so /bin is put first, ahead of any + # Homebrew bash. cronic-macos: runs-on: macos-latest timeout-minutes: 10 @@ -45,5 +47,6 @@ jobs: fetch-depth: 1 show-progress: false persist-credentials: false - - run: /bin/bash --version + - run: echo /bin >> "$GITHUB_PATH" + - run: bash --version - run: make -C tests/cronic-test test diff --git a/cronic b/cronic index 34221c1..8cbc845 100755 --- a/cronic +++ b/cronic @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env bash # Cronic v2 - cron job report wrapper # Copyright 2007 Chuck Houpt @@ -14,11 +14,12 @@ # --duplicate-stderr options, which may be given in any order. # The options and the treatment of execution traces are documented in function # `cronic_help`; run `cronic --help`. -# This script runs on macOS, whose /bin/bash is bash 3.2. So it uses a feature -# of a later bash, such as BASH_XTRACEFD (bash 4.1), only after checking the -# version; see the BASH_VERSINFO check below. Its uses of `grep`, `sed`, and -# `cmp` are POSIX. `mktemp -d` with a template is not POSIX, but macOS, the -# BSDs, and GNU support it. CI runs its tests on macOS. +# This script runs under the first `bash` in PATH, which on macOS may be +# /bin/bash, bash 3.2. So it uses a feature of a later bash, such as +# BASH_XTRACEFD (bash 4.1), only after checking the version; see the +# BASH_VERSINFO check below. Its uses of `grep`, `sed`, and `cmp` are POSIX. +# `mktemp -d` with a template is not POSIX, but macOS, the BSDs, and GNU +# support it. CI runs its tests on macOS, under /bin/bash. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -199,9 +200,9 @@ cronic_trace_pattern() { [[ $PS4 =~ ^([$'\xc0'-$'\xff'][$'\x80'-$'\xbf']*|.) ]] local first first=$(cronic_bre_quote "${BASH_REMATCH[0]}") - # `\(...\)*` rather than GNU's `\+`, which is not a POSIX basic regular + # `\{1,\}` rather than GNU's `\+`, which is not a POSIX basic regular # expression. - printf '^%s\\(%s\\)*%s\n' "$first" "$first" "$(cronic_bre_quote "${PS4#"${BASH_REMATCH[0]}"}")" + printf '^\\(%s\\)\\{1,\\}%s\n' "$first" "$(cronic_bre_quote "${PS4#"${BASH_REMATCH[0]}"}")" } cronic_pattern=$(cronic_trace_pattern) @@ -223,10 +224,11 @@ fi # directory as 'dir' from version 4.0, and as `dir' before then; macOS's # /usr/bin/make is GNU make 3.81. # TODO: Expand this. -cat "$cronic_err" \ - | grep -v "^Everything up-to-date$" \ - | grep -E -v '^g?make(\[[0-9]+\])?: (Entering|Leaving) directory '"[\`']"'.*'"'"'$' \ - > "$cronic_reduced_err" || true +# `|| true` because `grep -v` exits with status 1 when it selects no lines. +grep -E -v \ + -e '^Everything up-to-date$' \ + -e '^g?make(\[[0-9]+\])?: (Entering|Leaving) directory '"[\`']"'.*'"'"'$' \ + "$cronic_err" > "$cronic_reduced_err" || true if [ $cronic_result -ne "$cronic_expected" ] || { [ $cronic_permitstderr -eq 0 ] && [ -s "$cronic_reduced_err" ]; }; then echo "Cronic detected failure or error output for the command:" diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index ceb18f1..08b14ed 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -35,7 +35,7 @@ status=0 # A command whose stderr is nothing but trace lines. cat > "$work/trace-only" << 'EOF' -#!/bin/bash +#!/usr/bin/env bash set -x echo "the standard output" exit "$1" @@ -44,7 +44,7 @@ chmod +x "$work/trace-only" # A command that writes real error output in addition to trace lines. cat > "$work/trace-and-stderr" << 'EOF' -#!/bin/bash +#!/usr/bin/env bash set -x echo "a real error" 1>&2 exit "$1" @@ -84,7 +84,7 @@ chmod +x "$work/make-noise-and-stderr" # A bash command whose trace lines go to stderr, as under a bash earlier than # 4.1, whatever the bash version. Its trace lines are nested 3 deep. cat > "$work/nested-trace" << 'EOF' -#!/bin/bash +#!/usr/bin/env bash unset BASH_XTRACEFD set -x outer=$(inner=$(echo deepest); echo "$inner") @@ -329,7 +329,9 @@ unset $exported_names # PS4 contains an expansion or an escape, so it reports them as error output. # This tests for the features themselves, rather than for the bash version # that `cronic` tests for, so that it detects a wrong version test in `cronic`. -if [ -z "$(/bin/bash -c 'exec {fd}> /dev/null && BASH_XTRACEFD=$fd && set -x && :' 2>&1)" ]; then +# `cronic` and the bash scripts above all run under the first `bash` in PATH, +# so that is the bash tested here. +if [ -z "$(bash -c 'exec {fd}> /dev/null && BASH_XTRACEFD=$fd && set -x && :' 2>&1)" ]; then bash_has_xtracefd=true else bash_has_xtracefd=false @@ -337,27 +339,17 @@ fi export PS4 # shellcheck disable=SC2016 # The expansion is for the traced command to do. PS4='+${LINENO}: ' -if [ "$bash_has_xtracefd" = true ]; then - check "bash, PS4 with expansions, trace-only stderr" 0 silent \ - "$work/trace-only" 0 - # The trace lines are not error output, so the real error is what is reported. - check "bash, PS4 with expansions, trace lines and real stderr" 0 \ - "report:^a real error$" "$work/trace-and-stderr" 0 -else - check "bash before 4.1, PS4 with expansions, trace-only stderr" 0 \ - "report:^+3: echo 'the standard output'$" "$work/trace-only" 0 - echo "SKIP: bash, PS4 with expansions, trace lines and real stderr," \ - "because /bin/bash is earlier than 4.1, so the trace lines are error output" -fi +# The real error is reported, whether or not the trace lines are too. +check "bash, PS4 with expansions, trace lines and real stderr" 0 \ + "report:^a real error$" "$work/trace-and-stderr" 0 check "bash, PS4 with expansions, trace in the report" 3 \ "report:echo 'the standard output'$" "$work/trace-only" 3 -PS4='+\D{%H}: ' if [ "$bash_has_xtracefd" = true ]; then + check "bash, PS4 with expansions, trace-only stderr" 0 silent \ + "$work/trace-only" 0 + PS4='+\D{%H}: ' check "bash, PS4 with an escape, trace-only stderr" 0 silent \ "$work/trace-only" 0 -else - check "bash before 4.1, PS4 with an escape, trace-only stderr" 0 \ - "report:^+[0-9][0-9]: echo 'the standard output'$" "$work/trace-only" 0 fi PS4='[trace] ' check "bash, PS4 with metacharacters, trace-only stderr" 0 silent \ @@ -373,6 +365,18 @@ if [ "$(id -u)" -eq 0 ]; then exit "$status" fi +# An earlier bash writes its trace lines to stderr, and when PS4 contains an +# expansion or an escape, they are reported. +if [ "$bash_has_xtracefd" = false ]; then + # shellcheck disable=SC2016 # The expansion is for the traced command to do. + PS4='+${LINENO}: ' + check "bash before 4.1, PS4 with expansions, trace-only stderr" 0 \ + "report:^+3: echo 'the standard output'$" "$work/trace-only" 0 + PS4='+\D{%H}: ' + check "bash before 4.1, PS4 with an escape, trace-only stderr" 0 \ + "report:^+[0-9][0-9]: echo 'the standard output'$" "$work/trace-only" 0 +fi + # Trace lines are recognized when PS4 contains regular-expression # metacharacters or quotes, and the metacharacters do not match other lines. PS4='[trace] ' diff --git a/tests/cronic-test/temp-file-test.sh b/tests/cronic-test/temp-file-test.sh index a15f955..092677b 100755 --- a/tests/cronic-test/temp-file-test.sh +++ b/tests/cronic-test/temp-file-test.sh @@ -88,11 +88,13 @@ marker="cronic temp-file-test marker $work" # marker_files: prints the files that contain the marker. Files directly # within /tmp, or within a subdirectory of it, are searched, except for this -# test's own files. Errors, such as unreadable files, are ignored. +# test's own files. Errors, such as unreadable files, are ignored. `-H` +# because on macOS, /tmp is a symbolic link, which `find` otherwise does not +# follow. marker_files() { { find "$TMPDIR" -type f -exec grep -lF -- "$marker" {} + 2> /dev/null || true - find /tmp -maxdepth 2 -path "$work" -prune \ + find -H /tmp -maxdepth 2 -path "$work" -prune \ -o -type f -user "$(id -u)" -exec grep -lF -- "$marker" {} + \ 2> /dev/null || true } | sort -u From 2a731346d29702773cdd09ad8989b2c64e21aa9d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 13:20:30 -0700 Subject: [PATCH 08/12] Treat wrapped output as text in cronic's greps, filter more make names, and make the BASH_XTRACEFD probe ignore warnings --- cronic | 26 +++++++++++------ tests/cronic-test/cronic-test.sh | 49 ++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/cronic b/cronic index 8cbc845..56401ea 100755 --- a/cronic +++ b/cronic @@ -17,9 +17,11 @@ # This script runs under the first `bash` in PATH, which on macOS may be # /bin/bash, bash 3.2. So it uses a feature of a later bash, such as # BASH_XTRACEFD (bash 4.1), only after checking the version; see the -# BASH_VERSINFO check below. Its uses of `grep`, `sed`, and `cmp` are POSIX. -# `mktemp -d` with a template is not POSIX, but macOS, the BSDs, and GNU -# support it. CI runs its tests on macOS, under /bin/bash. +# BASH_VERSINFO check below. Letting PATH choose bash exposes nothing new: +# PATH also chooses the wrapped command, which runs with the same privileges. +# Its uses of `sed` and `cmp` are POSIX, as are its uses of `grep` apart from +# `-a`. `grep -a` and `mktemp -d` with a template are not POSIX, but macOS, +# the BSDs, and GNU support them. CI runs its tests on macOS, under /bin/bash. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -205,13 +207,18 @@ cronic_trace_pattern() { printf '^\\(%s\\)\\{1,\\}%s\n' "$first" "$(cronic_bre_quote "${PS4#"${BASH_REMATCH[0]}"}")" } +# Each `grep` that reads the wrapped command's output uses `-a`, so that it +# treats the output as text even if it contains a NUL byte or a byte that is +# invalid in the locale. Without `-a`, `grep` would treat such output as +# binary and print only a "Binary file ... matches" notice in place of the +# lines it selects, so that a real error could go unreported. cronic_pattern=$(cronic_trace_pattern) -if [ -n "$cronic_pattern" ] && grep -q "$cronic_pattern" "$cronic_trace"; then +if [ -n "$cronic_pattern" ] && grep -a -q "$cronic_pattern" "$cronic_trace"; then # `|| true` because `grep -v` exits with status 1 when it selects no lines, # which happens whenever stderr consists only of trace lines. Without it, # `set -e` would abort the script here, before it produces its report, and # it would exit with status 1 rather than with the wrapped command's status. - grep -v "$cronic_pattern" "$cronic_trace" > "$cronic_err" || true + grep -a -v "$cronic_pattern" "$cronic_trace" > "$cronic_err" || true else cronic_err=$cronic_trace fi @@ -220,14 +227,15 @@ fi # For example, git does this, and the POSIX standard requires it for make. # The full stderr output is shown, but file cronic_reduced_err is checked for # emptiness below. -# GNU make names itself as invoked, such as `make` or `gmake`. It quotes the -# directory as 'dir' from version 4.0, and as `dir' before then; macOS's +# GNU make names itself by the name it was invoked as, without the directory, +# such as `make`, `gmake`, `gnumake` (on macOS), or `mingw32-make`. It quotes +# the directory as 'dir' from version 4.0, and as `dir' before then; macOS's # /usr/bin/make is GNU make 3.81. # TODO: Expand this. # `|| true` because `grep -v` exits with status 1 when it selects no lines. -grep -E -v \ +grep -a -E -v \ -e '^Everything up-to-date$' \ - -e '^g?make(\[[0-9]+\])?: (Entering|Leaving) directory '"[\`']"'.*'"'"'$' \ + -e '^[[:alnum:]_.-]*make(\[[0-9]+\])?: (Entering|Leaving) directory '"[\`']"'.*'"'"'$' \ "$cronic_err" > "$cronic_reduced_err" || true if [ $cronic_result -ne "$cronic_expected" ] || { [ $cronic_permitstderr -eq 0 ] && [ -s "$cronic_reduced_err" ]; }; then diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index 08b14ed..ca80c8c 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -11,10 +11,12 @@ # command's. (The temporary files would still be removed, by `cronic`'s EXIT # trap, so the left-behind-files check in `check` does not detect that bug.) # The checks that catch that bug are the "trace-only stderr" checks that use -# `stderr-lines` with a literal PS4, which run only when the test is not run as -# root. The checks that use `trace-only`, a bash script, catch it only under a -# bash earlier than 4.1: a later bash writes its trace lines to BASH_XTRACEFD, -# so `cronic` sees no trace lines in stderr and does not run `grep -v`. +# `stderr-lines` with a literal PS4, and the "nested trace in stderr" check, +# whose bash script writes its trace lines to stderr whatever the bash version. +# All of them run only when the test is not run as root. The checks that use +# `trace-only`, a bash script, catch it only under a bash earlier than 4.1: a +# later bash writes its trace lines to BASH_XTRACEFD, so `cronic` sees no trace +# lines in stderr and does not run `grep -v`. set -eu @@ -53,8 +55,9 @@ chmod +x "$work/trace-and-stderr" # A command whose stderr is nothing but `make` directory-change notices. Both # the top-level form (`make:`) and the recursive form (`make[1]:`) appear, as do -# the quoting of GNU make 3.81 (macOS's /usr/bin/make) and the name `gmake`; -# all must be filtered out of the reduced error output. +# the quoting of GNU make 3.81 (macOS's /usr/bin/make) and the other names +# that GNU make runs under; all must be filtered out of the reduced error +# output. cat > "$work/make-noise" << 'EOF' #!/bin/sh echo "make: Entering directory '/tmp/x'" 1>&2 @@ -63,6 +66,10 @@ echo 'make[2]: Entering directory `/tmp/x/sub/old'"'" 1>&2 echo 'make[2]: Leaving directory `/tmp/x/sub/old'"'" 1>&2 echo "gmake[2]: Entering directory '/tmp/x/sub/g'" 1>&2 echo "gmake[2]: Leaving directory '/tmp/x/sub/g'" 1>&2 +echo "gnumake[2]: Entering directory '/tmp/x/sub/gnu'" 1>&2 +echo "gnumake[2]: Leaving directory '/tmp/x/sub/gnu'" 1>&2 +echo "mingw32-make[2]: Entering directory '/tmp/x/sub/mingw'" 1>&2 +echo "mingw32-make[2]: Leaving directory '/tmp/x/sub/mingw'" 1>&2 echo "make[1]: Leaving directory '/tmp/x/sub'" 1>&2 echo "make: Leaving directory '/tmp/x'" 1>&2 exit "$1" @@ -81,6 +88,16 @@ exit "$1" EOF chmod +x "$work/make-noise-and-stderr" +# A command that writes a trace line and then an error line that contains a +# NUL byte and a byte that is invalid in UTF-8, so that `grep` would treat the +# output as binary. +cat > "$work/binary-stderr" << 'EOF' +#!/bin/sh +printf '[trace] echo hi\n' 1>&2 +printf 'bad \377\000 error\n' 1>&2 +EOF +chmod +x "$work/binary-stderr" + # A bash command whose trace lines go to stderr, as under a bash earlier than # 4.1, whatever the bash version. Its trace lines are nested 3 deep. cat > "$work/nested-trace" << 'EOF' @@ -205,6 +222,9 @@ check "make directory-change notices, exit 0" 0 silent "$work/make-noise" 0 check "make directory-change notices and real stderr, exit 0" 0 \ "report:^a real error$" "$work/make-noise-and-stderr" 0 +# Error output that `grep` would treat as binary is still reported. +check "binary stderr" 0 "report:error$" "$work/binary-stderr" + # Without a command, `cronic` reports a usage error rather than aborting on an # unset variable. check "no arguments" 64 "message:^Usage: " @@ -330,8 +350,11 @@ unset $exported_names # This tests for the features themselves, rather than for the bash version # that `cronic` tests for, so that it detects a wrong version test in `cronic`. # `cronic` and the bash scripts above all run under the first `bash` in PATH, -# so that is the bash tested here. -if [ -z "$(bash -c 'exec {fd}> /dev/null && BASH_XTRACEFD=$fd && set -x && :' 2>&1)" ]; then +# so that is the bash tested here. The probe checks whether a trace line +# reaches the file, rather than whether bash writes to stderr, because bash can +# write an unrelated warning to stderr, such as for an uninstalled locale. +if bash -c 'exec {fd}> "$1" && BASH_XTRACEFD=$fd && set -x && :' \ + bash "$work/xtracefd-probe" 2> /dev/null && [ -s "$work/xtracefd-probe" ]; then bash_has_xtracefd=true else bash_has_xtracefd=false @@ -355,10 +378,10 @@ PS4='[trace] ' check "bash, PS4 with metacharacters, trace-only stderr" 0 silent \ "$work/trace-only" 0 -# The tests below concern trace lines that a shell other than bash writes to -# stderr; they are recognized by PS4. Bash running as root does not import -# PS4 from the environment, so `cronic` would see the default PS4, and these -# tests are skipped. +# The tests below concern trace lines in stderr, as written by a shell other +# than bash or by a bash that does not use BASH_XTRACEFD; they are recognized +# by PS4. Bash running as root does not import PS4 from the environment, so +# `cronic` would see the default PS4, and these tests are skipped. if [ "$(id -u)" -eq 0 ]; then echo "SKIP: tests of PS4 in stderr, because bash ignores PS4 when run as root" unset PS4 @@ -391,6 +414,8 @@ check "bash, PS4 with metacharacters, nested trace in the report" 3 \ check "PS4 with metacharacters, trace lines and real stderr" 0 \ "report:^a real error$" \ "$work/stderr-lines" "[trace] echo hi" "a real error" +check "PS4 with metacharacters, trace lines and binary stderr" 0 \ + "report:error$" "$work/binary-stderr" PS4='+.* ' check "PS4 with metacharacters, error line that they would match" 0 \ "report:^+++ b/file$" "$work/stderr-lines" "+++ b/file" From 15053ae51717c2e45cdf99c485cecce03959b165 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:01:38 -0700 Subject: [PATCH 09/12] Fall back to unfiltered stderr when cronic's grep fails, and filter the trace file in one pass --- cronic | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/cronic b/cronic index 56401ea..4978394 100755 --- a/cronic +++ b/cronic @@ -200,11 +200,11 @@ cronic_trace_pattern() { # multibyte character is repeated as a whole, whatever the locale. local LC_ALL=C [[ $PS4 =~ ^([$'\xc0'-$'\xff'][$'\x80'-$'\xbf']*|.) ]] - local first - first=$(cronic_bre_quote "${BASH_REMATCH[0]}") + local first=${BASH_REMATCH[0]} + local rest=${PS4#"$first"} # `\{1,\}` rather than GNU's `\+`, which is not a POSIX basic regular # expression. - printf '^\\(%s\\)\\{1,\\}%s\n' "$first" "$(cronic_bre_quote "${PS4#"${BASH_REMATCH[0]}"}")" + printf '^\\(%s\\)\\{1,\\}%s\n' "$(cronic_bre_quote "$first")" "$(cronic_bre_quote "$rest")" } # Each `grep` that reads the wrapped command's output uses `-a`, so that it @@ -213,12 +213,14 @@ cronic_trace_pattern() { # binary and print only a "Binary file ... matches" notice in place of the # lines it selects, so that a real error could go unreported. cronic_pattern=$(cronic_trace_pattern) -if [ -n "$cronic_pattern" ] && grep -a -q "$cronic_pattern" "$cronic_trace"; then - # `|| true` because `grep -v` exits with status 1 when it selects no lines, - # which happens whenever stderr consists only of trace lines. Without it, - # `set -e` would abort the script here, before it produces its report, and - # it would exit with status 1 rather than with the wrapped command's status. - grep -a -v "$cronic_pattern" "$cronic_trace" > "$cronic_err" || true +if [ -n "$cronic_pattern" ]; then + # `|| [ $? -eq 1 ]` because `grep -v` exits with status 1 when it selects no + # lines, which happens whenever stderr consists only of trace lines. Without + # it, `set -e` would abort the script here, before it produces its report, + # and it would exit with status 1 rather than with the wrapped command's + # status. If `grep` fails (status 2), the unfiltered stderr is used, rather + # than an empty or truncated file cronic_err that could hide an error. + grep -a -v "$cronic_pattern" "$cronic_trace" > "$cronic_err" || [ $? -eq 1 ] || cronic_err=$cronic_trace else cronic_err=$cronic_trace fi @@ -232,11 +234,12 @@ fi # the directory as 'dir' from version 4.0, and as `dir' before then; macOS's # /usr/bin/make is GNU make 3.81. # TODO: Expand this. -# `|| true` because `grep -v` exits with status 1 when it selects no lines. +# `|| [ $? -eq 1 ]` because `grep -v` exits with status 1 when it selects no +# lines. If `grep` fails (status 2), the unreduced stderr is used. grep -a -E -v \ -e '^Everything up-to-date$' \ -e '^[[:alnum:]_.-]*make(\[[0-9]+\])?: (Entering|Leaving) directory '"[\`']"'.*'"'"'$' \ - "$cronic_err" > "$cronic_reduced_err" || true + "$cronic_err" > "$cronic_reduced_err" || [ $? -eq 1 ] || cronic_reduced_err=$cronic_err if [ $cronic_result -ne "$cronic_expected" ] || { [ $cronic_permitstderr -eq 0 ] && [ -s "$cronic_reduced_err" ]; }; then echo "Cronic detected failure or error output for the command:" @@ -264,7 +267,7 @@ if [ $cronic_result -ne "$cronic_expected" ] || { [ $cronic_permitstderr -eq 0 ] fi echo "STANDARD OUTPUT:" cat "$cronic_out" - if [ "$cronic_trace" != "$cronic_err" ]; then + if ! cmp -s "$cronic_trace" "$cronic_err"; then echo echo "TRACE-ERROR OUTPUT:" cat "$cronic_trace" From 26aaf8b8689632a5def6ae30446f9b463079e09d Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sat, 10 Oct 2026 19:21:23 -0700 Subject: [PATCH 10/12] End cronic's captured stderr with a newline, so that the report does not repeat unfiltered stderr --- cronic | 15 ++++++++++++--- tests/cronic-test/cronic-test.sh | 27 ++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/cronic b/cronic index 4978394..42a05d8 100755 --- a/cronic +++ b/cronic @@ -19,9 +19,9 @@ # BASH_XTRACEFD (bash 4.1), only after checking the version; see the # BASH_VERSINFO check below. Letting PATH choose bash exposes nothing new: # PATH also chooses the wrapped command, which runs with the same privileges. -# Its uses of `sed` and `cmp` are POSIX, as are its uses of `grep` apart from -# `-a`. `grep -a` and `mktemp -d` with a template are not POSIX, but macOS, -# the BSDs, and GNU support them. CI runs its tests on macOS, under /bin/bash. +# Its uses of `sed`, `cmp`, `tail`, and `wc` are POSIX, as are its uses of +# `grep` apart from `-a`. `grep -a` and `mktemp -d` with a template are not +# POSIX, but macOS, the BSDs, and GNU support them. CI runs its tests on macOS, under /bin/bash. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. @@ -177,6 +177,15 @@ set -e # Unsetting BASH_XTRACEFD also closes its file descriptor. unset BASH_XTRACEFD +# End stderr with a newline, if it is not empty. `grep` ends its output with a +# newline, so without this, the filtered stderr would differ from the +# unfiltered stderr even when `grep` removed no line, and the report would +# show the same output twice. The newline also keeps the last line of stderr +# off the line of the report's next heading. +if [ -s "$cronic_trace" ] && [ "$(tail -c 1 "$cronic_trace" | wc -l)" -eq 0 ]; then + echo >> "$cronic_trace" +fi + # Prints a basic regular expression that matches its argument literally. # The argument must not contain a backslash. cronic_bre_quote() { diff --git a/tests/cronic-test/cronic-test.sh b/tests/cronic-test/cronic-test.sh index ca80c8c..230fd28 100755 --- a/tests/cronic-test/cronic-test.sh +++ b/tests/cronic-test/cronic-test.sh @@ -135,9 +135,10 @@ temp_files() { # check DESCRIPTION EXPECTED-STATUS EXPECTED-OUTPUT COMMAND...: runs `cronic` # on COMMAND and checks its exit status, whether it printed a report, and that # it left no temporary files behind. EXPECTED-OUTPUT is "silent", "report", -# "report:TEXT", which also requires TEXT to appear in the report, or -# "message:TEXT", which requires TEXT to appear in output that need not be a -# report. +# "report:TEXT", which also requires TEXT to appear in the report, +# "report-once:TEXT", which also requires TEXT to appear on exactly one line of +# the report, or "message:TEXT", which requires TEXT to appear in output that +# need not be a report. check() { description="$1" expected_status="$2" @@ -175,6 +176,15 @@ check() { ;; esac case "$expected_output" in + report-once:*) + if [ "$(grep -c -e "${expected_output#*:}" "$work/output")" -ne 1 ]; then + echo "FAIL: $description: expected exactly one line of the output to" \ + "contain \"${expected_output#*:}\", but got:" + cat "$work/output" + status=1 + return + fi + ;; report:* | message:*) if ! grep -q -e "${expected_output#*:}" "$work/output"; then echo "FAIL: $description: expected the output to contain" \ @@ -222,6 +232,17 @@ check "make directory-change notices, exit 0" 0 silent "$work/make-noise" 0 check "make directory-change notices and real stderr, exit 0" 0 \ "report:^a real error$" "$work/make-noise-and-stderr" 0 +# Error output that does not end with a newline is shown once, on a line of +# its own, both when trace lines are filtered out of it and when they are not. +check "stderr without a final newline" 0 "report-once:^oops$" \ + sh -c 'printf oops >&2' +# shellcheck disable=SC2016 # The expansion is for the traced command to do. +PS4='+$X ' +export PS4 +check "stderr without a final newline, unfiltered" 0 "report-once:^oops$" \ + sh -c 'printf oops >&2' +unset PS4 + # Error output that `grep` would treat as binary is still reported. check "binary stderr" 0 "report:error$" "$work/binary-stderr" From 31821227f83a567d758fef73888669494dc91ef3 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 09:30:04 -0700 Subject: [PATCH 11/12] Brevity --- cronic | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/cronic b/cronic index 42a05d8..87526c4 100755 --- a/cronic +++ b/cronic @@ -17,11 +17,7 @@ # This script runs under the first `bash` in PATH, which on macOS may be # /bin/bash, bash 3.2. So it uses a feature of a later bash, such as # BASH_XTRACEFD (bash 4.1), only after checking the version; see the -# BASH_VERSINFO check below. Letting PATH choose bash exposes nothing new: -# PATH also chooses the wrapped command, which runs with the same privileges. -# Its uses of `sed`, `cmp`, `tail`, and `wc` are POSIX, as are its uses of -# `grep` apart from `-a`. `grep -a` and `mktemp -d` with a template are not -# POSIX, but macOS, the BSDs, and GNU support them. CI runs its tests on macOS, under /bin/bash. +# BASH_VERSINFO check below. # Exit if a command exits with a non-zero status, and treat unset variables # as an error. From b0e6a13344838d73f4b39c0187f4b5f6c6a70c48 Mon Sep 17 00:00:00 2001 From: Michael Ernst Date: Sun, 11 Oct 2026 09:30:47 -0700 Subject: [PATCH 12/12] Say what the cronic-macos CI job tests, rather than what cronic promises --- .github/workflows/tests.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index ad8b60e..75d6f5a 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -34,8 +34,8 @@ jobs: command -v xxd command -v bc - run: make -C tests test - # `cronic` promises to run under macOS's /bin/bash, which is bash 3.2, and - # with macOS's BSD utilities. `cronic` and the bash scripts in its tests run + # This job tests `cronic` under macOS's /bin/bash, which is bash 3.2, and + # macOS's BSD utilities. `cronic` and the bash scripts in its tests run # under the first `bash` in PATH, so /bin is put first, ahead of any # Homebrew bash. cronic-macos: