Skip to content

feat(check): restrict a run to named rules with --rule - #385

Open
thecodedrift wants to merge 1 commit into
mainfrom
feat/check-rule-filter
Open

thecodedrift wants to merge 1 commit into
mainfrom
feat/check-rule-filter

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Why

An author iterating on a new rule wants one number: how often does this rule fire across the repository. check had no way to ask it, so the documented workaround ran every engine and every rule and filtered afterwards:

taskless check --json | jq '[.results[] | select(.ruleId == "<id>")] | length'

On the dogfood repository (~1,100 markdown files, eleven voice rules) that is the slow path on every iteration of a branch. test <path> isolates one rule but runs only its fixtures, never the project.

What

taskless check --rule <id>, repeatable. --rule a --rule b measures both.

The load-bearing claim is an equality: what --rule <id> reports has to be exactly what an unfiltered check reports for that id, because the author records that number against a branch and compares it to CI. Each engine therefore narrows by the mechanism that leaves everything else alone:

Engine Mechanism Why
ast-grep sg scan --filter '^(?:a|b)$' ast-grep's own flag for scanning with a subset of a config's rules. Config, walk, --no-ignore hidden and the --globs exclusions are byte-identical to an unfiltered run. Anchored, or --rule no-eval would also report no-eval-in-tests.
Vale the assembled .vale.ini is built from only the selected rules Vale has no rule-selection flag. Each rule's own matchers are kept verbatim, so its scope is unchanged.
runtime the discovered rule list is filtered before planning --rule narrows what may run and never widens it: a runtime rule named here still faces the signature gate.

An id that names no rule directory under any engine is refused (RULE_NOT_FOUND, exit 1, the id in the message). A typo that silently measured nothing reports "0 findings", which is also what a clean rule reports, and those are the two answers the author is choosing between. An id held by two engines selects both, unlike rules delete, which refuses an ambiguous id because deleting the wrong rule is irreversible.

The issue's suggested mechanism was not the right one

#379 proposes reusing buildIsolatingConfig, the config test assembles for one rule, "pointed at the project walk instead of the fixture tree". That config scopes the rule under [*], which is right for a fixture tree and wrong for a project: a rule scoped [docs/**.md] by its own config would be measured over every file Vale can read, code included, and the count would come out larger than check reports in a way that looks like a noisy rule rather than a broken harness. Narrowing assembly instead keeps the rule's real scope. design.md in the change records the reasoning, including why removing the other rules' blocks cannot alter the surviving one (the config schema rejects a rule assigning another rule's key).

Tests

packages/cli/test/check-rule-filter.test.ts, spawning the built CLI over the mixed-engine fixture: a single --rule narrows an ast-grep run and a Vale run; repeated --rule unions across engines; an unknown id errors naming it; the git-ignore exclusions still hold under a filter; the filtered result equals the unfiltered run's findings for that id, for both engines; two engines holding one id run both. Plus unit coverage for the repeatable argv parsing and the anchored filter argv.

pnpm typecheck, pnpm lint and pnpm --filter @taskless/cli test (1658 tests) pass.

Notes

  • Changeset is patch: pre-1.0, an added flag does not earn a minor, and with --rule absent every path is the one that shipped.
  • OpenSpec change check-rule-filter is archived on this PR (single PR, declared in the proposal). Its delta is ADDED-only; the archive dry-run confirmed the cli-check spec went from 46 scenarios to 54 with none dropped.
  • check: run a single rule over the project to measure a new branch #379's last bullet (name the flag in the create-vale-rule corpus-count passage) is left as a follow-up: that recipe is being edited on another branch right now.

Fixes #379
Refs #362

@claude /review

@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 22, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 5m 49s —— View job


Review complete

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contained no prior review threads, review summaries, or comments beyond the @claude /review trigger itself, so this is the first review of this PR — every finding below is new.

  • Read .prior-review.json (no prior threads/reviews/comments found — first review)
  • Gather PR diff via gh pr diff
  • Read changed files for context (check.ts, assemble.ts, dispatch.ts, scan.ts, rule-filter.ts, layout.ts, engines.ts)
  • Run multi-angle review (correctness, cross-file, cleanup, altitude, conventions)
  • Verify candidate findings against the diff and surrounding code
  • Post inline comments and final summary

