From 5095ca889cc3822b2801333cf26eef95abaf7fd0 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 05:27:26 +0000 Subject: [PATCH 1/2] family.py and tests: `Family.rewritten`'s refusal of two `members:` lines is asked of the method again, its docstring says no verb reaches that count since #974, and a refusal that leaves a ref behind fails `assert_unchanged` (#1029) - tests/test_family_add_gitlink_and_null_members.py: two direct-API rows (`members: null` and `members: []` above `name:`, the real block at the end) expect `family-members-unrecognised` with `has 2`, and the file untouched. The premise is asserted too: the reader refuses that text (`key 'members' is written twice`), which is why no verb reaches the count since #974 and the rows must call the method (RV1026 SHOULD 1). - scripts/family.py: `rewritten`'s docstring says so, in the past tense for the reader that took the last of two keys, and that the count stays as defence in depth (RV1026 NIT 1). No code changed. - tests/test_family_add_refusals.py: `snapshot()` records every ref, by name and object (`git for-each-ref --format=%(refname) %(objectname)`), so RV1026's branch mutant now fails `assert_unchanged` (RV1026 NIT 2). The explicit format records a ref whose object is missing instead of failing (`fatal: missing object` under the default format, git 2.43). - #1024's remediation note (item 4) is decided as left: see the PR body. Co-Authored-By: Claude Opus 5.5 --- scripts/family.py | 16 +++++++-- ...est_family_add_gitlink_and_null_members.py | 34 ++++++++++++++++++- tests/test_family_add_refusals.py | 13 ++++++- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/scripts/family.py b/scripts/family.py index bbcfd3a9..5eb7c9c9 100755 --- a/scripts/family.py +++ b/scripts/family.py @@ -655,10 +655,20 @@ def rewritten(self, rows: list[dict]) -> str: EVERY SUCH LINE IS COUNTED FIRST, and anything but one is refused (#920, lane 5's review of #957, SHOULD 1). The rewrite stopped at the first and never counted a second, so a `members: []` above `name:`, - with the real block still at the end, which the reader takes as the - last of two keys and the holder's gate passes, was cut off there with - exit 0: every key below it went, and the gate then failed the + with the real block still at the end, which the reader then took as + the last of two keys and the holder's gate passed, was cut off there + with exit 0: every key below it went, and the gate then failed the commit. With the null words found, `members: null` did the same. + + NO VERB REACHES A COUNT OF TWO since #974 (#1029). The reader refuses + a key written twice, by the line of the second, so `Family.open` + stops such a manifest as `yaml-unparsable` before any verb asks this. + The count stays as defence in depth, for a `family.yaml` changed + between the open and the rewrite, a caller of this method, or a + reader relaxed later, and it is asked of the method itself + (`test_two_members_lines_are_refused_by_the_rewrite_itself`). A + count of none is still reached through a verb: a comment on the + line, or no block at all. """ text = self.path.read_text(encoding="utf-8") lines = text.splitlines() diff --git a/tests/test_family_add_gitlink_and_null_members.py b/tests/test_family_add_gitlink_and_null_members.py index a46db3f9..d687b52f 100644 --- a/tests/test_family_add_gitlink_and_null_members.py +++ b/tests/test_family_add_gitlink_and_null_members.py @@ -465,6 +465,36 @@ def test_a_word_the_reader_refuses_is_no_members_line(tmp_path, assert held.rewritten([]) == text +@pytest.mark.parametrize("line", ["members: null", "members: []"]) +def test_two_members_lines_are_refused_by_the_rewrite_itself( + tmp_path, family_module, line): + """THE COUNT OF TWO, ASKED OF THE METHOD (#1029, lane 4's RV1026, + SHOULD 1). Since #974 the reader refuses a key written twice, so a verb + stops such a manifest at `Family.open` (the premise below) and + `test_a_second_members_line_above_the_block_is_refused` never reaches + this count. It is defence in depth, for a manifest changed after the + open or a caller of the method: a second margin `members:` line above + `name:` is refused with both lines counted, and nothing is rewritten, + rather than the manifest cut off at the first one and every key below + it lost.""" + path = tmp_path / "family.yaml" + text = f"kind: family-manifest\n{line}\nname: x\nmembers: []\n" + path.write_text(text, encoding="utf-8") + with pytest.raises(family_module.Refusal) as read: + family_module.load_yaml(path) + assert (read.value.code, read.value.detail) == ( + "yaml-unparsable", + f"{path}: line 4: key 'members' is written twice"), "premise" + held = family_module.Family(tmp_path, {"members": []}) + with pytest.raises(family_module.Refusal) as caught: + held.rewritten([]) + assert caught.value.code == "family-members-unrecognised" + assert caught.value.detail == ( + f"{path} has 2 `members:` line(s) where exactly one was expected, " + "so this tool will not rewrite it") + assert path.read_text(encoding="utf-8") == text + + def a_line_above_the_name(root: Path, line: str) -> None: """`line` at the margin above `family.yaml`'s `name:`, with the real `members:` block still at the end, committed, as a person commits a @@ -489,7 +519,9 @@ def test_a_second_members_line_above_the_block_is_refused(family, holder, written twice, by the line of the second, so `add` stops at `Family.open`, before the mount and before any line is counted, and the file is untouched. The refusal is the reader's own `yaml-unparsable`: - one line, with no remediation of its own.""" + one line, with no remediation of its own. The count of two itself is + asked of the method, #1029: + `test_two_members_lines_are_refused_by_the_rewrite_itself`.""" a_line_above_the_name(holder, line) opens = [number for number, text in enumerate( (holder / "family.yaml").read_text(encoding="utf-8").splitlines(), 1) diff --git a/tests/test_family_add_refusals.py b/tests/test_family_add_refusals.py index bc976f28..bfd94607 100644 --- a/tests/test_family_add_refusals.py +++ b/tests/test_family_add_refusals.py @@ -147,7 +147,16 @@ def snapshot(root: Path, *, status: bool = True) -> dict: """Everything a refused `add` must leave exactly as it found it. `status=False` for a holder with no `git status` to ask: one whose HEAD - names a missing object answers `fatal: bad object HEAD`.""" + names a missing object answers `fatal: bad object HEAD`. + + EVERY REF, NOT HEAD ALONE (#1029, lane 4's RV1026, NIT 2). A refusal + that left a branch behind, made before a later step refused, kept every + test built on `assert_unchanged` green: RV1026's mutant that makes + `refs/heads/rv-mutant` in `Family.open`'s refused parse passed all 25 + rows of `test_family_add_gitlink_and_null_members.py`. Each ref is + recorded by name and object, and only those two fields, so a ref whose + object is missing is recorded as it is rather than failing the + snapshot.""" def read(name: str) -> bytes | None: target = root / name return target.read_bytes() if target.is_file() else None @@ -160,6 +169,8 @@ def listing(directory: Path) -> list[str] | None: # rather than failing the snapshot. "HEAD": git("rev-parse", "-q", "--verify", "HEAD", cwd=root, check=False).stdout, + "refs": git("for-each-ref", "--format=%(refname) %(objectname)", + cwd=root).stdout, "index": git("ls-files", "-s", cwd=root).stdout, "status": (git("status", "--porcelain", "--ignored", cwd=root).stdout if status else None), From 0bcb6f597707be7bc9b6d7797069bf6444198b37 Mon Sep 17 00:00:00 2001 From: Brett Heap Date: Tue, 20 Oct 2026 05:49:25 +0000 Subject: [PATCH 2/2] tests: the refusal of two `members:` lines is asked of `write_members`, the method that writes, so its "file unchanged" assertion can fail (#1029, RV1039 NIT 1) `test_two_members_lines_are_refused_by_the_rewrite_itself` called `rewritten()`, which never writes, so `assert path.read_text(...) == text` could not fail. It now calls `write_members([])` inside the `pytest.raises` block: the same refusal is expected, and the assertion covers the method that writes. Co-Authored-By: Claude Opus 5.5 --- tests/test_family_add_gitlink_and_null_members.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_family_add_gitlink_and_null_members.py b/tests/test_family_add_gitlink_and_null_members.py index d687b52f..6c261a78 100644 --- a/tests/test_family_add_gitlink_and_null_members.py +++ b/tests/test_family_add_gitlink_and_null_members.py @@ -487,7 +487,7 @@ def test_two_members_lines_are_refused_by_the_rewrite_itself( f"{path}: line 4: key 'members' is written twice"), "premise" held = family_module.Family(tmp_path, {"members": []}) with pytest.raises(family_module.Refusal) as caught: - held.rewritten([]) + held.write_members([]) assert caught.value.code == "family-members-unrecognised" assert caught.value.detail == ( f"{path} has 2 `members:` line(s) where exactly one was expected, "