Ctrl K
ComunidadGuías…

hack/package-mk-phony.bats

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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
}