Skip to content

feat(runtime): run a rule's fixtures, and stop reporting a pass when they did not - #252

Merged
thecodedrift merged 8 commits into
mainfrom
feat/runtime-fixture-runner
Sep 3, 2026
Merged

feat(runtime): run a rule's fixtures, and stop reporting a pass when they did not#252
thecodedrift merged 8 commits into
mainfrom
feat/runtime-fixture-runner

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Implements runtime-fixture-runner task groups 1 through 6. Group 7 (close-out, outbound to the generator team) is untouched.

The defect

taskless test printed ✓ runtime/<id> and "1 rule(s) tested" at exit 0 for a rule whose fixtures never ran. testOneRule returned { ok: true, errors: [], ran: false }, and the renderer read only ok. The comment above that return said "test reports that rather than quietly claiming a pass." The human path did exactly the quiet claim.

Runtime was the only tier without a fixture runner: sg runs ast-grep test, vale runs both buckets and asserts the asymmetry, runtime returned a tick.

What landed

Group
1 readRuntimeFixtures — buckets read independently, entries must be directories, four-way coverage
2 run-fixtures.ts — cases through the existing executor, four defect classes
3 planRuntime moved beside the engine it gates; test gains --dangerously-run-scripts
4 the third outcome — , excluded from "N tested", ignored by the exit code
5 17 tests spawning the built CLI
6 create-runtime-rule and verify-rule say what runs and what stops it

D8: one execution path, not two

executeRuntimeRule gates on the narrow (if (matches.length === 0) return []), so a case whose narrow matches nothing was indistinguishable from a check that ran and found nothing — which would have failed a fail/ case and blamed the check for a fixture that never reached it.

A literal added field was impossible, since the function returns an array with nowhere to put one. Instead executeRuntimeRuleDetailed is now the single execution path returning { findings, invoked, failure? }, and executeRuntimeRule is a two-line projection of it. check's call site in dispatch.ts is untouched and compiles unchanged. One narrow, one invocation, one source of truth. runNarrow is not duplicated.

failure also settles task 2.3: a narrow that throws is invoked: false, failure; a check that throws is invoked: true, failure. The runner checks failure before invoked before the bucket assertion, so a crashed check in fail/ is never scored as the case firing correctly.

Where implementation found the design wrong

ran alone cannot carry the state (D2). A rule whose verify failed is also ok: false, ran: false, and that one must fail the command — while a refused run must not. Task 4.1 forbids ok: true and 4.4 forbids failing, so the two states are indistinguishable from ok and ran together. An explicit refused field carries the reason and is the discriminator the renderer, the summary and the exit code all read. This is a --json envelope addition the design did not anticipate and deserves a reviewer's eye.

A pass/ case is harder to author than the design implies. D8 requires every case to match the narrow, so a pass/ case must match the capture and leave the check quiet — meaning a check that flags every match has no valid pass/ fixture at all. That went into create-runtime-rule.txt as guidance rather than staying a comment.

Coverage is assessed only on the admitted path. A refused rule means the CLI never looked at its fixtures, rather than reporting "refused and also under-fixtured". Defensible; the design left it open.

Proving it bites (5.6)

Reverted 5a85ce8 and 4054f4e, and verified the revert applied before running anythingrun-fixtures.ts absent, zero occurrences of executeRuntimeRuleDetailed, renderer back to result.ok ? "✓" : "✗", runtime branch back to ok: true. A revert that does not revert has made passing tests look like proof in this repo before.

  • Reverted: 15 failed / 1190 passed of 1205, 1 test file failed of 75
  • Restored: 1205 passed of 1205, 75 files

The first failure reproduced the proposal's output exactly: expected '✓ runtime/no-eval\n\n1 rule(s) tested…' not to contain '✓ runtime/no-eval'.

Two of the 17 passed under the revert, and passed for the wrong reason: with no runner at all, the gate's "nothing was executed" is trivially true.

Checks

pnpm build, pnpm typecheck, pnpm lint, full suite (1205 passed), pnpm cli check with exactly the 4 pre-existing no-hedging warnings.

…s/fail

Group 1 of the fixture runner: the reader, with nothing executing yet.

A case is a DIRECTORY, and its path is the root the harness will be handed.
That follows from `executeRuntimeRule(root, rule)` already taking a root rather
than from a preference: a case directory is the same argument with a smaller
tree behind it.

Two guards carry the weight, both taken from the Vale reader because the
mistakes they prevent are the same ones.

A bucket that cannot be read is an error, never an empty bucket. Swallowing an
`EACCES` on `pass/` would yield nothing while `fail/` still had cases, so a
two-sided rule would look one-sided and could report a pass having never
checked its pass side.

