feat: implement deterministic Horizon Mixins MVP

Add declarative planning, private same-crate helpers, AST transforms, atomic staging, locked Cargo checks, compilation fixtures, snapshots and Linux CI. Verify an isolated pinned horizon-core prototype and record build measurements.
This commit is contained in:
levzzz committed 2026-10-28 00:25:26 +05:00
1 parent 5005eb0cae
commit 2dfb8c10b7
74 files changed
+4694 -160

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
name: Rust MVP
on:
push:
pull_request:
permissions:
contents: read
jobs:
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install pinned stable toolchain
run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy
- run: cargo fetch --locked
- run: python3 tools/feasibility.py
- run: cargo fmt --all --check
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
- run: cargo test --workspace --locked
+2
View File
@@ -1 +1,3 @@
/target/
/.horizon-mixins/
__pycache__/
Generated
+635
View File
@@ -0,0 +1,635 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys",
]
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "camino"
version = "1.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591"
dependencies = [
"serde_core",
]
[[package]]
name = "cargo-platform"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea"
dependencies = [
"serde",
]
[[package]]
name = "cargo_metadata"
version = "0.19.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba"
dependencies = [
"camino",
"cargo-platform",
"semver",
"serde",
"serde_json",
"thiserror",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "horizon-mixins-cli"
version = "0.1.0"
dependencies = [
"clap",
"fs2",
"horizon-mixins-core",
"horizon-mixins-transform",
"rustix",
"serde",
"serde_json",
"tempfile",
"toml",
]
[[package]]
name = "horizon-mixins-core"
version = "0.1.0"
dependencies = [
"cargo_metadata",
"quote",
"serde",
"serde_json",
"sha2",
"syn 2.0.119",
"thiserror",
"toml",
]
[[package]]
name = "horizon-mixins-transform"
version = "0.1.0"
dependencies = [
"horizon-mixins-core",
"prettyplease",
"quote",
"syn 2.0.119",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn 2.0.119",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rustix"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+28
View File
@@ -0,0 +1,28 @@
[workspace]
members = ["crates/horizon-mixins-core", "crates/horizon-mixins-transform", "crates/horizon-mixins-cli"]
exclude = ["fixtures/feasibility", "fixtures/privacy-failure", "fixtures/cycle", "fixtures/game"]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
[workspace.dependencies]
horizon-mixins-core = { path = "crates/horizon-mixins-core" }
horizon-mixins-transform = { path = "crates/horizon-mixins-transform" }
syn = { version = "2", features = ["full", "visit", "visit-mut", "extra-traits"] }
quote = "1"
proc-macro2 = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
cargo_metadata = "0.19"
prettyplease = "0.2"
thiserror = "2"
sha2 = "0.10"
fs2 = "0.4"
clap = { version = "4", features = ["derive"] }
tempfile = "3"
rustix = { version = "1", features = ["fs"] }
+100 -35
View File
@@ -1,50 +1,115 @@
# horizon-mixins
**Status: design specification; no implemented Mixin engine yet.**
Proposed stable-Rust, build-time source Mixins for [Horizon](https://github.com/ludovic111/horizon), the Rust Minecraft rewrite. This repository is prepared as a Codex implementation handoff.
## Goal
Let trusted native mods inject into Horizon Rust functions **before** Cargo compilation without requiring manual runtime hook sites. Match and validate injection locations against original source, perform deterministic AST transformations, build in a separate staging workspace, and cache compilations and completed executables. Preserve Horizon's existing WebAssembly mod system.
A working stable-Rust CLI for deterministic, build-time source mixins for
[Horizon](https://github.com/ludovic111/horizon). The MVP implements declarative
TOML mods, HEAD, BEFORE_CALL, OVERWRITE, JSON planning, atomic Linux staging,
and compilation-tested fixtures. Native mods run with your user privileges.
```text
Pinned Horizon source + mod manifests
↓
Index original functions and validate targets
↓
Resolve and conflict-check combined injection plan
↓
Transform Rust AST with syn, stage generated workspace
↓
Pinned Cargo/rustc + incremental build cache
↓
Modded executable
Pristine Cargo workspace + independent mod bundles
→ index source → validate combined plan → transform AST
→ separate staged workspace → cargo test --locked --offline
```
## Implementation documentation
The three workspace crates are `horizon-mixins-core` (manifests, indexing,
planning, diagnostics), `horizon-mixins-transform` (AST rewriting), and
`horizon-mixins-cli` (the `horizon-mixins` binary). Rust 1.95.0 and Cargo.lock
are pinned. Cargo metadata and builds use offline, locked dependency resolution.
Run `cargo fetch --locked` once to populate the tool's dependencies.
1. [AGENTS.md](AGENTS.md) — Codex implementation instructions, MVP acceptance criteria.
2. [Architecture](docs/architecture.md) — crates, indexing, data flow, privacy and dependency-cycle issues.
3. [Mixin API](docs/mixin-api.md) — proposed HEAD, BEFORE_CALL and OVERWRITE syntax, future injection types.
4. [Transformations](docs/transformations.md) — syntactic selector matching, rewrite ordering, validation and diagnostics.
5. [Build and cache](docs/build-and-cache.md) — launcher pipeline, stable staging and Cargo reuse.
6. [Security](docs/security.md) — unsandboxed native code and supported boundaries.
7. [Roadmap](docs/roadmap.md) — staged implementation, compile-tested fixtures, CI.
8. [Manifest example](examples/horizon-mixin.toml) — illustrative only.
Build and run the complete two-mod fixture:
## Verified Horizon context (2026-10-27)
```sh
cargo build --locked -p horizon-mixins-cli
./target/debug/horizon-mixins plan \
--workspace fixtures/game --mods fixtures/mods --out target/plan.json
./target/debug/horizon-mixins apply \
--workspace fixtures/game --mods fixtures/mods --out target/modded-game
./target/debug/horizon-mixins check \
--workspace fixtures/game --mods fixtures/mods --out target/modded-game \
--trust-native-mods
```
Horizon's Cargo workspace contains `horizon-core`, `horizon-world`, `horizon-client`, `horizon-server`, `horizon-mod-sdk`, `horizon-mods`, and `cargo-horizon-mod`, among others. Its launcher lives separately in `launcher/`; mods currently use Rust compiled to WebAssembly. Root Cargo.toml declares edition 2024 / minimum Rust 1.85 while README requests Rust 1.90+ for source builds; confirm toolchain at integration. The fast build profile enables incremental compilation and disables LTO.
`plan` writes the full validated plan to `--out`. `apply` and `check` rebuild
that plan from pristine source, then stage all input files. They never patch
the original checkout. `check` executes the staged workspace's tests; it
requires explicit native-code trust. Successful commands print JSON to stdout;
failures print a structured diagnostic to stderr and exit nonzero. JSON plans
are review artifacts; the CLI does not execute saved plans.
## MVP scope
`check --package NAME` limits Cargo to a package; `--target-dir PATH` selects
a reusable, locked target directory. The default target directory is a stable
sibling of the stage. Outputs, mods and originals must be separate. Existing
output directories need the generated stage marker. Unchanged files keep their
bytes and mtimes, including across publication of changed stages.
A source-only stable-Rust transformer using `syn`, `quote`, declarative manifests and exact syntax selectors. Implement HEAD, BEFORE_CALL, OVERWRITE, deterministic conflict resolution, immutable input, staged output and compilation-tested fixtures. Not full semantic Rust resolution, macro expansion, arbitrary local injection, MIR rewriting or runtime binary patching.
A bundle contains `horizon-mixin.toml` and helper `.rs` files. For example:
**Important:** Source rewriting does not bypass Rust privacy, type checking, borrow checking or Cargo dependency cycles. Native mods are unsandboxed and must be explicitly trusted.
```toml
[mod]
id = "movement"
version = "0.1.0"
priority = 100
Respect Horizon's public-tree and Minecraft asset policies. No Minecraft game assets, decompiled Java or prepared resources.
[[injection]]
id = "head"
target = "fixture_game::entity::advance"
kind = "HEAD"
helper = "head.rs"
bindings = [{ name = "entity", ty = "&mut Entity", value = "&mut *entity" }]
```
## License
```rust
// head.rs: compiled as a local function inside the target function.
fn head(entity: &mut Entity) {
entity.velocity += 3; // Private fields in the target module are accessible.
}
```
No license selected yet. Repository owner should select one before accepting external contributions or distributing binaries.
Helpers use explicit typed parameter bindings. They are compiled in the game
crate and introduce no game→mod→game Cargo cycle. All selectors resolve on the
original AST; same-site hooks run by priority descending, mod ID ascending,
then injection ID ascending. An OVERWRITE conflicts with every other hook on
its function.
BEFORE_CALL requires an exact syntax selector, explicit total `require`, and
zero-based `ordinal`. It supports a complete expression statement, plain `let`
initializer, or block/function tail. Calls nested in larger expressions are
rejected because wrapping them can change temporary drop timing. Macro sites,
unresolved cfg, async/closures/`?`, generic/unsafe/const targets, and ambiguous
source identities are rejected. Rust checks privacy, types and borrowing in
`check`; successful planning alone does not guarantee compilation.
Validate the implementation:
```sh
python3 tools/feasibility.py
cargo fmt --all --check
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
python3 tools/benchmark.py # fresh target/benchmark; use --out for another run
```
The integration suite includes generated source/plan snapshots, positive and
negative compilation, deterministic ordering, immutable originals, stale-source
rejection, concurrent checks, and unchanged mtimes. Linux CI runs the same checks.
An isolated test against the complete `horizon-core` crate from commit
`028f60881678a9a0e3e198f693a82858fa475c76` passed, including two independent
HEAD hooks in `jmath::atan`. Reproduce it using your local clone:
```sh
python3 tools/horizon_prototype.py --horizon /home/levzzz/Documents/horizon
```
It exports committed crate sources into a fresh `target/horizon-prototype`,
uses an isolated workspace manifest/lockfile, runs upstream tests and generated
probes, and records timings. It does not build the full game or launcher.
No upstream checkout is edited; no Minecraft assets or prepared resources are used.
Further documentation: [API](docs/mixin-api.md),
[transformations](docs/transformations.md), [architecture](docs/architecture.md),
[staging and cache](docs/build-and-cache.md), [security](docs/security.md),
[roadmap](docs/roadmap.md), [feasibility and measurements](docs/implementation.md).
Advanced injection kinds, launcher integration, semantic resolution, and a
complete executable cache remain future work. License: [MIT](LICENSE).
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "horizon-mixins-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "horizon-mixins"
path = "src/main.rs"
[dependencies]
horizon-mixins-core.workspace = true
horizon-mixins-transform.workspace = true
serde.workspace = true
serde_json.workspace = true
clap.workspace = true
fs2.workspace = true
tempfile.workspace = true
rustix.workspace = true
[dev-dependencies]
toml.workspace = true
+173
View File
@@ -0,0 +1,173 @@
mod staging;
use clap::{Args, Parser, Subcommand};
use horizon_mixins_core::{Diagnostic, Result, build_plan, diagnostic::io};
use serde_json::json;
use std::{
path::{Path, PathBuf},
process::{Command, ExitCode},
time::Instant,
};
#[derive(Parser)]
#[command(
name = "horizon-mixins",
version,
about = "Deterministic, staged source mixins for trusted native mods"
)]
struct Cli {
#[command(subcommand)]
command: Action,
}
#[derive(Subcommand)]
enum Action {
/// Validate all mods on pristine source and atomically write a JSON plan.
Plan(Common),
/// Stage transformed source; original workspace is immutable.
Apply(Common),
/// Stage and run cargo test --locked --offline. Native code is unsandboxed.
Check {
#[command(flatten)]
common: Common,
#[arg(long)]
trust_native_mods: bool,
#[arg(long)]
target_dir: Option<PathBuf>,
#[arg(long = "package")]
packages: Vec<String>,
},
}
#[derive(Args)]
struct Common {
#[arg(long)]
workspace: PathBuf,
#[arg(long)]
mods: PathBuf,
#[arg(long)]
out: PathBuf,
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(value) => {
println!("{}", serde_json::to_string_pretty(&value).unwrap());
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("{}", serde_json::to_string_pretty(&error).unwrap());
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<serde_json::Value> {
let start = Instant::now();
let (common, check) = match cli.command {
Action::Plan(common) => {
let root = common.workspace.canonicalize().map_err(io)?;
let out = staging::output_path(&common.out)?;
protect(&root, &out)?;
let mods = common.mods.canonicalize().map_err(io)?;
protect(&mods, &out)?;
let plan = build_plan(&root, &mods)?;
let mut bytes = serde_json::to_vec_pretty(&plan)
.map_err(|e| Diagnostic::new("E_JSON", e.to_string()))?;
bytes.push(b'\n');
let _lock = staging::lock(&out, "plan")?;
staging::write_atomic(&out, &bytes)?;
return Ok(
json!({"status":"ok", "plan":out, "injections":plan.injections.len(), "seconds":start.elapsed().as_secs_f64()}),
);
}
Action::Apply(common) => (common, None),
Action::Check {
common,
trust_native_mods,
target_dir,
packages,
} => {
if !trust_native_mods {
return Err(Diagnostic::new(
"E_TRUST_NATIVE_MODS",
"native mods, dependencies and build scripts execute unsandboxed; review them and pass --trust-native-mods to run Cargo",
));
}
(common, Some((target_dir, packages)))
}
};
let root = common.workspace.canonicalize().map_err(io)?;
let mods = common.mods.canonicalize().map_err(io)?;
let out = staging::output_path(&common.out)?;
protect(&root, &out)?;
protect(&mods, &out)?;
let target = check
.as_ref()
.map(|(target, _)| {
let target = target.clone().unwrap_or_else(|| {
out.with_file_name(format!(
".{}.cargo-target",
out.file_name().unwrap().to_string_lossy()
))
});
staging::output_path(&target)
})
.transpose()?;
if let Some(target) = &target {
protect(&root, target)?;
protect(&mods, target)?;
protect(&out, target)?;
}
let _stage_lock = staging::lock(&out, "stage")?;
let plan = build_plan(&root, &mods)?;
let files = horizon_mixins_transform::transform(&root, &plan)?;
let stage_changed = staging::publish(&root, &out, files, &plan)?;
let transformation_seconds = start.elapsed().as_secs_f64();
let mut result = json!({"status":"ok", "stage":out, "injections":plan.injections.len(),
"stage_changed":stage_changed, "transformation_seconds":transformation_seconds});
if let Some((_, packages)) = check {
let target = target.unwrap();
let _target_lock = staging::lock(&target, "cargo")?;
let cargo_start = Instant::now();
let mut cmd = Command::new("cargo");
cmd.current_dir(&out)
.args(["test", "--locked", "--offline", "--target-dir"])
.arg(&target);
if packages.is_empty() {
cmd.arg("--workspace");
}
for package in packages {
cmd.args(["--package", &package]);
}
let run = cmd.output().map_err(io)?;
let stdout = String::from_utf8_lossy(&run.stdout);
let stderr = String::from_utf8_lossy(&run.stderr);
if !run.status.success() {
return Err(Diagnostic::new(
"E_BUILD",
format!(
"staged cargo test failed ({}):\n{stderr}\n{stdout}",
run.status
),
)
.file(out.join("Cargo.toml")));
}
result["cargo_seconds"] = json!(cargo_start.elapsed().as_secs_f64());
result["target_dir"] = json!(target);
result["cargo_stdout"] = json!(stdout);
result["cargo_stderr"] = json!(stderr);
}
result["seconds"] = json!(start.elapsed().as_secs_f64());
Ok(result)
}
fn protect(source: &Path, output: &Path) -> Result<()> {
if output.starts_with(source) || source.starts_with(output) {
return Err(Diagnostic::new(
"E_INVALID_PATH",
"output/target must be separate from and cannot contain workspace, mods or stage",
)
.file(output));
}
// Existing paths have already been canonicalized after rejecting symlinks.
if output.is_file() && source == output {
return Err(Diagnostic::new("E_INVALID_PATH", "cannot overwrite source"));
}
Ok(())
}
+163
View File
@@ -0,0 +1,163 @@
use fs2::FileExt;
use horizon_mixins_core::{Diagnostic, Plan, Result, diagnostic::io, source};
use std::{
collections::BTreeMap,
fs::{self, File, OpenOptions},
path::{Component, Path, PathBuf},
};
const MARKER: &str = ".horizon-mixins-stage.json";
/// Reject symlinks in every existing output component, including parent directories.
pub fn output_path(path: &Path) -> Result<PathBuf> {
let path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_err(io)?.join(path)
};
let mut output = PathBuf::new();
for component in path.components() {
match component {
Component::ParentDir => {
return Err(
Diagnostic::new("E_INVALID_PATH", "output paths cannot contain '..'")
.file(path),
);
}
Component::CurDir => continue,
_ => output.push(component),
}
match fs::symlink_metadata(&output) {
Ok(meta) if meta.is_symlink() => {
return Err(Diagnostic::new(
"E_INVALID_PATH",
"output paths cannot traverse symlinks",
)
.file(output));
}
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(io(e).file(&output)),
}
}
if output.file_name().is_none() {
return Err(Diagnostic::new(
"E_INVALID_PATH",
"output must have a filename",
));
}
Ok(output)
}
pub fn lock(path: &Path, purpose: &str) -> Result<File> {
let parent = path.parent().unwrap();
fs::create_dir_all(parent).map_err(io)?;
let name = format!(
".{}.horizon-mixins-{purpose}.lock",
path.file_name().unwrap().to_string_lossy()
);
let lock_path = parent.join(name);
if fs::symlink_metadata(&lock_path).is_ok_and(|m| m.is_symlink()) {
return Err(Diagnostic::new("E_INVALID_PATH", "lock file is a symlink").file(&lock_path));
}
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.map_err(io)?;
file.lock_exclusive().map_err(io)?;
Ok(file)
}
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
if fs::read(path).is_ok_and(|old| old == bytes) {
return Ok(());
}
let parent = path.parent().unwrap();
fs::create_dir_all(parent).map_err(io)?;
let mut pending = tempfile::NamedTempFile::new_in(parent).map_err(io)?;
use std::io::Write;
pending.write_all(bytes).map_err(io)?;
pending.as_file().sync_all().map_err(io)?;
pending.persist(path).map_err(|e| io(e.error))?;
Ok(())
}
pub fn publish(
root: &Path,
out: &Path,
mut files: BTreeMap<String, Vec<u8>>,
plan: &Plan,
) -> Result<bool> {
if files.contains_key(MARKER) {
return Err(Diagnostic::new(
"E_INVALID_PATH",
"input uses reserved stage marker filename",
));
}
let metadata =
serde_json::to_vec_pretty(plan).map_err(|e| Diagnostic::new("E_JSON", e.to_string()))?;
files.insert(MARKER.into(), metadata);
let old = if out.exists() {
if !out.is_dir() || !out.join(MARKER).is_file() {
return Err(Diagnostic::new(
"E_STAGE_OWNERSHIP",
"refusing to replace a directory without a Horizon Mixins stage marker",
)
.file(out));
}
let old = source::files(out)?;
if old == files {
return Ok(false);
}
old
} else {
BTreeMap::new()
};
let pending = tempfile::Builder::new()
.prefix(".horizon-mixins-pending-")
.tempdir_in(out.parent().unwrap())
.map_err(io)?;
for (name, bytes) in files {
source::relative(Path::new(&name))?;
let destination = pending.path().join(&name);
fs::create_dir_all(destination.parent().unwrap()).map_err(io)?;
if old.get(&name) == Some(&bytes) {
// Link only our prior staged files. Never hard-link immutable originals.
fs::hard_link(out.join(&name), &destination).map_err(io)?;
} else {
fs::write(&destination, bytes).map_err(io)?;
if let Ok(meta) = fs::metadata(root.join(&name)) {
fs::set_permissions(&destination, meta.permissions()).map_err(io)?;
}
}
}
if out.exists() {
exchange(pending.path(), out)?;
// pending now owns the old complete workspace and removes it on drop.
} else {
fs::rename(pending.path(), out).map_err(io)?;
}
Ok(true)
}
#[cfg(target_os = "linux")]
fn exchange(a: &Path, b: &Path) -> Result<()> {
rustix::fs::renameat_with(
rustix::fs::CWD,
a,
rustix::fs::CWD,
b,
rustix::fs::RenameFlags::EXCHANGE,
)
.map_err(|e| {
Diagnostic::new(
"E_ATOMIC_STAGE",
format!("atomic directory exchange failed; prior stage retained: {e}"),
)
})
}
#[cfg(not(target_os = "linux"))]
fn exchange(_: &Path, _: &Path) -> Result<()> {
Err(Diagnostic::new(
"E_ATOMIC_STAGE",
"atomic replacement of an existing stage currently requires Linux; use a fresh output directory",
))
}
@@ -0,0 +1,790 @@
use horizon_mixins_core::{build_plan, index::Index, source};
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::{Command, Output},
};
fn repo() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap()
}
struct Fixture {
_temp: tempfile::TempDir,
root: PathBuf,
mods: PathBuf,
out: PathBuf,
}
impl Fixture {
fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("original");
let mods = temp.path().join("mods");
copy(&repo().join("fixtures/game"), &root);
copy(&repo().join("fixtures/mods"), &mods);
Self {
out: temp.path().join("stage"),
_temp: temp,
root,
mods,
}
}
fn replace(&self, path: &str, from: &str, to: &str) {
let path = match path.strip_prefix("mods/") {
Some(p) => self.mods.join(p),
None => self.root.join(path),
};
let text = fs::read_to_string(&path).unwrap();
assert!(
text.contains(from),
"{from} is absent in {}",
path.display()
);
fs::write(path, text.replace(from, to)).unwrap();
}
fn append(&self, path: &str, text: &str) {
let path = match path.strip_prefix("mods/") {
Some(p) => self.mods.join(p),
None => self.root.join(path),
};
let old = fs::read_to_string(&path).unwrap();
fs::write(path, old + text).unwrap();
}
fn command(&self, action: &str) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_horizon-mixins"));
command
.arg(action)
.arg("--workspace")
.arg(&self.root)
.arg("--mods")
.arg(&self.mods)
.arg("--out")
.arg(&self.out);
if action == "check" {
command.arg("--trust-native-mods");
}
command
}
fn run(&self, action: &str) -> serde_json::Value {
success(self.command(action).output().unwrap())
}
}
fn copy(from: &Path, to: &Path) {
for (name, bytes) in source::files(from).unwrap() {
let path = to.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
}
fn success(output: Output) -> serde_json::Value {
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
serde_json::from_slice(&output.stdout).unwrap()
}
fn mtimes(root: &Path) -> BTreeMap<String, std::time::SystemTime> {
source::files(root)
.unwrap()
.keys()
.map(|name| {
(
name.clone(),
fs::metadata(root.join(name)).unwrap().modified().unwrap(),
)
})
.collect()
}
#[test]
fn compiled_mvp_is_deterministic_preserves_sources_and_unchanged_mtimes() {
let f = Fixture::new();
let originals = source::files(&f.root).unwrap();
let original_times = mtimes(&f.root);
let first = f.run("check");
assert_eq!(first["stage_changed"], true);
assert!(first["cargo_stdout"].as_str().unwrap().contains("4 passed"));
let stage = source::files(&f.out).unwrap();
let times = mtimes(&f.out);
assert_eq!(f.run("check")["stage_changed"], false);
assert_eq!(source::files(&f.out).unwrap(), stage);
assert_eq!(mtimes(&f.out), times);
// A change in one hook publishes a new complete stage, retaining other files' mtimes.
f.replace("mods/alpha/replace.rs", "input * 7", "input * 7 + 0");
assert_eq!(f.run("check")["stage_changed"], true);
let new_times = mtimes(&f.out);
for (name, time) in &times {
if name != "engine/src/replacement.rs" && name != ".horizon-mixins-stage.json" {
assert_eq!(new_times[name], *time, "mtime changed for {name}");
}
}
assert_eq!(source::files(&f.root).unwrap(), originals);
assert_eq!(mtimes(&f.root), original_times);
// Removed inputs disappear when the next complete workspace is published.
fs::remove_file(f.root.join("README.md")).unwrap();
f.run("apply");
assert!(!f.out.join("README.md").exists());
}
#[test]
fn canonical_plan_and_source_snapshots_ignore_discovery_order() {
let f = Fixture::new();
let first = build_plan(&f.root, &f.mods).unwrap();
fs::rename(f.mods.join("alpha"), f.mods.join("z-last-on-disk")).unwrap();
fs::rename(f.mods.join("beta"), f.mods.join("a-first-on-disk")).unwrap();
let second = build_plan(&f.root, &f.mods).unwrap();
assert_eq!(
serde_json::to_vec(&first).unwrap(),
serde_json::to_vec(&second).unwrap()
);
let serialized = serde_json::to_value(&first).unwrap();
let head: Vec<_> = first
.injections
.iter()
.filter(|p| p.target.ends_with("::advance"))
.map(|p| p.mod_id.as_str())
.collect();
assert_eq!(head, ["alpha", "beta"]);
let output = horizon_mixins_transform::transform(&f.root, &first).unwrap();
let snapshots = repo().join("crates/horizon-mixins-cli/tests/snapshots");
for (name, file) in [
("entity.rs", "engine/src/entity.rs"),
("calls.rs", "engine/src/calls.rs"),
("replacement.rs", "engine/src/replacement.rs"),
] {
if std::env::var_os("UPDATE_SNAPSHOTS").is_some() {
fs::write(snapshots.join(name), &output[file]).unwrap();
}
assert_eq!(
output[file],
fs::read(snapshots.join(name)).unwrap(),
"snapshot {name}"
);
}
let mut portable_plan = serialized;
portable_plan.as_object_mut().unwrap().remove("rustc");
portable_plan.as_object_mut().unwrap().remove("inputs");
let snapshot = serde_json::to_string_pretty(&portable_plan).unwrap() + "\n";
if std::env::var_os("UPDATE_SNAPSHOTS").is_some() {
fs::write(snapshots.join("plan.json"), &snapshot).unwrap();
}
assert_eq!(
snapshot,
fs::read_to_string(snapshots.join("plan.json")).unwrap()
);
}
#[test]
fn priority_ties_sort_by_mod_then_injection_id() {
let f = Fixture::new();
f.replace(
"mods/beta/horizon-mixin.toml",
"priority = 50",
"priority = 100",
);
f.append("mods/alpha/horizon-mixin.toml", "\n[[injection]]\nid = \"aaa-head\"\ntarget = \"fixture_game::entity::advance\"\nkind = \"HEAD\"\nhelper = \"head.rs\"\nbindings = [{ name = \"entity\", ty = \"&mut Entity\", value = \"&mut *entity\" }]\n");
let plan = build_plan(&f.root, &f.mods).unwrap();
let order: Vec<_> = plan
.injections
.iter()
.filter(|p| p.target.ends_with("::advance"))
.map(|p| (p.mod_id.as_str(), p.injection_id.as_str()))
.collect();
assert_eq!(
order,
[("alpha", "aaa-head"), ("alpha", "head"), ("beta", "head")]
);
}
#[test]
fn indexes_inline_external_path_inherent_and_trait_methods() {
let f = Fixture::new();
let index = Index::load(&f.root).unwrap();
for target in [
"fixture_game::entity::advance",
"fixture_game::entity::Entity::tick",
"fixture_game::entity::<Entity as Marker>::marked",
"fixture_game::inline::identity",
"fixture_game::inline::leaf::identity",
"fixture_game::overwrite::value",
] {
assert!(index.resolve(target).is_ok(), "missing {target}");
}
// Transform a nested inline/external module and actually compile its output.
f.append("mods/alpha/horizon-mixin.toml", "\n[[injection]]\nid = \"inline\"\ntarget = \"fixture_game::inline::identity\"\nkind = \"HEAD\"\nhelper = \"nop.rs\"\n\n[[injection]]\nid = \"external\"\ntarget = \"fixture_game::inline::leaf::identity\"\nkind = \"HEAD\"\nhelper = \"nop.rs\"\n");
fs::write(f.mods.join("alpha/nop.rs"), "fn hook() {}\n").unwrap();
f.run("check");
}
#[test]
fn negative_planning_diagnostics_are_actionable() {
let cases = [
(
"missing",
"mods/alpha/horizon-mixin.toml",
"fixture_game::entity::advance",
"fixture_game::entity::missing",
"E_TARGET_NOT_FOUND",
),
(
"selector missing",
"mods/alpha/horizon-mixin.toml",
"selector = \"consume\"",
"selector = \"missing\"",
"E_MATCH_COUNT",
),
(
"wrong count",
"mods/alpha/horizon-mixin.toml",
"require = 2",
"require = 3",
"E_MATCH_COUNT",
),
(
"wrong ordinal",
"mods/alpha/horizon-mixin.toml",
"ordinal = 1",
"ordinal = 2",
"E_ORDINAL",
),
(
"explicit count",
"mods/alpha/horizon-mixin.toml",
"require = 2",
"",
"E_SELECTOR",
),
(
"explicit ordinal",
"mods/alpha/horizon-mixin.toml",
"ordinal = 1",
"",
"E_SELECTOR",
),
(
"duplicate mod",
"mods/beta/horizon-mixin.toml",
"id = \"beta\"",
"id = \"alpha\"",
"E_DUPLICATE_ID",
),
(
"duplicate injection",
"mods/alpha/horizon-mixin.toml",
"id = \"tick\"",
"id = \"head\"",
"E_DUPLICATE_ID",
),
(
"traversal",
"mods/alpha/horizon-mixin.toml",
"helper = \"head.rs\"",
"helper = \"../../escape.rs\"",
"E_INVALID_PATH",
),
(
"invalid helper",
"mods/alpha/head.rs",
"fn head",
"pub fn head",
"E_INVALID_HELPER",
),
(
"helper syntax",
"mods/alpha/head.rs",
"fn head",
"fn { head",
"E_INVALID_HELPER",
),
(
"missing helper",
"mods/alpha/horizon-mixin.toml",
"helper = \"head.rs\"",
"helper = \"absent.rs\"",
"E_IO",
),
(
"binding name",
"mods/alpha/horizon-mixin.toml",
"name = \"entity\"",
"name = \"absent\"",
"E_INVALID_BINDING",
),
(
"binding local",
"mods/alpha/horizon-mixin.toml",
"value = \"&mut *entity\"",
"value = \"&mut *missing\"",
"E_INVALID_BINDING",
),
(
"binding call",
"mods/alpha/horizon-mixin.toml",
"value = \"&mut *entity\"",
"value = \"Entity::new()\"",
"E_INVALID_BINDING",
),
(
"cfg",
"engine/src/entity.rs",
"pub fn advance",
"#[cfg(feature = \"unknown\")]\npub fn advance",
"E_UNSUPPORTED_CFG",
),
(
"cfg module",
"engine/src/lib.rs",
"pub mod entity;",
"#[cfg(feature = \"unknown\")]\npub mod entity;",
"E_UNSUPPORTED_CFG",
),
(
"macro body",
"engine/src/entity.rs",
"entity.velocity += 1;",
"unknown_macro!();",
"E_UNSUPPORTED_MACRO",
),
(
"closure",
"engine/src/entity.rs",
"entity.velocity += 1;",
"let _closure = || 1;",
"E_UNSUPPORTED_CONTROL_FLOW",
),
(
"async",
"engine/src/entity.rs",
"pub fn advance",
"pub async fn advance",
"E_UNSUPPORTED_SIGNATURE",
),
(
"generic",
"engine/src/entity.rs",
"pub fn advance(",
"pub fn advance<T>(",
"E_UNSUPPORTED_SIGNATURE",
),
(
"rust syntax",
"engine/src/entity.rs",
"pub fn advance",
"pub fn { advance",
"E_RUST_SYNTAX",
),
(
"unknown manifest key",
"mods/alpha/horizon-mixin.toml",
"priority = 100",
"priority = 100\nunknown = true",
"E_MANIFEST",
),
(
"bad kind",
"mods/alpha/horizon-mixin.toml",
"kind = \"HEAD\"",
"kind = \"REDIRECT\"",
"E_MANIFEST",
),
];
for (name, path, from, to, code) in cases {
let f = Fixture::new();
f.replace(path, from, to);
let error = build_plan(&f.root, &f.mods).unwrap_err();
assert_eq!(error.code, code, "{name}: {error:?}");
assert!(!error.message.is_empty());
assert!(error.file.is_some(), "{name}: missing file");
}
}
#[test]
fn ambiguous_macro_generated_overwrite_and_fingerprint_failures() {
let f = Fixture::new();
f.append(
"engine/src/entity.rs",
"\npub fn advance(entity: &mut Entity) -> i32 { 0 }\n",
);
assert_eq!(
build_plan(&f.root, &f.mods).unwrap_err().code,
"E_TARGET_AMBIGUOUS"
);
let f = Fixture::new();
f.append("engine/src/lib.rs", "\npub mod generated { macro_rules! generate { () => { pub fn hidden() {} }; } generate!(); }\n");
f.replace(
"mods/alpha/horizon-mixin.toml",
"fixture_game::entity::advance",
"fixture_game::generated::hidden",
);
assert_eq!(
build_plan(&f.root, &f.mods).unwrap_err().code,
"E_UNSUPPORTED_MACRO"
);
for target in [
"fixture_game::entity::advance",
"fixture_game::overwrite::value",
] {
let f = Fixture::new();
let (helper, name, ty, value) = if target.ends_with("advance") {
("head.rs", "entity", "&mut Entity", "&mut *entity")
} else {
("replace.rs", "input", "i32", "input")
};
f.append("mods/alpha/horizon-mixin.toml", &format!("\n[[injection]]\nid = \"conflict\"\ntarget = \"{target}\"\nkind = \"OVERWRITE\"\nhelper = \"{helper}\"\nbindings = [{{ name = \"{name}\", ty = \"{ty}\", value = \"{value}\" }}]\n"));
let error = build_plan(&f.root, &f.mods).unwrap_err();
assert_eq!(error.code, "E_OVERWRITE_CONFLICT");
assert!(error.message.contains("competing injections"));
let output = f.command("apply").output().unwrap();
assert!(!output.status.success());
assert!(!f.out.exists());
}
let f = Fixture::new();
f.replace(
"mods/alpha/horizon-mixin.toml",
"id = \"head\"",
"id = \"head\"\nfingerprint = \"wrong\"",
);
assert_eq!(
build_plan(&f.root, &f.mods).unwrap_err().code,
"E_FINGERPRINT"
);
let f = Fixture::new();
f.replace(
"mods/alpha/horizon-mixin.toml",
"[[injection]]",
"[compatibility]\nhorizon_revision = \"wrong\"\n\n[[injection]]",
);
assert_eq!(build_plan(&f.root, &f.mods).unwrap_err().code, "E_MANIFEST");
}
#[test]
fn stale_source_is_rejected_before_rewrite() {
let f = Fixture::new();
let plan = build_plan(&f.root, &f.mods).unwrap();
f.append("engine/src/entity.rs", "\n// changed\n");
assert_eq!(
horizon_mixins_transform::transform(&f.root, &plan)
.unwrap_err()
.code,
"E_STALE_SOURCE"
);
}
#[test]
fn compilation_reports_helper_borrow_and_return_errors() {
for (file, text, message) in [
(
"head.rs",
"fn head(entity: &mut Entity) { let moved = entity; entity.velocity += 1; let _ = moved; }",
"moved value",
),
(
"replace.rs",
"fn replace(input: i32) -> bool { input == 7 }",
"mismatched types",
),
] {
let f = Fixture::new();
fs::write(f.mods.join("alpha").join(file), text).unwrap();
let originals = source::files(&f.root).unwrap();
let output = f.command("check").output().unwrap();
assert!(!output.status.success());
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert_eq!(error["code"], "E_BUILD");
assert!(
error["message"].as_str().unwrap().contains(message),
"{error}"
);
assert_eq!(source::files(&f.root).unwrap(), originals);
}
}
#[test]
fn cli_plan_is_json_and_validation_failure_keeps_prior_stage() {
let mut f = Fixture::new();
f.out = f._temp.path().join("plan.json");
f.run("plan");
let bytes = fs::read(&f.out).unwrap();
let plan: horizon_mixins_core::Plan = serde_json::from_slice(&bytes).unwrap();
assert_eq!(plan.injections.len(), 8);
let time = fs::metadata(&f.out).unwrap().modified().unwrap();
f.run("plan");
assert_eq!(fs::metadata(&f.out).unwrap().modified().unwrap(), time);
f.out = f._temp.path().join("stage");
f.run("apply");
let old = source::files(&f.out).unwrap();
f.replace(
"mods/alpha/horizon-mixin.toml",
"ordinal = 1",
"ordinal = 999",
);
let output = f.command("apply").output().unwrap();
assert!(!output.status.success());
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert_eq!(error["code"], "E_ORDINAL");
assert_eq!(error["mod_id"], "alpha");
assert_eq!(error["injection_id"], "before-second");
assert_eq!(error["selector"], "consume");
assert_eq!(source::files(&f.out).unwrap(), old);
}
#[test]
fn output_protection_ownership_trust_and_missing_lockfile() {
let mut f = Fixture::new();
for out in [
f.root.clone(),
f.root.join("nested"),
f._temp.path().to_path_buf(),
f.mods.join("nested"),
] {
f.out = out;
let output = f.command("apply").output().unwrap();
assert!(!output.status.success());
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert_eq!(error["code"], "E_INVALID_PATH");
}
f.out = f._temp.path().join("unowned");
fs::create_dir(&f.out).unwrap();
fs::write(f.out.join("important"), "keep").unwrap();
let output = f.command("apply").output().unwrap();
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert_eq!(error["code"], "E_STAGE_OWNERSHIP");
assert_eq!(fs::read_to_string(f.out.join("important")).unwrap(), "keep");
let output = Command::new(env!("CARGO_BIN_EXE_horizon-mixins"))
.args(["check", "--workspace"])
.arg(&f.root)
.arg("--mods")
.arg(&f.mods)
.arg("--out")
.arg(&f.out)
.output()
.unwrap();
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert_eq!(error["code"], "E_TRUST_NATIVE_MODS");
fs::remove_file(f.root.join("Cargo.lock")).unwrap();
assert_eq!(build_plan(&f.root, &f.mods).unwrap_err().code, "E_UNPINNED");
}
#[cfg(unix)]
#[test]
fn symlink_escapes_are_rejected() {
use std::os::unix::fs::symlink;
let f = Fixture::new();
fs::remove_file(f.mods.join("alpha/head.rs")).unwrap();
symlink(
repo().join("fixtures/mods/alpha/head.rs"),
f.mods.join("alpha/head.rs"),
)
.unwrap();
assert_eq!(
build_plan(&f.root, &f.mods).unwrap_err().code,
"E_INVALID_PATH"
);
let f = Fixture::new();
symlink(
repo().join("fixtures/game/engine/src/entity.rs"),
f.root.join("escape.rs"),
)
.unwrap();
assert_eq!(
build_plan(&f.root, &f.mods).unwrap_err().code,
"E_INVALID_PATH"
);
let f = Fixture::new();
symlink(&f.root, &f.out).unwrap();
let output = f.command("apply").output().unwrap();
let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap();
assert_eq!(error["code"], "E_INVALID_PATH");
}
#[test]
fn concurrent_checks_are_serialized_and_keep_a_complete_stage() {
let f = Fixture::new();
let first = f
.command("check")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let second = f
.command("check")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let a = success(first.wait_with_output().unwrap());
let b = success(second.wait_with_output().unwrap());
assert_ne!(a["stage_changed"], b["stage_changed"]);
assert_eq!(a["target_dir"], b["target_dir"]);
assert!(f.out.join("engine/src/entity.rs").is_file());
}
#[test]
fn cfg_attributes_shared_module_sources_and_shadowing_fail_closed() {
for (path, from, to, code) in [
(
"engine/src/entity.rs",
"pub fn advance",
"#[instrument]\npub fn advance",
"E_UNSUPPORTED_MACRO",
),
(
"engine/src/lib.rs",
"pub mod entity;",
"#[instrument]\npub mod entity;",
"E_UNSUPPORTED_MACRO",
),
(
"engine/src/lib.rs",
"pub mod entity;",
"#![cfg(feature = \"unknown\")]\npub mod entity;",
"E_UNSUPPORTED_CFG",
),
(
"engine/src/entity.rs",
"pub fn advance",
"#[cfg_attr(feature = \"unknown\", inline)]\npub fn advance",
"E_UNSUPPORTED_CFG",
),
(
"engine/src/calls.rs",
"let first = consume(next(evaluations, log));",
"let mut log = Vec::new();\n let first = consume(next(evaluations, log));",
"E_INVALID_BINDING",
),
(
"engine/src/entity.rs",
"entity.velocity += 1;",
"let _value = unknown()?;",
"E_UNSUPPORTED_CONTROL_FLOW",
),
(
"engine/src/entity.rs",
"entity.velocity += 1;",
"let _value = &unknown();",
"E_UNSUPPORTED_CONTROL_FLOW",
),
(
"engine/src/lib.rs",
"pub mod entity;",
"#[path = \"../escape.rs\"]\npub mod entity;",
"E_INVALID_PATH",
),
] {
let f = Fixture::new();
f.replace(path, from, to);
assert_eq!(build_plan(&f.root, &f.mods).unwrap_err().code, code, "{to}");
}
let f = Fixture::new();
f.append(
"engine/src/lib.rs",
"\n#[path = \"entity.rs\"]\npub mod reused;\n",
);
assert_eq!(
build_plan(&f.root, &f.mods).unwrap_err().code,
"E_TARGET_AMBIGUOUS"
);
let f = Fixture::new();
f.append(
"engine/src/entity.rs",
"\nimpl Another for Entity { fn marked(&self) -> i32 { 0 } }\n",
);
let index = Index::load(&f.root).unwrap();
assert!(
index
.resolve("fixture_game::entity::<Entity as Marker>::marked")
.is_ok()
);
assert!(
index
.resolve("fixture_game::entity::<Entity as Another>::marked")
.is_ok()
);
}
#[test]
fn exact_revision_pin_is_checked() {
let f = Fixture::new();
fs::write(
f.root.join(".horizon-revision"),
"1111111111111111111111111111111111111111\n",
)
.unwrap();
f.replace("mods/alpha/horizon-mixin.toml", "[[injection]]\nid = \"head\"", "[compatibility]\nhorizon_revision = \"1111111111111111111111111111111111111111\"\n\n[[injection]]\nid = \"head\"");
build_plan(&f.root, &f.mods).unwrap();
f.replace(
".horizon-revision",
"1111111111111111111111111111111111111111",
"2222222222222222222222222222222222222222",
);
assert_eq!(build_plan(&f.root, &f.mods).unwrap_err().code, "E_REVISION");
}
#[test]
fn unsupported_temporary_scopes_and_malformed_ir_are_rejected() {
let f = Fixture::new();
f.replace(
"engine/src/calls.rs",
"let second = consume(next(evaluations, log));",
"let second = consume(next(evaluations, log)) + 0;",
);
assert_eq!(
build_plan(&f.root, &f.mods).unwrap_err().code,
"E_UNSUPPORTED_SITE"
);
let f = Fixture::new();
let mut plan = build_plan(&f.root, &f.mods).unwrap();
let patch = plan
.injections
.iter_mut()
.find(|p| p.call_site.is_some())
.unwrap();
patch.call_site = None;
assert_eq!(
horizon_mixins_transform::transform(&f.root, &plan)
.unwrap_err()
.code,
"E_PLAN_SITE"
);
}
#[test]
fn before_discarded_call_preserves_receiver_and_result_drop_order() {
let f = Fixture::new();
f.append(
"engine/src/calls.rs",
r#"
type Trace = std::rc::Rc<std::cell::RefCell<Vec<&'static str>>>;
struct Guard { trace: Trace, name: &'static str }
impl Drop for Guard { fn drop(&mut self) { self.trace.borrow_mut().push(self.name); } }
impl Guard {
fn produce(&self) -> Guard {
self.trace.borrow_mut().push("call");
Guard { trace: self.trace.clone(), name: "result-drop" }
}
}
fn receiver(trace: &Trace) -> Guard {
trace.borrow_mut().push("receiver");
Guard { trace: trace.clone(), name: "receiver-drop" }
}
pub fn discarded(trace: &Trace) {
receiver(trace).produce();
trace.borrow_mut().push("after");
}
"#,
);
f.append("driver/src/lib.rs", r#"
#[test]
fn receiver_and_result_drop_order() {
let trace = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
fixture_game::calls::discarded(&trace);
assert_eq!(*trace.borrow(), ["hook", "receiver", "call", "result-drop", "receiver-drop", "after"]);
}
"#);
fs::write(
f.mods.join("alpha/drop.rs"),
"fn before(trace: &Trace) { trace.borrow_mut().push(\"hook\"); }",
)
.unwrap();
f.append("mods/alpha/horizon-mixin.toml", "\n[[injection]]\nid = \"drop\"\ntarget = \"fixture_game::calls::discarded\"\nkind = \"BEFORE_CALL\"\nselector = \"receiver(trace).produce\"\nordinal = 0\nrequire = 1\nhelper = \"drop.rs\"\nbindings = [{ name = \"trace\", ty = \"&Trace\", value = \"trace\" }]\n");
let report = f.run("check");
assert!(
report["cargo_stdout"]
.as_str()
.unwrap()
.contains("5 passed")
);
}
@@ -0,0 +1,50 @@
pub fn consume(value: i32) -> i32 {
value * 3
}
fn next(evaluations: &mut i32, log: &mut Vec<&'static str>) -> i32 {
*evaluations += 1;
log.push("argument");
*evaluations
}
pub fn compute(evaluations: &mut i32, log: &mut Vec<&'static str>) -> i32 {
let first = consume(next(evaluations, log));
let second = {
{
fn before(log: &mut Vec<&'static str>) {
let _ = consume(0);
log.push("alpha-before");
}
before(&mut *log)
};
{
fn before(log: &mut Vec<&'static str>) {
log.push("beta-before");
}
before(&mut *log)
};
consume(next(evaluations, log))
};
first + second
}
struct Receiver(i32);
impl Receiver {
fn take(self, arg: i32) -> i32 {
self.0 + arg
}
}
fn make_receiver(evaluations: &mut i32) -> Receiver {
*evaluations += 1;
Receiver(*evaluations)
}
pub fn method_compute(evaluations: &mut i32, log: &mut Vec<&'static str>) -> i32 {
{
{
fn before(log: &mut Vec<&'static str>) {
let _ = consume(0);
log.push("alpha-before");
}
before(&mut *log)
};
make_receiver(evaluations).take(next(evaluations, log))
}
}
@@ -0,0 +1,56 @@
pub struct Entity {
velocity: i32,
log: Vec<&'static str>,
}
impl Entity {
pub fn new() -> Self {
Self {
velocity: 2,
log: Vec::new(),
}
}
pub fn log(&self) -> &[&'static str] {
&self.log
}
pub fn tick(&mut self) -> i32 {
{
fn tick(entity: &mut Entity) {
entity.velocity += 1;
}
tick(&mut *self)
};
self.velocity
}
}
pub trait Marker {
fn marked(&self) -> i32;
}
impl Marker for Entity {
fn marked(&self) -> i32 {
{
fn observe(entity: &Entity) {
let _ = entity.velocity;
}
observe(self)
};
self.velocity
}
}
pub fn advance(entity: &mut Entity) -> i32 {
{
fn head(entity: &mut Entity) {
entity.log.push("alpha");
entity.velocity *= 2;
}
head(&mut *entity)
};
{
fn head(entity: &mut Entity) {
entity.log.push("beta");
entity.velocity += 3;
}
head(&mut *entity)
};
entity.velocity += 1;
entity.velocity
}
@@ -0,0 +1,216 @@
{
"injections": [
{
"bindings": [
{
"name": "log",
"ty": "&mut Vec<&'static str>",
"value": "&mut *log"
}
],
"call_site": 2,
"file": "engine/src/calls.rs",
"function_fingerprint": "b941316339d89602f5b6a49c22b95106b7f9ee95521080f783dd5a54f4e555a0",
"helper_source": "fn before(log: &mut Vec<&'static str>) {\n // This call must never become another mod's selector match.\n let _ = consume(0);\n log.push(\"alpha-before\");\n}\n",
"injection_id": "before-second",
"kind": "BEFORE_CALL",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
2
],
"ordinal": 1,
"priority": 100,
"require": 2,
"selector": "consume",
"site_fingerprint": "91e4186df028df223f88e14ac08970a269397f15d137c6dd6f3c533c289c0b3b",
"target": "fixture_game::calls::compute"
},
{
"bindings": [
{
"name": "log",
"ty": "&mut Vec<&'static str>",
"value": "&mut *log"
}
],
"call_site": 2,
"file": "engine/src/calls.rs",
"function_fingerprint": "b941316339d89602f5b6a49c22b95106b7f9ee95521080f783dd5a54f4e555a0",
"helper_source": "fn before(log: &mut Vec<&'static str>) { log.push(\"beta-before\"); }\n",
"injection_id": "before-second",
"kind": "BEFORE_CALL",
"mod_id": "beta",
"mod_version": "0.1.0",
"node": [
2
],
"ordinal": 1,
"priority": 50,
"require": 2,
"selector": "consume",
"site_fingerprint": "91e4186df028df223f88e14ac08970a269397f15d137c6dd6f3c533c289c0b3b",
"target": "fixture_game::calls::compute"
},
{
"bindings": [
{
"name": "log",
"ty": "&mut Vec<&'static str>",
"value": "&mut *log"
}
],
"call_site": 0,
"file": "engine/src/calls.rs",
"function_fingerprint": "a55eb4368e3ac36f776057a277b207ccd5f69bcae0974eebd1ef6b081c4782a0",
"helper_source": "fn before(log: &mut Vec<&'static str>) {\n // This call must never become another mod's selector match.\n let _ = consume(0);\n log.push(\"alpha-before\");\n}\n",
"injection_id": "before-method",
"kind": "BEFORE_CALL",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
6
],
"ordinal": 0,
"priority": 100,
"require": 1,
"selector": "make_receiver(evaluations).take",
"site_fingerprint": "5f870604ee50ff4950be4bd1d6880e63eb411a450ba5abc582bb98b5d471f67e",
"target": "fixture_game::calls::method_compute"
},
{
"bindings": [
{
"name": "entity",
"ty": "&mut Entity",
"value": "&mut *self"
}
],
"call_site": null,
"file": "engine/src/entity.rs",
"function_fingerprint": "d6a07cd9f6453e017191cf70eab3ee54ca6226ef13c67537b89a824ad5fcba93",
"helper_source": "fn tick(entity: &mut Entity) { entity.velocity += 1; }\n",
"injection_id": "tick",
"kind": "HEAD",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
1,
2
],
"ordinal": null,
"priority": 100,
"require": null,
"selector": null,
"site_fingerprint": null,
"target": "fixture_game::entity::Entity::tick"
},
{
"bindings": [
{
"name": "entity",
"ty": "&Entity",
"value": "self"
}
],
"call_site": null,
"file": "engine/src/entity.rs",
"function_fingerprint": "5a591d2321b97f60705703ddccb094b87716afeac6a2d6398bdeca6042726a98",
"helper_source": "fn observe(entity: &Entity) { let _ = entity.velocity; }\n",
"injection_id": "trait",
"kind": "HEAD",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
3,
0
],
"ordinal": null,
"priority": 100,
"require": null,
"selector": null,
"site_fingerprint": null,
"target": "fixture_game::entity::<Entity as Marker>::marked"
},
{
"bindings": [
{
"name": "entity",
"ty": "&mut Entity",
"value": "&mut *entity"
}
],
"call_site": null,
"file": "engine/src/entity.rs",
"function_fingerprint": "5456dccec0b252785f86534280d36304e49b60d1c8ae30bcc853ec28baa860f7",
"helper_source": "fn head(entity: &mut Entity) {\n entity.log.push(\"alpha\");\n entity.velocity *= 2;\n}\n",
"injection_id": "head",
"kind": "HEAD",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
4
],
"ordinal": null,
"priority": 100,
"require": null,
"selector": null,
"site_fingerprint": null,
"target": "fixture_game::entity::advance"
},
{
"bindings": [
{
"name": "entity",
"ty": "&mut Entity",
"value": "&mut *entity"
}
],
"call_site": null,
"file": "engine/src/entity.rs",
"function_fingerprint": "5456dccec0b252785f86534280d36304e49b60d1c8ae30bcc853ec28baa860f7",
"helper_source": "fn head(entity: &mut Entity) {\n entity.log.push(\"beta\");\n entity.velocity += 3;\n}\n",
"injection_id": "head",
"kind": "HEAD",
"mod_id": "beta",
"mod_version": "0.1.0",
"node": [
4
],
"ordinal": null,
"priority": 50,
"require": null,
"selector": null,
"site_fingerprint": null,
"target": "fixture_game::entity::advance"
},
{
"bindings": [
{
"name": "input",
"ty": "i32",
"value": "input"
}
],
"call_site": null,
"file": "engine/src/replacement.rs",
"function_fingerprint": "1a21793757ea38555e338cc1d8d8a4228a229240c4245c21dfe3edba45e77e6b",
"helper_source": "fn replace(input: i32) -> i32 { input * 7 }\n",
"injection_id": "replace",
"kind": "OVERWRITE",
"mod_id": "alpha",
"mod_version": "0.1.0",
"node": [
0
],
"ordinal": null,
"priority": 100,
"require": null,
"selector": null,
"site_fingerprint": null,
"target": "fixture_game::overwrite::value"
}
],
"schema": 1,
"transformer": "0.1.0"
}
@@ -0,0 +1,8 @@
pub fn value(input: i32) -> i32 {
{
fn replace(input: i32) -> i32 {
input * 7
}
replace(input)
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-mixins-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
syn.workspace = true
quote.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
cargo_metadata.workspace = true
thiserror.workspace = true
sha2.workspace = true
@@ -0,0 +1,47 @@
use serde::{Deserialize, Serialize};
pub type Result<T> = std::result::Result<T, Diagnostic>;
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
#[error("{code}: {message}")]
pub struct Diagnostic {
pub code: String,
pub message: Box<str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mod_id: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub injection_id: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub target: Option<Box<str>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub selector: Option<Box<str>>,
}
impl Diagnostic {
pub fn new(code: &str, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into().into_boxed_str(),
file: None,
mod_id: None,
injection_id: None,
target: None,
selector: None,
}
}
pub fn file(mut self, file: impl AsRef<std::path::Path>) -> Self {
self.file = Some(file.as_ref().display().to_string().into());
self
}
pub fn injection(mut self, mod_id: &str, injection: &crate::manifest::Injection) -> Self {
self.mod_id = Some(mod_id.into());
self.injection_id = Some(injection.id.clone().into());
self.target = Some(injection.target.clone().into());
self.selector = injection.selector.as_deref().map(Into::into);
self
}
}
pub fn io(error: std::io::Error) -> Diagnostic {
Diagnostic::new("E_IO", error.to_string())
}
+427
View File
@@ -0,0 +1,427 @@
use crate::{Diagnostic, Result, diagnostic::io, source};
use quote::ToTokens;
use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::{Path, PathBuf},
};
use syn::{Attribute, Block, FnArg, Item, Signature};
#[derive(Debug, Clone)]
pub struct Function {
pub id: String,
pub file: String,
/// Item indices, descending through inline modules and ending at a function or impl method.
pub node: Vec<usize>,
pub signature: Signature,
pub block: Block,
pub unsupported_cfg: bool,
pub unsupported_macro: bool,
pub generic_impl: bool,
}
#[derive(Debug)]
pub struct Index {
pub functions: Vec<Function>,
pub blocked: BTreeSet<String>,
pub macro_modules: BTreeSet<String>,
pub files: BTreeMap<String, syn::File>,
}
pub fn tokens(value: &impl ToTokens) -> String {
value.to_token_stream().to_string()
}
pub fn cfg(attrs: &[Attribute]) -> bool {
attrs
.iter()
.any(|a| a.path().is_ident("cfg") || a.path().is_ident("cfg_attr"))
}
pub fn macro_attributes(attrs: &[Attribute]) -> bool {
attrs.iter().any(|a| {
let Some(name) = a.path().get_ident() else {
return true;
};
!matches!(
name.to_string().as_str(),
"doc"
| "cfg"
| "cfg_attr"
| "path"
| "allow"
| "warn"
| "deny"
| "forbid"
| "expect"
| "inline"
| "cold"
| "must_use"
| "deprecated"
| "track_caller"
)
})
}
impl Index {
pub fn load(root: &Path) -> Result<Self> {
if !root.join("Cargo.lock").is_file() {
return Err(Diagnostic::new(
"E_UNPINNED",
"workspace needs a committed Cargo.lock; generate it before planning",
)
.file(root));
}
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(root.join("Cargo.toml"))
.no_deps()
.other_options(vec!["--offline".into(), "--locked".into()])
.exec()
.map_err(|e| Diagnostic::new("E_METADATA", e.to_string()))?;
if metadata
.workspace_root
.as_std_path()
.canonicalize()
.map_err(io)?
!= root
{
return Err(Diagnostic::new(
"E_INVALID_PATH",
"--workspace must name the Cargo workspace root",
));
}
for package in &metadata.packages {
for dep in &package.dependencies {
if let Some(path) = &dep.path
&& !path
.as_std_path()
.canonicalize()
.map_err(io)?
.starts_with(root)
{
return Err(Diagnostic::new(
"E_INVALID_PATH",
"path dependencies must remain inside the staged workspace",
)
.file(path.as_std_path()));
}
}
}
let mut index = Self {
functions: Vec::new(),
blocked: BTreeSet::new(),
macro_modules: BTreeSet::new(),
files: BTreeMap::new(),
};
for package in &metadata.packages {
if !metadata.workspace_members.contains(&package.id) {
continue;
}
for target in &package.targets {
if !target.kind.iter().any(|k| {
matches!(
k,
cargo_metadata::TargetKind::Lib
| cargo_metadata::TargetKind::Bin
| cargo_metadata::TargetKind::RLib
| cargo_metadata::TargetKind::CDyLib
| cargo_metadata::TargetKind::DyLib
| cargo_metadata::TargetKind::StaticLib
)
}) {
continue;
}
let path = target.src_path.as_std_path();
let prefix = target.name.replace('-', "_");
if !target.required_features.is_empty() {
index.blocked.insert(prefix);
continue;
}
index.read(
root,
path,
&prefix,
path.parent().unwrap(),
false,
&mut BTreeSet::new(),
)?;
}
}
Ok(index)
}
fn read(
&mut self,
root: &Path,
path: &Path,
prefix: &str,
module_dir: &Path,
inherited_cfg: bool,
active: &mut BTreeSet<PathBuf>,
) -> Result<()> {
let path = path.canonicalize().map_err(|e| io(e).file(path))?;
if !path.starts_with(root) {
return Err(Diagnostic::new("E_INVALID_PATH", "module escapes workspace").file(&path));
}
if !active.insert(path.clone()) {
return Err(Diagnostic::new("E_INVALID_PATH", "recursive external module").file(path));
}
let file = path
.strip_prefix(root)
.unwrap()
.to_str()
.ok_or_else(|| Diagnostic::new("E_INVALID_PATH", "non UTF-8 module path"))?
.replace('\\', "/");
let tree = syn::parse_file(&fs::read_to_string(&path).map_err(io)?)
.map_err(|e| Diagnostic::new("E_RUST_SYNTAX", e.to_string()).file(&path))?;
self.items(
root,
&tree.items,
&file,
prefix,
module_dir,
&[],
inherited_cfg || cfg(&tree.attrs),
active,
)?;
if macro_attributes(&tree.attrs) {
self.macro_modules.insert(format!("@{prefix}"));
}
self.files.insert(file, tree);
active.remove(&path);
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn items(
&mut self,
root: &Path,
items: &[Item],
file: &str,
prefix: &str,
module_dir: &Path,
parent: &[usize],
inherited_cfg: bool,
active: &mut BTreeSet<PathBuf>,
) -> Result<()> {
for (i, item) in items.iter().enumerate() {
let mut node = parent.to_vec();
node.push(i);
match item {
Item::Fn(f) => self.functions.push(Function {
id: format!("{prefix}::{}", f.sig.ident),
file: file.into(),
node,
signature: f.sig.clone(),
block: *f.block.clone(),
unsupported_cfg: inherited_cfg || cfg(&f.attrs),
unsupported_macro: macro_attributes(&f.attrs),
generic_impl: false,
}),
Item::Mod(m) => {
let next = format!("{prefix}::{}", m.ident);
let uncertain = inherited_cfg || cfg(&m.attrs);
if macro_attributes(&m.attrs) {
self.macro_modules.insert(format!("@{next}"));
continue;
}
if uncertain {
self.blocked.insert(next);
continue;
}
if let Some((_, nested)) = &m.content {
self.items(
root,
nested,
file,
&next,
&module_dir.join(m.ident.to_string()),
&node,
false,
active,
)?;
} else {
let attr = m.attrs.iter().find(|a| a.path().is_ident("path"));
let path = if let Some(attr) = attr {
let syn::Meta::NameValue(nv) = &attr.meta else {
return Err(Diagnostic::new(
"E_INVALID_PATH",
"expected #[path = \"relative.rs\"]",
)
.file(file));
};
let syn::Expr::Lit(lit) = &nv.value else {
return Err(Diagnostic::new(
"E_INVALID_PATH",
"module path must be literal",
)
.file(file));
};
let syn::Lit::Str(value) = &lit.lit else {
return Err(Diagnostic::new(
"E_INVALID_PATH",
"module path must be a string",
)
.file(file));
};
source::relative(Path::new(&value.value()))?;
// #[path] on an external item is relative to the containing source directory.
let base = if parent.is_empty() {
root.join(file).parent().unwrap().to_path_buf()
} else {
module_dir.to_path_buf()
};
base.join(value.value())
} else {
let a = module_dir.join(format!("{}.rs", m.ident));
let b = module_dir.join(m.ident.to_string()).join("mod.rs");
match (a.is_file(), b.is_file()) {
(true, false) => a,
(false, true) => b,
(true, true) => {
return Err(Diagnostic::new(
"E_TARGET_AMBIGUOUS",
format!(
"both {} and {} define {next}",
a.display(),
b.display()
),
)
.file(file));
}
_ => {
return Err(Diagnostic::new(
"E_INVALID_PATH",
format!("module {next} has no source file"),
)
.file(file));
}
}
};
let subdir =
if path.file_name().is_some_and(|n| n == "mod.rs") || attr.is_some() {
path.parent().unwrap().to_path_buf()
} else {
path.parent().unwrap().join(path.file_stem().unwrap())
};
self.read(root, &path, &next, &subdir, false, active)?;
}
}
Item::Impl(imp) => {
let ty = tokens(&imp.self_ty).replace(' ', "");
let identity = if let Some((_, tr, _)) = &imp.trait_ {
format!("<{ty} as {}>", tokens(tr).replace(' ', ""))
} else {
ty
};
for (j, member) in imp.items.iter().enumerate() {
if let syn::ImplItem::Fn(f) = member {
let mut node = node.clone();
node.push(j);
self.functions.push(Function {
id: format!("{prefix}::{identity}::{}", f.sig.ident),
file: file.into(),
node,
signature: f.sig.clone(),
block: f.block.clone(),
unsupported_cfg: inherited_cfg || cfg(&imp.attrs) || cfg(&f.attrs),
unsupported_macro: macro_attributes(&imp.attrs)
|| macro_attributes(&f.attrs),
generic_impl: !imp.generics.params.is_empty(),
});
}
}
}
Item::Macro(_) => {
self.macro_modules.insert(prefix.into());
}
_ => {}
}
}
Ok(())
}
pub fn resolve(&self, target: &str) -> Result<&Function> {
if self
.blocked
.iter()
.any(|p| target == p || target.starts_with(&format!("{p}::")))
{
return Err(Diagnostic::new(
"E_UNSUPPORTED_CFG",
"target is under unresolved cfg/cfg_attr; select unconditional source",
));
}
let found: Vec<_> = self.functions.iter().filter(|f| f.id == target).collect();
if self
.macro_modules
.iter()
.filter_map(|p| p.strip_prefix('@'))
.any(|p| target.starts_with(&format!("{p}::")))
{
return Err(Diagnostic::new(
"E_UNSUPPORTED_MACRO",
"target module has an unexpanded attribute macro",
));
}
if let [f] = found.as_slice() {
if self
.functions
.iter()
.filter(|other| other.file == f.file && other.node == f.node)
.count()
> 1
{
return Err(Diagnostic::new("E_TARGET_AMBIGUOUS", "source function is reused by multiple modules or Cargo targets; it cannot be patched independently").file(&f.file));
}
if f.unsupported_macro {
return Err(Diagnostic::new(
"E_UNSUPPORTED_MACRO",
"target function/impl has an unexpanded attribute macro",
)
.file(&f.file));
}
}
match found.as_slice() {
[f] if f.unsupported_cfg => Err(Diagnostic::new(
"E_UNSUPPORTED_CFG",
"target or impl has unresolved cfg/cfg_attr",
)
.file(&f.file)),
[f] => Ok(f),
[] => {
let module = target.rsplit_once("::").map(|(p, _)| p).unwrap_or(target);
let code = if self.macro_modules.contains(module) {
"E_UNSUPPORTED_MACRO"
} else {
"E_TARGET_NOT_FOUND"
};
Err(Diagnostic::new(
code,
format!(
"no source function '{target}'; use an exact crate::module::function, Type::method or <Type as Trait>::method ID"
),
))
}
_ => Err(Diagnostic::new(
"E_TARGET_AMBIGUOUS",
format!(
"'{target}' resolves to {} definitions (including Cargo targets); use an unambiguous unconditional target",
found.len()
),
)),
}
}
}
pub fn parameters(sig: &Signature) -> Result<BTreeSet<String>> {
sig.inputs
.iter()
.map(|arg| match arg {
FnArg::Receiver(_) => Ok("self".into()),
FnArg::Typed(t) => {
if let syn::Pat::Ident(i) = &*t.pat {
Ok(i.ident.to_string())
} else {
Err(Diagnostic::new(
"E_INVALID_BINDING",
"destructured function parameters are unsupported",
))
}
}
})
.collect()
}
+7
View File
@@ -0,0 +1,7 @@
pub mod diagnostic;
pub mod index;
pub mod manifest;
pub mod plan;
pub mod source;
pub use diagnostic::{Diagnostic, Result};
pub use plan::{Plan, PlannedInjection, build_plan};
+163
View File
@@ -0,0 +1,163 @@
use crate::{Diagnostic, Result, diagnostic::io, source};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeSet, fs, path::Path};
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
#[serde(rename = "mod")]
pub info: Mod,
#[serde(default)]
pub compatibility: Compatibility,
pub injection: Vec<Injection>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Mod {
pub id: String,
pub version: String,
#[serde(default)]
pub priority: i32,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Compatibility {
pub horizon_revision: Option<String>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum Kind {
Head,
BeforeCall,
Overwrite,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Injection {
pub id: String,
pub target: String,
pub kind: Kind,
pub helper: String,
#[serde(default)]
pub bindings: Vec<Binding>,
pub priority: Option<i32>,
pub selector: Option<String>,
pub ordinal: Option<usize>,
pub require: Option<usize>,
pub fingerprint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Binding {
pub name: String,
pub ty: String,
pub value: String,
}
#[derive(Debug)]
pub struct LoadedMod {
pub manifest: Manifest,
pub file: String,
pub helpers: Vec<String>,
}
fn valid_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 100
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
/// Only direct TOML files and one directory level of horizon-mixin.toml bundles.
pub fn load(root: &Path) -> Result<Vec<LoadedMod>> {
let root = root.canonicalize().map_err(io)?;
let mut paths = Vec::new();
if !root.is_dir() {
return Err(Diagnostic::new("E_INVALID_PATH", "--mods must be a directory").file(&root));
}
for entry in fs::read_dir(&root).map_err(io)? {
let entry = entry.map_err(io)?;
if entry.file_type().map_err(io)?.is_symlink() {
return Err(
Diagnostic::new("E_INVALID_PATH", "mod bundles cannot be symlinks")
.file(entry.path()),
);
}
if entry.path().extension().is_some_and(|e| e == "toml") {
paths.push(entry.path());
} else if entry.file_type().map_err(io)?.is_dir()
&& entry.path().join("horizon-mixin.toml").exists()
{
paths.push(entry.path().join("horizon-mixin.toml"));
}
}
paths.sort();
if paths.is_empty() {
return Err(Diagnostic::new("E_MANIFEST", "no TOML mod manifests found").file(&root));
}
let mut ids = BTreeSet::new();
let mut mods = Vec::new();
for path in paths {
let path = source::contained(&root, path.strip_prefix(&root).unwrap())?;
let text = fs::read_to_string(&path).map_err(|e| io(e).file(&path))?;
let manifest: Manifest = toml::from_str(&text)
.map_err(|e| Diagnostic::new("E_MANIFEST", e.to_string()).file(&path))?;
let fail = |code: &str, message: String| Diagnostic::new(code, message).file(&path);
if !valid_id(&manifest.info.id)
|| manifest.info.version.is_empty()
|| manifest.injection.is_empty()
{
return Err(fail(
"E_MANIFEST",
"mod needs an ASCII id, a version and at least one injection".into(),
));
}
if !ids.insert(manifest.info.id.clone()) {
return Err(fail(
"E_DUPLICATE_ID",
format!("duplicate mod id '{}'", manifest.info.id),
));
}
let mut injections = BTreeSet::new();
let mut helpers = Vec::new();
for injection in &manifest.injection {
let context = |e: Diagnostic| e.injection(&manifest.info.id, injection).file(&path);
if !valid_id(&injection.id) || !injections.insert(&injection.id) {
return Err(context(fail(
"E_DUPLICATE_ID",
format!("invalid or duplicate injection id '{}'", injection.id),
)));
}
if injection.kind == Kind::BeforeCall {
if injection.selector.is_none()
|| injection.ordinal.is_none()
|| injection.require.is_none_or(|n| n == 0)
{
return Err(context(fail(
"E_SELECTOR",
"BEFORE_CALL requires selector, zero-based ordinal, and positive require"
.into(),
)));
}
} else if injection.selector.is_some()
|| injection.ordinal.is_some()
|| injection.require.is_some()
{
return Err(context(fail(
"E_SELECTOR",
"selector/ordinal/require apply only to BEFORE_CALL".into(),
)));
}
let helper_path =
source::contained(path.parent().unwrap(), Path::new(&injection.helper))
.map_err(context)?;
let helper = fs::read_to_string(&helper_path).map_err(|e| context(io(e)))?;
helpers.push(helper);
}
mods.push(LoadedMod {
manifest,
helpers,
file: path.display().to_string(),
});
}
mods.sort_by(|a, b| a.manifest.info.id.cmp(&b.manifest.info.id));
Ok(mods)
}
+586
View File
@@ -0,0 +1,586 @@
use crate::{
Diagnostic, Result,
diagnostic::io,
index::{self, Function, Index, tokens},
manifest::{self, Binding, Kind},
source,
};
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::Path, process::Command};
use syn::{
Expr, FnArg, Item, ReturnType,
visit::{self, Visit},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Plan {
pub schema: u32,
pub transformer: String,
pub rustc: String,
pub inputs: BTreeMap<String, String>,
pub injections: Vec<PlannedInjection>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PlannedInjection {
pub mod_id: String,
pub mod_version: String,
pub injection_id: String,
pub priority: i32,
pub target: String,
pub file: String,
pub node: Vec<usize>,
pub function_fingerprint: String,
pub kind: Kind,
/// Preorder call number in the pristine body, never in an already rewritten tree.
pub call_site: Option<usize>,
pub site_fingerprint: Option<String>,
pub selector: Option<String>,
pub ordinal: Option<usize>,
pub require: Option<usize>,
pub helper_source: String,
pub bindings: Vec<Binding>,
}
impl Plan {
/// Validate externally deserialized IR before traversing any AST path.
pub fn validate_structure(&self) -> Result<()> {
let mut ids = std::collections::BTreeSet::new();
for p in &self.injections {
source::relative(Path::new(&p.file))?;
let call_fields_valid = match p.kind {
Kind::BeforeCall => {
p.call_site.is_some()
&& p.site_fingerprint.is_some()
&& p.selector.as_ref().is_some_and(|s| !s.is_empty())
&& matches!((p.ordinal, p.require), (Some(o), Some(n)) if n > 0 && o < n)
}
_ => {
p.call_site.is_none()
&& p.site_fingerprint.is_none()
&& p.selector.is_none()
&& p.ordinal.is_none()
&& p.require.is_none()
}
};
if !call_fields_valid
|| p.node.is_empty()
|| !self.inputs.contains_key(&p.file)
|| !ids.insert((&p.mod_id, &p.injection_id))
{
return Err(Diagnostic::new(
"E_PLAN_SITE",
"invalid call fields, AST path, input file or duplicate injection in plan",
)
.file(&p.file));
}
}
Ok(())
}
}
pub fn rustc(root: &Path) -> Result<String> {
let out = Command::new("rustc")
.current_dir(root)
.arg("--version")
.output()
.map_err(io)?;
if !out.status.success() {
return Err(Diagnostic::new(
"E_TOOLCHAIN",
String::from_utf8_lossy(&out.stderr),
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().into())
}
pub fn build_plan(workspace: &Path, mods: &Path) -> Result<Plan> {
let root = workspace.canonicalize().map_err(io)?;
let mods = manifest::load(mods)?;
let inputs = source::files(&root)?
.into_iter()
.map(|(name, bytes)| (name, source::fingerprint(bytes)))
.collect();
let index = Index::load(&root)?;
let mut injections = Vec::new();
for bundle in mods {
if let Some(revision) = &bundle.manifest.compatibility.horizon_revision {
let actual = if root.join(".horizon-revision").is_file() {
std::fs::read_to_string(root.join(".horizon-revision")).map_err(io)?
} else {
let output = Command::new("git")
.arg("-C")
.arg(&root)
.args(["rev-parse", "HEAD"])
.output()
.map_err(io)?;
if !output.status.success() {
return Err(Diagnostic::new(
"E_REVISION",
"cannot establish a pinned Horizon revision",
)
.file(&bundle.file));
}
String::from_utf8_lossy(&output.stdout).into_owned()
};
if revision.len() != 40
|| !revision.bytes().all(|b| b.is_ascii_hexdigit())
|| actual.trim() != revision
{
return Err(Diagnostic::new(
"E_REVISION",
format!(
"requires exact Horizon revision {revision}, found {}",
actual.trim()
),
)
.file(&bundle.file));
}
}
for (injection, helper) in bundle.manifest.injection.iter().zip(&bundle.helpers) {
let context = |e: Diagnostic| {
let e = e.injection(&bundle.manifest.info.id, injection);
if e.file.is_none() {
e.file(&bundle.file)
} else {
e
}
};
let f = index.resolve(&injection.target).map_err(context)?;
validate_target(f).map_err(context)?;
validate_helper(f, injection.kind, helper, &injection.bindings).map_err(context)?;
if injection.kind == Kind::BeforeCall {
reject_shadowed_parameters(f).map_err(context)?;
}
let function_fingerprint =
source::fingerprint(format!("{}{}", tokens(&f.signature), tokens(&f.block)));
if injection
.fingerprint
.as_ref()
.is_some_and(|expected| expected != &function_fingerprint)
{
return Err(context(Diagnostic::new(
"E_FINGERPRINT",
format!(
"original function changed; expected {}, got {function_fingerprint}",
injection.fingerprint.as_ref().unwrap()
),
)));
}
let (call_site, site_fingerprint) = if injection.kind == Kind::BeforeCall {
let selector = injection.selector.as_ref().unwrap();
validate_selector(selector).map_err(context)?;
let mut calls = Calls {
all: 0,
selector,
found: Vec::new(),
next_is_root: false,
};
calls.visit_block(&f.block);
let count = injection.require.unwrap();
if calls.found.len() != count {
return Err(context(Diagnostic::new("E_MATCH_COUNT", format!("selector '{selector}' requires {count} syntactic matches, found {} in pristine {}", calls.found.len(), f.id)).file(&f.file)));
}
let ordinal = injection.ordinal.unwrap();
let Some((site, fingerprint, eligible)) = calls.found.get(ordinal) else {
return Err(context(
Diagnostic::new(
"E_ORDINAL",
format!(
"ordinal {ordinal} is outside 0..{} for '{selector}'",
calls.found.len()
),
)
.file(&f.file),
));
};
if !eligible {
return Err(context(Diagnostic::new("E_UNSUPPORTED_SITE", "BEFORE_CALL requires the complete expression of a statement, a plain let initializer, or a function/block tail; nesting can change temporary drop timing").file(&f.file)));
}
(Some(*site), Some(fingerprint.clone()))
} else {
(None, None)
};
injections.push(PlannedInjection {
mod_id: bundle.manifest.info.id.clone(),
mod_version: bundle.manifest.info.version.clone(),
injection_id: injection.id.clone(),
priority: injection.priority.unwrap_or(bundle.manifest.info.priority),
target: f.id.clone(),
file: f.file.clone(),
node: f.node.clone(),
function_fingerprint,
kind: injection.kind,
call_site,
site_fingerprint,
selector: injection.selector.clone(),
ordinal: injection.ordinal,
require: injection.require,
helper_source: helper.clone(),
bindings: injection.bindings.clone(),
});
}
}
injections.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then(a.node.cmp(&b.node))
.then(a.call_site.cmp(&b.call_site))
.then(b.priority.cmp(&a.priority))
.then(a.mod_id.cmp(&b.mod_id))
.then(a.injection_id.cmp(&b.injection_id))
});
let mut groups: BTreeMap<(&str, &[usize]), Vec<&PlannedInjection>> = BTreeMap::new();
for p in &injections {
groups.entry((&p.file, &p.node)).or_default().push(p);
}
for entries in groups.values() {
if entries.len() > 1 && entries.iter().any(|p| p.kind == Kind::Overwrite) {
let p = entries[0];
return Err(Diagnostic {
mod_id: Some(p.mod_id.clone().into()),
injection_id: Some(p.injection_id.clone().into()),
target: Some(p.target.clone().into()),
..Diagnostic::new(
"E_OVERWRITE_CONFLICT",
format!(
"OVERWRITE must be exclusive on {}; competing injections: {}",
p.target,
entries
.iter()
.map(|p| format!("{}/{}", p.mod_id, p.injection_id))
.collect::<Vec<_>>()
.join(", ")
),
)
.file(&p.file)
});
}
}
// Ensure metadata did not change inputs and catch concurrent source edits.
let after: BTreeMap<_, _> = source::files(&root)?
.into_iter()
.map(|(name, bytes)| (name, source::fingerprint(bytes)))
.collect();
if inputs != after {
return Err(Diagnostic::new(
"E_STALE_SOURCE",
"workspace changed during planning; retry from pristine source",
));
}
Ok(Plan {
schema: 1,
transformer: env!("CARGO_PKG_VERSION").into(),
rustc: rustc(&root)?,
inputs,
injections,
})
}
fn validate_target(f: &Function) -> Result<()> {
let s = &f.signature;
if s.asyncness.is_some()
|| s.constness.is_some()
|| s.unsafety.is_some()
|| s.abi.is_some()
|| s.variadic.is_some()
|| !s.generics.params.is_empty()
|| s.generics.where_clause.is_some()
|| f.generic_impl
{
return Err(Diagnostic::new(
"E_UNSUPPORTED_SIGNATURE",
"MVP requires a synchronous, safe, non-const, non-generic Rust function",
)
.file(&f.file));
}
let mut safety = Safety { error: None };
safety.visit_signature(s);
safety.visit_block(&f.block);
if let Some((code, why)) = safety.error {
return Err(Diagnostic::new(code, why).file(&f.file));
}
Ok(())
}
fn reject_shadowed_parameters(f: &Function) -> Result<()> {
struct Shadow {
names: std::collections::BTreeSet<String>,
found: Option<String>,
}
impl<'ast> Visit<'ast> for Shadow {
fn visit_pat_ident(&mut self, pat: &'ast syn::PatIdent) {
if self.names.contains(&pat.ident.to_string()) {
self.found = Some(pat.ident.to_string());
}
visit::visit_pat_ident(self, pat);
}
}
let mut shadow = Shadow {
names: index::parameters(&f.signature)?,
found: None,
};
shadow.visit_block(&f.block);
if let Some(name) = shadow.found {
return Err(Diagnostic::new(
"E_INVALID_BINDING",
format!("BEFORE_CALL cannot bind parameters in a body that shadows '{name}'"),
)
.file(&f.file));
}
Ok(())
}
struct Safety {
error: Option<(&'static str, &'static str)>,
}
impl<'ast> Visit<'ast> for Safety {
fn visit_expr(&mut self, expr: &'ast Expr) {
match expr {
Expr::Macro(_) => {
self.error = Some((
"E_UNSUPPORTED_MACRO",
"opaque macros in target body are unsupported; expose ordinary Rust source",
))
}
Expr::Closure(_)
| Expr::Async(_)
| Expr::Await(_)
| Expr::Try(_)
| Expr::TryBlock(_)
| Expr::Yield(_)
| Expr::Unsafe(_)
| Expr::Const(_) => {
self.error = Some((
"E_UNSUPPORTED_CONTROL_FLOW",
"closures, async, try/?, unsafe and const blocks are unsupported injection contexts",
))
}
// Calls under a reference can depend on temporary lifetime extension.
Expr::Reference(r) if contains_call(&r.expr) => {
self.error = Some((
"E_UNSUPPORTED_CONTROL_FLOW",
"calls beneath a reference may change temporary lifetimes",
))
}
_ => {}
}
visit::visit_expr(self, expr);
}
fn visit_macro(&mut self, _: &'ast syn::Macro) {
self.error = Some(("E_UNSUPPORTED_MACRO", "target contains an opaque macro"));
}
fn visit_item(&mut self, _: &'ast Item) {
self.error = Some((
"E_UNSUPPORTED_CONTROL_FLOW",
"nested items in target functions are unsupported",
));
}
fn visit_attribute(&mut self, attr: &'ast syn::Attribute) {
if index::macro_attributes(std::slice::from_ref(attr)) {
self.error = Some((
"E_UNSUPPORTED_MACRO",
"target contains an unexpanded attribute macro",
));
}
if attr.path().is_ident("cfg") || attr.path().is_ident("cfg_attr") {
self.error = Some((
"E_UNSUPPORTED_CFG",
"target body contains unresolved cfg/cfg_attr",
));
}
}
}
fn contains_call(expr: &Expr) -> bool {
struct Find(bool);
impl<'ast> Visit<'ast> for Find {
fn visit_expr(&mut self, e: &'ast Expr) {
if is_call(e) {
self.0 = true;
}
visit::visit_expr(self, e);
}
}
let mut f = Find(false);
f.visit_expr(expr);
f.0
}
pub fn is_call(expr: &Expr) -> bool {
matches!(expr, Expr::Call(_) | Expr::MethodCall(_))
}
pub fn call_key(expr: &Expr) -> Option<String> {
match expr {
Expr::Call(c) => Some(tokens(&c.func).replace(' ', "")),
Expr::MethodCall(c) => Some(format!(
"{}.{}{}",
tokens(&c.receiver).replace(' ', ""),
c.method,
tokens(&c.turbofish).replace(' ', "")
)),
_ => None,
}
}
fn validate_selector(selector: &str) -> Result<()> {
let parsed = syn::parse_str::<Expr>(&format!("{selector}()"));
if parsed.as_ref().ok().and_then(call_key).as_deref() != Some(selector) {
return Err(Diagnostic::new(
"E_SELECTOR",
"selector must be an exact callable path or receiver.method without spaces or parentheses",
));
}
Ok(())
}
struct Calls<'a> {
all: usize,
selector: &'a str,
found: Vec<(usize, String, bool)>,
next_is_root: bool,
}
impl<'ast> Visit<'ast> for Calls<'_> {
fn visit_stmt(&mut self, stmt: &'ast syn::Stmt) {
match stmt {
syn::Stmt::Expr(expr, _) => {
self.next_is_root = true;
self.visit_expr(expr);
}
syn::Stmt::Local(local) => {
if let Some(init) = &local.init {
self.next_is_root = matches!(&local.pat, syn::Pat::Ident(p) if p.by_ref.is_none() && p.subpat.is_none());
self.visit_expr(&init.expr);
if let Some((_, expr)) = &init.diverge {
self.visit_expr(expr);
}
}
}
_ => visit::visit_stmt(self, stmt),
}
}
fn visit_expr(&mut self, expr: &'ast Expr) {
let eligible = std::mem::take(&mut self.next_is_root);
if let Some(key) = call_key(expr) {
let site = self.all;
self.all += 1;
if key == self.selector {
self.found
.push((site, source::fingerprint(tokens(expr)), eligible));
}
}
if matches!(expr, Expr::Paren(_)) {
self.next_is_root = eligible;
}
visit::visit_expr(self, expr);
}
}
pub fn parse_helper(source: &str) -> Result<syn::ItemFn> {
let file =
syn::parse_file(source).map_err(|e| Diagnostic::new("E_INVALID_HELPER", e.to_string()))?;
if !file.attrs.is_empty() || file.items.len() != 1 {
return Err(Diagnostic::new(
"E_INVALID_HELPER",
"helper file must contain exactly one private function",
));
}
let Some(Item::Fn(helper)) = file.items.into_iter().next() else {
return Err(Diagnostic::new(
"E_INVALID_HELPER",
"helper must be a function",
));
};
let sig = &helper.sig;
if !helper.attrs.is_empty()
|| !matches!(helper.vis, syn::Visibility::Inherited)
|| sig.asyncness.is_some()
|| sig.constness.is_some()
|| sig.unsafety.is_some()
|| sig.abi.is_some()
|| sig.variadic.is_some()
|| !sig.generics.params.is_empty()
|| sig.generics.where_clause.is_some()
{
return Err(Diagnostic::new(
"E_INVALID_HELPER",
"helper must be private, synchronous, safe, non-generic and have no attributes",
));
}
Ok(helper)
}
pub fn validate_helper(f: &Function, kind: Kind, source: &str, bindings: &[Binding]) -> Result<()> {
let helper = parse_helper(source)?;
let parameters = index::parameters(&f.signature)?;
if parameters.contains(&helper.sig.ident.to_string()) {
return Err(Diagnostic::new(
"E_INVALID_HELPER",
"helper name shadows a target parameter; rename it",
));
}
if helper.sig.inputs.len() != bindings.len() {
return Err(Diagnostic::new(
"E_INVALID_BINDING",
"one explicit binding is required per helper parameter",
));
}
if kind != Kind::Overwrite
&& !matches!(&helper.sig.output, ReturnType::Default)
&& !matches!(&helper.sig.output, ReturnType::Type(_, ty) if matches!(&**ty, syn::Type::Tuple(t) if t.elems.is_empty()))
{
return Err(Diagnostic::new(
"E_INVALID_HELPER",
"insertion helpers must return ()",
));
}
let mut names = std::collections::BTreeSet::new();
for (arg, binding) in helper.sig.inputs.iter().zip(bindings) {
let FnArg::Typed(arg) = arg else {
return Err(Diagnostic::new(
"E_INVALID_HELPER",
"helpers cannot have self receivers",
));
};
let syn::Pat::Ident(pattern) = &*arg.pat else {
return Err(Diagnostic::new(
"E_INVALID_BINDING",
"helper parameters must be named identifiers",
));
};
let ty: syn::Type = syn::parse_str(&binding.ty)
.map_err(|e| Diagnostic::new("E_INVALID_BINDING", e.to_string()))?;
let value: Expr = syn::parse_str(&binding.value)
.map_err(|e| Diagnostic::new("E_INVALID_BINDING", e.to_string()))?;
if pattern.ident != binding.name
|| !names.insert(&binding.name)
|| tokens(&ty) != tokens(&arg.ty)
{
return Err(Diagnostic::new(
"E_INVALID_BINDING",
format!(
"binding '{}' must match helper parameter name and type in declaration order",
binding.name
),
));
}
if !binding_expr(&value, &parameters) {
return Err(Diagnostic::new(
"E_INVALID_BINDING",
format!(
"binding '{}' must be a target parameter or its explicit borrow/reborrow; no calls or locals",
binding.value
),
));
}
if tokens(&ty).split_whitespace().any(|s| s == "Self") {
return Err(Diagnostic::new(
"E_INVALID_BINDING",
"use an explicit concrete type instead of Self in local helper signatures",
));
}
}
Ok(())
}
fn binding_expr(expr: &Expr, parameters: &std::collections::BTreeSet<String>) -> bool {
match expr {
Expr::Path(p) if p.qself.is_none() && p.path.segments.len() == 1 => {
parameters.contains(&p.path.segments[0].ident.to_string())
}
Expr::Reference(r) => binding_expr(&r.expr, parameters),
Expr::Unary(u) if matches!(u.op, syn::UnOp::Deref(_)) => binding_expr(&u.expr, parameters),
Expr::Paren(p) => binding_expr(&p.expr, parameters),
_ => false,
}
}
+79
View File
@@ -0,0 +1,79 @@
use crate::{Diagnostic, Result, diagnostic::io};
use sha2::{Digest, Sha256};
use std::{
collections::BTreeMap,
fs,
path::{Component, Path, PathBuf},
};
pub fn fingerprint(bytes: impl AsRef<[u8]>) -> String {
format!("{:x}", Sha256::digest(bytes))
}
pub fn relative(path: &Path) -> Result<()> {
if path.as_os_str().is_empty()
|| path
.components()
.any(|c| !matches!(c, Component::Normal(_)))
{
return Err(Diagnostic::new(
"E_INVALID_PATH",
"expected a nonempty relative path without '.' or '..'",
)
.file(path));
}
Ok(())
}
pub fn contained(root: &Path, path: &Path) -> Result<PathBuf> {
relative(path)?;
let resolved = root
.join(path)
.canonicalize()
.map_err(|e| io(e).file(root.join(path)))?;
if !resolved.starts_with(root) {
return Err(Diagnostic::new("E_INVALID_PATH", "path escapes its source root").file(path));
}
Ok(resolved)
}
/// Snapshot all staged inputs. Exclude build outputs, VCS and local player/assets data.
pub fn files(root: &Path) -> Result<BTreeMap<String, Vec<u8>>> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, Vec<u8>>) -> Result<()> {
for entry in fs::read_dir(dir).map_err(io)? {
let entry = entry.map_err(io)?;
let path = entry.path();
let name = entry.file_name();
if matches!(
name.to_str(),
Some("target" | ".git" | ".local" | ".horizon-mixins" | "node_modules")
) {
continue;
}
let ty = entry.file_type().map_err(io)?;
if ty.is_symlink() {
return Err(Diagnostic::new(
"E_INVALID_PATH",
"symlinks are not supported in staged inputs",
)
.file(&path));
}
if ty.is_dir() {
walk(root, &path, out)?;
} else if ty.is_file() {
let key = path
.strip_prefix(root)
.unwrap()
.to_str()
.ok_or_else(|| Diagnostic::new("E_INVALID_PATH", "paths must be UTF-8"))?
.replace('\\', "/");
out.insert(key, fs::read(&path).map_err(io)?);
} else {
return Err(
Diagnostic::new("E_INVALID_PATH", "special files cannot be staged").file(&path),
);
}
}
Ok(())
}
let mut out = BTreeMap::new();
walk(root, root, &mut out)?;
Ok(out)
}
@@ -0,0 +1,10 @@
[package]
name = "horizon-mixins-transform"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
horizon-mixins-core.workspace = true
syn.workspace = true
quote.workspace = true
prettyplease.workspace = true
+243
View File
@@ -0,0 +1,243 @@
use horizon_mixins_core::{
Diagnostic, Plan, PlannedInjection, Result,
index::{Function, tokens},
manifest::Kind,
plan, source,
};
use quote::quote;
use std::{collections::BTreeMap, path::Path};
use syn::{
Block, Expr, Item, Signature,
visit_mut::{self, VisitMut},
};
/// Rewrite only after validating all original fingerprints. No filesystem mutation.
pub fn transform(root: &Path, plan: &Plan) -> Result<BTreeMap<String, Vec<u8>>> {
if plan.schema != 1 || plan.transformer != env!("CARGO_PKG_VERSION") {
return Err(Diagnostic::new(
"E_PLAN_VERSION",
"unsupported plan schema/transformer",
));
}
plan.validate_structure()?;
if plan.rustc != plan::rustc(root)? {
return Err(Diagnostic::new(
"E_TOOLCHAIN",
"rustc differs from the planned toolchain",
));
}
let mut files = source::files(root)?;
let fingerprints: BTreeMap<_, _> = files
.iter()
.map(|(name, bytes)| (name.clone(), source::fingerprint(bytes)))
.collect();
if fingerprints != plan.inputs {
return Err(Diagnostic::new(
"E_STALE_SOURCE",
"workspace no longer matches planned inputs; regenerate plan",
));
}
let mut groups: BTreeMap<(&str, &[usize]), Vec<&PlannedInjection>> = BTreeMap::new();
for patch in &plan.injections {
groups
.entry((&patch.file, &patch.node))
.or_default()
.push(patch);
}
let mut trees = BTreeMap::new();
for (file, _) in groups.keys() {
if !trees.contains_key(*file) {
let bytes = files
.get(*file)
.ok_or_else(|| Diagnostic::new("E_STALE_SOURCE", "planned file is absent"))?;
let text = std::str::from_utf8(bytes)
.map_err(|e| Diagnostic::new("E_RUST_SYNTAX", e.to_string()))?;
trees.insert(
file.to_string(),
syn::parse_file(text)
.map_err(|e| Diagnostic::new("E_RUST_SYNTAX", e.to_string()).file(file))?,
);
}
}
for ((file, node), mut patches) in groups {
patches.sort_by(|a, b| {
a.call_site
.cmp(&b.call_site)
.then(b.priority.cmp(&a.priority))
.then(a.mod_id.cmp(&b.mod_id))
.then(a.injection_id.cmp(&b.injection_id))
});
let tree = trees.get_mut(file).unwrap();
let (signature, block) = locate(&mut tree.items, node)?;
let fingerprint = source::fingerprint(format!("{}{}", tokens(signature), tokens(block)));
for patch in &patches {
if patch.function_fingerprint != fingerprint {
return Err(
Diagnostic::new("E_STALE_SOURCE", "function fingerprint mismatch").file(file),
);
}
let f = Function {
id: patch.target.clone(),
file: file.into(),
node: node.to_vec(),
signature: signature.clone(),
block: block.clone(),
unsupported_cfg: false,
unsupported_macro: false,
generic_impl: false,
};
plan::validate_helper(&f, patch.kind, &patch.helper_source, &patch.bindings)?;
}
if patches.iter().any(|p| p.kind == Kind::Overwrite) {
if patches.len() != 1 {
return Err(Diagnostic::new(
"E_OVERWRITE_CONFLICT",
"OVERWRITE must be exclusive",
));
}
// The helper call is the tail expression, preserving the target signature's return type.
let expr = helper_call(patches[0])?;
*block = syn::parse2(quote!({ #expr })).map_err(syntax)?;
continue;
}
let calls: BTreeMap<usize, Vec<&PlannedInjection>> = patches
.iter()
.filter(|p| p.kind == Kind::BeforeCall)
.fold(BTreeMap::new(), |mut groups, p| {
groups.entry(p.call_site.unwrap()).or_default().push(*p);
groups
});
let expected = calls.len();
let mut rewrite = Rewrite {
calls,
cursor: 0,
matched: 0,
error: None,
next_discards_result: false,
};
rewrite.visit_block_mut(block);
if let Some(e) = rewrite.error {
return Err(e.file(file));
}
if rewrite.matched != expected {
return Err(
Diagnostic::new("E_STALE_SOURCE", "planned call site disappeared").file(file),
);
}
let mut head = Vec::new();
for patch in patches.iter().filter(|p| p.kind == Kind::Head) {
let expr = helper_call(patch)?;
head.push(syn::parse2(quote!(#expr;)).map_err(syntax)?);
}
head.append(&mut block.stmts);
block.stmts = head;
}
for (file, tree) in trees {
files.insert(file, prettyplease::unparse(&tree).into_bytes());
}
Ok(files)
}
fn syntax(e: syn::Error) -> Diagnostic {
Diagnostic::new("E_TRANSFORM", e.to_string())
}
fn helper_call(patch: &PlannedInjection) -> Result<Expr> {
let helper = plan::parse_helper(&patch.helper_source)?;
let name = &helper.sig.ident;
let values: Vec<Expr> = patch
.bindings
.iter()
.map(|b| syn::parse_str(&b.value).map_err(syntax))
.collect::<Result<_>>()?;
syn::parse2(quote!({ #helper #name(#(#values),*) })).map_err(syntax)
}
struct Rewrite<'a> {
calls: BTreeMap<usize, Vec<&'a PlannedInjection>>,
cursor: usize,
matched: usize,
error: Option<Diagnostic>,
next_discards_result: bool,
}
impl VisitMut for Rewrite<'_> {
fn visit_stmt_mut(&mut self, stmt: &mut syn::Stmt) {
// Retain the original call's own statement boundary when its value is
// discarded, so a returned Drop value is destroyed before its receiver.
self.next_discards_result = matches!(stmt, syn::Stmt::Expr(_, Some(_)));
visit_mut::visit_stmt_mut(self, stmt);
}
fn visit_expr_mut(&mut self, expr: &mut Expr) {
let discards_result = std::mem::take(&mut self.next_discards_result);
let site = if plan::is_call(expr) {
let site = self.cursor;
self.cursor += 1;
Some(site)
} else {
None
};
let original = source::fingerprint(tokens(expr));
// Descend into original children first, but assign IDs in the same preorder as the planner.
if matches!(expr, Expr::Paren(_)) {
self.next_discards_result = discards_result;
}
visit_mut::visit_expr_mut(self, expr);
if let Some(patches) = site.and_then(|site| self.calls.get(&site)) {
if patches
.iter()
.any(|p| p.site_fingerprint.as_ref() != Some(&original))
{
self.error = Some(Diagnostic::new(
"E_STALE_SOURCE",
"call site fingerprint mismatch",
));
return;
}
let mut calls = Vec::new();
for patch in patches {
match helper_call(patch) {
Ok(call) => calls.push(call),
Err(e) => {
self.error = Some(e);
return;
}
}
}
let rewritten = if discards_result {
quote!({ #(#calls;)* #expr; })
} else {
quote!({ #(#calls;)* #expr })
};
match syn::parse2(rewritten) {
Ok(new) => {
*expr = new;
self.matched += 1;
}
Err(e) => self.error = Some(syntax(e)),
}
}
}
}
fn locate<'a>(items: &'a mut [Item], path: &[usize]) -> Result<(&'a Signature, &'a mut Block)> {
let Some((first, rest)) = path.split_first() else {
return Err(Diagnostic::new("E_PLAN_SITE", "empty AST path"));
};
let item = items
.get_mut(*first)
.ok_or_else(|| Diagnostic::new("E_PLAN_SITE", "invalid AST item index"))?;
match item {
Item::Fn(f) if rest.is_empty() => Ok((&f.sig, &mut f.block)),
Item::Mod(m) => {
if let Some((_, items)) = &mut m.content {
locate(items, rest)
} else {
Err(Diagnostic::new("E_PLAN_SITE", "expected inline module"))
}
}
Item::Impl(i) if rest.len() == 1 => match i.items.get_mut(rest[0]) {
Some(syn::ImplItem::Fn(f)) => Ok((&f.sig, &mut f.block)),
_ => Err(Diagnostic::new("E_PLAN_SITE", "expected impl method")),
},
_ => Err(Diagnostic::new(
"E_PLAN_SITE",
"AST path does not address a supported function",
)),
}
}
+42 -38
View File
@@ -1,45 +1,49 @@
# Architecture
## Stages
1. Discover pinned Horizon revision/toolchain and mod manifests.
2. Build module/function index from Cargo metadata + source files using `syn`.
3. Resolve every selector against the **original** AST, not prior mods' output.
4. Construct canonical patch plan and preflight conflicts.
5. Transform a copied syntax tree; generate staged source and bridge code.
6. Compile staged workspace with pinned Cargo/rustc; publish a fingerprinted executable.
7. Eventually add a launcher-facing JSON integration.
The implemented workspace has three crates:
## Suggested workspace
```text
crates/
horizon-mixins-core/ # manifests, IR, selectors, planner, errors
horizon-mixins-transform/ # syn AST visitor, code generation
horizon-mixins-cli/ # plan, apply, check
fixtures/ # standalone game crate, mod snippets, tests
docs/
```
- `horizon-mixins-core`: strict manifests, source snapshots, module/function
indexing, original-site IR, conflict detection and structured diagnostics.
- `horizon-mixins-transform`: pure `syn` AST rewrite and `prettyplease` output.
- `horizon-mixins-cli`: `plan`, `apply`, `check`, atomic staging and Cargo locks.
## Index and naming
Index module tree including `mod name { }`, `mod name;`, `#[path]`, free functions, inherent impls and `impl Trait for Type`. Include crate, module, impl identity and function in target IDs. Rust `cfg` and macro-generated functions cannot be confidently inferred by a syntax-only pass; reject unsupported targets. Same-named methods require explicit disambiguation. Fingerprint original body/site for version compatibility.
The planner snapshots inputs, asks `cargo_metadata` for workspace libraries and
binaries with `--no-deps --locked --offline`, and walks their original module
trees. It handles inline modules, external `mod.rs`/`name.rs`, `#[path]`, free
functions, inherent impls and trait impls. It rejects external path dependencies,
recursive/escaping module files, ambiguous identities and source files compiled
under more than one module/target identity. Tests/examples/benches are copied
and compiled by Cargo but are not injection targets in the MVP.
## Major integration issue: privacy and crate cycles
A function patched inside `horizon-world` cannot simply call private code in a separate mod crate; Rust privacy remains. Also game→mod→game introduces Cargo dependency cycles. Prototype a generated **same-crate shim** (or inlined mod-specific helper), with explicit validated context bindings, and compile a fixture to prove visibility/lifetimes. For later versions a stable typed bridge API can expose game types safely.
Every injection resolves before any transformation. The schema-1 JSON plan
records transformer/rustc version, every input file's SHA-256, mod identity and
version, target ID, original file and item-index path, signature/body fingerprint,
call preorder number/fingerprint, selector/count/ordinal, priority, helper source,
and explicit bindings. It contains no absolute checkout paths or discovery paths,
so bundle enumeration does not affect serialized bytes.
## IR (illustrative)
```rust
enum Kind { Head, BeforeCall, Overwrite }
struct Injection {
mod_id: String,
injection_id: String,
target: String,
kind: Kind,
selector: Option<String>,
ordinal: Option<usize>,
require: usize,
priority: i32,
}
```
Plan record contains original file path, target, AST site path, original fingerprint, insertion order, required helper and compatibility metadata. Stable serialization permits launcher diagnostics and caching.
The transformer verifies input/toolchain fingerprints, groups patches by source
function, numbers only original call nodes in preorder, and rewrites their
children before replacing each selected node. It never traverses generated
helper code. It applies HEAD statements after rewriting original call sites.
This preserves original coordinates even when hooks contain matching calls.
Only files with target functions are pretty-printed; other files retain bytes.
Non-doc comments in transformed files are not preserved by `syn`/`prettyplease`.
## Constraints
Keep original checkout unchanged, pin revisions and lockfiles, avoid distributing Minecraft assets or prepared resources. Later compiler-aided resolution may augment the syntactic core but must not be required for MVP.
The privacy bridge is a local function inside the patched body. Explicit
parameter bindings supply all context, with Rust enforcing privacy, moves,
borrows and return types during `check`. No mod crate becomes a dependency of
the game, so this design introduces no Cargo cycle. The phase-0 two-crate fixture
proves private field access/reborrowing and checks that cross-crate private
access and circular dependencies actually fail. Native helpers use the target
crate's existing dependency/import environment; adding dependencies is future work.
The CLI rebuilds a validated plan for each invocation, transforms in memory,
then publishes a complete separate workspace. Saved JSON plans are reviewable
artifacts and are not executable CLI inputs. The transform library validates
IR structure and original fingerprints before traversing it; callers should
obtain semantic plans through `build_plan`.
See [staging](build-and-cache.md), [supported bindings](mixin-api.md), and
[prototype results](implementation.md). Compiler-aided resolution, launcher
protocols and fingerprinted executable publication remain future work.
+41 -12
View File
@@ -1,17 +1,46 @@
# Launcher build and cache
# Staging, Cargo reuse and measurements
## Pipeline
Resolve exact Horizon source commit, Cargo.lock, mod versions and dependencies, toolchain and target. Verify bundles. Produce validated plan, stage only changed files under a stable profile directory, then compile with pinned Cargo. Atomically publish binary only after success and reuse last working binary on failure.
`apply` transforms all validated inputs in memory and assembles a complete
sibling temporary directory. The first stage is published with directory
rename. Linux updates use `renameat2(RENAME_EXCHANGE)` so a reader sees a
complete old or new workspace. A failed plan, transformation or exchange leaves
the prior stage intact. Old staged generations are cleaned up after exchange.
Atomic replacement currently requires Linux; another platform can publish a
fresh output directory, but cannot replace an existing stage.
## Cache layers
1. **Source transformation:** source-file fingerprint + transformer version + applicable mods/patches → staged bytes.
2. **Cargo artifacts:** stable workspace paths and compatible target/profile/rustc config; preserve incremental compilation metadata.
3. **Final executable:** complete fingerprint including game revision, all mod sources, lockfile/dependencies, target triple, rustc, features, rustflags, profile, relevant environment and build scripts.
A stage marker prevents replacing arbitrary directories. A sibling advisory
stage lock serializes publishers and remains held throughout `check`'s Cargo
process. The configurable Cargo target directory has its own advisory lock,
including when different stages share it. Locks release on process exit; Cargo
also retains its own internal locks. Direct user Cargo commands do not acquire
the CLI's stage lock, so use `check` while staging concurrently.
## Rules
Do not rewrite files if bytes unchanged (maintain mtimes), recreate target dirs on every invocation, share mutable targets across concurrent builds without locks, or assume rustc incremental artifacts are portable across machines/toolchains. Cargo invalidation is often crate-granular: changing a core crate may recompile dependents and relink. Benchmark baseline, no-change, single-mod-change, and shared-core-change builds.
If all bytes are unchanged, staging does nothing. Changed generations hard-link
unchanged files only from the previous stage, preserving file mtimes and modes;
original input files are never hard-linked or modified. New/rewritten files are
written independently. Removed input files disappear on the next publication.
Build directories, `.git`, `.local`, `.horizon-mixins` and `node_modules` are
excluded. Other symlinks and special files are rejected. Use source-only exports
for large upstream trees containing unrelated runtime data.
Horizon defines a `fast` profile with LTO disabled, 16 codegen units and incremental compilation enabled. Test profile suitability. Portable compiler distribution needs verified downloads plus OS-specific toolchains/linker prerequisites.
`check` executes only `cargo test --workspace --locked --offline`, optionally
limited by repeated `--package`. `--trust-native-mods` explicitly opts into
unsandboxed native execution. It captures compiler/test logs and reports timing
as JSON. A failed compile retains the staged source for inspection and returns
`E_BUILD`; it does not publish a binary. Stable sibling target paths are reused
across no-change and mod-change builds.
## Launcher integration (future)
Use a JSON status/progress protocol. Distinguish validation failures, incompatible upstream revision, build failures and cache hits. Never make launcher integration prerequisite for standalone tested CLI MVP.
`tools/benchmark.py` measures a first modded build, unchanged run, one helper
change, and a shared game-source change. It stores transformation time, Cargo
build-plus-test wall time, and Cargo's reported compile duration, plus platform
and rustc version. Each invocation needs a fresh `--out`. The Horizon prototype
also records a baseline library test and two staged Cargo test runs. Results
are documented in [implementation.md](implementation.md); they are measurements
on one machine, not promises about game rebuild speed.
The full launcher cache is future work: source transformation cache entries,
complete environment/feature/rustflag/build-script fingerprints, verified toolchain
packages, final executable caching, successful-binary publication and rollback.
No launcher integration is implemented. Rust incremental artifacts are not
assumed portable. Horizon's `fast` profile is not selected automatically; the
MVP uses Cargo's test profile and measures it.
+71
View File
@@ -0,0 +1,71 @@
# Feasibility, acceptance and measurements
Implemented and exercised on 2026-10-28 with stable rustc 1.95.0
(`59807616e`), Linux x86_64. The Cargo workspace and fixtures commit their
lockfiles; the fixture also carries its toolchain pin into staging.
The phase-0 prototype was compiled before implementation of the engine. A
private `Entity.velocity` field is modified by a local helper taking
`&mut Entity`, called with an explicit reborrow of `self`; the separate driver
crate verifies two successive results. The positive two-crate fixture passed.
A separate mod crate trying to access that field failed Rust privacy checking.
A game→mod→game fixture failed Cargo's cyclic dependency check. These are real
compiler/Cargo failures asserted by `tools/feasibility.py`, not simulated errors.
The resulting binding design compiles one ordinary helper function directly
inside the target's lexical module/function scope. Every context value comes
from a declared target parameter binding. Concrete helper argument types,
return types, private access and reborrows are checked by staged Cargo tests.
The MVP adds no dependencies and requires no cross-crate private visibility.
See [mixin-api.md](mixin-api.md) for syntax and limits.
The main fixture has a game crate and a driver crate, plus two independent mod
bundles. Its four runtime tests prove private-field HEAD ordering, function-call
result/exactly-once evaluation, method receiver/argument evaluation, and
OVERWRITE. The engine integration suite adds source/plan snapshots, manifest
and selector failures, cfg/macros, ambiguity, conflicts, borrowing/return-type
compilation failures, concurrent checks, immutable originals and preserved
mtimes. All 16 integration tests pass. Linux CI runs rustfmt, clippy with
warnings denied, phase-0 compilation and the integration suite.
A first four-scenario fixture measurement produced:
| Scenario | Transform/stage wall | Cargo compile | Cargo build + tests |
|---|---:|---:|---:|
| First modded build | 0.056 s | 0.13 s | 0.171 s |
| Unchanged run | 0.053 s | 0.00 s | 0.042 s |
| One helper changed | 0.051 s | 0.09 s | 0.129 s |
| Shared game source changed | 0.053 s | 0.08 s | 0.128 s |
Measured platform: `Linux-7.3.0-2-cachyos-x86_64-with-glibc2.44`.
These tiny builds establish cache reuse; they do not predict full-game compile
time. `tools/benchmark.py` stores raw Cargo logs/reports under its fresh output
directory. The Cargo compile column is Cargo's reported test-profile duration;
the wall column includes executing tests and doc tests.
The Horizon experiment exports the complete `horizon-core` crate from local
Git commit `028f60881678a9a0e3e198f693a82858fa475c76`. Its crate sources remain
unchanged before transformation. The experiment replaces only the exported
root workspace manifest to isolate that crate, reduces the exported lockfile
to its existing exact `libm = 0.2.16` dependency, adds an export revision stamp,
and adds two test probes. The local Horizon checkout is never edited.
Two separately declared HEAD hooks target `horizon_core::jmath::atan`. Each
panics at a different sentinel input, and the probe catches the exact messages
to prove both hooks execute. A normal input retains the original bitwise result.
All upstream library and integration tests pass: 106 library tests, 8 existing
integration tests, 2 added probes, and 2 deliberately ignored upstream
microbenchmarks. No Minecraft resources are needed.
The first upstream run measured 0.377 s transformation/staging, 2.71 s Cargo
compilation and 12.324 s Cargo-plus-tests. An identical run preserved the stage
and reported 0.00 s Cargo compilation; its 9.606 s wall time is primarily the
upstream tests, rather than recompilation. The baseline untransformed library
test run took 11.884 s including compilation and execution (2.33 s reported compilation). Fresh repetitions
will differ. Run `tools/horizon_prototype.py` with a fresh `--out` to generate
raw baseline/Cargo logs and a report on the current machine.
This validates the isolated complete foundation crate, not the full Horizon
game, graphics/audio stack, prepared assets, launcher, or production gameplay
compatibility. Advanced injection semantics, semantic resolution, cross-platform
atomic replacement and executable publication/caching remain future work.
+80 -18
View File
@@ -1,26 +1,88 @@
# Proposed mod API (not yet implemented)
# Declarative MVP API
Start with declarative manifest entries. Attribute syntax below is **future ergonomic sugar**, not valid today.
Mods are trusted Rust snippets described by TOML, rather than proc-macro
attributes. `--mods DIR` discovers direct `.toml` files and
`DIR/<bundle>/horizon-mixin.toml`. Discovery order has no semantic effect.
Unknown fields, duplicate IDs and missing helpers fail validation.
```toml
[mod]
id = "better-movement"
version = "0.1.0"
priority = 100 # default; each injection can override it
# Optional, exact 40-character commit; see the isolated Horizon prototype.
[compatibility]
horizon_revision = "028f60881678a9a0e3e198f693a82858fa475c76"
[[injection]]
id = "before-second-consume"
target = "fixture_game::calls::compute"
kind = "BEFORE_CALL"
selector = "consume"
ordinal = 1
require = 2
helper = "before.rs"
bindings = [{ name = "log", ty = "&mut Vec<&'static str>", value = "&mut *log" }]
# fingerprint = "<SHA-256 from the original plan>" # optional compatibility guard
```
`horizon_revision` is checked against Git HEAD or the `.horizon-revision` stamp
of a controlled source export. A stamp is provenance metadata, not cryptographic
attestation. The source files, Cargo.lock, toolchain file when present, and all
other staged inputs also have SHA-256 fingerprints in the plan.
Helper paths must be relative to their manifest directory, without traversal
or escapes through symlinks. A helper file contains exactly one private,
synchronous, safe, non-generic function without attributes. It becomes a local
function in a separate block inside the target. Independent helpers can use
the same Rust function name. Module types, imports and private fields are in
scope; outer function locals and `Self` are not implicit context.
```rust
#[mixin(target = "horizon_world::entity::update_velocity")]
mod movement {
#[inject(at = "HEAD")]
fn at_start(/* declared parameter bindings */) {}
#[inject(at = "BEFORE_CALL", target = "entity.apply_friction", ordinal = 0, require = 1)]
fn before_friction(/* validated bindings */) {}
fn before(log: &mut Vec<&'static str>) {
log.push("before");
}
```
## v1 semantics
- **HEAD:** insert a helper invocation before the first statement of the resolved target body. Bind arguments by explicit names/types; never assume `entity` or `self` exists.
- **BEFORE_CALL:** insert before a specific AST `Expr::Call` or `Expr::MethodCall` in the target function. Require a syntactic selector plus `ordinal` and `require`. Do not claim semantic trait/method resolution using `syn`.
- **OVERWRITE:** replace body of exactly one target function. Conflicts with all other injections targeting the same function in MVP.
- Priorities order compatible same-site insertion deterministically; they do not resolve overwrites.
- Version restrictions are pinned to exact Horizon commit initially, with optional original-function fingerprint.
Every helper argument needs a binding in declaration order. `name` and `ty`
must exactly match its parsed parameter name/type. `value` is a target parameter,
its borrow, or explicit dereference/reborrow (`self`, `&self`, `&mut *entity`).
Calls, fields and arbitrary locals are not binding expressions. Use explicit
concrete types in helper signatures. BEFORE_CALL rejects parameter shadowing.
Rust compilation validates the actual types, mutability, lifetimes and moves.
## Future
AFTER_CALL, RETURN, REDIRECT, MODIFY_ARG, MODIFY_VARIABLE. RETURN must handle implicit tail return, early return, `?`, async/closures. MODIFY_ARG must preserve argument evaluation and borrow order. MODIFY_VARIABLE must select a particular binding/scope rather than equal text.
HEAD inserts helper calls before the original first statement. BEFORE_CALL
inserts before receiver/callee and argument evaluation, preserving one original
call as the final expression. OVERWRITE replaces only the body with a helper
call as its tail, retaining the original attributes/signature. Insertion helpers
return `()`; overwrite return compatibility is checked by Cargo. HEAD and
OVERWRITE do not accept selector/count/ordinal fields.
Native Mixins require recompilation and are trusted unsandboxed code; Horizon's WASM mods remain separate.
Function IDs include Cargo target name (hyphens become underscores), module,
impl identity, and function:
| Source | Target ID |
|---|---|
| Free function | `fixture_game::entity::advance` |
| Inherent method | `fixture_game::entity::Entity::tick` |
| Trait impl method | `fixture_game::entity::<Entity as Marker>::marked` |
Selectors are syntax, not semantic resolution: `consume`, `crate::consume`,
`entity.apply_friction`, or `make_receiver(evaluations).take`. Whitespace is not
accepted. `require` counts all exact matches in the original body, and
`ordinal` selects one in AST preorder. A selector is never matched against an
injected helper. Counts and ordinals are mandatory even when only one match
is expected. Bare method names do not match qualified receiver expressions.
BEFORE_CALL supports complete expression statements, plain identifier `let`
initializers and block/function tails. Other placements fail with
`E_UNSUPPORTED_SITE`. Functions containing opaque macros, unresolved cfg,
closures, async, await, `?`/try, unsafe/const blocks, nested items or calls under
references are outside the proven MVP subset. Generic, const, unsafe and
foreign-ABI target functions are also rejected. Feature-gated module trees
and Cargo targets are not guessed or expanded.
Future attribute syntax, additional dependencies, arbitrary helper modules,
semantic selectors, AFTER_CALL, RETURN, REDIRECT, MODIFY_ARG and
MODIFY_VARIABLE require separate specifications and tests.
+25 -25
View File
@@ -1,29 +1,29 @@
# Roadmap / acceptance matrix
# Roadmap and acceptance matrix
## Phase 0 — Feasibility
Create two-crate Cargo fixture testing a same-crate injected helper, access to private fields, borrowing and dependency cycles. Write down supported binding design.
Phases 0–4 are implemented. Phase 5 is verified for an isolated complete
`horizon-core` crate at `028f60881678a9a0e3e198f693a82858fa475c76`.
Full game and launcher integration remain future work.
## Phase 1 — Parsing and indexing
Workspace/CLI skeleton; TOML parser; explicit module tree, free functions and impl methods; precise ambiguous/missing errors.
| Phase | Result |
|---|---|
| 0 — Feasibility | Two-crate compilation fixture proves local bridge/private fields/reborrowing; private cross-crate access and dependency cycles fail as expected |
| 1 — Parsing/index | Three-crate CLI workspace; strict TOML; inline/external/path modules; free/inherent/trait functions; missing/ambiguous/cfg/macro diagnostics |
| 2 — Planning | Canonical JSON; original-site fingerprints; explicit counts/ordinals; deterministic priority/ID order; overwrite conflicts before mutation |
| 3 — Transform | HEAD, BEFORE_CALL and OVERWRITE; source/plan snapshots; compiled behavior, borrowing and return-type tests |
| 4 — Staging/Cargo | Immutable originals; atomic Linux publication; unchanged mtimes; stable locked target directories; concurrency tests and four-scenario benchmark |
| 5 — Upstream prototype | Isolated pinned horizon-core export, two HEAD hooks in jmath::atan, upstream tests and hook probes; no assets/upstream edits |
| 6 — Advanced mixins | Future: independent semantics/tests for AFTER_CALL, RETURN, REDIRECT, MODIFY_ARG, MODIFY_VARIABLE and semantic matching |
## Phase 2 — Planning
JSON plan of all injections resolved against original source; ordinals, require counts, deterministic ordering; reject conflicting overwrites before mutation.
`cargo test --workspace --locked` checks two independent mods, same-site priority
and ID tie order, original selectors unaffected by injected calls, call results
and exactly-once arguments/receiver evaluation, overwrite success/conflicts,
source/plan snapshots, discovery order independence, repeated bytes/mtimes,
immutable originals, removed files, concurrent Cargo checks, and stale source.
It also checks missing/ambiguous targets, wrong counts/ordinals, duplicate IDs,
malformed manifests/helpers, invalid paths, cfg/macros, unproven temporal scopes,
parameter shadowing and compiler-diagnosed helper borrow/return failures.
## Phase 3 — AST transformer
HEAD, BEFORE_CALL, OVERWRITE; generation snapshots; positive/negative compilation-tested fixtures.
## Phase 4 — Staging/build cache
Separate immutable-original workspace; no-change file preservation; locking; cargo test of output; stable build directories and profiling.
## Phase 5 — Upstream Horizon prototype
Pin Horizon commit; choose a small low-risk game function, run isolated source patch and compilation. Only integrate launcher through a separate maintainer-approved change.
## Phase 6 — Advanced Mixins
Add AFTER_CALL, REDIRECT, RETURN, MODIFY_ARG, MODIFY_VARIABLE with independent semantic specifications/tests; consider optional type-aware analysis.
## Mandatory tests
Positive: two mods at HEAD, BEFORE_CALL preserving original call result, OVERWRITE, stable ordering independent of discovery, two identical runs unchanged outputs/mtimes, staged `cargo test` success.
Negative: unknown/ambiguous function, selector absent, invalid ordinal or require, two overwrites, overwrite+HEAD, duplicate mod IDs, invalid paths, syntax errors, macro-only targets, uncertain cfg, invalid helper/borrow. Every failure must be nonzero with actionable diagnostics.
CI: rustfmt, clippy, unit tests, source snapshots and Linux compilation fixtures first; expand Windows/macOS after validation.
Linux CI runs the phase-0 script, rustfmt, clippy with warnings denied, and the
integration suite. Expand atomic staging and compilation CI to Windows/macOS
after implementing their directory-publication strategy. Launcher work requires
a separate change; it is not part of this MVP.
+28 -5
View File
@@ -1,9 +1,32 @@
# Security / trust model
# Security and supported boundaries
Native mod source, build.rs, proc macros and Cargo dependencies can execute code with user privileges. **This is not sandboxed.** Show explicit trust warnings and native-mod opt-in; keep Horizon's WASM mod environment as the safer separated path.
Native helper source, dependencies, build scripts and proc macros can execute
with user privileges. They are not sandboxed. `check` requires
`--trust-native-mods` and reports an explicit diagnostic without that opt-in.
Planning/staging does not compile the source or run build scripts; it uses
Cargo metadata and Rust syntax parsing. Review the workspace's Cargo configuration
and toolchain as part of trusting the native workspace.
Pin and verify source/dependencies/toolchain, prevent manifest path traversal and symlink escapes, keep original Horizon checkout read-only, isolate build staging and avoid executing arbitrary build commands outside clearly approved Cargo steps. An OS build sandbox is desirable future work but not assumed available.
Cargo.lock is required, dependency resolution/builds are locked and offline,
and source files/toolchain are fingerprinted before rewriting. Horizon bundles
can pin an exact commit and original function fingerprint. Source export revision
stamps are trusted provenance metadata, not signed attestations. Helper path
traversal, symlink escapes, source symlinks, external path dependencies and
output paths overlapping originals/mods are rejected. Staging only replaces
its own marked outputs, atomically on Linux.
`syn` cannot resolve Rust methods/types or expand opaque macros. Injection cannot bypass privacy, borrowing, orphan rules or trait resolution. Build artifacts aren't generally portable. Compilation success does not guarantee mod safety, compatibility or gameplay correctness.
These checks prevent accidental source escape and mutation under a cooperative
local filesystem. They are not protection against a hostile local process racing
filesystem checks, malicious trusted build scripts, arbitrary helper behavior or
manual edits through hard-linked prior-stage files. Do not modify a stage while
`check` builds it. An OS sandbox and signed/verified mod bundles remain future work.
Do not redistribute Minecraft binaries, game data, decompiled source or prepared assets. Project license remains for owner selection.
Rust privacy, ownership, borrowing, orphan rules and trait resolution still
apply. Helpers compile inside the target crate with explicit context. `syn`
provides syntax, not macro expansion or semantic resolution. Unproven source
contexts are rejected; successful compilation does not establish gameplay safety.
Horizon's WebAssembly mod system remains separate.
No game binaries, Minecraft assets, decompiled Java or prepared resources belong
in this repository. The Horizon prototype exports only committed Rust crate
source. License: MIT; see the repository LICENSE.
+54 -16
View File
@@ -1,26 +1,64 @@
# AST rewriting and conflict semantics
## Plan first
Resolve each mod's targets and injection sites on **pristine original** `syn::File` AST. Store stable function identity + original-site syntax path/fingerprint. Validate `require`, ordinal, feature/cfg and supported expression forms. Never match code injected by another mod.
All targets and sites resolve on pristine source. A function has a stable
module/impl identity, source item-index path, and signature/body fingerprint.
A call has an original preorder index and syntax fingerprint. Rewriting never
matches helpers emitted by another injection.
## Deterministic order
At same site, execute injections sorted by (priority descending, mod ID ascending, injection ID ascending). Resolve all sites first, then rewrite once. Two OVERWRITE requests or OVERWRITE plus any other injection into one function are errors, never silent wins.
Compatible same-site inserts execute in priority descending, mod ID ascending,
injection ID ascending order. Priorities never select an overwrite winner.
Two overwrites, or OVERWRITE plus any other hook on the same physical function,
fail with `E_OVERWRITE_CONFLICT` during planning, before stage publication.
## HEAD
Prepend generated statements to `syn::Block.stmts`. Require explicitly mapped arguments/context, with borrow-checked generated Rust; don't hard-code local names.
HEAD prepends separate blocks, each defining and invoking a local helper.
OVERWRITE replaces the body with a local helper and a tail call, retaining the
surrounding original function signature and attributes.
BEFORE_CALL replaces a selected call with an expression block:
## BEFORE_CALL
Conceptually replace `entity.apply_friction()` by:
```rust
{ generated_bridge::before_friction(/* validated context */); entity.apply_friction() }
let result = {
{ fn before() {} before() };
original(argument())
};
```
This block normally preserves the call result. Do not duplicate receiver/argument evaluation; preserve `?`, await/temporaries and lifetimes or fail as unsupported. A bare method name is only a syntactic match, not a resolved trait identity.
## OVERWRITE
Replace only target function body, preserving surrounding attrs/signature; validate helper visibility and return compatibility by staged compilation.
The hook runs before receiver/callee and arguments, and the original call is
emitted exactly once. Its result remains the tail expression. For a discarded
expression statement, the original call instead retains a semicolon inside the
generated block; this preserves destruction of a returned value before its
receiver temporary. A separate compiled Drop-tracing test covers that order. The compiled
fixture tests both a function call and a side-effecting method receiver, checks
argument/receiver counts and returned values, and uses two hooks at one call
site. A helper deliberately contains another matching call to prove it does
not change original `require` or `ordinal` resolution.
## Diagnostics / fail closed
Suggested error codes: `E_TARGET_NOT_FOUND`, `E_TARGET_AMBIGUOUS`, `E_MATCH_COUNT`, `E_ORDINAL`, `E_OVERWRITE_CONFLICT`, `E_UNSUPPORTED_MACRO`, `E_UNSUPPORTED_CFG`, `E_INVALID_HELPER`. Include file path, mod ID and selector. Macro-generated bodies, uncertain cfg, semantic overload matching and unsupported control-flow rewrites must fail clearly, not silently skip.
An extra block can shorten temporary lifetimes or change drop order inside a
larger expression. Consequently the selected call must be the complete
expression of a statement, a plain identifier `let` initializer, or a block or
function tail (parentheses are allowed). Binary operands, call arguments,
conditions and match-arm expressions are rejected as `E_UNSUPPORTED_SITE`.
Reference-wrapped calls, closures/async/await/try/`?`, nested items, unsafe/const
contexts, cfg and opaque macros fail closed. Parameter shadowing is rejected
for BEFORE_CALL so bindings cannot silently refer to a different local.
Compilation remains necessary to validate actual borrowing and return types.
## Tests
Snapshots of emitted code, two-mod same-site priority test, exact-once evaluation, unchanged return values, conflict and ambiguity tests, determinism independent of filesystem enumeration, and cargo test of generated fixtures.
Representative diagnostics:
| Code | Meaning |
|---|---|
| `E_TARGET_NOT_FOUND` | Exact source function is absent |
| `E_TARGET_AMBIGUOUS` | Multiple definitions or shared source identities |
| `E_MATCH_COUNT`, `E_ORDINAL` | Selector compatibility mismatch |
| `E_OVERWRITE_CONFLICT` | Overwrite is not exclusive |
| `E_UNSUPPORTED_MACRO`, `E_UNSUPPORTED_CFG` | Syntax cannot be resolved safely |
| `E_UNSUPPORTED_SIGNATURE`, `E_UNSUPPORTED_CONTROL_FLOW`, `E_UNSUPPORTED_SITE` | Unsupported source context |
| `E_MANIFEST`, `E_DUPLICATE_ID`, `E_INVALID_HELPER`, `E_INVALID_BINDING` | Invalid mod declaration |
| `E_INVALID_PATH`, `E_STAGE_OWNERSHIP` | Unsafe input/output location |
| `E_UNPINNED`, `E_REVISION`, `E_FINGERPRINT`, `E_STALE_SOURCE` | Compatibility/source mismatch |
| `E_BUILD` | Staged Cargo failed; compiler output included |
Diagnostics carry code/message and applicable file, mod, injection, target and
selector. The CLI exits nonzero and prints JSON on stderr. Source and plan
snapshots live in `crates/horizon-mixins-cli/tests/snapshots`; update deliberately
with `UPDATE_SNAPSHOTS=1 cargo test --workspace --locked`.
+3
View File
@@ -0,0 +1,3 @@
fn before(log: &mut Vec<&'static str>) {
log.push("example");
}
+10 -11
View File
@@ -1,17 +1,16 @@
# Illustrative manifest; loader does not exist yet.
# A loadable declaration for the pinned tiny workspace (fixtures/game).
# For the complete passing two-mod compilation fixture, use fixtures/mods.
[mod]
id = "better-movement"
id = "example"
version = "0.1.0"
priority = 100
[compatibility]
horizon_revision = "PINNED_COMMIT_SHA"
[[injection]]
id = "before-friction"
target = "horizon_client::entity::update_velocity"
id = "before-second-call"
target = "fixture_game::calls::compute"
kind = "BEFORE_CALL"
selector = "entity.apply_friction"
ordinal = 0
require = 1
helper = "better_movement::before_friction"
selector = "consume"
ordinal = 1
require = 2
helper = "before.rs"
bindings = [{ name = "log", ty = "&mut Vec<&'static str>", value = "&mut *log" }]
+3
View File
@@ -0,0 +1,3 @@
[workspace]
members = ["a", "b"]
resolver = "2"
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "cycle-game"
version = "0.1.0"
edition = "2024"
[dependencies]
cycle-mod = { path = "../b" }
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "cycle-mod"
version = "0.1.0"
edition = "2024"
[dependencies]
cycle-game = { path = "../a" }
View File
Whitespace-only changes.
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "spike-driver"
version = "0.1.0"
dependencies = [
"spike-game",
]
[[package]]
name = "spike-game"
version = "0.1.0"
+3
View File
@@ -0,0 +1,3 @@
[workspace]
members = ["game", "driver"]
resolver = "2"
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "spike-driver"
version = "0.1.0"
edition = "2024"
[dependencies]
spike-game = { path = "../game" }
+6
View File
@@ -0,0 +1,6 @@
#[test]
fn same_crate_private_bridge_and_reborrow() {
let mut entity = spike_game::Entity::new();
assert_eq!(entity.tick(), 10);
assert_eq!(entity.tick(), 28);
}
+4
View File
@@ -0,0 +1,4 @@
[package]
name = "spike-game"
version = "0.1.0"
edition = "2024"
+14
View File
@@ -0,0 +1,14 @@
mod private_game {
pub struct Entity { velocity: i32 }
impl Entity {
pub fn new() -> Self { Self { velocity: 1 } }
pub fn tick(&mut self) -> i32 {
// This is the bridge shape the transformer will generate.
fn injected(entity: &mut Entity) { entity.velocity += 4; }
injected(&mut *self);
self.velocity *= 2;
self.velocity
}
}
}
pub use private_game::Entity;
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "fixture-driver"
version = "0.1.0"
dependencies = [
"fixture-game",
]
[[package]]
name = "fixture-game"
version = "0.1.0"
+3
View File
@@ -0,0 +1,3 @@
[workspace]
members = ["engine", "driver"]
resolver = "2"
+4
View File
@@ -0,0 +1,4 @@
Pinned, dependency-free, edition-2024 two-crate compilation fixture. The driver
asserts the modded behavior, so run it through `horizon-mixins check` with
`fixtures/mods`, rather than running its untransformed tests directly.
Toolchain: repository rust-toolchain.toml; dependencies: committed Cargo.lock.
+6
View File
@@ -0,0 +1,6 @@
[package]
name = "fixture-driver"
version = "0.1.0"
edition = "2024"
[dependencies]
fixture-game = { path = "../engine" }
+33
View File
@@ -0,0 +1,33 @@
#[cfg(test)]
mod tests {
use fixture_game::{calls, entity::{advance, Entity, Marker}};
#[test]
fn two_independent_mods_access_private_fields_in_priority_order() {
let mut entity = Entity::new();
assert_eq!(advance(&mut entity), 8);
assert_eq!(entity.log(), &["alpha", "beta"]);
assert_eq!(entity.tick(), 9);
assert_eq!(entity.marked(), 9);
}
#[test]
fn before_call_keeps_result_and_evaluates_arguments_once() {
let mut evaluations = 0;
let mut log = Vec::new();
assert_eq!(calls::compute(&mut evaluations, &mut log), 9);
assert_eq!(evaluations, 2);
assert_eq!(log, ["argument", "alpha-before", "beta-before", "argument"]);
}
#[test]
fn method_receiver_and_argument_evaluate_once() {
let mut evaluations = 0;
let mut log = Vec::new();
assert_eq!(calls::method_compute(&mut evaluations, &mut log), 3);
assert_eq!(evaluations, 2);
assert_eq!(log, ["alpha-before", "argument"]);
}
#[test]
fn overwrite_preserves_signature_and_return_type() {
assert_eq!(fixture_game::overwrite::value(6), 42);
assert_eq!(fixture_game::inline::leaf::identity(7), 7);
}
}
+4
View File
@@ -0,0 +1,4 @@
[package]
name = "fixture-game"
version = "0.1.0"
edition = "2024"
+22
View File
@@ -0,0 +1,22 @@
pub fn consume(value: i32) -> i32 { value * 3 }
fn next(evaluations: &mut i32, log: &mut Vec<&'static str>) -> i32 {
*evaluations += 1;
log.push("argument");
*evaluations
}
pub fn compute(evaluations: &mut i32, log: &mut Vec<&'static str>) -> i32 {
let first = consume(next(evaluations, log));
let second = consume(next(evaluations, log));
first + second
}
struct Receiver(i32);
impl Receiver {
fn take(self, arg: i32) -> i32 { self.0 + arg }
}
fn make_receiver(evaluations: &mut i32) -> Receiver {
*evaluations += 1;
Receiver(*evaluations)
}
pub fn method_compute(evaluations: &mut i32, log: &mut Vec<&'static str>) -> i32 {
make_receiver(evaluations).take(next(evaluations, log))
}
+17
View File
@@ -0,0 +1,17 @@
pub struct Entity {
velocity: i32,
log: Vec<&'static str>,
}
impl Entity {
pub fn new() -> Self { Self { velocity: 2, log: Vec::new() } }
pub fn log(&self) -> &[&'static str] { &self.log }
pub fn tick(&mut self) -> i32 { self.velocity }
}
pub trait Marker { fn marked(&self) -> i32; }
impl Marker for Entity {
fn marked(&self) -> i32 { self.velocity }
}
pub fn advance(entity: &mut Entity) -> i32 {
entity.velocity += 1;
entity.velocity
}
+1
View File
@@ -0,0 +1 @@
pub fn identity(value: i32) -> i32 { value }
+8
View File
@@ -0,0 +1,8 @@
pub mod entity;
pub mod calls;
#[path = "replacement.rs"]
pub mod overwrite;
pub mod inline {
pub mod leaf;
pub fn identity(value: i32) -> i32 { value }
}
+1
View File
@@ -0,0 +1 @@
pub fn value(input: i32) -> i32 { input - 1 }
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.95.0"
profile = "minimal"
@@ -0,0 +1,12 @@
[mod]
id = "alpha"
version = "0.1.0"
priority = 100
[compatibility]
horizon_revision = "028f60881678a9a0e3e198f693a82858fa475c76"
[[injection]]
id = "observe-atan"
target = "horizon_core::jmath::atan"
kind = "HEAD"
helper = "observe.rs"
bindings = [{ name = "x", ty = "f64", value = "x" }]
+3
View File
@@ -0,0 +1,3 @@
fn observe(x: f64) {
if x == 1337.0 { panic!("alpha-hook"); }
}
@@ -0,0 +1,12 @@
[mod]
id = "beta"
version = "0.1.0"
priority = 50
[compatibility]
horizon_revision = "028f60881678a9a0e3e198f693a82858fa475c76"
[[injection]]
id = "observe-atan"
target = "horizon_core::jmath::atan"
kind = "HEAD"
helper = "observe.rs"
bindings = [{ name = "x", ty = "f64", value = "x" }]
+3
View File
@@ -0,0 +1,3 @@
fn observe(x: f64) {
if x == 1338.0 { panic!("beta-hook"); }
}
+5
View File
@@ -0,0 +1,5 @@
fn before(log: &mut Vec<&'static str>) {
// This call must never become another mod's selector match.
let _ = consume(0);
log.push("alpha-before");
}
+4
View File
@@ -0,0 +1,4 @@
fn head(entity: &mut Entity) {
entity.log.push("alpha");
entity.velocity *= 2;
}
+52
View File
@@ -0,0 +1,52 @@
[mod]
id = "alpha"
version = "0.1.0"
priority = 100
[[injection]]
id = "head"
target = "fixture_game::entity::advance"
kind = "HEAD"
helper = "head.rs"
bindings = [{ name = "entity", ty = "&mut Entity", value = "&mut *entity" }]
[[injection]]
id = "tick"
target = "fixture_game::entity::Entity::tick"
kind = "HEAD"
helper = "tick.rs"
bindings = [{ name = "entity", ty = "&mut Entity", value = "&mut *self" }]
[[injection]]
id = "trait"
target = "fixture_game::entity::<Entity as Marker>::marked"
kind = "HEAD"
helper = "trait.rs"
bindings = [{ name = "entity", ty = "&Entity", value = "self" }]
[[injection]]
id = "before-second"
target = "fixture_game::calls::compute"
kind = "BEFORE_CALL"
selector = "consume"
ordinal = 1
require = 2
helper = "before.rs"
bindings = [{ name = "log", ty = "&mut Vec<&'static str>", value = "&mut *log" }]
[[injection]]
id = "before-method"
target = "fixture_game::calls::method_compute"
kind = "BEFORE_CALL"
selector = "make_receiver(evaluations).take"
ordinal = 0
require = 1
helper = "before.rs"
bindings = [{ name = "log", ty = "&mut Vec<&'static str>", value = "&mut *log" }]
[[injection]]
id = "replace"
target = "fixture_game::overwrite::value"
kind = "OVERWRITE"
helper = "replace.rs"
bindings = [{ name = "input", ty = "i32", value = "input" }]
+1
View File
@@ -0,0 +1 @@
fn replace(input: i32) -> i32 { input * 7 }
+1
View File
@@ -0,0 +1 @@
fn tick(entity: &mut Entity) { entity.velocity += 1; }
+1
View File
@@ -0,0 +1 @@
fn observe(entity: &Entity) { let _ = entity.velocity; }
+1
View File
@@ -0,0 +1 @@
fn before(log: &mut Vec<&'static str>) { log.push("beta-before"); }
+4
View File
@@ -0,0 +1,4 @@
fn head(entity: &mut Entity) {
entity.log.push("beta");
entity.velocity += 3;
}
+21
View File
@@ -0,0 +1,21 @@
[mod]
id = "beta"
version = "0.1.0"
priority = 50
[[injection]]
id = "head"
target = "fixture_game::entity::advance"
kind = "HEAD"
helper = "head.rs"
bindings = [{ name = "entity", ty = "&mut Entity", value = "&mut *entity" }]
[[injection]]
id = "before-second"
target = "fixture_game::calls::compute"
kind = "BEFORE_CALL"
selector = "consume"
ordinal = 1
require = 2
helper = "before.rs"
bindings = [{ name = "log", ty = "&mut Vec<&'static str>", value = "&mut *log" }]
+14
View File
@@ -0,0 +1,14 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "private-mod"
version = "0.1.0"
dependencies = [
"spike-game",
]
[[package]]
name = "spike-game"
version = "0.1.0"
+7
View File
@@ -0,0 +1,7 @@
[workspace]
[package]
name = "private-mod"
version = "0.1.0"
edition = "2024"
[dependencies]
spike-game = { path = "../feasibility/game" }
+1
View File
@@ -0,0 +1 @@
pub fn illegal(entity: &mut spike_game::Entity) { entity.velocity += 1; }
+4
View File
@@ -0,0 +1,4 @@
[toolchain]
channel = "1.95.0"
profile = "minimal"
components = ["rustfmt", "clippy"]
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env python3
"""Measure fixture transformation, Cargo compilation, and tests under four edits."""
import argparse
import json
import pathlib
import platform
import re
import shutil
import subprocess
ROOT = pathlib.Path(__file__).resolve().parents[1]
def compilation_seconds(log):
match = re.search(r"Finished .* target\(s\) in ([0-9.]+)(s|ms)", log)
if match:
return float(match[1]) / (1000 if match[2] == "ms" else 1)
return None
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=pathlib.Path, default=ROOT / "target/benchmark")
args = parser.parse_args()
out = args.out.resolve()
if out.exists():
parser.error("use a fresh --out directory")
out.mkdir(parents=True)
shutil.copytree(ROOT / "fixtures/game", out / "original")
shutil.copytree(ROOT / "fixtures/mods", out / "mods")
subprocess.run(["cargo", "build", "--locked", "-p", "horizon-mixins-cli"], cwd=ROOT, check=True)
results = {"rustc": subprocess.check_output(["rustc", "--version"], text=True).strip(),
"platform": platform.platform(), "scenarios": {}}
for scenario in ["baseline_modded", "no_change", "single_mod_change", "shared_core_change"]:
if scenario == "single_mod_change":
helper = out / "mods/alpha/replace.rs"
helper.write_text(helper.read_text().replace("input * 7", "input * 7 + 0"))
if scenario == "shared_core_change":
source = out / "original/engine/src/entity.rs"
source.write_text(source.read_text().replace("entity.velocity += 1", "entity.velocity += 1 + 0"))
run = subprocess.run([str(ROOT / "target/debug/horizon-mixins"), "check", "--workspace", str(out / "original"),
"--mods", str(out / "mods"), "--out", str(out / "stage"),
"--target-dir", str(out / "cargo-target"), "--trust-native-mods"], capture_output=True, text=True)
if run.returncode:
raise RuntimeError(run.stderr)
report = json.loads(run.stdout)
results["scenarios"][scenario] = {key: report[key] for key in ["stage_changed", "transformation_seconds", "cargo_seconds"]}
results["scenarios"][scenario]["cargo_compile_seconds"] = compilation_seconds(report["cargo_stderr"])
(out / f"{scenario}.json").write_text(run.stdout)
(out / "report.json").write_text(json.dumps(results, indent=2) + "\n")
print(json.dumps(results, indent=2))
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
"""Compile the bridge and assert Rust rejects private access and crate cycles."""
import json
import pathlib
import subprocess
import time
root = pathlib.Path(__file__).resolve().parents[1]
results = []
for fixture, command, expected in [
("feasibility", "test", None),
("privacy-failure", "check", "private"),
("cycle", "check", "cyclic package dependency"),
]:
start = time.monotonic()
run = subprocess.run(
["cargo", command, "--offline", "--locked", "--manifest-path", str(root / "fixtures" / fixture / "Cargo.toml"),
"--target-dir", str(root / "target" / "feasibility")], capture_output=True, text=True,
)
assert (run.returncode == 0) == (expected is None), run.stdout + run.stderr
if expected:
assert expected in run.stderr, run.stderr
results.append({"fixture": fixture, "seconds": round(time.monotonic() - start, 3),
"expected_failure": expected is not None})
print(json.dumps(results, indent=2))
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Export and test the pinned horizon-core crate; never write to the upstream clone."""
import argparse
import io
import json
import pathlib
import subprocess
import tarfile
import time
from benchmark import compilation_seconds
PIN = "028f60881678a9a0e3e198f693a82858fa475c76"
ROOT = pathlib.Path(__file__).resolve().parents[1]
def run(args, log_path=None, **kwargs):
result = subprocess.run(args, capture_output=True, text=True, **kwargs)
if log_path:
log_path.write_text(result.stderr)
if result.returncode:
raise RuntimeError(result.stdout + result.stderr)
return result.stdout
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--horizon", type=pathlib.Path, required=True)
parser.add_argument("--out", type=pathlib.Path, default=ROOT / "target/horizon-prototype")
args = parser.parse_args()
upstream = args.horizon.resolve()
out = args.out.resolve()
if upstream == out or upstream in out.parents or out in upstream.parents:
parser.error("prototype output must be separate from the upstream clone")
if out.exists():
parser.error("use a fresh --out directory; prior prototype artifacts are preserved")
archive = subprocess.run(["git", "-C", str(upstream), "archive", PIN,
"Cargo.toml", "Cargo.lock", "crates/horizon-core"],
check=True, capture_output=True).stdout
original = out / "original"
original.mkdir(parents=True)
with tarfile.open(fileobj=io.BytesIO(archive)) as tar:
tar.extractall(original, filter="data")
# Isolate only the unmodified, pinned horizon-core source and its exact libm dependency.
# This avoids preparing Minecraft data or resolving graphics/audio/launcher dependencies.
(original / "Cargo.toml").write_text('''[workspace]
members = ["crates/horizon-core"]
resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
rust-version = "1.85"
[workspace.lints.clippy]
too_many_arguments = "allow"
type_complexity = "allow"
needless_range_loop = "allow"
large_enum_variant = "allow"
''')
(original / ".horizon-revision").write_text(PIN + "\n")
(original / "rust-toolchain.toml").write_bytes((ROOT / "fixtures/game/rust-toolchain.toml").read_bytes())
# Cargo reduces the exported workspace lockfile to the pinned crate's dependency set.
run(["cargo", "generate-lockfile", "--offline", "--manifest-path", str(original / "Cargo.toml")])
probe = original / "crates/horizon-core/tests/horizon_mixins_probe.rs"
probe.write_text('''#[test]
fn both_independent_head_hooks_execute() {
for (input, message) in [(1337.0, "alpha-hook"), (1338.0, "beta-hook")] {
let panic = std::panic::catch_unwind(|| horizon_core::jmath::atan(input)).unwrap_err();
assert_eq!(panic.downcast_ref::<&str>().copied(), Some(message));
}
}
#[test]
fn ordinary_atan_result_is_preserved() {
assert_eq!(horizon_core::jmath::atan(0.5).to_bits(), libm::atan(0.5).to_bits());
}
''')
run(["cargo", "build", "--locked", "-p", "horizon-mixins-cli"], cwd=ROOT)
binary = ROOT / "target/debug/horizon-mixins"
mods = ROOT / "fixtures/horizon-mods"
stage = out / "stage"
target = out / "cargo-target"
baseline_start = time.monotonic()
# Run upstream tests before adding transformed hooks. The exported probe intentionally
# expects transformed behavior, so baseline runs only the upstream library suite.
baseline = run(["cargo", "test", "--locked", "--offline", "--manifest-path", str(original / "Cargo.toml"),
"--lib", "--target-dir", str(target)], log_path=out / "baseline.cargo.log")
measurements = {"baseline_seconds": round(time.monotonic() - baseline_start, 4),
"baseline_cargo_compile_seconds": compilation_seconds((out / "baseline.cargo.log").read_text())}
for label in ["first_modded", "no_change"]:
report = json.loads(run([str(binary), "check", "--workspace", str(original),
"--mods", str(mods), "--out", str(stage),
"--target-dir", str(target), "--trust-native-mods"]))
measurements[label] = {key: report[key] for key in ["stage_changed", "transformation_seconds", "cargo_seconds"]}
measurements[label]["cargo_compile_seconds"] = compilation_seconds(report["cargo_stderr"])
(out / f"{label}.json").write_text(json.dumps(report, indent=2) + "\n")
result = {"revision": PIN, "scope": "isolated complete horizon-core crate, not full game/launcher",
"measurements": measurements, "stage": str(stage)}
(out / "baseline.txt").write_text(baseline)
(out / "report.json").write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()