Files
ersatztv/scripts/tests/test_merge_consent_exemption.py
T
timothyandClaude Opus 5 bb1809fbf0 fix(647): the jq-1.6 fail-open was ALSO in review-verdict.yml — the enforced gate
Round-4 review caught the miss that matters most: #643 fixed the `jq -e`-on-empty
fail-open in .claude/hooks/pretooluse-merge-consent.sh, but the SAME construct sits in
.gitea/workflows/review-verdict.yml — and that is the copy that runs on the CI runner,
where jq is 1.6, and that feeds the branch-protection-required review-verdict/h10
status.

Reproduced: `printf "" | jq -e '.statuses | type == "array"'` exits 4 on jq 1.8.2 (guard
fires, correct) and 0 on jq 1.6 (guard passes). So on a transient API error `statusjson`
is empty, the guard lets it through, `existing` reads "", and the job posts `pending` —
or for a bot/docs-only PR an exemption `success` — over a possibly-existing human
verdict. That is precisely what the comment three lines above it says must never happen.

The hook version was harmless in practice because it runs on a dev Mac with jq 1.8. This
one is live. Fixed identically, with a comment naming why the sibling fix missed it, and
the same hardening applied to the changed-files read in the same workflow.

Also from round 4:

- LOW, reproduced — an ARRAY-valued `.status` dodged the closed allow-list. `index` is
  polymorphic: with an array argument it does SUBSEQUENCE matching, so
  `[...,"renamed",...] | index(["renamed"])` is truthy while `.status == "renamed"` is
  false — the row passed the allow-list AND skipped the `previous_filename REQUIRED`
  clause. The same `git mv code -> docs/` dodge the closed set exists to block, one type
  away. Now requires `.status` to be a string first; mutation-verified.

