diff --git a/actions/house-rules/house_rules.py b/actions/house-rules/house_rules.py index af430f8..6d6d123 100644 --- a/actions/house-rules/house_rules.py +++ b/actions/house-rules/house_rules.py @@ -39,19 +39,41 @@ def digest(text: str) -> str: return hashlib.sha256(normalize(text).encode("utf-8")).hexdigest() +def without_fenced_examples(text: str) -> str: + """Mask Markdown fences without changing offsets used for byte-preserving writes.""" + result = [] + fence = None + for line in text.splitlines(keepends=True): + if fence is None: + opening = re.match(r" {0,3}(`{3,}|~{3,})([^\r\n]*)", line) + if opening and not (opening[1][0] == "`" and "`" in opening[2]): + fence = opening[1] + else: + result.append(line) + continue + else: + closing = re.fullmatch(r" {0,3}" + re.escape(fence[0]) + + r"{" + str(len(fence)) + r",}[ \t]*(?:\r\n|\r|\n)?", line) + if closing: + fence = None + result.append("".join(c if c in "\r\n" else " " for c in line)) + return "".join(result) + + def parse_blocks(text: str): """Return (blocks, spans). A span is the half-open range of one block in `text`.""" + markers = without_fenced_examples(text) blocks = [] spans = [] position = 0 while True: - marker = MARKER.search(text, position) + marker = MARKER.search(markers, position) if marker is None: break match = START.match(text, marker.start()) if match is None: raise ValueError("house rules: malformed or unexpected block marker") - closing = MARKER.search(text, match.end()) + closing = MARKER.search(markers, match.end()) if closing is None: raise ValueError(f"house rules: {match.group(1)} block is not closed") end_at = closing.start() @@ -224,8 +246,10 @@ def apply_blocks(agents: str, rendered: list[str]) -> str: generated, _ = parse_blocks(text) replacements[generated[0]["name"]] = text if not spans: - separator = "\n" if agents and not agents.startswith(("\n", "\r")) else "" - return "".join(rendered) + separator + agents + bom = "\ufeff" if agents.startswith("\ufeff") else "" + local = agents[len(bom):] + separator = "\n" if local and not local.startswith(("\n", "\r")) else "" + return bom + "".join(rendered) + separator + local names = [block["name"] for block in blocks] if len(set(names)) != len(names): raise ValueError("house rules: a block name is repeated") diff --git a/tests/test_house_rules.py b/tests/test_house_rules.py index 9bcd0b7..f453d1f 100644 --- a/tests/test_house_rules.py +++ b/tests/test_house_rules.py @@ -61,9 +61,35 @@ class HouseRulesTests(unittest.TestCase): house_rules.write_agents(self.app, self.source, require_rust) def check(self, require_rust: bool = True): - text = (self.app / "AGENTS.md").read_text() + text = (self.app / "AGENTS.md").read_text(encoding="utf-8") return house_rules.check_file(text, self.source, require_rust) + def test_initial_write_keeps_bom_at_start(self): + target = self.app / "AGENTS.md" + target.write_bytes("\ufeff# Local\r\nKeep me.".encode("utf-8")) + self.write() + output = target.read_bytes() + self.assertTrue(output.startswith("\ufeff\n", 1)[1].split("", 1)[0] text = text.replace(house_rules.digest("Core two.\n"), house_rules.digest(body), 1) @@ -148,7 +174,7 @@ class HouseRulesTests(unittest.TestCase): def test_a_digest_that_does_not_match_the_bytes_fails(self): self.write() - text = (self.app / "AGENTS.md").read_text() + text = (self.app / "AGENTS.md").read_text(encoding="utf-8") text = text.replace(house_rules.digest("Core two.\n"), "0" * 64, 1) (self.app / "AGENTS.md").write_text(text) failures, _ = self.check() @@ -157,7 +183,7 @@ class HouseRulesTests(unittest.TestCase): def test_an_intact_older_commit_warns(self): house_rules.write_agents(self.app, self.source, True) # Move the recorded revision back to the first commit without changing the checker's view of HEAD. - text = (self.app / "AGENTS.md").read_text() + text = (self.app / "AGENTS.md").read_text(encoding="utf-8") old_core = house_rules.render("core", self.first, "Core one.\n") old_rust = house_rules.render("rust", self.first, "Rust one.\n") (self.app / "AGENTS.md").write_text(old_core + old_rust) @@ -190,7 +216,7 @@ class HouseRulesTests(unittest.TestCase): def test_a_missing_revision_could_not_be_checked(self): self.write() - text = (self.app / "AGENTS.md").read_text().replace(self.second, "a" * 40) + text = (self.app / "AGENTS.md").read_text(encoding="utf-8").replace(self.second, "a" * 40) (self.app / "AGENTS.md").write_text(text) failures, _ = self.check() self.assertTrue(any(item.startswith("house rules: could not check") for item in failures)) @@ -249,7 +275,7 @@ class HouseRulesTests(unittest.TestCase): git(self.source, "checkout", "-b", "side") self.commit_standards("Side core.\n", "Side rust.\n") self.write() - text = (self.app / "AGENTS.md").read_text() + text = (self.app / "AGENTS.md").read_text(encoding="utf-8") self.assertIn(self.second, text) self.assertIn("Core two.", text) self.assertNotIn("Side core.", text) @@ -258,7 +284,7 @@ class HouseRulesTests(unittest.TestCase): def test_origin_main_takes_priority_over_local_main(self): git(self.source, "update-ref", "refs/remotes/origin/main", self.first) self.write() - text = (self.app / "AGENTS.md").read_text() + text = (self.app / "AGENTS.md").read_text(encoding="utf-8") self.assertIn(self.first, text) self.assertIn("Core one.", text) self.assertEqual(self.check(), ([], [])) @@ -277,7 +303,7 @@ class HouseRulesTests(unittest.TestCase): def test_detached_head_uses_named_main(self): git(self.source, "checkout", "--detach", self.first) self.write() - self.assertIn(self.second, (self.app / "AGENTS.md").read_text()) + self.assertIn(self.second, (self.app / "AGENTS.md").read_text(encoding="utf-8")) self.assertEqual(self.check(), ([], [])) def test_ancestry_errors_are_not_reported_as_non_ancestry(self): @@ -312,7 +338,7 @@ class HouseRulesTests(unittest.TestCase): class HouseRulesWiringTests(unittest.TestCase): def test_the_action_passes_its_inputs_through_the_environment(self): - action = yaml.load((ROOT / "actions/house-rules/action.yml").read_text(), Loader=yaml.BaseLoader) + action = yaml.load((ROOT / "actions/house-rules/action.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader) step = action["runs"]["steps"][0] self.assertNotIn("${{", step["run"]) self.assertEqual(step["env"]["WORKSPACE_DIR"], "${{ inputs.working-directory }}") @@ -321,7 +347,7 @@ class HouseRulesWiringTests(unittest.TestCase): self.assertEqual(action["inputs"]["require-rust"]["default"], "true") def test_the_workflow_checks_out_craft_contrib_and_runs_the_action(self): - workflow = yaml.load((ROOT / ".github/workflows/house-rules.yml").read_text(), Loader=yaml.BaseLoader) + workflow = yaml.load((ROOT / ".github/workflows/house-rules.yml").read_text(encoding="utf-8"), Loader=yaml.BaseLoader) job = workflow["jobs"]["house-rules"] self.assertEqual(workflow["permissions"], {"contents": "read"}) self.assertIn("timeout-minutes", job)