Claude Skill

property-based-testing

Writes, reviews, and debugs property-based tests — Hypothesis, fast-check, proptest, jqwik, rapid, and Echidna or Medusa for Solidity invariants. Use whenever tests should cover a whole input domain instead of a hand-picked list of examples: encode/decode and serialize/deserializ

LLM Mart · 0 points · 10 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download trailofbits-skills-plugins_property-based-testing_skills_property-based-testing-123037e.zip · 19 KB
trailofbits/skills 7234 616 forks CC-BY-SA-4.0 Updated 13h ago
Part of trailofbits/skills — 100 skills

Install

skills CLI npx skills add https://github.com/trailofbits/skills/tree/main/plugins/property-based-testing/skills/property-based-testing
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install trailofbits-skills@llmmart
Git git clone https://github.com/trailofbits/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole trailofbits/skills collection as a plugin from our marketplace. Git is the plain clone.

README

Property-Based Testing Skill

Guidance for property-based testing across languages, including Echidna and Medusa for EVM smart contracts.

What it does

  • Spots PBT opportunities — encode/decode pairs, validators, normalizers, pure functions with wide input domains, smart-contract invariants
  • Writes property tests — strategy design, edge-case pinning, settings
  • Reviews existing ones — tautologies, vacuous assume(), missing stronger properties
  • Triages failures — separates a wrong property from an ambiguous spec from a real bug

Structure

skills/property-based-testing/
├── SKILL.md                          # Property catalog, failure modes, routing
└── references/
    ├── generating.md                 # Strategy design, settings, edge cases
    ├── refactoring.md                # Rearrangements that expose a property
    ├── reviewing.md                  # Quality issues by severity
    ├── interpreting-failures.md      # Grounding and classifying a failure
    └── libraries.md                  # Library per language; Echidna/Medusa

What was cut, and why

Three reference files were removed rather than rewritten, and one was kept after initially being cut. Recorded because a deletion with no rationale is indistinguishable from an oversight:

  • design.md — a Phase 1–5 prose workflow. AGENTS.md is explicit that a procedure a model is meant to follow step by step belongs in a script, where it either runs or fails, not in prose it can drift from.
  • strategies.md — per-language generator syntax. st.integers(min_value=1) and fc.string() are not knowledge a current model lacks, and paying context for them crowds out the judgment it does lack. generating.md keeps the parts that are decisions rather than syntax: constraints in the strategy instead of assume(), @example pinning, deadline=None.
  • refactoring.md — cut, then restored in trimmed form. The cut was wrong. Both SKILL.md and the strength ordering send you to "this code is a poor PBT candidate", and without this file that is a dead end where an answer exists: extract the pure core, add the missing inverse, structure-plus-render, return instead of mutate, inject the dependency. evals/03's own fixture is the case in point — send_welcome_email is impure SMTP whose message construction is separable and property-worthy, and the measured runs found that seam unprompted. Trimmed on the way back in: the rg detection one-liners (fragile, and two of them wrong), an effort/risk table and a prioritisation list that both restated the strength ordering, and a "generators for validators" pattern already covered by st.composite in generating.md.

The eval harnesses are deliberately not inside the skill. They sit at the plugin root alongside evals/, so a directory the model reads guidance from does not also ship 900 lines of bash, a requirements.txt naming hypothesis, and a fixture full of tests that are broken on purpose:

evals-extra/                          # run by hand, never by `make check`
├── run.sh                            # Trigger-rate eval
├── effectiveness.sh                  # Does the suite find a real bug?
├── *.md                              # Labelled queries (query/should_trigger)
└── fixture/                          # Small repo the queries refer to

Every command below runs from the plugin root (plugins/property-based-testing/).

Evals

Two things are worth measuring and they are not the same thing.

Why evals-extra and not evals

A full sweep is 45 Claude sessions, ~52 minutes and ~$36. That is not something to attach to a routine check, so the directory is named to stay out of the way: nothing runs these sweeps automatically, and a developer invokes them when the description or the guidance changes.

The --self-test entry points are the exception and do run in make check. They use stub binaries, cost nothing, take about nine seconds, and are what proves the harness still discriminates — an eval that has quietly stopped measuring reports a green skill forever. The Makefile discovers them with an evals* glob, so the rename does not smuggle them out of CI.

Two machinery globs depend on that same prefix, and both fail loudly rather than silently if it drifts: python-tests excludes evals*/fixture/ (which ships a deliberately vacuous assume() test that pytest is meant to fail), and the plugin validator skips evals* when resolving reference links.

