Skip to content

fix(rules): let a terminal unsupported say why - #231

Merged
thecodedrift merged 2 commits into
mainfrom
fix/unsupported-reason
Sep 1, 2026
Merged

fix(rules): let a terminal unsupported say why#231
thecodedrift merged 2 commits into
mainfrom
fix/unsupported-reason

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Answers the generator team's N7, which asked — before landing the change that starts producing them — whether a terminal unsupported reaches a user.

It does. What reaches them is the wrong reason.

unsupported is already handled as terminal: it calls fail() with RULE_UNSUPPORTED, printed to stderr or carried in the --json envelope. But the message was hardcoded to an entitlement explanation and never read status.error:

This rule generation isn't available on your current Taskless plan. … Ask your Taskless administrator or upgrade your plan to enable it.

That was accurate while the only route to unsupported was an account lacking a capability. The service now also terminates a request when the CLI is below the floor a runtime rule needs, with a reason that says so — so a user on 0.10.x would be told to upgrade their plan. They ask an administrator for a capability they already have, and the one command that would fix it is never mentioned.

Worth noting the asymmetry that hid it: failed already surfaced status.error. unsupported did not.

The fix

The service's reason wins whenever it sends one; the entitlement text stays as the fallback for an unsupported that arrives bare. A blank or whitespace-only reason counts as none, rather than printing a heading with nothing under it.

Extracted to rules/unsupported.ts so the message is testable without driving the whole command, matching the reload-notice precedent from #228.

The tests pin the regression directly rather than just the happy path: given a CLI-floor reason the output must not mention a plan or an administrator, and no branch may read as a failure — unsupported is deliberately not failed, and a reader told otherwise goes hunting a service bug instead of the thing they have to change.

Two notes back to the generator

N6 is merged but not deployed. Their status line says shipped in #130, and the live schema still has unsafe as {expected, file, got} with no ruleId:

unsafe   required=['expected', 'file', 'got']
missing  required=['file', 'ruleId']

So #230 keeps parsing the id out of the path for now. That is fine and expected mid-flight; flagging it because "merged" and "generate against it" are not the same moment, which is the lesson from the last time.

The schema still documents error as failed-only. "Error message (present when status is failed)". If it now rides along with unsupported too, the description should say so, or a generated client's own docs will tell the next reader it cannot be there.

Verification

1092/1092 tests, typecheck, lint, and pnpm cli check clean.

