Skip to content

docs(openspec): propose the runtime fixture runner - #249

Merged
thecodedrift merged 5 commits into
mainfrom
openspec/runtime-fixture-runner
Sep 2, 2026
Merged

docs(openspec): propose the runtime fixture runner#249
thecodedrift merged 5 commits into
mainfrom
openspec/runtime-fixture-runner

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Sep 2, 2026

Copy link
Copy Markdown
Member

Proposal only. No source changes; every path is under openspec/.

The defect

taskless test reports a runtime rule as passing when its fixtures never ran. Measured against a scaffolded project holding one runtime rule:

$ taskless test                    $ taskless test --json
✓ runtime/demo-probe               {"ok":true,"rules":[{"engine":"runtime",
                                     "ruleId":"demo-probe","ok":true,
1 rule(s) tested.                    "errors":[],"ran":false}]}
exit 0

testOneRule returns ok: true, errors: [], ran: false for runtime, and the renderer at commands/verify.ts is mark = result.ok ? "✓" : "✗" — it never reads ran. The comment directly above that return says "test reports that rather than quietly claiming a pass." The human path does exactly the quiet claim.

This is an unimplemented requirement, not a missing feature. cli-rule-validation already says test executes a rule against "ast-grep test cases, Vale pass/fail fixture buckets, or the runtime harness". Two of the three are implemented and have scenarios. The third has neither, and its absence reports as success.

Why now

  • Nothing else consumes a runtime rule's fixtures. Delivery accepts .tests/ paths, PRESERVED_SUBTREES keeps them through a purge, strayModules exempts them at any depth. Every piece of support exists except the runner.
  • The demonstration depends on it. The generator team withdrew the findings the demo was to serve, so what a reader gets is a rule they can run. They have recorded a green-without-running test as a limit on what their side can assert.
  • It is this repo's named failure mode in the command built to catch it. The same requirement says an engine "SHALL NOT be trusted to report this itself", because ast-grep test calls an empty invalid: bucket a pass. The runtime tier does it one level up.

The decision this turns on (D2)

ok: false is the obvious correction and it is also wrong. A rule that cannot run because nothing blessed it is not defective, and failing it would turn test red for every project holding a runtime rule, with no action available to make it green. That trades a silent wrong answer for a loud useless one.

So there are three outcomes: it ran and behaved, it ran and did not, or it did not run and here is why. The third gets its own marker, does not count toward "N rule(s) tested", and does not fail the command. ran stops being advisory metadata and becomes what a caller branches on.

The sg precedent (verify.ts:581, "skips are errors, never a pass") points the other way and that is consistent, not contradictory: there a skip means something went wrong, because the runner exists. Here the run is refused by a security policy working as designed. Same word, opposite correct handling — the distinction this codebase already got wrong once with unsafe versus missing.

The gate does not move (D1)

A fixture run executes the same check.ts, from the same delivery, under the same signature as a scan. That the input is test data is a statement about the input, not about what the program may do. Blessed-by-reconcile or --dangerously-run-scripts, no id exempt.

Consequence, stated rather than discovered (D6): a locally authored rule has no signature and never will, so its author needs the flag to test their own rule. That is the correct friction — a delivered rule and an authored one are indistinguishable on disk.

Modelled on the Vale runner

rules/vale/verify.ts already carries the lessons this tier would otherwise relearn, and the design cites them rather than re-deriving: buckets read independently so a swallowed EACCES cannot present as "no fixtures were written"; coverage classified four ways because fail-only is more misleading than none; a non-conforming entry refused by name rather than skipped.

Runtime differs in one way that matters (D3), and it is an observation rather than a choice: executeRuntimeRule(root, rule, options) already takes a root, and check already passes the repository root through dispatch.ts:205. A fixture case directory is simply a different root handed to the same function.

What already exists, corrected

