Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 205 additions & 2 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
name: Mutation Testing

# All mutation-testing CI lives here. Three concerns, one file:
# * cargo-mutants — generic structural operators (diff-scoped on PRs, sharded
# full-crate run nightly)
# * spec-gate — universalmutator + comby custom operators for SALT's trie
# arithmetic / canonicalization / endianness invariants
# (diff-scoped blocking gate on PRs, per-pack sweep nightly)
# * suppression hygiene — flag suppressions that no longer match a live mutant
# All three share the scorer scripts/mutation_gate.py. See mutants/operators/.

on:
pull_request:
workflow_dispatch:
Expand Down Expand Up @@ -126,6 +135,176 @@ jobs:
mutation-report.md
if-no-files-found: ignore

# ── spec-gate: universalmutator + comby custom operators, diff-scoped on PRs ─
# The domain-specific complement to cargo-mutants: comby structural operators
# for trie arithmetic, canonicalization, and endianness (mutants/operators/).
# Only files+lines the PR changed are mutated, so most PRs generate zero
# spec-gate mutants and this is near-instant. Shares the scorer with
# cargo-mutants (scripts/mutation_gate.py) via the umutate.py results contract.
spec-gate:
name: spec-gate
if: github.event_name == 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 60
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need base history for the diff
persist-credentials: false

- name: Install Rust
# Lint-tolerant mutant analysis: a valid mutant can trip a warning (e.g.
# deleting a sort leaves its `let mut` binding unused), and under the
# action's default RUSTFLAGS="-D warnings" that testable mutant would
# fail `cargo check` and be miscounted unviable instead of exercised.
# Empty rustflags keeps warnings non-fatal; genuine type/scope errors
# still fail, so real unviable mutants are still caught. (flyq, PR #143)
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ""

- name: Install cargo-nextest
uses: taiki-e/install-action@nextest

- name: Install universalmutator
# Pin the version: a new release can change generated mutants and shift
# the gate baseline.
run: pipx install universalmutator==1.14.1

- name: Install comby
# Pin the version (reproducible mutant generation). The prebuilt binary
# links against libev/libpcre, which are not on the runner by default.
run: |
sudo apt-get update
sudo apt-get install -y libpcre3 libev4
sudo curl -fsSL https://github.com/comby-tools/comby/releases/download/1.8.1/comby-1.8.1-x86_64-linux -o /usr/local/bin/comby
sudo chmod +x /usr/local/bin/comby
comby -version

- name: Run diff-scoped spec-gate mutation
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
run: |
python3 scripts/umutate.py run \
--diff "origin/${BASE_REF}" \
--output target/umutate

- name: Score and gate
run: |
python3 scripts/mutation_gate.py report \
--results target/umutate \
--suppressions mutants/suppressions.toml \
--comment spec-gate-report.md \
--summary "$GITHUB_STEP_SUMMARY"

- name: Upsert PR comment
if: always() && github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
const fs = require("fs");
let body;
try {
body = fs.readFileSync("spec-gate-report.md", "utf8");
} catch {
body = "## Spec-gate mutation\n\nNo report produced.";
}
const marker = "<!-- salt-spec-gate-mutation-report -->";
body = `${marker}\n${body}`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}

- name: Upload spec-gate artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: salt-spec-gate-results
path: |
target/umutate
spec-gate-report.md
if-no-files-found: ignore

# ── spec-gate: nightly full sweep, sharded by pack (informational) ──────────
# Each pack stays bounded (analyze is sequential). Surfaces survivors but does
# not fail the workflow — triage into suppressions or killing tests.
spec-gate-sweep:
name: spec-gate sweep (${{ matrix.pack }})
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 330
strategy:
fail-fast: false
matrix:
pack: [trie, canon, endian]
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- name: Install Rust
# Lint-tolerant mutant analysis; see the spec-gate job. (flyq, PR #143)
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
rustflags: ""

- name: Install cargo-nextest
uses: taiki-e/install-action@nextest

- name: Install universalmutator
run: pipx install universalmutator==1.14.1

- name: Install comby
run: |
sudo apt-get update
sudo apt-get install -y libpcre3 libev4
sudo curl -fsSL https://github.com/comby-tools/comby/releases/download/1.8.1/comby-1.8.1-x86_64-linux -o /usr/local/bin/comby
sudo chmod +x /usr/local/bin/comby
comby -version

- name: Run spec-gate mutation (${{ matrix.pack }})
run: |
python3 scripts/umutate.py run --packs ${{ matrix.pack }} \
--output "target/umutate/${{ matrix.pack }}"

- name: Score (informational)
run: |
python3 scripts/mutation_gate.py report \
--results "target/umutate/${{ matrix.pack }}" \
--suppressions mutants/suppressions.toml \
--summary "$GITHUB_STEP_SUMMARY" || true

- name: Upload spec-gate sweep artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: salt-spec-gate-sweep-${{ matrix.pack }}
path: target/umutate/${{ matrix.pack }}
if-no-files-found: ignore

