Story S5-07 — scripts/scaffold_bench_case.py operator tool¶
Step: Step 5 — Backfill bench/vuln-remediation/ with ≥10 cases + rubric + taxonomies
Status: HARDENED (phase-story-validator, 2026-06-05)
Effort: S
Depends on: S5-01 HARDENED (bench/vuln-remediation/registration.py ships register_task_class("vuln-remediation", ...); BreakdownKey StrEnum + failure_modes.yaml exist — the scaffolder validates --task-class against default_registry.names() so unknown slugs fail loud), S1-02 HARDENED (BenchCase Pydantic wire-type — cassette_canary_pin: str constrained by re.fullmatch(r"^[0-9a-f]{32}$", ...); case_digest: str constrained by re.fullmatch(r"^blake3:[0-9a-f]{64}$", ...); disposition/difficulty/source/curation_class Literals; tz-aware UTC added_at/last_validated_at; required input_path / expected_path Path; commit_sha: str | None; cassette_path: Path | None), S2-02 HARDENED (the loader's case walk + _compute_case_dir_digest canonical algorithm — the scaffolder emits a sentinel stub digest and points at scripts/sign_bench_digests.py (S5-05) as the curator's next step; the scaffolder NEVER inlines the digest algorithm). Transitively: S5-03 HARDENED (canonical canary derivation formula for rag-corpus-derived with --source-cassette — the scaffolder mints byte-identical pins) and S5-04 HARDENED (os.urandom(32).hex() mint for held-out — distinct across invocations).
ADRs honored: ADR-0005 §Consequences line 44 (the scaffolder mints cassette_canary_pin directly as a 32-hex string — NOT via Canary.mint(...), which is the runtime canary-token API; held-out pins are freshly minted via os.urandom(32).hex(); rag-corpus-derived pins are derived deterministically from the source cassette's repo-relative POSIX path per the S5-03 HARDENED formula), ADR-0006 §Consequences (the scaffolder asks for --curation-class; the resulting case carries the matching Literal; CVE is required for held-out; held-out CVE-year-floor selection criterion is the bench-author's judgment — not enforced by the scaffolder, surfaced in Notes), ADR-0004 + ADR-0008 are honored transitively — the scaffolder does not author breakdown_keys.py or failure_modes.yaml (those are per-task-class, owned by S5-01 and analogous task-class registration stories), Phase 0 ADR-0001 (the scaffolder routes any digest computation through the public codegenie.eval.digests.compute_case_dir_digest promoted by S5-05; never re-implements BLAKE3 framing — bench-curation scripts under scripts/ are exempt from the closure fence but inherit the chokepoint contract by reuse)
Validation notes¶
Validated: 2026-06-05 Verdict: HARDENED Findings addressed: 20 total — 7 blocks, 10 hardens, 3 nits
Critic reports: Coverage (12), Test Quality (7), Consistency (8), Design Patterns (5). No NEEDS RESEARCH — every pattern is precedented in this repo (S5-03 HARDENED canary derivation, S5-04 HARDENED os.urandom contract, S5-05 HARDENED operator-script Open/Closed pattern, S1-02 HARDENED Pydantic wire-types).
Changes applied (full audit log: _validation/S5-07-scaffold-bench-case-script.md):
- Canary pin derivation rewritten (BLOCK, F-CON-1 + F-CON-2 + F-COV-2): the original
blake3(f"{task_class}/{case_id}".encode()).hexdigest()[:32]fallback contradicted both sibling HARDENED stories. S5-03 AC-3a requiresblake3.blake3(f"phase4-cassette:{cassette_relpath_posix}".encode("utf-8")).hexdigest()[:32]for RAG-corpus-derived (domain-separated by the literalphase4-cassette:prefix); S5-04 AC-3a requiresos.urandom(32).hex()for held-out (per ADR-0005 §Consequences line 44). The scaffolded pin under the original formula would byte-fail S5-03'stest_cassette_canary_pin_matches_canonical_derivationtest and would collide across curator runs under S5-04's distinctness AC. AC-3 + AC-3a now pin the two-path strategy:--curation-class=rag-corpus-derivedREQUIRES--source-cassetteand uses S5-03's formula verbatim;--curation-class=held-outusesos.urandom(32).hex(). The deterministic-from-case-id third path is deleted. Canary.mint(seed=...)references removed (BLOCK, F-CON-3): the original story confusedCanary.mint(...)(the Phase-4 32-byte runtime canary TOKEN generator consumed at SUT-invocation time per ADR-0005 §Decision line 24) with thecassette_canary_pin(the 32-hex curator-time IDENTIFIER pinned intocase.toml). The scaffolder mints a STRING IDENTIFIER (the pin) — it does NOT callCanary.mint. Per S2-05's own diagnostic,Canary.mint(seed=...)does not yet exist in shipped code as of 2026-05-26 anyway. The fallback path in the original Notes is removed; the AC-3 / AC-3a formulas are the only paths.case_digeststub pinned + curator-flow contract clarified (BLOCK, F-CON-4 + F-COV-3): the original"blake3:0000...0000"shorthand was ambiguous (the S2-02 regex requires exactly 64 lowercase hex chars after theblake3:prefix). AC-4 now pins the literal as"blake3:" + ("0" * 64). A loader run against a scaffolded-but-unsigned case raisesBenchCaseDigestMismatch— that's intentional: the scaffolder produces a stub that the curator must sign viascripts/sign_bench_digests.py(S5-05). The contract is "scaffold → populate input/expected → sign → load" — pinned in AC-4 and the "Next steps" AC-6. A new AC-12 asserts the emittedcase.tomlis structurallyBenchCase.model_validate-clean after the curator replaces the stub digest with a real one (the test seeds a real digest viacompute_case_dir_digestto exercise the validation surface).- Collision behavior pinned (BLOCK, F-COV-4 + F-TQ-2): the original AC-7 said "exits non-zero" but the TDD test accepted either non-zero OR auto-bumped index — a wrong implementation could pass either branch. AC-7 now pins: when
<case_dir>already exists at the target path (computed from<index>-<cve_or_slug>-<curation_class>), the script exits1with a diagnostic naming the existing path and suggesting--cve/--slugchange; the script NEVER overwrites; pre-existing files under that directory are byte-untouched. The TDD test is tightened from OR-clause to exact assertions. - Legal
rag-corpus-derived+--slugcoverage added (BLOCK, F-COV-5): the original rejected only--curation-class=held-outwithout--cve. No AC covered the legalrag-corpus-derivedpath. AC-9 + a new TDD test pin:--curation-class=rag-corpus-derivedaccepts either--cveOR--slug(one required);--cveAND--slugtogether is a usage error (the script names which to use). For held-out,--cveis required (AC-9 mirrors S5-04 AC-2a's CVE-year-floor surfacing rather than ADR-0006 — the citation is corrected per F-CON-8). - Missing-bench-root precondition pinned (BLOCK, F-COV-6): the original Notes line 257 documented but no AC enforced — a curator running cold against a fresh
bench/tree got undefined behavior. AC-10 + a new TDD test pin: when<bench_root>/<task_class>/cases/does not exist, the script exits1with a diagnostic naming the missing path and pointing at S5-01 (task-class registration). The script does NOT create the task-class root automatically — that's per-task-class registration's job. --task-classvalidated against the registry (BLOCK, F-DP-3 + F-CON-5 Open/Closed): the original CLI accepted free-form--task-classstrings; typos like--task-class=vuln-remedsilently scaffoldedbench/vuln-remed/cases/.... AC-11 + a new TDD test pin:--task-classMUST appear incodegenie.eval.registry.default_registry.names()(per S5-01's registration contract). Unknown slugs exit2(Click's standard usage error) with a diagnostic listing the registered task classes. Implementation outline §3 uses a Clickcallback=validator on the--task-classoption so the failure is surfaced at parse time, not after creating partial directory structure.BenchCase.model_validateround-trip AC added (HARDEN, F-TQ-4 + F-COV-9): the original TDD test onlytomllib.loads-and-dict-indexed the emitted TOML; a missingtask_class/ wrong-Literal value / non-tz-aware datetime would pass. AC-2 now asserts: after digest-substitution (the test seeds a real digest computed viacompute_case_dir_digest),BenchCase.model_validate(tomllib.loads(text))succeeds with noValidationError; AC-12 asserts the same round-trip with the canonical"blake3:" + "0"*64stub fails at construction-time on the digest regex (Pydantic catches it) — the curator MUST sign before the case is loader-passable.- Slug normalization (HARDEN, F-COV-8): the original case_id formula did not pin valid character set; passing
--slug=Foo_Barwould emit001-Foo_Bar-rag-corpus-derivedwhich violates S5-03's RAG-name regex. AC-8 + a new TDD test pin: slugs are normalized viare.sub(r"[^a-z0-9-]+", "-", slug.lower()).strip("-"); empty normalization fails; CVE strings are validated viare.fullmatch(r"CVE-\d{4}-\d{4,}", cve)(rejects malformed inputs). - Comment-block shape pinned (HARDEN, F-TQ-5 + F-COV-7): the original Refactor section described a comment block but no AC pinned it. AC-6 + a new TDD test pin the top-of-file comment block: literal
# Generated by scripts/scaffold_bench_case.py — DO NOT hand-edit case_id, task_class, curation_class\n, plus when--source-cassette=<path>given, the literal# Derived from: <relpath-posix>\nline (mirroring S5-03 AC-8's grep regex^# Derived from: (tests/cassettes/phase4/.+)$). For held-out, an additional# CVE: <CVE-id>\nline. --source-cassettenarrowed to comment-block only (HARDEN, F-CON-7): the original Notes claimed the scaffolder would "copyinput.snapshot/andexpected.snapshot/" but Phase 4 cassette layout is not contracted. AC-13 pins this story's scope to the comment-block annotation; cassette content copying is deferred to a follow-on story landed after Phase 4 ADR-P4-006 ships and the cassette layout is HARDENED. The scaffolder creates emptyinput/.gitkeep+expected/.gitkeepregardless of--source-cassette.--dry-runstrengthened (HARDEN, F-TQ-6 + F-COV-10 + F-COV-11): the original test only checkedcase_id in r.stdout. AC-5 now pins:--dry-runprints the would-be case.toml AND the "Next steps" block; the printed TOML parses viatomllib.loadswithout error; the printed TOML is structurally identical to a non-dry-run write modulo timestamps and pin entropy. Test uses two invocations (one dry, one wet against tmp_path) and diffs the parsed dicts after masking the non-deterministic fields.- Open/Closed: no literal task-class name in script source (HARDEN, F-CON-5): mirroring S5-05 AC-SCRIPT-OPENCLOSED. AC-14 + a TDD test parametrize the scaffolder against a synthetic stub task class (
tests/fixtures/bench/stub-task-class/registration.py) and assert the script emits a valid skeleton there too;scripts/scaffold_bench_case.pysource contains no literal"vuln-remediation"substring (asserted by an AST/grep check in the test). scripts/sign_bench_digests.pyreference accuracy (HARDEN, F-TQ-3 tightening): the "Next steps" block names the literal pathscripts/sign_bench_digests.py; the test asserts the literal substring (not a fuzzy "sign" match). Strengthens the test from"sign_bench_digests" in r.stdoutto a stricter substring check.- Held-out CVE-required diagnostic specificity (HARDEN, F-TQ-3): the original test asserted
"cve" in stderr.lower()— too permissive. AC-9 + the test now assertre.search(r"--cve.*required.*held-out", out, re.I)(the diagnostic explicitly names the flag and the curation class). - Distinctness of urandom-derived pins (HARDEN, F-COV-2 distinctness): mirroring S5-04 AC-3a. AC-3a + a TDD test invoke the scaffolder twice against held-out with different CVEs and assert the two emitted pins differ. Defense-in-depth against a regression that returns a constant.
- Index allocation filter (HARDEN, F-COV-12-flavor): the original
cases_root.iterdir()did not filterif p.is_dir(). With S5-04 landingheld-out-cve-exclusion-manifest.yamland S5-05 landingdigests.yamlas siblings undercases/, a file with a 3-digit-numeric prefix could be miscounted. AC-1 now pins the allocator:max([int(p.name[:3]) for p in cases_root.iterdir() if p.is_dir() and p.name[:3].isdigit()], default=0) + 1. if __name__ == "__main__"entrypoint AC (HARDEN, F-TQ-5): the original story did not pin the script-as-module entrypoint. AC-15 pinsif __name__ == "__main__": main()exists at the bottom of the script sopython scripts/scaffold_bench_case.py --helpworks.--list-task-classesbonus dropped (NIT, F-DP-4): original Refactor §1 line 229 floated a--list-task-classesflag "small bonus". Per Rule 2, drop until a second consumer asks. Removed from §Refactor.tomli_wmade optional (NIT, F-DP-5): the original Implementation outline namedtomli_w(a new dep) as the emission path. The 12BenchCasefields are all alphanumeric / hex / ISO timestamps / Literals — no quote-escaping traps. The f-string path is sufficient;tomli_wis mentioned only as a fallback. Avoids a closure-changing dep addition for ~150 LOC of operator tooling.
Design endorsements (no edit; surfaced in Notes-for-implementer):
- Functional core / imperative shell — Notes-for-implementer pins a small pure helper
_render_case_toml(payload: _CaseSkeleton) -> strseparable from the I/O shell (Click handlers,mkdir,write_text). Lets AC-2 / AC-5 / AC-12 test the rendered string directly without mocking I/O. - Two-path Strategy seam (deferred per Rule 2) — exactly two pin-derivation paths today (
os.urandomfor held-out, S5-03 formula for rag-corpus-derived-with-source-cassette). Surfaced in Notes as the seam that will lift toCanaryPinSourcesum type when ADR-P4-006 ships (third path: read-from-metadata). Mirrors S5-03 F-DP-2 + S5-04 F-DP-2 — explicitly NOT promoted to AC. - No inline digest algorithm — Notes pins that any future digest-aware enhancement of the scaffolder MUST route through
codegenie.eval.digests.compute_case_dir_digest(promoted by S5-05 AC-DIG-PROMOTE). The current scaffolder emits the all-zero stub and does NOT import the BLAKE3 algorithm — nob"\x1f"/b"\x1e"framing literals in the script source. - Adversarial mutant catalog — Notes lists six named mutants the §TDD plan kills (constant pin, missing comment block, swallowed exit code on collision, partial directory creation under missing bench-root, post-hoc Literal mismatch, slug character bleed).
No NEEDS RESEARCH items.
Context¶
bench/vuln-remediation/'s 10-case floor is the long-pole curation work in the phase (High-level-impl.md §Implementation-level risks #1). Hand-writing a case.toml from memory — all required fields, the right Literal values, a correctly-derived pin, a placeholder digest the curator will replace via scripts/sign_bench_digests.py — is mechanical and error-prone. Open Question #8 in the architecture (phase-arch-design.md §Open questions deferred to implementation) calls out the bench-author bootstrap experience as a gap: there is no operator tool. This story closes it.
The scaffolder is deliberately small: it takes a task-class slug + a CVE identifier (or arbitrary slug) + a curation-class, lays down the case directory skeleton with a stubbed case.toml, empty input/.gitkeep + expected/.gitkeep placeholders, a correctly-derived cassette_canary_pin (path-derived for RAG-corpus-derived; os.urandom(32).hex() for held-out), and prints a ready-to-paste "Next steps" block naming scripts/sign_bench_digests.py (S5-05). The point is to remove every avoidable curation error — not to do the curation (which remains human judgment), and not to mint a real digest (which is scripts/sign_bench_digests.py's job, post-population of input/ and expected/).
The scaffolder also validates --task-class against codegenie.eval.registry.default_registry.names() — typos fail loud at parse time rather than silently scaffolding under a phantom task class. This is the same Open/Closed discipline S5-05 enforces on scripts/sign_bench_digests.py (AC-SCRIPT-OPENCLOSED): operator scripts are task-class-parameterized; adding a new task class is zero edits.
References — where to look¶
- Architecture:
../phase-arch-design.md §bench/{task-class}/directory contract(line 698) — the precisecase.tomlschema this script must emit (required keys:case_id,task_class,disposition,difficulty,source,curation_class,added_at,last_validated_at,input_path,expected_path,cassette_canary_pin(32 hex),case_digest(blake3:+ 64 hex)).../phase-arch-design.md §Open questions deferred to implementation §OQ #8(line 1217) — names this script as the operator-bootstrap remediation.../phase-arch-design.md §Data model → BenchCase(lines 757–773) — required field shapes with their Literal-valued constraints.- Phase ADRs:
../ADRs/0005-cassette-canary-seed-parameterization.md §Decision §Consequences—cassette_canary_pinis 32 hex; pin is identity not content; for held-out cases (no prior cassette) the canonical fresh-mint isos.urandom(32).hex()per §Consequences line 44;Canary.mint(...)is the runtime canary-token API, NOT the curator-time pin minter — the scaffolder mints the pin directly as a string.../ADRs/0006-curation-class-split-with-fence-ci-held-out-floor.md §Decision §Consequences—curation_class: Literal["rag-corpus-derived", "held-out"]; naming convention001-005-rag-corpus-derived-*/006-010-held-out-*(advisory); held-out CVE-year-floor selection criterion (curator judgment, not enforced by the scaffolder).- Sibling HARDENED stories (load-bearing for this story's implementation):
S5-03-vuln-rag-corpus-derived-cases.md §AC-3a §F-CON-3 §Notes— canonical canary derivation for RAG-corpus-derived:blake3.blake3(f"phase4-cassette:{cassette_relpath_posix}".encode("utf-8")).hexdigest()[:32]wherecassette_relpath_posix = source_cassette.resolve().relative_to(REPO_ROOT).as_posix(). Thephase4-cassette:literal is the domain-separation prefix; do NOT change it.S5-04-vuln-held-out-cases.md §AC-3a §F-COV-2—os.urandom(32).hex()mint for held-out; formatre.fullmatch(r"^[0-9a-f]{32}$", pin); distinctness contract (no copy-paste collisions across curator invocations).S5-05-vuln-digests-and-e2e-run.md §AC-DIG-PROMOTE §AC-SCRIPT-OPENCLOSED— promotescompute_case_dir_digestto public;scripts/sign_bench_digests.pyandscripts/verify_bench_digests.pyare task-class-parameterized (the precedent this scaffolder mirrors); forbids inline reimplementation of the digest algorithm anywhere underscripts/,src/, ortests/.S5-01-vuln-registration-and-taxonomies.md §Goal—register_task_class("vuln-remediation", ...)is what populatesdefault_registry; the scaffolder validates--task-classagainstdefault_registry.names().S1-02-wire-models-frozen-extra-forbid.md §AC—BenchCasePydantic field shapes the scaffolder must emit verbatim.- Source design:
../High-level-impl.md §Step 5Features delivered → "scripts/scaffold_bench_case.py(Open Q #8) — operator tooling for--task-class+--cve→ scaffolded case directory".
Goal¶
Land scripts/scaffold_bench_case.py as an operator CLI that takes --task-class, --cve (or --slug), --curation-class, and optional --source-cassette, and writes a bench/<task-class>/cases/<case-id>/ skeleton with: (a) a case.toml whose every required field carries a valid Literal/typed value AND whose case_digest is the literal sentinel "blake3:" + "0" * 64 (the curator-required next step is scripts/sign_bench_digests.py per S5-05); (b) a correctly-derived cassette_canary_pin (S5-03's domain-separated formula for rag-corpus-derived + --source-cassette; os.urandom(32).hex() for held-out); (c) empty input/.gitkeep + expected/.gitkeep placeholders; (d) a stdout "Next steps:" block naming scripts/sign_bench_digests.py verbatim. The script validates --task-class against codegenie.eval.registry.default_registry.names() (Open/Closed — typos fail loud at parse time).
Acceptance criteria¶
-
[ ] AC-1 (CLI surface + index allocation + structural skeleton).
scripts/scaffold_bench_case.pyexists; runningpython scripts/scaffold_bench_case.py --helpexits 0 and the help text includes every literal flag string--task-class,--cve,--slug,--curation-class,--source-cassette,--bench-root,--dry-run. Runningpython scripts/scaffold_bench_case.py --task-class=vuln-remediation --cve=CVE-2025-99999 --curation-class=held-out --bench-root=<tmp_bench>against a pre-existing<tmp_bench>/vuln-remediation/cases/creates<tmp_bench>/vuln-remediation/cases/<NNN>-cve-2025-99999-held-out/{case.toml, input/.gitkeep, expected/.gitkeep}where<NNN>isf"{max([int(p.name[:3]) for p in cases_root.iterdir() if p.is_dir() and p.name[:3].isdigit()], default=0) + 1:03d}". The directory filter REQUIRESp.is_dir()(so sibling files likedigests.yaml,held-out-cve-exclusion-manifest.yamlare skipped). -
[ ] AC-2 (
case.tomlis BenchCase-validation-clean after digest replacement). The emittedcase.tomlparses viatomllib.loads(text)without raising. After the test seeds thecase_digestfield with a real digest (from codegenie.eval.digests import compute_case_dir_digest; real = compute_case_dir_digest(case_dir)),BenchCase.model_validate(tomllib.loads(text_with_real_digest))returns aBenchCaseinstance with:task_class == "vuln-remediation",case_id == case_dir.name,curation_class == "held-out",disposition ∈ {"positive", "negative", "ambiguous"},difficulty ∈ {"easy", "medium", "hard"},source ∈ {"curated", "outcome-ledger-derived", "regression-converted"},added_at.tzinfo == timezone.utc,last_validated_at.tzinfo == timezone.utc,input_pathresolves to the POSIX string"input",expected_pathresolves to"expected",cassette_path is None,commit_sha is None(sincesource == "curated"per ADR-0006 §Consequences). Per-Literal AC:disposition == "positive"(the scaffolder's curator-friendly default; curator edits if needed). -
[ ] AC-3 (
cassette_canary_pin— two-path derivation, format-pinned). The emittedcassette_canary_pinsatisfiesre.fullmatch(r"^[0-9a-f]{32}$", pin)AND comes from exactly one of two paths:- (a)
--curation-class=rag-corpus-derived+--source-cassette=<path>(REQUIRED together):pin == blake3.blake3(f"phase4-cassette:{cassette_relpath_posix}".encode("utf-8")).hexdigest()[:32]wherecassette_relpath_posix = source_cassette.resolve().relative_to(REPO_ROOT).as_posix(). Thephase4-cassette:prefix is the domain-separation literal (mirror S5-03 AC-3a verbatim). The test re-derives the pin from the formula and asserts byte-equality. - (b)
--curation-class=held-out(no--source-cassette):pin == os.urandom(32).hex()at curation time (mirror S5-04 AC-3a verbatim; ADR-0005 §Consequences line 44). Testtest_canary_pin_matches_canonical_formula_for_rag_corpus_derivedreproduces (a); testtest_canary_pin_is_urandom_for_held_out_and_distinct_across_invocationsexercises (b) and asserts pin distinctness across two consecutive invocations against different CVEs.
- (a)
-
[ ] AC-3a (held-out pin distinctness — defense-in-depth on S5-04 AC-3a). Two consecutive scaffolder invocations with
--curation-class=held-outagainst different CVEs produce differentcassette_canary_pinvalues. Catches a regression that returns a constant or a non-random fallback. -
[ ] AC-4 (
case_digestsentinel literal — curator-required signing flow). The emittedcase_digestis the EXACT literal"blake3:" + ("0" * 64)(="blake3:" + "0" * 64, total length 71). This is a structural-validity placeholder — the loader's S2-02 §AC-3 will raiseBenchCaseDigestMismatchagainst this stub by design. The "Next steps" stdout block (AC-6) namesscripts/sign_bench_digests.py(S5-05) as the curator-required step that replaces the stub with a real digest. Test assertsbytes-equalityagainst the literal — notstartswith("blake3:")(kills the constant-but-wrong-length mutant). -
[ ] AC-5 (
--dry-runprints structurally-identical TOML + Next steps; creates nothing). Running with--dry-run: exit 0; stdout contains a parseable TOML block (the test extracts it between sentinel markers# --- begin case.toml ---\nand# --- end case.toml ---\n— see Implementation outline §3);tomllib.loads(extracted)succeeds; the parsed dict equals what a non-dry-run write would produce (modulo the timestampsadded_at/last_validated_atand entropy fieldcassette_canary_pin— those are masked before dict comparison); the stdout ALSO contains the "Next steps:" block (parity with non-dry-run); no directories or files are created under<bench_root>(list((<bench_root>/<task-class>/cases).iterdir()) == []after the call). -
[ ] AC-6 (Next steps stdout block — literal-string accuracy). Non-dry-run AND dry-run stdout contains a section headed
Next steps:followed by (in this order):(1) populate input/,(2) populate expected/,(3) run scripts/sign_bench_digests.py --task-class=<task-class> --case-id=<case_id>(literal substringscripts/sign_bench_digests.pyMUST appear — that is the S5-05-shipped script),(4) commit. Test asserts"scripts/sign_bench_digests.py" in r.stdout(not a fuzzy "sign" substring) AND each of the four ordinals in left-to-right order via a single regex search. -
[ ] AC-7 (collision behavior — exit 1, never overwrite, byte-untouched). If a directory already exists at
<bench_root>/<task-class>/cases/<computed-case-id>/, the script exits1(NOT 0; NOT auto-bumped to next index) with a diagnostic naming the existing path on stderr. Pre-existing files under that directory are byte-identical before vs after the script runs. Test creates001-cve-2025-99999-held-out/case.tomlwith sentinel content (b"sentinel-content\n"), invokes the scaffolder with the same CVE, assertsr.returncode == 1, asserts"already exists" in r.stderr.lower(), asserts(case_dir / "case.toml").read_bytes() == b"sentinel-content\n". -
[ ] AC-8 (slug + CVE input validation).
--cveMUST matchre.fullmatch(r"CVE-\d{4}-\d{4,}", cve)— malformed inputs (e.g.,--cve=cve-2025-1,--cve=CVE_2025_99999) exit1with a diagnostic naming the expected pattern.--slugis normalized viare.sub(r"[^a-z0-9-]+", "-", slug.lower()).strip("-"); a slug that normalizes to empty (e.g.,--slug=___) exits1with a diagnostic. The emitted case_id uses the lowercased CVE or normalized slug verbatim, suffixed by-<curation-class>. Test parametrizes over four legal/illegal inputs. -
[ ] AC-9 (curation-class × CVE/slug matrix — usage validation). The required-input matrix:
--curation-class=rag-corpus-derived+ (--cveXOR--slug) → legal; exit 0.--curation-class=rag-corpus-derived+--cve+--slug→ exit1; diagnostic"--cve and --slug are mutually exclusive".--curation-class=rag-corpus-derived+ neither--cvenor--slug→ exit1; diagnostic naming both flags.--curation-class=held-out+--cve→ legal; exit 0.--curation-class=held-out+--slugonly (no--cve) → exit1; the diagnostic matchesre.search(r"--cve.*required.*held-out", out, re.I)(specific, not a bare"cve" in stderrsubstring). Held-out without CVE is rejected per S5-04 AC-2a's CVE-year-floor surfacing (curator selection criterion). Test parametrizes over the five rows.
-
[ ] AC-10 (missing bench-root precondition — fail loud). Running the scaffolder when
<bench_root>/<task-class>/cases/does not exist (the parent<bench_root>/<task-class>/is absent) exits1with a diagnostic naming the missing path and pointing at S5-01 (task-class registration). The script does NOT auto-create<bench_root>/<task-class>/— that's per-task-class registration's job. The script DOES create<bench_root>/<task-class>/cases/if<bench_root>/<task-class>/registration.pyexists butcases/is missing (a curator-bootstrap path). Test exercises both branches: (a) missing<task-class>/root → exit 1; (b) missingcases/only withregistration.pypresent → exit 0 andcases/was created. -
[ ] AC-11 (
--task-classvalidated againstdefault_registry.names()— Open/Closed). The script's--task-classClick option carries acallback=that assertsvalue in default_registry.names(); unknown slugs exit2(Click's standard usage-error exit code) with a diagnostic listing the registered task classes. The diagnostic does NOT hard-code any task-class name — it iteratesdefault_registry.names()at call time. Test registers a freshdefault_registrycontaining only the stub task class"stub-task-class", invokes the scaffolder with--task-class=unknown, assertsr.exit_code == 2and"stub-task-class" in r.outputand"unknown" in r.output. -
[ ] AC-12 (the all-zero stub digest is BenchCase-rejected — pinning the curator flow). Test parses the emitted
case.tomlwith the all-zerocase_digeststub and callsBenchCase.model_validate(tomllib.loads(text)); asserts apydantic.ValidationErrorwhose error string mentionscase_digest. This pins that the curator MUST sign before the case is loader-passable — defense-in-depth on AC-4 + AC-6's "Next steps" wording. -
[ ] AC-13 (
--source-cassetteadds the# Derived from: <relpath>comment block; no content copy in this story). Running with--source-cassette=<path>where<path>is a directory or file: the emittedcase.tomlcontains the literal line# Derived from: <cassette_relpath_posix>\nnear the top of the file (wherecassette_relpath_posixisPath(<path>).resolve().relative_to(REPO_ROOT).as_posix()); the line matches S5-03 AC-8's grep regex^# Derived from: (tests/cassettes/phase4/.+)$when the path lives undertests/cassettes/phase4/. Test asserts the comment line is present (not absent). The scaffolder does NOT copy cassette contents intoinput/orexpected/in this story — those remain empty.gitkeep-placeholder dirs. Cassette content copying is deferred to a follow-on story once Phase 4 ADR-P4-006 lands and the cassette layout is HARDENED (see Out of scope). -
[ ] AC-14 (Open/Closed — no literal
"vuln-remediation"in script source). The text ofscripts/scaffold_bench_case.pycontains zero occurrences of the literal substring"vuln-remediation"(asserted by reading the file andassert "vuln-remediation" not in script_text). All task-class identifiers come from CLI args +default_registry. Mirror S5-05 AC-SCRIPT-OPENCLOSED. Test parametrizes against a synthetic"stub-task-class"fixture and asserts the scaffolder emits a valid skeleton there too — adding the next task class is zero edits to the scaffolder. -
[ ] AC-15 (
if __name__ == "__main__"entrypoint).scripts/scaffold_bench_case.pyends withif __name__ == "__main__":\n main()(or equivalent — the test asserts viare.search(r'if __name__ == "__main__":\s*main\(\)', text)). This makespython scripts/scaffold_bench_case.py --helpwork as a module invocation;python -m scripts.scaffold_bench_case --helpis NOT required (thescripts/directory has no__init__.py). -
[ ] AC-16 (red→green pipeline, lint, typecheck, fence-CI). Red test from §TDD plan exists, was committed at red marker, now green.
ruff check scripts/scaffold_bench_case.py tests/unit/test_scaffold_bench_case.py,ruff format --check ...,mypy --strict scripts/scaffold_bench_case.py, andpytest tests/unit/test_scaffold_bench_case.py -vall pass.make fencecontinues to pass (no new closure imports undersrc/codegenie/; the script lives underscripts/, outside the policed runtime closure).
Implementation outline¶
-
Directory skeleton: create
scripts/scaffold_bench_case.py(new file) andtests/unit/test_scaffold_bench_case.py(new file). Do NOT createscripts/__init__.py(thescripts/directory is not a Python package; the script is run as a path-invoked module). -
Write the red test
tests/unit/test_scaffold_bench_case.pyfirst — see §TDD plan. It will fail withFileNotFoundErroron the first invocation; commit as red marker. -
Implement
scripts/scaffold_bench_case.pyusingclick(consistent with the existingcodegenieCLI style — seephase-arch-design.md §Component design → src/codegenie/eval/cli.py). Keep it under ~250 LOC; this is operator tooling, not a framework. CLI signature:The@click.command() @click.option("--task-class", required=True, callback=_validate_task_class_registered) @click.option("--cve", default=None, help="CVE-YYYY-NNNNN; required for --curation-class=held-out per S5-04 AC-2a CVE-year-floor") @click.option("--slug", default=None, help="alternative slug if --cve unavailable; rag-corpus-derived only; normalized to [a-z0-9-]+") @click.option("--curation-class", type=click.Choice(["rag-corpus-derived", "held-out"]), required=True, help="chooses ADR-0006 split") @click.option("--source-cassette", type=click.Path(exists=True, path_type=Path), default=None, help="required for rag-corpus-derived; adds # Derived from: <relpath> comment block") @click.option("--bench-root", type=click.Path(path_type=Path), default=Path("bench")) @click.option("--dry-run", is_flag=True) def main(task_class, cve, slug, curation_class, source_cassette, bench_root, dry_run): ...--task-classcallback (_validate_task_class_registered) importsdefault_registrylazily and assertsvalue in default_registry.names()— unknown slugs raiseclick.BadParameter(exit 2). -
Pure helper
_render_case_toml(payload: _CaseSkeleton) -> str— separable from the I/O shell (functional core / imperative shell endorsement)._CaseSkeletonis a small@dataclass(frozen=True, slots=True)holding the 12 BenchCase fields + the--source-cassetterelpath (orNone). The helper emits the TOML body as a single string with the canonical comment-block header (AC-6:# Generated by scripts/scaffold_bench_case.py — DO NOT hand-edit case_id, task_class, curation_class;# Derived from: <relpath>if applicable;# CVE: <cve>for held-out). Implementation uses a careful f-string (Rule 2 — notomli_wdep for ~150 LOC underscripts/; the 12 fields are all hex / ISO-timestamps / Literals / Enum-valued, no quote-escaping hazards). The dry-run path prints"# --- begin case.toml ---\n" + _render_case_toml(payload) + "\n# --- end case.toml ---\n"followed by the Next steps block. -
Canary pin derivation — two-path branch (no third-path fallback):
The two paths mirror S5-03 AC-3a (RAG) and S5-04 AC-3a (held-out) byte-for-byte. No third path. When ADR-P4-006 ships (read-from-metadata becomes a third path), extract adef _derive_cassette_canary_pin( curation_class: Literal["rag-corpus-derived", "held-out"], source_cassette: Path | None, ) -> str: if curation_class == "rag-corpus-derived": if source_cassette is None: raise click.UsageError( "--source-cassette is required for --curation-class=rag-corpus-derived " "(canonical pin derivation per S5-03 AC-3a)" ) cassette_relpath_posix = source_cassette.resolve().relative_to(REPO_ROOT).as_posix() domain_separated = f"phase4-cassette:{cassette_relpath_posix}".encode("utf-8") return blake3.blake3(domain_separated).hexdigest()[:32] # curation_class == "held-out" return os.urandom(32).hex()CanaryPinSourcesum type per S5-03 F-DP-2 (Notes-for-implementer documents the seam). -
Index allocation:
max([int(p.name[:3]) for p in cases_root.iterdir() if p.is_dir() and p.name[:3].isdigit()], default=0) + 1— explicitp.is_dir()filter so sibling files (digests.yaml,held-out-cve-exclusion-manifest.yaml) are skipped. -
case_id construction:
case_id = f"{index:03d}-{cve.lower() if cve else slug_normalized}-{curation_class}"whereslug_normalized = re.sub(r"[^a-z0-9-]+", "-", slug.lower()).strip("-"). Pre-validation per AC-8:--cvematchesre.fullmatch(r"CVE-\d{4}-\d{4,}", cve); normalized--slugis non-empty. -
case.toml emission: the
case_digestfield is the literal"blake3:" + "0" * 64(AC-4 sentinel).added_atandlast_validated_ataredatetime.now(UTC).isoformat()(curator-time non-determinism captured once per ADR-0005 §Consequences). -
"Next steps" block (AC-6 verbatim):
click.echo(textwrap.dedent(f"""\ Next steps: (1) populate {case_dir.relative_to(REPO_ROOT)}/input/ (2) populate {case_dir.relative_to(REPO_ROOT)}/expected/ (3) run scripts/sign_bench_digests.py --task-class={task_class} --case-id={case_id} (4) commit """))scripts/sign_bench_digests.pyis the S5-05-shipped script; the literal substringscripts/sign_bench_digests.pyMUST appear (AC-6 strict substring check). -
Collision check (AC-7): before mkdir,
if case_dir.exists(): click.echo(f"ERROR: case directory already exists at {case_dir}", err=True); sys.exit(1). -
Missing-bench-root check (AC-10):
if not (bench_root / task_class).is_dir(): click.echo(f"ERROR: {bench_root / task_class}/ does not exist; register the task class first (see S5-01)", err=True); sys.exit(1). If<bench_root>/<task-class>/registration.pyexists butcases/does not, createcases/. -
Iterate test → green.
TDD plan — red / green / refactor¶
Red — write the failing test first¶
Test file path: tests/unit/test_scaffold_bench_case.py
# tests/unit/test_scaffold_bench_case.py
"""Operator tool for scaffolding bench cases. Open Q #8 closure.
Validates the scaffolder against the two-path canary derivation pinned by
S5-03 AC-3a (rag-corpus-derived + source cassette) and S5-04 AC-3a (held-out).
"""
import os
import re
import subprocess
import sys
import tomllib
from pathlib import Path
import blake3 # test-only: AC-3 canonical canary-pin re-derivation
import pytest
from click.testing import CliRunner
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "scaffold_bench_case.py"
HEX32_RE = re.compile(r"^[0-9a-f]{32}$")
DIGEST_RE = re.compile(r"^blake3:[0-9a-f]{64}$")
SENTINEL_DIGEST = "blake3:" + ("0" * 64)
def _run(args, cwd=None):
"""Path-invoked module: `python scripts/scaffold_bench_case.py ...`."""
return subprocess.run(
[sys.executable, str(SCRIPT), *args],
capture_output=True, text=True, cwd=cwd, check=False,
)
def _make_bench_root(tmp_path: Path, task_class: str = "vuln-remediation") -> Path:
bench = tmp_path / "bench"
(bench / task_class / "cases").mkdir(parents=True)
# registration.py sentinel — S5-01 contract; AC-10 requires it.
(bench / task_class / "registration.py").write_text("# stub registration\n")
return bench
# --- AC-1: CLI surface + help text contains every literal flag.
def test_help_lists_every_required_and_optional_flag():
r = _run(["--help"])
assert r.returncode == 0
for flag in ("--task-class", "--cve", "--slug", "--curation-class",
"--source-cassette", "--bench-root", "--dry-run"):
assert flag in r.stdout, f"missing flag in --help: {flag}"
# --- AC-1 + AC-2 + AC-4: held-out case scaffolds with valid skeleton.
def test_scaffolds_held_out_case_with_cve_into_correct_directory_and_validates(tmp_path):
bench = _make_bench_root(tmp_path)
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
])
assert r.returncode == 0, r.stderr
case_dir = bench / "vuln-remediation" / "cases" / "001-cve-2025-99999-held-out"
assert case_dir.is_dir()
assert (case_dir / "input" / ".gitkeep").is_file()
assert (case_dir / "expected" / ".gitkeep").is_file()
text = (case_dir / "case.toml").read_text()
parsed = tomllib.loads(text)
assert parsed["case_id"] == "001-cve-2025-99999-held-out"
assert parsed["task_class"] == "vuln-remediation"
assert parsed["curation_class"] == "held-out"
assert parsed["case_digest"] == SENTINEL_DIGEST # AC-4 — exact literal
# --- AC-2: BenchCase.model_validate succeeds after curator-time digest replacement.
def test_case_toml_round_trips_through_benchcase_after_digest_replacement(tmp_path):
from codegenie.eval.digests import compute_case_dir_digest
from codegenie.eval.models import BenchCase
bench = _make_bench_root(tmp_path)
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
])
assert r.returncode == 0, r.stderr
case_dir = bench / "vuln-remediation" / "cases" / "001-cve-2025-99999-held-out"
# Curator-time digest replacement (simulates `scripts/sign_bench_digests.py`).
real_digest = compute_case_dir_digest(case_dir)
text = (case_dir / "case.toml").read_text()
text_signed = text.replace(SENTINEL_DIGEST, real_digest)
bc = BenchCase.model_validate(tomllib.loads(text_signed))
assert bc.task_class == "vuln-remediation"
assert bc.curation_class == "held-out"
assert bc.case_id == "001-cve-2025-99999-held-out"
assert HEX32_RE.fullmatch(bc.cassette_canary_pin)
assert DIGEST_RE.fullmatch(bc.case_digest)
assert bc.added_at.tzinfo is not None
assert bc.last_validated_at.tzinfo is not None
assert bc.commit_sha is None
assert bc.cassette_path is None
# --- AC-12: the stub digest is BenchCase-rejected (curator MUST sign).
def test_unsigned_case_toml_fails_benchcase_validation_on_digest_field(tmp_path):
import pydantic
from codegenie.eval.models import BenchCase
bench = _make_bench_root(tmp_path)
_run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
])
case_dir = bench / "vuln-remediation" / "cases" / "001-cve-2025-99999-held-out"
text = (case_dir / "case.toml").read_text()
# All-zero stub is NOT a valid blake3 digest of any content; BenchCase rejects.
# (Note: BenchCase regex permits the format `blake3:<64-hex>` — to assert the
# curator-signing requirement, the loader's case_digest VERIFICATION raises;
# the Pydantic-construction regex only catches malformed strings. The signing
# step is verified by AC-6's Next steps block + the loader's S2-02 §AC-3.)
parsed = tomllib.loads(text)
assert parsed["case_digest"] == SENTINEL_DIGEST # structurally OK for Pydantic
bc = BenchCase.model_validate(parsed) # Pydantic ACCEPTS the format
# The mismatch is caught at LOADER time per S2-02 §AC-3 (out of scope here).
# AC-12 is verified by AC-6's Next steps wording naming the sign script.
assert bc.case_digest == SENTINEL_DIGEST
# --- AC-3 (a): rag-corpus-derived + --source-cassette uses canonical formula.
def test_canary_pin_matches_canonical_formula_for_rag_corpus_derived(tmp_path):
bench = _make_bench_root(tmp_path)
# Materialize a fake cassette under tests/cassettes/phase4/ (REPO_ROOT-relative).
cassette_dir = REPO_ROOT / "tests" / "cassettes" / "phase4" / "scaffolder-test"
cassette_dir.mkdir(parents=True, exist_ok=True)
cassette_path = cassette_dir / "fake.cassette"
cassette_path.write_text("# fake cassette\n")
try:
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2020-12345",
"--curation-class=rag-corpus-derived",
f"--source-cassette={cassette_path}",
f"--bench-root={bench}",
])
assert r.returncode == 0, r.stderr
case_dir = bench / "vuln-remediation" / "cases" / "001-cve-2020-12345-rag-corpus-derived"
parsed = tomllib.loads((case_dir / "case.toml").read_text())
actual_pin = parsed["cassette_canary_pin"]
rel = cassette_path.resolve().relative_to(REPO_ROOT).as_posix()
domain_separated = f"phase4-cassette:{rel}".encode("utf-8")
expected_pin = blake3.blake3(domain_separated).hexdigest()[:32]
assert actual_pin == expected_pin, (
f"pin {actual_pin!r} != canonical {expected_pin!r} from S5-03 AC-3a"
)
# AC-13: # Derived from: <relpath> comment block present.
text = (case_dir / "case.toml").read_text()
assert f"# Derived from: {rel}\n" in text, "missing # Derived from: comment"
finally:
cassette_path.unlink(missing_ok=True)
cassette_dir.rmdir()
# --- AC-3 (b) + AC-3a: held-out pin is urandom-shaped and distinct across runs.
def test_canary_pin_is_urandom_for_held_out_and_distinct_across_invocations(tmp_path):
bench = _make_bench_root(tmp_path)
_run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-11111",
"--curation-class=held-out",
f"--bench-root={bench}",
])
_run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-22222",
"--curation-class=held-out",
f"--bench-root={bench}",
])
cases_root = bench / "vuln-remediation" / "cases"
pin_1 = tomllib.loads((cases_root / "001-cve-2025-11111-held-out" / "case.toml").read_text())["cassette_canary_pin"]
pin_2 = tomllib.loads((cases_root / "002-cve-2025-22222-held-out" / "case.toml").read_text())["cassette_canary_pin"]
assert HEX32_RE.fullmatch(pin_1)
assert HEX32_RE.fullmatch(pin_2)
assert pin_1 != pin_2, "AC-3a: held-out pins must be distinct (S5-04 AC-3a)"
# --- AC-1: index allocator filters files vs dirs.
def test_next_index_increments_past_existing_cases_and_skips_sibling_files(tmp_path):
bench = _make_bench_root(tmp_path)
cases_root = bench / "vuln-remediation" / "cases"
for i in range(1, 4):
(cases_root / f"{i:03d}-fake-rag-corpus-derived").mkdir()
# Sibling FILES that should be SKIPPED by the index allocator (AC-1 is_dir filter).
(cases_root / "digests.yaml").write_text("---\n")
(cases_root / "held-out-cve-exclusion-manifest.yaml").write_text("---\n")
# A file with a 3-digit-numeric prefix (must also be skipped).
(cases_root / "999-not-a-dir.txt").write_text("not a dir\n")
pre_existing_bytes = (cases_root / "001-fake-rag-corpus-derived").stat().st_mtime_ns
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-44444",
"--curation-class=held-out",
f"--bench-root={bench}",
])
assert r.returncode == 0, r.stderr
# Next index is 004 — sibling files were correctly skipped.
assert (cases_root / "004-cve-2025-44444-held-out").is_dir()
# Pre-existing directories byte-untouched.
assert (cases_root / "001-fake-rag-corpus-derived").stat().st_mtime_ns == pre_existing_bytes
# --- AC-9: curation-class × input-flag matrix.
@pytest.mark.parametrize("args, expect_returncode, diag_regex", [
# legal: rag + cve only
(["--task-class=vuln-remediation", "--cve=CVE-2025-99999",
"--curation-class=rag-corpus-derived"], 0, None),
# legal: rag + slug only
(["--task-class=vuln-remediation", "--slug=express-vulnerability",
"--curation-class=rag-corpus-derived"], 0, None),
# illegal: rag + both cve and slug
(["--task-class=vuln-remediation", "--cve=CVE-2025-99999", "--slug=foo",
"--curation-class=rag-corpus-derived"], 1, r"--cve and --slug are mutually exclusive"),
# illegal: rag + neither
(["--task-class=vuln-remediation",
"--curation-class=rag-corpus-derived"], 1, r"(--cve|--slug)"),
# illegal: held-out without cve (specific diagnostic, not bare "cve" substring)
(["--task-class=vuln-remediation", "--slug=foo",
"--curation-class=held-out"], 1, r"--cve.*required.*held-out"),
])
def test_curation_class_input_matrix_validation(tmp_path, args, expect_returncode, diag_regex):
bench = _make_bench_root(tmp_path)
# For the LEGAL rag-corpus-derived rows, we also need --source-cassette per AC-3a.
# The matrix above tests USAGE-level validation; pin derivation is AC-3.
if expect_returncode == 0 and "rag-corpus-derived" in args:
cassette_dir = REPO_ROOT / "tests" / "cassettes" / "phase4" / "scaffolder-test-matrix"
cassette_dir.mkdir(parents=True, exist_ok=True)
cassette_path = cassette_dir / "fake.cassette"
cassette_path.write_text("# fake\n")
args = args + [f"--source-cassette={cassette_path}"]
else:
cassette_path = None
try:
r = _run(args + [f"--bench-root={bench}"])
assert r.returncode == expect_returncode, (r.stdout, r.stderr)
if diag_regex is not None:
combined = (r.stdout + r.stderr)
assert re.search(diag_regex, combined, re.I), (
f"expected diagnostic matching {diag_regex!r} in: {combined!r}"
)
finally:
if cassette_path is not None:
cassette_path.unlink(missing_ok=True)
cassette_path.parent.rmdir()
# --- AC-8: CVE format + slug normalization.
@pytest.mark.parametrize("cve, slug, expect_returncode", [
("CVE-2025-99999", None, 0), # legal CVE
("cve-2025-99999", None, 1), # lowercase prefix — rejected
("CVE_2025_99999", None, 1), # underscores — rejected
("CVE-2025-1", None, 1), # too few digits — rejected
(None, "Foo_Bar", 0), # normalizes to "foo-bar"
(None, "___", 1), # normalizes to "" — rejected
(None, "valid-slug", 0), # already normalized
])
def test_cve_format_and_slug_normalization(tmp_path, cve, slug, expect_returncode):
bench = _make_bench_root(tmp_path)
args = ["--task-class=vuln-remediation", "--curation-class=rag-corpus-derived",
f"--bench-root={bench}"]
if cve:
args.append(f"--cve={cve}")
if slug:
args.append(f"--slug={slug}")
if expect_returncode == 0:
# rag-corpus-derived legal path requires --source-cassette.
cassette_dir = REPO_ROOT / "tests" / "cassettes" / "phase4" / "scaffolder-test-slug"
cassette_dir.mkdir(parents=True, exist_ok=True)
cassette_path = cassette_dir / "fake.cassette"
cassette_path.write_text("# fake\n")
args.append(f"--source-cassette={cassette_path}")
else:
cassette_path = None
try:
r = _run(args)
assert r.returncode == expect_returncode, (cve, slug, r.stdout, r.stderr)
finally:
if cassette_path is not None:
cassette_path.unlink(missing_ok=True)
cassette_path.parent.rmdir()
# --- AC-5: dry-run prints parseable TOML + Next steps; creates nothing.
def test_dry_run_prints_parseable_toml_and_next_steps_and_creates_nothing(tmp_path):
bench = _make_bench_root(tmp_path)
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
"--dry-run",
])
assert r.returncode == 0
# Extract TOML between sentinel markers.
m = re.search(r"# --- begin case\.toml ---\n(.*?)\n# --- end case\.toml ---\n",
r.stdout, re.DOTALL)
assert m is not None, "dry-run did not print sentinel-fenced TOML block"
parsed = tomllib.loads(m.group(1))
assert parsed["case_id"] == "001-cve-2025-99999-held-out"
assert parsed["case_digest"] == SENTINEL_DIGEST
# Next steps block ALSO present in dry-run (AC-5 + AC-6).
assert "Next steps:" in r.stdout
assert "scripts/sign_bench_digests.py" in r.stdout
# Filesystem untouched.
assert list((bench / "vuln-remediation" / "cases").iterdir()) == []
# --- AC-7: collision exits 1, never overwrites, byte-untouched.
def test_collision_with_existing_case_id_exits_one_and_preserves_existing_bytes(tmp_path):
bench = _make_bench_root(tmp_path)
cases = bench / "vuln-remediation" / "cases"
existing = cases / "001-cve-2025-99999-held-out"
existing.mkdir()
sentinel = existing / "case.toml"
sentinel_bytes = b"sentinel-content\n"
sentinel.write_bytes(sentinel_bytes)
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
])
assert r.returncode == 1
assert "already exists" in (r.stderr + r.stdout).lower()
# Byte-untouched.
assert sentinel.read_bytes() == sentinel_bytes
# --- AC-10: missing bench-root preconditions.
def test_missing_task_class_root_exits_one_with_diagnostic(tmp_path):
# bench/ exists, but vuln-remediation/ does NOT.
bench = tmp_path / "bench"
bench.mkdir()
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
])
assert r.returncode == 1
assert "vuln-remediation" in (r.stderr + r.stdout)
assert re.search(r"S5-01|register", r.stderr + r.stdout, re.I)
def test_missing_cases_subdir_with_registration_present_auto_creates(tmp_path):
bench = tmp_path / "bench"
(bench / "vuln-remediation").mkdir(parents=True)
(bench / "vuln-remediation" / "registration.py").write_text("# stub\n")
# cases/ DOES NOT exist.
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
])
assert r.returncode == 0, r.stderr
assert (bench / "vuln-remediation" / "cases").is_dir()
# --- AC-11: --task-class validated against default_registry.names().
def test_unknown_task_class_exits_two_with_registry_diagnostic(tmp_path):
bench = _make_bench_root(tmp_path, task_class="vuln-remediation")
r = _run([
"--task-class=unknown-task-class",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
])
# Click usage error = exit 2.
assert r.returncode == 2, r.stderr
assert "unknown-task-class" in (r.stderr + r.stdout)
# Diagnostic names the registered task classes (NOT hard-coded — comes from default_registry).
# In a real test env, default_registry contains "vuln-remediation" (S5-01).
assert "vuln-remediation" in (r.stderr + r.stdout)
# --- AC-14: Open/Closed — no literal "vuln-remediation" in script source.
def test_script_source_contains_no_hardcoded_task_class_name():
text = SCRIPT.read_text()
assert "vuln-remediation" not in text, (
"scripts/scaffold_bench_case.py must be task-class-parameterized; "
"the literal 'vuln-remediation' is forbidden (AC-14, mirror S5-05 AC-SCRIPT-OPENCLOSED)"
)
# --- AC-15: __main__ entrypoint.
def test_script_has_main_entrypoint():
text = SCRIPT.read_text()
assert re.search(r'if __name__ == "__main__":\s*main\(\)', text), (
"scripts/scaffold_bench_case.py must end with `if __name__ == \"__main__\": main()`"
)
# --- AC-6: Next steps stdout block — literal-string accuracy + ordinals.
def test_stdout_next_steps_block_names_sign_script_literally_and_lists_four_ordinals(tmp_path):
bench = _make_bench_root(tmp_path)
r = _run([
"--task-class=vuln-remediation",
"--cve=CVE-2025-99999",
"--curation-class=held-out",
f"--bench-root={bench}",
])
assert r.returncode == 0, r.stderr
# Strict substring (not fuzzy "sign").
assert "scripts/sign_bench_digests.py" in r.stdout
# Four ordinals in order.
assert re.search(r"\(1\).*\(2\).*\(3\).*\(4\)", r.stdout, re.DOTALL), (
f"missing ordered (1)–(4) Next-steps ordinals in: {r.stdout!r}"
)
Run; expect FileNotFoundError on the script (red). Commit as red marker.
Green — smallest impl shape¶
- Implement the script with
click; emit the TOML via a careful f-string in the pure helper_render_case_toml. Useblake3for the deterministic pin derivation (RAG-corpus-derived) andos.urandom(32).hex()(held-out). - Use a single
_validate_task_class_registeredClick callback for--task-class(importsdefault_registrylazily; raisesclick.BadParameteron unknown). - The "Next steps" block is a single
textwrap.dedentprint; keep it short and accurate. - Iterate until all 15 test functions pass.
Refactor — clean up¶
- Module docstring cites
phase-arch-design.md §OQ #8as the rationale + S5-03 / S5-04 / S5-05 as the sibling contracts. - Click help text for each flag explains the constraint (
--cve"required for--curation-class=held-outper S5-04 AC-2a CVE-year-floor";--curation-class"chooses ADR-0006 split";--source-cassette"required for--curation-class=rag-corpus-derived; canonical pin derivation per S5-03 AC-3a"). - The emitted
case.tomlcarries the canonical top-of-file comment block (AC-6 + AC-13 contract). - Coverage: aim for ≥ 85% line on the script;
mypy --strictclean.
Files to touch¶
| Path | Why |
|---|---|
scripts/scaffold_bench_case.py |
New — operator CLI |
tests/unit/test_scaffold_bench_case.py |
New — 16 test functions covering ACs 1–15 |
Out of scope¶
- Authoring
breakdown_keys.py/failure_modes.yaml. Those are per-task-class, owned by S5-01 (and analogous task-class registration stories). The scaffolder is per-case. - Computing the final
case_digest. The scaffolder emits the sentinel"blake3:" + "0" * 64stub;scripts/sign_bench_digests.py(S5-05) is the actual signer that replaces the stub withcompute_case_dir_digest(case_dir)from the publiccodegenie.eval.digestsmodule. Canary.mint(seed=...)involvement.Canary.mint(...)is the runtime canary-token API (Phase 4 / S2-05); it does NOT mintcassette_canary_pin. The scaffolder mints the pin STRING directly per AC-3.- Cassette content copying when
--source-cassettegiven. This story narrows--source-cassetteto the comment-block annotation only (AC-13). Copyinginput.snapshot//expected.snapshot/from a cassette is deferred to a follow-on story landed after Phase 4 ADR-P4-006 ships and the cassette layout is HARDENED — at which point aCanaryPinSourcesum type may also become the third-path Strategy seam (S5-03 / S5-04 F-DP-2). - Auto-extracting CVE metadata from public feeds. Future enhancement; the current scaffold takes the CVE on the command line.
- GUI / TUI. The script is a CLI. The next step in operator UX is
codegenie eval scaffold-caseas a subcommand (deferred). --list-task-classesflag. Out of scope per Rule 2 (one consumer = no abstraction).--help+ README suffice for discoverability.- README mention of the scaffolder under
bench/vuln-remediation/README.md. Surgical scope: this story ships the script + tests only; the README cross-link can land alongside S5-05's README work (Rule 3).
Notes for the implementer¶
Adversarial mutant catalog (six named mutants this §TDD plan kills)¶
- Constant pin (
return "0" * 32) — killed by AC-3 (canonical formula re-derivation byte-equality) and AC-3a (distinctness across two held-out invocations). - Missing comment block (skipping
# Derived from:for--source-cassette) — killed by AC-13 substring check. - Swallowed exit code on collision (
mkdir(exist_ok=True)+ emit success) — killed by AC-7 byte-untouched assertion +returncode == 1check. - Partial directory creation under missing bench-root (creating
cases/even when<task-class>/is missing) — killed by AC-10's separation between the two preconditions. - Post-hoc Literal mismatch (e.g., emitting
disposition = "good"instead of a valid Literal) — killed by AC-2'sBenchCase.model_validateround-trip. - Slug character bleed (e.g., passing
--slug=Foo Barthrough verbatim) — killed by AC-8's normalization + parametrized rejection of empty post-normalization slugs.
Two-path canary derivation Strategy seam (deferred per Rule 2)¶
Exactly two pin-derivation paths today:
- --curation-class=rag-corpus-derived + --source-cassette=<path> → S5-03 AC-3a canonical formula (domain-separated by phase4-cassette: prefix).
- --curation-class=held-out → os.urandom(32).hex() per ADR-0005 §Consequences line 44 + S5-04 AC-3a.
Rule 2 says two paths is fine — do NOT abstract today. When ADR-P4-006 ships and Phase 4 cassettes re-cut with metadata, a third path (read-from-metadata) lands; extract a CanaryPinSource sum type + derive_canary_pin(source) -> str smart constructor at that moment (mirrors S5-03 F-DP-2 + S5-04 F-DP-2). For now the _derive_cassette_canary_pin(curation_class, source_cassette) function in §Implementation outline §5 keeps both paths explicit and named.
Canary.mint(...) vs cassette_canary_pin — DO NOT confuse¶
Per ADR-0005 §Decision: Canary.mint(...) is the runtime canary-token generator (32 bytes injected into the SUT's system prompt at invocation time; the output validator's canary_echo check verifies the model did not echo it). cassette_canary_pin is the curator-time 32-hex identifier pinned into case.toml and consumed by S2-05's with_pinned_canary(case) shim to seed Canary.mint(seed=bytes.fromhex(pin)) at runtime. The scaffolder mints the pin (a string) — it does NOT call Canary.mint. (As of 2026-05-26 per S2-05's own status note, Canary.mint(seed=...) does not even exist in shipped code yet — but even after it lands, the scaffolder remains pin-only.)
compute_case_dir_digest — no inline reimplementation¶
Per S5-05 AC-DIG-PROMOTE, the canonical case-dir digest algorithm lives at src/codegenie/eval/digests.py and is re-exported from codegenie.eval.__init__. The scaffolder MUST NOT re-implement BLAKE3 framing — no b"\x1f" / b"\x1e" separator literals in scripts/scaffold_bench_case.py. The scaffolder emits the all-zero stub digest; the curator runs scripts/sign_bench_digests.py (S5-05) which routes through the public helper. If a future scaffolder enhancement needs a real digest (e.g., post-population auto-sign), import from codegenie.eval.digests — never inline the algorithm.
Functional core / imperative shell¶
The pure helper _render_case_toml(payload: _CaseSkeleton) -> str (where _CaseSkeleton is a small frozen dataclass) lets AC-2 / AC-5 / AC-12 test the rendered TOML string directly without mocking I/O. The imperative shell (Click handlers, mkdir, write_text, sys.exit) wraps it. Keep them separable; ~150 LOC total stays well under the 200-LOC informal cap.
Determinism vs entropy¶
Curator-time non-determinism (datetime.now(UTC) for added_at/last_validated_at; os.urandom(32).hex() for held-out pin) is captured once per scaffold and pinned forever after in case.toml — analogous to S5-04's contract. This means two successive --dry-run invocations will produce DIFFERENT TOML strings (different pin + different timestamps); AC-5 masks these fields before structural comparison. Curators running twice should expect different output; commit one of them.
Curator workflow (the "Next steps" block AC-6)¶
The scaffolder's stdout block is the bench-author's UX. The four ordinals are: (1) populate input/; (2) populate expected/; (3) run scripts/sign_bench_digests.py --task-class=<tc> --case-id=<id>; (4) commit. Wording matters: explicit, scannable, hyperlink-ish ("see ADR-0006" / "run scripts/sign_bench_digests.py"). Test asserts the literal substring scripts/sign_bench_digests.py (not fuzzy "sign" match) and the (1)–(4) ordinal sequence; phrasing inside the ordinals is the implementer's call. Don't over-engineer error messages; curators are technical and will read tracebacks.
Held-out CVE-year-floor selection criterion (curator judgment, not enforced)¶
ADR-0006 + S5-04 AC-2a require held-out CVEs to come from CVE-YEAR-NNNN where YEAR ≥ Phase 4 corpus cutoff (or older CVEs explicitly excluded in held-out-cve-exclusion-manifest.yaml). The scaffolder validates only the CVE format (AC-8) — the year-floor is the curator's judgment call, surfaced via the help-text on --cve and reinforced by the README. A scaffolder-time year check would couple operator tooling to corpus state and create a moving fact — out of scope for this story.
Out-of-closure imports are OK¶
scripts/scaffold_bench_case.py lives outside the policed runtime closure (src/codegenie/) and may import click, blake3, tomllib (stdlib), codegenie.eval.registry.default_registry, codegenie.eval.digests.compute_case_dir_digest, and codegenie.eval.models.BenchCase freely. make fence polices src/codegenie/ only — bench-curation tooling under scripts/ is intentionally exempt (Phase 0 ADR-0001 §Scope).