Skip to content

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

Merged
thecodedrift merged 2 commits into
mainfrom
feat/check-rule-filter
Sep 22, 2026
Merged

thecodedrift merged 2 commits 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)

Comment thread packages/cli/src/rules/rule-filter.ts Outdated
Comment thread packages/cli/src/commands/check.ts
Comment thread packages/cli/src/rules/assemble.ts Outdated
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.

@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 5m 49s — Review complete…"
#385 (comment)

All three medium findings verified against the code and fixed in b1e0d52: Vale configs are now validated for every rule (only the written blocks are narrowed), resolveRuleSelection reuses findRuleEngines, and extractRuleFilters reads the shared splitRawArguments scan. Added a broken-sibling test that fails against the old narrowing. The one low-priority efficiency note is left for the author to decide on.

— AI Coding Agent

`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.
… scan

`assembleValeConfig` narrowed `ruleIds` before the validation loop, so an
unselected rule's config was never read and could never contribute to
`refusals`. In a project with a schema-rejected `bad-rule`, an unfiltered
`check` refused the whole Vale engine and exited non-zero, while
`check --rule good-rule` assembled and ran clean — the filtered run reporting
findings the unfiltered run never produces, which is the one equality the flag
rests on. Narrowing now applies to which validated blocks are written, never to
which rules are validated.

`resolveRuleSelection` recomputed "which engines hold this id" from a locally
built per-engine map; it now calls `findRuleEngines`, the one implementation
that already answers it, which also stops `check` walking every rule directory
twice.

`extractRuleFilters` scanned argv itself and so did not know that `--timeout`
consumes the token after it: for `check --timeout --rule no-eval` it claimed
`no-eval` as a rule id while `splitRawArguments` had already made it a path.
`splitRawArguments` now reports each value-taking flag's value, in both
spellings, and `extractRuleFilters` reads that one pass.
@thecodedrift
thecodedrift merged commit 1c971cf into main Sep 22, 2026
4 checks passed
@thecodedrift
thecodedrift deleted the feat/check-rule-filter branch September 22, 2026 21:08
thecodedrift added a commit that referenced this pull request Sep 22, 2026
`check --rule <id>` selects every engine holding the id, and #385's test
proved it by seeding `vale/no-eval` beside the fixture's `sg/no-eval`.
Migration 9 now renames exactly that state, and `runCli` migrates on every
invocation through `migrateFixture`, so the collision was renamed to
`no-eval-sg`/`no-eval-vale` before `check` ever saw it: `--rule no-eval`
exited `RULE_NOT_FOUND` and the test died reading `.map` of an undefined
`results`.

The migration invalidated the setup, not the behaviour. An id held by two
engines still selects both, and a project can still reach that state — by
hand, or by a merge landing a same-id rule under another engine — which is
the case the new per-rule check in `verify` exists to catch. So the fixture
is migrated first and the second engine's copy seeded after, with a comment
naming migration 9 so the setup is not "simplified" back.

Also names the consequence in the changeset: an id passed to `--rule`
yesterday may not exist today, and that failure is `RULE_NOT_FOUND` rather
than a quiet zero findings.
thecodedrift added a commit that referenced this pull request Sep 22, 2026
`check --rule <id>` selects every engine holding the id, and #385's test
proved it by seeding `vale/no-eval` beside the fixture's `sg/no-eval`.
Migration 9 now renames exactly that state, and `runCli` migrates on every
invocation through `migrateFixture`, so the collision was renamed to
`no-eval-sg`/`no-eval-vale` before `check` ever saw it: `--rule no-eval`
exited `RULE_NOT_FOUND` and the test died reading `.map` of an undefined
`results`.

The migration invalidated the setup, not the behaviour. An id held by two
engines still selects both, and a project can still reach that state — by
hand, or by a merge landing a same-id rule under another engine — which is
the case the new per-rule check in `verify` exists to catch. So the fixture
is migrated first and the second engine's copy seeded after, with a comment
naming migration 9 so the setup is not "simplified" back.

Also names the consequence in the changeset: an id passed to `--rule`
yesterday may not exist today, and that failure is `RULE_NOT_FOUND` rather
than a quiet zero findings.
thecodedrift added a commit that referenced this pull request Sep 22, 2026
`check --rule <id>` selects every engine holding the id, and #385's test
proved it by seeding `vale/no-eval` beside the fixture's `sg/no-eval`.
Migration 9 now renames exactly that state, and `runCli` migrates on every
invocation through `migrateFixture`, so the collision was renamed to
`no-eval-sg`/`no-eval-vale` before `check` ever saw it: `--rule no-eval`
exited `RULE_NOT_FOUND` and the test died reading `.map` of an undefined
`results`.

The migration invalidated the setup, not the behaviour. An id held by two
engines still selects both, and a project can still reach that state — by
hand, or by a merge landing a same-id rule under another engine — which is
the case the new per-rule check in `verify` exists to catch. So the fixture
is migrated first and the second engine's copy seeded after, with a comment
naming migration 9 so the setup is not "simplified" back.

Also names the consequence in the changeset: an id passed to `--rule`
yesterday may not exist today, and that failure is `RULE_NOT_FOUND` rather
than a quiet zero findings.
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