Findings

1. [New] packages/cli/src/rules/assemble.ts:214 — Vale's fail-closed refusal is bypassed under --rule. assembleValeConfig narrows ruleIds to the selection before validating each rule's config. A sibling rule excluded by the filter is never read or validated, so it can never contribute to refusals. In an unfiltered check, one broken Vale rule anywhere refuses the entire Vale engine (fail-closed, exit 1, zero findings from any Vale rule). Under check --rule good-rule with a different, broken bad-rule present, bad-rule is simply never looked at, so good-rule assembles and runs normally — producing real findings and a possible clean exit where the unfiltered run would have reported 0 findings for it plus a failure. This contradicts the PR's own stated invariant in design.md ("what --rule <id> reports has to be exactly what an unfiltered check reports for that id"), and no test in check-rule-filter.test.ts covers a broken sibling rule under --rule. This is the one finding I'd treat as blocking.

2. [New] packages/cli/src/rules/rule-filter.ts:22 — Reuse. enginesHolding reimplements findRuleEngines (already in engines.ts, doing the same "which engine(s) hold this id" lookup). Not a functional bug today, but two independent implementations of the same fact can drift.

3. [New] packages/cli/src/commands/check.ts:238 — Minor efficiency. resolveRuleSelection already reads all three engines' rule directories internally; check.ts re-reads listRuleIds(cwd, "sg") (line 265) and listRuleIds(cwd, "vale") (line 380) again shortly after, discarding what was already fetched. Cheap in absolute terms, but avoidable.

4. [New] packages/cli/src/commands/check.ts:92 — Minor parsing inconsistency. extractRuleFilters hand-scans rawArgs instead of extending the shared splitRawArguments, so it doesn't know --timeout/-d/--dir also consume the next token. For the malformed invocation check --timeout --rule no-eval, extractPositionalPaths (via splitRawArguments) treats --rule as --timeout's value and no-eval as a positional path, while extractRuleFilters independently treats --rule as a real flag and consumes no-eval as the rule id. Low severity — only triggers on an already-malformed --timeout invocation.

What looked solid

  • The core per-engine design (ast-grep --filter, Vale config narrowing, runtime pre-planning filter) matches what design.md/proposal.md describe, and the ast-grep --filter regex is correctly anchored and escaped (scan.ts).
  • RULE_NOT_FOUND resolution correctly runs before the "no rules configured" gate and before dispatch, and is handled for both --json and text output.
  • The ambiguous-id-selects-both-engines behavior, the engine-skip-when-empty-selection logic (astGrepConfigPath forced to undefined when selection.sg is empty), and the runtime signature-gate interaction all check out against their call sites.
  • assembleEngineConfigs/assembleValeConfig/runAstGrepScan/runEngines signature changes are backward compatible (new params optional) — checked all other callers (verify.ts, etc.), none break.
  • OpenSpec delta is ## ADDED Requirements only (no MODIFIED block), so the "delta replaces a requirement" archive footgun from the root CLAUDE.md doesn't apply here.
  • Changeset bump (patch) is correct per the root CLAUDE.md pre-1.0 guidance.
    (branch: feat/check-rule-filter)

}

/** Whether `id` names a rule directory under any engine, engine by engine. */
function enginesHolding(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] Reuse: packages/cli/src/rules/engines.ts already exports findRuleEngines(cwd, id): Promise<EngineName[]>, documented as "Every engine whose directory holds id... Empty when no engine does" — the exact fact enginesHolding recomputes here from a locally-built byEngine map. Two independent implementations of "which engine(s) hold this rule id" now exist (one stat-based per id, one list-based batched over all ids); a future change to rule-identity resolution (e.g. case-insensitive ids, or a new engine) only needs to update one of them to silently diverge from the other. Not a functional bug today — listRuleIds/subdirectories and findRuleEngines's per-engine stat should agree — but worth calling out as the kind of duplication STYLEGUIDE-CODE.md's "Reuse" concerns target.