A loose file is refused by name rather than skipped. The check is given a root
and reads beneath it, so a bare file has no root to be; ignoring it would leave
an author with a fixture they wrote, that never ran, and that nothing mentioned.

Both proved rather than asserted: reverting each guard fails its own test and
leaves the other six green.
A fixture case is a case DIRECTORY handed to `executeRuntimeRule` as its
`root`, which is the same argument `check` already passes with the repository
root. So the runner is a caller of the executor rather than a sibling of it:
the loop and the pass/fail assertion are new, the execution is not.

D8 is the part that needed a change in the harness. `executeRuntimeRule` gates
on the narrow and returns `[]` without invoking `check.ts`, so a case whose
narrow matches nothing is indistinguishable downstream from a check that ran
and found nothing. Under the pass/fail rules that would fail a `fail/` case and
blame the check for a fixture that never reached it, and would pass a `pass/`
case that proves only that the narrow did not match.

`executeRuntimeRuleDetailed` is therefore the single execution path, adding
`invoked` and `failure` beside the findings, and `executeRuntimeRule` is a thin
projection of it. One narrow, one invocation, one source of truth for "did it
run", and the scan's call site is untouched.

A case producing no narrow matches is reported as a fixture defect in BOTH
buckets, naming the case and saying the check never ran. A check that throws is
reported as the check failing, since both arrive as zero findings and only one
is the rule's fault.
…ates

`test` has to run a runtime rule's fixtures under the policy `check` already
applies: an authenticated reconcile that returns the rule's signature in `run`,
or `--dangerously-run-scripts`. Nothing about a fixture makes the code safer.
The bytes do not know what directory they are pointed at, and "it is only
running against test data" is a statement about the input rather than about
what the program may do.

`planRuntime` and its repair pass therefore move out of `commands/check.ts`
and into `rules/runtime/plan.ts`, unchanged. The alternative was a second
implementation of the gate in `test`, which is a bypass waiting to be
discovered: faithful on the day it is written and drifting from then on.

`createRuntimeGate` wraps it for a command that reports rule by rule. The plan
is memoized, so a `test` over a tree of runtime rules asks the service once
rather than once per rule, and the whole discovered set is reported to
reconcile exactly as `check` reports it. It is lazy because most projects hold
no runtime rules, and a `test` over `sg` rules must not reach the network.

No rule id is exempt, and no path through the gate is softer than another.
…ilure

`testOneRule` returned `{ ok: true, errors: [], ran: false }` for every runtime
rule, and the renderer read only `ok`, so `test` printed `✓ runtime/<id>` and
"1 rule(s) tested" about a rule it had not tested. The one field that knew was
the one nothing read.

The runtime branch now takes the rule through the shared gate and, when
admitted, runs its fixtures. When the gate refuses, the result carries
`refused` with the reason, and three call sites read it: the renderer prints
`○` rather than a tick, the summary counts the rule under "did not run" rather
than among the rules tested, and the exit code ignores it.

`ok: false` alone would have been the wrong correction. A rule that cannot run
because nothing blessed it is not defective, and failing it turns `test` red
for every project holding a runtime rule with no action available that makes it
green. `ok` also cannot express the state on its own: a rule whose `verify`
failed is likewise `ok: false, ran: false` and must still fail.

`ran` becomes load-bearing in the `--json` envelope, `refused` carries the
reason beside it, and both are documented as the fields a caller branches on.

The refusal message names `--dangerously-run-scripts`, because for a locally
authored rule that is the author's only route: blessing is recording, and
nothing recorded a rule that has never left the working tree. The flag is added
to `test` with `check`'s description and `check`'s warning, on stderr, and one
gate is built per command run so a tree of runtime rules is planned once.
Written against the unfixed CLI first, where 15 of the 17 assertions failed and
the first one failed with exactly the output the proposal recorded:

    expected '✓ runtime/no-eval\n\n1 rule(s) tested…' not to contain
    '✓ runtime/no-eval'

The two that passed are the gate's, and they passed for the wrong reason: with
no runner at all, "no fixture case was executed" was trivially true.

The suite covers the defect (no tick, no count, `ran: false`, exit 0), the gate
(nothing executes unblessed, no id is exempt, the flag is the only other way
in), both fixture directions, both never-invoked cases, a check that throws,
every coverage class, and an unreadable or malformed bucket.