# Full-crate runs are far too large for one hosted runner within the job
# timeout, so scheduled/manual runs shard the mutant universe. Keep the
# shard denominator in sync with the matrix size.
Expand Down Expand Up @@ -196,8 +375,10 @@ jobs:
filters: |
hygiene:
- 'mutants/suppressions.toml'
- 'mutants/operators/**'
- 'scripts/mutation_*.py'
- 'scripts/mutation_*.sh'
- 'scripts/umutate*'
- '.cargo/mutants.toml'
- 'salt/src/**'

Expand All @@ -222,8 +403,30 @@ jobs:
with:
tool: cargo-mutants@27.1.0

- name: Build full mutant universe
run: cargo mutants --list --package salt --colors never > universe.txt
- name: Install universalmutator
run: pipx install universalmutator==1.14.1

- name: Install comby
run: |
sudo apt-get update
sudo apt-get install -y libpcre3 libev4
sudo curl -fsSL https://github.com/comby-tools/comby/releases/download/1.8.1/comby-1.8.1-x86_64-linux -o /usr/local/bin/comby
sudo chmod +x /usr/local/bin/comby
comby -version

- name: Build the full mutant universe (both engines)
run: |
# pipefail so a failing `umutate.py plan` fails the step instead of
# grep swallowing it and the orphan check running on a partial universe.
set -o pipefail
# --package salt: match the driver scope so a stale suppression can't
# be kept alive by a same-named mutant in another crate.
# --colors never: CI forces color, which would embed ANSI codes and
# break exact suppression matching.
cargo mutants --list --package salt --colors never > universe.txt
python3 scripts/umutate.py plan \
| grep -oE 'salt/src/[^:]+:[0-9]+:[0-9]+: .*' >> universe.txt
Comment thread
vincent-k2026 marked this conversation as resolved.
echo "universe: $(wc -l < universe.txt) mutants"

- name: Check for orphan suppressions
run: |
Expand Down
115 changes: 115 additions & 0 deletions mutants/operators/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Domain-specific mutation operators

