"""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()] # `.base.ref` is served because the hook now reads it and threads it to the enumeration as the # required 5th argument (ersatztv#698 route 1). Without it the hook passes an empty base, the # script exits 2, and EVERY docs-only exemption silently stops being granted — which is exactly # how this stub failed when the argument was added: the eight failures were all positive cases. # Fail-closed, so not dangerous, but it would have made the advisory hook prompt on every # docs-only PR. # # `.base.sha` joined it for the same reason one release later (ersatztv#707), and the symptom # repeated almost exactly: NINE failures, every one a positive control, because the enumeration # now binds the base's TIP across the paging window and an absent tip fails closed. Worth stating # as a standing property of this stub rather than a second anecdote — it serves the fields the # SHARED enumeration reads, so every new binding the script learns must be modelled here too, and # the tell is always a wave of positive cases going red at once. base_sha = os.environ.get("STUB_BASE_SHA", "b" * 40) alt_base = state / "base_sha_after.txt" if alt_base.exists() and nread >= 1: base_sha = alt_base.read_text().strip() print(json.dumps({"head": {"sha": shas[0]}, "base": {"ref": os.environ.get("STUB_BASE", "main"), "sha": base_sha}, "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" # noqa: S105 - deliberately fake; the real credential comes from the environment 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`: 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]`. """ 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" # noqa: S105 - deliberately fake; the real credential comes from the environment 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 latter is rejected by the allow-list on its own merits, so a test using it passes with the newline guard entirely removed — it asserts 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 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. Measured on 1.25.4; a 200-PR corpus on 1.27.1 (2026-08-28, ersatztv#747) showed `changed`, `added`, `renamed` and `deleted`. `copied`/`modified` did not appear there, which does not show they are never emitted.""" 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 (three surviving mutants) ------------------------------------------ # The `..` 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 # --- The `grep -q` / pipefail inversion, on the ADVISORY side (ersatztv#698) -------------------- # # The enforced gate gained large-input regression tests while the # hook — which carries the SAME predicate — did not. The hook's blast radius is smaller (a missing # prompt, not a green required check), but `ci.shared-pr-file-enumeration` exists precisely because # the copy with LESS authority is the one that quietly keeps a bug. So test both. # # `grep -q` exits at its first match; the producer then takes SIGPIPE (141) once the path list exceeds # the pipe buffer, and under `set -o pipefail` a MATCH is reported as a FAILED pipeline — inverting the # negated docs-only test. ~171KB is needed to cross the threshold; every other test in this file uses a # handful of short paths, which is exactly why the class was invisible here. def _many_docs(n=1900): return [f"docs/{'d' * 40}-{i:040d}.md" for i in range(n)] def test_a_LARGE_pr_containing_a_code_file_is_NOT_exempt(hook): """The code file goes FIRST so the guard matches immediately and the producer is left with the bulk of ~171KB still to write.""" hook.set_pages(_rows(["A.cs", *_many_docs()])) assert hook.exempted() is False, ( "a large PR containing A.cs was granted the docs-only exemption — the predicate inverted" ) def test_positive_control_a_LARGE_genuinely_docs_only_pr_IS_still_exempt(hook): """Guards the opposite failure: if large lists merely errored, the test above would pass while the hook prompted on every big docs PR. Without this, 'fixed' and 'broken' are indistinguishable.""" hook.set_pages(_rows(_many_docs())) assert hook.exempted() is True, "a large but genuinely docs-only PR lost its exemption"