* `--rule=a`), and scanning stops at `--` so a path literally named `--rule`
* after the end-of-options marker is a path.
*/
export function extractRuleFilters(rawArguments: string[]): string[] {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] extractRuleFilters hand-rolls its own argv scan instead of extending the shared splitRawArguments (util/argv.ts), which already tracks which flags consume the next token (-d/--dir, and now --timeout/--rule via VALUE_FLAGS). Because this function only recognizes --rule/--rule=/--, it has no awareness that --timeout (or -d/--dir) also consumes the following token.

Concretely: for check --timeout --rule no-eval (a malformed invocation — --timeout given no value), extractPositionalPaths (via splitRawArguments, which does know --timeout is value-consuming) treats --rule as --timeout's value and no-eval as a positional path. extractRuleFilters, scanning the same rawArgs independently, has no such awareness, sees --rule as a real flag, and consumes no-eval as the rule id. The two scanners disagree about what the same tokens mean. Low-severity since it only triggers on a malformed --timeout/-d invocation immediately followed by --rule, but it's a real inconsistency introduced by not sharing the scanner.

options: AssembleOptions = {}
): Promise<ValeAssembly | undefined> {
const ruleIds = await listRuleIds(cwd, "vale");
const ruleIds = selectRuleIds(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] This filtering bypasses the fail-closed Vale refusal for rules the filter excludes, which breaks the PR's own equality invariant.

ruleIds is narrowed to the selected ids here, before the validation loop starting a few lines below (for (const ruleId of ruleIds) { ... validateValeRuleConfig ... }). That means an unselected sibling rule's config is never read, never validated, and can never contribute to refusals.

Concretely: in an unfiltered check, if any Vale rule's config is schema-rejected (e.g. it assigns another rule's <id>.<id> key — a real rejection per schemas/vale-config.ts), refusals.length > 0 and the whole Vale engine is refused (dispatch.ts's runValeEngine treats status: "refused" as a failure — no Vale rule runs, exit code 1). That's deliberate fail-closed behavior per RefusedValeConfig's docstring.

But check --rule good-rule in the same project, where a different rule bad-rule is broken and good-rule is not, never even looks at bad-rule's config (it's filtered out of ruleIds before the loop runs), so refusals stays empty and good-rule assembles and runs normally, producing real findings and (possibly) a clean exit — while the unfiltered run reports 0 findings for good-rule (Vale never ran at all) plus a failure.

That directly contradicts design.md's stated constraint: "what --rule <id> reports has to be exactly what an unfiltered check reports for that id". None of the new tests in check-rule-filter.test.ts cover a broken sibling rule under --rule, so this isn't caught.

A fix consistent with the design's own reasoning would be to still validate every rule (for refusal purposes) and only narrow which validated blocks are written into the assembled config, rather than narrowing which rules get validated at all.

let mutableSelection: RuleSelection | undefined;
if (requestedRules.length > 0) {
try {
mutableSelection = await resolveRuleSelection(cwd, requestedRules);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] Efficiency (minor): resolveRuleSelection here already reads every engine's rule directory via listRuleIds(cwd, "sg" | "vale" | "runtime") internally. A few lines below, astGrepRuleIds = await listRuleIds(cwd, "sg") (line 265) and later valeRuleIds = await listRuleIds(cwd, "vale") (line 380) each re-read the same directories from scratch, discarding the lists resolveRuleSelection already fetched moments earlier. Not expensive (a couple of extra readdirs), but RuleSelection — or a small helper alongside it — could expose the unfiltered per-engine lists so check doesn't walk the same rule directories twice per run.

`taskless check --rule <id>` (repeatable) measures one rule over the whole
project, instead of running every rule and filtering the JSON afterwards.

Each engine narrows by its own mechanism, so a filtered run reports exactly
what an unfiltered run reports for that rule: ast-grep gets an anchored
`--filter`, leaving the config and the walk byte-identical; Vale's config is
assembled from only the selected rules, each rule's own matchers verbatim, so
its scope is unchanged; runtime rules are filtered before planning, so the
signature gate still applies. An id no rule directory has is refused with
RULE_NOT_FOUND naming it, because a typo that measured nothing reports the
same "0 findings" a clean rule does.

This branch has not been deployed

No deployments
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.

check: run a single rule over the project to measure a new branch

1 participant