Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
190 changes: 188 additions & 2 deletions .github/workflows/mutation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,171 @@ 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
uses: actions-rust-lang/setup-rust-toolchain@v1

- 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: pip install universalmutator==1.14.1
Comment thread
vincent-k2026 marked this conversation as resolved.
Outdated

- 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: Warm test binaries
run: cargo nextest run -p salt --no-default-features --features parallel --no-run

- 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
uses: actions-rust-lang/setup-rust-toolchain@v1

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

- name: Install universalmutator
run: pip 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: Warm test binaries
run: cargo nextest run -p salt --no-default-features --features parallel --no-run

- 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 +361,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 +389,27 @@ 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: pip 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: |
# --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
108 changes: 108 additions & 0 deletions mutants/operators/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# 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`, `mode` (`comby` or
`regex`), `targets` (source globs), optional `match` (a regex file-content
filter), the `rules` filename, and the `test_cmd` whose non-zero exit means a
mutant was killed.
- `<name>.rules` — the operator rules. `PATTERN ==> REPLACEMENT` per line, `#`
for comments. In `comby` mode the pattern is a
[comby template](https://comby.dev/docs/syntax-reference); in `regex` mode a
Python regex.

| 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/ahash/convert.rs`, `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 (prefer a `line = <n>` pin; note that same-line twins cannot
Comment thread
vincent-k2026 marked this conversation as resolved.
Outdated
be pinned apart — rely on a killing test there instead).

**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]])
17 changes: 17 additions & 0 deletions mutants/operators/canon/manifest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# 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"

mode = "comby"

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 👍 / 👎.


rules = "canon.rules"
test_cmd = "cargo nextest run -p salt --no-default-features --features parallel"
12 changes: 12 additions & 0 deletions mutants/operators/endian/endian.rules
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Family B - endianness / byte-order (issue #141), comby structural rules.
#
# Flipping endianness silently breaks determinism and cross-platform
# reproducibility (issue #127); no generic operator flips it. Structural
# method-/path-call matching means these can only hit a real `to/from_*_bytes`
# call, never a substring of an identifier like `serialize_to_le_bytes`.
# comby rule format: PATTERN ==> REPLACEMENT.

:[recv].to_le_bytes() ==> :[recv].to_be_bytes()
:[recv].to_be_bytes() ==> :[recv].to_le_bytes()
:[ty]::from_le_bytes(:[args]) ==> :[ty]::from_be_bytes(:[args])
:[ty]::from_be_bytes(:[args]) ==> :[ty]::from_le_bytes(:[args])
Comment thread
vincent-k2026 marked this conversation as resolved.
20 changes: 20 additions & 0 deletions mutants/operators/endian/manifest.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Operator pack: endian (Family B, issue #141)
#
# Run through universalmutator + comby, scored by the shared gate
# (scripts/mutation_gate.py). See mutants/operators/README.md.

name = "endian"
description = "Byte-order flips on explicit to/from_*_bytes packing (determinism / cross-platform reproducibility)"

# Mutation engine mode: "comby" (structural) or "regex".
mode = "comby"

# Candidate source files (matched from the repo root).
targets = [
"salt/src/state/ahash/convert.rs",
"salt/src/state/hasher.rs",
"salt/src/types.rs",
]

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