diff --git a/tests/test_stamp_native_hooks.py b/tests/test_stamp_native_hooks.py index 6e202b582b..3d2d9aae11 100644 --- a/tests/test_stamp_native_hooks.py +++ b/tests/test_stamp_native_hooks.py @@ -6,6 +6,7 @@ import subprocess import tempfile import unittest from pathlib import Path +from unittest.mock import patch SPEC = importlib.util.spec_from_file_location( "stamp_native_hooks", Path(__file__).resolve().parents[1] / "tools/stamp_native_hooks.py" @@ -88,6 +89,84 @@ class StampTests(unittest.TestCase): self.assertEqual(json.loads((self.root / stamp.CATALOG).read_text())["hooks"], []) self.assertEqual(json.loads((self.root / stamp.CATALOG).read_text())["helpers"], fragment["helpers"]) + def test_selected_fragments_keep_merge_order_and_others_stay_pending(self): + self.git("add", ".") + self.git("commit", "-qm", "base") + # An older, unfinished API must not stop two later, reviewed APIs being registered. + for name in ("blocked", "z", "a"): + self.fragment(name) + self.git("add", ".") + self.git("commit", "-qm", name) + blocked = self.root / stamp.FRAGMENTS / "blocked.json" + pending_comment = self.root / "pending.rs" + pending_comment.write_text("/// Since API @API:blocked@.\n") + fragment = json.loads(blocked.read_text()) + fragment["comments"] = ["pending.rs"] + blocked.write_text(json.dumps(fragment)) + api = self.root / stamp.MOD_API + api.write_text("/// APIs @API:blocked@, @API:z@ and @API:a@.\n") + before = {path: path.read_bytes() for path in (blocked, pending_comment)} + + # CLI order must not override merge order; duplicate selections must not consume numbers. + self.assertFalse(stamp.stamp(False, ["a", "z", "a"])) + source = (self.root / stamp.SOURCE).read_text() + self.assertIn("API 8 adds z.", source) + self.assertIn("API 9 adds a.", source) + self.assertIn("NATIVE_HOOKS_API: u32 = 9;", source) + self.assertEqual(api.read_text(), "/// APIs @API:blocked@, 8 and 9.\n") + docs = (self.root / stamp.DOCS).read_text() + self.assertIn("API 8 adds z; requires 8.", docs) + self.assertIn("API 9 adds a; requires 9.", docs) + self.assertEqual({path: path.read_bytes() for path in before}, before) + self.assertEqual(sorted(path.stem for path in blocked.parent.glob("*.json")), ["blocked"]) + hooks = json.loads((self.root / stamp.CATALOG).read_text())["hooks"] + self.assertEqual([h["name"] for h in hooks], ["a", "z"]) + + # Once ready, the skipped API gets the next free number, even though it merged first. + self.assertTrue(stamp.stamp(True)) + self.assertFalse(stamp.stamp(False)) + self.assertIn("API 10 adds blocked.", (self.root / stamp.SOURCE).read_text()) + self.assertIn("API 10 adds blocked; requires 10.", (self.root / stamp.DOCS).read_text()) + self.assertEqual(api.read_text(), "/// APIs 10, 8 and 9.\n") + self.assertEqual(pending_comment.read_text(), "/// Since API 10.\n") + hooks = json.loads((self.root / stamp.CATALOG).read_text())["hooks"] + self.assertEqual([h["name"] for h in hooks], ["a", "z", "blocked"]) + self.assertFalse(stamp.stamp(False)) + + def test_selection_rejects_unknown_names_before_writing_even_in_check_mode(self): + path = self.fragment("ready") + before = {p: p.read_bytes() for p in self.root.rglob("*") if p.is_file()} + for check in (False, True): + with self.subTest(check=check): + with self.assertRaisesRegex(ValueError, "unknown pending fragment.*typo"): + stamp.stamp(check, ["ready", "typo"]) + self.assertEqual({p: p.read_bytes() for p in before}, before) + path.unlink() + with self.assertRaisesRegex(ValueError, "unknown pending fragment.*ready"): + stamp.stamp(False, ["ready"]) + + def test_selection_cli_check_is_read_only_and_can_stamp_helper_only(self): + self.git("add", ".") + self.git("commit", "-qm", "base") + path = self.fragment("ready") + fragment = json.loads(path.read_text()) + fragment["hooks"] = [] + fragment["helpers"] = [{"name": "helper", "target": "target::helper"}] + path.write_text(json.dumps(fragment)) + blocked = self.fragment("blocked") + before = {p: p.read_bytes() for p in self.root.rglob("*") if p.is_file()} + with patch("sys.argv", ["stamp_native_hooks.py", "--check", "--only", "ready"]): + self.assertEqual(stamp.main(), 1) + self.assertEqual({p: p.read_bytes() for p in before}, before) + with patch("sys.argv", ["stamp_native_hooks.py", "--only", "ready"]): + self.assertEqual(stamp.main(), 0) + catalog = json.loads((self.root / stamp.CATALOG).read_text()) + self.assertEqual(catalog["hooks"], []) + self.assertEqual(catalog["helpers"], fragment["helpers"]) + self.assertIn("NATIVE_HOOKS_API: u32 = 8;", (self.root / stamp.SOURCE).read_text()) + self.assertFalse(path.exists()) + self.assertEqual(blocked.read_bytes(), before[blocked]) + def test_catalog_facts_travel_with_the_hook(self): self.git("add", ".") self.git("commit", "-qm", "base") diff --git a/tools/native-hook-fragments/README.md b/tools/native-hook-fragments/README.md index 03ea29c508..37f95431e3 100644 --- a/tools/native-hook-fragments/README.md +++ b/tools/native-hook-fragments/README.md @@ -26,7 +26,23 @@ not API yet; do not depend on it until it has a number. After hook PRs merge, run `python3 tools/stamp_native_hooks.py` on current main and open a PR with the result. The tool assigns one API number per -fragment in first-parent merge order. It consumes all pending fragments. -`--check` exits nonzero when any are pending. Running the tool again does -nothing. For local testing only, run it on a hook branch without committing +fragment in first-parent merge order. By default it consumes all pending +fragments; running it again does nothing. `--check` exits nonzero when any +are pending. + +To register reviewed fragments while unrelated contracts are still being +settled, select their names without `.json`: + +```sh +python3 tools/stamp_native_hooks.py --only gui_render_post client_services +``` + +Only the selected fragments get numbers, still in merge order regardless of +argument order. All others and their markers remain pending; a later stamp +continues from the current API number. Missing names (including already +stamped ones) fail before writing anything. `--check --only ...` checks the +selection without changing files. Selection does not check dependencies: +include any pending contracts the selected APIs require. + +For local testing only, run the tool on a hook branch without committing the result. If another stamp PR merges first, regenerate from current main. diff --git a/tools/stamp_native_hooks.py b/tools/stamp_native_hooks.py index ddbac2dd01..2b81298642 100644 --- a/tools/stamp_native_hooks.py +++ b/tools/stamp_native_hooks.py @@ -16,8 +16,14 @@ CATALOG = Path("native-hooks.json") MOD_API = Path("crates/horizon-client/src/mod_api.rs") -def stamp(check): +def stamp(check, only=None): paths = sorted((ROOT / FRAGMENTS).glob("*.json")) + if only is not None: + selected = set(only) + missing = selected - {path.stem for path in paths} + if missing: + raise ValueError(f"unknown pending fragment(s): {', '.join(sorted(missing))}") + paths = [path for path in paths if path.stem in selected] if not paths: return False if check: @@ -45,7 +51,7 @@ def stamp(check): names = {hook["name"] for hook in catalog["hooks"]} helper_names = {helper["name"] for helper in catalog.get("helpers", [])} extra = {} - # Match the build-time generator's fragment order so stamping preserves the generated API. + # Append the selected entries in filename order, as the build-time generator reads fragments. for path in sorted(ordered): fragment = json.loads(path.read_text()) for hook in fragment["hooks"]: @@ -109,9 +115,11 @@ def stamp(check): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--check", action="store_true", help="fail if fragments are pending") + parser.add_argument("--only", nargs="+", metavar="FRAGMENT", + help="stamp only these pending fragment names (without .json), in merge order") args = parser.parse_args() try: - return int(stamp(args.check)) + return int(stamp(args.check, args.only)) except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error: print(f"stamp_native_hooks: {error}", file=sys.stderr) return 1