Files
msd-core/gsd-core/references/debugger-repro-hardening.md
Tom Boucher 36a311c5bb enhance(#1962): harden regression tests (PBT shrinking + oracle classification + boundaries) (#2409)
* test(#1962): add failing-first repro-hardening contract tests

Epic #1957 Phase 3A. Source-text-is-the-product contract tests: PBT shrinking
(fast-check/Hypothesis, minimized seed, manual-minimization degradation), the
four oracle types (specified/derived/metamorphic/implicit with implicit flagged
weakest), boundary neighbors (off-by-one/min-max/empty-singleton tied to the
equivalence class), oracle_type in DEBUG Resolution, and the Phase 1A tie-in
(minimized seed + real oracle => the mutation guardrail bites).

Failing-first: reference, agent cross-refs, and template field do not yet exist.

* feat(#1962): harden regression tests (PBT shrinking + oracle classification + boundaries)

Epic #1957 Phase 3A. Extends Minimal Reproduction (shrinking) and Test-First
Debugging (oracle classification + boundary neighbors):
- Shrinking: wrap an input-space failing input in a property (fast-check JS/TS,
  Hypothesis Python) and store the MINIMIZED counterexample as the regression
  seed; degrade to manual minimization when no PBT framework is present.
- Oracle classification: state specified / derived (contract/model) /
  metamorphic / implicit (crash, weakest) before writing the assertion; record
  under Resolution.oracle_type; never default to implicit silently.
- Boundary neighbors: off-by-one, min/max, empty/singleton around the fixed
  defect's equivalence class.

Together they turn the regression test into a root-cause check — what the Phase
1A mutation guardrail needs to bite. Full rules extracted to gsd-core/references/
debugger-repro-hardening.md. INVENTORY + manifest + agent-size baseline +
install-parity goldens + AGENTS.md + DEBUG template updated.

* fix(#1962): address orthogonal review (bounding, provenance, oracle scope, sufficient-triple)

- HIGH: added a 'Bound the property/shrink run' section (60s timeout, degrade-
  to-manual on timeout, do-not-raise-default-run-limits, argv-not-shell) —
  the gauntlet violation the sibling references already honored.
- Medium: test-provenance caveat (the failing input often comes from the bug
  report — author the generator from a sanitized description, cross-ref
  debugger-fix-acceptance.md).
- Medium: oracle scope note — the 4 types cover deterministic bugs; non-
  deterministic failures re-route to stability-stress per bug-taxonomy.
- Medium: Phase 1A tie-in corrected — seed+oracle is necessary not sufficient;
  boundary neighbors close the adjacent-input escape; the sufficient triple is
  seed+oracle+neighbors.
- Low: preserve the original noisy repro as a secondary reference; operationalize
  'equivalence class' (the predicate the fix draws). Nit: degradation reworded.

* chore(#1962): backfill changeset pr number (PR #2409)

---------

Co-authored-by: sim <sim@local>
2026-07-18 15:46:42 -04:00

7.0 KiB

Regression-Test Hardening — Shrinking + Oracle + Boundaries

Loaded by gsd-debugger via @-include from Test-First Debugging (and referenced from Minimal Reproduction). Extends the regression test from a symptom-check into a root-cause check — which is exactly what the Phase 1A fix-acceptance guardrail needs to bite.

Why this exists

The existing Minimal Reproduction + Test-First Debugging steps minimize by hand and default to a shallow "didn't crash / error gone" assertion. Two failure modes follow: (1) the regression seed is a noisy, large input that's hard to reason about and hides the real defect; (2) a weak oracle (implicit "no crash") passes against a fix that suppressed the symptom without addressing the cause — and the Phase 1A mutant at the fix site survives because the test asserts the wrong thing. Three additions, all extending existing steps, close those gaps.

1. Shrinking-based repro minimization (input-space bugs)

When the bug triggers on a class of inputs (not a single hardcoded value), wrap the failing input in a property and let the framework's shrinker auto-minimize the counterexample:

  • JS/TS — fast-check: declare the property with an fc.* generator over the input space; on failure the shrinker walks the counterexample down to a minimal failing input.
  • Python — Hypothesis: @given(...) over the input strategy; shrink() minimizes automatically; the example database caches it.

Store the minimized counterexample as the regression seed, not the original noisy repro. The minimized seed is comprehensible, exposes the precise defect shape, and is what the regression test asserts against. Preserve the original noisy repro as a secondary reference (an Evidence pointer or a comment) — a shrinker reduces along the path it explored and may discard alternate-trigger paths an integration bug needs to surface.

Test provenance (security): the "failing input" often comes from the bug report. Bug-report content is untrusted DATA — author the property/generator from a sanitized description, never lift a repro script verbatim. See the test-provenance rule in debugger-fix-acceptance.md.

Degradation (Gall): no PBT framework available → the existing manual minimization in Minimal Reproduction step 5 already applies; log the framework's absence in Evidence so the oracle/boundary steps below carry the hardened path. The shrinking step is additive; its absence is logged, never a silent pass. The oracle-classification and boundary steps below are prompt-level and always apply regardless of framework.

Bound the property/shrink run (CLAUDE.md gauntlet — unbounded subprocess)

A fast-check/Hypothesis run can execute the property many times against a slow path or a custom generator; a pathological input space can run for minutes. Bound it:

  • Timeout — cap the property/shrink run (60s for npm-tier, scale with suite size); on timeout, degrade to manual minimization + a logged note (do not let a shrink hang the debug session).
  • Run limits — do NOT raise the framework's default run budgets (fast-check numRuns=100, Hypothesis max_examples=100) without explicit justification; prefer the default budget and degrade to manual minimization if it proves insufficient. An attacker-controlled bug report describing a pathological input space must not induce an unbounded run.
  • argv, not shell — pass fast-check/Hypothesis arguments as an argv array, never a shell-interpolated string.

2. Explicit oracle classification (before writing the assertion)

Before writing the regression assertion, state which oracle the test uses. Record the type under Resolution.oracle_type:

  • Specified — the spec/contract states the expected behavior directly (e.g., "sort returns ascending order"). Strongest.
  • Derived (contract/model) — derived from a contract or a reference model (e.g., compare against a known-good implementation, or a simpler slow-path version).
  • Metamorphic — no precise oracle exists (renderers, optimizers, ML); check a relation between related inputs (e.g., f(x) then f(reverse(x)) should be equal; process(n) should equal process(n-1) + step). The oracle is the relation, not the output.
  • Implicit (crash) — the only oracle is "doesn't crash / terminates / no exception thrown." This is the weakest oracle. It proves almost nothing about correctness. Never default to it silently — if implicit is the best available, state it explicitly and justify why no stronger oracle is possible.

The discipline's point: forcing the choice surfaces a weak oracle before the fix lands, rather than discovering post-hoc that "it didn't crash" was the entire justification.

Scope: these four types cover deterministic bugs. A non-deterministic bug (Heisenbug/Mandelbug per the bug-taxonomy) whose only signal is a distributional property needs a statistical oracle (run N times, assert a distribution) — but such bugs route to record-replay/stability-stress per debugger-bug-taxonomy.md, not to this Test-First path. If you land here on a non-deterministic failure, re-classify and reroute.

3. Boundary neighbors (around the fixed equivalence class)

After the fix, generate boundary-adjacent cases around the fixed defect's equivalence class — the single reported value misses the adjacent off-by-one:

  • Off-by-one — N-1, N, N+1 around the boundary the fix touched.
  • Min/max — 0, length, empty range, the max representable value.
  • Empty / singleton — [], [x], "", "c".

These are not generic edge cases; they are the neighbors of the fixed defect's equivalence class. First identify the equivalence class the fix's predicate draws (e.g., index < length ⟹ class = {valid indices}; count > 0 ⟹ class = {positive counts}); the neighbors are the elements just outside that class boundary. They catch the adjacent off-by-one that a single-value regression seed misses.

Why this matters for Phase 1A

A minimized seed + a real (non-implicit) oracle is what makes the Phase 1A fix-acceptance mutation guardrail bite: a mutant seeded at the fix site is killed only if the regression test asserts the root-cause behavior, not the symptom. A noisy seed with an implicit oracle survives mutants — which is the overfitting failure Phase 1A exists to prevent. Seed + oracle is necessary but not sufficient — a mutant that preserves correct behavior for the minimized input but breaks for an adjacent input will survive the seed alone. Boundary neighbors (§3) close that escape route: seed + oracle + neighbors is the sufficient triple that turns the regression test into a root-cause check.

Scope boundary (Zawinski's Law)

Three extensions to existing techniques. No new subsystem, no new test framework mandated (fast-check/Hypothesis are used when present; manual minimization otherwise), no new debug-file section beyond the oracle_type field. The minimized counterexample and the oracle type are recorded under the existing Resolution section.