Files
trailofbits__skills/ruff.toml
T
Graham Sutherland 04b241176f Rework property-based-testing skill and add eval suites. (#235)
* Rework property-based-testing skill and add eval suites.

* Fix shellcheck SC2015 and exclude eval fixtures from CI pytest

* Run eval self-tests under uv so `make check` survives the python3 shim

`eval-self-tests` discovers harnesses repo-wide, and all three it finds today
pipe a script to `python3`: property-based-testing's run.sh and
effectiveness.sh, and writing-lean-proofs' run.sh. On any machine with the
modern-python plugin installed, its `python3` shim rejects those calls and
`make check` fails for reasons that have nothing to do with the code under
test.

That is the trap #207 documents, and the reason `shell-suites` is deliberately
kept out of `check`. Excluding a second target is the wrong answer here — these
self-tests are free and they are what makes an eval result trustworthy — so run
them under `uv run --no-project`, which puts a real interpreter ahead of the
shim on PATH.

Harnesses should still call uv themselves. The wrapper only applies inside
`make check`, and the real sweeps are invoked by hand.

* Move evals-extra out of the skill directory to the plugin root

`evals/` already sits at the plugin root; `evals-extra/` sat inside
`skills/property-based-testing/`, so every user of the plugin shipped 900 lines
of bash, a `requirements.txt` naming hypothesis, and a fixture whose tests are
broken on purpose — inside the one directory the model reads guidance from.
run.sh's own header notes the hazard of the model finding skill files by
filesystem exploration; this removes the material it would find.

Nothing in the machinery cares which of the two locations it is in: the
Makefile, the CI pytest exclusion, ruff's per-file ignores and the plugin
validator all match on an `evals*` prefix anywhere under `plugins/`. Verified
by running eval-self-tests and validate from the new layout.

`plugin_root` in both harnesses walks up one directory now instead of three.

* Re-execute run.sh by path in its own self-test instead of $0

The four end-to-end assertions run the whole sweep in a subprocess by invoking
`"$0"`. That is only a runnable command when the caller passed a path with a
slash in it: `bash run.sh --self-test` from this directory sets `$0` to
`run.sh`, which is not on PATH, so all four exited 127 while the eleven unit
assertions passed. It worked by accident because the Makefile and the README
both happen to pass a path.

Use `$here/run.sh`, which is invocation-independent.

* Report a detector that could not run as a failure, not as a non-trigger

`skill_invoked` returned an exit status, and both "the model did not call the
skill" and "python3 blew up" came back as 1. `check_triggered` then fell
through its ladder to `no` — the one verdict the aggregator treats as a
measurement. So a broken interpreter did not fail the sweep; it scored every
positive session as a clean negative, and 45 sessions and $36 came back looking
like a recall regression.

This is not hypothetical. The modern-python plugin's `python3` shim rejects the
call (#207), which is how it was found: the self-test's "skill invoked -> yes"
case returned `no`.

The verdict is now a printed token — `yes`, `no`, or `error:<detail>` — because
an exit status cannot carry the distinction: 1 is both python3's own failure
status and the detector's "not found". A healthy session whose detector failed
lands in a new `crash:detector` branch, which invalidates the sweep like any
other failed session and puts the interpreter's message in the NOTE column.

Every python3 call in the script goes through `uv run --no-project` for the
same reason, so the harness also runs correctly by hand under the shim rather
than only under `make check`. That adds a uv dependency, guarded at startup
alongside the existing claude CLI check.

Pinned by a new assertion that points the detector at a nonexistent interpreter
and asserts `crash:detector`, not `no`. 16 assertions, was 15.

* Refuse to grade an effectiveness run whose patch never applied

`grade()` diffs the failing tests before and after replacing canonicalize_url
with an identity stub. It called `patch_codec` and never checked the result, on
the assumption that `set -e` would abort — but errexit does not propagate out of
a function into the command substitution `got="$(grade "$d")"` runs in. So a
failed patch left the "after" suite running against the UNPATCHED fixture:
before and after come out identical, nothing moves, and a suite that genuinely
caught the defect is written down as `part` — "suite fails, but not on this
defect".

That also silently disarmed the drift guard inside patch_codec, whose entire
job is to refuse to grade in exactly this situation. Its message went to stderr
and the grade continued.

A failed patch is now ERR, and the fixture is restored from the backup on that
path. patch_codec goes through `uv run --no-project` like the rest of the repo,
so the shim (#207) is not what triggers it either.

Pinned by a new assertion that hands the grader a fixture with no
canonicalize_url to replace and asserts ERR rather than a grade. 4 assertions,
was 3.

* Refuse a multi-level effort sweep while SKILL.md pins `effort:`

A skill's `effort:` frontmatter overrides the session level, so the `--effort`
that effectiveness.sh passes each session is ignored the moment the skill loads.
SKILL.md pins `effort: low`, so the default `EFFORTS="low medium high"` ran three
sessions at `low` and printed the level each one *asked* for in the EFFORT
column.

Nothing in the output gives that away. Three rows agreeing is also what a healthy
sweep looks like when effort genuinely does not matter, which is the conclusion
the table invites — and the conclusion that keeps the pin at `low` forever. It is
the same shape as the failures this suite already guards against: a checker that
has quietly stopped varying its independent variable reports a clean result.

It also means the recorded sweep cannot be reproduced against the plugin as
shipped. Either that sweep predates the pin, or it was already this artefact;
there is no third reading. That matters because re-running an effort sweep is
what AGENTS.md asks for whenever the model changes, and this is the check that
would have been re-run.

Requesting the pinned level alone is still allowed — that scores the shipped
configuration and the label is true. NOPLUGIN loads no skill, so nothing
overrides and a sweep there is honest. Anything else with a pin present exits 2
and names both ways forward.

The `q` in the sed matters: a second `effort:` line anywhere in the file, a
fenced YAML example say, would otherwise make `$pinned` multi-line and refuse
even a correct `EFFORTS=low`.

Both READMEs documented a bare `./evals-extra/effectiveness.sh`, which now exits
2, so they move to `EFFORTS=low` here rather than in a follow-up that would leave
the docs describing a failing command in between.

Known cost, not fixed here: `EFFORTS=low` is one session, where the broken sweep
at least sampled the same configuration three times. run.sh:20-22 rejects n=1 for
the sibling metric on the grounds that invocation is stochastic. Fixing it means
a repetition knob or a different default — a change to how the eval samples
rather than to what it reports, so it is left to the author.

Pinned by two new assertions: a pinned skill refuses `low medium high`, and
allows `low`. 6 assertions, was 4.

* Check for the claude CLI below run.sh's --self-test dispatch, not above it

The preflight sat at the top of the script, so it ran before the `--self-test`
branch and the self-test exited 2 on any machine without Claude Code installed,
having run zero of its sixteen assertions:

    $ env PATH=/usr/bin:/bin bash run.sh --self-test
    claude CLI not found: claude

`claude_bin` is only swapped for the stub inside `self_test()` itself, which is
far too late to matter. So the guarantee in the comment above that function —
"uses a stub binary, so it costs nothing and can run in CI" — was false, and
`make eval-self-tests`, and therefore `make check`, broke for any contributor
without the CLI. AGENTS.md draws exactly this line for the two loadability
checks: they run in CI rather than in `make check` precisely because needing the
Claude Code CLI is not a reasonable local prerequisite. A self-test that claims
to be free must not smuggle that requirement back in.

The uv check stays above the dispatch, because the self-test genuinely needs it:
the detectors run through `uv run --no-project python3`, and uv is already a
prerequisite everywhere else in the repo. effectiveness.sh had this split right
and was the template.

Verified with `claude` absent from PATH and uv plus GNU coreutils present: all
17 assertions pass. With neither present it now stops on uv, which is the honest
dependency rather than a borrowed one.

Worth knowing and not fixed here: `timeout(1)` is still an undeclared
dependency of both a real sweep and the self-test, and it does not exist on a
stock macOS PATH. Absent, the child-sweep assertions fail with 127. CI is Linux
so it is covered there, and the bash-3.2 accommodation at the end of the
self-test suggests stock macOS is meant to work, so it wants either a preflight
alongside uv or a documented prerequisite.

Pinned by a new assertion that runs a real sweep with a nonexistent CLAUDE_BIN
and asserts exit 2. The risk on the next edit is the check being deleted rather
than moved, which would turn a typo'd CLAUDE_BIN into 45 crash:rc127 sessions
instead of an immediate refusal. 17 assertions, was 16.

* Fix sed issue on BSD

* Restore refactoring.md

---------

Co-authored-by: kz-tob <kara.zaffarano@trailofbits.com>
Co-authored-by: Emilio López <emilio.lopez@trailofbits.com>
2026-08-17 08:34:24 -04:00

18 lines
766 B
TOML

line-length = 100
target-version = "py311"
[lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
[lint.per-file-ignores]
"**/tests/**/*.py" = ["F401", "SIM118"]
"**/test_samples/**/*.py" = ["F", "E", "B"]
# Eval fixtures are deliberately defective — the non-idempotent canonicalizer and
# the assertion-free @given test are what the evals measure. Linting them clean
# would delete the signal. The glob is evals*, so it covers evals-extra (harnesses
# invoked by hand); with a bare `evals` ruff flags the fixture and fails the build.
"**/evals*/fixture/**/*.py" = ["F", "E", "B", "SIM", "I"]
# ct_analyzer has long opcode tables and string constants
"**/ct_analyzer/analyzer.py" = ["E501"]
"**/ct_analyzer/script_analyzers.py" = ["E501", "SIM102", "SIM108", "F841"]