Skip to content

Close Makefile masks and limit PyPI credentials in the release tests - #650

Merged
MaxGhenis merged 11 commits into
masterfrom
claude/core-pr-649-followups-c3va8g
Nov 5, 2026
Merged

MaxGhenis merged 11 commits into
masterfrom
claude/core-pr-649-followups-c3va8g

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Follow-ups from the independent review of #649 (landed as d9da48a1c7): #649 (comment), items P3 1 to P3 4. The PR changes only tests/core/test_release_tagging.py and adds changelog.d/core649-release-test-followups.changed.md. Nothing under policyengine_core/ or .github/ changes.

impact: tests only; no change to the package or to any workflow.

What's changed

  • Makefile masks (P3 1; this PR's review, P3 1). assert_make_test_fails_when_tests_fail takes the Makefile path. It fails when MAKE_SETTING_THAT_MASKS matches anywhere in the file: any mention of .IGNORE, .ONESHELL, MAKEFLAGS (so GNUMAKEFLAGS, override MAKEFLAGS and $(eval MAKEFLAGS …) too) or PYTEST_ADDOPTS, or an assignment to SHELL or .SHELLFLAGS, including target-specific ones. It then walks test's prerequisites at every depth, including order-only ones. Each target it reaches must have a single rule line, single-colon, starting in the first column and with no ; recipe, because make_rules misreads every other form. make_rules skips comment-only lines inside a recipe, as make does. It fails, naming the syntax, on a conditional, a define block, .RECIPEPREFIX, a line that ends in \ or a line break other than a newline, since recipe lines after them could run unread. The check fails on any recipe line it reaches that starts with -, contains ||, or ends in ; true, ; exit 0 or ; :. assert_test_job_fails_when_tests_fail also fails when the env of the workflow, the Test job or the test step sets MAKEFLAGS. Master passes: its Makefile mentions none of these settings, each target has one plain rule line, and coverage xml -i matches none of the recipe forms.
  • Makefile upload scan (this PR's review, P3 2). test_only_push_publish_job_uploads_to_pypi applies the same one-rule-line check to every target on a rule line in the root Makefile. So a build:: rule, a later build: install line, or build: ; twine upload dist/* can no longer hide an upload from pypi_uploading_recipe_lines.
  • PyPI credentials (P3 2). test_only_publish_job_gets_pypi_credentials reads every workflow file. It fails if any job other than push.yaml's Publish declares environment: pypi or gets id-token: write, or if Publish lacks either. It reads the environment as a name or as a mapping's name, ignoring case, and a job's own permissions, or the workflow's when the job sets none, counting write-all. On master no other job declares id-token: write (Docs has contents: write only), so the test checks both declarations.
  • Scan comment (P3 3). The PR replaces the comment on test_only_push_publish_job_uploads_to_pypi with the review's text, adds a pointer to the new credentials check, and leaves the regex as it was.
  • Locator message (P3 4). When the locator finds no test step, the failure now reads: expected one test (a step whose run starts with the words `make test`; if the command moved, update this locator and the exact match below) step, found [].

Each new check has in-suite mutant tests.

What the checks still can't see

  • An upload inside a script or program that a workflow step or recipe calls, for example bash release.sh or python upload.py.
  • An upload command in another form, such as uv --verbose publish (an option between the tool and publish), $TOOL publish, a curl to the upload API or a publisher not in the regex. The scan also misses the publish action with a mixed-case owner (PyPA/gh-action-pypi-publish).
  • An upload in a remote action that a step uses (uses: owner/action@ref), other than pypa/gh-action-pypi-publish, when its job declares neither environment: pypi nor id-token: write. The scan also skips a local action outside .github/actions/ (uses: ./some/dir). Under .github/actions/, it reads only the runs.steps of composite actions, so it skips Docker and JavaScript actions (runs.using: docker or node*).
  • The credentials check rests on PyPI's trusted-publisher configuration, which these tests can't read. It doesn't cover an upload that uses a stored API token, nor an environment name that a workflow gives as an expression (${{ … }}).
  • Makefiles other than the root Makefile: a root GNUmakefile or makefile, which make reads instead; a makefile named in MAKEFILES in a workflow env, which make reads first; and included makefiles (include, -include, sinclude).
  • A target that a recipe runs through $(MAKE).
  • A name that make builds from parts: a rule line whose target is a variable ($(T):, or .$(I): with I = IGNORE), or an assignment whose name is one ($(S) = /bin/true with S = SHELL).
  • -i that a workflow env sets in GNUMAKEFLAGS, or that an earlier step writes to $GITHUB_ENV in MAKEFLAGS or GNUMAKEFLAGS. The checks catch a make -i test call in the test step itself, because they require the step's command to equal make test.
  • A recipe line that masks a failure in another form, for example if cmd; then :; fi, cmd | tee log, or cmd; true # note. The recipe parser targets this repository's Makefile.
  • The Makefile checks fail closed, so they also fail on text that masks nothing. Master has none of these forms:
    • a recipe comment such as # a || b, or echo 'a || b';
    • a line that only reads $(MAKEFLAGS), or a comment that mentions .IGNORE or PYTEST_ADDOPTS;
    • a second .PHONY: line;
    • a line that ends in \, a conditional (ifeq … endif), a define block or .RECIPEPREFIX, or a recipe line that opens with one of those directive words, such as else;
    • a form feed, vertical tab, lone carriage return or other line break that Python splits at and make doesn't, anywhere in the file, even where make reads it as plain whitespace;
    • a recipe line that sets SHELL for one command, such as SHELL=bash ./run;
    • a prerequisite of test that names a file, a variable or a pattern-rule target with no rule line of its own.
  • Pytest options set elsewhere. An earlier step can write PYTEST_ADDOPTS to $GITHUB_ENV, or the Makefile can build that name from parts (export $(P)OPTS with P = PYTEST_ADD). Options can also come from addopts in pytest.toml, .pytest.toml or pytest.ini, from conftest.py, or from a different variable. If someone deletes pytest.ini, addopts in .pytest.ini, pyproject.toml, tox.ini or setup.cfg takes effect too.
  • Workflow files in subdirectories of .github/workflows/, which GitHub doesn't run (the Guard the release tests against masked test failures and other PyPI uploads #648 review quotes its docs).

Tests

One file per process, uv run pytest -q -p no:cacheprovider <file>:

test_release_tagging.py test_docs_deployment.py
master eaef9c2 42 passed 5 passed
this PR 102 passed 5 passed

ruff format --check and ruff check pass on the changed test file. Comments on this PR give the mutant results: R7–R16 from #649's review, A1–A13 for the first round of checks, and N1–N15 from this PR's review. X1–X13 cover the further Makefile forms, and C1–C6 the forms the list named before the round 2 review (C4, cmd; \ with true on the next line, now fails). From the round 2 review, F1–F11, F9b, G1–G4 and H1–H4 cover forms it found, Q1–Q7 its probes, W1–W9 the list's workflow entries and L1–L6 the fail-closed entries. From the round 3 review, T1–T6 cover a conditional or define after a space and a tab, after a form feed, or after a tab outside a rule, and PA1–PA2 an export PYTEST_ADDOPTS = --collect-only line, global and target-specific. From the round 4 review, B1–B5 cover a || true or an upload after a form feed, \u2028, \x1c or a lone carriage return inside a recipe line.

🤖 Generated with Claude Code

The independent review of #649 confirmed that .IGNORE, MAKEFLAGS (in the
workflow env or the Makefile) and a `|| true`-style recipe line each let
make test pass after a failure without any release test noticing, and that
remote actions in other jobs could upload unseen. The make checks now reject
those forms, and a new check keeps the pypi environment and id-token: write
on push.yaml's Publish job alone, which covers uploads the text scan cannot
read when they rely on trusted publishing. The scan comment and the test
step locator's failure message now say what they cover and what to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6JEB5vwPLRB2PWBmRa
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
3acba61

Follow-ups from the #649 review (#649 (comment)), P3 1 to P3 4. Base: master eaef9c2 (the version-bump commit after d9da48a1c7; tests/core/test_release_tagging.py, Makefile and .github/workflows/ are unchanged between them). Files: tests/core/test_release_tagging.py and changelog.d/core649-release-test-followups.changed.md. No changes under policyengine_core/, .github/ or uv.lock.

Items

  1. Makefile masks (P3 1). Done.
    • .IGNORE with or without prerequisites: tests/core/test_release_tagging.py:711. MAKE_RULE already parses .IGNORE: and .IGNORE: test as rules, so ".IGNORE" in rules covers both.
    • Masking recipe lines, checked on test and its rule-line prerequisites: MASKED_RECIPE_LINE :702 (\|\||;\s*(true|exit\s+0|:)\s*$, the review's regex), applied at :721-723.
    • MAKEFLAGS in the Makefile: MAKEFLAGS_ASSIGNMENT :699 (^\s*(export\s+)?MAKEFLAGS\b, multiline), applied at :712-714.
    • MAKEFLAGS in the env of the workflow, Test job or test step: :690-695.
    • In-suite mutant tests: ignore_make_errors_in_* (:781-791, run at :808-810), test_make_test_check_rejects_ignore_special_target :876, test_make_test_check_rejects_makeflags_in_makefile :887, test_make_test_check_rejects_masked_recipe_line :905.
    • Master passes: coverage xml -i matches none of these patterns.
  2. PyPI credentials (P3 2). Done. pypi_environment :457, grants_id_token :465, pypi_credential_jobs :471, test_only_publish_job_gets_pypi_credentials :499. In-suite tests: test_pypi_credential_check_finds_another_job :581 (5 cases) and test_pypi_credential_check_applies_workflow_permissions :592.
    • Jobs on master that declare id-token: write: only push.yaml Publish. No workflow sets top-level permissions. Docs has {contents: write} and lint-workflows has {contents: read}. So the test checks both declarations, not the environment alone.
    • The body's limits say that the check rests on PyPI's trusted-publisher setup, which the tests can't read, and that it doesn't cover a stored-token upload.
  3. Scan comment (P3 3). Done, :440-446. It uses the review's text, split into two sentences, and adds a pointer to the new credentials test. The regex is unchanged.
  4. Locator message (P3 4). Done, :660-661. Output from mutants A12 and A13 (run: make -C . test and run: uv run make test), the same for both:
    AssertionError: expected one test (a step whose run starts with the words `make test`; if the command moved, update this locator and the exact match below) step, found []
    
    In-suite test: test_test_job_check_explains_missing_test_step :856.

Tests (one file per process, uv run pytest -q -p no:cacheprovider <file>)

test_release_tagging.py test_docs_deployment.py
master eaef9c2, before 42 passed 5 passed
head 3acba61, after 65 passed 5 passed

ruff format --check and ruff check pass on the test file.

Mutants

A script applied each mutant to the working tree. Workflow mutants were YAML round-trips, Makefile mutants were text edits, and R7/R11/A-new used a new workflow release-extra.yaml. For each mutant I ran the head file, test_docs_deployment.py and master's test_release_tagging.py in separate pytest processes, then ran git checkout -- .github Makefile tests/core/test_release_tagging.py && git clean -fdq .github and asserted that git status --porcelain was empty for those paths. The tree was clean at the end.

Mutant Head Killed by (head) docs Master test file
unmodified 65 passed — 5 passed 42 passed
R7 PyO3/maturin-action@v1 command: upload, new workflow, no creds 65 passed none (expected: no trusted-publisher token) 6 passed 42 passed
R7 + environment: pypi + id-token: write 7 failed, 58 passed test_only_publish_job_gets_pypi_credentials (+ 6 in-suite credential tests that copy the mutated dir) 6 passed 42 passed
R8 .IGNORE: test 14 failed, 51 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite Makefile tests that read the mutated file) 5 passed 42 passed
R9 MAKEFLAGS: -i on Test job env 2 failed, 63 passed test_test_job_cannot_pass_with_failing_tests, …accepts_separate_template_test_step 5 passed 42 passed
R10 make test || true 16 failed, 49 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 11 failed, 31 passed
R11 some-org/publish-action@v1, no creds 65 passed none (expected) 6 passed 42 passed
R11 + environment: pypi + id-token: write 7 failed, 58 passed test_only_publish_job_gets_pypi_credentials (+ in-suite) 6 passed 42 passed
R12 || true on make test's pytest line 3 failed, 62 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 42 passed
R13 bare .IGNORE: 14 failed, 51 passed same as R8 5 passed 42 passed
R14 MAKEFLAGS += -i 11 failed, 54 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 42 passed
R15 PyPA/gh-action-pypi-publish step in pr.yaml Test 65 passed none (out of scope; listed in body) 5 passed 42 passed
R16 uv --verbose publish step in pr.yaml Test 65 passed none (out of scope; listed in body) 5 passed 42 passed
A1 MAKEFLAGS: -i on test step env 2 failed, 63 passed test_test_job_cannot_pass_with_failing_tests (+1) 5 passed 42 passed
A2 MAKEFLAGS: -i on push.yaml workflow env 2 failed, 63 passed same 5 passed 42 passed
A3 export MAKEFLAGS = -i 11 failed, 54 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 42 passed
A4 ; true on pytest line 3 failed, 62 passed same 5 passed 42 passed
A5 ; exit 0 on coverage xml -i 3 failed, 62 passed same 5 passed 42 passed
A6 ; : on pytest line 3 failed, 62 passed same 5 passed 42 passed
A7 || true on test-country-template recipe 1 failed, 64 passed test_test_job_cannot_pass_with_failing_tests 5 passed 42 passed
A8 environment: pypi on pr.yaml Test 4 failed, 61 passed test_only_publish_job_gets_pypi_credentials (+ in-suite) 5 passed 42 passed
A9 permissions: {id-token: write} on pr.yaml Test 4 failed, 61 passed same 5 passed 42 passed
A10 workflow-level permissions: write-all on pr.yaml 7 failed, 58 passed same 5 passed 42 passed
A11 Publish drops environment: pypi 7 failed, 58 passed same 5 passed 42 passed
A12 test step run: make -C . test 16 failed, 49 passed test_test_job_cannot_pass_with_failing_tests (message above) 5 passed 11 failed, 31 passed
A13 test step run: uv run make test 16 failed, 49 passed same 5 passed 11 failed, 31 passed

So R8, R9, R12, R13 and R14 now fail. R7 and R11 fail once their jobs add the environment and the permission.

GNU Make 4.3 probe (make -s test with a recipe line false, exit codes): plain 2; .IGNORE: test 0; .IGNORE: 0; env MAKEFLAGS=-i 0; MAKEFLAGS += -i 0; export MAKEFLAGS = -i 0; || true, || exit 0, ; true, ; exit 0, ; : 0 each; || true on a prerequisite's recipe 0; env GNUMAKEFLAGS=-i 0; false \ + || true 0 (the parser catches it); false; \ + true 0 (the parser misses it). Hand computation: coverage xml -i contains no || and doesn't end in ; true|exit 0|:, and no Makefile line starts with MAKEFLAGS or .IGNORE.

CI

The PR is a draft. Pull request run 41736909058 at 3acba61 was in_progress when I posted this: https://github.com/PolicyEngine/policyengine-core/actions/runs/41736909058. I haven't checked its result.

Judgment calls

  • Branch. This session can push only to claude/core-pr-649-followups-c3va8g, so I used that branch instead of claude/core649-release-test-followups (Extend the release tests' PyPI scan and Test-job guard #649 had the same situation). Before I pushed, neither branch existed (404), and no open PR mentioned test_release_tagging. Base is master, not main.
  • Where the Makefile MAKEFLAGS check lives. The brief put it in assert_test_job_fails_when_tests_fail. I put it in assert_make_test_fails_when_tests_fail, which now takes the Makefile path instead of parsed rules, so each function reads one file. Both run in test_test_job_cannot_pass_with_failing_tests, so coverage is the same.
  • The credentials check is broader than "declares". It also counts permissions: write-all and workflow-level permissions that a job without its own inherits (A10), plus an environment mapping's name compared ignoring case. These are stricter. The check does not claim GitHub's or PyPI's semantics for them, because docs.github.com, docs.pypi.org, gnu.org and web.archive.org were unreachable from this session (403 or DNS failure).
  • The MAKEFLAGS regex matches leading whitespace, so a tab-indented recipe line that starts MAKEFLAGS=… would also fail. That is a loud false positive and none exists on master.
  • Locator text. I wrote "starts with the words make test" instead of the brief's "starts with make test", because the locator compares split words, and make test-country-template starts with that text but isn't matched.
  • No axiom: line. The item doesn't name one.

Follow-ups I'd propose

  • Reject GNUMAKEFLAGS alongside MAKEFLAGS in the release tests. GNU Make 4.3 ignores the failure with env GNUMAKEFLAGS=-i (exit 0 above). It's the same check with a second name.
  • Join \-continued recipe lines before scanning. make_rules treats each physical line separately, so cmd; \ + true slips past the masking check. Joining continuations in the parser would close that gap.

Generated by Claude Code

@MaxGhenis
MaxGhenis marked this pull request as ready for review November 3, 2026 23:17
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Hub review in progress

Head 3acba61ddd3d9263c9f1f964b4d16f4e893edec0 (unchanged since the hand-off). Findings so far:

  • Tests: 65 / 5 passed at head and 42 / 5 at master, which matches the hand-off. Pull request CI run 41736909058 succeeded on all 19 jobs.
  • I reran all of R7–R16 and A1–A13, and every count matches the hand-off.
  • The Makefile check misses .IGNORE: with leading spaces (N1), override MAKEFLAGS += -i (N2), $(eval MAKEFLAGS += -i), .ONESHELL:, a masked line two prerequisites down (N5), a repeated test: rule line and a double-colon prerequisite rule. GNU Make 4.3 exits 0 with each of these. The fragment's wording ("a .IGNORE rule", "a line that sets or exports MAKEFLAGS") claims N1 and N2.
  • N3 (mapping environment) and N4 (write-all on Docs) are both caught.

The full review follows.


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
VERDICT: REQUEST_CHANGES
3acba61
METHODOLOGY_CHOICE: no

I reviewed head 3acba61ddd3d9263c9f1f964b4d16f4e893edec0. It is still the PR head, so nothing changed after the hand-off. The base is master eaef9c24858369af85093bfb5de729b48208be81.

The PR does the four items. Every hand-off mutant I reran (R7–R16, A1–A13) gives the hand-off's count, and CI is green. I found no P1.

There are two P2s, both about claims:

  • The changelog fragment names forms that the new checks miss: an indented .IGNORE: and override MAKEFLAGS += -i. With each of them, GNU Make 4.3 exits 0 on a failing recipe and the release tests pass.
  • The body's "can't see" list leaves out those two, plus five other Makefile masks I confirmed.

A small code change, which I ran and give below, closes most of them. The other option is to narrow the fragment and the list. None of these gaps exists in the repository today.

Assignment. The PR does everything the build brief asked for, apart from the gaps above. The branch is claude/core-pr-649-followups-c3va8g, not claude/core649-release-test-followups, and the hand-off says so. pulls/650/files lists only changelog.d/core649-release-test-followups.changed.md and tests/core/test_release_tagging.py. git diff --quiet eaef9c2 3acba61 -- uv.lock policyengine_core .github exits 0.

P2

1. The fragment says the tests fail on "a .IGNORE rule" and on "a line that sets or exports MAKEFLAGS". Two such lines pass (changelog.d/core649-release-test-followups.changed.md:1; the code is at tests/core/test_release_tagging.py:351, :699 and :711).

Failure scenario: someone adds .IGNORE: (indented with spaces) or override MAKEFLAGS += -i to the Makefile. make test then exits 0 when pytest fails, and the release tests stay green.

  • .IGNORE is checked through make_rules. Its MAKE_RULE (^([^\s:=#]…) rejects a line that starts with whitespace, so an indented .IGNORE: never becomes a rule.
  • MAKEFLAGS_ASSIGNMENT (^\s*(export\s+)?MAKEFLAGS\b) has no override form.

The brief asked the fragment to "name exactly the forms the new checks catch, and no more".

Commands and output:

  • GNU Make 4.3, recipe false, make -s test: plain gives exit=2, .IGNORE: gives exit=0, and override MAKEFLAGS += -i gives exit=0.
  • Mutants on the working tree, python -m pytest -q -p no:cacheprovider tests/core/test_release_tagging.py: N1 ( .IGNORE: on the first line of Makefile) gives 65 passed, and N2 (override MAKEFLAGS += -i) gives 65 passed.

Fix: either apply P3 1's code, which closes both, or narrow the fragment. Replacement for the fragment's first clause:

The release tests now also fail if the root Makefile has a .IGNORE rule line that starts in the first column (with or without prerequisites) or a line that starts with MAKEFLAGS or export MAKEFLAGS, …

The rest of the fragment is accurate. I checked each remaining clause against the code and against mutants A1–A13, N3 and N4.

2. The body's "What the checks still can't see" list leaves out Makefile masks I confirmed (PR body, fifth bullet).

Failure scenario: a maintainer reads the list as complete and relies on the guard to catch any of the forms below.

Each row was confirmed two ways. GNU Make 4.3 exits 0 on a failing recipe, from a small probe Makefile. And the head test file passes against the repository's Makefile with the mutation applied:

Form GNU Make 4.3 Head test file
N1 .IGNORE: (leading spaces) exit 0 65 passed
N2 override MAKEFLAGS += -i exit 0 65 passed
$(eval MAKEFLAGS += -i) exit 0 65 passed
.ONESHELL:: only the recipe's last line (coverage xml -i) sets the status exit 0 65 passed
SHELL := /bin/true, .SHELLFLAGS := -c true exit 0 the check passes (scratch call)
N5 || true on a recipe line of a prerequisite's prerequisite exit 0 65 passed
$(MAKE) -i other in a recipe exit 0 the check passes (scratch call)
N8 || true on test's pytest line, plus a later test: format line the recipe still runs (a split-rule probe gives exit 2 with false) test_test_job_cannot_pass_with_failing_tests passes. make_rules replaces test's entry with the later, empty recipe. Only two in-suite tests fail, incidentally, because their own mutation lands on the overwritten rule
N9 test-country-template:: (double colon) with || true exit 0 the guard passes. MAKE_RULE rejects ::, so the recipe is dropped. Two in-suite tests fail incidentally
…; true # comment exit 0 the check passes (scratch call)

The list's "a recipe that masks a failure in another form" covers the last row, and arguably the | tee form, which also passes. The other rows aren't recipe forms.

Replacement for that bullet (keep line continuations):

  • Makefiles other than the root one, included makefiles, and line continuations: the checks catch a continued line that begins || true, but not cmd; \ followed by true. Makefile settings the checks don't read: an indented .IGNORE:, override MAKEFLAGS or $(eval MAKEFLAGS …), .ONESHELL, and SHELL or .SHELLFLAGS. Recipe lines the checks don't read: a prerequisite's prerequisite, a target that a recipe runs through $(MAKE), a double-colon rule, and a target whose recipe a later rule line for the same target hides from the parser. Also -i passed through GNUMAKEFLAGS, or through MAKEFLAGS written to $GITHUB_ENV by an earlier step, and a recipe line that masks a failure in another form (for example if cmd; then :; fi, cmd | tee log, or cmd; true # note). The recipe parser targets this repository's Makefile. The checks catch a make -i test call in the test step itself, because the step must run exactly make test.

If P3 1's code lands, drop the forms it closes from this bullet.

P3

1. A small change closes most of P2's Makefile forms (tests/core/test_release_tagging.py:697-723). I ran this in a scratch module against master's Makefile and against each form in P2 2.

  • It passes on master.
  • It fails on N1, .IGNORE: # c, .IGNORE:test, N2, $(eval …), .ONESHELL, SHELL :=, N5, N8 and N9.
  • It still passes $(MAKE) -i other, ; true # c, | tee and cmd; \ + true.
# A setting that lets make test pass after a recipe line fails: .IGNORE or
# .ONESHELL (only the last line's status counts), any mention of MAKEFLAGS
# (=, +=, override, export, $(eval …)), or a SHELL or .SHELLFLAGS of its own.
MAKE_SETTING_THAT_MASKS = re.compile(
    r"^ *(\.IGNORE|\.ONESHELL)\s*:(?!=)|\bMAKEFLAGS\b"
    r"|^ *(override\s+|export\s+)*(SHELL|\.SHELLFLAGS)\s*[:+?!]?=",
    re.MULTILINE,
)


def assert_make_test_fails_when_tests_fail(makefile: Path) -> None:
    text = makefile.read_text()
    rules = make_rules(makefile)
    assert not MAKE_SETTING_THAT_MASKS.search(text), (
        "the Makefile can ignore recipe failures"
    )
    # Walk test's prerequisites at every depth.
    checked, queue = [], ["test"]
    while queue:
        target = queue.pop()
        if target in checked:
            continue
        checked.append(target)
        prerequisites, recipe = rules.get(target, ([], []))
        queue += prerequisites
        # make_rules keeps only a target's last rule line and skips
        # double-colon rules, so require exactly one single-colon rule line.
        colons = re.findall(rf"^{re.escape(target)}\s*(::?)(?!=)", text, re.M)
        assert colons == [":"], f"make {target} needs exactly one single-colon rule line"
        for line in recipe:
            prefix = line[: len(line) - len(line.lstrip("@+- \t"))]
            assert "-" not in prefix, f"make {target} ignores a failing recipe line"
            assert not MASKED_RECIPE_LINE.search(line), (
                f"make {target} masks a failing command: {line}"
            )

Notes on this code:

  • The in-suite tests that match the Makefile sets MAKEFLAGS or ignores recipe failures \(\.IGNORE\) would need the new message. Add N1, N2 and a two-level prerequisite as parameters.
  • \bMAKEFLAGS\b also fails on a line that only reads it, such as $(MAKEFLAGS). That is a loud false positive, and none exists on master (grep -c MAKEFLAGS Makefile gives 0).
  • This is a recommendation. If the hub prefers to narrow the claims (P2 1 and P2 2), that also resolves the P2s.

2. The same parser gap hides an upload from the Makefile upload scan. This is not new in this PR. pypi_uploading_recipe_lines uses make_rules, which:

  • drops a double-colon rule's recipe;
  • replaces a target's recipe when the target appears again on a later rule line.

Mutants:

  • N11: build:: plus \ttwine upload dist/*.
  • N12: build: with \ttwine upload dist/*, plus a later build: install.

For each, test_only_push_publish_job_uploads_to_pypi passes. Only test_pypi_upload_check_finds_upload_in_makefile_recipe fails (1 failed, 64 passed), and only because its own edit of build: lands on the overwritten rule. The rule-line check in P3 1, applied to every target in make_rules, or a parser that merges repeated rule lines, would close it. Suggest it as a follow-up.

3. The masked-line regex fails on text that masks nothing. That is loud, and I'd keep it.

  • N6 (\t# a || b in test's recipe): GNU Make passes the line to the shell as a comment (probe: the next line's false still gives exit 2). Yet the check fails with make test masks a failing command: # a || b.
  • So does echo 'a || b'.
  • scp coverage.xml host:, a : as the last word with no ;, isn't flagged, which is correct.
  • MAKEFLAGS ?= -i and a bare export MAKEFLAGS are flagged, although make 4.3 ignores both (exit 2).
  • On master's coverage xml -i, nothing is flagged: the unmodified run passes.

Each of these fails loudly, and none occurs on master.

Mutants

Each mutant was a text edit on the working tree, made by a script. For each one, the script ran three pytest processes: head's test_release_tagging.py, test_docs_deployment.py, and master's test_release_tagging.py copied in place. After each mutant it ran git checkout -q -- .github Makefile tests && git clean -fdq .github and asserted that git status --porcelain was empty. It was empty every time, and at the end. "Killed by" names the guard test that fails, with in-suite tests that fail because they read the mutated file in parentheses.

Mutant Head Killed by (head) docs Master test file
unmodified 65 passed — 5 passed 42 passed
R7 PyO3/maturin-action@v1 upload, new workflow, no credentials 65 passed none (no trusted-publisher token) 6 passed 42 passed
R7 + environment: pypi + id-token: write 7 failed, 58 passed test_only_publish_job_gets_pypi_credentials (+ in-suite credential tests) 6 passed 42 passed
R8 .IGNORE: test 14 failed, 51 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 42 passed
R9 MAKEFLAGS: -i in the Test job's env 2 failed, 63 passed test_test_job_cannot_pass_with_failing_tests, …accepts_separate_template_test_step 5 passed 42 passed
R10 make test || true 16 failed, 49 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 11 failed, 31 passed
R11 some-org/publish-action@v1, no credentials 65 passed none 6 passed 42 passed
R11 + credentials 7 failed, 58 passed test_only_publish_job_gets_pypi_credentials (+ in-suite) 6 passed 42 passed
R12 || true on the pytest line 3 failed, 62 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 42 passed
R13 bare .IGNORE: 14 failed, 51 passed as R8 5 passed 42 passed
R14 MAKEFLAGS += -i 11 failed, 54 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 42 passed
R15 PyPA/gh-action-pypi-publish in pr.yaml Test 65 passed none (listed in the body) 5 passed 42 passed
R16 uv --verbose publish in pr.yaml Test 65 passed none (listed in the body) 5 passed 42 passed
A1 MAKEFLAGS in the test step's env 2 failed, 63 passed as R9 5 passed 42 passed
A2 MAKEFLAGS in push.yaml's workflow env 2 failed, 63 passed as R9 5 passed 42 passed
A3 export MAKEFLAGS = -i 11 failed, 54 passed as R14 5 passed 42 passed
A4 ; true on the pytest line 3 failed, 62 passed as R12 5 passed 42 passed
A5 ; exit 0 on coverage xml -i 3 failed, 62 passed as R12 5 passed 42 passed
A6 ; : on the pytest line 3 failed, 62 passed as R12 5 passed 42 passed
A7 || true on test-country-template 1 failed, 64 passed test_test_job_cannot_pass_with_failing_tests 5 passed 42 passed
A8 environment: pypi on pr.yaml Test 4 failed, 61 passed test_only_publish_job_gets_pypi_credentials (+ in-suite) 5 passed 42 passed
A9 id-token: write on pr.yaml Test 4 failed, 61 passed same 5 passed 42 passed
A10 workflow permissions: write-all on pr.yaml 7 failed, 58 passed same 5 passed 42 passed
A11 Publish without environment: pypi 7 failed, 58 passed same 5 passed 42 passed
A12 run: make -C . test 16 failed, 49 passed test_test_job_cannot_pass_with_failing_tests (+ in-suite) 5 passed 11 failed, 31 passed
A13 run: uv run make test 16 failed, 49 passed same 5 passed 11 failed, 31 passed
N1 .IGNORE: (two leading spaces) 65 passed none (P2 1) 5 passed 42 passed
N2 override MAKEFLAGS += -i 65 passed none (P2 1) 5 passed 42 passed
N3 environment: {name: pypi} on pr.yaml Test 4 failed, 61 passed test_only_publish_job_gets_pypi_credentials (+ in-suite) 5 passed 42 passed
N4 permissions: write-all on Docs 7 failed, 58 passed test_only_publish_job_gets_pypi_credentials (+ in-suite) 1 failed, 4 passed 42 passed
N5 || true two levels down (test-country-template: deeper) 65 passed none (P2 2) 5 passed 42 passed
N6 recipe comment # a || b in test (false-positive probe) 3 failed, 62 passed test_test_job_cannot_pass_with_failing_tests: a false positive (P3 3) 5 passed 42 passed
N7 .ONESHELL: 65 passed none (P2 2) 5 passed 42 passed
N8 || true on the pytest line + a later test: format 10 failed, 55 passed not the guard; in-suite tests only, incidentally (P2 2) 5 passed 4 failed, 38 passed
N9 test-country-template:: + || true 2 failed, 63 passed not the guard; in-suite tests only, incidentally (P2 2) 5 passed 1 failed, 41 passed
N10 $(eval MAKEFLAGS += -i) 65 passed none (P2 2) 5 passed 42 passed
N11 build:: + twine upload 1 failed, 64 passed not the scan; incidental in-suite test only (P3 2) 5 passed 1 failed, 41 passed
N12 build: + twine upload, later build: install 1 failed, 64 passed as N11 5 passed 1 failed, 41 passed
N13 local reusable-workflow call with id-token: write in pr.yaml 7 failed, 58 passed test_only_publish_job_gets_pypi_credentials 6 passed 42 passed
N14 called workflow's job declares environment: pypi 7 failed, 58 passed same 6 passed 42 passed
N15 two make test steps in push.yaml Test 15 failed, 50 passed test_test_job_cannot_pass_with_failing_tests 5 passed 10 failed, 32 passed

Locator messages (item 4), taken from the pytest output:

  • A12 and A13 give AssertionError: expected one test (a step whose run starts with the words `make test`; if the command moved, update this locator and the exact match below) step, found [].
  • N15 gives the same text ending found [4, 5]. "If the command moved" is slightly off for a duplicated step, but the indices point to the fix.

Checked and sound

  • Tests, one file per process (python -m pytest -q -p no:cacheprovider <file>):

    test_release_tagging.py test_docs_deployment.py
    master eaef9c2 42 passed 5 passed
    head 3acba61 65 passed 5 passed

    ruff format --check gives "1 file already formatted" and ruff check gives "All checks passed!" (ruff 0.17.3). The environment is a uv-created .venv (uv was already installed) with uv pip install -e ".[dev]".

  • Item 1 as built.

    • .IGNORE with and without prerequisites, and with a trailing comment (.IGNORE: # c), is caught.
    • MAKEFLAGS set by =, +=, := and export … = is caught. ?= and a bare export MAKEFLAGS are also caught, although neither changes make's behaviour (probe: exit 2).
    • The env check reads the key, so a mapping MAKEFLAGS: ${{ … }} in the workflow, job or step env is caught. A whole env: written as one expression string isn't parsed: "MAKEFLAGS" in <string> is a substring test on the expression text.
    • Recipe lines are read for test and for the targets on its rule line (test-country-template), and nowhere else (P2 2).
    • coverage xml -i on master isn't flagged.
  • Item 2.

    • On master, only push.yaml Publish declares id-token: write, and no workflow sets top-level permissions. A YAML dump of every job shows Docs {contents: write} and lint-workflows {contents: read}. So the test checks both declarations, as the brief asked.
    • The test reads:
      • a mapping-form environment (N3);
      • job-level write-all (N4);
      • workflow-level write-all inherited by a job without permissions (A10);
      • a job that calls a local reusable workflow with id-token: write (N13);
      • a called workflow's own jobs (N14). Called workflows are top-level files, which load_workflows reads.
    • grants_id_token("read-all") is False.
    • The body says the check "rests on PyPI's trusted-publisher configuration, which these tests can't read. It doesn't cover an upload that uses a stored API token, nor an environment name written as an expression". That is accurate for the code.
  • Item 3. The comment at tests/core/test_release_tagging.py:440-446 matches the code:

    • uploads_to_pypi matches the lower-case pypa/… prefix and PYPI_UPLOAD_COMMAND's \s+-separated words;
    • the scans cover workflow steps, .github/actions composite steps and root Makefile recipes;
    • remote_reusable_workflow_jobs rejects reusable workflows from other repositories;
    • the pointer to the credentials test is right.
    • Its "Makefile recipes" has the same parser blind spots as P3 2.
  • Item 4. See the locator messages above. The in-suite test test_test_job_check_explains_missing_test_step covers both runs.

  • Fragment.

    • changed is a configured towncrier type (pyproject.toml, [[tool.towncrier.type]] directory = "changed").
    • It is one sentence, as docs/engineering/skills/github-prs.md asks.
    • It is the only file in changelog.d/.
    • Its wording overclaims only as P2 1 says.
  • Body list (item 7). I compared it with Extend the release tests' PyPI scan and Test-job guard #649's list:

    • It drops exactly the gaps this PR closes (.IGNORE, MAKEFLAGS in env and +=, || true / ; true).
    • It keeps "line continuations" and says precisely which continuation the parser catches.
    • It adds GNUMAKEFLAGS, $GITHUB_ENV and the mixed-case owner.
    • It narrows the remote-action entry to jobs that declare neither environment: pypi nor id-token: write.
    • It is missing only the forms in P2 2.
  • CI. Pull request run 41736909058 at 3acba61ddd3d9263c9f1f964b4d16f4e893edec0 is completed / success, and it is the only run at that SHA. All 19 jobs are success: Lint, Lint workflows, Check changelog fragment, 8 Test jobs (ubuntu and windows, Python 3.11–3.14) and 8 "Test Core and country packages" jobs.

What I couldn't check

  • The documentation. docs.github.com and www.gnu.org don't resolve from this session (WebFetch: getaddrinfo ENOTFOUND docs.github.com; curl returns code 000 for both). Ubuntu's make-dfsg_4.3.orig.tar.gz has the manual removed, and make-doc has no install candidate. So I quote neither. In particular, I couldn't quote:

    • how job permissions combine with the workflow's;
    • what write-all and read-all grant;
    • whether environment names are case-sensitive;
    • what a jobs.<id>.uses caller grants the called workflow, and whether a called job may declare environment.

    The test's treatment of each (job overrides workflow; write-all grants id-token; read-all doesn't; names compared ignoring case) is the stricter reading, so a wrong reading would cause a loud false positive, not a miss. The make statements above are GNU Make 4.3's observed behaviour from probes, not the manual's wording.

  • PyPI's trusted-publisher configuration, which the credentials check relies on.

  • Whether GitHub resolves a mixed-case action owner (R15).


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

… that mask failures

The review of #650 found that an indented .IGNORE:, override MAKEFLAGS,
$(eval MAKEFLAGS ...), .ONESHELL, SHELL/.SHELLFLAGS, a prerequisite's
prerequisite, a later rule line for the same target and a double-colon rule
each let make test pass after pytest fails while the release tests stayed
green. Take the review's guard and tighten it where GNU Make 4.3 probes showed
further masks: any mention of .IGNORE, .ONESHELL or MAKEFLAGS (which also
covers GNUMAKEFLAGS and `foo .IGNORE:`), a target-specific SHELL, a later rule
line indented with spaces or naming other targets too, a recipe after `;` on
the rule line, and order-only prerequisites. Apply the one-rule-line check to
every Makefile target in the PyPI upload scan too, so that `build::` or a later
`build:` line cannot hide a twine upload from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6Jdt6qgZLwHQRNYNF6
The review found the fragment named forms the checks missed (an indented
.IGNORE: and override MAKEFLAGS). With the wider guard, name exactly what it
rejects: any mention of .IGNORE, .ONESHELL or MAKEFLAGS, a SHELL or
.SHELLFLAGS assignment, a target without exactly one plain single-colon rule
line, and the masked recipe forms at any prerequisite depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6Jdt6qgZLwHQRNYNF6
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
1bda485

Answers the review at #650 (comment). Two commits on 3acba61, which was still the head when I picked this up. The last push before mine was 2026-11-03T23:09Z. Changed files: tests/core/test_release_tagging.py and changelog.d/core649-release-test-followups.changed.md. git diff --quiet origin/master HEAD -- uv.lock policyengine_core .github exits 0.

Items

  1. The guard (review P3 1): done, and a little stricter than the review's code.

    • MAKE_SETTING_THAT_MASKS is at tests/core/test_release_tagging.py:702-711. It matches \.(IGNORE|ONESHELL)\b, the substring MAKEFLAGS, and (?<[\w.])(SHELL|\.SHELLFLAGS)\s*[:+?!]*=.
    • assert_make_test_fails_when_tests_fail is at :742-770. It walks test's prerequisites at every depth (:750), skipping only the | token, so order-only prerequisites are walked too (:760). Each target it reaches must be in make_rules and not in make_targets_make_rules_misreads (:720-739), which asks for exactly one rule line, single-colon, in the first column, with no ; on it.
    • The $(MAKEFLAGS) false positive is named in the comment at :702-707.
    • It passes on master's Makefile and fails on N1, .IGNORE: # c (X1), .IGNORE:test (X2), N2, $(eval MAKEFLAGS += -i) (N10), .ONESHELL (N7), SHELL := (X6), N5, N8 and N9. See the table.
    • In-suite tests: the .IGNORE and MAKEFLAGS tests are merged into test_make_test_check_rejects_setting_that_masks (:943), which has 18 parameters including N1 and N2. test_make_test_check_rejects_recipe_it_cannot_read (:1012) has 8 parameters: a prerequisite without a rule, N5 (two levels), order-only, a later test: format, a later format test:, a later indented test:, ::, and ; recipe.
  2. The upload scan (review P3 2): applied. make_targets_make_rules_misreads(MAKEFILE) == [] is asserted in test_only_push_publish_job_uploads_to_pypi (:453), for every target on any rule line. Master passes. N11, N12 and X13 (build: ; twine upload dist/*) now fail it. They are also in-suite parameters of test_pypi_upload_check_rejects_recipe_it_cannot_read (:1030).

  3. The fragment: rewritten to name what the checks catch, and nothing more. Clause by clause, with the mutant that fails head:

    • mention of .IGNORE: R8, R13, N1, X1–X4;
    • .ONESHELL: N7;
    • MAKEFLAGS: R14, A3, N2, N10; GNUMAKEFLAGS: X5;
    • SHELL: X6; target-specific SHELL: X8; .SHELLFLAGS: X7. I checked ?=, +=, !=, ::= and export SHELL = against the regex directly; it matches all of them and doesn't match $(SHELL) -c x;
    • one rule line: N8, N12, X10 (more than one), N9, N11 (::), X9 (indented), X11, X13 (;); a prerequisite with no rule: in-suite;
    • recipe forms at any depth: R12, A4–A7, N5; order-only: X12;
    • MAKEFLAGS in env: R9, A1, A2;
    • credentials: R7+, R11+, A8–A11, N3, N4, N13, N14;
    • locator: A12, A13.
  4. The body: "What the checks still can't see" now keeps only the forms that remain. Each is checked by a control mutant that passes head (C1–C6) or by a probe:

    • other Makefiles, include (C5), and cmd; \ plus true (C4);
    • $(MAKE) (C1);
    • GNUMAKEFLAGS in a workflow env, and $GITHUB_ENV;
    • other recipe forms: if … fi (C6), | tee (C3), ; true # note (C2).

    I added one form I found: a rule line whose target is a variable ($(T): with T = test). Make 4.3 exits 0 on it, and the guard passes. The review's P3 3 note is a new bullet, which also lists the new loud cases: a second .PHONY: line, and a prerequisite that is a file, a variable or a pattern-rule target. The "What's changed" Makefile bullet and the test counts are updated. I read the body back after the PATCH. It matches, except that the server rewrote the footer line.

  5. Mutants. See below.

  6. Runs. See below.

Mutants

Each mutant is a scripted text edit on the working tree. For each one, the script ran head's test_release_tagging.py, test_docs_deployment.py, and master's test_release_tagging.py copied into place, each in its own pytest process. After each mutant it ran git checkout -q -- .github Makefile tests && git clean -fdq .github, removed extra.mk, and asserted that git status --porcelain was empty. It was empty after every mutant and at the end.

  • R/A/N rows follow the review's descriptions. X rows are the further forms this PR claims. C rows are controls for forms the body still lists, so they are expected to pass.
  • Head's file has 89 tests, up from 65, so most counts differ from the review's. A row's kill lists the guard tests that fail, and in-suite tests that fail incidentally count in parentheses.
  • N1 also fails the upload guard: .IGNORE: reads as an indented rule line. N6 is the review's deliberate false-positive probe.
Mutant Head (1bda485) Killed by (head guard tests; + in-suite count) docs Master test file
unmodified 89 passed none 5 passed 42 passed
R7 PyO3/maturin-action@v1 upload, new workflow, no credentials 89 passed none 6 passed 42 passed
R7+ R7 + environment: pypi + id-token: write 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 6 passed 42 passed
R8 .IGNORE: test 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
R9 MAKEFLAGS: -i in the Test job's env 2 failed, 87 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed
R10 make test || true 16 failed, 73 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 11 failed, 31 passed
R11 some-org/publish-action@v1, no credentials 89 passed none 6 passed 42 passed
R11+ R11 + credentials 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 6 passed 42 passed
R12 || true on the pytest line 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
R13 bare .IGNORE: 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
R14 MAKEFLAGS += -i 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
R15 PyPA/gh-action-pypi-publish in pr.yaml Test 89 passed none 5 passed 42 passed
R16 uv --verbose publish in pr.yaml Test 89 passed none 5 passed 42 passed
A1 MAKEFLAGS in the test step's env 2 failed, 87 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed
A2 MAKEFLAGS in push.yaml's workflow env 2 failed, 87 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed
A3 export MAKEFLAGS = -i 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
A4 ; true on the pytest line 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
A5 ; exit 0 on coverage xml -i 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
A6 ; : on the pytest line 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
A7 || true on test-country-template 3 failed, 86 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed
A8 environment: pypi on pr.yaml Test 4 failed, 85 passed …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed
A9 id-token: write on pr.yaml Test 4 failed, 85 passed …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed
A10 workflow permissions: write-all on pr.yaml 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed
A11 Publish without environment: pypi 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed
A12 run: make -C . test 16 failed, 73 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 11 failed, 31 passed
A13 run: uv run make test 16 failed, 73 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 11 failed, 31 passed
N1 .IGNORE: (two leading spaces) 23 failed, 66 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed
N2 override MAKEFLAGS += -i 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
N3 environment: {name: pypi} on pr.yaml Test 4 failed, 85 passed …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed
N4 permissions: write-all on Docs 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 1 failed, 4 passed 42 passed
N5 || true two levels down (test-country-template: deeper) 6 failed, 83 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed
N6 recipe comment # a || b in test (false-positive probe) 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
N7 .ONESHELL: 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
N8 || true on the pytest line + a later test: format 20 failed, 69 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed
N9 test-country-template:: + || true 11 failed, 78 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 1 failed, 41 passed
N10 $(eval MAKEFLAGS += -i) 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
N11 build:: + twine upload 8 failed, 81 passed …uploads_to_pypi (+3 in-suite) 5 passed 1 failed, 41 passed
N12 build: + twine upload, later build: install 2 failed, 87 passed …uploads_to_pypi (+1 in-suite) 5 passed 1 failed, 41 passed
N13 local reusable-workflow call with id-token: write in pr.yaml 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 6 passed 42 passed
N14 called workflow's job declares environment: pypi 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 6 passed 42 passed
N15 two make test steps in push.yaml Test 15 failed, 74 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 10 failed, 32 passed
X1 .IGNORE: # c 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
X2 .IGNORE:test 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
X3 foo .IGNORE: 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
X4 $(eval .IGNORE:) 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
X5 GNUMAKEFLAGS += -i 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
X6 SHELL := /bin/true 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
X7 .SHELLFLAGS := -c true 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
X8 test: SHELL = /bin/true 23 failed, 66 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed
X9 later rule line indented with spaces: test: + recipe true 20 failed, 69 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed
X10 || true on the pytest line + later format test: 20 failed, 69 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed
X11 test-country-template: ; false || true (recipe on the rule line) 11 failed, 78 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed
X12 order-only prerequisite test: ... | deeper, deeper: false || true 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed
X13 build: ; twine upload dist/* (recipe on the rule line) 8 failed, 81 passed …uploads_to_pypi (+3 in-suite) 5 passed 1 failed, 41 passed
C1 $(MAKE) -i other in test's recipe 89 passed none 5 passed 42 passed
C2 ; true # note on the pytest line 89 passed none 5 passed 42 passed
C3 | tee log on the pytest line 89 passed none 5 passed 42 passed
C4 cmd; \ + true continuation on the pytest line 89 passed none 5 passed 42 passed
C5 include extra.mk, with .IGNORE: in extra.mk 89 passed none 5 passed 42 passed
C6 if cmd; then :; fi on the pytest line 89 passed none 5 passed 42 passed

GNU Make 4.3 probes

Each probe is a scratch Makefile whose test recipe is false, run with make -s test. Plain gives exit 2. Each of these gives exit 0:

  • .IGNORE:, foo .IGNORE:, $(eval .IGNORE:);
  • GNUMAKEFLAGS += -i in the Makefile, and GNUMAKEFLAGS=-i in the environment;
  • override MAKEFLAGS += -i;
  • .ONESHELL: followed by a true line;
  • test: SHELL = /bin/true;
  • a later test: rule line, indented with spaces, with a true recipe;
  • a: ; false || true as a prerequisite;
  • test: | a with a: running false || true;
  • $(T): with true and T = test.

\t.IGNORE: (tab-indented, before any rule) and MFLAGS += -i give exit 2. A later test: format with no recipe also gives exit 2: make still runs the original recipe. The guard rejects it because make_rules then misreads test's recipe, as N8 shows.

Tests

One file per process, UV_FROZEN=1 uv run pytest -q -p no:cacheprovider <file>:

  • tests/core/test_release_tagging.py: 89 passed;
  • tests/core/test_docs_deployment.py: 5 passed;
  • master's test_release_tagging.py: 42 passed (the unmodified row).

ruff format --check gives "1 file already formatted" and ruff check gives "All checks passed!" on the changed test file. The first uv run rewrote uv.lock. I restored it, ran everything after that with UV_FROZEN=1, and nothing under uv.lock is committed.

CI

The Pull request run 41833712248 at 1bda485331c971d65891da49f133d1794ed86c25 is completed / success, and it is the only run at that SHA. All 19 jobs are success: https://github.com/PolicyEngine/policyengine-core/actions/runs/41833712248

Judgment calls

  • Stricter than the review's code. The review's code missed several forms that probes showed mask a failure: foo .IGNORE:, $(eval .IGNORE:), GNUMAKEFLAGS, a target-specific SHELL, a later rule line indented with spaces or naming several targets, a ; recipe on the rule line, and order-only prerequisites. So the guard takes these steps:
    • It matches any mention of .IGNORE/.ONESHELL, rather than a rule at line start.
    • It matches MAKEFLAGS as a substring, rather than \bMAKEFLAGS\b.
    • It doesn't anchor the SHELL assignment.
    • It counts rule lines per target across multi-target lines.
    • Each change can only fail more often, and master still passes.
  • One rule line for every target in the upload scan. Item 2 says to apply the check to "every target the upload scan reads". make_rules drops build:: entirely, so N11's target never reaches the upload scan. I apply the check to every target named on any rule line, double-colon and indented ones included. The cost is a loud failure on a repeated .PHONY: line, which the body names.
  • A prerequisite with no rule line fails. This follows the review's code (colons == [":"]). It also catches test: ; … inline recipes and variable or pattern-rule prerequisites, loudly. I named this in the body.
  • The push went to the PR's branch claude/core-pr-649-followups-c3va8g, as the brief says. The session's tooling also required its own branch, claude/core-650-makefile-guard-43in9y, to be pushed. It holds the same commits and has no PR. It can be deleted.
  • Workflow tool: not used, per the brief, although ultracode was on.

Proposed follow-ups

  • Release tests: read included makefiles and $(MAKE) sub-makes. The guard reads only the root Makefile's text. An include extra.mk holding .IGNORE:, or a recipe running $(MAKE) -i other, still lets make test pass after pytest fails. Following include and $(MAKE) targets would close both.
  • Release tests: catch GNUMAKEFLAGS in workflow env. The env check compares the key to MAKEFLAGS exactly, but GNU Make 4.3 also takes -i from GNUMAKEFLAGS (probe: exit 0). Checking both keys is a one-line change.

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Hub review in progress

Round 2 review of head 1bda485331c971d65891da49f133d1794ed86c25, which is still the PR head. Interim findings so far:

  • Baseline, one file per process: head test_release_tagging.py 89 passed, test_docs_deployment.py 5 passed, master's test_release_tagging.py 42 passed. Every hand-off mutant I reran gives the hand-off's count.
  • CI run 41833712248 at this SHA: 19 of 19 jobs success.
  • Probable P2. A line of only a comment, or an ifeq … endif, inside a recipe hides the recipe lines after it from make_rules. GNU make still runs them. The manual says: "Blank lines and lines of just comments may appear among the recipe lines; they are ignored." So || true on test's pytest line, or on the template recipe, after such a line passes all 89 tests. So does twine upload dist/* in build after # upload. The fragment's claim "if a recipe line of make test … contains ||" is too broad, and the body doesn't list these forms.
  • Other forms head misses that the body doesn't list: define SHELL … endef, MAKEFILES in the Test job's env, a root GNUmakefile, a name built from parts (.$(I):, $(S) = …), and cmd \ followed by an untabbed || true.

The final review follows.


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
VERDICT: REQUEST_CHANGES
1bda485
METHODOLOGY_CHOICE: no

I reviewed head 1bda485331c971d65891da49f133d1794ed86c25. It is still the PR head, so nothing changed after the hand-off. The base is master eaef9c24858369af85093bfb5de729b48208be81. This round answers the round 1 review at 3acba61.

The fix session did items 1 to 6. Every hand-off mutant I reran gives the hand-off's count (46 rows: head, docs and master files). Master's Makefile raises no false positive, and CI is green. I found no P1.

There are two P2s, both of the same kind as round 1's:

  • A column-1 comment line, or an ifeq … endif, inside a recipe hides the lines after it from make_rules, and GNU make still runs those lines. So || true on make test's pytest line or on the template tests passes all 89 tests. So does twine upload dist/* in build. The fragment's "if a recipe line of make test … contains ||" is too broad as a result. A short change to make_rules, which I ran, closes these forms and some others.
  • The body's "What the checks still can't see" list leaves out forms head misses. They are define SHELL, MAKEFILES in a workflow env, a root GNUmakefile, names built from parts, and a continuation whose next line has no tab. The body's "the checks catch a continued line that begins || true" holds only when that line starts with a tab.

None of these forms exists in the repository today.

Assignment. The PR does items 1 to 6 of the fix brief.

  • git diff --stat 3acba61 1bda485 changes only changelog.d/core649-release-test-followups.changed.md (1 line) and tests/core/test_release_tagging.py (+168/−26).
  • pulls/650/files lists the same two files against master. git diff --quiet eaef9c2 1bda485 -- uv.lock policyengine_core .github exits 0.
  • What the brief asked for that the PR doesn't fully deliver:
    • item 3, "name exactly the forms the checks catch … and no more" (P2 1);
    • item 4, the list's completeness (P2 2).
  • The hand-off quotes the SHELL pattern as (?<[\w.])…. The code holds (?<[\w.])(SHELL|\.SHELLFLAGS)\s*[:+?!]*= (tests/core/test_release_tagging.py:708-710). The difference is only a typo in the hand-off.

P2

1. A comment-only line or a conditional inside a recipe hides the recipe lines after it, from both the test guard and the upload scan (tests/core/test_release_tagging.py:408-425, make_rules; the claim is in changelog.d/core649-release-test-followups.changed.md:1 and the body's first "What's changed" bullet).

make_rules starts a new, orphan recipe list at any non-blank line that isn't tab-indented and isn't a rule (elif line.strip(): recipe = []). GNU make treats several such lines differently.

  • A comment line. The GNU make 4.3 manual, Recipe Syntax, says: "Blank lines and lines of just comments may appear among the recipe lines; they are ignored."
  • A conditional. make processes ifeq … endif inside a recipe and keeps the tab lines between them in the recipe.
  • .RECIPEPREFIX = >, set before the rule. It makes > lines recipe lines, and make_rules never reads them.

Failure scenario: someone writes

test-country-template:
	@echo running template tests
# run them
	policyengine-core test policyengine_core/country_template/tests -c policyengine_core.country_template || true

make test then passes when the template tests fail. Publish still runs, and all 89 tests pass. The same shape in build (# upload, then \ttwine upload dist/*) gets an upload past pypi_uploading_recipe_lines. That answers item 2's question: this form hides an upload and still passes.

Commands and output:

  • Mutants on the working tree, python -m pytest -q -p no:cacheprovider tests/core/test_release_tagging.py:
    • H1, H2, H3 (@echo … as the first recipe line, then # … or ifeq (1,1) before the || true line) and F4, F10 (twine upload after # upload or inside ifeq) each give 89 passed;
    • docs gives 5 passed and master's file 42 passed for each.
    • Without the leading @echo line, the same forms (F1–F3, G1–G4) pass the guard tests. Only test_make_test_check_rejects_masked_recipe_line fails, and only by accident: it appends its suffix to whatever line follows the rule line.
  • make -n test on a scratch copy of the mutated Makefile (GNU Make 4.3):
    • H1 prints coverage run … --reruns-delay 5 || true;
    • H2 and H3 print policyengine-core test … -c policyengine_core.country_template || true;
    • make -n build for F4 and F10 prints twine upload dist/*.
    • Exit-status probes: test: / \ttrue / # note / \techo RAN; false gives RAN and exit 2, so make runs the line after the comment. test: / ifeq (1,1) / \techo RAN; false / endif gives RAN and exit 2.

Fix. Let the parser skip comment-only lines as make does, and fail closed on the syntax it can't follow. Master's Makefile has none of that syntax: grep -nE '^ *(ifeq|ifneq|ifdef|ifndef|else|endif|define|endef)\b|\.RECIPEPREFIX|\\$|^ *#' Makefile exits 1. I ran this exact diff:

@@ -405,22 +405,39 @@
     ]
 
 
+# Make syntax that make_rules does not follow, so recipe lines after it could
+# run unread: a conditional or define block, a changed recipe prefix, or a
+# backslash at the end of a line.
+MAKE_SYNTAX_MAKE_RULES_SKIPS = re.compile(
+    r"^ *((override|export)\s+)*(ifn?eq|ifn?def|else|endif|define|endef)\b"
+    r"|\.RECIPEPREFIX|\\$",
+    re.MULTILINE,
+)
+
+
 def make_rules(makefile: Path) -> dict[str, tuple[list[str], list[str]]]:
     """Each target's prerequisites and recipe lines (without the tab).
 
     Enough for this Makefile: it has no line continuations, includes or
     pattern rules.
     """
+    text = makefile.read_text()
+    assert not MAKE_SYNTAX_MAKE_RULES_SKIPS.search(text), (
+        "make_rules cannot read a conditional, define block, .RECIPEPREFIX or "
+        "line continuation in the Makefile"
+    )
     rules = {}
     recipe = []
-    for line in makefile.read_text().splitlines():
+    for line in text.splitlines():
         if line.startswith("\t"):
             recipe.append(line[1:])
         elif rule := MAKE_RULE.match(line):
             recipe = []
             for target in rule.group(1).split():
                 rules[target] = (rule.group(2).split(), recipe)
-        elif line.strip():
+        # make ignores blank lines and lines of just a comment among recipe
+        # lines, so neither ends a recipe.
+        elif line.strip() and not line.lstrip().startswith("#"):
             recipe = []
     return rules

What this diff does, run as a scratch copy of the head test file:

  • Master and existing rows. It gives 89 passed on master's Makefile, and ruff format --check and ruff check pass. Every hand-off row keeps its result, apart from C4 below (last column of the table).
  • What it closes. H1–H3, F1–F5, F9, F10 and G1–G4 now fail test_test_job_cannot_pass_with_failing_tests, and the ones in build fail test_only_push_publish_job_uploads_to_pypi.
    • The define alternative also closes define SHELL (P2 2).
    • The trailing-backslash alternative closes line continuations, including the body's C4 (cmd; \ + true). That one now fails, loudly.
  • In-suite parameters to add:
    • to test_make_test_check_rejects_recipe_it_cannot_read: a comment line before a masked line, with the expected message make test masks a failing command;
    • to a new parametrized test expecting make_rules cannot read: ifeq (1,1)/endif around a recipe line, .RECIPEPREFIX = >, define SHELL, and a trailing \;
    • to test_pypi_upload_check_rejects_recipe_it_cannot_read or a sibling test: build: / \tpython -m build / # upload / \ttwine upload dist/*, expecting pypi_uploading_recipe_lines(...) == ["build: twine upload dist/*"].

If the hub prefers not to change the code, narrow the fragment's recipe clause instead:

… if a recipe line of make test or of a prerequisite it runs at any depth (order-only included), up to the first line in that recipe that is a comment, a conditional or .RECIPEPREFIX syntax, contains || or ends in ; true, ; exit 0 or ; :, …

Then add the forms to the body's list (P2 2).

2. The body's "What the checks still can't see" leaves out Makefile and environment forms that head misses (PR body, the Makefile bullets).

Failure scenario: a maintainer reads the list as complete and relies on the guard for one of the forms below.

Form GNU Make 4.3, recipe false, make -s test Head test file
define SHELL / /bin/true / endef (the manual: define takes "an (optional) assignment operator") exit 0 89 passed (F5)
MAKEFILES: extra.mk in the Test job's env, with .IGNORE: in extra.mk (the manual: make reads the files in MAKEFILES "before the others") exit 0 89 passed (F6)
a root GNUmakefile holding include Makefile and .IGNORE: (the manual: make tries GNUmakefile, makefile, Makefile "in order") exit 0 89 passed (F7)
a name built from parts: I = IGNORE with .$(I): or $(eval .$(I):), and S = SHELL with $(S) = /bin/true exit 0 for each 89 passed (F11, Q5, F8)
a recipe line ending \, then || true on a line with no tab exit 0 89 passed (F9). With a tab (F9b), the guard catches it: 8 failed
a comment line, a conditional, or .RECIPEPREFIX before a masked line (P2 1) as P2 1 89 passed (H1–H3)

The body's "Makefiles other than the root one" doesn't clearly cover GNUmakefile or MAKEFILES, because both are read instead of or before the root Makefile. "included makefiles" covers -include and sinclude (Q3 and Q4: exit 0, 89 passed), but naming them helps.

Replacement for the body's Makefile bullets, if P2 1's diff lands:

  • Makefiles other than the root Makefile: a root GNUmakefile or makefile, which make reads instead; a makefile named in MAKEFILES in a workflow env, which make reads first; and included makefiles (include, -include, sinclude).
  • A target that a recipe runs through $(MAKE), and a name that make builds from parts: a rule line whose target is a variable ($(T):, or .$(I): with I = IGNORE), or an assignment whose name is one ($(S) = /bin/true with S = SHELL).

Keep the GNUMAKEFLAGS/$GITHUB_ENV and recipe-form bullets as they are. Drop "line continuations" and its sentence about || true. Add to the fail-closed sub-list:

  • a line that ends in \, a conditional (ifeq … endif), a define block or .RECIPEPREFIX;
  • a recipe line that sets SHELL for one command, such as SHELL=bash ./run.

If P2 1's diff doesn't land, also add P2 1's forms and define SHELL to the list. Correct the continuation sentence to: "The checks catch a continued line that begins with a tab and || true, but not one without the tab, nor cmd; \ with true on the next line."

P3

1. The fragment's SHELL clause says "with any assignment operator", but define SHELL has no operator, and it masks a failure. Probe P12: define SHELL / /bin/true / endef gives exit 0. define .SHELLFLAGS / -c true / endef also gives exit 0. The F5 mutant gives 89 passed. P2 1's diff closes it, because the fragment's text then holds through the define check. Without that diff, change "(with any assignment operator, also target-specific)" to "(with =, :=, ::=, ?=, += or !=, also target-specific, but not through define)".

2. One loud false positive isn't in the body's list. A recipe line such as \tSHELL=bash true matches MAKE_SETTING_THAT_MASKS (L6: 19 failed, 70 passed), but it sets SHELL only for one shell command and masks nothing. Master has no such line. P2 2's replacement adds it to the list.

Mutants

Each mutant was a text edit on the working tree, made by a script. For each one, the script ran three pytest processes:

  • head's test_release_tagging.py;
  • test_docs_deployment.py;
  • master's test_release_tagging.py, copied into place.

After each mutant it ran git checkout -q -- .github Makefile tests && git clean -fdq .github and removed extra.mk, GNUmakefile and pubaction/. It then asserted that git status --porcelain was empty. It was empty every time, and at the end.

How to read the table:

  • R, A, N, X and C rows follow the hand-off's descriptions.
  • Q rows are the brief's item 4 probes.
  • F, G and H rows are forms I found.
  • W rows check the body's workflow entries, and L rows its fail-closed entries.
  • "Killed by" names the guard tests that fail. In-suite tests that fail, often only by accident, are counted in parentheses.
  • The last column reruns head's file with P2 1's diff applied.

Results:

  • All 46 R, A, N, X and C rows match the hand-off's counts for head, docs and master (a script compared them).
  • The cycle (Q7) ends. The guard passes, and only the in-suite order-only test fails, because its edit of test: test-country-template no longer finds its text.
  • make -s test on a cycle probe prints Circular b <- a dependency dropped. and still exits 2 on the failing recipe.
Mutant Head (1bda485) Killed by docs Master file Head + P2 1 fix
R7+ 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 6 passed 42 passed 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite)
R8 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
R9 2 failed, 87 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite)
R12 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
R13 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
R14 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A1 2 failed, 87 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite)
A2 2 failed, 87 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite)
A3 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A4 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A5 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A6 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A7 3 failed, 86 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite)
A8 4 failed, 85 passed …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed 4 failed, 85 passed; …gets_pypi_credentials (+2 in-suite)
A10 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite)
N1 23 failed, 66 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed 23 failed, 66 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
N2 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
N4 7 failed, 82 passed …gets_pypi_credentials (+2 in-suite) 1 failed, 4 passed 42 passed 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite)
N5 6 failed, 83 passed …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 6 failed, 83 passed; …cannot_pass_with_failing_tests (+1 in-suite)
N6 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
N7 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
N8 20 failed, 69 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
N9 11 failed, 78 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 1 failed, 41 passed 11 failed, 78 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
N10 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
N11 8 failed, 81 passed …uploads_to_pypi (+3 in-suite) 5 passed 1 failed, 41 passed 8 failed, 81 passed; …uploads_to_pypi (+3 in-suite)
N12 2 failed, 87 passed …uploads_to_pypi (+1 in-suite) 5 passed 1 failed, 41 passed 2 failed, 87 passed; …uploads_to_pypi (+1 in-suite)
N15 15 failed, 74 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 10 failed, 32 passed 15 failed, 74 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X1 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X2 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X3 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X4 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X5 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X6 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X7 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X8 23 failed, 66 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed 23 failed, 66 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
X9 20 failed, 69 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
X10 20 failed, 69 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
X11 11 failed, 78 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed 11 failed, 78 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
X12 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X13 8 failed, 81 passed …uploads_to_pypi (+3 in-suite) 5 passed 1 failed, 41 passed 8 failed, 81 passed; …uploads_to_pypi (+3 in-suite)
C1 89 passed none 5 passed 42 passed 89 passed; none
C2 89 passed none 5 passed 42 passed 89 passed; none
C3 89 passed none 5 passed 42 passed 89 passed; none
C4 89 passed none 5 passed 42 passed 16 failed, 73 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
C5 89 passed none 5 passed 42 passed 89 passed; none
C6 89 passed none 5 passed 42 passed 89 passed; none
Q1 @- on the pytest line 13 failed, 76 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 2 failed, 40 passed 13 failed, 76 passed; …cannot_pass_with_failing_tests (+3 in-suite)
Q2 +- on the pytest line 13 failed, 76 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 2 failed, 40 passed 13 failed, 76 passed; …cannot_pass_with_failing_tests (+3 in-suite)
Q3 -include extra.mk, .IGNORE: in it 89 passed none 5 passed 42 passed 89 passed; none
Q4 sinclude extra.mk, .IGNORE: in it 89 passed none 5 passed 42 passed 89 passed; none
Q5 I = IGNORE + $(eval .$(I):) 89 passed none 5 passed 42 passed 89 passed; none
Q6 GNUMAKEFLAGS: -i in push Test env 89 passed none 5 passed 42 passed 89 passed; none
Q7 cycle: test: … a, a: b, b: a 1 failed, 88 passed none (+1 in-suite) 5 passed 42 passed 1 failed, 88 passed; none (+1 in-suite)
F1 # … line, then pytest line || true 5 failed, 84 passed none (+1 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
F2 ifeq (1,1) around pytest line || true 5 failed, 84 passed none (+1 in-suite) 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
F3 .RECIPEPREFIX = >, test recipe >…|| true 5 failed, 84 passed none (+1 in-suite) 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
F4 build: # upload, then \ttwine upload dist/* 89 passed none 5 passed 42 passed 2 failed, 87 passed; …uploads_to_pypi (+1 in-suite)
F5 define SHELL / /bin/true / endef 89 passed none 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
F6 MAKEFILES: extra.mk in push Test env, .IGNORE: in it 89 passed none 5 passed 42 passed 89 passed; none
F7 root GNUmakefile: include Makefile + .IGNORE: 89 passed none 5 passed 42 passed 89 passed; none
F8 S = SHELL + $(S) = /bin/true 89 passed none 5 passed 42 passed 89 passed; none
F9 pytest line \, next line || true (no tab) 89 passed none 5 passed 42 passed 16 failed, 73 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
F10 build: ifeq around twine upload dist/* 89 passed none 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
F11 I = IGNORE + .$(I): 89 passed none 5 passed 42 passed 89 passed; none
G1 template: # … line, then || true 1 failed, 88 passed none (+1 in-suite) 5 passed 42 passed 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite)
G2 template: ifeq around || true 1 failed, 88 passed none (+1 in-suite) 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
G3 template: .RECIPEPREFIX = >, >…|| true 1 failed, 88 passed none (+1 in-suite) 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
G4 template: # … line, then || true 1 failed, 88 passed none (+1 in-suite) 5 passed 42 passed 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite)
H1 test: @echo, # …, pytest || true 89 passed none 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
H2 template: @echo, # …, || true 89 passed none 5 passed 42 passed 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite)
H3 template: @echo, ifeq, || true 89 passed none 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
H4 template: @echo, mid-recipe .RECIPEPREFIX (make error) 89 passed none 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
F9b pytest line \, next line tab + || true 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 16 failed, 73 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
W1 new workflow run: python upload.py, TWINE_PASSWORD secret 89 passed none 6 passed 42 passed 89 passed; none
W2 new workflow run: $TOOL publish 89 passed none 6 passed 42 passed 89 passed; none
W3 new workflow curl to upload.pypi.org/legacy/ 89 passed none 6 passed 42 passed 89 passed; none
W4 uses: ./pubaction (composite, twine upload) 89 passed none 6 passed 42 passed 89 passed; none
W5 Docker action under .github/actions with twine upload args 89 passed none 5 passed 42 passed 89 passed; none
W6 environment: ${{ 'pypi' }} on pr Test 89 passed none 5 passed 42 passed 89 passed; none
W7 earlier step writes MAKEFLAGS=-i to $GITHUB_ENV 89 passed none 5 passed 42 passed 89 passed; none
W8 earlier step writes PYTEST_ADDOPTS=--co to $GITHUB_ENV 89 passed none 5 passed 42 passed 89 passed; none
W9 T = test + $(T): / true 89 passed none 5 passed 42 passed 89 passed; none
L1 loud: echo 'a || b' in test 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
L2 loud: echo $(MAKEFLAGS) in test 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
L3 loud: # never add .IGNORE here 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)
L4 loud: two .PHONY: lines 4 failed, 85 passed …uploads_to_pypi (+1 in-suite) 5 passed 42 passed 4 failed, 85 passed; …uploads_to_pypi (+1 in-suite)
L5 loud: test: … pyproject.toml 8 failed, 81 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
L6 loud: recipe line SHELL=bash true 19 failed, 70 passed …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite)

Checked and sound

  • Tests, one file per process (python -m pytest -q -p no:cacheprovider <file>, Python 3.14.6, in a uv venv with uv pip install -e ".[dev]"; uv was already installed):

    • head test_release_tagging.py: 89 passed;
    • head test_docs_deployment.py: 5 passed;
    • master's test_release_tagging.py in place: 42 passed.

    ruff format --check gives "1 file already formatted" and ruff check gives "All checks passed!" (ruff 0.17.3).

  • The pattern against the manual (GNU make 4.3's doc/make.texi, read from the mirror/make GitHub mirror at tag 4.3; gnu.org is blocked here).

    • The pattern matches SHELL with =, :=, ::=, ?=, += and !=, and with export, override, export override and target-specific prefixes (test: SHELL = …, test: export SHELL = …, test: .SHELLFLAGS := …). It also matches define SHELL = and :::=.
    • It doesn't match $(SHELL) -c …, x = $(SHELL), FOO_SHELL = 1 or a bare export SHELL.
    • Make probes:
      • =, :=, ::=, !=, export, override, target-specific SHELL and .SHELLFLAGS = -c true each give exit 0;
      • SHELL ?= /bin/true gives exit 2, because make sets SHELL by default;
      • :::= is not a 4.3 operator (*** empty variable name);
      • SHELL=/bin/true in the environment gives exit 2. The manual: "the variable SHELL is never set from the environment".
    • \.(IGNORE|ONESHELL)\b matches every .IGNORE and .ONESHELL form in the hand-off (X1–X4, N1, N7). The manual: .IGNORE with no prerequisites ignores errors "for all files", and .ONESHELL gives "all lines of the recipe … to a single invocation of the shell".
    • MAKEFLAGS as a substring also catches GNUMAKEFLAGS (X5). The manual says GNUMAKEFLAGS "is parsed just before MAKEFLAGS, in the same way".
    • MFLAGS can't pass -i. The probe gives exit 2, and the manual says "you cannot use MFLAGS this way … make does not interpret a value you set for it in any way". The hand-off's probe is right.
  • Recipe prefixes. @- and +- (Q1, Q2) fail the guard, because the prefix test strips any mix of @, +, - and whitespace. Both give exit 0 in make.

  • The prerequisite walk (:749-760).

    • It is depth-first with a checked list, so a cycle ends (Q7, and a scratch call on test: a, a: b, b: a).
    • It reaches depth 4: test: a, a: b, b: c, c: d, d: with \tfalse || true fails with make d masks a failing command.
    • It skips the | token, so it walks order-only prerequisites (X12). test: a |b fails loudly: make |b needs exactly one single-colon rule line.
  • The one-rule-line check (make_targets_make_rules_misreads, :720-739). It rejects every form make_rules misreads:

    • a second rule line, which make_rules lets replace the first (N8, X10, N12);
    • a double-colon rule, which MAKE_RULE's (?[:=]) rejects, so its recipe goes to an orphan list (N9, N11);
    • a space-indented rule line, which MAKE_RULE's ^[^\s…] rejects (X9);
    • a ; recipe, which make_rules reads as prerequisites (X11, X13);
    • a prerequisite with no rule line, which fails through target in rules (L5).
  • No false positive on master. test_test_job_cannot_pass_with_failing_tests and test_only_push_publish_job_uploads_to_pypi pass on the unmodified tree. Master's Makefile doesn't mention MAKEFLAGS, .IGNORE, .ONESHELL, SHELL or .SHELLFLAGS. Each of its 9 targets has one plain rule line.

  • Upload scan (item 2). make_targets_make_rules_misreads(MAKEFILE) == [] (:453) fails on N11, N12 and X13, and master passes. The only forms I found that hide an upload and still pass are P2 1's (F4, F10).

  • Fragment (item 3), clause by clause. Each clause has a mutant that fails head, apart from the P2 1 and P3 1 forms:

    • .IGNORE: R8, R13, N1, X1–X4;
    • .ONESHELL: N7;
    • MAKEFLAGS and GNUMAKEFLAGS: R14, A3, N2, N10, X5;
    • SHELL and .SHELLFLAGS: X6–X8;
    • one rule line: N8, N9, N11, N12, X9–X11, X13;
    • recipe forms at any depth, order-only included: R12, A4–A7, N5, X12;
    • MAKEFLAGS in env: R9, A1, A2;
    • credentials: R7+, A8, A10, N4.
  • Body (item 4). Each form the list names passes head:

    • Makefile forms: C1–C6, W9 ($(T):), Q3, Q4, Q6 (GNUMAKEFLAGS in env), W7 (MAKEFLAGS via $GITHUB_ENV);
    • workflow forms: W1–W6, W8.

    Each fail-closed entry fails head: N6, L1–L5. GNU make gives exit 0 for every Makefile form it names: $(T):, $(MAKE) -i other, include, | tee, ; true # note, if …; then :; fi, and cmd; \ + true.

  • CI. Pull request run 41833712248 at 1bda485331c971d65891da49f133d1794ed86c25 is completed / success, and it is the only run at that SHA. All 19 jobs are success: Lint, Lint workflows, Check changelog fragment, 8 Test jobs (ubuntu and windows, Python 3.11–3.14) and 8 "Test Core and country packages" jobs. None was cancelled or skipped.

What I couldn't check

  • docs.github.com and www.gnu.org: the proxy refuses both (CONNECT tunnel failed, response 403). I read the GNU make manual from its Texinfo source at the 4.3 tag of the mirror/make GitHub mirror, and I quote from that. I quote nothing from GitHub's docs. So these rest on the body's own wording and the stricter-reading argument from round 1:
    • how env from $GITHUB_ENV reaches later steps;
    • how a job's working-directory would point make test at another Makefile.
  • Which GNU make version ubuntu-latest and windows-latest run. My probes use GNU Make 4.3.
  • PyPI's trusted-publisher configuration, which the credentials check relies on.

Generated by Claude Code

…ollow

A comment-only line, a conditional or .RECIPEPREFIX inside a recipe ended
make_rules's recipe, so later recipe lines (an `|| true` or a twine upload)
went unread while GNU make still ran them. make_rules now skips comment-only
lines, as make does, and fails on conditionals, define blocks, .RECIPEPREFIX
and trailing backslashes. Adds in-suite cases for each form.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6Kc7ibRJ5e1WVQfQD7
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
b788b3e

This answers the round 2 review at #650 (comment). It adds two commits on 1bda485, which was still the head when I picked this up. The last push before mine was 2026-11-04T12:57Z. I waited until 18:57Z, six hours after it, before I commented or pushed. Changed files against master: tests/core/test_release_tagging.py and changelog.d/core649-release-test-followups.changed.md. git diff --quiet origin/master HEAD -- uv.lock policyengine_core .github exits 0.

Items

  1. The parser (P2 1): applied as written. git apply --recount applied the review's diff cleanly.
    • MAKE_SYNTAX_MAKE_RULES_SKIPS is at tests/core/test_release_tagging.py:411-415. The assertion is at :432-435, and the comment-only-line skip is at :447.
    • The docstring of make_rules (:419-430) now names what the function reads: first-column single-colon rule lines and the tab lines after them, read past blank and comment-only lines. It also names what it fails on (a conditional, define, .RECIPEPREFIX, a trailing \) and what it misreads, which make_targets_make_rules_misreads reports.
    • On master's Makefile, the review's diff alone gives 89 passed. grep -nE '^ *(ifeq|ifneq|ifdef|ifndef|else|endif|define|endef)\b|\.RECIPEPREFIX|\\$|^ *#' on master's Makefile exits 1.
    • H1–H3, F1–F5, F9, F10 and G1–G4 now fail the guard. C4 now fails too. See the table below.
    • In-suite parameters:
      • comment-line-before-masked-line in test_make_test_check_rejects_recipe_it_cannot_read (:1034-1039) expects make test masks a failing command;
      • the new test_make_rules_rejects_syntax_it_cannot_follow (:1049-1076) expects make_rules cannot read for ifeq (1,1) … endif around a recipe line, .RECIPEPREFIX = >, define SHELL and a trailing \. It calls make_rules directly;
      • the new test_pypi_upload_check_reads_past_comment_line (:1079-1085) expects pypi_uploading_recipe_lines(...) == ["build: twine upload dist/*"] for build: / \tpython -m build / # upload / \ttwine upload dist/*.
    • Code mutants of the fix, each run in its own process with the file restored afterwards:
      • dropping the comment skip gives 2 failed, killed by comment-line-before-masked-line and test_pypi_upload_check_reads_past_comment_line;
      • dropping the syntax assertion gives 4 failed (all four new syntax cases);
      • dropping .RECIPEPREFIX from the regex gives 1 failed (recipeprefix);
      • dropping \\$ gives 1 failed (line-continuation);
      • dropping ifeq, ifneq, endif and endef gives 1 failed (conditional).
      • Dropping define alone, or ifeq alone, gives 95 passed. The matching endef or endif still trips the same assertion, so each alternative pair is redundant for whole blocks. I left the review's regex as written.
  2. The fragment: I replaced it with the brief's text verbatim. No clause is false at this head:
    • define SHELL (F5) now fails through the define check, so "with any assignment operator" holds (the review's P3 1);
    • the conditional, define, .RECIPEPREFIX and \ clause: F2, F3, F5, F9 and F9b, and G2, G3, H3 and H4, all fail with make_rules cannot read;
    • "reads past comment-only lines": F1, G1, G4, H1 and H2 now fail through masks a failing command, and F4 through the upload scan;
    • the other clauses keep the mutants that last round's hand-off listed. Every one gives the same result here.
  3. The body: PATCHed and read back in full. It matches what I sent, except the footer, which the server rewrites.
    • The first "What's changed" bullet has the two new sentences after "because make_rules misreads every other form."
    • The bullet that began "Makefiles other than the root one" is replaced by the GNUmakefile/MAKEFILES/include bullet.
    • The $(MAKE) bullet is now two bullets: $(MAKE), and names built from parts.
    • The fail-closed sub-list has the two new entries after "a second .PHONY: line;".
    • In "Tests", the PR row reads 95 passed. The mutant sentence adds F1–F11, F9b, G1–G4, H1–H4, Q1–Q7, W1–W9 and L1–L6, and says C4 now fails.
    • The impact: line is unchanged. I reread every sentence against this head and found none that the change makes false.
  4. Mutants: all 88 rows, below. Every value in the review's "Head + P2 1 fix" column reproduces exactly when I run head's file with only the review's diff applied. Its Head, docs and Master-file columns reproduce too. The rows the fix closes, and what kills them at this head:
    • …cannot_pass_with_failing_tests alone: F1, G1, G4, H1 and H2;
    • …uploads_to_pypi alone: F4;
    • both guards: F2, F3, F5, F9, F10, G2, G3, H3, H4, C4 and F9b.
  5. Runs: see Tests and CI.

Mutants

Each row is a scripted text edit, run in one of four detached git worktree copies of this head. Each row got five pytest processes:

  • head 1bda485's test_release_tagging.py;
  • head's file with only the review's diff;
  • this head's file;
  • test_docs_deployment.py;
  • master's test_release_tagging.py.

Each file was copied into place before its run. After every process the script ran git checkout -q -- .github Makefile tests && git clean -fdq .github, removed extra.mk, GNUmakefile and pubaction/, and asserted that git status --porcelain was empty. It was empty every time, and in all four copies at the end. The copies were then removed.

How to read the table:

  • "Killed by" names the guard tests that fail. The parentheses count the other test functions that fail, not their parameters.
  • The two differences from the review are both placement:
    • Prepending test: SHELL = /bin/true (X8) gave 3 failed in master's file. Appending it gives the review's 4 failed, 38 passed, and the table shows the appended run. Its head and fix columns are the same either way.
    • My script listed the guards in a different order, which the table below corrects.
  • In this head's column, every row has the same guard set as the review-diff-only column. Only the counts differ, because the six new tests are added and some of them fail incidentally:
    • comment-line-before-masked-line fails on any prepended Makefile setting, since the settings check fires first with another message;
    • test_pypi_upload_check_reads_past_comment_line fails where the mutant edits build: or parses as unreadable.
Mutant Head 1bda485 docs Master file Head + review diff only This head
unmodified 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
R7+ 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite) 6 passed 42 passed 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite) 7 failed, 88 passed; …gets_pypi_credentials (+2 in-suite)
R8 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
R9 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite) 2 failed, 93 passed; …cannot_pass_with_failing_tests (+1 in-suite)
R12 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
R13 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
R14 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A1 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite) 2 failed, 93 passed; …cannot_pass_with_failing_tests (+1 in-suite)
A2 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 2 failed, 87 passed; …cannot_pass_with_failing_tests (+1 in-suite) 2 failed, 93 passed; …cannot_pass_with_failing_tests (+1 in-suite)
A3 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A4 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A5 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 11 failed, 84 passed; …cannot_pass_with_failing_tests (+4 in-suite)
A6 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
A7 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite) 3 failed, 92 passed; …cannot_pass_with_failing_tests (+1 in-suite)
A8 4 failed, 85 passed; …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed 4 failed, 85 passed; …gets_pypi_credentials (+2 in-suite) 4 failed, 91 passed; …gets_pypi_credentials (+2 in-suite)
A10 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite) 5 passed 42 passed 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite) 7 failed, 88 passed; …gets_pypi_credentials (+2 in-suite)
N1 23 failed, 66 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed 23 failed, 66 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 24 failed, 71 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
N2 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
N4 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite) 1 failed, 4 passed 42 passed 7 failed, 82 passed; …gets_pypi_credentials (+2 in-suite) 7 failed, 88 passed; …gets_pypi_credentials (+2 in-suite)
N5 6 failed, 83 passed; …cannot_pass_with_failing_tests (+1 in-suite) 5 passed 42 passed 6 failed, 83 passed; …cannot_pass_with_failing_tests (+1 in-suite) 6 failed, 89 passed; …cannot_pass_with_failing_tests (+1 in-suite)
N6 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
N7 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
N8 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 21 failed, 74 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
N9 11 failed, 78 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 1 failed, 41 passed 11 failed, 78 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 11 failed, 84 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
N10 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
N11 8 failed, 81 passed; …uploads_to_pypi (+3 in-suite) 5 passed 1 failed, 41 passed 8 failed, 81 passed; …uploads_to_pypi (+3 in-suite) 9 failed, 86 passed; …uploads_to_pypi (+4 in-suite)
N12 2 failed, 87 passed; …uploads_to_pypi (+1 in-suite) 5 passed 1 failed, 41 passed 2 failed, 87 passed; …uploads_to_pypi (+1 in-suite) 3 failed, 92 passed; …uploads_to_pypi (+2 in-suite)
N15 15 failed, 74 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 10 failed, 32 passed 15 failed, 74 passed; …cannot_pass_with_failing_tests (+3 in-suite) 15 failed, 80 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X1 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X2 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X3 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X4 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X5 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X6 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X7 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X8 23 failed, 66 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed 23 failed, 66 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 24 failed, 71 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
X9 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 21 failed, 74 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
X10 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 4 failed, 38 passed 20 failed, 69 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 21 failed, 74 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
X11 11 failed, 78 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 5 passed 42 passed 11 failed, 78 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 11 failed, 84 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite)
X12 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
X13 8 failed, 81 passed; …uploads_to_pypi (+3 in-suite) 5 passed 1 failed, 41 passed 8 failed, 81 passed; …uploads_to_pypi (+3 in-suite) 9 failed, 86 passed; …uploads_to_pypi (+4 in-suite)
C1 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
C2 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
C3 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
C4 89 passed; none 5 passed 42 passed 16 failed, 73 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 18 failed, 77 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
C5 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
C6 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
Q1 13 failed, 76 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 2 failed, 40 passed 13 failed, 76 passed; …cannot_pass_with_failing_tests (+3 in-suite) 14 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
Q2 13 failed, 76 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 2 failed, 40 passed 13 failed, 76 passed; …cannot_pass_with_failing_tests (+3 in-suite) 14 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite)
Q3 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
Q4 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
Q5 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
Q6 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
Q7 1 failed, 88 passed; none (+1 in-suite) 5 passed 42 passed 1 failed, 88 passed; none (+1 in-suite) 1 failed, 94 passed; none (+1 in-suite)
F1 5 failed, 84 passed; none (+1 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
F2 5 failed, 84 passed; none (+1 in-suite) 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 23 failed, 72 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
F3 5 failed, 84 passed; none (+1 in-suite) 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 25 failed, 70 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+6 in-suite)
F4 89 passed; none 5 passed 42 passed 2 failed, 87 passed; …uploads_to_pypi (+1 in-suite) 3 failed, 92 passed; …uploads_to_pypi (+2 in-suite)
F5 89 passed; none 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 23 failed, 72 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
F6 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
F7 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
F8 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
F9 89 passed; none 5 passed 42 passed 16 failed, 73 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 18 failed, 77 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
F10 89 passed; none 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 23 failed, 72 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
F11 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
G1 1 failed, 88 passed; none (+1 in-suite) 5 passed 42 passed 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite) 3 failed, 92 passed; …cannot_pass_with_failing_tests (+1 in-suite)
G2 1 failed, 88 passed; none (+1 in-suite) 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 23 failed, 72 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
G3 1 failed, 88 passed; none (+1 in-suite) 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 23 failed, 72 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
G4 1 failed, 88 passed; none (+1 in-suite) 5 passed 42 passed 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite) 3 failed, 92 passed; …cannot_pass_with_failing_tests (+1 in-suite)
H1 89 passed; none 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
H2 89 passed; none 5 passed 42 passed 3 failed, 86 passed; …cannot_pass_with_failing_tests (+1 in-suite) 3 failed, 92 passed; …cannot_pass_with_failing_tests (+1 in-suite)
H3 89 passed; none 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 23 failed, 72 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
H4 89 passed; none 5 passed 42 passed 21 failed, 68 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 23 failed, 72 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
F9b 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 16 failed, 73 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+4 in-suite) 18 failed, 77 passed; …uploads_to_pypi, …cannot_pass_with_failing_tests (+5 in-suite)
W1 89 passed; none 6 passed 42 passed 89 passed; none 95 passed; none
W2 89 passed; none 6 passed 42 passed 89 passed; none 95 passed; none
W3 89 passed; none 6 passed 42 passed 89 passed; none 95 passed; none
W4 89 passed; none 6 passed 42 passed 89 passed; none 95 passed; none
W5 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
W6 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
W7 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
W8 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
W9 89 passed; none 5 passed 42 passed 89 passed; none 95 passed; none
L1 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
L2 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
L3 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)
L4 4 failed, 85 passed; …uploads_to_pypi (+1 in-suite) 5 passed 42 passed 4 failed, 85 passed; …uploads_to_pypi (+1 in-suite) 4 failed, 91 passed; …uploads_to_pypi (+1 in-suite)
L5 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 8 failed, 81 passed; …cannot_pass_with_failing_tests (+3 in-suite) 8 failed, 87 passed; …cannot_pass_with_failing_tests (+3 in-suite)
L6 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 5 passed 42 passed 19 failed, 70 passed; …cannot_pass_with_failing_tests (+3 in-suite) 20 failed, 75 passed; …cannot_pass_with_failing_tests (+3 in-suite)

GNU Make 4.3 probes (make -s test):

  • test: / \ttrue / # note / \techo RAN; false prints RAN and exits 2. With # note (spaces) it does the same, and with ifeq (1,1) … endif around the failing line too.
  • .RECIPEPREFIX = > with >echo RAN; false || true exits 0.
  • define SHELL / /bin/true / endef before test: / \tfalse exits 0.
  • \tSHELL=bash true then \tfalse exits 2, so L6 is a false positive, as the body now lists.

Tests

One file per process, UV_FROZEN=1 uv run pytest -q -p no:cacheprovider <file>:

  • tests/core/test_release_tagging.py: 95 passed (89 plus 6 new);
  • tests/core/test_docs_deployment.py: 5 passed.

On tests/core/test_release_tagging.py, ruff format --check gives "1 file already formatted" and ruff check gives "All checks passed!" (ruff 0.15.5). An early uv run without UV_FROZEN rewrote uv.lock. I restored it, and it is not committed.

CI

The Pull request run 41892304633 at b788b3e062609802206028e96b35847bfedbc1a1 is completed / success, and it is the only run at that SHA. All 19 jobs are success:

  • Lint, Lint workflows and Check changelog fragment;
  • 8 Test jobs (ubuntu and windows, Python 3.11–3.14);
  • 8 "Test Core and country packages" jobs.

https://github.com/PolicyEngine/policyengine-core/actions/runs/41892304633

Judgment calls

  • Waiting out the six-hour window. The pick-up check ran at 17:50Z, which is 4h53m after the last commit. I did the work locally and pushed only to this session's own branch, claude/core-650-round-2-fjy1dp. It has no PR, holds the same commits and can be deleted. I rechecked the head (still 1bda485) at 18:57Z, then commented and pushed to the PR branch.
  • What the new tests call. The syntax test calls make_rules directly, so the message it matches doesn't depend on the guard's check order. define SHELL doesn't trip MAKE_SETTING_THAT_MASKS, since it has no =, so that case does reach make_rules.
  • Where the edits go. The .RECIPEPREFIX and define cases insert their lines before all: install, not all:, because install: also contains all: and edit_makefile asserts a single match.
  • No other code changes. The regex is the review's, including its redundant define/endef and ifeq/endif pairs.
  • Workflow tool: not used, per the brief, although ultracode was on.

Proposed follow-ups

  • Release tests: read the makefiles make reads before or instead of the root Makefile. A root GNUmakefile or makefile, MAKEFILES in a workflow env, and include/-include/sinclude all let .IGNORE: reach make test unread (F6, F7, Q3, Q4, C5). The guard could fail on those files and keys, or read them.
  • Release tests: fail on names make builds from parts. .$(I):, $(eval .$(I):), $(S) = … and $(T): pass the guard (F8, F11, Q5, W9). Failing on any $( in a rule target or an assignment name would close them loudly.

Generated by Claude Code

@MaxGhenis

MaxGhenis commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Hub review in progress

Done. The round 3 review at b788b3e062609802206028e96b35847bfedbc1a1 is #650 (comment) (REQUEST_CHANGES: one P2, one P3).


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
VERDICT: REQUEST_CHANGES
b788b3e
METHODOLOGY_CHOICE: no

I reviewed head b788b3e062609802206028e96b35847bfedbc1a1, which is still the PR head. The base is master eaef9c24858369af85093bfb5de729b48208be81. This round answers the round 2 review at 1bda485.

The two new commits apply round 2's diff exactly, and they make every text change the fix brief asked for. Every hand-off row I reran gives the hand-off's count. Master's Makefile raises no false positive, and CI is green. I found no P1.

There is one P2, of the same kind as round 2's P2 1. MAKE_SYNTAX_MAKE_RULES_SKIPS allows only spaces before a directive (^ *), but GNU make skips any whitespace there. So these forms all pass every test, while make runs the masked line or the upload, or skips test's recipe:

  • a space then a tab, or a form feed, before ifeq … endif;
  • the same before define SHELL … endef;
  • a tab-indented ifeq outside a rule context.

The fragment's "They fail if it holds a conditional (ifeq … endif), a define block …" is false for those forms. Changing ^ * to ^[ \t\f\v]* closes all six forms I found. It keeps master passing and changes no other row's result.

None of these forms exists in the repository today.

Assignment

  • git diff --stat 1bda485 b788b3e changes only changelog.d/core649-release-test-followups.changed.md (1 line) and tests/core/test_release_tagging.py (+74/−4). The two commits are 4006b6ca (parser) and b788b3e0 (fragment).

  • pulls/650/files lists the same two files against master. git diff --quiet eaef9c2 b788b3e -- uv.lock policyengine_core .github exits 0. Master is the merge base.

  • Item 1: the make_rules diff equals round 2's diff line for line (tests/core/test_release_tagging.py:408-449). The three in-suite cases the brief named are present:

    • comment-line-before-masked-line (:1034-1039);
    • test_make_rules_rejects_syntax_it_cannot_follow (:1049-1076);
    • test_pypi_upload_check_reads_past_comment_line (:1079-1085).

    The docstring (:419-430) names what the function reads and what it refuses.

  • Item 2: the fragment is the brief's text verbatim (diff against the quoted text is empty).

  • Item 3: each body edit the brief quoted is present verbatim: the two new sentences, the GNUmakefile/MAKEFILES/include bullet, the split $(MAKE) and built-names bullets, and the two fail-closed entries. The "Tests" row reads 95 passed, the mutant sentence names the F, G, H, Q, W and L rows, and the impact: line is unchanged.

  • Items 4 and 5: see Mutants and CI below.

  • What the brief asked for that the PR doesn't deliver: nothing the brief spelled out. The brief told the fix session to fix any fragment clause a mutant shows false, and the P2 below is such a clause. That isn't a fault of the fix session: round 2's diff carried the gap.

P2

1. A directive after a tab or form feed, rather than spaces, passes MAKE_SYNTAX_MAKE_RULES_SKIPS, and make still obeys it (tests/core/test_release_tagging.py:412; the claim is in changelog.d/core649-release-test-followups.changed.md:1 and in the body's first "What's changed" bullet and fail-closed entry).

What GNU make 4.3 does, from src/read.c and src/main.c at tag 4.3 of the mirror/make GitHub mirror:

  • eval() treats a line as a recipe line only if it starts with the recipe prefix and a rule is open (if (line[0] == cmd_prefix) … if (filenames != 0), read.c:670-700).
  • Every other line goes through NEXT_TOKEN, which skips any ISSPACE character (makeint.h:451). main.c:642-656 puts space, tab and every other isspace character, such as \f and \v, in that set.
  • conditional_line and the define parser run after that (read.c:722-790).
  • A variable assignment ends the open rule (/* Variable assignment ends the previous rule. */ record_waiting_files ();, read.c:739-740), which sets filenames = 0 (read.c:589-601). So a tab line after an assignment is not a recipe line, and if it reads ifeq, it is a conditional.

The manual's "a tab is not allowed" (Conditional Syntax) covers only a tab as the first character inside a rule context. A space then a tab is not a recipe line, and neither is a tab line outside a rule.

Failure scenario: someone writes this in test's recipe, with then a tab before each directive:

test: test-country-template
 	ifeq (1,1)
	coverage run -a --branch -m pytest tests … || true
 	endif
	coverage xml -i

make test passes when pytest fails, Publish runs, and all 95 tests pass. The same shape around \ttwine upload dist/* in build gets an upload past pypi_uploading_recipe_lines.

Mutants on the working tree (head's file, python -m pytest -q -p no:cacheprovider tests/core/test_release_tagging.py), with GNU Make 4.3 run on a scratch copy of the same mutated Makefile:

Mutant Head Guards that fail GNU make
T1 \tifeq (1,1) … \tendif around the pytest line + || true 6 failed, 89 passed none (2 in-suite, by accident) make -n test exits 0 and prints coverage run … --reruns-delay 5 || true
T2 \tifeq (1,1) … \tendif around \ttwine upload dist/* in build 95 passed none make -n build exits 0 and prints twine upload dist/*
T3 X = 1, \tifeq (1,0), then test's rule and recipe, \tendif, then %: / \t@: 95 passed none make -n test prints :; make -s test exits 0 without running pytest
T4 \fifeq (1,1) … \fendif around the pytest line + || true 11 failed, 84 passed none (3 in-suite, by accident) make -n test exits 0 and prints the || true line
T5 \tdefine SHELL / /bin/true / \tendef prepended 95 passed none make -s test exits 0
T6 X = 1, \tdefine SHELL, /bin/true, \vendef prepended 10 failed, 85 passed none (2 in-suite, by accident) make -s test exits 0

For each row, docs gives 5 passed and master's file 42 passed. git status --porcelain was empty after each run.

Small probes confirm the mechanism: test: / \ttrue / \tifeq (1,1) / \techo RAN; false || true / \tendif prints RAN and exits 0, and the same with \f before each directive also exits 0.

T3 and T6 need a tab line after a variable assignment, which is unusual. T1, T2 and T4 need only an indent that mixes a space and a tab, or a form feed.

Fix: one character class.

 MAKE_SYNTAX_MAKE_RULES_SKIPS = re.compile(
-    r"^ *((override|export)\s+)*(ifn?eq|ifn?def|else|endif|define|endef)\b"
+    r"^[ \t\f\v]*((override|export)\s+)*(ifn?eq|ifn?def|else|endif|define|endef)\b"
     r"|\.RECIPEPREFIX|\\$",
     re.MULTILINE,
 )

I ran this on a scratch copy of head's file:

  • master's Makefile: 95 passed; ruff format --check and ruff check pass;
  • T1 to T6 each give 23 failed, 72 passed, killed by both test_test_job_cannot_pass_with_failing_tests and test_only_push_publish_job_uploads_to_pypi;
  • every other row in the Mutants section below gives the same result as at head.

Add these in-suite parameters to test_make_rules_rejects_syntax_it_cannot_follow, after line-continuation. They give 3 failed, 95 passed on head's file, and 98 passed with the fix:

        pytest.param(
            "\tcoverage xml -i\n",
            " \tifeq (1,1)\n\tcoverage xml -i || true\n \tendif\n",
            id="conditional-after-space-and-tab",
        ),
        pytest.param(
            "all: install",
            "\fdefine SHELL\n/bin/true\n\fendef\n\nall: install",
            id="define-after-form-feed",
        ),
        pytest.param(
            "all: install",
            "X = 1\n\tifeq (1,0)\n\tendif\n\nall: install",
            id="tab-conditional-outside-rule",
        ),

With the fix, a recipe line whose first word after the tab is a directive word now fails loudly too. \telse and \tendif match, and \techo ifeq does not. Master has no such line. In the body's fail-closed sub-list, replace "- a line that ends in \, a conditional (ifeq … endif), a define block or .RECIPEPREFIX;" with:

  • a line that ends in \, a conditional (ifeq … endif), a define block or .RECIPEPREFIX, or a recipe line whose first word is one of those directives, such as else;

With the fix, the fragment and the body's first bullet hold as written.

If the hub prefers not to change the code, add this to "What the checks still can't see" and narrow the fragment's sentence to "… a conditional (ifeq … endif) or define block indented only with spaces, …":

  • A conditional or define directive indented with a tab after a space, with a form feed, or with a tab outside a rule (after a variable assignment). make obeys it, and make_rules doesn't see it.

P3

1. The PYTEST_ADDOPTS bullet doesn't name the Makefile as a source (PR body, "What the checks still can't see", the PYTEST_ADDOPTS bullet).

export PYTEST_ADDOPTS = --collect-only at the top of the root Makefile passes all 95 tests (docs 5 passed, master's file 42 passed). make exports it to pytest's environment. A probe with a failing test_t.py and test: / \tpython -m pytest -q test_t.py prints 1 test collected and exits 0. Without the export line, the same probe exits 2. The bullet names $GITHUB_ENV, pyproject.toml, pytest.ini and conftest.py, so a reader could take the Makefile as covered, because it is the file these checks read. Replacement:

  • PYTEST_ADDOPTS or another option that comes from elsewhere: an earlier step that writes it to $GITHUB_ENV, an export of it in the root Makefile, pyproject.toml, pytest.ini or conftest.py, or a different variable.

The alternative is to add PYTEST_ADDOPTS to MAKE_SETTING_THAT_MASKS. Master's Makefile doesn't mention it.

Mutants

Each row was a scripted text edit on the working tree. Three pytest processes ran for each row: head's test_release_tagging.py, test_docs_deployment.py, and master's test_release_tagging.py copied into place. After each row the script ran git checkout -q -- .github Makefile tests && git clean -fdq .github, removed extra.mk, GNUmakefile, makefile and pubaction/, and asserted that git status --porcelain was empty. It was empty every time.

I rebuilt the rows from the hand-off's and round 2's one-line descriptions, so my edits may differ in detail from theirs. Where they differ, the count can differ. Every row below matches the hand-off's "This head" column.

Mutant Head b788b3e Guards that fail (+ other test functions) docs Master file
unmodified 95 passed none 5 passed 42 passed
F1 # …, then pytest || true 8 failed, 87 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
F2 ifeq around pytest || true 23 failed, 72 passed …uploads_to_pypi, …cannot_pass_with_failing_tests (+5) 5 passed 42 passed
F3 .RECIPEPREFIX = >, > lines 25 failed, 70 passed both (+6) 5 passed 42 passed
F4 build: # upload, \ttwine upload dist/* 3 failed, 92 passed …uploads_to_pypi (+2) 5 passed 42 passed
F5 define SHELL / /bin/true / endef 23 failed, 72 passed both (+5) 5 passed 42 passed
F6 MAKEFILES: extra.mk in Test step env, .IGNORE: in it 95 passed none 5 passed 42 passed
F7 root GNUmakefile: include Makefile, .IGNORE: 95 passed none 5 passed 42 passed
F8 S = SHELL, $(S) = /bin/true 95 passed none 5 passed 42 passed
F9 pytest \, next line || true (no tab) 18 failed, 77 passed both (+5) 5 passed 42 passed
F9b pytest \, next line tab + || true 18 failed, 77 passed both (+5) 5 passed 42 passed
F10 build: ifeq around twine upload 23 failed, 72 passed both (+5) 5 passed 42 passed
F11 I = IGNORE, .$(I): 95 passed none 5 passed 42 passed
G1 template: # …, || true 3 failed, 92 passed …cannot_pass_with_failing_tests (+1) 5 passed 42 passed
G2 template: ifeq around || true 23 failed, 72 passed both (+5) 5 passed 42 passed
G3 template: .RECIPEPREFIX = > 23 failed, 72 passed both (+5) 5 passed 42 passed
G4 template: # …, || true 3 failed, 92 passed …cannot_pass_with_failing_tests (+1) 5 passed 42 passed
H1 test: @echo, # …, pytest || true 8 failed, 87 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
H2 template: @echo, # …, || true 3 failed, 92 passed …cannot_pass_with_failing_tests (+1) 5 passed 42 passed
H3 template: @echo, ifeq, || true 23 failed, 72 passed both (+5) 5 passed 42 passed
H4 template: @echo, mid-recipe .RECIPEPREFIX 23 failed, 72 passed both (+5) 5 passed 42 passed
C4 pytest line ; \, then \ttrue 18 failed, 77 passed both (+5) 5 passed 42 passed
Q1 @- on the pytest line 14 failed, 81 passed …cannot_pass_with_failing_tests (+3) 5 passed 2 failed, 40 passed
Q7 cycle test: … a, a: b, b: a 1 failed, 94 passed none (+1) 5 passed 42 passed
L1 echo 'a || b' in test 8 failed, 87 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
L4 two .PHONY: lines 4 failed, 91 passed …uploads_to_pypi (+1) 5 passed 42 passed
L6 recipe line SHELL=bash true 20 failed, 75 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
T1–T6 (P2 1) see P2 1 none 5 passed 42 passed

"Both" means …uploads_to_pypi and …cannot_pass_with_failing_tests.

The first time I ran it, my script put C4 on the coverage xml -i line and gave 25 failed, 70 passed. On the pytest line it gives the hand-off's 18 failed, 77 passed. My first Q7 and L4 runs appended to the last line of the Makefile, which has no final newline. With a newline they give the hand-off's counts.

Checked and sound

  • Tests, one file per process (python -m pytest -q -p no:cacheprovider <file>, Python 3.14.6, in a uv venv with uv pip install -e ".[dev]"; uv was already installed):

    • head test_release_tagging.py: 95 passed;
    • head test_docs_deployment.py: 5 passed;
    • master's test_release_tagging.py in place: 42 passed.

    ruff format --check gives "1 file already formatted" and ruff check gives "All checks passed!" (ruff 0.17.3).

  • The parser against the GNU make 4.3 manual (doc/make.texi at tag 4.3 of the mirror/make mirror; gnu.org is blocked here), with probes on GNU Make 4.3:

    • Conditionals. The pattern matches ifeq, ifneq, ifdef, ifndef, else and endif after leading spaces, and else ifeq (a,b) on one line (through else). Probes: a space-indented ifeq inside a recipe, and an else ifeq true branch, each run the line inside (RAN, exit 2 on false). A tab-indented ifeq inside a rule is a recipe line, as the manual says ("will be considered part of a recipe and be passed to the shell"). The probe passes it to /bin/sh, which fails on it. P2 1 covers the other whitespace.

    • define. The pattern matches define SHELL, define SHELL =, override define, export define, export override define and override export define. Each of those makes test: false exit 0. It misses private define SHELL, but that probe exits 2 (make: -c: No such file or directory), and its endef matches anyway.

    • .RECIPEPREFIX. It matches any mention. Probes: =, :=, ::= and += each make >-lines recipe lines (exit 0 with || true). ?= and != echo > give a make error (exit 2).

    • Trailing \. The manual: outside recipes, "backslash/newlines are converted into a single space"; in a recipe, they are "not removed … If the first character of the next line … is the recipe prefix character … then that character … is removed". Probes:

      • a rule line test: \ + dep runs dep;
      • a variable X = a \ + b gives a b;
      • a recipe line echo RAN; false \ + \t|| true exits 0;
      • a comment line # note \ swallows the next recipe line (exit 0, no RAN).

      \\$ matches all four.

    • The comment-only-line skip. It runs only on lines that don't start with a tab, because the tab branch comes first (:439-447). That matches make: the manual says "Blank lines and lines of just comments may appear among the recipe lines; they are ignored", and read.c:743-746 skips a line that is empty after remove_comments. A tab line that starts with # is a recipe line: the manual says it "is not a make comment; it will be passed to the shell as-is". make -n prints it. make_rules appends it to the recipe, so the guard reads it as make does. That is why \t# a || b fails loudly, as the body's fail-closed list says.

    • A variable assignment between recipe lines. It ends the recipe in make_rules. In make, test: / \ttrue / X = 1 / \techo RAN; false stops with recipe commences before first target (exit 2). So nothing runs unread.

  • False positives on master. The new pattern finds nothing in master's Makefile, and head's checks pass on it (95 passed). Probes:

    • \techo ifeq in test's recipe: 95 passed. It should pass, and it does.
    • \tcoverage xml \ + \t\t-i in test:: 25 failed, 70 passed, killed by both guards. This is a loud false positive, and the body's fail-closed list names it.
    • A comment line # define the build before build:: 95 passed. It should pass, and it does.
  • Redundant alternatives. I tested the hand-off's claim with code mutants of the pattern on head's file:

    • dropping define alone: 95 passed;
    • dropping ifeq alone (ifneq kept): 95 passed.

    In both, F2 and F5 still give 23 failed, killed by both guards, through endif or endef. Dropping define and endef together gives 1 failed (define) and lets F5 through. Dropping ifeq and endif together fails 3 in-suite tests and lets F2 past the guards.

    The redundancy doesn't matter today. make refuses an unterminated block: ifeq (1,1) with no endif gives *** missing 'endif', and define X with no endef gives *** missing 'endef', unterminated 'define', both exit 2. So a block can't drop its closer to dodge the check. It does mean no test pins define or ifeq on its own.

  • Fragment, clause by clause. Each clause has a mutant that fails head:

    • .IGNORE, .ONESHELL, GNUMAKEFLAGS += -i: 20 failed each;
    • SHELL != echo /bin/true: 20 failed. test: .SHELLFLAGS := -c true: 24 failed;
    • conditional, define, .RECIPEPREFIX, \: F2, F5, F3, F9;
    • one rule line: build:: (9 failed) and L4;
    • recipe forms: || true (F1, H1), ; exit 0 on the template line (3 failed), ; : (11 failed), and an order-only prerequisite at depth 2 with false; true (5 failed);
    • reading past comment-only lines: F1, G1, G4, H1, H2 and F4;
    • MAKEFLAGS in the workflow env and in the Test step env: 2 failed each;
    • credentials: environment: {name: PyPI} on Docs, and permissions: write-all on pr.yaml: 7 failed each;
    • locator: run: make check fails with the expected one test (a step whose run starts with the words … message (:684-690).

    The fragment leaves out the - prefix and PYTEST_ADDOPTS checks, but master already has both (master:586-587, :570-573), so the fragment rightly doesn't call them new. Apart from P2 1, I found no clause the checks miss and no new check the fragment leaves out.

  • Body "can't see" list. Each Makefile and environment form still passes every guard test at head. Each gives 95 passed, docs 5 and master 42, except where noted:

    • root GNUmakefile (F7), root makefile (include Makefile + .IGNORE:; make exits 0), MAKEFILES in env (F6);
    • include, -include and sinclude of an extra.mk holding .IGNORE:;
    • $(MAKE) -i test-country-template as test's first recipe line (7 failed, all in-suite, and 1 failed in master's file; a $(MAKE) -s -i sub probe with sub: false exits 0);
    • $(T): with T = test (W9), .$(I): (F11), $(eval .$(I):) (Q5), $(S) = /bin/true (F8);
    • GNUMAKEFLAGS: -i in the Test step env (Q6), MAKEFLAGS=-i written to $GITHUB_ENV (W7);
    • if …; then :; fi, | tee log, ; true # note on the pytest line.

    Each fail-closed entry fails head:

    • echo 'a || b' (L1);
    • echo $(MAKEFLAGS) (L2) and a comment that mentions .IGNORE (L3), 20 failed each;
    • two .PHONY: lines (L4);
    • a trailing \ (C4 and the -i probe above), and a harmless ifeq ($(OS),Windows_NT) … endif (23 failed);
    • SHELL=bash true (L6);
    • test: … pyproject.toml (L5, 8 failed).
  • The two proposed follow-ups. Both describe real gaps at head:

    • Other makefiles: F6, F7, the lower-case makefile, and include, -include and sinclude all pass all 95 tests, and make obeys .IGNORE: in each.
    • Built names: F8, F11, Q5 and W9 pass all 95 tests.

    For the second follow-up, a check for $( in a rule target or assignment name would see Q5's line as a rule line, because ANY_MAKE_RULE reads $(eval .$(I) as its target. Neither follow-up covers P2 1's forms, so P2 1 is a separate fix.

  • CI. Pull request run 41892304633 at b788b3e062609802206028e96b35847bfedbc1a1 is completed / success, and it is the only run at that SHA. All 19 jobs are success: Lint, Lint workflows, Check changelog fragment, 8 Test jobs (ubuntu and windows, Python 3.11–3.14) and 8 "Test Core and country packages" jobs. None was cancelled or skipped. The PR's mergeable_state is clean.

What I couldn't check

  • docs.github.com and www.gnu.org: the proxy refuses both (CONNECT tunnel failed, response 403). I read the GNU make manual and source from tag 4.3 of the mirror/make GitHub mirror, and I quote from those. I quote nothing from GitHub's docs, so how $GITHUB_ENV reaches later steps still rests on the body's wording.
  • Which GNU make version ubuntu-latest and windows-latest run. My probes use GNU Make 4.3.
  • The exact edits behind the hand-off's rows. I rebuilt them from one-line descriptions, so a row of mine can differ from theirs in detail even where the counts match.
  • PyPI's trusted-publisher configuration, which the credentials check relies on.

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

GNU make skips any whitespace before a conditional or define, not only
spaces, so a directive after a space and a tab, after a form feed, or after
a tab outside a rule passed every check while make obeyed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6GrDhQfRQyGWsSRqqw
…file

make exports a variable named in an export line to the recipes it runs, and
pytest reads options from PYTEST_ADDOPTS, so export PYTEST_ADDOPTS =
--collect-only let make test pass without running a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6GrDhQfRQyGWsSRqqw
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
efdb3cd

This answers the round 3 review (#650 (comment)): P2 1 is fixed in code, and P3 1 is closed in code rather than in the body. There are three new commits on b788b3e: d040c08 (directive pattern), 3937cf2 (PYTEST_ADDOPTS) and efdb3cd (fragment). git diff --stat b788b3e efdb3cd touches only tests/core/test_release_tagging.py (+25/−5) and the fragment (1 line). git diff --quiet origin/master efdb3cd -- uv.lock policyengine_core .github exits 0.

Items

  1. Directive pattern (P2 1).
    • MAKE_SYNTAX_MAKE_RULES_SKIPS now opens with ^[ \t\f\v]* (tests/core/test_release_tagging.py:412), as in the review's diff.
    • test_make_rules_rejects_syntax_it_cannot_follow has the review's three parameters, with its old/new strings, after line-continuation: conditional-after-space-and-tab (:1076-1080), define-after-form-feed (:1081-1085) and tab-conditional-outside-rule (:1086-1090).
    • Master's Makefile (identical to head's) passes: 100 passed. T1 to T6 each now fail head's file (see Mutants).
  2. PYTEST_ADDOPTS in the Makefile (P3 1).
    • MAKE_SETTING_THAT_MASKS gains |PYTEST_ADDOPTS (:737-739).
    • The brief's four comment lines follow "(test: SHELL = /bin/true)." (:731-734). The sentence about reading MAKEFLAGS now follows them on its own lines (:735-736), with its wording unchanged.
    • test_make_test_check_rejects_setting_that_masks gains "export PYTEST_ADDOPTS = --collect-only" and "test: export PYTEST_ADDOPTS = --collect-only" (:970-971).
    • git show origin/master:Makefile | grep -c PYTEST_ADDOPTS gives 0.
    • The review's probe as mutant PA1 (export PYTEST_ADDOPTS = --collect-only at the top of the root Makefile) fails test_test_job_cannot_pass_with_failing_tests at head. It gave 95 passed at b788b3e.
    • Probe on GNU Make 4.3 with a failing test_t.py: without the export line make test exits 2; with it, it prints 1 test collected and exits 0.
  3. Fragment. The brief's replacement is made verbatim (changelog.d/core649-release-test-followups.changed.md:1). No mutant showed another clause false at this head, so the rest is unchanged. With item 1, the "conditional … define block" clause now holds for T1 to T6.
  4. Body. I PATCHed it and read the whole body back. It matches what I sent, apart from the trailing newline GitHub drops.
    • The fail-closed entry is replaced with the brief's text.
    • PYTEST_ADDOPTS is added to the "any mention of .IGNORE, .ONESHELL, MAKEFLAGS …" list in the first "What's changed" bullet. That is the only place the body lists those three names together.
    • The "Tests" row now reads 100 passed.
    • The mutant-family sentence now ends: "From the round 3 review, T1–T6 cover a conditional or define after a space and a tab, after a form feed, or after a tab outside a rule, and PA1–PA2 an export PYTEST_ADDOPTS = --collect-only line, global and target-specific."
    • The PYTEST_ADDOPTS bullet in "What the checks still can't see" and the impact: line are unchanged.
    • Judgment call: the fail-closed entry "a comment that mentions .IGNORE" now reads "… .IGNORE or PYTEST_ADDOPTS", because a Makefile comment naming PYTEST_ADDOPTS is now a loud false positive.
    • I found no other sentence made false.
  5. Mutants. See below.
  6. Runs. See Tests and CI below.

Tests

One file per process, uv run pytest -q -p no:cacheprovider <file> (Python from uv sync --all-extras; uv.lock restored after sync):

  • tests/core/test_release_tagging.py: 100 passed. That is 95 + 3 + 2, and 98 at d040c08 alone.
  • tests/core/test_docs_deployment.py: 5 passed.

ruff format --check tests/core/test_release_tagging.py gives "1 file already formatted", and ruff check gives "All checks passed!" (ruff 0.15.5 from the lock).

Mutants

Each row was a scripted text edit, rebuilt from the review's one-line descriptions, applied separately for each of four pytest processes:

  • head's test_release_tagging.py;
  • b788b3e's test_release_tagging.py, copied into place;
  • test_docs_deployment.py;
  • master's test_release_tagging.py, copied into place.

After each process the script ran git checkout -q -- .github Makefile tests, git clean -fdq .github and removed extra.mk, GNUmakefile and makefile. It then asserted that git status --porcelain was empty, which it was every time. Running b788b3e's file on the same edit compares like with like, so a difference between my edit and the review's can't masquerade as a change.

"Both" means test_only_push_publish_job_uploads_to_pypi and test_test_job_cannot_pass_with_failing_tests.

Row head efdb3cd b788b3e guards failing at head docs master file
unmodified 100 passed 95 passed none 5 passed 42 passed
F1 8 failed, 92 passed 8 failed, 87 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
F2 23 failed, 77 passed 23 failed, 72 passed both (+5) 5 passed 42 passed
F3 26 failed, 74 passed 25 failed, 70 passed both (+6) 5 passed 42 passed
F4 3 failed, 97 passed 3 failed, 92 passed …uploads_to_pypi (+2) 5 passed 42 passed
F5 23 failed, 77 passed 23 failed, 72 passed both (+5) 5 passed 42 passed
F6 100 passed 95 passed none 5 passed 42 passed
F7 100 passed 95 passed none 5 passed 42 passed
F8 100 passed 95 passed none 5 passed 42 passed
F9 18 failed, 82 passed 18 failed, 77 passed both (+5) 5 passed 42 passed
F9b 18 failed, 82 passed 18 failed, 77 passed both (+5) 5 passed 42 passed
F10 23 failed, 77 passed 23 failed, 72 passed both (+5) 5 passed 42 passed
F11 100 passed 95 passed none 5 passed 42 passed
G1 3 failed, 97 passed 3 failed, 92 passed …cannot_pass_with_failing_tests (+1) 5 passed 42 passed
G2 23 failed, 77 passed 23 failed, 72 passed both (+5) 5 passed 42 passed
G3 22 failed, 78 passed 22 failed, 73 passed both (+5) 5 passed 42 passed
G4 3 failed, 97 passed 3 failed, 92 passed …cannot_pass_with_failing_tests (+1) 5 passed 42 passed
H1 8 failed, 92 passed 8 failed, 87 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
H2 3 failed, 97 passed 3 failed, 92 passed …cannot_pass_with_failing_tests (+1) 5 passed 42 passed
H3 23 failed, 77 passed 23 failed, 72 passed both (+5) 5 passed 42 passed
H4 23 failed, 77 passed 23 failed, 72 passed both (+5) 5 passed 42 passed
C4 18 failed, 82 passed 18 failed, 77 passed both (+5) 5 passed 42 passed
Q1 14 failed, 86 passed 14 failed, 81 passed …cannot_pass_with_failing_tests (+3) 5 passed 2 failed, 40 passed
Q7 1 failed, 99 passed 1 failed, 94 passed none (+1) 5 passed 42 passed
L1 8 failed, 92 passed 8 failed, 87 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
L4 4 failed, 96 passed 4 failed, 91 passed …uploads_to_pypi (+1) 5 passed 42 passed
L6 20 failed, 80 passed 20 failed, 75 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
T1 \tifeq… \tendif around pytest || true 23 failed, 77 passed 6 failed, 89 passed both (+5) 5 passed 42 passed
T2 \tifeq… \tendif around \ttwine upload in build 23 failed, 77 passed 95 passed both (+5) 5 passed 42 passed
T3 X = 1, \tifeq (1,0), test rule, \tendif, %:/\t@: 23 failed, 77 passed 95 passed both (+5) 5 passed 42 passed
T4 \fifeq…\fendif around pytest || true 23 failed, 77 passed 11 failed, 84 passed both (+5) 5 passed 42 passed
T5 \tdefine SHELL//bin/true/ \tendef 23 failed, 77 passed 95 passed both (+5) 5 passed 42 passed
T6 X = 1, \tdefine SHELL, /bin/true, \vendef 23 failed, 77 passed 10 failed, 85 passed both (+5) 5 passed 42 passed
PA1 export PYTEST_ADDOPTS = --collect-only prepended 20 failed, 80 passed 95 passed …cannot_pass_with_failing_tests (+3) 5 passed 42 passed
PA2 test: export PYTEST_ADDOPTS = --collect-only before test: 24 failed, 76 passed 18 failed, 77 passed both (+4) 5 passed 42 passed

What kills each row:

  • T rows. Each T row is killed by make_rules's MAKE_SYNTAX_MAKE_RULES_SKIPS assertion, reached through both guards. At b788b3e no guard fails on any of them, which matches the review's counts (6/95/95/11/95/10 failed).
  • PA1. Killed by MAKE_SETTING_THAT_MASKS through test_test_job_cannot_pass_with_failing_tests.
  • PA2. At b788b3e it already fails, because the extra test: rule line breaks the one-rule-line check. At head the mention check adds 6 in-suite failures, and the guard set is unchanged.

GNU Make 4.3 on scratch copies of each mutated Makefile:

  • T1 and T4: make -n test exits 0 and prints the … --reruns-delay 5 || true line.
  • T2: make -n build exits 0 and prints twine upload dist/*.
  • T3, T5 and T6: make -s test exits 0 with no output.

Rows outside T1 to T6 each fail the same test functions at head as at b788b3e, with 5 more passes. The one exception is F3: 26 failed at head against 25. F3 rewrites \tcoverage xml -i as a > line, so the new conditional-after-space-and-tab parameter's old string is gone, and edit_makefile's count(old) == 1 assertion fails. The existing conditional and line-continuation parameters already fail on the same anchor at b788b3e. This is in-suite only; the guards are unchanged.

G3 gives 22 failed here against the review's 23 because my rebuild differs in detail. It gives the same 22 at both heads. All other non-T rows match the review's counts plus 5.

CI

The Pull request run at efdb3cd, https://github.com/PolicyEngine/policyengine-core/actions/runs/41924244227, is completed / success. All 19 jobs are success:

  • Check changelog fragment, Lint and Lint workflows;
  • 8 Test jobs (ubuntu and windows, Python 3.11–3.14);
  • 8 "Test Core and country packages" jobs.

None was cancelled or skipped.

Judgment calls

  • Ownership check. The last commit was 17:52Z, about 4h20m before pick-up, which is inside the 6-hour window. Round 2's session made that push and handed off at 19:19Z, round 3 reviewed this exact head at 20:12Z, and the hub filed this item at that head. So I took the PR as idle and proceeded.
  • Comment placement. The brief's comment block went after "(test: SHELL = /bin/true).", so the existing MAKEFLAGS-reading sentence moved to the following lines with its wording unchanged.
  • make_rules docstring. It still lists "a conditional, a define block, .RECIPEPREFIX or a line that ends in a backslash". It doesn't say that a tab recipe line opening with a directive word now also fails. The statement is incomplete rather than false, and the brief didn't ask for it, so I left it.
  • Extra fail-closed mention. I added PYTEST_ADDOPTS to the body's fail-closed "a comment that mentions" entry (see item 4).
  • Branches pushed. I also pushed the same commits to this cloud session's own branch, claude/release-tests-whitespace-pytest-7xyh4j, which has no PR.

What I couldn't check

  • I didn't rerun the review's separate clause-by-clause fragment probes beyond the table rows.
  • Which make version the CI runners use. My probes use GNU Make 4.3.

Proposed follow-up

  • Name directive-word recipe lines in make_rules's docstring and assertion message. Since the whitespace fix, a tab recipe line that opens with else, endif or define fails make_rules. Its docstring and message name only conditionals, define, .RECIPEPREFIX and \.

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Hub review in progress

Round 4 review of head efdb3cd468be8011521e8d4092c4796d74156ff1. Interim findings so far, which may change:

  • At head, tests/core/test_release_tagging.py gives 100 passed, test_docs_deployment.py gives 5 passed, and master's test_release_tagging.py gives 42 passed. ruff format --check and ruff check pass.
  • Since b788b3e, only the test file and the fragment change. Nothing under policyengine_core/, .github/ or uv.lock differs from master.
  • I reran T1 to T6, PA1, PA2, F1, F3, F5, G3, L6 and Q1 and got the hand-off's counts. Each T row is killed by both guards.
  • Likely P2: make_rules splits lines with read_text() and splitlines(). Those also break a line at \f, \v, a lone \r, \x1c-\x1e, \x85, and , but make breaks a line only at \n. So the recipe line coverage run … $(if ,<form feed>)|| true passes both guards (99 passed and 1 in-suite failure). make -n test prints … --reruns-delay 5 || true. The same split hides python -m build $(if ,<form feed>)&& twine upload dist/* from the upload scan. I'm testing a fix on a scratch copy.

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
VERDICT: REQUEST_CHANGES
efdb3cd
METHODOLOGY_CHOICE: no

I reviewed head efdb3cd468be8011521e8d4092c4796d74156ff1, which is still the PR head. The base is master eaef9c24858369af85093bfb5de729b48208be81. This round answers the round 3 review at b788b3e.

The three new commits do what the fix brief asked. The directive pattern now kills T1 to T6 through both guards, and PYTEST_ADDOPTS in the Makefile now fails the check. Every row I reran gives the hand-off's count, master's Makefile raises no false positive, and CI is green. I found no P1.

I found one P2 in the same family as round 3's P2: Python and make split the Makefile into lines at different places. make_rules reads lines with read_text() and splitlines(). Those also break a line at a form feed, a vertical tab, a lone \r, \x1c to \x1e, \x85, and . GNU make breaks a line only at \n. So a recipe line that holds one of those characters reaches make_rules as two lines, and a || true or a twine upload after the break goes unread. The fragment's "fail if a recipe line … contains ||" is false for that form. The gap has been in the PR since make_rules arrived, so this round's commits didn't cause it. No such form is in the repository today.

Assignment

  • git diff --stat b788b3e efdb3cd touches only tests/core/test_release_tagging.py (+25/−5) and changelog.d/core649-release-test-followups.changed.md (1 line). The commits are d040c080, 3937cf23 and efdb3cd4.
  • pulls/650/files lists the same two files against master. git diff --quiet origin/master efdb3cd -- uv.lock policyengine_core .github exits 0, and master is the merge base.
  • Item 1: :412 reads ^[ \t\f\v]*, as the review's diff showed. The three parameters sit after line-continuation with the review's exact old and new strings (:1077-1091).
  • Item 2: |PYTEST_ADDOPTS is in MAKE_SETTING_THAT_MASKS (:737-739). The brief's four comment lines follow "(test: SHELL = /bin/true)." (:731-734), and the two setting parameters are at :970-971. git show origin/master:Makefile | grep -c PYTEST_ADDOPTS gives 0.
  • Item 3: the fragment's first sentence is the brief's replacement, verbatim. The rest of the fragment is unchanged.
  • Item 4: the body carries the brief's fail-closed entry and PYTEST_ADDOPTS in the first bullet's list. The "Tests" row reads 100 passed, the mutant sentence names T1–T6 and PA1–PA2, and the impact: line and the PYTEST_ADDOPTS "can't see" bullet are unchanged. The hand-off also added PYTEST_ADDOPTS to the fail-closed "a comment that mentions" entry. That entry is true: the check fails a comment line # PYTEST_ADDOPTS is set in CI with "the Makefile can ignore recipe failures".
  • Items 5 and 6: see Mutants and CI below.
  • What the brief asked for that the PR doesn't do: nothing it spelled out. The brief told the fix session to fix any fragment clause that a mutant shows false. P2 1 below shows the || clause false, but none of the brief's mutants covered that form.

P2

1. A line break that make doesn't see hides a recipe line's || true or upload from make_rules (tests/core/test_release_tagging.py:431 text = makefile.read_text() and :438 for line in text.splitlines():; the claims are in the fragment's "contains ||" sentence and in the body's first and second "What's changed" bullets).

Python's str.splitlines() breaks lines at \r, \v, \f, \x1c, \x1d, \x1e, \x85, and as well as \n. Path.read_text() also turns a lone \r into \n. GNU make ends a Makefile line only at \n, and the make function $(if ,X) expands to nothing. So the shell never sees the extra character.

Failure scenario: someone writes test's pytest line as

	coverage run -a --branch -m pytest tests --disable-pytest-warnings --reruns 2 --reruns-delay 5 $(if ,<form feed>)|| true

make_rules reads … --reruns-delay 5 $(if , as the recipe line and )|| true as a separate non-recipe line, so MASKED_RECIPE_LINE never sees the ||. make runs … --reruns-delay 5 || true, make test passes when the tests fail, and Publish runs.

Commands: scripted edits to the root Makefile, python -m pytest -q -p no:cacheprovider tests/core/test_release_tagging.py, with git status --porcelain empty after each run. GNU Make 4.3 ran on a scratch copy of each mutated Makefile.

Mutant head efdb3cd Guards that fail GNU make
pytest line + $(if ,\f)|| true 1 failed, 99 passed none (1 in-suite, comment-line-before-masked-line, because its anchor is gone) make -n test prints … --reruns-delay 5 || true
the same with `
` 1 failed, 99 passed none make -n test prints … --reruns-delay 5 || true
the same with \x1c 1 failed, 99 passed none make -n test on echo RAN; false $(if ,\x1c)|| true prints echo RAN; false || true
the same with a lone \r 1 failed, 99 passed none make -s test with echo RAN; false $(if ,\r)|| true exits 0
\tpython -m build $(if ,\f)&& twine upload dist/* 5 failed, 95 passed none (5 in-suite) make -n build prints python -m build && twine upload dist/*

For the \f, \x1c and upload rows, test_docs_deployment.py gives 5 passed, and master's test_release_tagging.py gives 42 passed (41 passed and 1 failed for the upload row). I didn't run those two files on the \u2028 and \r rows. An end-to-end probe has a failing test_t.py and the recipe line python -m pytest -q test_t.py $(if ,\f)|| true, and make test exits 0. Without the || true it exits 2.

Fix: refuse those characters before make_rules reads any lines. Read the raw bytes, so a lone \r stays visible, and allow \r\n, because the Windows test jobs may check the Makefile out with CRLF line endings (the repository has no .gitattributes):

@@ -414,6 +414,13 @@ MAKE_SYNTAX_MAKE_RULES_SKIPS = re.compile(
     re.MULTILINE,
 )
 
+# Python ends a line at \r, \v, \f, \x1c-\x1e, \x85, 
 and 
 too
+# (read_text turns a lone \r into \n, and splitlines splits at the rest), but
+# make ends one only at \n. So a recipe line such as
+# `cmd $(if ,<form feed>)|| true` would reach make_rules as two lines, and its
+# `|| true` or upload would go unread.
+MAKE_LINE_BREAK_MAKE_IGNORES = re.compile(r"\r(?!\n)|[\v\f\x1c-\x1e\x85

]")
+
 
 def make_rules(makefile: Path) -> dict[str, tuple[list[str], list[str]]]:
@@ (in make_rules, before `text = makefile.read_text()`)
+    assert not MAKE_LINE_BREAK_MAKE_IGNORES.search(makefile.read_bytes().decode()), (
+        "make_rules cannot read a line break other than \\n or \\r\\n in the Makefile"
+    )
     text = makefile.read_text()

Add these parameters to test_make_rules_rejects_syntax_it_cannot_follow, after tab-conditional-outside-rule. On head's unfixed file they give 2 failed, 100 passed:

        pytest.param(
            "\tcoverage xml -i\n",
            "\tcoverage xml -i $(if ,\f)|| true\n",
            id="form-feed-inside-recipe-line",
        ),
        pytest.param(
            "\tcoverage xml -i\n",
            "\tcoverage xml -i $(if ,\r)|| true\n",
            id="carriage-return-inside-recipe-line",
        ),

I ran the fix, with the docstring and message from P3 2, on a scratch copy of head's file:

  • master's Makefile: 102 passed. ruff format --check gives "1 file already formatted", and ruff check gives "All checks passed!".
  • The whole Makefile rewritten with CRLF line endings: 102 passed, as head gives 100.
  • Each of the five rows above, plus test: test-country-template $(if ,\f)masked with masked: / \tfalse || true, fails both guards.
  • T1 to T6, PA1, PA2, F1, F5, G3, L6, Q1 and CRLF-continuation give the same failures as at head, with 2 more passes. F3 gives 2 more in-suite failures, because the two new parameters anchor on \tcoverage xml -i\n, which F3 rewrites, as conditional-after-space-and-tab already does.

Then:

  • Fragment: replace "a define block, .RECIPEPREFIX or a line that ends in \, which their recipe parser can't follow." with "a define block, .RECIPEPREFIX, a line that ends in \ or a line break other than a newline (such as a form feed), which their recipe parser can't follow."
  • Body, fail-closed sub-list: add "- a form feed, vertical tab or lone carriage return anywhere in the file, even where make reads it as plain whitespace;". In the first "What's changed" bullet, replace "It fails, naming the syntax, on a conditional, a define block, .RECIPEPREFIX or a line that ends in \," with "It fails, naming the syntax, on a conditional, a define block, .RECIPEPREFIX, a line that ends in \ or a line break other than a newline,". Update the "Tests" count to 102.

With this check in place, \f and \v in MAKE_SYNTAX_MAKE_RULES_SKIPS's class can no longer be reached, because the new check refuses those characters first. They do no harm, and you can keep them.

P3

1. The body's PYTEST_ADDOPTS bullet leaves out the route pytest reads first, and one of its routes doesn't work today (PR body, "What the checks still can't see").

I checked each route against pytest 9.2.0's installed source, _pytest/config/findpaths.py (CONFIG_LOADERS_BY_NAME). It reads pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini and setup.cfg, in that order, and uses the first that holds pytest configuration. _pytest/config/__init__.py:1695-1700 puts shlex.split(os.environ["PYTEST_ADDOPTS"]) in front of the arguments. I ran probes in a scratch directory with a copy of the repository's pytest.ini and a failing test:

  • addopts = --collect-only in pyproject.toml, tox.ini, setup.cfg or .pytest.ini: exit 1. The repository's pytest.ini wins, so these routes don't work today.
  • The same in pytest.toml or .pytest.toml: exit 0. A root pytest.toml in the repository makes the release-test file itself print "100 tests collected" and exit 0.
  • In pytest.ini: exit 0. A conftest.py with pytest_sessionfinish setting session.exitstatus = 0: exit 0.
  • A step echo PYTEST_ADDOPTS=--collect-only >> $GITHUB_ENV before "Run tests" in push.yaml: head gives 100 passed, and docs 5 passed.
  • P = PYTEST_ADD and export $(P)OPTS = --collect-only in the root Makefile: head gives 100 passed. With a failing test, make test prints 1 test collected and exits 0. The body's built-names bullet covers this form in general, but its examples are all SHELL and .IGNORE.

Replacement:

  • PYTEST_ADDOPTS or another option that comes from elsewhere: an earlier step that writes it to $GITHUB_ENV, a name the Makefile builds from parts (export $(P)OPTS with P = PYTEST_ADD), addopts in pytest.toml, .pytest.toml or pytest.ini (or in pyproject.toml, tox.ini or setup.cfg if pytest.ini goes), conftest.py, or a different variable.

2. make_rules's docstring and its assertion message don't name the directive-word recipe line that head now refuses (tests/core/test_release_tagging.py:424-426 and :433-434).

The hand-off's account is right. The docstring names "a conditional, a define block, .RECIPEPREFIX or a line that ends in a backslash", and the message names the same four. At head, \telse in test's recipe fails with "make_rules cannot read a conditional, define block, .RECIPEPREFIX or l…", while make -n test passes else to the shell. The PR introduces this behaviour, so the PR should describe it. The test matches only make_rules cannot read, so the new text keeps the tests passing (102 passed with P2 1's fix). Replacement, which includes P2 1's line breaks:

    other line ends the recipe. It fails on a conditional, a define block,
    .RECIPEPREFIX, a line that ends in a backslash or a line break other than
    a newline, since recipe lines after them could run unread. It also fails
    on a recipe line that opens with a directive word, such as `else`, which
    it can't tell from a directive. It misreads a double-colon, indented or
    repeated rule line and a `;` recipe on the rule line, which
        "make_rules cannot read a conditional, define block, .RECIPEPREFIX, "
        "line continuation or recipe line that opens with a directive word in "
        "the Makefile"

3. No test pins \v in the directive class. If the class is changed from [ \t\f\v] to [ \t\f], all 100 tests still pass at head (I ran it), because only define-after-form-feed covers a character beyond [ \t]. Changing it to [ \t] fails only that parameter. With P2 1's check, a \v anywhere in the file fails anyway, so no extra parameter is needed if P2 1 lands. Otherwise, add "X = 1\n\vifeq (1,0)\n\vendif\n\nall: install" against "all: install".

Mutants

Each row was a scripted edit to the root Makefile, run in its own processes on four files: head's test_release_tagging.py, b788b3e's copied into place, test_docs_deployment.py, and master's test_release_tagging.py copied into place. After each process the script ran git checkout -q -- Makefile tests .github and git clean -fdq .github, and asserted that git status --porcelain was empty. It was empty every time. I rebuilt the rows from the reviews' one-line descriptions. "Both" means test_only_push_publish_job_uploads_to_pypi and test_test_job_cannot_pass_with_failing_tests.

Row head efdb3cd Guards failing at head b788b3e docs master file
unmodified 100 passed none 95 passed 5 passed 42 passed
T1 \tifeq (1,1) … \tendif around pytest || true 23 failed, 77 passed both 6 failed, 89 passed (no guard) 5 passed 42 passed
T2 the same around \ttwine upload dist/* in build 23 failed, 77 passed both 95 passed 5 passed 42 passed
T3 X = 1, \tifeq (1,0), test rule, \tendif, %: / \t@: 23 failed, 77 passed both 95 passed 5 passed 42 passed
T4 \fifeq … \fendif around pytest || true 23 failed, 77 passed both 11 failed, 84 passed (no guard) 5 passed 42 passed
T5 \tdefine SHELL / /bin/true / \tendef 23 failed, 77 passed both 95 passed 5 passed 42 passed
T6 X = 1, \tdefine SHELL, /bin/true, \vendef 23 failed, 77 passed both 10 failed, 85 passed (no guard) 5 passed 42 passed
PA1 export PYTEST_ADDOPTS = --collect-only prepended 20 failed, 80 passed …cannot_pass_with_failing_tests 95 passed 5 passed 42 passed
PA2 test: export PYTEST_ADDOPTS = --collect-only before test: 24 failed, 76 passed both 18 failed, 77 passed (both) 5 passed 42 passed
F1 \t# …, pytest || true 8 failed, 92 passed …cannot_pass_with_failing_tests 8 failed, 87 passed 5 passed 42 passed
F3 .RECIPEPREFIX = >, every tab line as > 29 failed, 71 passed both 28 failed, 67 passed 1 failed, 4 passed 1 failed, 41 passed
F5 define SHELL / /bin/true / endef 23 failed, 77 passed both 23 failed, 72 passed 5 passed 42 passed
G3 .RECIPEPREFIX = >, template >… || true 23 failed, 77 passed both 23 failed, 72 passed 5 passed 42 passed
L6 \tSHELL=bash true in test 20 failed, 80 passed …cannot_pass_with_failing_tests 20 failed, 75 passed 5 passed 42 passed
Q1 @- on the pytest line 14 failed, 86 passed …cannot_pass_with_failing_tests 14 failed, 81 passed 5 passed 2 failed, 40 passed
  • T rows. At head, MAKE_SYNTAX_MAKE_RULES_SKIPS in make_rules kills each one through both guards. At b788b3e no guard fails on any of them. The counts match the hand-off and round 3.
  • PA1. MAKE_SETTING_THAT_MASKS kills it through test_test_job_cannot_pass_with_failing_tests. PA2 already failed both guards at b788b3e, through the one-rule-line check.
  • F3. My rebuild changes every tab line, so its counts differ from the hand-off's 26/25. The account holds: the only test that fails at head and not at b788b3e is test_make_rules_rejects_syntax_it_cannot_follow[conditional-after-space-and-tab], whose \tcoverage xml -i\n anchor F3 removes. The guards are the same.
  • G3. Mine gives 23 at both heads, against the hand-off's 22 at both. The failing test functions are the same at both heads, which is the point of the hand-off's account. The count differs only because the edits differ in detail.
  • Code mutants of the new lines. Turning ^[ \t\f\v]* back into ^ * fails exactly the three new parameters (3 failed, 97 passed). Dropping |PYTEST_ADDOPTS fails exactly the two new setting parameters (2 failed, 98 passed). Changing the class to [ \t] fails only define-after-form-feed (P3 3).

Checked and sound

  • Tests, one file per process (python -m pytest -q -p no:cacheprovider <file>, Python 3.14.6, in a uv venv with uv pip install -e ".[dev]"; uv was already installed):

    • head test_release_tagging.py: 100 passed;
    • head test_docs_deployment.py: 5 passed;
    • master's test_release_tagging.py in place: 42 passed.

    ruff format --check gives "1 file already formatted", and ruff check gives "All checks passed!" (ruff 0.17.3).

  • The character class against GNU Make 4.3. I probed each whitespace form before ifeq (1,0) … endif around \tfalse inside a rule, before the same outside a rule (after X = 1), and before define SHELL … endef. "Obeyed" means make -s test exits 0:

    Before the directive in a rule outside a rule define head's pattern matches
    spaces obeyed obeyed obeyed yes
    tab recipe line: /bin/sh gets ifeq (1,0), exit 2 obeyed make error (missing 'endef'), exit 2 yes
    space then tab obeyed obeyed obeyed yes
    form feed obeyed obeyed obeyed yes
    vertical tab obeyed obeyed obeyed yes
    carriage return, alone or after a space obeyed obeyed obeyed no, but read_text() turns the \r into \n, so the directive starts a line. End to end, \rdefine SHELL … \rendef and \rifeq outside a rule each give 23 failed, killed by both guards.
    \x1c, \xa0 make error (missing separator), exit 2 the same the same no; nothing to catch

    So make reads a tab-indented directive inside a rule as a recipe line and passes it to the shell. Head's pattern reads it as a directive and fails, which is the fail-closed entry the body names.

  • False positives. Master's Makefile passes, at 100 passed. These recipe lines inside test: pass head's check, and make -n test passes each to the shell. Passing is right, because none of them is a directive:

    • \techo endif;
    • \telsewhere --flag (no word boundary after else);
    • \tdefine_x=1 ./run (_ is a word character).

    \telse, \tifeq (1,0) and \tdefine-x ./run fail head's check, and make passes each to the shell. That is the loud false positive the body's new fail-closed entry describes ("a recipe line that opens with one of those directive words"). define-x counts as opening with define, because - ends the word.

  • The PYTEST_ADDOPTS comment. pytest's source puts PYTEST_ADDOPTS in front of its command line (_pytest/config/__init__.py:1695-1700), and the probe below shows make passing an exported variable to the recipe's command. So the comment holds. It names only export, but the check matches the name anywhere, so other ways of handing the variable to pytest are caught as long as they name it. The hand-off's probe reproduces: with export PYTEST_ADDOPTS = --collect-only and a failing test_t.py, make test prints 1 test collected in 0.09s and exits 0. Without the line it exits 2. A form that hands PYTEST_ADDOPTS to pytest without naming it in the root Makefile is either a built name (P3 1) or in another makefile, which the body's GNUmakefile/MAKEFILES/include bullet covers. export pytest_addopts = … passes the check, but Linux keeps environment names case-sensitive, and pytest reads only the upper-case name. push.yaml's Test job runs on ubuntu-latest.

  • Fragment. Each new clause has a mutant that fails head: PYTEST_ADDOPTS (PA1, PA2, and a comment that names it), and the conditional and define clause for T1 to T6. Round 3 checked the other clauses, and the rows above give the same failing functions at both heads. The exception is the || clause (P2 1).

  • Body. The fail-closed entries for \telse and for a PYTEST_ADDOPTS comment hold, as shown above. The "Tests" counts (100 and 5, with master 42 and 5) match my runs.

  • CI. Pull request run 41924244227 at efdb3cd468be8011521e8d4092c4796d74156ff1 is completed / success, and it is the only run at that SHA. All 19 jobs are success: Lint, Lint workflows, Check changelog fragment, 8 Test jobs (ubuntu and windows, Python 3.11–3.14) and 8 "Test Core and country packages" jobs. None was cancelled or skipped. The PR's mergeable_state is clean.

What I couldn't check

  • The GNU make manual: the proxy refuses www.gnu.org (CONNECT tunnel failed, response 403), and the image has no make info pages. So every statement here about how make reads a line rests on GNU Make 4.3 probes, not on the manual or make's source.
  • Which make version ubuntu-latest runs. My probes use GNU Make 4.3.
  • Whether actions/checkout on windows-latest writes the Makefile with CRLF line endings. P2 1's fix allows \r\n, so it passes either way. I simulated a CRLF Makefile on Linux but didn't run Windows.
  • Characters above \x7f that make might treat as whitespace in a non-C locale. \xa0 gives missing separator here.
  • The exact edits behind the hand-off's rows. I rebuilt them from one-line descriptions, so F3 and G3 differ in count but not in which guards fail.

Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Picking this up for the US hub. I'll post a hand-off comment here when it's ready.


Generated by Claude Code

Python's splitlines breaks a line at a form feed, vertical tab, lone
carriage return, \x1c-\x1e, \x85, 
 and 
, but GNU make ends a
line only at \n. A recipe line such as `cmd $(if ,<form feed>)|| true`
therefore reached make_rules as two lines, hiding its `|| true` or
upload. make_rules now refuses those characters, reading raw bytes so a
lone \r stays visible and allowing \r\n for CRLF checkouts on Windows.

The docstring and assertion message now also name the directive-word
recipe line that make_rules already refuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6JgiWGYWii3moSdXzX
The previous commit put the raw U+2028 and U+2029 characters into the
pattern and dropped U+2029 from its comment. Escapes keep the source
ASCII and readable; the pattern matches the same nine characters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LwQq6JgiWGYWii3moSdXzX
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review

Head: 5b084b6082b23667437f07e3421a88916d5e4c83 (base master eaef9c24858369af85093bfb5de729b48208be81). This round answers the round 4 review at efdb3cd: #650 (comment)

New commits: 92750f87 (check, parameters, docstring, message), a3f9f42c (fragment), 5b084b60 (writes U+2028/U+2029 as escapes; see judgment call 2). The PR still changes only tests/core/test_release_tagging.py and the fragment: git diff --quiet origin/master HEAD -- .github policyengine_core uv.lock exits 0.

Items

Item Resolution
1. Line breaks make doesn't see (P2 1) MAKE_LINE_BREAK_MAKE_IGNORES and its comment follow MAKE_SYNTAX_MAKE_RULES_SKIPS (tests/core/test_release_tagging.py:417-422), with every character written as an escape. The assertion reads makefile.read_bytes().decode() before text = makefile.read_text() (:440-442). The two parameters form-feed-inside-recipe-line and carriage-return-inside-recipe-line follow tab-conditional-outside-rule with the review's old/new strings (:1104-1113). Master's Makefile passes (102 passed) and so does a CRLF copy of it (102 passed; row "CRLF whole file" below).
2. Docstring and message (P3 2) The docstring lines are the brief's text (:431-436). The MAKE_SYNTAX_MAKE_RULES_SKIPS message is the brief's three strings (:444-447). The tests match only make_rules cannot read, so both messages satisfy them.
3. Fragment The parser clause is the brief's replacement, verbatim. No mutant showed another clause false, so the rest is unchanged. B1–B5 now fail the || and upload clauses' guards, so the || clause the review found false for this form now holds.
4. Body All six edits are in, and I read the whole body back after the PATCH. Two notes: the server replaced the closing 🤖 line with its own _Generated by [Claude Code](https://claude.ai/code)_ footer, so the body can't end with the line the brief asked for (judgment call 3). I wrote the round 4 sentence's second character as `\u2028` (judgment call 2).
5. Mutants Table below.
6. Runs Below.

Tests

One file per process, uv run --no-sync pytest -q -p no:cacheprovider <file>:

  • head test_release_tagging.py: 102 passed (was 100; the 2 new parameters);
  • head test_docs_deployment.py: 5 passed;
  • master's test_release_tagging.py in place: 42 passed.

ruff format --check gives "1 file already formatted" and ruff check gives "All checks passed!" on the changed file (ruff 0.15.5).

Pattern check: over all 0x110000 code points, the 9 characters other than \n at which ('a' + c + 'b').splitlines() gives 2 lines are exactly the 9 that MAKE_LINE_BREAK_MAKE_IGNORES matches (\r \v \f \x1c \x1d \x1e \x85 \u2028 \u2029). It doesn't match a\r\nb, and it does match a\r.

Mutants

A script made each row as a text edit to the root Makefile, written as bytes, and ran each of four test files in its own process: head's test_release_tagging.py, efdb3cd's copied into place, test_docs_deployment.py, and master's test_release_tagging.py copied into place. After every process it ran git checkout -q -- Makefile tests and asserted that git status --porcelain was empty, which it was every time. I ran the whole table twice, at a3f9f42c and at 5b084b60, and got identical results. "Both" means test_only_push_publish_job_uploads_to_pypi and test_test_job_cannot_pass_with_failing_tests. I rebuilt T1–T6, PA1–PA2, F1, F3, F5, G3, L6 and Q1 from the reviews' one-line descriptions. CRLF-continuation is my own reconstruction: the whole file in CRLF, with the pytest line ending in ; \ and \ttrue next.

Row head guards at head efdb3cd guards at efdb3cd docs master file
unmodified 102 passed none 100 passed none 5 passed 42 passed
B1 pytest line + $(if ,\f)|| true 13 failed, 89 passed both 1 failed, 99 passed none 5 passed 42 passed
B2 the same with \u2028 13 failed, 89 passed both 1 failed, 99 passed none 5 passed 42 passed
B3 the same with \x1c 13 failed, 89 passed both 1 failed, 99 passed none 5 passed 42 passed
B4 the same with a lone \r 3 failed, 99 passed both 1 failed, 99 passed none 5 passed 42 passed
B5 \tpython -m build $(if ,\f)&& twine upload dist/* 16 failed, 86 passed both 5 failed, 95 passed none 5 passed 1 failed, 41 passed
test: test-country-template $(if ,\f)masked, masked: / \tfalse || true 20 failed, 82 passed both 14 failed, 86 passed …cannot_pass_with_failing_tests 5 passed 42 passed
T1 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
T2 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
T3 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
T4 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
T5 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
T6 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
PA1 20 failed, 82 passed …cannot_pass_with_failing_tests 20 failed, 80 passed …cannot_pass_with_failing_tests 5 passed 42 passed
PA2 24 failed, 78 passed both 24 failed, 76 passed both 5 passed 42 passed
F1 8 failed, 94 passed …cannot_pass_with_failing_tests 8 failed, 92 passed …cannot_pass_with_failing_tests 5 passed 42 passed
F3 31 failed, 71 passed both 29 failed, 71 passed both 1 failed, 4 passed 1 failed, 41 passed
F5 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
G3 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
L6 20 failed, 82 passed …cannot_pass_with_failing_tests 20 failed, 80 passed …cannot_pass_with_failing_tests 5 passed 42 passed
Q1 14 failed, 88 passed …cannot_pass_with_failing_tests 14 failed, 86 passed …cannot_pass_with_failing_tests 5 passed 2 failed, 40 passed
CRLF-continuation 18 failed, 84 passed both 18 failed, 82 passed both 5 passed 42 passed
CRLF whole file 102 passed none 100 passed none 5 passed 42 passed
  • B1–B5 and the masked row fail both guards at head. At efdb3cd, B1–B5 fail no guard, which reproduces the review. B4 has only 3 failures at head because the in-suite tests build their temporary makefiles from MAKEFILE.read_text(), which turns the lone \r into \n. Both guards still fail.
  • T1–T6, PA1–PA2, F1, F5, G3, L6, Q1, CRLF-continuation. I compared the failing test IDs, not just the counts: at head they equal efdb3cd's exactly, with the 2 new parameters passing.
  • F3. It adds exactly [form-feed-inside-recipe-line] and [carriage-return-inside-recipe-line], whose \tcoverage xml -i\n anchor F3 rewrites, as the review predicted.

Code mutants of the new check, each run in its own process with the file restored after (git status clean):

  • read_bytes().decode() → read_text(): 1 failed, 101 passed (carriage-return-inside-recipe-line).
  • Drop \r(?!\n)|: 1 failed, 101 passed (the same test).
  • Drop \f from the class: 1 failed, 101 passed (form-feed-inside-recipe-line).
  • \r(?!\n) → \r (no lookahead): 102 passed with the LF checkout. With the root Makefile in CRLF it gives 2 failed, 100 passed (both guards). So only a CRLF checkout pins the CRLF allowance, which the Windows jobs may or may not have.

CI

Pull request run 41957742064 at 5b084b6082b23667437f07e3421a88916d5e4c83 is completed / success: https://github.com/PolicyEngine/policyengine-core/actions/runs/41957742064. All 19 jobs are success: Lint, Lint workflows, Check changelog fragment, 8 Test jobs (ubuntu-latest and windows-latest, Python 3.11–3.14) and 8 "Test Core and country packages" jobs. None was cancelled or skipped. The 4 Windows Test jobs passing means the new check accepts the Makefile as those jobs check it out. I didn't confirm whether that checkout is CRLF, so it shows that the allowance holds, or isn't needed, there. The run at my intermediate head a3f9f42c (41957262906) was cancelled when I pushed 5b084b60, so it doesn't count. The PR's mergeable_state is clean.

Judgment calls

  1. The 6-hour rule. The last push before mine was at 2026-11-04T22:11:42Z, 4.5 hours before I started. It is the head the item names, its session posted a hand-off at 22:40Z, and the hub's round 4 review followed at 00:47Z. No one else was working on the branch, so I went ahead rather than skip.
  2. Escapes for U+2028/U+2029. My first commit (92750f87) put the raw U+2028 and U+2029 characters into the pattern, and the comment lost its U+2029. 5b084b60 writes both as \u2028/\u2029 escapes, so the file is ASCII again. The pattern is the same, and the mutant table is identical before and after. 92750f87's commit message still holds those two raw characters, and I didn't force-push to fix it. In the body's round 4 sentence and in the comment, I wrote the brief's blank-looking characters as \u2028 (and \u2029).
  3. Body footer. The server replaces a closing 🤖 line with its own _Generated by…_ footer, so the body ends with that footer.
  4. define-after-form-feed. That existing parameter now fails at the new line-break check before it reaches MAKE_SYNTAX_MAKE_RULES_SKIPS, so \f and \v in that pattern's class are unreachable, as the review says. I kept them.
  5. pytest routes. I checked the new "can't see" bullet against the installed pytest 9.1.1 _pytest/config/findpaths.py:167-173 (pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg). A probe directory held a copy of the repository's pytest.ini and a failing test. addopts = --collect-only in pyproject.toml, tox.ini, setup.cfg or .pytest.ini exits 1 next to pytest.ini and 0 without it. In pytest.toml or .pytest.toml it exits 0 either way.

Proposed follow-up

  • Pin the CRLF allowance on Linux. Add a parameter that writes the root Makefile with CRLF endings and asserts that make_rules reads it, so that dropping (?!\n) fails on every runner, not only on a CRLF checkout.

Generated by Claude Code

@MaxGhenis

MaxGhenis commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor Author

Hub review in progress

Round 5, head 5b084b6082b23667437f07e3421a88916d5e4c83. All checks are done. The final review follows as a separate comment: APPROVE with three P3s (the self-tests don't pin \v/\x1c– in the new pattern, the CRLF allowance is pinned only on a CRLF checkout, and there are raw U+2028/U+2029 characters in 92750f87's message). I found no P1 or P2.


Generated by Claude Code

@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Ready for hub review
VERDICT: APPROVE
5b084b6
METHODOLOGY_CHOICE: no

I reviewed head 5b084b6082b23667437f07e3421a88916d5e4c83, which is still the PR head (state: open, mergeable_state: clean). The base is master eaef9c24858369af85093bfb5de729b48208be81. This round answers the round 4 review at efdb3cd.

The three new commits do what the fix brief asked. The new check refuses each of the nine characters that Python splits a line at and GNU make doesn't, and allows \r\n. B1–B5 and the masked row now fail both guards. Every row I reran gives the hand-off's count and guards, and the rows from rounds 1–3 fail the same tests as at efdb3cd, plus the 2 new passes. The body and fragment claims I probed hold, and CI is green on all 19 jobs. I found no P1 or P2. The three P3s below are coverage gaps in the test's own self-tests, plus a cosmetic note for the squash, and none of them blocks the merge.

Assignment

  • git diff --stat efdb3cd 5b084b6: tests/core/test_release_tagging.py (+27/−6) and changelog.d/core649-release-test-followups.changed.md (1 line changed). The commits are 92750f87, a3f9f42c and 5b084b60.
  • Against master, pulls/650/files lists only those two files. git diff --quiet origin/master 5b084b6 -- .github policyengine_core uv.lock exits 0, master is the merge base, and the root Makefile is unchanged from master.
  • Item 1: MAKE_LINE_BREAK_MAKE_IGNORES and its comment sit at :417-422, right after MAKE_SYNTAX_MAKE_RULES_SKIPS. Each character is written as an escape, and the file is pure ASCII (no code point above 127). The bytes assertion is at :440-442, before text = makefile.read_text() (:443). The two parameters follow tab-conditional-outside-rule (:1105-1114) with the round 4 review's old/new strings.
  • Item 2: the docstring (:431-436) and the MAKE_SYNTAX_MAKE_RULES_SKIPS message (:445-447) match the round 4 replacement word for word.
  • Item 3: the fragment's parser clause is the brief's replacement, verbatim. Nothing else in the fragment changed.
  • Item 4: the body has the first-bullet replacement, the fail-closed entry, the "Pytest options set elsewhere" bullet, 102 in "Tests" and the B1–B5 sentence. The impact: line is unchanged. The brief's blank-looking U+2028 appears in the body as ` `, which says the same thing. The hub handles the closing footer, so I don't report it.
  • What the brief asked for that the PR doesn't do: nothing.

P3

1. The self-tests don't pin \v, \x1c–\x1e, \x85, or in the new pattern (tests/core/test_release_tagging.py:422; the two parameters at :1105-1114 cover only \f and lone \r).

Failure scenario: someone drops \v, or `\x1c-\x1e\x85

, from MAKE_LINE_BREAK_MAKE_IGNORES's class, and all 102 tests still pass. A later root Makefilewith$(if ,\v)|| trueor$(if ,\x1c)|| trueon a recipe line then passes both guards again, which is the round 4 P2 for those characters. The round 4 review's P3 3 expected the new check to cover\v`, and it does at head, but no test fails if the check loses it.

Command: code mutants on a scratch copy of the file, one process each, file restored after:

c6 drop \v                         LF   102 passed   CRLF 102 passed
c7 drop \x1c-\x1e\x85

  LF   102 passed   CRLF 102 passed

Replacement: add this test after test_make_rules_rejects_syntax_it_cannot_follow. It writes bytes, so \x85 and don't depend on the Windows locale encoding that write_text uses. Layout is as ruff format gives it:

@pytest.mark.parametrize(
    "line_break", ["\v", "\x1c", "\x1d", "\x1e", "\x85", "
", "
"]
)
def test_make_rules_rejects_each_line_break_make_ignores(tmp_path, line_break):
    makefile = tmp_path / "Makefile"
    makefile.write_bytes(
        MAKEFILE.read_bytes().replace(
            b"\tcoverage xml -i",
            f"\tcoverage xml -i $(if ,{line_break})|| true".encode(),
        )
    )

    with pytest.raises(AssertionError, match="make_rules cannot read a line break"):
        make_rules(makefile)

On a scratch copy at head this gives 109 passed, and ruff check passes. c6 then fails [\x0b] (1 failed, 108 passed), and c7 fails the six others (6 failed, 103 passed). It fits in the same follow-up as P3 2.

2. The CRLF allowance is pinned only on a CRLF checkout (:422, \r(?!\n)). This is the hand-off's proposed follow-up. The mutant \r(?!\n) → \r gives 102 passed with the LF Makefile and 2 failed, 100 passed (both guards) with a CRLF copy, which reproduces the hand-off. If the mutant lands, it fails loudly on any runner whose checkout is CRLF, and it never lets a mask through. I'd do it after this PR, as a parameter that writes MAKEFILE.read_bytes().replace(b"\n", b"\r\n") and asserts make_rules returns the same rules as for the LF file. Combine it with P3 1, rather than starting a sixth round for it.

3. 92750f87's commit message holds a raw U+2028 and U+2029 (offsets 169 and 175 in git log -1 --format=%B 92750f87). The repository squashes with squash_merge_commit_message: COMMIT_MESSAGES (gh api repos/PolicyEngine/policyengine-core), and d9da48a1 (#649) shows that default listing each commit's message in the squash body. So unless someone edits the message, both characters reach master's history. They change nothing that runs. push.yaml reads head_commit.message only to compare it with 'Update PolicyEngine Core' (:11, :22, :66, :88, :119), and bump_version.py reads changelog.d/, not git. Some viewers render the characters as invisible text or as line breaks. When the hub squashes, it can write them as / in that paragraph or drop the paragraph.

Judgment calls

  • U+2028/U+2029 in the commit message: see P3 3. It doesn't matter for CI or the release, and squashing with an edited message removes it. No force-push needed.
  • \f and \v in MAKE_SYNTAX_MAKE_RULES_SKIPS's class: keep them. Changing the class to [ \t] gives 102 passed with the LF and with the CRLF Makefile (mutant c8), so at head they don't act. But with the line-break assertion removed (mutant c5), only the two new parameters fail, and define-after-form-feed still passes, because the directive class catches \fdefine. So the class gives a second layer if someone moves or loosens the new check. It costs nothing.
  • pytest routes ("can't see" bullet): reproduced on pytest 9.2.0 (the hand-off used 9.1.1). _pytest/config/findpaths.py:245-253 (CONFIG_LOADERS_BY_NAME) lists pytest.toml, .pytest.toml, pytest.ini, .pytest.ini, pyproject.toml, tox.ini, setup.cfg, in that order. _pytest/config/__init__.py:1695-1698 reads PYTEST_ADDOPTS. Scratch directory with a copy of the repository's pytest.ini and a failing test_t.py, addopts = --collect-only in each file:
    pytest.toml      with pytest.ini exit=0  without exit=0
    .pytest.toml     with pytest.ini exit=0  without exit=0
    .pytest.ini      with pytest.ini exit=1  without exit=0
    pyproject.toml   with pytest.ini exit=1  without exit=0
    tox.ini          with pytest.ini exit=1  without exit=0
    setup.cfg        with pytest.ini exit=1  without exit=0
    baseline (pytest.ini only) exit= 1
    addopts in pytest.ini exit= 0
    conftest sessionfinish exit= 0
    PYTEST_ADDOPTS env exit= 0
    
    The bullet is accurate.
  • The proposed CRLF follow-up: after this PR (P3 2).

Mutants

A script made each row as a byte-level edit to the root Makefile. It ran four files, each in its own process: head's test_release_tagging.py, efdb3cd's copied into place, test_docs_deployment.py, and master's test_release_tagging.py copied into place. After each process it ran git checkout -q -- Makefile tests and asserted git status --porcelain was empty, and it was every time. "Both" means test_only_push_publish_job_uploads_to_pypi and test_test_job_cannot_pass_with_failing_tests. I rebuilt the rows from the reviews' one-line descriptions.

Row head guards at head efdb3cd guards at efdb3cd docs master file
unmodified 102 passed none 100 passed none 5 passed 42 passed
B1 pytest line + $(if ,\f)|| true 13 failed, 89 passed both 1 failed, 99 passed none 5 passed 42 passed
B2 the same with `
` 13 failed, 89 passed both 1 failed, 99 passed none 5 passed 42 passed
B3 the same with \x1c 13 failed, 89 passed both 1 failed, 99 passed none 5 passed 42 passed
B4 the same with a lone \r 3 failed, 99 passed both 1 failed, 99 passed none 5 passed 42 passed
B5 \tpython -m build $(if ,\f)&& twine upload dist/* 16 failed, 86 passed both 5 failed, 95 passed none 5 passed 1 failed, 41 passed
test: test-country-template $(if ,\f)masked, masked: / \tfalse || true 20 failed, 82 passed both 14 failed, 86 passed …cannot_pass_with_failing_tests 5 passed 42 passed
T1 \tifeq (1,1) … \tendif around pytest || true 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
T4 \fifeq … \fendif around pytest || true 23 failed, 79 passed both 23 failed, 77 passed both 5 passed 42 passed
PA2 test: export PYTEST_ADDOPTS = --collect-only 24 failed, 78 passed both 24 failed, 76 passed both 5 passed 42 passed
F1 \t# note, pytest || true 8 failed, 94 passed …cannot_pass_with_failing_tests 8 failed, 92 passed …cannot_pass_with_failing_tests 5 passed 42 passed
Q1 @- on the pytest line 14 failed, 88 passed …cannot_pass_with_failing_tests 14 failed, 86 passed …cannot_pass_with_failing_tests 5 passed 2 failed, 40 passed
CRLF-continuation (whole file CRLF, pytest line ; \, then \ttrue) 18 failed, 84 passed both 18 failed, 82 passed both 5 passed 42 passed
CRLF whole file 102 passed none 100 passed none 5 passed 42 passed
  • Every count and guard matches the hand-off's table. For T1, T4, PA2, F1, Q1 and CRLF-continuation (the spot checks), the failing test IDs at head equal those at efdb3cd.
  • For B1 and B4, both guards fail with make_rules cannot read a line break other than \n or \r\n in the Makefile, so the new check kills them, not some other assertion.

Code mutants of the new lines, one process each, file restored after, run with the LF Makefile and a CRLF copy:

Mutant LF CRLF Killed by
c1 read_bytes().decode() → read_text() 1 failed 1 failed [carriage-return-inside-recipe-line]
c2 drop \r(?!\n)| 1 failed 1 failed [carriage-return-inside-recipe-line]
c3 drop \f 1 failed 1 failed [form-feed-inside-recipe-line]
c4 \r(?!\n) → \r 102 passed 2 failed none with LF; both guards with CRLF (P3 2)
c5 drop the line-break assertion 2 failed 2 failed both new parameters
c6 drop \v 102 passed 102 passed none (P3 1)
c7 drop `\x1c-\x1e\x85

| 102 passed | 102 passed | **none** (P3 1) | | c8 directive class[ \t\f\v]→[ \t]` | 102 passed | 102 passed | none (judgment call above) |

Checked and sound

  • Tests, one file per process (python -m pytest -q -p no:cacheprovider <file>, Python 3.14.6, pytest 9.2.0, in a uv venv with uv pip install -e ".[dev]"; uv was already installed): head test_release_tagging.py 102 passed, test_docs_deployment.py 5 passed, master's test_release_tagging.py in place 42 passed. ruff format --check gives "1 file already formatted" and ruff check gives "All checks passed!" (ruff 0.17.3).
  • The pattern against Python. Over all 0x110000 code points, the characters other than \n at which ('a' + c + 'b').splitlines() gives two lines are `\r \v \f \x1c \x1d \x1e \x85

. That is exactly the set the pattern matches. It matches a\r, a\rbanda\r\r\nb, and not a\r\nbora\nb`.

  • The pattern against GNU Make 4.3, scratch directory, make -s test, for each of the nine characters C:
    • inside a rule, \techo RAN; false $(if ,C)|| true: every one prints RAN and exits 0, so make reads one line and runs the || true. Python's universal-newline read plus splitlines() gives 3 lines for each file, where make sees 2.
    • outside a rule, X := a$(if ,C)b: every one echoes [ab], so make keeps the line whole.
    • raw X := aCb: $(words $(X)) is 2 for \r, \v and \f (make reads them as whitespace) and 1 for the other six. The body's "even where make reads it as plain whitespace" fits the first three.
    • CRLF: test:\r\n\techo RAN; false\r\n gives make error 1, not 127, so make reads \r\n as a line end and the shell gets false. The \r\n allowance matches make.
  • Head's checks on master's Makefile. It is head's Makefile (unchanged): 102 and 5 passed. A CRLF copy also gives 102 and 5.
  • The fragment's parser clause ("a line break other than a newline (such as a form feed)"): a \f in a comment line, a \v after X = 1 outside any rule, and a \x85 in a comment each fail both guards with the line-break message. A CRLF-only file passes both. The || clause now holds for the round 4 form: B1–B4 fail both guards at head and none at efdb3cd. The other clauses fail the same guards as at efdb3cd (the rows above).
  • Docstring and message. The docstring names what the two assertions refuse: conditional, define, .RECIPEPREFIX, trailing backslash, a non-newline line break, and a recipe line that opens with a directive word. The new message's "other than \n or \r\n" matches the lookahead. The test matches only make_rules cannot read, which both messages contain.
  • Body. The first bullet, the new fail-closed entry and the pytest bullet hold (probes above). The "Tests" counts (102 and 5; master 42 and 5) match my runs. The B1–B5 sentence matches the rows.
  • CI. Pull request run 41957742064 at 5b084b6082b23667437f07e3421a88916d5e4c83 is completed / success, and it is the only run at that SHA. All 19 jobs are completed / success: Check changelog fragment, Lint, Lint workflows, Test on ubuntu-latest and windows-latest for 3.11–3.14 (8 jobs), and Test Core and country packages on both OSes for py3.11–3.14 (8 jobs). None was cancelled or skipped. The Test (windows-latest, 3.13) log shows tests\core\test_release_tagging.py ssss... with 98 tests run. The 4 skips are the Bash release-script tests (:28-31, :128-131), so both guards and the two new parameters ran and passed on Windows.

What I couldn't check

  • Whether actions/checkout on windows-latest writes the Makefile with CRLF. The job log doesn't show line endings. The Windows guards pass, so the allowance holds there or isn't needed, but a green Windows run doesn't prove that the lookahead is exercised (P3 2).
  • The GNU make manual. I didn't fetch it this round, so every statement here about how make reads a line rests on GNU Make 4.3 probes in this session, not on the manual. I don't know which make version ubuntu-latest runs.
  • The hand-off's exact edits for its rows. I rebuilt them from one-line descriptions, and all of them match its counts.

Generated by Claude Code

@MaxGhenis
MaxGhenis merged commit 6925606 into master Nov 5, 2026
19 checks passed
@MaxGhenis
MaxGhenis deleted the claude/core-pr-649-followups-c3va8g branch November 5, 2026 03:52
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

US + core hub merge audit, core#650 at 5b084b6082b23667437f07e3421a88916d5e4c83 (squash): Close Makefile masks and limit PyPI credentials in the release tests.

  • Review: an independent review in a separate Claude Code session approved this head at round 5, with three P3 findings and no P1 or P2: Close Makefile masks and limit PyPI credentials in the release tests #650 (comment)
    • Its reruns of the hand-off's Makefile forms gave the hand-off's counts and guards.
    • It checked the new line-break pattern against Python over every code point and against GNU Make 4.3.
  • Change after the review: none to the code. The hub restored the description's closing line and split one sentence in it.
  • Follow-ups for a later core PR (the review's P3s):
    • P3 1: pin \v, \x1c to \x1e, \x85, 
 and 
 in the new check, since its mutants c6 and c7 pass every test;
    • P3 2: pin the CRLF allowance on an LF checkout, with a test that compares make_rules on the LF file and a CRLF copy.
  • Squash message: the hub wrote the raw U+2028 and U+2029 in commit 92750f87 as escapes (the review's P3 3).
  • Classification: CORE_SEMANTICS no. The PR changes release tests and the changelog, not package code.
  • PE-US core gate: the hub skipped it, since no package code changes.
  • Gates the lander checked at merge:
    • every check passes at the head;
    • MERGEABLE;
    • not a draft;
    • no CHANGES_REQUESTED review;
    • the merge pins the head (--match-head-commit).
  • Axiom: n/a; the PR changes no policy.

🤖 Generated with Claude Code

MaxGhenis added a commit that referenced this pull request Nov 5, 2026
* Pin each line break make_rules refuses, and its CRLF allowance

The independent review of #650 found that dropping \v (mutant c6) or
\x1c-\x1e, \x85, U+2028 and U+2029 (c7) from MAKE_LINE_BREAK_MAKE_IGNORES
left all 102 tests passing, and that turning \r(?!\n) into \r (c4)
failed only on a CRLF checkout. A parametrized test now writes each of
the seven unpinned characters into a recipe line as bytes, so the
result doesn't depend on the locale encoding, and expects make_rules to
refuse it. A second test writes LF and CRLF copies of the root Makefile
(normalized first, in case the checkout is already CRLF) and expects the
same rules from both, so c4 fails on an LF checkout too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants