Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/check-rule-filter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@taskless/cli": patch
---

`taskless check --rule <id>` (repeatable) restricts a run to the named rules, so a rule can be measured over the whole project without running every other rule and filtering the JSON afterwards. The filter applies to both static engines and to runtime rules, keeps every exclusion a whole-project run applies, and refuses an id no rule directory has.
51 changes: 51 additions & 0 deletions openspec/changes/archive/2026-09-22-check-rule-filter/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
## Context

`check --rule <id>` has to report, for the named rule, exactly what an unfiltered `check` reports for that rule. Not approximately: the author writes the number into a branch's record and compares it against the number `check` produces in CI. Any divergence between "the filtered path" and "the unfiltered path" is a wrong number that nothing detects.

That constraint decides the mechanism, engine by engine.

## Decisions

### The issue's suggestion (reuse `buildIsolatingConfig`) is the wrong mechanism for Vale

taskless/cli#379 proposes reusing "the isolating config `test` already assembles for one rule, pointed at the project walk instead of the fixture tree". Read, that config (`rules/vale/verify.ts`) is:

```
StylesPath = <abs>
MinAlertLevel = suggestion

[*]
<id>.<id> = YES
```

`[*]` is the problem. It is correct for a fixture tree, where every document exists to exercise the rule, and wrong for a project walk: a rule scoped `[docs/**.md]` by its own config would, under this config, be measured over every file Vale can read, code included. The count would be larger than `check` reports, and larger in a way that looks like the rule being noisy rather than like the harness being wrong. The config schema calls the same shape out in a rule's own config (`matcher [*] enables <id> for every file Vale can read, code included`).

**Instead, assembly is narrowed.** `assembleValeConfig` takes the selected ids and emits only those rules' blocks, each rule's own matchers verbatim. The rule's scope is then byte-identical to what it is in a full run.

**Narrowing applies to what is written, not to what is validated.** Every Vale rule's config is still read and put through the config schema, and any rejection still refuses the whole assembly; only the surviving blocks are filtered. Validating just the selected rules would let `check --rule good` exit clean in a project where `bad`'s config is rejected, while an unfiltered `check` there refuses the Vale engine and reports nothing for `good` — the filtered run would then report findings the unfiltered run never produced, which is the exact equality this flag rests on.

Removing the other rules' blocks cannot change the surviving rule's effective setting, and that is a fact about the config schema rather than an assumption: a rule's config may only assign its own `<id>.<id>` key — the schema rejects an assignment that "names another rule" — so no removed block could have been turning the selected rule on or off. Vale's positional precedence (last matcher wins; since 3.21.0 last assignment within a matcher wins) has nothing to act on across rules.

### ast-grep narrows with `--filter`, not by narrowing `ruleDirs`

ast-grep 0.45.3 has `scan --filter <REGEX>`: "Scan the codebase with rules with ids matching REGEX." It changes exactly one thing — which loaded rules may report — leaving the config, the walk, `--no-ignore hidden` and the two `--globs` exclusions untouched. The alternative, writing an assembled config whose `ruleDirs` names only the selected rule directories, would have been a second config-generation path to keep in step with the first.

The regex is anchored (`^(?:a|b)$`). Unanchored, `--rule no-eval` would also report `no-eval-in-tests`.

### An unknown id is a refusal, not an empty run

A `--rule` naming nothing runs nothing and reports "0 findings" — which is also what a rule that fires nowhere reports, and that is precisely the answer the author is trying to obtain. The two must not look alike, so an unresolvable id exits 1 with `RULE_NOT_FOUND` and the id in the message.

Resolution happens **before** the "No rules configured" gate, so the message is about the id in every project rather than about the project in some of them.

### An ambiguous id selects both rules

`rules delete` refuses an id held by two engines (`RULE_ID_AMBIGUOUS`) because deleting the wrong one is irreversible. Measuring is neither irreversible nor destructive, and an unfiltered `check` would have run both, so `--rule` runs both and each finding carries its engine in `source`.

### `--rule` is read from raw argv

The flag is repeatable, and a parser that collapses a repeat to one value turns `--rule a --rule b` into a measurement of one rule while the author reads the number as covering two. Values are scanned out of `rawArgs` (both `--rule a` and `--rule=a`, stopping at `--`), and `--rule` is added to the value-taking flags the shared positional scanner knows — without that, `check --rule no-eval` scans `no-eval` as a path, finds no such file, and takes the "every supplied path was filtered out" branch: a clean exit 0 with no findings, indistinguishable from the measurement the author wanted.

### An engine with nothing selected is skipped, not filtered to nothing

