mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-03 10:37:21 +09:00
`.PHONY=a b c` and `.PHONY: a b c` differ in the `=` against the `:`, and only the second declares anything. The first defines an ordinary variable named `.PHONY` that nothing reads, leaving every listed target file-backed. A file or directory of that name beside a package Makefile then stands in as the product of the rule: for a target with no prerequisites make reports it up to date, skips the recipe and exits 0, and a target whose prerequisite is itself file-less keeps rebuilding until that prerequisite is shadowed too. The neighbouring `.DEFAULT_GOAL=help` is a real variable and is correct with `=`, so the two lines read alike while only one of them works, which is what let the inert form sit there unnoticed. Add hack/package-mk-phony.bats, six tests, picked up automatically by the hack/*.bats glob in `bats-unit-tests`. Three cover the declaration itself. The first reads the names off the `.PHONY:` line rather than repeating them, plants a file named after each beside a stub Makefile, and requires `make --dry-run` to print the recipe instead of "is up to date". A negative control mutates a copy back to the assignment form and requires the probe to report up-to-date, over the targets package.mk defines rather than the names it declares, because "is up to date" is only a meaningful answer for a name that has a rule. The third keeps those two sets equal: it requires the declared list and the defined targets to be the same set. Defined but undeclared is the direction the list rots in: check, clean, suspend and resume sat undeclared for as long as the declaration itself was inert. Declared but undefined is the empty-target trap in its general form, so a future name cannot slip in the way update and image once did. Two more pin what the list deliberately leaves out. package.mk defines neither `update` nor `image`; the including package Makefile does, and most packages define neither. Naming them here would create them as empty targets everywhere, turning "No rule to make target" into "Nothing to be done". Since the CI build matrix is parsed from the root Makefile `build:` target and runs `make -C <pkg> image` per unit, a package reaching that list without an `image:` rule would go from a red job to a green one that builds no image. One test requires that loud failure to survive, pinning both of the observables it is made of: make's exit status 2 and its diagnostic. They are independent, and a wrapper that kept the wording while returning something else would satisfy a check on the message alone. Its control extends the declaration with the two names in a copy and requires the failure to disappear, so the pin tracks the list rather than some property of the harness. Every decoy carries one fixed timestamp. Six of the nine targets take check as a prerequisite, and apply and delete reach it through suspend as well, so touching the decoys in list order can leave check newer than its own dependents. Make then rebuilds them for that reason and prints a recipe whatever .PHONY says, and the probe reports success against a declaration that does nothing. Equal mtimes remove the question in any list order. Measured on GNU Make 4.3: list order left the per-target assertion inert for up to six of the nine, moving with where the clock tick fell, while 3.81 fits the loop inside a single timestamp and shows none of it. The control test plants its decoys the same way for the same reason, and goes red if that is undone in its own loop. The two loops are independent, so neither guards the other, and the header is what keeps the arrangement from being unpicked. A sixth test leaves package.mk and covers the root Makefile, where this class had already bitten: `test` is the one target in the tree with a path of its own name beside it. It probes with --dry-run because the recipe wants a live cluster, and fails loudly if that path ever goes away rather than passing on a question with no subject. `%-update` is not coverable: .PHONY matches target names literally and does not expand patterns. Assisted-By: Claude <noreply@anthropic.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
331 lines
14 KiB
Bash
331 lines
14 KiB
Bash
#!/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: hack/cozytest.sh hack/package-mk-phony.bats
|
|
# -----------------------------------------------------------------------------
|
|
|
|
@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
|
|
}
|