diff --git a/CLAUDE.md b/CLAUDE.md index c134ce5a..dd3ce094 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,11 +28,11 @@ means adding it to `LibCopyArtifacts.contracts()` AND `crates/bindings/src/lib.rs` together, or that assertion fails. `foundry.toml`'s `ffi = true` and its filesystem permissions exist for -`CopyArtifacts` and for `test/subgraph/SubgraphManifest.t.sol`, which reads the -built interface artifact with `jq` and the subgraph manifest with `yq` — both -over `vm.ffi`, so the manifest needs no `fs_permissions` entry, and both -binaries have to be on `PATH` in the sol shell. Nothing else here shells out or -touches the filesystem. +`CopyArtifacts` and for `test/subgraph/`, which reads the built interface +artifact with `jq`, the subgraph manifest and compose file with `yq`, and the +git index with `git` — all over `vm.ffi`, so none of them needs an +`fs_permissions` entry, and all three binaries have to be on `PATH` in the sol +shell. Nothing else here shells out or touches the filesystem. ## The subgraph is SOURCE only (#149) diff --git a/foundry.toml b/foundry.toml index ec266cb5..88f6313a 100644 --- a/foundry.toml +++ b/foundry.toml @@ -12,10 +12,12 @@ bytecode_hash = "none" cbor_metadata = false # `script/CopyArtifacts.sol` and its freshness test shell out to `jq` via -# `vm.ffi` to extract the deterministic subset of the forge artifact, and +# `vm.ffi` to extract the deterministic subset of the forge artifact, # `test/subgraph/SubgraphManifest.t.sol` shells out to `jq` for that artifact's # event signatures and to `yq` to read `subgraph/subgraph.yaml` as parsed YAML -# rather than as text. Both are on `PATH` in the rainix sol shell. +# rather than as text, and `test/subgraph/SubgraphMatchstick.t.sol` shells out +# to `git` for what the index tracks and to `yq` for the compose file. All +# three are on `PATH` in the rainix sol shell. ffi = true libs = ["dependencies"] @@ -23,8 +25,8 @@ libs = ["dependencies"] # The filesystem access in this repo. `script/CopyArtifacts.sol` reads forge # build output and writes the stable subset for the rust bindings to consume; # `test/script/CopyArtifacts.t.sol` reads both back to assert the committed copy -# is fresh. `test/subgraph/SubgraphManifest.t.sol` needs no entry: it reads the -# manifest through `yq` over `vm.ffi`, which this list does not gate. +# is fresh. The `test/subgraph/` tests need no entry: they read through `yq` and +# `git` over `vm.ffi`, which this list does not gate. fs_permissions = [ { access = "read", path = "out/" }, { access = "read-write", path = "crates/bindings/abi/" }, diff --git a/subgraph/.gitignore b/subgraph/.gitignore index bb4f6494..1ba821ec 100644 --- a/subgraph/.gitignore +++ b/subgraph/.gitignore @@ -2,4 +2,5 @@ build cache generated node_modules +tests/.bin typechain \ No newline at end of file diff --git a/subgraph/docker-compose.yml b/subgraph/docker-compose.yml index 463438da..19aad1b9 100644 --- a/subgraph/docker-compose.yml +++ b/subgraph/docker-compose.yml @@ -2,5 +2,10 @@ version: '3' services: matchstick: image: rainprotocol/matchstick:main + # Matchstick reuses `tests/.bin/*.wasm` unless an mtime says a source is + # newer, and an mtime is not a fact about content, so without `-r` the run + # can execute a cached binary instead of the sources in the tree. + environment: + ARGS: -r volumes: - ..:/matchstick diff --git a/subgraph/tests/.bin/metaboard.wasm b/subgraph/tests/.bin/metaboard.wasm deleted file mode 100644 index 1d083ef5..00000000 Binary files a/subgraph/tests/.bin/metaboard.wasm and /dev/null differ diff --git a/test/subgraph/SubgraphMatchstick.t.sol b/test/subgraph/SubgraphMatchstick.t.sol new file mode 100644 index 00000000..a4f7dec5 --- /dev/null +++ b/test/subgraph/SubgraphMatchstick.t.sol @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.2/src/Test.sol"; + +/// @title SubgraphMatchstickTest +/// @notice The matchstick run held to compiling the sources that are in the +/// tree, rather than reusing a binary of sources that were. +/// +/// Matchstick 0.6.0 compiles each suite to `tests/.bin/.wasm` and then +/// reuses that file: `Compiler::execute` compiles only when `--recompile` is +/// passed, when the wasm is absent, or when `is_source_modified` finds the test +/// file or a transitively imported file carrying an mtime strictly NEWER than +/// the wasm. An mtime is not a fact about content. Archive extraction that +/// preserves timestamps, a coarse timestamp granularity, or any ordering that +/// lands the wasm last all leave a cached binary that matchstick executes in +/// place of the mappings actually in the tree — a suite reporting passes for +/// code nothing ran (rainlanguage/rain.metadata#230). +/// +/// That is not hypothetical here. `subgraph/tests/.bin/metaboard.wasm` was +/// tracked in git, last written in 2024, and still exports +/// `generated/.../MetaBoard#hash` — an entry point deleted from the sources, +/// along with its mocks, in 2026. The binary a fresh checkout landed was a +/// build of tests that no longer exist. +/// +/// Two independent things have to hold for the cached path to be unreachable, +/// and neither implies the other, so both are checked. There is nothing to +/// reuse: no compiled binary is in git, and the directory they land in is +/// ignored so a local run cannot put one back. And nothing is reused anyway: +/// the docker run passes `--recompile`, so the decision never consults an +/// mtime at all. +/// +/// Solidity in the `rainix-sol` lane rather than a check inside the matchstick +/// suite, deliberately: the question is whether the matchstick lane executes +/// the current sources at all, and a check that ships inside the cached binary +/// is answered by the stale build along with everything else. It needs no +/// docker, so it runs on every push. +contract SubgraphMatchstickTest is Test { + /// The compose file `rainix`'s `subgraph-test` task brings up. That task is + /// `npm ci && docker compose up --abort-on-container-exit` from + /// `subgraph/`, so this file is the whole of how matchstick is invoked in + /// CI. + string constant DOCKER_COMPOSE_YML = "subgraph/docker-compose.yml"; + + /// JSON path of the environment variable the matchstick image expands into + /// its own command line. The image's `CMD` is `/binary-linux-22 ${ARGS}` + /// with `ENV ARGS=` empty, so `ARGS` is the only place a flag can be put + /// without overriding the command outright. + string constant MATCHSTICK_ARGS = ".services.matchstick.environment.ARGS"; + + /// The flag that makes compilation unconditional. `-r` is matchstick's + /// short spelling of `--recompile`; pinned as an exact value rather than + /// searched for, so respelling it or appending to it is a change that has + /// to be made here too. + string constant RECOMPILE_FLAG = "-r"; + + /// The directory matchstick writes compiled suites into, from the repo + /// root. `.bin` under the tests folder is matchstick's own layout and + /// `subgraph/` carries no `matchstick.yaml`, so the default tests folder + /// `tests` applies and this is where the wasm lands. + string constant MATCHSTICK_BIN = "subgraph/tests/.bin"; + + /// A file under `subgraph/tests/` that IS tracked. + /// + /// Named so that the tracked-file check has a non-empty expected result. An + /// empty `git ls-files` is what "no binary is tracked" looks like, and it + /// is also what a `git` that failed to run looks like, so asserting against + /// emptiness would pass in the one case this test exists to catch itself + /// being broken by. Asking for this path in the same query makes the + /// expected output a value the command has to produce. + string constant TRACKED_TEST_SOURCE = "subgraph/tests/metaBoard.test.ts"; + + /// No compiled matchstick binary is in git, and none can be added back. + /// + /// Tracked and ignored are separate claims. Deleting the wasm without the + /// ignore rule leaves every local `subgraph-test` run rewriting an + /// untracked file that the next `git add -A` commits again. The ignore rule + /// without the deletion leaves the stale binary exactly where it was, since + /// ignore rules do not apply to tracked paths. + function testMatchstickBinariesAreNotInGit() external { + string[] memory cmd = new string[](5); + cmd[0] = "git"; + cmd[1] = "ls-files"; + cmd[2] = "--"; + cmd[3] = MATCHSTICK_BIN; + cmd[4] = TRACKED_TEST_SOURCE; + assertEq( + string(vm.ffi(cmd)), + TRACKED_TEST_SOURCE, + string.concat( + "git tracks a compiled matchstick binary under ", + MATCHSTICK_BIN, + ", or no longer tracks ", + TRACKED_TEST_SOURCE + ) + ); + + cmd = new string[](4); + cmd[0] = "git"; + cmd[1] = "check-ignore"; + cmd[2] = "--no-index"; + cmd[3] = MATCHSTICK_BIN; + assertEq( + string(vm.ffi(cmd)), + MATCHSTICK_BIN, + string.concat(MATCHSTICK_BIN, " is not gitignored; a local matchstick run leaves its binary stageable") + ); + } + + /// The docker matchstick run recompiles unconditionally. + /// + /// Read as parsed YAML rather than as text for the reason + /// `SubgraphManifest.t.sol` gives: a commented out `# ARGS: -r` satisfies a + /// text search while the compose parser never sees it, which is exactly the + /// way this protection would be lost. + function testMatchstickRunForcesRecompilation() external { + string[] memory cmd = new string[](4); + cmd[0] = "yq"; + cmd[1] = "-o=json"; + cmd[2] = "."; + cmd[3] = DOCKER_COMPOSE_YML; + string memory json = string(vm.ffi(cmd)); + + assertTrue( + vm.keyExistsJson(json, MATCHSTICK_ARGS), + string.concat(DOCKER_COMPOSE_YML, " sets no ", MATCHSTICK_ARGS, "; matchstick then falls back to mtimes") + ); + assertEq( + vm.parseJsonString(json, MATCHSTICK_ARGS), + RECOMPILE_FLAG, + string.concat( + DOCKER_COMPOSE_YML, " does not pass ", RECOMPILE_FLAG, " to matchstick; it then falls back to mtimes" + ) + ); + } +}