Skip to content

fix(skill-evals): a CLI that grades nothing is an ERROR, not a PASS - #1161

Merged
potiuk merged 1 commit into
apache:mainfrom
potiuk:eval-harness-silent-pass
Sep 8, 2026
Merged

fix(skill-evals): a CLI that grades nothing is an ERROR, not a PASS#1161
potiuk merged 1 commit into
apache:mainfrom
potiuk:eval-harness-silent-pass

Conversation

@potiuk

@potiuk potiuk commented Sep 8, 2026

Copy link
Copy Markdown
Member

Summary

When the model CLI produced no usable JSON, the runner wrapped its stdout as
{"raw_output": …} and handed that to the intersection-only comparator. The
comparator only checks keys present on both sides, so if expected.json
declared no wrap key it compared nothing — and printed PASS.

That turns a broken CLI into a fully green run.

This is not hypothetical. A sandboxed claude -p cannot reach the keychain or
~/.claude.json, so it returns Not logged in. #1156's test plan claims the
three security-model-* suites passed 35/35 on exactly that basis; no model
graded a single case. Re-run with credentials, the real result is 28/35.

The fix

The wrap stays, because a step whose output is prose has two legitimate ways to
gate on it:

  • a raw_output / stderr / exit_code key in expected.json, or
  • a structural assertion whose field addresses one of those in
    assertions.json — which is what
    security-issue-deduplicate/step-3-merge-body does.

wrap_is_asserted() checks both. When neither is present, the case reports
ERROR naming the cause and printing the first 120 characters of stdout, instead
of a pass that asserted nothing. The same guard covers the non-zero-exit branch,
since some auth failures and safety filters signal via exit code rather than
prose. --exact mode is unchanged.

Blast radius

Narrow by construction: the guard only fires where no JSON was extracted at
all
. A working CLI emits JSON, takes the normal path, and is unaffected — the
only runs that change are ones where nothing was being graded.

Verified end-to-end rather than by argument:

  • Sandboxed (unauthenticated) security-model-prepare: was 9 passed, now
    0 passed, 9 errored, each naming the cause. Same command, same fixtures.
  • security-issue-deduplicate/step-3-merge-body/case-1 — the one suite that
    grades prose via raw_output assertions — still reports FAIL, not ERROR, so
    the guard does not intercept it. (It fails for an unrelated pre-existing
    reason: the model returns [] rather than the prose body the step specifies.
    Not addressed here.)

Test plan

  • prek run --all-files passes (exit 0)
  • Workspace: pytest 32/32 members, mypy 26/26, ruff + ruff-format 29/29
  • skill-evals suite: 161 tests pass
  • Two tests that asserted the old vacuous-PASS behaviour are inverted, with
    the reasoning in their docstrings
  • Three new tests cover the paths that must keep grading:
    assertions.json field, expected.json key, and a negative case

Notes for reviewers

The two inverted tests are the interesting part of the diff —
test_cli_mode_non_json_wraps_and_passes_under_field_aware and
test_cli_mode_non_zero_exit_wraps_under_field_aware encoded the vacuous pass
as intended behaviour, which is why nothing caught this earlier.

The README passage describing the old behaviour is rewritten, and a note is
added to Prerequisites: --cli runs must happen outside any sandbox that
denies the CLI its credentials.

Follow-up, not in this PR: the seven real failures the corrected numbers
exposed (security-model-prepare 8/9, verify 9/11, update 11/15). Five of
the seven are rejection paths — cases asserting a cluster is not eligible for
a suppression rule, or that a widening suggestion must be refused. Each needs
diagnosing as either an over-specified fixture or a genuine gap in the skill
prose.

🤖 Generated with Claude Code

https://claude.ai/code/session_01So3JRGXrbqSGrohtZuHWKg

When the model CLI produced no usable JSON, the runner wrapped its stdout
as {"raw_output": ...} and handed that to the intersection-only
comparator. The comparator only checks keys present on both sides, so if
expected.json declared no wrap key it compared nothing — and reported
PASS.

That turns a broken CLI into a green run. It is not hypothetical: a
sandboxed `claude -p` returns "Not logged in", and a full suite recently
reported 35/35 passed with no model having graded a single case. Re-run
with credentials, the real result was 28/35.

The wrap still exists, because a step whose output is prose has two
legitimate ways to gate on it: a raw_output / stderr / exit_code key in
expected.json, or a structural assertion whose `field` addresses one
(security-issue-deduplicate/step-3-merge-body uses the latter, and is
unaffected). When neither is present the case now reports ERROR naming
the cause, with the first 120 characters of stdout.

Same guard on the non-zero-exit branch: some auth failures and safety
filters signal via exit code rather than prose.

Two tests asserted the old behaviour and are inverted; three cover the
paths that must keep grading. README documents the guard and adds the
operational note that --cli runs need to be outside any sandbox that
denies the CLI its credentials.