Two details in the fixtures are load-bearing rather than incidental. The rule
flags `eval` only on a non-literal argument, because a check that flagged every
match would have no `pass/` case that both matched the narrow and stayed quiet,
and a `pass/` case that does not match the narrow proves nothing. And `matching`
and `flagged` are separate knobs on a case, because the narrow and the check are
what D8 exists to keep apart.
Reverting the runner and the reporting fix, and rebuilding, fails 15 of 1205
tests in one file. Restoring returns 1205 passed.

The revert was verified applied before the suite was run, by grepping for the
absence of `run-fixtures.ts` and `executeRuntimeRuleDetailed` and for the
return of the old renderer and the old `ok: true`. A revert that does not
revert makes passing tests look like proof, which has happened here before.
`create-runtime-rule` now states that testing a rule you just wrote needs
`--dangerously-run-scripts`, and why the obvious reading of the message is
wrong: a locally authored rule has no signature and never will, because
blessing is recording and nothing recorded a rule that has not left the working
tree. The message says "not authenticated", which reads like logging in would
fix it, so the recipe says plainly that it would not.

It also states what a passing runtime rule needs: both buckets populated, and
every case matching at least one capture. A case the captures do not match
never reaches `check.ts` and is a defect in the case, in `pass/` as much as in
`fail/`.

`verify-rule` stopped describing runtime tests as "reported as not run" with
nothing about what would run them. Its engine table now says what `test` runs
for a runtime rule, and the reporting section says how a refused run appears:
`○` rather than a tick, `ran: false` with `refused` beside it, counted under
"did not run", and never on its own a reason for a non-zero exit.
@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 13m 4s —— View job


Review

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json holds no reviews, no reviewThreads, and no comments beyond the trigger comment itself, so there is nothing to classify against — this is the first review pass on this PR. Every finding below is [New].

I read the full diff (gh pr diff 252) and the current contents of every touched file, then ran 8 finder passes (line-by-line, removed-behavior, cross-file, reuse, simplification, efficiency, altitude, CLAUDE.md conventions) and self-verified each surviving candidate against the actual code before reporting it. git was not used anywhere.

Overall the core defect fix is solid: testOneRule's runtime branch no longer returns ok: true, ran: false, verify.ts correctly threads a third refused outcome through the renderer/summary/exit-code without letting it leak into verify's (non-runtime) results, executeRuntimeRuleDetailed is a real single execution path with dispatch.ts's call site untouched, and the D8 narrow-gate distinction (invoked/failure) is implemented correctly. Several other candidates I chased down (a TOCTOU in RuntimeGate.admit()'s fallback message, a materialized-vs-working-tree rule/fixture mismatch, a .gitkeep-in-a-bucket hard failure) turned out to be either unreachable given verifyOneRule's existing checks, correct by design, or exactly what task 1.1 specifies (a loose file must error, not be skipped) — so I dropped them.

[New] test silently starts making network calls it never made before, with no opt-out check has
packages/cli/src/rules/runtime/plan.ts:346createRuntimeGate hardcodes anonymous: false when calling planRuntime. check exposes --anonymous ("Run only trusted static rules; skip runtime rules (no reconciliation)", packages/cli/src/commands/check.ts:94-98) precisely so a caller can avoid touching auth/network for runtime rules. testCommand (packages/cli/src/commands/verify.ts) exposes no equivalent flag. Before this PR, test was fully offline for runtime rules — it unconditionally returned ok: true, ran: false. Now, any project holding a runtime rule causes taskless test to call getToken, and if a (possibly stale) local token is present, resolveOrgSubject + reconcile over the network — with no way to suppress it short of logging out. It fails soft (an unavailable/unauthorized reconcile becomes a refused result, not a crash), so this isn't a crash-level bug, but it's a real, unannounced behavior change for CI/sandboxed callers that expect test to stay offline, and it undercuts the PR's own framing that test runs fixtures "under exactly this policy" as check — the policy differs in exactly the branch that would matter for a locked-down environment.

[New] Runtime fixture-bucket logic (coverage classification + directory reading) is a third parallel implementation of Vale's
packages/cli/src/rules/runtime/fixtures.ts:46 (coverageOf) and :65 (directoryEntries) duplicate, near-verbatim, packages/cli/src/rules/vale/verify.ts:139 and :65 — which is itself already a duplicate of coverageOf in packages/cli/src/rules/verify.ts:368 (the sg engine). Vale's directoryEntries doc comment even claims to be "The single place that decides which readdir failures are absence and which are problems, so no caller can accidentally answer that question differently" — a claim this PR makes false by adding a second, independent copy. describeFixtureReport (packages/cli/src/rules/runtime/run-fixtures.ts) also re-derives the "half a claim" coverage message that's already inlined twice in inspect.ts (sg branch and vale branch), and the three copies have already drifted from each other in punctuation ("only fail:" at inspect.ts:292 vs "only fail/" at inspect.ts:328 and in the new runtime message) — concrete evidence this pattern drifts when copied instead of shared.