Handing ast-grep a filter that matches no rule still spawns it, loads every rule and walks the project to report none. When the selection contains no `sg` rule the engine is skipped outright; Vale assembly returns `undefined` for the same case, which dispatch already reads as "nothing to run".
50 changes: 50 additions & 0 deletions openspec/changes/archive/2026-09-22-check-rule-filter/proposal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
## Why

An author iterating on a new rule wants one number: how many times does this rule fire across the repository, before it ships at `warning`. `check` has no way to ask that. It runs every rule in `.taskless/rules/`, so the documented workaround is to run everything and filter afterwards:

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

That runs every engine and every rule to answer a question about one. On the dogfood repository (~1,100 markdown files, eleven voice rules) it is the slow path on every iteration of a branch. `test <path>` does isolate one rule, but it runs only that rule's fixtures and never the project, so it cannot answer the question at all.

## What Changes

- `taskless check --rule <id>`, repeatable, restricting the run to the named rules. `--rule a --rule b` measures both.
- The narrowing is per engine, because the engines narrow by different mechanisms:
- **ast-grep**: `sg scan --filter '^(?:a|b)$'`, ast-grep's own flag for scanning with a subset of the rules a config loads. Everything else about the invocation — the config, the walk, `--no-ignore hidden`, the `--globs` exclusions — is byte-identical to an unfiltered run.
- **Vale**: the assembled `.vale.ini` is built from only the selected rules, each rule's own matchers kept verbatim. Vale has no rule-selection flag, so the config is the only place to express it.
- **runtime**: the discovered rule list is filtered before planning, so `--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 a **refusal** (`RULE_NOT_FOUND`, exit 1, the id named). A typo that silently measured nothing would report "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. `check` with no filter would have run both, and `--rule` narrows a run rather than redefining it.

Nothing here is **BREAKING**. Pre-1.0, an added flag is a `patch`; nothing that exists today changes behavior when `--rule` is absent.

## Non-goals

- `--rule` does not take a path. `test` takes a path, `check --rule` takes an id, which is what a finding carries in `ruleId` and what the author reads out of the JSON.
- `--rule` does not override the runtime signature gate, and does not re-enable a rule the project has removed.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `cli-check`: one new requirement, "Check restricts the run to named rules with --rule". Nothing existing is modified — `--rule` narrows a run the way positional paths already do, and the auth, dispatch and exit-code requirements are unchanged.

## Impact

- `packages/cli/src/commands/check.ts`: the `--rule` flag, its repeatable argv parsing, and the resolution/refusal.
- `packages/cli/src/rules/rule-filter.ts` (new): resolve requested ids into a per-engine selection, or refuse.
- `packages/cli/src/rules/scan.ts`: `--filter` argv for ast-grep.
- `packages/cli/src/rules/assemble.ts`: Vale assembly accepts a rule-id narrowing.
- `packages/cli/src/rules/dispatch.ts`: carries the ast-grep selection through to the scan.
- `packages/cli/test/check-rule-filter.test.ts` (new).
- `packages/cli/src/agent/create-vale-rule.md`: the corpus-count passage should name the flag. **Deliberately not touched here** — that file is being edited on another branch, and the recipe change is a follow-up (taskless/cli#379, last bullet).

## Delivery shape

**Single PR.** One flag, its per-engine plumbing, its tests, the spec delta and the archive fit one reviewable diff, and the change is safe in production on its own: with `--rule` absent every code path is the one that shipped.
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
## ADDED Requirements

### Requirement: Check restricts the run to named rules with --rule

The `check` subcommand SHALL accept a `--rule <id>` flag, repeatable, naming the rules the run is restricted to. When `--rule` is absent the run SHALL be unchanged. When one or more are given, the CLI SHALL report findings only for the named rules, and for each named rule SHALL report exactly what an unfiltered `check` over the same paths would have reported for it: the same walk, the same exclusions (`.taskless/`, `.git/`, git-ignored paths, converter-dependent formats), and the same per-rule scope.

A rule id is the name of a rule's directory under `.taskless/rules/<engine>/`. The filter SHALL apply to every engine. An id whose rule directory exists under more than one engine SHALL select the rule under each of them. An id that names no rule directory under any engine SHALL be refused: the CLI SHALL exit with code 1 and report the error code `RULE_NOT_FOUND` naming the unresolved id, and SHALL NOT run any engine.

`--rule` SHALL NOT widen what may run. A runtime rule named by `--rule` SHALL remain subject to the signature-validated path, and SHALL be reported as skipped on an unverified path exactly as it would be in an unfiltered run.

#### Scenario: A single --rule narrows the run to that rule

- **WHEN** a user runs `taskless check --rule <id>` in a project with rules beyond `<id>`
- **THEN** the results SHALL contain findings for `<id>` only
- **AND** they SHALL be the findings an unfiltered `taskless check` reports for `<id>`

#### Scenario: Repeated --rule unions the named rules

- **WHEN** a user runs `taskless check --rule a --rule b`
- **THEN** the results SHALL contain the findings for `a` and the findings for `b`, and no others

#### Scenario: The filter applies to both static engines

- **WHEN** a user names an ast-grep rule with `--rule`, and separately names a Vale rule
- **THEN** each run SHALL report that rule's findings over the whole project
- **AND** a Vale rule SHALL be measured under its own config's matchers, not over every file Vale can read

#### Scenario: Whole-project exclusions still apply under --rule

- **WHEN** a user runs `taskless check --rule <id>` with no positional paths in a project with git-ignored directories
- **THEN** the CLI SHALL NOT report findings from `.taskless/`, `.git/`, or git-ignored paths

#### Scenario: A refused sibling config refuses a filtered run too

- **WHEN** a user runs `taskless check --rule <id>` in a project where a DIFFERENT Vale rule's config is rejected by the config schema
- **THEN** the CLI SHALL refuse the Vale engine and exit non-zero, exactly as an unfiltered `taskless check` does
- **AND** the refusal SHALL name the rejected rule even though `--rule` did not select it

#### Scenario: An unknown rule id is refused

- **WHEN** a user runs `taskless check --rule <id>` and no engine directory holds a rule directory named `<id>`
- **THEN** the CLI SHALL exit with code 1
- **AND** under `--json` stdout SHALL carry the standardized error envelope with code `RULE_NOT_FOUND` and a message naming `<id>`
- **AND** the CLI SHALL NOT run any engine

#### Scenario: An id held by two engines selects both rules

- **WHEN** a user runs `taskless check --rule <id>` and `<id>` names a rule directory under two engines
- **THEN** the CLI SHALL run both rules and SHALL report the findings of each, distinguished by the `source` field

#### Scenario: --rule does not bypass the runtime signature gate

- **WHEN** a user runs `taskless check --rule <id>` where `<id>` is a runtime rule and the run is on an unverified path
- **THEN** the CLI SHALL NOT execute that rule's `check.ts`
- **AND** SHALL report it as skipped exactly as an unfiltered `check` would

#### Scenario: --rule leaves positional path arguments intact

- **WHEN** a user runs `taskless check --rule <id> src/foo.ts`
- **THEN** the CLI SHALL treat `src/foo.ts` as the path to scan and `<id>` as the rule filter
- **AND** SHALL NOT treat `<id>` as a path
27 changes: 27 additions & 0 deletions openspec/changes/archive/2026-09-22-check-rule-filter/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## 1. Design

- [x] 1.1 Read `buildIsolatingConfig` and establish whether it is the right mechanism for a project walk (it is not: its `[*]` matcher discards the rule's own scope).
- [x] 1.2 Confirm the vendored ast-grep exposes a rule-id filter for `scan` (`--filter <REGEX>`, 0.45.3) and that the config schema forbids a rule assigning another rule's key.

## 2. Implementation

- [x] 2.1 `packages/cli/src/rules/rule-filter.ts`: resolve requested ids into a per-engine selection; refuse an unknown id with `RULE_NOT_FOUND`.
- [x] 2.2 `packages/cli/src/rules/scan.ts`: `sgFilterArgv` emitting an anchored `--filter`, threaded through `runAstGrepScan`.
- [x] 2.3 `packages/cli/src/rules/assemble.ts`: `AssembleOptions.ruleIds` narrowing the Vale assembly.
- [x] 2.4 `packages/cli/src/rules/dispatch.ts`: carry the ast-grep selection to the scan.
- [x] 2.5 `packages/cli/src/commands/check.ts`: the `--rule` flag, repeatable argv parsing, `--rule` as a value-taking flag for the positional scanner, selection applied to all three engines.
- [x] 2.6 `.changeset/check-rule-filter.md` (`patch`).

## 3. Tests

- [x] 3.1 `packages/cli/test/check-rule-filter.test.ts`: a single `--rule` narrows an ast-grep run and a Vale run; repeated `--rule` unions across engines; an unknown id errors naming it; the gitignore exclusions still hold under a filter; the filtered result equals the unfiltered run's findings for that id, for both engines.
- [x] 3.2 Unit coverage for the repeatable argv parsing and the anchored filter argv.

## 4. Spec

- [x] 4.1 Add the `cli-check` requirement "Check restricts the run to named rules with --rule" as an ADDED block; nothing existing needs modifying.
- [x] 4.2 `pnpm openspec validate check-rule-filter --strict`, then the dry-run archive check from CLAUDE.md (scenario count before vs after), then archive for real.

## 5. Verification

- [x] 5.1 `pnpm build`, `pnpm typecheck`, `pnpm lint`, `pnpm --filter @taskless/cli test` all pass.
60 changes: 60 additions & 0 deletions openspec/specs/cli-check/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,3 +395,63 @@ SHALL be the only way to execute runtime rules on an unverified path.

- **WHEN** the `vale` binary is unavailable but `.taskless/sg/` has rules
- **THEN** the CLI reports the Vale engine as unavailable and still returns ast-grep results

### Requirement: Check restricts the run to named rules with --rule

The `check` subcommand SHALL accept a `--rule <id>` flag, repeatable, naming the rules the run is restricted to. When `--rule` is absent the run SHALL be unchanged. When one or more are given, the CLI SHALL report findings only for the named rules, and for each named rule SHALL report exactly what an unfiltered `check` over the same paths would have reported for it: the same walk, the same exclusions (`.taskless/`, `.git/`, git-ignored paths, converter-dependent formats), and the same per-rule scope.

A rule id is the name of a rule's directory under `.taskless/rules/<engine>/`. The filter SHALL apply to every engine. An id whose rule directory exists under more than one engine SHALL select the rule under each of them. An id that names no rule directory under any engine SHALL be refused: the CLI SHALL exit with code 1 and report the error code `RULE_NOT_FOUND` naming the unresolved id, and SHALL NOT run any engine.

`--rule` SHALL NOT widen what may run. A runtime rule named by `--rule` SHALL remain subject to the signature-validated path, and SHALL be reported as skipped on an unverified path exactly as it would be in an unfiltered run.

#### Scenario: A single --rule narrows the run to that rule

- **WHEN** a user runs `taskless check --rule <id>` in a project with rules beyond `<id>`
- **THEN** the results SHALL contain findings for `<id>` only
- **AND** they SHALL be the findings an unfiltered `taskless check` reports for `<id>`

#### Scenario: Repeated --rule unions the named rules

- **WHEN** a user runs `taskless check --rule a --rule b`
- **THEN** the results SHALL contain the findings for `a` and the findings for `b`, and no others

#### Scenario: The filter applies to both static engines

- **WHEN** a user names an ast-grep rule with `--rule`, and separately names a Vale rule
- **THEN** each run SHALL report that rule's findings over the whole project
- **AND** a Vale rule SHALL be measured under its own config's matchers, not over every file Vale can read

#### Scenario: Whole-project exclusions still apply under --rule

- **WHEN** a user runs `taskless check --rule <id>` with no positional paths in a project with git-ignored directories
- **THEN** the CLI SHALL NOT report findings from `.taskless/`, `.git/`, or git-ignored paths

#### Scenario: A refused sibling config refuses a filtered run too

- **WHEN** a user runs `taskless check --rule <id>` in a project where a DIFFERENT Vale rule's config is rejected by the config schema
- **THEN** the CLI SHALL refuse the Vale engine and exit non-zero, exactly as an unfiltered `taskless check` does
- **AND** the refusal SHALL name the rejected rule even though `--rule` did not select it

#### Scenario: An unknown rule id is refused

- **WHEN** a user runs `taskless check --rule <id>` and no engine directory holds a rule directory named `<id>`
- **THEN** the CLI SHALL exit with code 1
- **AND** under `--json` stdout SHALL carry the standardized error envelope with code `RULE_NOT_FOUND` and a message naming `<id>`
- **AND** the CLI SHALL NOT run any engine

#### Scenario: An id held by two engines selects both rules

- **WHEN** a user runs `taskless check --rule <id>` and `<id>` names a rule directory under two engines
- **THEN** the CLI SHALL run both rules and SHALL report the findings of each, distinguished by the `source` field

#### Scenario: --rule does not bypass the runtime signature gate

- **WHEN** a user runs `taskless check --rule <id>` where `<id>` is a runtime rule and the run is on an unverified path
- **THEN** the CLI SHALL NOT execute that rule's `check.ts`
- **AND** SHALL report it as skipped exactly as an unfiltered `check` would

#### Scenario: --rule leaves positional path arguments intact

- **WHEN** a user runs `taskless check --rule <id> src/foo.ts`
- **THEN** the CLI SHALL treat `src/foo.ts` as the path to scan and `<id>` as the rule filter
- **AND** SHALL NOT treat `<id>` as a path
Loading
Loading