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..6c261a78 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.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, " + "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),