hack/package-mk-phony.bats
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Unit tests for the .PHONY declarations this tree depends on: the one in
# hack/package.mk, which most package Makefiles under packages/ include, and the
# root Makefile's, where one target is already shadowed by a real path.
#
# `.PHONY=a b c` assigns a variable; `.PHONY: a b c` declares targets. Only the
# second does anything, and make reports neither. The declaration lists the nine
# targets package.mk defines and stops there.
#
# `update` and `image` are deliberately absent: package.mk does not define them,
# the including package Makefile does, and naming an undefined target in .PHONY
# creates it as an empty target. That turns "No rule to make target" (exit 2)
# into "Nothing to be done" (exit 0) wherever the rule is missing, including the
# CI build matrix, which is parsed from the root Makefile `build:` target and
# runs `make -C <pkg> image` per unit. The last two tests pin the exclusion.
#
# The first test and the control below deliberately read different lists — the
# first the names on the `.PHONY:` line, the control the targets the file
# defines — and merging them back into one is the tidy-up to resist. Which list
# each takes is argued where it is taken.
#
# The first test is narrower than its name suggests, in two ways. It probes the
# names on the `.PHONY:` line, so it cannot see a name dropped from that line;
# the set-equality test is what guards every name's presence. And it decides by
# matching make's "is up to date", which make prints only for a target that has
# a recipe. A recipe-less alias never draws it: with no prerequisite both forms
# print "Nothing to be done", and with a prerequisite that is itself out of
# date, which every target here is for being phony, the working form prints that
# prerequisite's recipe while the broken form prints "Nothing to be done". The
# probe separates neither pair, so it would pass on such a name either way.
# Every target package.mk defines has a recipe, so nothing is uncovered today.
# The control test below does not close the gap either: an alias is a defined
# target, so the control probes it and requires "is up to date" from it, which
# turns the control red against the probe rather than against the alias.
# Adding one means revisiting both.
#
# The decoy files all carry one fixed timestamp, and that is load-bearing rather
# than tidy. Six of the nine targets take `check` as a prerequisite, and `apply`
# and `delete` reach it through `suspend` as well. Touch the decoys in list order
# and `check` lands newer than the targets that depend on it, so make rebuilds
# them for that reason alone and prints a recipe whatever `.PHONY` says. The
# probe then reports success against the broken form. Equal mtimes remove the
# question: nothing is ever newer than its own prerequisite, in any list order,
# so the recipe running means phony and nothing else. Measured on GNU Make 4.3,
# which is what CI runs, list order hid the bug for up to six of the nine, and
# which six moved with where the clock tick fell. On 3.81 the whole loop landed
# inside one timestamp and hid nothing, so a local run could not see it.
#
# Constraints on anyone editing this file: every make invocation is pinned to
# LC_ALL=C, because the assertions match English make wording; cozytest.sh
# recognizes only @test blocks and a bare `}` at column 0, and injects `set -e`,
# so exit statuses are captured explicitly rather than tested inline.
#
# Run with: bats hack/package-mk-phony.bats
# -----------------------------------------------------------------------------
load test_helper
@test "every target hack/package.mk declares phony still runs when a file of that name exists" {
repo=$(pwd)
targets=$(sed -n 's/^\.PHONY:[[:space:]]*//p' "$repo/hack/package.mk")
# Without this guard the loop below is a vacuous pass against `.PHONY=`,
# which the sed no longer matches.
if [ -z "$targets" ]; then
echo "no '.PHONY:' declaration found in hack/package.mk" >&2
exit 1
fi
tmp=$(mktemp -d)
printf 'include %s/hack/package.mk\n' "$repo" > "$tmp/Makefile"
# One fixed timestamp for every decoy, so no target is ever newer than a
# target it depends on. See the header: list order silently disarms this
# probe for the targets that hang off `check`.
for t in $targets; do
touch -t 200101010000 "$tmp/$t"
done
for t in $targets; do
# cozytest.sh runs with `set -e`, so capture the exit status explicitly
# rather than letting a failing make abort before the diagnostics below.
rc=0
out=$(LC_ALL=C make --dry-run --directory "$tmp" "$t" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
echo "make $t exited $rc under --dry-run:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
fi
case "$out" in
*"is up to date"*)
echo "make $t no-oped with a file named $t present:" >&2
echo "$out" >&2
echo "hack/package.mk does not actually declare $t phony" >&2
rm -rf "$tmp"
exit 1
;;
esac
done
rm -rf "$tmp"
}
@test "the probe reports up-to-date when .PHONY is written as an assignment" {
# Negative control: proves the probe can see a file-backed target at all.
# The list is the targets package.mk defines, not the names on its `.PHONY:`
# line, because "is up to date" is only a meaningful answer for a name that
# has a rule. A declared name the file does not define prints "Nothing to be
# done" in both forms, so probing it here fails against the probe instead of
# against the defect; the set-equality test is what catches that name, and
# it carries the message that says so. The list is read from the original
# file: the copy is the mutated one.
repo=$(pwd)
targets=$(grep -E '^[A-Za-z0-9][A-Za-z0-9_-]*([[:space:]]+[A-Za-z0-9][A-Za-z0-9_-]*)*:' \
"$repo/hack/package.mk" \
| grep -vE '^[^:]*:+=' | sed 's/:.*//' \
| tr ' ' '\n' | grep -v '^$' | sort -u)
if [ -z "$targets" ]; then
echo "no targets extracted from hack/package.mk — extraction broke" >&2
exit 1
fi
tmp=$(mktemp -d)
sed 's/^\.PHONY:/.PHONY=/' "$repo/hack/package.mk" > "$tmp/package.mk"
if ! grep -q '^\.PHONY=' "$tmp/package.mk"; then
echo "mutation to the assignment form did not apply to the copy" >&2
rm -rf "$tmp"
exit 1
fi
printf 'include %s/package.mk\n' "$tmp" > "$tmp/Makefile"
# Same fixed timestamp as the first test, for the same reason.
for t in $targets; do
touch -t 200101010000 "$tmp/$t"
done
for t in $targets; do
rc=0
out=$(LC_ALL=C make --dry-run --directory "$tmp" "$t" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
echo "make $t exited $rc under --dry-run:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
fi
case "$out" in
*"is up to date"*) ;;
*)
echo "probe failed to detect a file-backed $t:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
;;
esac
done
rm -rf "$tmp"
}
@test "the .PHONY list and the targets package.mk defines are the same set" {
# The first test only probes names already on the list, so this is the sole
# guard on a name being dropped from it. The reverse direction catches a
# declared-but-undefined name, which make turns into an empty target.
# `%-update` is out of reach for both: .PHONY matches literally.
repo=$(pwd)
# Flattened because the `case` tests match on " name " and an embedded
# newline makes a name fail that check and get reported wrongly.
declared=$(sed -n 's/^\.PHONY:[[:space:]]*//p' "$repo/hack/package.mk" \
| tr '\n' ' ')
# A target name outside this pattern — leading underscore, dot, slash — is
# legal in make and skipped here in silence, so keep new names inside it.
# One rule may name several targets, so the pattern accepts a run of them
# and the `tr` splits the run apart again; anchoring the colon to a single
# name instead would drop every name on such a line, silently for a target
# nothing depends on. The second grep drops assignments by the `:=` itself
# rather than by whatever precedes it, `:+` covering `::=` and `:::=`, so
# `export FOO:=bar` goes too — matching the name in front instead would let
# that line through and report `export` and `FOO` as undeclared targets.
defined=$(grep -E '^[A-Za-z0-9][A-Za-z0-9_-]*([[:space:]]+[A-Za-z0-9][A-Za-z0-9_-]*)*:' \
"$repo/hack/package.mk" \
| grep -vE '^[^:]*:+=' | sed 's/:.*//' \
| tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')
if [ -z "$declared" ] || [ -z "$defined" ]; then
echo "could not extract targets from hack/package.mk — extraction broke" >&2
exit 1
fi
missing=""
for t in $defined; do
case " $declared " in
*" $t "*) ;;
*) missing="$missing $t" ;;
esac
done
if [ -n "$missing" ]; then
echo "hack/package.mk defines these targets but does not declare them phony:$missing" >&2
echo "a file of that name next to a package Makefile would silence the recipe" >&2
exit 1
fi
undefined=""
for t in $declared; do
case " $defined " in
*" $t "*) ;;
*) undefined="$undefined $t" ;;
esac
done
if [ -n "$undefined" ]; then
echo "hack/package.mk declares these targets phony but defines none of them:$undefined" >&2
echo "naming an undefined target in .PHONY creates it as an empty target, so the" >&2
echo "command reports success instead of failing with 'No rule to make target'" >&2
exit 1
fi
}
@test "update and image stay undeclared so a package without the rule fails loudly" {
# Pins the exclusion: a package with no `update:`/`image:` rule must still
# fail loudly. Declaring them centrally would make every such package report
# success having built nothing, CI included.
repo=$(pwd)
tmp=$(mktemp -d)
printf 'include %s/hack/package.mk\n' "$repo" > "$tmp/Makefile"
for t in update image; do
rc=0
out=$(LC_ALL=C make --directory "$tmp" "$t" 2>&1) || rc=$?
# Exit status and diagnostic are independent observables, so both are
# pinned. 2 is what make returns for a fatal error, and the number is
# what a caller branches on — a wrapper or a future make that kept the
# wording while returning something else would pass the case below.
if [ "$rc" -ne 2 ]; then
echo "expected 'make $t' to exit 2 in a package that defines no $t rule," >&2
echo "but it exited $rc — is $t named in the .PHONY list?" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
fi
case "$out" in
*"No rule to make target"*) ;;
*)
echo "expected 'make $t' to report no rule to make target, got:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
;;
esac
done
rm -rf "$tmp"
}
@test "adding update and image to the .PHONY list silences that failure" {
# Negative control. Test 4 passes vacuously if its Makefile never reads
# package.mk at all: a broken `include` path also exits non-zero with "No
# rule to make target", naming the missing file. This mutates a copy and
# greps that the mutation landed, so a wrong path fails here instead.
repo=$(pwd)
tmp=$(mktemp -d)
sed 's/^\(\.PHONY:.*\)$/\1 update image/' "$repo/hack/package.mk" > "$tmp/package.mk"
if ! grep -q '^\.PHONY:.* update image$' "$tmp/package.mk"; then
echo "mutation extending the declaration did not apply to the copy" >&2
rm -rf "$tmp"
exit 1
fi
printf 'include %s/package.mk\n' "$tmp" > "$tmp/Makefile"
for t in update image; do
rc=0
out=$(LC_ALL=C make --directory "$tmp" "$t" 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
echo "mutation did not take effect: 'make $t' still failed with exit $rc" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
fi
case "$out" in
*"Nothing to be done"*) ;;
*)
echo "expected the mutated copy to report nothing to be done for $t, got:" >&2
echo "$out" >&2
rm -rf "$tmp"
exit 1
;;
esac
done
rm -rf "$tmp"
}
@test "the root Makefile declares test phony so the test directory cannot shadow it" {
# The one target in this tree that a real path already shadows. `test:` in
# the root Makefile produces no file, so make treats the existing test/
# directory as its product: undeclared, `make test` prints "is up to date"
# and the recipe never runs, while docs/agents/overview.md advertises the
# command as the way to run the full suite. Every other undeclared target
# in that file is unshadowed today, so this is the only live one.
#
# Probed with --dry-run because the recipe wants a live cluster. The path is
# what makes the probe mean anything, so its absence fails loudly rather
# than passing quietly.
repo=$(pwd)
if [ ! -e "$repo/test" ]; then
echo "no test/ path at the repo root: this probe no longer measures anything" >&2
exit 1
fi
rc=0
out=$(LC_ALL=C make --dry-run --directory "$repo" test 2>&1) || rc=$?
if [ "$rc" -ne 0 ]; then
echo "make test exited $rc under --dry-run:" >&2
echo "$out" >&2
exit 1
fi
case "$out" in
*"is up to date"*)
echo "make test no-oped with the test/ path present:" >&2
echo "$out" >&2
echo "the root Makefile does not declare test phony" >&2
exit 1
;;
esac
}