An earlier draft of this description understated the reuse. Runtime rules execute todaycheck runs them against your repository through the harness, gated by signature planning, and the process spawn, timeout, narrowing, capture discovery and result shape are all proven there on every authenticated run.

The gap is fixtures, and only in test. testOneRule never calls any of it. So the runner is a caller of the existing executor, not a sibling of it: a loop over case directories plus the pass/fail assertion.

check:  executeRuntimeRule(repoRoot, rule)  ->  aggregate findings into a report
test:   executeRuntimeRule(caseDir,  rule)  ->  assert findings match the bucket

A rule needs a fixture (was 7.1, now settled)

A runtime rule holding no fixture cases fails, matching ast-grep and Vale. Making the tier that executes arbitrary code the one whose rules need not prove anything would be the wrong exception.

Depth is not policed: a trivial case that exercises little and passes is acceptable. But that latitude reaches one bucket only. A fail/ case producing no findings is the silent regression this runner exists to catch, so a case that tests nothing can only be a pass/ case, and the fail/ case has to do real work. That is also the one worth having, since it proves the rule fires at all.

The cost is named rather than hidden: runtime rules have not been carrying fixtures, because nothing ran them, so this fails rules that were correct under the old behaviour. Same trade this CLI already made when check, verify and test stopped migrating silently.

Checks

openspec validate runtime-fixture-runner --strict passes. The MODIFIED delta was built from the live requirement programmatically rather than transcribed, and verified by archiving on a scratch commit: all three existing scenarios survive alongside the three added.

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 2, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 6m 23s —— View job


Review mode: incremental — read 0 prior review thread(s) before reviewing

.prior-review.json contained no review threads, no reviews, and no top-level comments other than the @claude /review trigger itself. This is the first review of this PR — nothing was treated as already-addressed.

Scope