Does the skill fire? evals-extra/run.sh runs each labelled query in evals-extra/*.md against a real session and compares the trigger rate against should_trigger. Eight of the fifteen queries are near-miss negatives — a libFuzzer harness, a mutation-testing campaign, a Slither scan — because a description that triggers on everything is as broken as one that never triggers.

./evals-extra/run.sh                        # 3 runs per query, 4 at a time
RUNS=1 ./evals-extra/run.sh                 # smoke
PLUGIN_DIR=/tmp/old ./evals-extra/run.sh    # score a different copy of the skill
./evals-extra/run.sh --self-test            # free: proves the harness still discriminates

One session per query per run, so a sweep is queries x RUNS — 45 at present. The script prints the count on startup; trust that over any number written down here.

Timing and cost, from the 45-session sweep below: 51.9 min at JOBS=4, $36.50 ($0.81/session, read from each session's own total_cost_usd). Both figures track API latency, which moves a lot — median session duration was 78s on one sweep and 175s on another. The wave dispatcher is a barrier, so the slowest session in each wave of JOBS sets that wave's pace.

Two parameters are set from measurement rather than taste, and both were wrong before:

  • TIMEOUT_S=600. The slowest legitimate session in a clean sweep took 449s. At the old 300s it was killed, and four such kills invalidated a whole sweep.
  • TURNS=200. The slowest took 32 turns. The old cap of 14 truncated five of eight sessions in one sample; an interim value of 30 would still have caught this one. 200 is 10x the observed natural completion and cannot fire before the timeout does — see the note in run.sh for why it is kept rather than removed.

Invocation is stochastic, so one run per query measures nothing: the Echidna query scored 0/1 on one sweep and fired on the next identical run. Read a single-run sweep as a smoke test only.

A failed session is not a non-trigger. A crash, a timeout or a rate-limit produces no Skill call, which is indistinguishable from a model that considered the skill and declined — so it used to score as a miss and get absorbed by the floor's leeway. Ten queries at three runs against a floor of 27 tolerates three misses, so a query that crashed all three times still totalled 27 and reported a pass. Now the sweep reports each failure in the NOTE column (timeout and crash:* are distinguished), marks that query INVALID because its denominator is unknown, and exits 3 regardless of score. Every other query still runs and is still reported.

But an invocation outranks a failure. A Skill call is positive and final: nothing later in the session can un-call it, so the detector runs before the exit-status ladder and a yes stands however the process ended. Only the absence of a call depends on the session having reached a decision. Getting this backwards is what corrupted the figures below.

Every session's raw stdout and stderr is kept, in a directory printed at the start and end of the run and excluded from the cleanup trap. This is not optional instrumentation: two sweeps produced ten failures that were undiagnosable afterwards because the captures were deleted the moment they had been classified. Note that failures are reported in the final result record on stdout — in a clean 45-session sweep, 0 of 90 stderr files had any content at all, so anything reading only stderr learns nothing. Artifacts accumulate (~4MB/sweep) and are never cleaned up.

exit meaning
0 every query met its expectation and every session returned a verdict
1 regression — fewer queries passed than EXPECT_PASS
2 harness failure — no queries discovered, or a malformed eval file
3 invalid — a session crashed, timed out, or returned nothing

Measured on opus, 3 runs per query, 45/45 sessions returning a verdict and run.sh exiting 0 — the first sweep of this suite that is a measurement rather than an artefact. Per-query rates:

query expect rate
01-roundtrip-codec true 0/3 FAIL
02-normalizer-idempotence true 2/3
03-hypothesis-existing true 3/3
04-echidna-invariant true 3/3
05-review-weak-tests true 3/3
07-fuzz-serializer-noname true 3/3
08-sort-comparator true 3/3
all 8 negatives false 0/3 each

Totals: 14/15 queries passed, recall 6/7, precision 8/8, raw trigger hits 17/21.

The previously recorded figures were wrong, and why

An earlier version of this file recorded 13/15, recall 5/7, 14/21, with 04 and 07 at 1/3 each. Do not trust those. They were produced by a classifier that consulted the session's exit status before checking whether the skill had been invoked, so any session that called the skill and then hit the 14-turn cap was discarded as a crash. In one salvaged sample, 9 of 12 sessions were being thrown away and all 12 had invoked the skill — including all three runs of 04, the query recorded at 1/3 and since measured at 3/3.

The bias was not random. Longer, more exploratory queries are both the likeliest to reach a turn cap and the ones whose positive evidence matters most, so the inversion depressed recall on precisely the queries under study. Any conclusion drawn from the old table — in particular that the Echidna/Solidity path triggered poorly — does not survive.

The gate's floor stays at 13 rather than rising to the measured 14. Three different positives (01, 04, 07) have been the sole failure in different runs, so a floor of 14 would leave no room for the stochasticity this suite documents everywhere else. One valid sweep is not enough to tighten a gate.

  • 01 (wire-format roundtrip) is the current miss at 0/3, and it is a genuine reversal: in the salvaged sample it invoked the skill on all three runs. Worth a second valid sweep before treating it as a description problem.

Known gap: triage requests do not trigger

Nothing in the suite covers the third job — deciding whether a shrunk counterexample is a real bug, a wrong property, or an edge case the spec never settled. A query for it existed and was removed. It sat at 0/3: handed a falsifying input and the code, the model answers directly and never reaches for guidance, and description wording did not move that.

It was removed rather than kept as a documented failure because neither label was true. should_trigger: true asserts a trigger the description cannot produce; should_trigger: false asserts the skill should stay out of a job it advertises. The field is binary and the honest answer is "unmeasured" — nobody has checked whether loading references/interpreting-failures.md improves the classification over the unaided answer. It was worth nothing as a regression test either: because it never fired, its score was identical whether that reference file was intact or deleted, so it could not distinguish the two states.

To readmit it, run the query against the fixture with and without the plugin and compare the answers. If the guidance improves the classification, should_trigger: true becomes defensible and 0/3 becomes a real bug worth chasing. If it does not, that is a finding about interpreting-failures.md rather than about the description.

One caveat on that 0/3: it was measured by the same classifier that mis-scored 04, so it is not trustworthy either. It is less affected than 04 was — the inversion only discarded sessions that had invoked the skill, and a session invoking it would have scored yes under either ordering — but the figure was never re-measured, and the argument for removal rests on the binary-label problem rather than on the number.

Both scripts pin --model (MODEL, default opus) and print it above the table. Trigger rate is a property of a description and a model, so a number recorded without one cannot be compared to the next one. To judge a description change, score the old copy and the new one on the same model — PLUGIN_DIR exists for exactly that, and it takes a plugin directory rather than needing a dirty working tree.

Does it help once it fires? evals-extra/effectiveness.sh asks for property tests on fixture/src/codec.py, which contains a real defect — canonicalize_url percent- encodes with a safe set that omits %, so a second pass re-encodes its own escapes and canonicalize_url("a b") is not a fixed point. The script runs the generated suite against the defective function and again against a patched one, and counts tests that fail before and pass after. The verdict never comes from the model's own account of how it did, and never from matching test names.

EFFORTS=low ./evals-extra/effectiveness.sh  # score the skill as shipped
NOPLUGIN=1 ./evals-extra/effectiveness.sh   # baseline without the skill loaded

A bare ./evals-extra/effectiveness.sh asks for low/medium/high and is refused while SKILL.md pins an effort — see below.

Run the baseline before adding to this skill. Opus already writes competent Hypothesis suites unaided, so content that does not move a number against NOPLUGIN=1 is costing context without buying anything.

Why effort: low

Swept, not guessed. low, medium and high all detect the fixture defect, and low did it 4 runs out of 4; the review path at low independently named both planted defects in fixture/tests/test_parser.py as CRITICAL. Nothing measured justifies paying for more, which is what sweep downward on your own evals in the repo's AGENTS.md asks for.

Worth knowing before you change it: effort overrides the session level in both directions, so this drags a deliberate xhigh session down while the skill is active. That is the real cost of setting it at all, and it argues for raising the value — not lowering it further — if the generation path ever starts regressing.

The pin also breaks the sweep that justified it, because --effort is ignored once the skill loads: all three arms would run at low under the labels they asked for, and three identical rows are what a healthy sweep looks like too. So effectiveness.sh refuses a multi-level sweep while the pin is there (exit 2) and tells you to strip it from a copy and use PLUGIN_DIR. Re-sweep that way before changing the pinned value.

Example prompts

"Write property-based tests for this JSON serializer"
"Review this Hypothesis test for quality issues"
"Write Echidna invariants for this staking contract"
"Hypothesis shrank to '\x00' — is this a real bug?"

Skill manifest

Property-Based Testing

An example test asserts one point. A property asserts a rule over the whole input domain and lets the generator hunt for the counterexample. That trade is worth making when the code has an algebraic shape — an inverse, an invariant, an oracle — and not otherwise. Code with no such shape gets example tests; saying so is a valid outcome.

Check first whether the shape is missing or merely buried. A calculation wrapped in I/O, a string built by concatenation, an in-place mutation — each has a property and no seam to assert it through. See references/refactoring.md before concluding there is nothing to assert.

Property catalog

Property Formula Where it applies
Roundtrip decode(encode(x)) == x Serialization, conversion pairs
Inverse f(g(x)) == x encrypt/decrypt, compress/decompress
Oracle new(x) == reference(x) Optimization, refactoring, reimplementation
Idempotence f(f(x)) == f(x) Normalization, formatting, sorting
Invariant Holds before and after Any transformation, contract state
Easy to verify is_sorted(sort(x)) Complex algorithms with cheap checkers
Commutativity f(a, b) == f(b, a) Binary and set operations
Associativity f(f(a,b), c) == f(a, f(b,c)) Combining operations
Identity f(x, e) == x Operations with a neutral element

Strength ordering, weakest to strongest: no crash → type preservation → invariant → idempotence → roundtrip / oracle.

Assert the strongest property the code supports. "No crash" alone rarely justifies the dependency — if that is all you can find, either a small rearrangement exposes something stronger, or the honest report is that this code is a poor PBT candidate. Rule out the first before settling for the second.

The two ways a property test asserts nothing

  • Tautology. assert add(a, b) == a + b restates the implementation; no bug they share can fail it. Pick a property that constrains the function without recomputing it. Note the exception: f(x) == f(x) is a genuine determinism property when f is not obviously pure — serializers over dicts or sets, hashing, anything reading the clock.
  • Vacuity. assume() that filters out nearly every input passes without exercising anything, and self-contradictory assume() passes having run zero cases. Push constraints into the strategy so the generator produces valid inputs directly.

Where to look next

Load the one that matches the task in front of you:

Task File
Writing new tests, designing strategies references/generating.md
The code has no property to assert yet references/refactoring.md
Reviewing existing property tests references/reviewing.md
A property test just failed references/interpreting-failures.md
Library choice, Echidna and Medusa references/libraries.md

Introducing PBT to a project that lacks it

If the project already uses a PBT library, just write the tests in it. If it does not, adding one is a dependency decision that belongs to the user — offer it once with the specific property you would write, and take the answer either way.

Files (skills)
  • agents
    • openai.yaml 254 B
      interface:
        display_name: "Property-Based Testing"
        short_description: "Write and review tests built around properties and invariants"
        icon_small: "assets/trail-of-bits-mark.svg"
        icon_large: "assets/trail-of-bits-mark.svg"
        brand_color: "#D83A34"
      
  • assets
    • trail-of-bits-mark.svg 3 KB · in bundle
  • references
    • generating.md 2.6 KB
      # Generating Property-Based Tests
      
      Writing the `@given` decorator is the easy part. These are the decisions that make
      the difference between a suite that finds bugs and one that just runs.
      
      ## Put constraints in the strategy, not in `assume()`
      
      This is the single highest-value habit. `assume()` discards inputs after generation,
      so a filter that rejects most candidates wastes the budget and eventually trips
      Hypothesis's exhausted-filter guard — which surfaces as a warning nobody reads.
      
      ```python
      # Slow, and mostly discards
      @given(st.integers())
      def test_positive(x):
          assume(x > 0)
          ...
      
      # Generates only what you want
      @given(st.integers(min_value=1))
      def test_positive(x):
          ...
      ```
      
      Reserve `assume()` for conditions you genuinely cannot express as a generator — a
      relationship between two already-generated values, usually.
      
      Build compound inputs with `st.builds`, and derive dependent fields with
      `st.composite` or `.flatmap` rather than generating independently and filtering:
      
      ```python
      @st.composite
      def sized_list_and_index(draw):
          xs = draw(st.lists(st.integers(), min_size=1))
          i = draw(st.integers(min_value=0, max_value=len(xs) - 1))
          return xs, i
      ```
      
      ## Pin the edge cases you already know about
      
      Random generation finds boundaries eventually; `@example` finds them on every run and
      documents that you thought about them.
      
      ```python
      @given(st.lists(st.integers()))
      @example([])
      @example([1])
      @example([1, 1, 1])
      def test_sort(xs): ...
      ```
      
      Empty, single-element, all-duplicates, zero, negative, and the maximum representable
      value are the ones that recur.
      
      ## Settings
      
      Defaults (100 examples, 200ms deadline) are wrong at both ends of the workflow:
      
      ```python
      @settings(max_examples=10)                      # local iteration
      @settings(max_examples=200)                     # CI
      @settings(max_examples=1000, deadline=None)     # nightly
      ```
      
      Set `deadline=None` for anything doing real work — the default deadline turns a slow
      machine into a failing test, and that flake gets the whole suite deleted.
      
      ## Determinism as a property
      
      `f(x) == f(x)` is a tautology for a pure function and a real test for anything else.
      Serializers over dicts or sets, anything involving hashing, iteration order, or time —
      those can and do return different output for the same input. Assert it where a broken
      implementation could falsify it, and not otherwise.
      
      ## Testing the error path
      
      `st.binary()` against a decoder is worth writing: the contract is usually "raises
      `DecodeError` or succeeds, never `IndexError`, never hangs". Catch only the documented
      exception and let everything else fail the test.
      
    • interpreting-failures.md 3.3 KB
      # Interpreting Property-Based Test Failures
      
      A property test that fails has told you one of three things, and they need different
      responses:
      
      - **The property is wrong** — you asserted something the code never promised.
      - **The spec is ambiguous** — behaviour at this edge was never decided.
      - **The code is wrong** — a documented guarantee is violated.
      
      Most of the work is telling them apart. Skipping that step is how PBT gets a
      reputation for noise.
      
      ## Ground the property before you trust the failure
      
      Shrunk input in hand, check what the code actually promises. In descending order of
      authority:
      
      | Source | What it settles |
      |---|---|
      | External spec (RFC, format definition) | The real contract, when one exists |
      | Type annotations | Return type, nullability, domain |
      | Docstrings | Explicit guarantees and preconditions |
      | Existing tests | The contract maintainers believe they have |
      | Function name | Weak, but `sort` really does imply ordering |
      
      The name is the weakest signal and the one most likely to mislead you — plenty of
      functions called `normalize` do something narrower than the word suggests.
      
      Worked example. Hypothesis reports `test_normalize(s='\x00')` failing idempotence:
      
      ```python
      def normalize(s: str) -> str:
          """Normalize a string to NFC form.
      
          Args:
              s: Input string (any unicode)
          Returns:
              NFC-normalized string
          """
      ```
      
      "Any unicode" includes null bytes, so the input is in-domain and the property is
      grounded. This one is a real bug.
      
      Change the docstring to "ASCII printable only" and the same failure becomes a
      strategy bug — the fix is `st.text(alphabet=...)`, not a bug report.
      
      ## Classification
      
      | Symptom | Cause | Action |
      |---|---|---|
      | Violates a documented guarantee | Code bug | Report with the shrunk input and a quote from the doc |
      | Input violates a documented precondition | Over-broad strategy | Constrain the strategy |
      | Property contradicts the docstring or type | Wrong property | Fix the property |
      | Edge case the spec never addresses | Ambiguous spec | Ask the maintainer; a discussion, not a bug report |
      | Disappears under realistic constraints | Test artifact | Fix the strategy |
      | Behaviour differs from a sibling function | Possible inconsistency | Worth raising, flag the uncertainty |
      
      Precondition violations and explicitly-undefined behaviour are not bugs. Passing `-1`
      to a function documented as taking positive integers tells you nothing.
      
      Report what you find with the classification attached, including the cases you are
      unsure about — say "ambiguous spec, needs a maintainer decision" rather than staying
      quiet. A suppressed finding cannot be triaged by anyone else.
      
      ## Failure patterns that recur
      
      **Lone surrogates break text roundtrips.** `decode(encode(s)) == s` fails on
      `'\uD800'`. Whether that is a bug turns entirely on whether the format claims to
      accept arbitrary `str` or only valid UTF-8.
      
      **Denormals break numeric invariants.** A probability function returning a negative
      value for `x=1e-320` is a genuine bug against a documented `[0, 1]` range, and it is
      exactly the input no human writes by hand.
      
      **Hash/equality divergence violates a language contract**, not just a docstring —
      `a == b` must imply `hash(a) == hash(b)` in Python. No grounding required; report it.
      
      **Off-by-one in custom iterators** shows up as `list(it(xs)) == xs` dropping the last
      element. Almost always real.
      
    • libraries.md 2.9 KB
      # PBT Libraries by Language
      
      Match the project's existing choice. Introducing a second PBT library into a codebase
      that already has one is not worth the property you wanted to write.
      
      | Language | Default | Also in use |
      |---|---|---|
      | Python | Hypothesis | — |
      | TypeScript / JavaScript | fast-check | — |
      | Rust | proptest | quickcheck (simpler API, per-type shrinking) |
      | Go | rapid | gopter (ScalaCheck-style, more explicit) |
      | Java | jqwik | — |
      | Scala | ScalaCheck | — |
      | C# | FsCheck | — |
      | Elixir | StreamData | — |
      | Haskell | QuickCheck | Hedgehog (integrated shrinking, no type classes) |
      | Clojure | test.check | — |
      | Ruby | PropCheck | — |
      | Kotlin | Kotest | — |
      | C++ | RapidCheck | — |
      | Swift | SwiftCheck | unmaintained — check before recommending |
      
      Detect what a repo already uses before proposing anything:
      
      ```bash
      rg "from hypothesis import|fast-check|use proptest|pgregory.net/rapid|net.jqwik|echidna_|invariant_"
      ```
      
      ## Smart contracts (EVM / Solidity)
      
      This is where PBT earns the most, because contract state is adversarial and the input
      domain is every possible call sequence. Trail of Bits maintains both tools:
      
      - **Echidna** — property fuzzer, mature, the default choice.
      - **Medusa** — parallel execution, coverage-guided; faster on large contract suites.
      
      Two testing modes, and picking the wrong one is the usual mistake:
      
      **Property mode** — a function returning `bool` that must never become false.
      
      ```solidity
      // Echidna calls this after every transaction sequence.
      function echidna_total_matches_sum() public view returns (bool) {
          return token.totalSupply() == trackedSum;
      }
      ```
      
      **Assertion mode** — an `assert` inside a function the fuzzer is allowed to call
      directly, for properties about a specific operation rather than global state.
      
      ```solidity
      function testDepositIncreasesBalance(uint256 amount) public {
          uint256 before = vault.balanceOf(address(this));
          vault.deposit(amount);
          assert(vault.balanceOf(address(this)) >= before);
      }
      ```
      
      ### Contract invariants worth asserting
      
      Solvency (`sum(balances) <= totalAssets`), supply conservation, access control (a
      non-owner call sequence never reaches an owner-only state change), monotonic
      counters, and round-trip on share/asset conversion (`convertToShares` then
      `convertToAssets` never returns more than you put in).
      
      ### Tautologies specific to Solidity
      
      Type bounds are not properties. `uint256 x >= 0` is always true, and so is
      `address(this).balance >= 0` — the compiler guarantees it. Likewise a property that
      only reads state the fuzzer cannot reach is vacuous: if no call sequence can enter
      the branch, the invariant is never exercised. Check Echidna's coverage output rather
      than assuming.
      
      `echidna_` functions must be `view`/`pure` and take no arguments — a property that
      mutates state silently changes what it is testing.
      
      Tutorials: [secure-contracts.com](https://secure-contracts.com).
      
    • refactoring.md 4.7 KB
      # Refactoring to Expose a Property
      
      "This code has no algebraic shape" is often a fact about how the code is *arranged*
      rather than about what it does. A function that mixes a pure calculation with a database
      write has a property; it just does not have a seam to assert it through. These are the
      rearrangements that expose one, strongest first.
      
      Suggest the refactor, name the property it unlocks, and let the author decide. A change
      to production code to make a test possible is their call, not yours — the same rule that
      governs adding a PBT dependency at all.
      
      ## 1. Extract the pure core
      
      The highest-value one by a wide margin, and the reason most "untestable" code is
      testable. I/O at the edges, calculation in the middle, assert against the middle.
      
      ```python
      # Before: the arithmetic is real but unreachable without a database
      def process_order(order_id: str) -> None:
          order = db.fetch(order_id)
          total = apply_discount(order, calculate_discount(order))
          db.save(order_id, total)
      
      # After: the pure core takes arguments and returns a value
      def order_total(order: Order, rules: DiscountRules) -> Decimal:
          return apply_discount(order, calculate_discount(order, rules))
      
      def process_order(order_id: str) -> None:
          order = db.fetch(order_id)
          db.save(order_id, order_total(order, get_discount_rules()))
      ```
      
      `order_total` now supports invariants (never negative, never above the undiscounted
      total), monotonicity in the discount rate, and an oracle against a reference
      calculation. `process_order` keeps example tests with a mocked `db`, which is the right
      tool for a two-line wrapper.
      
      The same move applies to anything whose observable is a side effect: build the message,
      the request, the query object — then send it. Construction is testable; delivery is
      mocked.
      
      ## 2. Add the missing inverse
      
      A one-way operation has no roundtrip by definition. Sometimes the inverse is worth
      having in production anyway, and sometimes it is worth having *only* for the test — say
      which.
      
      ```python
      def encode_message(msg: dict) -> bytes: ...
      def decode_message(data: bytes) -> dict: ...   # unlocks decode(encode(x)) == x
      ```
      
      Unlocks roundtrip, the strongest property in the catalog. Worth asking for even when the
      production code never decodes: a serializer nobody can read back is usually a latent
      bug, not a design.
      
      ## 3. Structured representation plus a renderer
      
      String building by concatenation has nothing to assert beyond "contains a substring".
      Split the value from its rendering and the inverse becomes available.
      
      ```python
      # Before
      def build_query(table: str, filters: dict) -> str:
          q = f"SELECT * FROM {table}"
          ...
      
      # After
      @dataclass
      class Query:
          table: str
          filters: dict
      
      def render(q: Query) -> str: ...
      def parse(sql: str) -> Query: ...   # now render/parse is a roundtrip
      ```
      
      This is pattern 2 wearing different clothes, and it is where escaping bugs live: a
      roundtrip over generated filter values finds quoting errors that no hand-written example
      will.
      
      ## 4. Return a value instead of mutating
      
      An in-place mutation gives you nothing to compare against, because the input is gone by
      the time you want to assert on it.
      
      ```python
      def sort_tasks(tasks: list[Task]) -> None: ...      # before/after comparison impossible
      def sorted_tasks(tasks: list[Task]) -> list[Task]:  # unlocks is_sorted, permutation,
          ...                                             # idempotence, length preservation
      ```
      
      If the mutating signature has to stay, a wrapper that copies and returns is enough for
      the test to have something to hold.
      
      ## 5. Inject the dependency
      
      A function reading a global, a module constant, or `os.environ` can only be tested at
      whatever those happen to be, so the edges of its input domain are unreachable.
      
      ```python
      def validate(data: str) -> bool:          return len(data) <= CONFIG.max_length
      def validate(data: str, max_len: int):    return len(data) <= max_len
      ```
      
      Parameterising the bound is what lets a generator drive `max_len` to 0, to 1, and to the
      maximum representable value — the boundaries where validators actually break.
      
      ## When not to suggest this
      
      - **The property you would unlock is "no crash".** Restructuring production code to
        enable the weakest property in the catalog is a bad trade. Say the code is a poor PBT
        candidate and stop.
      - **The module needs wholesale restructuring.** Say that once, plainly. Twenty
        individually-reasonable suggestions on one file is noise, and it reads as a rewrite
        request rather than a testing recommendation.
      - **The refactor breaks a public API.** Flag it as breaking and offer the
        backwards-compatible version, even when the clean version is obviously nicer.
      - **Existing tests cover the code.** Run them after any refactor and say you did.
        "Enabled a property test and broke two example tests" is not progress.
      
    • reviewing.md 2.8 KB
      # Reviewing Property-Based Tests
      
      A property test can pass for years while asserting nothing. These are the ways that
      happens, worst first.
      
      Report every issue you find with its severity attached. Do not decide on the author's
      behalf that a MEDIUM is not worth mentioning.
      
      | Issue | Severity | How it shows up |
      |---|---|---|
      | Tautological | CRITICAL | Assertion is true regardless of the implementation |
      | Vacuous | CRITICAL | `assume()` filters out nearly everything, or contradicts itself |
      | No assertion | HIGH | Body calls the function and stops |
      | Reimplementation | HIGH | Assertion recomputes the function's own logic |
      | Weaker property available | MEDIUM | Length checked, ordering not |
      | Over-filtered | MEDIUM | Stacked `assume()` where a strategy constraint belongs |
      | Settings | LOW | `max_examples=5`, or no deadline on an expensive strategy |
      
      ## Tautological
      
      ```python
      @given(st.integers())
      def test_useless(x):
          result = compute(x)
          assert result == result
      ```
      
      Nothing about `compute` can make this fail.
      
      **But `f(x) == f(x)` is not automatically tautological.** It is a real determinism
      property whenever `f` is not obviously pure — serializers over dicts or sets, anything
      touching iteration order, hashing, or time. `pickle.dumps(obj) == pickle.dumps(obj)`
      genuinely fails for objects with a nondeterministic `__reduce__`. Ask whether a broken
      implementation could falsify it. If yes, it is a property; if no, it is noise.
      
      ## Vacuous
      
      ```python
      @given(st.integers())
      def test_vacuous(x):
          assume(x > 100)
          assume(x < 50)
          assert compute(x) > 0
      ```
      
      Hypothesis reports this as passing before it eventually errors on exhausted filters —
      and in CI nobody reads the warning. `assume(x == 42)` is the subtler version: it runs,
      it passes, and it is an example test wearing a `@given` decorator.
      
      ## Reimplementation
      
      ```python
      @given(st.integers(), st.integers())
      def test_reimplements(a, b):
          assert add(a, b) == a + b
      ```
      
      If `add` is `a + b`, this asserts `a + b == a + b`. The test survives any bug the two
      expressions share. Reach for an algebraic property instead — commutativity, identity,
      associativity — which constrains the function without restating it.
      
      ## Finding the tests
      
      ```bash
      rg "@given\(|from hypothesis import" --type py
      rg "fc\.(assert|property)" --type ts --type js
      rg "proptest!|#\[quickcheck\]" --type rust
      ```
      
      ## What to push for
      
      Compare each test against the property catalog in SKILL.md and name the strongest
      property the code supports but the suite does not assert. A suite that checks
      `len(sort(xs)) == len(xs)` and never checks ordering is the common case.
      
      Also worth flagging: floating-point equality without a tolerance, assertions on
      dict/set iteration order, and anything reading the clock — these produce flakes that
      get blamed on Hypothesis and then get deleted.
      
  • README.md 15 KB
    # Property-Based Testing Skill
    
    Guidance for property-based testing across languages, including Echidna and Medusa for
    EVM smart contracts.
    
    ## What it does
    
    - **Spots PBT opportunities** — encode/decode pairs, validators, normalizers, pure
      functions with wide input domains, smart-contract invariants
    - **Writes property tests** — strategy design, edge-case pinning, settings
    - **Reviews existing ones** — tautologies, vacuous `assume()`, missing stronger
      properties
    - **Triages failures** — separates a wrong property from an ambiguous spec from a
      real bug
    
    ## Structure
    
    ```
    skills/property-based-testing/
    ├── SKILL.md                          # Property catalog, failure modes, routing
    └── references/
        ├── generating.md                 # Strategy design, settings, edge cases
        ├── refactoring.md                # Rearrangements that expose a property
        ├── reviewing.md                  # Quality issues by severity
        ├── interpreting-failures.md      # Grounding and classifying a failure
        └── libraries.md                  # Library per language; Echidna/Medusa
    ```
    
    ### What was cut, and why
    
    Three reference files were removed rather than rewritten, and one was kept after
    initially being cut. Recorded because a deletion with no rationale is indistinguishable
    from an oversight:
    
    - **`design.md`** — a Phase 1–5 prose workflow. AGENTS.md is explicit that a procedure
      a model is meant to follow step by step belongs in a script, where it either runs or
      fails, not in prose it can drift from.
    - **`strategies.md`** — per-language generator syntax. `st.integers(min_value=1)` and
      `fc.string()` are not knowledge a current model lacks, and paying context for them
      crowds out the judgment it does lack. `generating.md` keeps the parts that are
      decisions rather than syntax: constraints in the strategy instead of `assume()`,
      `@example` pinning, `deadline=None`.
    - **`refactoring.md`** — cut, then **restored** in trimmed form. The cut was wrong. Both
      SKILL.md and the strength ordering send you to "this code is a poor PBT candidate",
      and without this file that is a dead end where an answer exists: extract the pure core,
      add the missing inverse, structure-plus-render, return instead of mutate, inject the
      dependency. `evals/03`'s own fixture is the case in point — `send_welcome_email` is
      impure SMTP whose message construction is separable and property-worthy, and the
      measured runs found that seam unprompted. Trimmed on the way back in: the `rg`
      detection one-liners (fragile, and two of them wrong), an effort/risk table and a
      prioritisation list that both restated the strength ordering, and a "generators for
      validators" pattern already covered by `st.composite` in `generating.md`.
    
    The eval harnesses are deliberately **not** inside the skill. They sit at the plugin
    root alongside `evals/`, so a directory the model reads guidance from does not also
    ship 900 lines of bash, a `requirements.txt` naming hypothesis, and a fixture full of
    tests that are broken on purpose:
    
    ```
    evals-extra/                          # run by hand, never by `make check`
    ├── run.sh                            # Trigger-rate eval
    ├── effectiveness.sh                  # Does the suite find a real bug?
    ├── *.md                              # Labelled queries (query/should_trigger)
    └── fixture/                          # Small repo the queries refer to
    ```
    
    **Every command below runs from the plugin root** (`plugins/property-based-testing/`).
    
    ## Evals
    
    Two things are worth measuring and they are not the same thing.
    
    ### Why `evals-extra` and not `evals`
    
    A full sweep is 45 Claude sessions, ~52 minutes and ~$36. That is not something to
    attach to a routine check, so the directory is named to stay out of the way: nothing
    runs these sweeps automatically, and a developer invokes them when the description or
    the guidance changes.
    
    The `--self-test` entry points are the exception and do run in `make check`. They use
    stub binaries, cost nothing, take about nine seconds, and are what proves the harness
    still discriminates — an eval that has quietly stopped measuring reports a green skill
    forever. The Makefile discovers them with an `evals*` glob, so the rename does not
    smuggle them out of CI.
    
    Two machinery globs depend on that same prefix, and both fail loudly rather than
    silently if it drifts: `python-tests` excludes `evals*/fixture/` (which ships a
    deliberately vacuous `assume()` test that pytest is meant to fail), and the plugin
    validator skips `evals*` when resolving reference links.
    
    **Does the skill fire?** `evals-extra/run.sh` runs each labelled query in
    `evals-extra/*.md` against a real session and compares the trigger rate against
    `should_trigger`. Eight of the fifteen queries are near-miss negatives — a libFuzzer
    harness, a mutation-testing campaign, a Slither scan — because a description that
    triggers on everything is as broken as one that never triggers.
    
    ```sh
    ./evals-extra/run.sh                        # 3 runs per query, 4 at a time
    RUNS=1 ./evals-extra/run.sh                 # smoke
    PLUGIN_DIR=/tmp/old ./evals-extra/run.sh    # score a different copy of the skill
    ./evals-extra/run.sh --self-test            # free: proves the harness still discriminates
    ```
    
    One session per query per run, so a sweep is `queries x RUNS` — 45 at present. The
    script prints the count on startup; trust that over any number written down here.
    
    Timing and cost, from the 45-session sweep below: **51.9 min at `JOBS=4`, $36.50**
    ($0.81/session, read from each session's own `total_cost_usd`). Both figures track API
    latency, which moves a lot — median session duration was 78s on one sweep and 175s on
    another. The wave dispatcher is a barrier, so the slowest session in each wave of
    `JOBS` sets that wave's pace.
    
    Two parameters are set from measurement rather than taste, and both were wrong before:
    
    - **`TIMEOUT_S=600`.** The slowest legitimate session in a clean sweep took **449s**.
      At the old 300s it was killed, and four such kills invalidated a whole sweep.
    - **`TURNS=200`.** The slowest took **32 turns**. The old cap of 14 truncated five of
      eight sessions in one sample; an interim value of 30 would still have caught this
      one. 200 is 10x the observed natural completion and cannot fire before the timeout
      does — see the note in `run.sh` for why it is kept rather than removed.
    
    Invocation is stochastic, so one run per query measures nothing: the Echidna query
    scored 0/1 on one sweep and fired on the next identical run. Read a single-run sweep
    as a smoke test only.
    
    **A failed session is not a non-trigger.** A crash, a timeout or a rate-limit produces
    no Skill call, which is indistinguishable from a model that considered the skill and
    declined — so it used to score as a miss and get absorbed by the floor's leeway. Ten
    queries at three runs against a floor of 27 tolerates three misses, so a query that
    crashed all three times still totalled 27 and reported a pass. Now the sweep reports
    each failure in the `NOTE` column (`timeout` and `crash:*` are distinguished), marks
    that query `INVALID` because its denominator is unknown, and exits 3 regardless of
    score. Every other query still runs and is still reported.
    
    **But an invocation outranks a failure.** A Skill call is positive and final: nothing
    later in the session can un-call it, so the detector runs *before* the exit-status
    ladder and a `yes` stands however the process ended. Only the absence of a call depends
    on the session having reached a decision. Getting this backwards is what corrupted the
    figures below.
    
    **Every session's raw stdout and stderr is kept**, in a directory printed at the start
    and end of the run and excluded from the cleanup trap. This is not optional
    instrumentation: two sweeps produced ten failures that were undiagnosable afterwards
    because the captures were deleted the moment they had been classified. Note that
    failures are reported in the final `result` record on **stdout** — in a clean 45-session
    sweep, 0 of 90 stderr files had any content at all, so anything reading only stderr
    learns nothing. Artifacts accumulate (~4MB/sweep) and are never cleaned up.
    
    | exit | meaning |
    |---|---|
    | 0 | every query met its expectation and every session returned a verdict |
    | 1 | regression — fewer queries passed than `EXPECT_PASS` |
    | 2 | harness failure — no queries discovered, or a malformed eval file |
    | 3 | invalid — a session crashed, timed out, or returned nothing |
    
    Measured on `opus`, 3 runs per query, 45/45 sessions returning a verdict and `run.sh`
    exiting 0 — the first sweep of this suite that is a measurement rather than an
    artefact. Per-query rates:
    
    | query | expect | rate |
    |---|---|---|
    | 01-roundtrip-codec | true | **0/3 FAIL** |
    | 02-normalizer-idempotence | true | 2/3 |
    | 03-hypothesis-existing | true | 3/3 |
    | 04-echidna-invariant | true | 3/3 |
    | 05-review-weak-tests | true | 3/3 |
    | 07-fuzz-serializer-noname | true | 3/3 |
    | 08-sort-comparator | true | 3/3 |
    | all 8 negatives | false | 0/3 each |
    
    Totals: **14/15 queries passed, recall 6/7, precision 8/8, raw trigger hits 17/21.**
    
    ### The previously recorded figures were wrong, and why
    
    An earlier version of this file recorded 13/15, recall 5/7, 14/21, with 04 and 07 at
    1/3 each. Do not trust those. They were produced by a classifier that consulted the
    session's exit status *before* checking whether the skill had been invoked, so any
    session that called the skill and then hit the 14-turn cap was discarded as a crash.
    In one salvaged sample, **9 of 12 sessions were being thrown away and all 12 had
    invoked the skill** — including all three runs of 04, the query recorded at 1/3 and
    since measured at 3/3.
    
    The bias was not random. Longer, more exploratory queries are both the likeliest to
    reach a turn cap and the ones whose positive evidence matters most, so the inversion
    depressed recall on precisely the queries under study. Any conclusion drawn from the
    old table — in particular that the Echidna/Solidity path triggered poorly — does not
    survive.
    
    The gate's floor stays at **13** rather than rising to the measured 14. Three different
    positives (01, 04, 07) have been the sole failure in different runs, so a floor of 14
    would leave no room for the stochasticity this suite documents everywhere else. One
    valid sweep is not enough to tighten a gate.
    
    - **01 (wire-format roundtrip)** is the current miss at 0/3, and it is a genuine
      reversal: in the salvaged sample it invoked the skill on all three runs. Worth a
      second valid sweep before treating it as a description problem.
    
    ### Known gap: triage requests do not trigger
    
    Nothing in the suite covers the third job — deciding whether a shrunk counterexample is
    a real bug, a wrong property, or an edge case the spec never settled. A query for it
    existed and was removed. It sat at 0/3: handed a falsifying input and the code, the
    model answers directly and never reaches for guidance, and description wording did not
    move that.
    
    It was removed rather than kept as a documented failure because neither label was true.
    `should_trigger: true` asserts a trigger the description cannot produce;
    `should_trigger: false` asserts the skill should stay out of a job it advertises. The
    field is binary and the honest answer is "unmeasured" — nobody has checked whether
    loading `references/interpreting-failures.md` improves the classification over the
    unaided answer. It was worth nothing as a regression test either: because it never
    fired, its score was identical whether that reference file was intact or deleted, so it
    could not distinguish the two states.
    
    To readmit it, run the query against the fixture with and without the plugin and
    compare the answers. If the guidance improves the classification, `should_trigger: true`
    becomes defensible and 0/3 becomes a real bug worth chasing. If it does not, that is a
    finding about `interpreting-failures.md` rather than about the description.
    
    One caveat on that 0/3: it was measured by the same classifier that mis-scored 04, so it
    is not trustworthy either. It is *less* affected than 04 was — the inversion only
    discarded sessions that had invoked the skill, and a session invoking it would have
    scored `yes` under either ordering — but the figure was never re-measured, and the
    argument for removal rests on the binary-label problem rather than on the number.
    
    Both scripts pin `--model` (`MODEL`, default `opus`) and print it above the table.
    Trigger rate is a property of a description *and* a model, so a number recorded
    without one cannot be compared to the next one. To judge a description change, score
    the old copy and the new one on the same model — `PLUGIN_DIR` exists for exactly
    that, and it takes a plugin directory rather than needing a dirty working tree.
    
    **Does it help once it fires?** `evals-extra/effectiveness.sh` asks for property tests on
    `fixture/src/codec.py`, which contains a real defect — `canonicalize_url` percent-
    encodes with a safe set that omits `%`, so a second pass re-encodes its own escapes
    and `canonicalize_url("a b")` is not a fixed point. The script runs the generated
    suite against the defective function and again against a patched one, and counts
    tests that fail before and pass after. The verdict never comes from the model's own
    account of how it did, and never from matching test names.
    
    ```sh
    EFFORTS=low ./evals-extra/effectiveness.sh  # score the skill as shipped
    NOPLUGIN=1 ./evals-extra/effectiveness.sh   # baseline without the skill loaded
    ```
    
    A bare `./evals-extra/effectiveness.sh` asks for low/medium/high and is **refused**
    while `SKILL.md` pins an effort — see below.
    
    Run the baseline before adding to this skill. Opus already writes competent Hypothesis
    suites unaided, so content that does not move a number against `NOPLUGIN=1` is
    costing context without buying anything.
    
    ## Why `effort: low`
    
    Swept, not guessed. `low`, `medium` and `high` all detect the fixture defect, and
    `low` did it 4 runs out of 4; the review path at `low` independently named both
    planted defects in `fixture/tests/test_parser.py` as CRITICAL. Nothing measured
    justifies paying for more, which is what `sweep downward on your own evals` in the
    repo's AGENTS.md asks for.
    
    Worth knowing before you change it: `effort` overrides the session level in both
    directions, so this drags a deliberate `xhigh` session *down* while the skill is
    active. That is the real cost of setting it at all, and it argues for raising the
    value — not lowering it further — if the generation path ever starts regressing.
    
    **The pin also breaks the sweep that justified it**, because `--effort` is ignored
    once the skill loads: all three arms would run at `low` under the labels they asked
    for, and three identical rows are what a healthy sweep looks like too. So
    `effectiveness.sh` refuses a multi-level sweep while the pin is there (exit 2) and
    tells you to strip it from a copy and use `PLUGIN_DIR`. Re-sweep that way before
    changing the pinned value.
    
    ## Example prompts
    
    ```
    "Write property-based tests for this JSON serializer"
    "Review this Hypothesis test for quality issues"
    "Write Echidna invariants for this staking contract"
    "Hypothesis shrank to '\x00' — is this a real bug?"
    ```
    
  • SKILL.md 4.2 KB
    ---
    name: property-based-testing
    effort: low
    description: "Writes, reviews, and debugs property-based tests — Hypothesis, fast-check, proptest, jqwik, rapid, and Echidna or Medusa for Solidity invariants. Use whenever tests should cover a whole input domain instead of a hand-picked list of examples: encode/decode and serialize/deserialize pairs, parsers, canonicalizers and normalizers, validators, numeric and Decimal types, comparators and sort order, data structures, and smart-contract state invariants. Also use when adding cases to an existing @given, fast-check, or proptest suite, when judging whether existing property tests assert anything real, and when a generator has shrunk a counterexample and you need to tell a wrong property from a genuine bug. Not for coverage-guided binary fuzzing (libFuzzer, AFL), mutation-testing campaigns, static analysis, benchmarking, or end-to-end UI tests."
    ---
    
    # Property-Based Testing
    
    An example test asserts one point. A property asserts a rule over the whole input
    domain and lets the generator hunt for the counterexample. That trade is worth making
    when the code has an algebraic shape — an inverse, an invariant, an oracle — and not
    otherwise. Code with no such shape gets example tests; saying so is a valid outcome.
    
    Check first whether the shape is missing or merely buried. A calculation wrapped in I/O,
    a string built by concatenation, an in-place mutation — each has a property and no seam
    to assert it through. See [references/refactoring.md](references/refactoring.md) before
    concluding there is nothing to assert.
    
    ## Property catalog
    
    | Property | Formula | Where it applies |
    |---|---|---|
    | Roundtrip | `decode(encode(x)) == x` | Serialization, conversion pairs |
    | Inverse | `f(g(x)) == x` | encrypt/decrypt, compress/decompress |
    | Oracle | `new(x) == reference(x)` | Optimization, refactoring, reimplementation |
    | Idempotence | `f(f(x)) == f(x)` | Normalization, formatting, sorting |
    | Invariant | Holds before and after | Any transformation, contract state |
    | Easy to verify | `is_sorted(sort(x))` | Complex algorithms with cheap checkers |
    | Commutativity | `f(a, b) == f(b, a)` | Binary and set operations |
    | Associativity | `f(f(a,b), c) == f(a, f(b,c))` | Combining operations |
    | Identity | `f(x, e) == x` | Operations with a neutral element |
    
    Strength ordering, weakest to strongest:
    `no crash → type preservation → invariant → idempotence → roundtrip / oracle`.
    
    Assert the strongest property the code supports. "No crash" alone rarely justifies the
    dependency — if that is all you can find, either a small rearrangement exposes something
    stronger, or the honest report is that this code is a poor PBT candidate. Rule out the
    first before settling for the second.
    
    ## The two ways a property test asserts nothing
    
    - **Tautology.** `assert add(a, b) == a + b` restates the implementation; no bug they
      share can fail it. Pick a property that constrains the function without recomputing
      it. Note the exception: `f(x) == f(x)` is a genuine determinism property when `f`
      is not obviously pure — serializers over dicts or sets, hashing, anything reading
      the clock.
    - **Vacuity.** `assume()` that filters out nearly every input passes without
      exercising anything, and self-contradictory `assume()` passes having run zero cases.
      Push constraints into the strategy so the generator produces valid inputs directly.
    
    ## Where to look next
    
    Load the one that matches the task in front of you:
    
    | Task | File |
    |---|---|
    | Writing new tests, designing strategies | [references/generating.md](references/generating.md) |
    | The code has no property to assert yet | [references/refactoring.md](references/refactoring.md) |
    | Reviewing existing property tests | [references/reviewing.md](references/reviewing.md) |
    | A property test just failed | [references/interpreting-failures.md](references/interpreting-failures.md) |
    | Library choice, Echidna and Medusa | [references/libraries.md](references/libraries.md) |
    
    ## Introducing PBT to a project that lacks it
    
    If the project already uses a PBT library, just write the tests in it. If it does not,
    adding one is a dependency decision that belongs to the user — offer it once with the
    specific property you would write, and take the answer either way.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related