Independent of the alignment stack (#229#230), which does not touch this file.

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 1, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 1m 52s —— View job


Review: fix(rules): let a terminal unsupported say why

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no prior review threads, reviews, or top-level comments beyond the @claude /review trigger itself — this is the first review of this PR.

Todo

  • Read .prior-review.json (empty — first review)
  • Read the diff (gh pr diff) and all four changed files in full
  • Trace status.error typing back through pollRuleStatus / generated API schema
  • Check for other consumers of RULE_UNSUPPORTED / unsupportedMessage across the repo
  • Review for correctness, security, performance, test adequacy, clarity
  • Post inline findings and this summary

Summary

Straightforward, well-scoped fix. unsupportedMessage(status.error) correctly prefers the service's reason and falls back to the entitlement text only when the reason is undefined, empty, or whitespace-only — matches the stated intent and the changeset. Both call sites (rules.ts:208, :445) were updated identically. The extraction to rules/unsupported.ts is clean, and the new test suite pins the actual regression (CLI-floor reason must not mention "plan" or "administrator", and no branch reads as failed) rather than just the happy path.

Confirmed non-issues: status.error is typed string | undefined on a single flat response object (not a discriminated variant), so reading it in the unsupported case type-checks safely; writeJsonError JSON-encodes the message, so newlines in the two-paragraph message are safe in --json mode; no other file in the repo depends on the old hardcoded string or calls unsupportedMessage.

Findings

[New] packages/cli/src/rules/unsupported.ts:21-22 — Emptiness is checked against reason.trim(), but the untrimmed reason is what gets interpolated into the returned message. A reason with incidental surrounding whitespace passes the blank check but leaks that padding into stderr and into the --json envelope verbatim. Posted inline with a one-line suggestion. Low severity — cosmetic, and today nothing sends a padded reason.

[New] packages/cli/src/agent/create-remote-rule.txt:170 and improve-rule.txt:126 (not part of this diff, so no inline comment was possible — noting here instead) — Both agent-recipe error tables still document RULE_UNSUPPORTED as meaning only "plan lacks this generation type" → "tell the user to enable it; do not retry." After this PR, the same code now also fires for a CLI-version floor. An agent following the recipe literally would give the exact wrong advice this PR fixes for humans, just for automated callers instead — pointing the user at an administrator instead of a CLI upgrade. Worth a follow-up (one row in each table), possibly bundled into this PR since the fix is small.

[New] packages/cli/src/rules/unsupported.ts:21 (style, not a defect) — reason !== undefined && reason.trim() !== "" is equivalent to reason?.trim() used truthily. Posted inline; folding it into the same fix as the trim leak above would resolve both in one edit.

Noted, not raised as findings

  • The PR body already flags that generated/api.d.ts still documents error as "present when status is failed" only, with no mention of unsupported — this is a generated file and the author has already routed it back to the generator team, so it isn't repeated here as an actionable item against this diff.
  • The sibling failed branches (rules.ts:213, :450, unchanged by this PR) interpolate status.error with no undefined guard (Rule generation failed: ${status.error} could print undefined). Pre-existing, out of scope for this fix, mentioned only as a pointer for a separate pass.
  • Minor cross-file duplication between createCommand's and improveCommand's switch/fail() blocks predates this PR (confirmed both call sites were already symmetric before this change) — not introduced or worsened here.
  • A shared isBlank()-style helper doesn't exist yet for the "blank/whitespace counts as absent" idiom, which now appears with three slightly different spellings across packages/cli/src (rules/unsupported.ts, rules/vale/map.ts, schemas/vale-rule.ts). Each site differs enough in shape that extracting a helper is arguable, not clearly warranted — not raised as a blocking finding.

Comment thread packages/cli/src/rules/unsupported.ts Outdated
Comment thread packages/cli/src/rules/unsupported.ts Outdated
Answers the generator's N7, which asked whether a terminal `unsupported`
reaches a user before they land the change that starts producing them.

It does reach a user: `unsupported` is already handled as terminal and
calls `fail()` with `RULE_UNSUPPORTED`, on stderr or in the `--json`
envelope. What reaches them is the wrong reason.

The message was hardcoded to an entitlement explanation and never read
`status.error`, which was accurate while the only route to `unsupported`
was an account lacking a capability. The service now also terminates a
request when the CLI is below the floor a runtime rule needs, with a
reason saying so — and we would have told a user on 0.10.x to upgrade
their PLAN. They ask an administrator for a capability they already
have, and the one command that fixes it is never mentioned. `failed`
already surfaced `status.error`; `unsupported` did not.

So the service's reason wins whenever it sends one, and the entitlement
text is the fallback for an `unsupported` that arrives bare. A blank or
whitespace reason counts as none, rather than printing a heading with
nothing under it.

Extracted to its own module so the message is testable without driving
the command, matching the reload-notice precedent. Tests pin the
regression directly: given a CLI-floor reason the output must not
mention a plan or an administrator, and no branch may read as a failure,
since `unsupported` is deliberately not `failed` and a reader told
otherwise hunts a service bug instead of the thing they have to change.
Review feedback on #231.

The blank check ran on `reason.trim()` and the interpolation then spliced
in `reason`, so a reason arriving with a trailing newline — the ordinary
shape of a templated server string — passed the check and carried its
padding into the printed message and the `--json` envelope.

Trimmed once and used, which also collapses the reviewer's second note:
`reason !== undefined && reason.trim() !== ""` was the long spelling of
the same question.

The gap that let it through is that nothing exercised a reason that was
non-empty but padded, so there is now a test for exactly that. Confirmed
it bites by restoring the raw interpolation.
@thecodedrift
thecodedrift force-pushed the fix/unsupported-reason branch from fa3b1a0 to 3ed03fd Compare September 1, 2026 13:30
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 1m 52s … Review: fix(rules): let a terminal unsupported say why"
#231 (comment)

Both inline findings taken in 3ed03fd — they were the same fix. The blank check ran on reason.trim() while the interpolation spliced in reason, so a reason with a trailing newline passed the check and carried its padding into the message and the --json envelope. Trimmed once and used now, which also collapses the second note.

The sentence worth keeping is "no test currently exercises a whitespace-padded-but-non-empty reason" — that is why it would have reached a user, and a templated server string ending in \n is the ordinary shape, not an exotic one. There is a test for it now, confirmed to bite by restoring the raw interpolation.

Also useful that you verified the three things independently rather than from the description: status.error being string | undefined on a flat response object (so reading it in the unsupported branch type-checks), writeJsonError JSON-encoding the two-paragraph message safely under --json, and no other consumer depending on the old hardcoded string.

Rebased onto current main. 1117/1117 tests, typecheck, lint, and pnpm cli check clean.

— AI Coding Agent

@thecodedrift
thecodedrift merged commit d95343a into main Sep 1, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the fix/unsupported-reason branch September 1, 2026 15:31
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