[New] Duplicate refusal predicate in runOverPath
packages/cli/src/commands/verify.ts:134-140 — the "is this result refused" test ("refused" in result && result.refused !== undefined) is written twice: once inline as the .filter() callback that builds refused, and again verbatim as the separately declared isRefused closure used for failed/the ✓✗○ mark. Simpler: declare isRefused once and derive refused from results.filter(isRefused). As written, a future change to what counts as "refused" has two call sites to keep in sync, with nothing enforcing they agree.

[New] Fixture cases run strictly sequentially; the stated justification doesn't hold
packages/cli/src/rules/runtime/run-fixtures.ts:74 — cases are awaited one at a time, and the comment justifies this by analogy to executeRuntimeRules's worker-startup predictability. But each fixture case runs its own independent sg scan narrow subprocess (narrow.ts) and its own independent invokeCheck subprocess (invoke.ts) against a private temp root — nothing is shared between cases, so there's no worker-pool state for concurrency to disturb. A rule with many fixture cases pays the full spawn+narrow+invoke latency of each case back-to-back where independent cases could run concurrently (or with a small pool).

[New] Three-outcome result (passed/failed/refused) is two booleans plus an optional string, not type-enforced
packages/cli/src/rules/inspect.ts:41 (RuleTestResult) / packages/cli/src/schemas/verify-test.ts — nothing in the TypeScript type or the Zod schema rejects { ok: true, ran: true, refused: "..." }. Today every construction site in testOneRule happens to hold the invariant "refused implies ok: false, ran: false" by convention across ~6 return statements, and I traced all of them and confirmed none currently violates it — so this is not a live bug. But it's the same shape of special-case-on-a-two-outcome-structure the PR's own D8 section argues against for executeRuntimeRule, and a status: "passed" | "failed" | "refused" discriminated union would make an invalid combination a compile error / schema-parse rejection instead of a convention six call sites have to individually uphold — worth a second look given this is exactly the kind of silent-tick regression (ok: true printed for a rule that didn't run) this PR exists to prevent for good.


No blocking issues — the network-call and duplication findings above are the ones I'd want addressed or consciously accepted before merge; the rest are lower-severity cleanup/design notes.

`createRuntimeGate` hardcoded `anonymous: false`, so `taskless test` called
`getToken` and, for any project holding a runtime rule, went on to
`resolveOrgSubject` and `reconcile` over the network. `test` was fully offline
before this change.

The fix is not `--anonymous`. It is to take reconcile out of `test` entirely.

`check` reconciles because it executes rules as a SIDE EFFECT of scanning a
repository: nobody asked for code to run, so a gate has to stand between the
request and the execution. `test` runs fixtures because the user asked it to,
and the verb is the consent — asking a server for permission to run your own
fixtures is overreach. The only party a reconcile here could ever admit is
someone testing an already-blessed delivered rule, which the service verified
before delivering it; a locally authored rule has no signature and never will,
so for the audience that actually runs `test` on a runtime rule it is a round
trip whose answer is always "no".

This is strictly more conservative. Nothing executes that would not have
executed before, and a blessed rule that `check` runs unflagged now needs
`--dangerously-run-scripts` here. It also disposes of the `--anonymous`
question rather than answering it: with no network there is nothing to
suppress. `check`'s gate, its reconcile, and its use of `planRuntime` are
untouched.

`createRuntimeGate`/`RuntimeGate` were `test`-only and are deleted.
`planRuntime` and `repairWithheldRules` stay in `rules/runtime/plan.ts`,
`check`-only: the move's original justification is gone, but policy still does
not belong in a command file. The refusal message no longer inherits `check`'s
"not authenticated", which read like a fix and was not one.

Also extracts the fixture reading that had been copied a third time. Vale's
`directoryEntries` claimed to be "the single place that decides which `readdir`
failures are absence and which are problems, so no caller can accidentally
answer that question differently", and the runtime copy made that false. The
missing-vs-unreadable discrimination and the four-way coverage classification
with its message now live in `rules/fixtures.ts`, and all three engines call
it. Each engine's rejection stays local, because they are opposites: Vale
refuses a nested DIRECTORY, runtime refuses a non-directory.

The coverage message's `valid:` versus `pass/` looked like drift and is not —
ast-grep's buckets are YAML keys and the other two are directories — so the
suffix is a parameter rather than something normalised away. Behaviour is
unchanged: 1205 tests before, 1206 after, the one addition being the new test
that the refusal never says `auth login`.
@thecodedrift

Copy link
Copy Markdown
Member Author

Ref: review comment 5515932991 → 7c7bb8a

Three of the five addressed; the other two consciously deferred, with reasons.

1. test making network calls — HIGH. Fixed, but not with --anonymous.

You were right about the defect and I went further than the suggested fix: reconcile is out of test entirely. createRuntimeGate is deleted, test never calls getToken, and --dangerously-run-scripts is now the whole gate.

The reasoning, which is now in D1 rather than only here: check reconciles because it executes rules as a side effect of scanning a repository — nobody asked for code to run, so a gate has to stand between the request and the execution. test runs fixtures because the user asked it to, and the verb is the consent. Asking a server for permission to run your own fixtures is overreach.

Ask who a reconcile in test would ever admit and the answer is: someone testing an already-blessed delivered rule, which the service verified against pass/fail examples before delivering it. That is the one audience for whom running the fixtures locally proves least. A locally authored rule has no signature and never will, so for the audience that actually runs test on a runtime rule it was a round trip whose answer is always "no".

This is strictly more conservative. Nothing executes that would not have executed before, and one thing that would have — a blessed rule running unflagged — now requires the flag. check's gate, its reconcile, and its use of planRuntime are untouched. It also disposes of the --anonymous question rather than answering it: with no network there is nothing to suppress.

Orphan check: createRuntimeGate/RuntimeGate were test-only and are gone. planRuntime and repairWithheldRules stay in rules/runtime/plan.ts, now check-only. The move in 27989c7 was justified by sharing the gate with test, and that justification has evaporated — but policy still does not belong in a command file, and repairWithheldRules is ~150 lines of recovery logic, so the module stays and its doc comment now says the honest reason.

One thing your finding did not mention that fell out of it: the refusal message inherited check's "not authenticated", which reads like a fix and is not one — authenticating cannot bless a rule that never left the working tree. There is now a test asserting the refusal never says auth login.

2. Third parallel bucket/coverage implementation — MEDIUM. Extracted.

rules/fixtures.ts now holds the missing-versus-unreadable readdir discrimination and the four-way coverage classification with its message; sg, vale and runtime all call it. Vale's doc comment claiming to be "the single place" is true again.

Each engine's rejection stayed local, because they are opposites rather than variants: Vale refuses a nested directory (its buckets hold documents), runtime refuses a non-directory (its buckets hold one directory per case). Folding those together needed a flag that would have made the shared function harder to read than the two callers it replaced.

One correction to the finding. The valid: versus pass/ punctuation is not drift. ast-grep's buckets are the valid:/invalid: keys of a test YAML document; Vale's and runtime's are directories. Each names the bucket in the shape its author will go looking for, so the suffix is a parameter of the shared message rather than something normalised away. The classification is likewise parameterised on bucket names (FixtureCoverage<"valid" | "invalid"> against FixtureCoverage<"pass" | "fail">), so the three engines share four states under their own vocabularies.

Left alone deliberately: sg's own test-file enumeration catches every readdir failure as "no test files", which is exactly the leniency the new module prevents. Fixing it changes what sg reports on an unreadable .tests/ — a behaviour change wearing a refactor's clothes — so it belongs in its own change with its own test. Noted in D9.

Behaviour proof, since the extraction had to be inert: 1205 tests / 75 files before, 1206 / 75 after, the single addition being the new auth login test from finding 1. pnpm cli check reports the same 4 pre-existing warnings on both sides.

3. Duplicate refusal predicate — LOW. Folded.

isRefused is declared once and refused derives from results.filter(isRefused).

4. Sequential fixture cases — not taken.

The analysis is right that nothing is shared between cases, so the stated justification does not hold as written. Not taken here because it is a performance change to a path with no measurement behind it, and it would land in the same diff as a security-gate change, where the two want different review attention. Worth its own change with a fixture count that shows the cost.

5. Three-outcome result as two booleans — not taken.

Agreed on the shape, and agreed it is the argument D8 makes one level down. Not taken because refused reaches the --json envelope through a Zod schema, so a discriminated union is a consumer-visible change to the output contract rather than an internal tidy — that deserves to be proposed, not slipped into a fix. The invariant is upheld at every construction site today, as you traced.

— AI Coding Agent

@thecodedrift
thecodedrift merged commit ad2d1e8 into main Sep 3, 2026
2 checks passed
@thecodedrift
thecodedrift deleted the feat/runtime-fixture-runner branch September 3, 2026 04:30
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