- The record now carries all THREE jq rules rather than the one, and notes that the
  durable fix is pinning/preflighting the runner's jq version rather than patching
  constructs one at a time (tracked on #647).

200 tests pass under BOTH jq 1.8.2 and jq 1.6.

Refs #647, #643, #631

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 13:24:20 +02:00

498 lines
23 KiB
Python

"""Tests for the docs-only exemption path in `.claude/hooks/pretooluse-merge-consent.sh` (#622).
This covers the *file-enumeration* half of the gate, which decides whether a PR skips the
Done-when/verdict checks entirely. Three defects lived here, all silent false negatives that
widened the exemption rather than narrowing it:
1. the list was read from a single `?limit=100` page, which Gitea caps at 50 — on PR #619 that
showed zero protected paths where the full enumeration finds ten;
2. only `.filename` was read, so a `git mv` of a protected file INTO `docs/` looked docs-only;
3. a mid-pagination transport failure produced an empty page, which counted as zero rows and
read as "end of list" — completing the enumeration from a PARTIAL list.
(3) is the one worth restating: it is a defect on the FAILURE path, so every happy-path run looked
correct. These tests therefore assert what happens when a page *errors*, not only when it returns.
Observable contract: the hook exits 0 with EMPTY stdout when the PR is exempt (it passes through to
normal permissioning), and emits a JSON `permissionDecision` when it is not. So "did the exemption
apply" is directly observable without stubbing the rest of the gate.
"""
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
HOOK = REPO_ROOT / ".claude" / "hooks" / "pretooluse-merge-consent.sh"
SHA = "a9e3e23abf337980ca4c05854f5b1e210099d08b"
# Serves paged `pulls/N/files`, plus the minimal PR object the hook reads first.
CURL_SHIM = r'''#!/usr/bin/env python3
import json, os, sys, pathlib, urllib.parse
state = pathlib.Path(os.environ["STUB_DIR"])
args = sys.argv[1:]
url = [a for a in args if a.startswith("http")][-1]
if "/pulls/" in url and "/files" in url:
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
page = int(q.get("page", ["1"])[0])
pages = json.loads((state / "pages.json").read_text())
if page > len(pages):
print("[]"); sys.exit(0)
entry = pages[page - 1]
if entry == "ERROR": # transport failure on this page
sys.exit(22)
if entry == "GARBAGE": # 200 with a non-array body (proxy/error page)
print('{"message":"internal error"}'); sys.exit(0)
# anything else is served verbatim as the page body (rows, [], malformed rows, scalars)
print(json.dumps(entry)); sys.exit(0)
if "/pulls/" in url:
# Optional: a second head sha served from the Nth PR-object read onward, modelling a
# force-push landing between pagination round-trips.
shas = [os.environ["STUB_SHA"]]
alt = state / "pr_sha_after.txt"
ctr = state / "pr_reads.txt"
nread = int(ctr.read_text()) if ctr.exists() else 0
ctr.write_text(str(nread + 1))
if alt.exists() and nread >= 1:
shas = [alt.read_text().strip()]
print(json.dumps({"head": {"sha": shas[0]}, "body": "no linked issue here"}))
sys.exit(0)
print("{}")
'''
def _rows(paths):
return [{"filename": p, "status": "modified"} for p in paths]
@pytest.fixture
def hook(tmp_path):
bindir = tmp_path / "bin"; bindir.mkdir()
shim = bindir / "curl"; shim.write_text(CURL_SHIM); shim.chmod(0o755)
state = tmp_path / "state"; state.mkdir()
env = dict(os.environ)
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["STUB_SHA"] = SHA
env["ETV_GITEA_TOKEN"] = "stub"
env["ETV_GITEA_URL"] = "http://gitea.example"
env.pop("ETV_GITEA_BASICAUTH", None)
class Handle:
def set_pages(self, *pages):
(state / "pages.json").write_text(json.dumps(list(pages)))
def run(self):
payload = {"tool_input": {"method": "merge", "owner": "timothy",
"repo": "ersatztv", "pull_number": 42}}
return subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
env=env, capture_output=True, text=True)
def exempted(self):
"""Exempt == passthrough == exit 0 with no decision JSON."""
r = self.run()
assert r.returncode == 0, r.stderr
return r.stdout.strip() == ""
return Handle()
def test_docs_only_pr_is_exempt(hook):
hook.set_pages(_rows(["docs/a.md", "docs/b.md", "README.md"]))
assert hook.exempted() is True
def test_code_pr_is_not_exempt(hook):
hook.set_pages(_rows(["docs/a.md", "ErsatzTV/Program.cs"]))
assert hook.exempted() is False
def test_protected_path_on_a_LATER_page_is_still_seen(hook):
"""The #619 shape: 50 docs files on page 1, code hiding on page 2."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
_rows(["scripts/decisions_lib.py"]))
assert hook.exempted() is False
def test_full_first_page_alone_does_not_end_enumeration(hook):
"""A full 50-row page must trigger a second fetch, not terminate the loop."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
_rows(["docs/tail.md"]))
assert hook.exempted() is True # genuinely all docs, but only provable by reading page 2
def test_rename_of_code_into_docs_is_not_exempt(hook):
"""`git mv ErsatzTV/Program.cs docs/note.md` — the code path lives only in previous_filename.
NOTE the source must be a path the hook does not already exempt. Its docs-only pattern
deliberately also covers `.claude/`/`.gitea/`/`.husky/` (process files exempt to a HUMAN
PROMPT, never to an auto-grant), so a rename between two exempt paths is correctly still
exempt. That is the hook's contract and differs from `review-verdict.yml`'s stricter
PROTECTED list, which must never auto-post a green status for those paths.
"""
hook.set_pages([{"filename": "docs/innocuous-note.md", "status": "renamed",
"previous_filename": "ErsatzTV/Program.cs"}])
assert hook.exempted() is False
def test_rename_between_two_exempt_paths_stays_exempt(hook):
"""Guards the above: reading previous_filename must not over-trigger on legitimate moves."""
hook.set_pages([{"filename": "docs/b.md", "status": "renamed",
"previous_filename": "docs/a.md"}])
assert hook.exempted() is True
def test_transport_failure_mid_pagination_withholds_the_exemption(hook):
"""The failure-path defect: an errored page must not read as 'end of list'."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), "ERROR")
assert hook.exempted() is False
def test_non_array_body_mid_pagination_withholds_the_exemption(hook):
"""A 200 carrying an error object must not be treated as an empty final page."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), "GARBAGE")
assert hook.exempted() is False
def test_first_page_failure_withholds_the_exemption(hook):
"""NOTE this one is deliberately weak on its own — see the test below.
With page 1 failing, the path list is empty, and the hook independently withholds the
exemption on an empty list. So this passes even with the validation guard removed. It is
kept as a smoke case, but it does NOT pin the guard; `test_malformed_rows_*` does.
"""
hook.set_pages("ERROR")
assert hook.exempted() is False
def test_malformed_rows_mid_pagination_withhold_the_exemption(hook):
"""Isolates the row-schema hole: `[{}]` is a valid ARRAY whose rows carry no filename.
Validating only the top-level type lets this through — it contributes no paths, so it looks
like a short final page and completes the enumeration from a PARTIAL list. The 50 docs rows on
page 1 are what make this non-vacuous: the path list is non-empty, so the exemption would
genuinely fire if `complete` were wrongly set.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), [{}])
assert hook.exempted() is False
def test_scalar_rows_mid_pagination_withhold_the_exemption(hook):
"""An array of scalars must be rejected, not crash the extraction under `set -e`."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), ["unexpected"])
assert hook.exempted() is False
def test_empty_final_page_is_a_legitimate_end_of_pagination(hook):
"""Positive control for the guard above: `[]` must still count as a clean end, not a failure.
Without this, a stricter guard could withhold every exemption and the tests above would still
pass — the suite would be asserting 'never exempt', which is not the contract.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), [])
assert hook.exempted() is True
def test_malformed_rename_row_withholds_the_exemption(hook):
"""A row marked `renamed` with NO `previous_filename` hides its source path.
The destination alone is `docs/...`, so validating only `filename` would accept the row and
silently drop whatever it was renamed FROM — defeating the reason both sides are collected.
Real Gitea always populates it (verified by constructing a rename), so this is the
malformed-2xx class the guard claims to fail closed on; the claim should match the behaviour.
"""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
[{"filename": "docs/moved.md", "status": "renamed"}])
assert hook.exempted() is False
def test_rename_row_with_empty_previous_filename_withholds_the_exemption(hook):
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]),
[{"filename": "docs/moved.md", "status": "renamed", "previous_filename": ""}])
assert hook.exempted() is False
def test_ordinary_row_without_previous_filename_is_still_valid(hook):
"""Positive control: `previous_filename` must be required ONLY for rename rows.
Requiring it globally would reject every normal modified/added row and make the gate refuse
all exemptions — which the 'withholds' tests above could not distinguish from working.
Every row carries a `status`: since the round-3 hardening an ABSENT status fails closed (it
would otherwise dodge the `renamed => previous_filename REQUIRED` clause), which is asserted by
`test_a_rename_disguised_by_an_unknown_status_is_rejected[None]`. The statusless row this test
used to carry was incidental to what it is actually pinning.
"""
hook.set_pages([{"filename": "docs/a.md", "status": "modified"},
{"filename": "docs/b.md", "status": "added"},
{"filename": "docs/c.md", "status": "changed"}])
assert hook.exempted() is True
def test_exceeding_max_pages_withholds_the_exemption(hook):
"""41 full pages: enumeration cannot be proven exhaustive, so no exemption."""
hook.set_pages(*[_rows([f"docs/p{p}f{i}.md" for i in range(50)]) for p in range(41)])
assert hook.exempted() is False
# --- #631: the guard must not depend on jq's empty-input exit status ---------------------------
#
# `jq -e` over EMPTY input exits 4 on jq >= 1.7 but 0 on jq 1.6. The pagination guard originally
# leaned on that status to reject a transport failure, so on jq 1.6 the failed page passed the
# guard, the loop walked PAST it, the next page legitimately returned `[]`, and the enumeration
# completed from a PARTIAL list — the docs-only exemption firing over unread pages.
#
# This was invisible for two compounding reasons: the suite never ran in CI at all (#631), and
# `test_transport_failure_mid_pagination_withholds_the_exemption` above only exposes it when the
# host jq happens to be 1.6 — it passes on a developer Mac (1.8.x) with the bug fully present.
# So that test cannot pin this property; this one does, on ANY jq, by putting a shim on PATH that
# reproduces the single 1.6 behaviour and nothing else.
_JQ16_SHIM = r'''#!/usr/bin/env python3
"""jq wrapper reproducing exactly one jq-1.6 behaviour: `-e` over EMPTY input exits 0 (not 4).
Deliberately narrow. The quirk applies ONLY to a `-e` invocation that reads stdin; it must not
touch `jq -n`, which the hook's `decide` uses to build its decision JSON and which legitimately
has empty stdin. An earlier, broader version of this shim swallowed those `-n` calls, so the hook
emitted nothing and every decision read as "passthrough" — the shim manufacturing the very result
the test was trying to disprove.
"""
import os, subprocess, sys
argv = sys.argv[1:]
uses_null_input = any(a in ("-n", "--null-input") for a in argv)
wants_exit_status = any(a in ("-e", "--exit-status") for a in argv)
data = b"" if uses_null_input else sys.stdin.buffer.read()
if wants_exit_status and not uses_null_input and not data.strip():
sys.exit(0) # <-- the jq 1.6 quirk under test
here = os.path.dirname(os.path.abspath(__file__))
parts = [p for p in os.environ.get("PATH", "").split(os.pathsep) if os.path.abspath(p or ".") != here]
for d in parts:
cand = os.path.join(d, "jq")
if os.path.isfile(cand) and os.access(cand, os.X_OK):
sys.exit(subprocess.run([cand, *argv], input=data).returncode)
sys.stderr.write("real jq not found\n")
sys.exit(127)
'''
@pytest.fixture
def hook_jq16(tmp_path):
"""Same harness as `hook`, plus a jq shim emulating jq 1.6's empty-input exit status."""
bindir = tmp_path / "bin"; bindir.mkdir()
(bindir / "curl").write_text(CURL_SHIM); (bindir / "curl").chmod(0o755)
(bindir / "jq").write_text(_JQ16_SHIM); (bindir / "jq").chmod(0o755)
state = tmp_path / "state"; state.mkdir()
env = dict(os.environ)
env["PATH"] = f"{bindir}{os.pathsep}{env['PATH']}"
env["STUB_DIR"] = str(state)
env["STUB_SHA"] = SHA
env["ETV_GITEA_TOKEN"] = "stub"
env["ETV_GITEA_URL"] = "http://gitea.example"
env.pop("ETV_GITEA_BASICAUTH", None)
class Handle:
def set_pages(self, *pages):
(state / "pages.json").write_text(json.dumps(list(pages)))
def exempted(self):
payload = {"tool_input": {"method": "merge", "owner": "timothy",
"repo": "ersatztv", "pull_number": 42}}
r = subprocess.run(["bash", str(HOOK)], input=json.dumps(payload),
env=env, capture_output=True, text=True)
assert r.returncode == 0, r.stderr
return r.stdout.strip() == ""
return Handle()
def test_jq16_shim_actually_reproduces_the_quirk(hook_jq16, tmp_path):
"""Verify the verifier: the shim must really exit 0 on empty and still work otherwise.
Without this, a shim that silently failed to install would make the test below pass
vacuously — reporting the guard safe on jq 1.6 without ever exercising the quirk."""
jq = tmp_path / "bin" / "jq"
# the quirk itself
empty = subprocess.run([str(jq), "-e", "type"], input=b"", capture_output=True)
assert empty.returncode == 0, "shim did not reproduce jq 1.6's empty-input exit 0"
# ...and everything the shim must NOT disturb
ok = subprocess.run([str(jq), "-r", "length"], input=b"[1,2,3]", capture_output=True)
assert ok.returncode == 0 and ok.stdout.strip() == b"3", f"shim broke real jq delegation: {ok}"
# `jq -n` legitimately has empty stdin and MUST still produce output — the hook's `decide`
# builds its decision JSON that way. A shim that swallowed it made every decision look like a
# passthrough, i.e. manufactured the exemption the test below is trying to disprove.
nullin = subprocess.run([str(jq), "-n", "--arg", "r", "hi", "{a:$r}"], input=b"", capture_output=True)
assert nullin.returncode == 0 and b"hi" in nullin.stdout, f"shim broke `jq -n`: {nullin}"
def test_transport_failure_withholds_exemption_even_on_jq16(hook_jq16):
"""The #631 regression: a mid-pagination transport failure must withhold the exemption on a
host whose jq returns 0 for empty input, not just on jq >= 1.7.
Page 1 = 50 docs rows (so the path list is non-empty and the exemption would genuinely fire),
page 2 = transport failure, page 3 = a legitimate empty page. Pre-fix this returned True."""
hook_jq16.set_pages(_rows([f"docs/f{i}.md" for i in range(50)]), "ERROR", [])
assert hook_jq16.exempted() is False
# --- #643 review findings: fail-opens independent of the jq version -----------------------------
def test_newline_in_filename_does_not_split_into_two_passing_paths(hook):
"""A path containing a newline must NOT be flattened into two allow-list-passing lines.
`chunk` renders paths as newline-delimited text, so `"safe.md\\ndocs/Program.cs"` becomes two
lines — `safe.md` and `docs/Program.cs` — which BOTH match the docs allow-list, while the real
single path ends in `.cs`. Git permits newlines in filenames, so this is reachable. Fail closed
on control characters."""
hook.set_pages([{"filename": "safe.md\ndocs/Program.cs", "status": "added"}])
assert hook.exempted() is False
def test_newline_in_previous_filename_is_also_rejected(hook):
"""Same hole via the rename side — `previous_filename` is flattened identically.
NOTE the payload's second segment must itself be allow-list-PASSING (`docs/Program.cs`, not
`ErsatzTV/Program.cs`). The first version of this test used the latter, which the allow-list
rejects on its own merits, so the test passed with the newline guard entirely removed — it
asserted the outcome without ever exercising the mechanism. That is the same
filter-hides-the-defect trap the guard itself is about."""
hook.set_pages([{"filename": "docs/ok.md", "previous_filename": "safe.md\ndocs/Program.cs",
"status": "renamed"}], [])
assert hook.exempted() is False
@pytest.mark.parametrize("status", ["modified", "copied", "added"])
def test_previous_filename_is_validated_on_NON_renamed_rows_too(hook, status):
"""The validation domain must match the CONSUMPTION domain.
`chunk` emits `(.previous_filename // empty)` for EVERY row regardless of `.status`, but the
field was validated only when `.status == "renamed"`. A row marked `modified` (or Gitea's
distinct `copied`) carrying a newline in `previous_filename` was reproducibly exempted."""
hook.set_pages([{"filename": "docs/ok.md", "status": status,
"previous_filename": "safe.md\ndocs/Program.cs"}], [])
assert hook.exempted() is False
def test_dotdot_path_component_is_rejected(hook):
"""The allow-list anchors `^docs/`, so `docs/../ErsatzTV/Program.cs` matches it. Git will not
produce such a path, but this guard's job is to fail closed on unexpected 2xx shapes rather
than assume a well-behaved peer."""
hook.set_pages([{"filename": "docs/../ErsatzTV/Program.cs", "status": "modified"}], [])
assert hook.exempted() is False
def test_legitimate_rename_within_docs_still_exempts(hook):
"""Positive control: the tightened row schema must not break a real docs-only rename."""
hook.set_pages([{"filename": "docs/b.md", "status": "renamed",
"previous_filename": "docs/a.md"}], [])
assert hook.exempted() is True
def test_short_NONTERMINAL_page_does_not_end_the_enumeration(hook):
""""Fewer rows than we asked for" must not be read as "last page".
Gitea caps `limit` at the server-wide MAX_RESPONSE_ITEMS (default 50, configurable) and may
return fewer rows than requested. A 30-row docs page followed by a page of code would otherwise
complete the enumeration over a PARTIAL list — the same fail-open, reached with no transport
error at all. Only a validated EMPTY page may terminate it."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(30)]),
_rows(["ErsatzTV/Program.cs"]),
[])
assert hook.exempted() is False
def test_short_page_then_empty_page_still_exempts_a_genuinely_docs_only_pr(hook):
"""Positive control for the change above: the stricter terminator must not break the happy path.
Without this, 'never terminate on a short page' could be satisfied by never exempting anything."""
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(30)]), [])
assert hook.exempted() is True
def test_head_moving_mid_enumeration_withholds_the_exemption(hook, tmp_path):
"""Paging is several round-trips; a force-push between them means the assembled list belongs to
no single commit. Page 1 from head A can be combined with a short docs tail from head B while
B's code page is never read. Re-read the head and refuse if it moved."""
(tmp_path / "state" / "pr_sha_after.txt").write_text("b" * 40)
hook.set_pages(_rows([f"docs/f{i}.md" for i in range(30)]), [])
assert hook.exempted() is False
@pytest.mark.parametrize("status", ["Renamed", "RENAMED", "bogus", None])
def test_a_rename_disguised_by_an_unknown_status_is_rejected(hook, status):
"""`renamed => previous_filename REQUIRED` was keyed on an exact lowercase string, so any other
value took the `else true` branch: a `git mv ErsatzTV/Program.cs -> docs/a.md` row whose status
is `"Renamed"` (or absent) validated fine and silently dropped its SOURCE path, reading as
docs-only. `.status` is now checked against the closed set Gitea actually emits."""
row = {"filename": "docs/a.md"}
if status is not None:
row["status"] = status
hook.set_pages([row], [])
assert hook.exempted() is False
def test_gitea_real_status_values_are_accepted(hook):
"""Positive control for the closed set. The real Gitea 1.25.4 value for an edit is `changed`,
NOT `modified` — a closed allow-list built from the wrong vocabulary would reject every real
docs-only PR, which is a far worse failure than the hole it closes."""
hook.set_pages([{"filename": "docs/a.md", "status": "changed"},
{"filename": "docs/b.md", "status": "added"},
{"filename": "docs/c.md", "status": "deleted"}], [])
assert hook.exempted() is True
# --- allow-list ANCHOR pins (round-3 review: three surviving mutants) --------------------------
# The round-3 `..` finding was an anchor subversion, and mutating the anchors showed no test
# covered them: dropping `^` from the docs/ alternative, or `$` from `.md`, both survived.
def test_docs_must_be_a_PREFIX_not_a_substring(hook):
"""Dropping `^` would exempt `ErsatzTV/docs/Program.cs`."""
hook.set_pages([{"filename": "ErsatzTV/docs/Program.cs", "status": "changed"}], [])
assert hook.exempted() is False
def test_md_must_be_a_SUFFIX_not_a_substring(hook):
"""Dropping `$` would exempt `x.md.cs`."""
hook.set_pages([{"filename": "ErsatzTV/x.md.cs", "status": "changed"}], [])
assert hook.exempted() is False
def test_an_empty_file_list_is_never_exempt(hook):
"""`[ -n "$files" ]` guards this: a PR whose enumeration yields no paths must not read as
'all of its files are docs'."""
hook.set_pages([])
assert hook.exempted() is False
def test_array_valued_status_does_not_dodge_the_allow_list(hook):
"""`index` is polymorphic: with an ARRAY argument it does SUBSEQUENCE matching, not element
equality. So `["added",...,"renamed",...] | index(["renamed"])` is 4 (truthy) while
`.status == "renamed"` is false — the row passed the allow-list AND skipped the
`previous_filename REQUIRED` clause, which is the same `git mv code -> docs/` dodge the closed
set exists to block, one type away. Fixed by requiring `.status` to be a string first."""
hook.set_pages([{"filename": "docs/a.md", "status": ["renamed"]}], [])
assert hook.exempted() is False
def test_object_valued_status_is_also_rejected(hook):
hook.set_pages([{"filename": "docs/a.md", "status": {"x": "renamed"}}], [])
assert hook.exempted() is False