This PR is proposal-only: design.md, proposal.md, two specs/*/spec.md deltas, and tasks.md, all under openspec/. No source changed. I cross-checked every factual claim in the proposal against the actual source it describes (inspect.ts, commands/verify.ts, rules/vale/verify.ts, rules/runtime/harness.ts / narrow.ts / invoke.ts, rules/verify.ts) and checked the spec delta against this repo's own CLAUDE.md rule that openspec archive replaces a requirement with exactly what the delta states, so a delta that silently drops an existing scenario deletes it from the spec with nothing reporting it.

Delta integrity — checked, passes. The MODIFIED Requirements block for cli-rule-validation restates the "Test runs a rule's fixtures and runs verify first" requirement's title and body text verbatim against the live openspec/specs/cli-rule-validation/spec.md, and carries all three pre-existing scenarios ("A malformed rule reports the malformation…", "Vale fixtures are tested per bucket", "ast-grep fixtures are counted per bucket") unchanged, alongside the three new runtime scenarios. Nothing would be lost on archive.

Factual claims — checked, accurate. testOneRule's runtime branch really does return ok: true, errors: [], ran: false (packages/cli/src/rules/inspect.ts:315-321), and the human renderer really does compute mark = result.ok ? "✓" : "✗" without reading ran (packages/cli/src/commands/verify.ts:140). The Vale-runner precedents cited in D3/D5 (independent bucket reads, rethrow-unless-missing, four-way coverage classification, nested-entry rejection) match rules/vale/verify.ts exactly. The --dangerously-run-scripts gate and reconcile/signature mechanism cited in D1/D6 match commands/check.ts and cli-rule-reconciliation's spec. PRESERVED_SUBTREES and strayModules' .tests/ exemption are both real and as described.

cli-runtime-rule-execution capability — checked, no collision. The two ADDED Requirements ("A runtime rule's fixtures are executed through the harness", "Fixture execution obeys the runtime execution gate") don't overlap any requirement already in the archived openspec/specs/cli-runtime-rule-execution/spec.md, so ADDED (rather than MODIFIED) is the correct delta type here.

Findings

[New] Design gap: the fixture runner's interaction with the narrow-match gate is unspecified, posted inline on specs/cli-runtime-rule-execution/spec.md. Neither design.md's D3 nor the new "A case directory is the harness root" scenario says what matches a fixture case's check.ts receives. That matters because the existing harness (rules/runtime/harness.ts:66-96) gates on the ast-grep narrow: if (matches.length === 0) return [];check.ts is never invoked when the narrow finds nothing. If the fixture runner reuses that pipeline against a case directory as root (which D1's "same check.ts, same signature, as a scan" framing and the existing create-runtime-rule.txt recipe both point toward), a fail/ case whose small, isolated directory doesn't happen to trigger the capture pattern would report zero findings without the check's logic ever running — indistinguishable, under the scenarios as written, from "the check ran and correctly found nothing." That's the exact ambiguity the adjacent "a check that throws is distinguished from one that finds nothing" scenario exists to prevent, just for a different cause. Worth one explicit sentence in D3 saying whether fixture execution reuses the narrow+gate step or bypasses it.

I did not find other correctness problems: no missing scenario, no internal contradiction between design.md's decisions (D1–D6) and the spec text, no formatting/schema deviation in .openspec.yaml versus sibling changes, and the open "no fixtures at all" question (task 7.1) is self-consciously flagged as unresolved rather than silently decided, which is accurate — sg/vale do currently fail a no-fixtures rule (fixtures === "none" forces valid: false / passed: false), so deferring that decision here rather than copying it is a real, correctly-labeled open question, not an oversight.

Not done

  • Did not run pnpm openspec validate --strict or any build/lint — the sandbox required interactive approval for those specific invocations that I wasn't able to obtain in this session; the proposal text states it was run and passes, and I have no reason to doubt it given the delta-integrity check above passed by direct comparison against the live spec.
  • Could not retrieve this workflow run's numeric ID (env var and gh api/gh run list access were both sandboxed), so the job link below points at the branch's run history rather than this specific run.

Branch run history

@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 6m 23s — Review mode: incremental…"
#249 (comment)

The narrow-gate finding was bucketed low and is not: it would have shipped a spec mandating the exact ambiguity this change exists to remove. Fixed as D8 with tasks 2.4/2.5 and a scenario, not deferred.

Two things worth naming about the review itself.

Checking the MODIFIED delta against CLAUDE.md's replace-not-patch rule is the right instinct, and the confirmation is worth having on the record: that delta was built from the live requirement programmatically rather than transcribed, then verified by archiving on a scratch commit. Your independent read of it agrees, which is the point of writing the rule down.

And tracing the claims into harness.ts / narrow.ts rather than taking the proposal's word is what surfaced the gate. The proposal asserted the runner reuses the existing executor and was right about that, while missing what the executor does before invoking. A claim can be accurate and still hide the thing that matters.

— AI Coding Agent

`taskless test` reports a runtime rule as passing when its fixtures never ran.
`testOneRule` returns `ok: true, errors: [], ran: false`, and the renderer reads
only `ok`, so the command prints a tick and "1 rule(s) tested" about a rule it
did not test.

This is an unimplemented requirement rather than a missing feature.
`cli-rule-validation` already says `test` executes a rule against "ast-grep test
cases, Vale pass/fail fixture buckets, or the runtime harness". Two of the three
are implemented and have scenarios; the third has neither, and its absence
reports as success.

The decision the change turns on is what to do with a run that did not happen.
Returning `ok: false` is the obvious correction and is also wrong: a rule that
cannot run because nothing blessed it is not defective, and failing it would
turn `test` red for every project holding a runtime rule with nothing available
to make it green. So there is a third outcome, reported as itself.

The gate is unchanged. A fixture run executes the same check.ts under the same
signature as a scan, and that the input is test data is a statement about the
input rather than about what the program may do.

The Vale runner is the model, including its strictness: buckets read
independently so an unreadable one cannot present as empty, coverage classified
four ways so only `both` can pass, and a non-directory entry refused by name
rather than skipped. Runtime differs in one way that matters, since a case is a
directory and that directory is the harness root.
…t a sibling

Settles the open question in 7.1: a runtime rule with no fixture cases fails,
matching ast-grep and Vale. Making the tier that executes arbitrary code the one
whose rules need not prove anything would be the wrong exception.

Depth is not policed. A trivial case that exercises little and passes is fine,
but the latitude reaches one bucket only: a `fail/` case producing no findings
is the silent regression this runner exists to catch, so a case that tests
nothing can only be a `pass/` case. The `fail/` case is the one that has to do
real work, and the one worth having.

Also corrects the change's own framing, which understated what already exists.
Runtime rules execute today: `check` reaches `executeRuntimeRule` through
`dispatch.ts:205` with the repository root, gated by signature planning. The
gap is fixtures, and only in `test`.

That makes D3 an observation rather than a decision. `executeRuntimeRule`
already takes a root, so a case directory is a different root handed to the same
function, and the runner is a loop plus an assertion over machinery `check`
already proves on every authenticated run.
…not taken

A rule with no fixtures proves nothing, which makes it incomplete by the same
standard as a rule with no captures. Delivery already answers that question and
answers it before writing, so a refused set leaves no directory behind. Catching
it in `test` instead means the rule is already on disk and the failure reads as
the holder's fault for a file the service sent.

That is the styleguide's rule about build output applied to a payload: an
invariant enforced where the artifact is produced cannot be violated, while one
enforced afterwards can only be detected.

It is not taken here, because it is a contract change. A runtime delivery with
no `.tests/` is valid today, and the published spec names `ENGINE_LAYOUTS` as
the completeness authority, so requiring fixtures refuses payloads that are
correct under the agreed contract. If the service is not already sending them,
every runtime delivery breaks with nothing written.

So the halves land in order. `test` failing an authored rule is ours alone and
ships with this change. Delivery completeness follows once the generator team
has confirmed fixtures ride in `files[]` as a matter of course, as its own
change.
`executeRuntimeRule` gates on the narrow and returns an empty array without
invoking `check.ts` when nothing matches. So a fixture case whose narrow finds
nothing in its small directory is indistinguishable downstream from a check that
ran and found nothing, and the scenarios as drafted would fail a `fail/` case
for it while blaming the check for a fixture that never reached it.

That is this change's own failure mode reintroduced inside the fix, which is
worth fixing in a proposal rather than after implementation.

The runner therefore needs the invocation signal, not only the findings, and
reports three outcomes per case. A case producing no narrow matches is a fixture
defect in BOTH buckets: the `pass/` side is the quieter half, because such a
case reads as a clean pass while proving only that the narrow did not match. It
cannot show the check stays quiet, so counting it as evidence is the same
empty-scan-reports-success shape one level down.

Found by review.
@thecodedrift
thecodedrift force-pushed the openspec/runtime-fixture-runner branch from 8f7ff48 to ff3551f Compare September 2, 2026 19:08
… proposal

D7 framed requiring `.tests/` as a contract change needing the generator team's
agreement. That is wrong. A runtime rule ships with a fixtures directory, and a
delivery without one is a defect on their side rather than a payload shape we
are asking them to adopt. They already hold the material, since their
verification gate executes the generated check against failing and passing
examples before accepting the rule, and they have already established that
fixtures are files in the collection rather than a field beside it.

What is sequenced is enforcement, and for a reason unrelated to agreement.
Being right about whose bug it is does not stop `describeIncompleteSet` from
turning that bug into a refused write for every user, with nothing written and
nothing they can do. So: confirm deliveries carry them, file anything that does
not as the defect it is, and enforce once the fix has shipped.
@thecodedrift
thecodedrift merged commit 91d08da into main Sep 2, 2026
2 checks passed
@thecodedrift
thecodedrift deleted the openspec/runtime-fixture-runner branch September 2, 2026 19:27
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