fix(cli): route file-set/tests guard in rule create+improve through fail() - #319
Conversation
…gh fail()
The guard refusing a file-set rule that also carries a stray `tests`
field threw a bare `CLIError` from inside the command's own `try`,
never touching the command's `fail()` helper. Under `--json` that
produced no envelope at all: stdout empty, prose on stderr, exit 1 —
indistinguishable from a crash, and RULE_GENERATION_FAILED (which the
create-remote-rule recipe documents as a branch target) was never
actually reachable through this path.
Both call sites (rules.ts create and improve) now call fail(), which
writes the { ok: false, code, message } envelope under --json and
marks the error reported. The duplicated guard is also consolidated
into a single fileSetTestsFieldError() helper, since two unmaintained
copies is how they drift.
Rules already written to disk earlier in the same delivery loop are
still not named in the failure envelope — the published envelope
shape has no field for a partial file list, and extending it is a
schema change out of scope here.
Fixes #280
A coordinator review flagged the delete branch's silence under --json as the same silent-success shape as #280's guard bug, and asked me to add a success envelope. Before landing that, I found test/error-envelope.test.ts already asserts the opposite as correct ("is silent on stdout when a real rule is deleted in --json mode"), introduced deliberately in 07c0d3c. The cli-check and cli-auth OpenSpec specs both say the standardized envelope applies "when ... exits with an error" — it is an error-only envelope. create/improve/ meta print on success because they have a payload to return; delete (like auth logout) does not, so silence is correct, not a bug. No behavior change. Adds a comment at the call site recording why, so the next reader (or reviewer) doesn't have to re-derive it.
e4ac94f to
f2bfb63
Compare
|
@claude /review |
|
Claude finished @thecodedrift's task in 4m 39s —— View job Review complete
Review mode: incremental — read 0 prior review thread(s) before reviewing. SummaryThe core fix is sound. I traced it end to end against I verified the new test's mocked HTTP surface against the real code paths ( One finding, posted inline: Everything else — the changeset wording, the |
| afterEach(async () => { | ||
| vi.restoreAllMocks(); | ||
| vi.unstubAllGlobals(); | ||
| delete process.env.TASKLESS_TOKEN; | ||
| delete process.env.TASKLESS_API_URL; | ||
| await rm(cwd, { recursive: true, force: true }); | ||
| }); |
There was a problem hiding this comment.
[New] process.exitCode is set to 1 by fail() (a real mutation on the actual process object the whole vitest worker runs in, not something stubbed here) and both tests assert expect(process.exitCode).toBe(1), but this afterEach never puts it back.
This codebase already has a named guard for exactly this hazard: test/agent-routing-telemetry.test.ts captures the prior value in beforeEach and restores it in afterEach, with the comment "The command sets process.exitCode on the process the suite runs in, so it has to be put back or one failing topic would fail the whole run." That reasoning applies identically here — after this file's tests run, the real Node process (or its vitest worker) is left with exitCode = 1, which can surface as a "worker exited with a non-zero exit code" failure or a spuriously-failing overall test run, independent of whether every assertion in this file passed.
Suggest capturing process.exitCode in beforeEach and restoring it in afterEach, the same way agent-routing-telemetry.test.ts does.
Stack (root → tip):
Summary
rule create/rule improvewrite files as they process each generated rule, and refuse when a rule arrives as a file set (files) but also carries a straytestsfield — that shape is unrepresentable in the published schema. Both guards (duplicated betweencreateandimprove,rules.ts~:253-268 and ~:536-550) threw a bareCLIErrorfrom inside the command's owntry, never touching the command'sfail()helper. Under--jsonthat produced no envelope at all: stdout empty, prose on stderr, exit 1 — indistinguishable from a crash, and theRULE_GENERATION_FAILEDcode thecreate-remote-rulerecipe documents as a branch target was never actually reachable through this path.What changed
fail(), which writes{ ok: false, code, message }to stdout under--jsonand marks the errorreported.fileSetTestsFieldError()helper shared bycreateandimprove— two unmaintained copies is how they drift, and neither had a test before this one did.Two things I decided deliberately
The duplication. Both copies check the exact same condition (
isFileSetRule(rule) && rule.tests !== undefined) with the exact same remedy message, over the sameGeneratedRuletype — genuinely shared behavior, not two things that happen to look alike. Extracted to a pure function (fileSetTestsFieldError) that returns the message-or-undefined; each command still calls its ownfail()with the result, sincefailcloses over that command'sargs.json/process.exitCodeand isn't itself shareable without bigger surgery.Rules written earlier in the loop. By the time the guard fires,
writtenFilesalready holds every rule file written earlier in this same delivery — the issue is right that the caller currently has no way to learn what landed on disk before the refusal. I judged this out of scope: the published--jsonerror envelope (CLIErrorEnvelopeintypes/errors.ts—{ ok, code, message }) has no field for a partial file list, and adding one is a schema/contract change, not a routing fix. (I also avoided touchingpackages/cli/src/schemas/per the task's file boundaries.) Noted explicitly in the changeset so it isn't silently dropped.Tests
Added
packages/cli/test/rule-guard-json-envelope.test.ts, driving the actual command (via citty's ownrunCommand, argv-parsed exactly like the built CLI) for bothrule create --jsonandrule improve --jsonagainst a mocked delivery carryingfiles+tests, asserting on the parsed envelope'scode(RULE_GENERATION_FAILED), not just a non-zero exit — this is the exact seam the issue calls out: "a unit test proving the function throws correctly says nothing about whether the command reports it correctly."Mutation check (both tests): reverted
fail(strayTestsError, "RULE_GENERATION_FAILED")back to a barethrow new CLIError(...)at both call sites → both tests failed (expected undefined to be 1, i.e.process.exitCodewas never set and no envelope was ever logged) → restored the fix → both tests passed again.Verification
pnpm typecheck— cleanpnpm exec vitest run(fullpackages/clisuite) — 1371/1372 passing; the one failure (vale-run.test.tstimeout-blocking test, a 1ms-timeout race) is pre-existing and unrelated — reproduced in isolation and it passes on its own, confirmed not touched by this changepnpm lint(build + house-stylecheck) — cleanpnpm build) and ran it end-to-end against a local mock API server with a payload carrying bothfilesandtests:{"ok":false,"code":"RULE_GENERATION_FAILED","message":"Rule \"guard-test-rule\" was delivered as a file set and also carries `tests`; a file set's fixtures belong in its own `.tests/` files."}guard-test-rule.ymlwas in fact written to.taskless/rules/sg/guard-test-rule/before the refusal — illustrating the "rules already written" gap called out above.Changeset
One changeset,
patch(pre-1.0, per repo convention).Fixes #280