Custom mutation-operator packs run through
[universalmutator](https://github.com/agroce/universalmutator) +
[comby](https://comby.dev), complementing the `cargo-mutants` gate (issue
#141). `cargo-mutants` ships a fixed set of structural operators and has no
plugin API, so it never generates the mutations that matter most for a
state-commitment system. Each pack targets a specific soundness or determinism
invariant. The selection filter for adding rules: *"what soundness or
determinism invariant would a surviving mutant here violate?"* Generic
arithmetic/relational/logical operators are deliberately excluded — the
`cargo-mutants` gate already owns those.

All packs are driven by `scripts/umutate.py` and scored by the **same** gate as
`cargo-mutants` (`scripts/mutation_gate.py`), via the
caught/missed/timeout/unviable results contract. Both engines live in one CI
workflow, `.github/workflows/mutation.yml`.

## Layout

Each pack is a directory `mutants/operators/<name>/` with:

- `manifest.toml` — pack config: `name`, `description`, `targets` (source
globs), optional `match` (a regex file-content filter), the `rules` filename,
the `test_cmd` whose non-zero exit means a mutant was killed, and an optional
`build_cmd` (a `cargo check`) — a mutant that fails it is classified
*unviable* rather than counted as caught (a constant-swap operator can
reference a symbol not imported in every file).
- `<name>.rules` — the operator rules. `PATTERN ==> REPLACEMENT` per line, `#`
for comments; the pattern is a
[comby template](https://comby.dev/docs/syntax-reference).

| Pack | Scope | Invariant guarded |
| --- | --- | --- |
| `trie` | `trie/node_utils.rs`, `trie/trie.rs`, `constant.rs`, `types.rs`, `proof/shape.rs` | Level-base and bit-width arithmetic of the two-tier trie (`STARTING_NODE_ID`, 8/24/40 splits, 256-way fanout, ceil rounding) |
| `canon` | `proof/prover.rs`, `proof/salt_witness.rs` | Canonical form: sort/dedup on the proof path must not be removable or reversible |
| `endian` | `state/hasher.rs`, `types.rs` | Byte-order determinism of the consensus hash and 12-byte metadata codec (see issue #127) |

## Why comby

comby matches **structurally**, not textually, which removes a class of
regex-fragility bugs:

- A method/path-call template (`:[recv].to_le_bytes()`,
`:[ty]::from_le_bytes(:[args])`) can only match a real call — never a
substring of an identifier such as `serialize_to_le_bytes`.
- A single-word hole `:[[recv]]` matches one identifier, so a comparator or
receiver hole cannot swallow a surrounding compound expression.

The one case comby does **not** anchor for free is a bare constant that is a
prefix of a longer one (`TRIE_WIDTH` inside `TRIE_WIDTH_BITS`): a literal
template is not word-anchored. Pin a boundary with a trailing negated-class
hole, reproduced in the replacement:

```
% TRIE_WIDTH:[b~[^A-Za-z0-9_]] ==> % (TRIE_WIDTH - 1):[b]
```

## Running

```bash
scripts/umutate_setup.sh # installs universalmutator + comby

# Show what WOULD be mutated (no tests run, tree untouched):
python3 scripts/umutate.py plan

# Full pipeline for one pack -> gate-ready results dir:
python3 scripts/umutate.py run --packs trie --output target/umutate
python3 scripts/mutation_gate.py report --results target/umutate \
--suppressions mutants/suppressions.toml

# Diff-scoped (the PR-gate scope: only files+lines changed vs a base):
python3 scripts/umutate.py run --diff origin/main --output target/umutate
```

Mutants inside `#[cfg(test)]` code are pruned automatically (mutating test
assertions is meaningless), as are — in `--diff` mode — mutants outside the
changed lines.

## CI

`.github/workflows/mutation.yml` runs three concerns off one file:

- **`spec-gate`** — diff-scoped, blocking, on every PR. Most PRs change no
operator site, so it generates zero mutants and is near-instant. An
unsuppressed survivor fails the gate and is upserted as a PR comment.
- **`spec-gate-sweep`** — nightly full sweep, sharded per pack, informational
(surfaces survivors to triage; does not fail the workflow).
- **`orphan-suppressions`** — folds the umutate mutant universe into the
`cargo-mutants` universe so a suppression that no longer matches any live
mutant (either engine) is flagged.

## Triage policy

Every **survivor** is either:

1. a missing test — add a killing test (preferred), or
2. an equivalent mutant — record it in `mutants/suppressions.toml` with a
justification. Use a `kind = "line"` entry whose `mutant` is the survivor's
description (from `missed.txt`/`timeout.txt`) with the leading
`file:line:col:` locator stripped: matching is scoped to `file` but
deliberately drops the line number, so the suppression keeps matching when
unrelated edits shift the code. The trade-off is that two mutants with
identical text in one file cannot be pinned apart — rely on a killing test
there instead. To exclude a whole equivalent-by-construction function, use a
`kind = "function"` entry with a `pattern` regex.

**Unviable** (non-compiling) mutants are fine in moderation; if a rule mostly
produces unviable mutants, tighten its template.

## Known gaps

- Families C (field-element chunk widths), D (serialization codec config) and F
(hash diffusion ops) from issue #141 are staged for a follow-up after the
first survivor triage of A/B/E.
24 changes: 24 additions & 0 deletions mutants/operators/canon/canon.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Family E - canonicalization removal (issue #141), comby structural rules.
#
# The proof path sorts and dedups to reach canonical form. Deleting those calls
# (or flipping comparator direction) is a soundness mutation: duplicates or
# unordered elements should be rejected or change the root. Statement deletion
# is not in cargo-mutants' repertoire.
#
# `:[[word]]` matches a single identifier (comby's whitespace-delimited hole), so
# a receiver / comparator operand can't swallow a surrounding compound
# expression the way a bare `:[hole]` would. `:[args]` spans the (possibly
# multi-line) closure by balanced delimiters.
# comby rule format: PATTERN ==> REPLACEMENT ('#' lines are comments).

# Delete single sort/dedup statements.
:[[recv]].sort(); ==>
:[[recv]].sort_unstable(); ==>
:[[recv]].sort_unstable_by(:[args]); ==>
:[[recv]].sort_unstable_by_key(:[args]); ==>
:[[recv]].dedup(); ==>
:[[recv]].dedup_by(:[args]); ==>
sort_unstable!(:[[m]]); ==>

# Flip comparator direction inside sort closures.
:[[x]].cmp(:[[y]]) ==> :[[y]].cmp(:[[x]])
20 changes: 20 additions & 0 deletions mutants/operators/canon/manifest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Operator pack: canon (Family E, issue #141)
#
# Run through universalmutator + comby, scored by the shared gate
# (scripts/mutation_gate.py). See mutants/operators/README.md.

name = "canon"
description = "Canonicalization removal: delete sort/dedup calls and flip comparator direction on the proof path"

targets = [
"salt/src/proof/prover.rs",
"salt/src/proof/salt_witness.rs",
]
Comment on lines +9 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include subtrie canonicalization in the canon pack

When a PR changes create_sub_trie's bucket canonicalization, this pack won't run because salt/src/proof/subtrie.rs is not in the target list. I searched the proof path for sort/dedup sites and found the production bucket_ids.dedup() at salt/src/proof/subtrie.rs:227; the existing :[[recv]].dedup(); ==> rule would cover it if this file were targeted. Add proof/subtrie.rs so deleting that dedup cannot pass spec-gate with zero canon mutants.

Useful? React with 👍 / 👎.


# Compile check: a mutant that fails this is unviable (e.g. a constant
# swapped for one not imported in that file), classified separately rather
# than miscounted as caught.
build_cmd = "cargo check -p salt --no-default-features --features parallel"

rules = "canon.rules"
test_cmd = "cargo nextest run -p salt --no-default-features --features parallel"
Loading
Loading