mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Keep unselected contracts pending, preserve merge order within the selection, and reject unknown names before writing. Co-Authored-By: Codex GPT-6 <[email protected]>
130 lines
5.9 KiB
Python
130 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Assign API numbers to pending native hook fragments."""
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
FRAGMENTS = Path("tools/native-hook-fragments")
|
|
SOURCE = Path("crates/horizon-client/src/game/native_hooks.rs")
|
|
DOCS = Path("docs/native-mods.md")
|
|
CATALOG = Path("native-hooks.json")
|
|
# Hand-written API types can carry markers too; generated copies are built from stamped sources.
|
|
MOD_API = Path("crates/horizon-client/src/mod_api.rs")
|
|
|
|
|
|
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:
|
|
print(f"{len(paths)} pending native hook fragment(s)")
|
|
return True
|
|
|
|
# First-parent additions give merge order, not author timestamps or filenames.
|
|
result = subprocess.run(
|
|
["git", "log", "--first-parent", "--reverse", "--diff-merges=first-parent",
|
|
"--format=", "--name-only", "--diff-filter=A", "--", str(FRAGMENTS)],
|
|
cwd=ROOT, check=True, text=True, capture_output=True,
|
|
)
|
|
pending = {p.relative_to(ROOT).as_posix(): p for p in paths}
|
|
ordered = [pending.pop(line) for line in result.stdout.splitlines() if line in pending]
|
|
# A developer can stamp uncommitted fragments for a local test.
|
|
ordered.extend(pending[name] for name in sorted(pending))
|
|
|
|
source = (ROOT / SOURCE).read_text()
|
|
docs = (ROOT / DOCS).read_text()
|
|
catalog = json.loads((ROOT / CATALOG).read_text())
|
|
match = re.search(r"^pub\(crate\) const NATIVE_HOOKS_API: u32 = (\d+);$", source, re.M)
|
|
if not match:
|
|
raise ValueError("missing numeric NATIVE_HOOKS_API")
|
|
version = int(match.group(1))
|
|
names = {hook["name"] for hook in catalog["hooks"]}
|
|
helper_names = {helper["name"] for helper in catalog.get("helpers", [])}
|
|
extra = {}
|
|
# 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"]:
|
|
if hook["name"] in names:
|
|
raise ValueError(f"duplicate hook: {hook['name']}")
|
|
names.add(hook["name"])
|
|
entry = {"name": hook["name"], "version": 1, "target": hook["target"]}
|
|
# The catalog's optional facts about a hook travel with it.
|
|
entry.update({k: hook[k] for k in ("neoforge", "side", "cancellable", "mod_api") if k in hook})
|
|
catalog["hooks"].append(entry)
|
|
for helper in fragment.get("helpers", []):
|
|
if helper["name"] in helper_names:
|
|
raise ValueError(f"duplicate helper: {helper['name']}")
|
|
helper_names.add(helper["name"])
|
|
catalog.setdefault("helpers", []).append(helper)
|
|
for path in ordered:
|
|
fragment = json.loads(path.read_text())
|
|
version += 1
|
|
marker = f"@API:{path.stem}@"
|
|
rust_summary = fragment["rust_summary"].replace(marker, str(version))
|
|
doc_summary = fragment["doc_summary"].replace(marker, str(version))
|
|
if not rust_summary or not doc_summary:
|
|
raise ValueError(f"{path}: expected summaries")
|
|
rust_entry = f"//! API {version} adds " + rust_summary.replace("\n", "\n//! ") + "\n//!\n"
|
|
if "//! API " not in source:
|
|
raise ValueError("missing source history insertion point")
|
|
source = source.replace("//! API ", rust_entry + "//! API ", 1)
|
|
docs_marker = "\n## What the launcher does"
|
|
if docs_marker not in docs:
|
|
raise ValueError("missing documentation insertion point")
|
|
docs = docs.replace(docs_marker, f"\nAPI {version} adds {doc_summary}\n" + docs_marker, 1)
|
|
for filename in fragment.get("comments", []):
|
|
file = Path(filename)
|
|
if file.is_absolute() or ".." in file.parts or file in (SOURCE, DOCS, CATALOG, MOD_API):
|
|
raise ValueError(f"invalid comment path: {filename}")
|
|
text = extra.get(file, (ROOT / file).read_text())
|
|
if marker not in text:
|
|
raise ValueError(f"missing {marker} in {filename}")
|
|
extra[file] = text.replace(marker, str(version))
|
|
# Most hook comments live in the source itself, and some hook lists in the docs.
|
|
source = source.replace(marker, str(version))
|
|
docs = docs.replace(marker, str(version))
|
|
if (ROOT / MOD_API).exists():
|
|
text = extra.get(MOD_API, (ROOT / MOD_API).read_text())
|
|
extra[MOD_API] = text.replace(marker, str(version))
|
|
|
|
source = re.sub(
|
|
r"^(pub\(crate\) const NATIVE_HOOKS_API: u32 = )\d+;$",
|
|
lambda m: f"{m.group(1)}{version};", source, count=1, flags=re.M)
|
|
(ROOT / SOURCE).write_text(source)
|
|
(ROOT / DOCS).write_text(docs)
|
|
(ROOT / CATALOG).write_text(json.dumps(catalog, indent=2, ensure_ascii=False) + "\n")
|
|
for file, content in extra.items():
|
|
(ROOT / file).write_text(content)
|
|
for path in ordered:
|
|
path.unlink()
|
|
print(f"Stamped {len(ordered)} fragment(s), through API {version}")
|
|
return False
|
|
|
|
|
|
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, args.only))
|
|
except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
|
|
print(f"stamp_native_hooks: {error}", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|