diff --git a/spec/validate.py b/spec/validate.py index 707eba4a..aa303b8d 100755 --- a/spec/validate.py +++ b/spec/validate.py @@ -1480,12 +1480,32 @@ def check_selector(where, applies_to): errors.append( f"files.json: repo-owned tree {source} source must be a normalized path below the repository root" ) - elif (link := symlinked_component(ROOT, source)) is not None: + continue + if (link := symlinked_component(ROOT, source)) is not None: errors.append( f"files.json: repo-owned tree {source} has a symlinked component {link}, below which git tracks no file" ) elif not (ROOT / source).is_dir(): errors.append(f"files.json: repo-owned tree {source} is missing or not a directory") + owned_path = pathlib.PurePosixPath(source) + for tree in validated_trees: + tree_source = pathlib.PurePosixPath(tree["source"]) + if ( + owned_path == tree_source + or owned_path in tree_source.parents + or tree_source in owned_path.parents + ): + errors.append( + f"files.json: repo-owned tree {source} and tree source {tree_source} have overlapping ownership" + ) + for item in baseline: + if not isinstance(item, dict) or not isinstance(item.get("path"), str): + continue + item_path = pathlib.PurePosixPath(item["path"]) + if item_path == owned_path or owned_path in item_path.parents: + errors.append( + f"files.json: repo-owned tree {source} and baseline path {item_path} have overlapping ownership" + ) copilot_path = ROOT / ".github/copilot-instructions.md" try: diff --git a/tests/test_spec_validate.py b/tests/test_spec_validate.py index 2ce5fe11..7fa2009c 100755 --- a/tests/test_spec_validate.py +++ b/tests/test_spec_validate.py @@ -15,6 +15,7 @@ import unittest import unittest.mock from pathlib import Path +from typing import ClassVar sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "spec")) import audit @@ -686,8 +687,12 @@ class RepoOwnedTreeGateCase(unittest.TestCase): Every run adds a marker entry with no reason, which the loop always reports, so each absence assertion proves the loop ran. """ - def run_against(self, owned: list[object], make_tree=None) -> str: - output = run_gate_on_files({"repoOwnedTrees": [*owned, {"source": "real"}]}, make_tree) + def run_against( + self, owned: list[object], make_tree=None, overrides: dict[str, object] | None = None + ) -> str: + output = run_gate_on_files( + {**(overrides or {}), "repoOwnedTrees": [*owned, {"source": "real"}]}, make_tree + ) self.assertIn( "files.json: repo-owned tree real reason must be a non-empty string", output, @@ -725,6 +730,54 @@ def test_a_plain_entry_draws_no_error(self) -> None: output = self.run_against([{"source": "real/sub", "reason": "r"}]) self.assertNotIn("repo-owned tree real/sub", output) + CARRIED: ClassVar[dict[str, object]] = { + "trees": [ + { + "source": "carried/kit", + "target": "carried/kit", + "fidelity": "verbatim-tree", + "appliesTo": "*", + "include": ["**/*"], + "prune": True, + } + ], + "baseline": [ + {"path": "shared/doc.md", "fidelity": "verbatim", "whole": True, "appliesTo": "*"} + ], + } + + @staticmethod + def make_carried(root: Path) -> None: + (root / "carried" / "kit" / "inner").mkdir(parents=True) + (root / "shared").mkdir() + (root / "shared" / "doc.md").write_text("doc\n", encoding="utf-8") + + def run_carried(self, source: str) -> str: + return self.run_against( + [{"source": source, "reason": "r"}], self.make_carried, self.CARRIED + ) + + def test_a_source_overlapping_a_tree_source_is_refused(self) -> None: + for source in ("carried/kit", "carried", "carried/kit/inner"): + with self.subTest(source=source): + self.assertIn( + f"files.json: repo-owned tree {source} and tree source carried/kit have overlapping ownership", + self.run_carried(source), + ) + + def test_a_source_holding_a_baseline_path_is_refused(self) -> None: + for source in ("shared", "shared/doc.md"): + with self.subTest(source=source): + self.assertIn( + f"files.json: repo-owned tree {source} and baseline path shared/doc.md have overlapping ownership", + self.run_carried(source), + ) + + def test_a_source_beside_carried_content_draws_no_error(self) -> None: + output = self.run_carried("real/sub") + self.assertNotIn("repo-owned tree real/sub", output) + self.assertNotIn("overlapping ownership", output) + def test_the_shipped_declaration_draws_no_error(self) -> None: shipped = json.loads((validate.ROOT / "spec" / "files.json").read_text(encoding="utf-8")) output = self.run_against(shipped["repoOwnedTrees"])