Generated-by: Claude Code (Opus 5)
@potiuk
potiuk merged commit de7d705 into apache:main Sep 8, 2026
9 checks passed
potiuk added a commit that referenced this pull request Sep 8, 2026
The marker sat at a1cff44, 17 commits behind main. Bumping it alone
would claim those commits are described by the specs, so the drift is
closed first.

Specs updated for what actually shipped:

- meta-and-quality-tooling: skill-evals errors, rather than passing, when
  a case's CLI produced no gradeable output (#1161).
- security-reporting: the tracker dashboard projects the current partial
  bucket to its end-of-bucket value, splitting RATE series (accumulate
  from zero) from LEVEL series (carry over), and deliberately not
  projecting mean-based signals (#1158).
- project-agnosticism: <PROJECT> and <project> are two placeholders
  holding different values, and the lint carries both spellings plus
  spaced variants (#1154).
- adapters: the forwarder relay's contact_handle defaults to an
  org-level shared inbox rather than a named individual (#1135). The
  multi-hop coordinator case is designed in RFC-AI-0008 and unimplemented.
- issue-management-family: the family's eval suites, and the note that
  --cli runs belong outside a credential-denying sandbox (#1145).

Commits needing no spec change: #1152, #1143 and #1156 updated their own
specs in-commit; #1149, #1147, #1151 are behaviour-preserving bug fixes;
#1155 and #1141 are CI and dependency chores; #1159's spec edits landed
with it; #1144 removes hardcoded literals that no spec asserted.

One genuine gap recorded rather than papered over: no spec covers
marketplace distribution or the dev-version stamping rule from #1160,
which is load-bearing because `claude plugin update` compares version
strings, not commit SHAs. Logged in adoption-and-setup as wanting its own
spec.

Generated-by: Claude Code (Opus 5)
potiuk added a commit that referenced this pull request Sep 8, 2026
… shared toolchain (#1164)

* ci(dev): add check-doc-sync — deterministic gates for doc/tree drift

This session fixed a batch of documentation that had drifted from the
tree: ten specs missing from both index files, per-family and per-mode
skill counts stale in three places, and catalogue totals stale in a
fourth. Every one was found by reading, not by a check. Left as is, they
come back.

check-doc-sync.py makes five of them mechanical:

- every tools/spec-loop/specs/*.md is listed in BOTH overview.md and
  README.md;
- README.md's family table "N skills" cells match live family:
  frontmatter;
- docs/modes.md's Modes-at-a-glance Skill-count column matches live
  mode: frontmatter — the validator's modes-doc rule checks section
  membership but parses only the mode and status columns, so the counts
  were unguarded;
- bare "N skills" totals in docs/setup/marketplaces.md match the
  catalogue;
- every script in tools/dev/ is named in tools/dev/README.md.

Each check was verified to fail on the exact defect it exists for, by
reintroducing that defect and confirming the reported number matched what
had actually been wrong. A check that cannot go red is worse than no
check, since it reads as evidence — the lesson from #1161, where an
unauthenticated CLI produced a fully green eval run.

The fifth check found three undocumented scripts on its first run
(agent-pre-commit.sh, check-placeholders.sh, run-workspace-check.sh), so
tools/dev/README.md now carries a table saying what each of the seven
guards rather than naming a couple in passing.

The counts are deliberately narrow: the total-skills check reads an
allowlist of files rather than sweeping the repo, because plenty of docs
legitimately count a subset ("Nine skills cover the staged path") and a
greedy scan would flag those as drift forever.

Generated-by: Claude Code (Opus 5)

* test(dev): bring tools/dev under the workspace checks

The scripts in tools/dev are the gates that guard everyone else's
changes, and they were the one Python tree nothing checked: the
workspace checks iterate `[tool.uv.workspace] members`, and tools/dev
was not one. So ruff, mypy, and pytest all skipped it.

Declares the tree as a workspace member, the same config-carrier pattern
`skills/pyproject.toml` uses, and adds 20 tests for check-doc-sync.py.

The tests are written around the red case. Each check builds a miniature
repo in tmp_path and asserts the drift is reported with both numbers,
then that the corrected tree is silent — because a gate that cannot fail
is worse than no gate, it reads as evidence while measuring nothing.
Also covered: a spec listed in only one of the two indexes still counts
as missing, a README table row that names no family is not read as a
family with a wrong count, a mode nothing declares must say 0 rather
than be skipped, every stale total on a page is reported rather than
just the first, and the totals check stays inside its allowlist.

Script filenames are hyphenated, so the tests load the module through
importlib rather than a plain import.

Bringing the tree under the checks surfaced four lint findings in
pre-existing scripts; three are fixed (an unused unpacked variable, two
loops ruff can express directly) and RUF003 is ignored with a reason —
the repo's prose style uses en dashes throughout, and rewriting comments
to hyphens to satisfy a homoglyph check would make them worse. Four
files were reformatted. Every script was re-run afterwards to confirm
behaviour is unchanged.

Generated-by: Claude Code (Opus 5)

* refactor(workspace): share the dev toolchain via magpie-dev

Every workspace member repeated the same three pins in its own
`[dependency-groups] dev`, under an instruction in the root pyproject to
"keep the version bounds here and in every member in lockstep". They were
not in lockstep:

  mypy    >=2.1.0, >=2.3.0, >=1.10   (three floors)
  pytest  >=9.1.1, >=8.0             (two)
  ruff    >=0.16.1, >=0.16.2         (two)

tools/skill-reconciler-diff declared only pytest, and tools/fossil had no
dev group at all despite being ruff-checked — its checks ran against
whatever happened to be in the environment. Nothing detected any of it.
The duplication was the defect and the lockstep instruction was the
workaround.

tools/dev becomes `magpie-dev`, a metadata-only wheel whose dependencies
are the toolchain. Every other member's dev group becomes
`["magpie-dev"]`, with the workspace source declared once at the root.
Members with extras keep them (tools/gmail/oauth-draft retains mcp).
Bumping a version is now one edit and the whole workspace moves.

Each member's environment stays self-contained: the checks still run
`uv run --directory <member> --project . python -m <tool>`, so nothing
leaks in from the root. Only the declaration is shared.

Two properties checked rather than assumed. Check discovery keys off the
config sections (`[tool.ruff]`, `[tool.mypy]`,
`[tool.pytest.ini_options]`) and not the dev group, so giving a member
more tools does not silently enrol it in more checks. And the whole
battery was re-run after deleting every member `.venv`, not against a
warm cache: ruff 30/30, ruff-format 30/30, mypy 27/27, pytest 33/33.

One real behaviour change: tools/bitbucket moves from mypy>=1.10 /
pytest>=8.0 to the current floors. It passes the fresh sweep, but that is
a version bump rather than a no-op.

Generated-by: Claude Code (Opus 5)

* ci(dev): verify every workspace member's tests actually run

Both the CI pytest matrix and the workspace sweep are driven by the
presence of `[tool.pytest.ini_options]`. A project can therefore carry a
full tests/ directory and be executed by nothing: the job is simply never
emitted, so there is no red tick for anyone to notice. Nothing checked
this.

check-workspace-members.py now reports three shapes:

  - tests on disk, no [tool.pytest.ini_options]  → the tests never run;
  - [tool.pytest.ini_options], no tests on disk  → the CI job runs and
    collects nothing, so its green tick proves nothing;
  - neither                                      → no tests at all.

A project that genuinely should not be tested declares it with
`[tool.magpie.checks] skip = ["pytest"]`, the same opt-out the sweep and
the CI matrix already honour — so an exemption is visible in the file
rather than being a silent absence from a matrix nobody reads.

All 33 members pass today, so this locks in the current state rather than
fixing a present gap. The hook now also re-fires on test files, since
adding or deleting tests changes the answer.

Ten tests cover it, including the false negative that would make the
whole check worthless: a test file vendored inside a member's .venv or
site-packages must not make an untested member look tested. Each of the
three failure shapes was also verified by hand against a real member,
and the declared opt-out confirmed to silence all three.

Generated-by: Claude Code (Opus 5)
potiuk added a commit that referenced this pull request Sep 8, 2026
…1169)

`extract_json_from_output`'s last-resort strategy takes the largest
balanced `{...}` / `[...]` block in the CLI's stdout. Ordinary Markdown
supplies those freely: a task-list `- [ ]` parses as the JSON value `[]`,
and a link's `[text]` as a malformed one. On a step whose output is
prose, that scavenged fragment then replaces the whole body — `actual`
becomes `[]`, `raw_output` no longer exists, and every assertion
addressing it fails against output the model got exactly right.

That is what happened to all four cases of
security-issue-deduplicate/step-3-merge-body. Read back, the model's
merged issue body was correct in each: both credits on separate lines,
both mailing-list threads, severity Unknown, the widened version range,
the CWE agreement noted. The harness threw it away and reported four
failures.

A suite whose expected side addresses a wrap key (`raw_output`,
`stderr`, `exit_code`, via expected.json or an assertions.json `field`)
is declaring that the step emits prose. Skip extraction for those and
pass the body through. This reuses `wrap_is_asserted` from #1161 on the
mirror-image problem: that change stopped the harness passing what it
never graded, this one stops it failing what it graded wrong.

All four cases pass after the change. Two tests cover both directions —
prose containing a task-list stays prose, and an ordinary JSON step still
goes through the extractor.

Also bumps the framework version to 0.2.0.dev202609081000 and
regenerates the manifests, so the fix reaches installed copies: the
marketplace is served from `main` and `claude plugin update` compares
version strings, not commit SHAs, so a frozen stamp leaves adopters told
they are already up to date.


Claude-Session: https://claude.ai/code/session_01So3JRGXrbqSGrohtZuHWKg

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant