diff --git a/.changeset/agent-topics-toc.md b/.changeset/agent-topics-toc.md new file mode 100644 index 00000000..90bbb3d6 --- /dev/null +++ b/.changeset/agent-topics-toc.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless agent ` now prints a long topic as an outline that fits one agent tool call: its steps, then a list of sections it serves on request. Print one with `taskless agent --section `, or the whole recipe with `--full`. Reference material such as quirk tables, engine reach lists, input schemas and the `update` ledger entries moved into those sections, so an agent harness that cuts long output no longer drops the middle of a recipe. `taskless onboard` and `taskless update` print the same outline. `@taskless/cli/prompts` still renders whole recipes. diff --git a/openspec/changes/archive/2026-11-11-agent-topics-toc/.openspec.yaml b/openspec/changes/archive/2026-11-11-agent-topics-toc/.openspec.yaml new file mode 100644 index 00000000..2a7773d6 --- /dev/null +++ b/openspec/changes/archive/2026-11-11-agent-topics-toc/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-11 diff --git a/openspec/changes/archive/2026-11-11-agent-topics-toc/proposal.md b/openspec/changes/archive/2026-11-11-agent-topics-toc/proposal.md new file mode 100644 index 00000000..e3de41dc --- /dev/null +++ b/openspec/changes/archive/2026-11-11-agent-topics-toc/proposal.md @@ -0,0 +1,31 @@ +## Why + +Several `agent ` recipes are longer than an agent harness shows from one tool call (#672). `create-vale-rule` rendered at 68KB, `update` at 36KB, `report` at 25KB, `create-sg-rule` at 24KB and `route` at 21KB. Harnesses cut the middle out of long output, and one measured its limit at about 8.6k characters, so an agent lost the reference a step depended on, or re-fetched the recipe into a file and read it in pieces. In the CLI PMF agent survey about 11 of 40 onboarding runs reported it, and the part lost most often was `route`'s engine reach lists, which decide where a rule goes. + +## What Changes + +- A recipe's `## ` headings divide it into sections, each addressable by the slug of its heading. An author marks a section with `` on the line above its heading to serve it on request instead of in the topic's default output. +- `taskless agent ` prints the topic's outline: every section not marked on demand, then a `## Sections on request` list naming the others and the command that prints one. A recipe with no on-demand section prints as before. +- `taskless agent --section ` prints one section beneath the recipe's header block and its one-command-per-call paragraph. An unknown name exits 1 and lists the topic's sections. `--full` prints the whole recipe. +- A cross-reference to another section of the same recipe is a markdown link (`[Quirks](#quirks)`). The full text keeps it as a link. The outline and a section rewrite a link to a section they do not print as the section's name. +- `taskless onboard` and `taskless update` serve the same outline as `agent onboard` and `agent update`. +- The long recipes are restructured: steps stay in the outline, and reference material (quirk tables, worked examples, input schemas, engine reach lists, ledger entries, templates) moves into sections served on request. Content moves; it is not dropped. +- A test holds every topic's outline, and every section on its own, to 8 KiB when rendered with a nightly's invocation, the feedback invite included for the surveyed topics. Another holds every section link and every `--section` citation to a section that exists. +- `cli_agent` carries a `section` property on a section fetch, so a funnel can tell a second look at a topic from a fresh fetch of it. A section fetch never carries the feedback invite. +- The skill tells the agent to print a section a step names before acting on that step. +- `@taskless/cli/prompts` is unchanged: it renders the whole recipe, markers removed. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-agent`: the agent subcommand gains `--section` and `--full`, and serves long topics as an outline with sections on request. + +## Impact + +- `packages/cli/src/prompts/sections.ts` (new), `src/prompts/recipes.ts`, `src/commands/agent.ts`, `src/commands/onboard.ts`, `src/commands/init.ts`, the recipes in `src/agent/`, and `skills/taskless/SKILL.md`. +- Delivery shape: **single PR**. The mechanism is small, and the recipe restructuring cannot land without it or be tested apart from the budget it is measured against. Most of the diff is recipe text moved between sections, so it runs well past the 1200-line guideline; it is one change and a split would leave a layer whose budget test is red. diff --git a/openspec/changes/archive/2026-11-11-agent-topics-toc/specs/cli-agent/spec.md b/openspec/changes/archive/2026-11-11-agent-topics-toc/specs/cli-agent/spec.md new file mode 100644 index 00000000..5156f1c8 --- /dev/null +++ b/openspec/changes/archive/2026-11-11-agent-topics-toc/specs/cli-agent/spec.md @@ -0,0 +1,129 @@ +## MODIFIED Requirements + +### Requirement: Agent subcommand serves recipes for commands + +The CLI SHALL support an `agent` subcommand that accepts at most one positional argument identifying a topic, an optional `--anonymous` boolean flag, an optional `--tool ` value flag, an optional `--section ` value flag, AND an optional `--full` boolean flag. Topics SHALL be addressed by a single token; the subcommand SHALL NOT join multiple positionals into a topic key. When a topic is provided, the subcommand SHALL look up a matching recipe file embedded at build time using the following resolution order: + +1. If `--tool` is set AND `..md` exists in the embedded map, return that file. +2. If `--anonymous` is set AND `.anonymous.md` exists in the embedded map, return that file. +3. Otherwise, return `.md`. +4. If none of these exists, exit with code 1 and an error message suggesting `taskless agent` for the topic index. + +On a topic that has tool variants, `--tool` SHALL refuse, with a non-zero exit and a message naming the recipe to use instead, a tool that has its own authoring recipe (`ast-grep`, `vale`) or that cannot hold a rule (`prettier`, `black`). An unrecognized tool name SHALL fall back to `.md`. + +Which part of the resolved recipe is printed is governed by the requirement "Long topics are served as an outline with sections on request": by default its outline, with `--section` one section, with `--full` all of it. A second positional SHALL NOT be read as a section name; the error for it SHALL name `--section `. + +When no positional argument is provided, the subcommand SHALL print a topic index containing a one-paragraph human slug followed by a topic disambiguation table mapping topic names to their summaries. + +The subcommand is named for its reader. It serves agents fetching a procedure, not humans asking for help, and single-token addressing exists so a topic name is a literal string an agent copies rather than a phrase it can reorder or paraphrase. + +#### Scenario: Agent subcommand for a topic returns the recipe + +- **WHEN** a user runs `taskless agent check` +- **THEN** the CLI SHALL print the outline of `check.md` to stdout + +#### Scenario: Multi-word topic paths are not resolved + +- **WHEN** a user runs `taskless agent rule create` +- **THEN** the CLI SHALL NOT look up `rule-create.md` by joining the positionals +- **AND** it SHALL exit non-zero rather than guessing a topic +- **AND** it SHALL NOT read `create` as a section name + +#### Scenario: Formerly nested topics are addressed by one token + +- **WHEN** a user runs `taskless agent improve-rule` +- **THEN** the CLI SHALL look up `improve-rule.md` and print its contents + +#### Scenario: The former command name is gone + +- **WHEN** a user runs `taskless help check` +- **THEN** the CLI SHALL NOT print recipe text for `check` + +#### Scenario: A tool variant is served + +- **WHEN** a user runs `taskless agent create-legacy-rule --tool=ruff` +- **AND** `create-legacy-rule.ruff.md` exists +- **THEN** the CLI SHALL print that file's contents + +#### Scenario: An unknown tool falls back to the generic recipe + +- **WHEN** a user runs `taskless agent create-legacy-rule --tool=rubocop` +- **AND** no `create-legacy-rule.rubocop.md` exists +- **THEN** the CLI SHALL print `create-legacy-rule.md` + +#### Scenario: A tool with its own recipe is refused + +- **WHEN** a user runs `taskless agent create-legacy-rule --tool=vale` +- **THEN** the CLI SHALL exit non-zero and name `create-vale-rule` as the recipe to use + +#### Scenario: A refused tool name is ignored on a topic without tool variants + +- **WHEN** a user runs `taskless agent route --tool=vale` +- **THEN** the CLI SHALL print `route.md` and exit zero + +#### Scenario: An unknown topic is not masked by a refused tool + +- **WHEN** a user runs `taskless agent no-such-topic --tool=vale` +- **THEN** the CLI SHALL exit non-zero with the unknown-topic error, not the tool refusal + +## ADDED Requirements + +### Requirement: Long topics are served as an outline with sections on request + +A recipe's `## ` headings outside fenced blocks SHALL divide it into sections, and everything before the first is its preamble: the header block and the one-command-per-call paragraph. Each section SHALL be addressable by the slug of its heading: lowercase, every run of characters other than letters and digits collapsed to one hyphen, with none leading or trailing. An author SHALL mark a section to be served on request by writing `` on the line directly above its heading. The marker SHALL be removed from every rendering, and a marker not directly above a `## ` heading SHALL fail when the recipes load. + +`taskless agent ` SHALL print the topic's outline: the preamble and every section not marked on demand, in order, then a `## Sections on request` section naming each section it left out and the command that prints one, ` agent --section `, spelled with the `--tool` or `--anonymous` flag that selected the recipe. A recipe with no section marked on demand SHALL print unchanged. `taskless onboard` and `taskless update` (without `--rules`) SHALL print the same outline as `agent onboard` and `agent update`. + +`--section ` SHALL print the preamble, that one section, and a closing line naming the command that prints the topic's outline. A name the topic has no section for SHALL exit 1 and list the topic's section names. `--full` SHALL print the whole recipe, identical to what `@taskless/cli/prompts` renders for the same options with the directive requested. The `@taskless/cli/prompts` export SHALL render the whole recipe. + +A cross-reference to another section of the same recipe SHALL be written as a markdown link to its name, `[Quirks](#quirks)`. The whole recipe SHALL keep the link. The outline and a section SHALL rewrite a link to a section they do not print as that section's name. A cross-reference to a section of another topic SHALL be written as the command that prints it. + +Each topic's outline, and each section printed on its own, SHALL fit 8 KiB when rendered with a nightly's invocation, the feedback invite included for a surveyed topic, enforced by a test. Every section link SHALL name a section of the same recipe and every `--section` citation a section of the cited topic, enforced by a test. + +A section fetch SHALL capture `cli_agent` with `topic` and `section`, and SHALL NOT carry the feedback invite. + +#### Scenario: A long topic prints its outline + +- **WHEN** a user runs `taskless agent create-sg-rule` +- **THEN** stdout SHALL contain its steps +- **AND** SHALL NOT contain the sections marked on demand +- **AND** SHALL end with `## Sections on request`, listing `quirks` and the command that prints a section + +#### Scenario: One section is printed + +- **WHEN** a user runs `taskless agent create-sg-rule --section quirks` +- **THEN** stdout SHALL begin with the recipe's header block and one-command-per-call paragraph +- **AND** SHALL contain the `## Quirks` section and no other section + +#### Scenario: An unknown section is refused + +- **WHEN** a user runs `taskless agent create-sg-rule --section nope` +- **THEN** the CLI SHALL exit 1 and list the topic's section names on stderr + +#### Scenario: The whole recipe is printed on request + +- **WHEN** a user runs `taskless agent route --full` +- **THEN** stdout SHALL equal the `@taskless/cli/prompts` rendering of `route` with the directive requested + +#### Scenario: A short topic is unchanged + +- **WHEN** a user runs `taskless agent rule` +- **AND** `rule.md` marks no section on demand +- **THEN** stdout SHALL be the whole recipe, with no `## Sections on request` + +#### Scenario: A link to a section left out names it + +- **WHEN** a step links `[Quirks](#quirks)` and the outline does not print `quirks` +- **THEN** the outline SHALL read "section `quirks`" in its place +- **AND** the whole recipe SHALL keep the link + +#### Scenario: Every topic fits the budget + +- **WHEN** the test suite renders every recipe's outline and every section with a nightly's invocation +- **THEN** each SHALL be at most 8192 bytes, the feedback invite counted for a surveyed topic + +#### Scenario: A section fetch is told apart in telemetry + +- **WHEN** a user runs `taskless agent create-sg-rule --section quirks` +- **THEN** PostHog SHALL receive `cli_agent` with `topic: "create-sg-rule"` and `section: "quirks"` +- **AND** stdout SHALL NOT carry the feedback invite diff --git a/openspec/changes/archive/2026-11-11-agent-topics-toc/tasks.md b/openspec/changes/archive/2026-11-11-agent-topics-toc/tasks.md new file mode 100644 index 00000000..9c92cb41 --- /dev/null +++ b/openspec/changes/archive/2026-11-11-agent-topics-toc/tasks.md @@ -0,0 +1,18 @@ +## 1. Mechanism + +- [x] 1.1 Add `src/prompts/sections.ts`: the on-demand marker, heading slugs, splitting at `## ` outside fences, the outline with its table of contents, one section under the preamble, and link rewriting +- [x] 1.2 Load recipes as source plus on-demand set in `recipes.ts`, and add `getServedRecipe` (outline, section, full) and `getServedRecipeText` +- [x] 1.3 Add `--section` and `--full` to `agent`, report an unknown section, and capture `section` on `cli_agent` +- [x] 1.4 Serve the outline from `onboard` and `update` as well + +## 2. Recipes + +- [x] 2.1 Restructure every recipe over budget so its outline and each section fit 8 KiB, moving reference material into sections on request and bumping each topic version +- [x] 2.2 Turn same-recipe cross-references into section links and cross-topic ones into `--section` citations +- [x] 2.3 Tell the skill to print a section a step names + +## 3. Tests + +- [x] 3.1 Unit tests for the section helpers +- [x] 3.2 Budget tests for every topic's outline and every section, and cross-reference tests for links and citations +- [x] 3.3 CLI tests for `--section`, an unknown section and a second positional; the prompts parity test uses `--full` diff --git a/openspec/specs/cli-agent/spec.md b/openspec/specs/cli-agent/spec.md index 8ca9f4d1..33cc34a8 100644 --- a/openspec/specs/cli-agent/spec.md +++ b/openspec/specs/cli-agent/spec.md @@ -8,7 +8,7 @@ Defines the `agent` subcommand for the `@taskless/cli` package, including recipe ### Requirement: Agent subcommand serves recipes for commands -The CLI SHALL support an `agent` subcommand that accepts at most one positional argument identifying a topic, an optional `--anonymous` boolean flag, AND an optional `--tool ` value flag. Topics SHALL be addressed by a single token; the subcommand SHALL NOT join multiple positionals into a topic key. When a topic is provided, the subcommand SHALL look up a matching recipe file embedded at build time using the following resolution order: +The CLI SHALL support an `agent` subcommand that accepts at most one positional argument identifying a topic, an optional `--anonymous` boolean flag, an optional `--tool ` value flag, an optional `--section ` value flag, AND an optional `--full` boolean flag. Topics SHALL be addressed by a single token; the subcommand SHALL NOT join multiple positionals into a topic key. When a topic is provided, the subcommand SHALL look up a matching recipe file embedded at build time using the following resolution order: 1. If `--tool` is set AND `..md` exists in the embedded map, return that file. 2. If `--anonymous` is set AND `.anonymous.md` exists in the embedded map, return that file. @@ -17,6 +17,8 @@ The CLI SHALL support an `agent` subcommand that accepts at most one positional On a topic that has tool variants, `--tool` SHALL refuse, with a non-zero exit and a message naming the recipe to use instead, a tool that has its own authoring recipe (`ast-grep`, `vale`) or that cannot hold a rule (`prettier`, `black`). An unrecognized tool name SHALL fall back to `.md`. +Which part of the resolved recipe is printed is governed by the requirement "Long topics are served as an outline with sections on request": by default its outline, with `--section` one section, with `--full` all of it. A second positional SHALL NOT be read as a section name; the error for it SHALL name `--section `. + When no positional argument is provided, the subcommand SHALL print a topic index containing a one-paragraph human slug followed by a topic disambiguation table mapping topic names to their summaries. The subcommand is named for its reader. It serves agents fetching a procedure, not humans asking for help, and single-token addressing exists so a topic name is a literal string an agent copies rather than a phrase it can reorder or paraphrase. @@ -24,13 +26,14 @@ The subcommand is named for its reader. It serves agents fetching a procedure, n #### Scenario: Agent subcommand for a topic returns the recipe - **WHEN** a user runs `taskless agent check` -- **THEN** the CLI SHALL print the contents of `check.md` to stdout +- **THEN** the CLI SHALL print the outline of `check.md` to stdout #### Scenario: Multi-word topic paths are not resolved - **WHEN** a user runs `taskless agent rule create` - **THEN** the CLI SHALL NOT look up `rule-create.md` by joining the positionals - **AND** it SHALL exit non-zero rather than guessing a topic +- **AND** it SHALL NOT read `create` as a section name #### Scenario: Formerly nested topics are addressed by one token @@ -686,3 +689,63 @@ The `init` recipe SHALL document the `telemetry` field of the `init --json` enve - **WHEN** an agent follows the `init` recipe and the envelope reports `telemetry.enabled: true` - **THEN** the recipe SHALL direct it to tell the user about telemetry and offer the `taskless.json` opt-out - **AND** SHALL NOT direct it to edit the manifest unasked + +### Requirement: Long topics are served as an outline with sections on request + +A recipe's `## ` headings outside fenced blocks SHALL divide it into sections, and everything before the first is its preamble: the header block and the one-command-per-call paragraph. Each section SHALL be addressable by the slug of its heading: lowercase, every run of characters other than letters and digits collapsed to one hyphen, with none leading or trailing. An author SHALL mark a section to be served on request by writing `` on the line directly above its heading. The marker SHALL be removed from every rendering, and a marker not directly above a `## ` heading SHALL fail when the recipes load. + +`taskless agent ` SHALL print the topic's outline: the preamble and every section not marked on demand, in order, then a `## Sections on request` section naming each section it left out and the command that prints one, ` agent --section `, spelled with the `--tool` or `--anonymous` flag that selected the recipe. A recipe with no section marked on demand SHALL print unchanged. `taskless onboard` and `taskless update` (without `--rules`) SHALL print the same outline as `agent onboard` and `agent update`. + +`--section ` SHALL print the preamble, that one section, and a closing line naming the command that prints the topic's outline. A name the topic has no section for SHALL exit 1 and list the topic's section names. `--full` SHALL print the whole recipe, identical to what `@taskless/cli/prompts` renders for the same options with the directive requested. The `@taskless/cli/prompts` export SHALL render the whole recipe. + +A cross-reference to another section of the same recipe SHALL be written as a markdown link to its name, `[Quirks](#quirks)`. The whole recipe SHALL keep the link. The outline and a section SHALL rewrite a link to a section they do not print as that section's name. A cross-reference to a section of another topic SHALL be written as the command that prints it. + +Each topic's outline, and each section printed on its own, SHALL fit 8 KiB when rendered with a nightly's invocation, the feedback invite included for a surveyed topic, enforced by a test. Every section link SHALL name a section of the same recipe and every `--section` citation a section of the cited topic, enforced by a test. + +A section fetch SHALL capture `cli_agent` with `topic` and `section`, and SHALL NOT carry the feedback invite. + +#### Scenario: A long topic prints its outline + +- **WHEN** a user runs `taskless agent create-sg-rule` +- **THEN** stdout SHALL contain its steps +- **AND** SHALL NOT contain the sections marked on demand +- **AND** SHALL end with `## Sections on request`, listing `quirks` and the command that prints a section + +#### Scenario: One section is printed + +- **WHEN** a user runs `taskless agent create-sg-rule --section quirks` +- **THEN** stdout SHALL begin with the recipe's header block and one-command-per-call paragraph +- **AND** SHALL contain the `## Quirks` section and no other section + +#### Scenario: An unknown section is refused + +- **WHEN** a user runs `taskless agent create-sg-rule --section nope` +- **THEN** the CLI SHALL exit 1 and list the topic's section names on stderr + +#### Scenario: The whole recipe is printed on request + +- **WHEN** a user runs `taskless agent route --full` +- **THEN** stdout SHALL equal the `@taskless/cli/prompts` rendering of `route` with the directive requested + +#### Scenario: A short topic is unchanged + +- **WHEN** a user runs `taskless agent rule` +- **AND** `rule.md` marks no section on demand +- **THEN** stdout SHALL be the whole recipe, with no `## Sections on request` + +#### Scenario: A link to a section left out names it + +- **WHEN** a step links `[Quirks](#quirks)` and the outline does not print `quirks` +- **THEN** the outline SHALL read "section `quirks`" in its place +- **AND** the whole recipe SHALL keep the link + +#### Scenario: Every topic fits the budget + +- **WHEN** the test suite renders every recipe's outline and every section with a nightly's invocation +- **THEN** each SHALL be at most 8192 bytes, the feedback invite counted for a surveyed topic + +#### Scenario: A section fetch is told apart in telemetry + +- **WHEN** a user runs `taskless agent create-sg-rule --section quirks` +- **THEN** PostHog SHALL receive `cli_agent` with `topic: "create-sg-rule"` and `section: "quirks"` +- **AND** stdout SHALL NOT carry the feedback invite diff --git a/packages/cli/src/agent/check.md b/packages/cli/src/agent/check.md index b7dd7f37..b48c76ba 100644 --- a/packages/cli/src/agent/check.md +++ b/packages/cli/src/agent/check.md @@ -1,4 +1,4 @@ -# Topic: check (CLI v%(CLI_VERSION)s / topic v10) +# Topic: check (CLI v%(CLI_VERSION)s / topic v11) %(ONE_COMMAND_PER_CALL)s @@ -15,8 +15,147 @@ in CI (diff-only scan), or after rule create/improve to validate. `.taskless/rules/runtime/`. (If none exist, the CLI exits 0 with a friendly message suggesting `%(TASKLESS_CLI)s rule create`.) - No auth required. Static rules always run; whether runtime rules run - depends on auth state. See "What runs". + depends on auth state. See [What runs](#what-runs). +## Flags +- `--json`: machine output (`{ success, results, summary, skipped?, failures?, notices?, integrity?, entitlement? }`). +- `--summary`: per-rule counts instead of every finding. With `--json` + it omits `results` and keeps `summary`, so the output stays small on + any repository. Use it when the question is "how many", not "where". +- `--anonymous`: run only static rules; skip runtime rules. +- `--dangerously-run-scripts`: run runtime `check.ts` unverified. +- `--timeout `: per-runtime-check wall-clock bound (default 10). +- `--preserve-logs` / `-l`: keep this run's directory under + `.taskless/.run/` (the snapshot that ran, the engine configs, and + `engine.log`, `sg.log`, `vale.log`, `runtime.log`) instead of removing it. + Its path is printed on stderr, or returned as `runDirectory` under + `--json`. Use it to debug a rule that behaves unexpectedly; the logs hold + matched source, so treat the directory like any local build output. It + survives later runs until the `keepUntil` in its `preserve` file, a + day out in unix milliseconds. Raise it to keep the directory longer, or + delete the file to let the next run remove it. + +## Steps + +1. **Decide scope.** Default is a full project scan. If the user + specified files (or you're in a CI context with a known diff), + pass them as positional arguments. + +2. **Invoke the CLI.** Either: + ``` + %(TASKLESS_CLI)s check --json + ``` + or, scoped to specific paths: + ``` + %(TASKLESS_CLI)s check --json src/foo.ts src/bar.ts + ``` + or, against a git diff: run `git diff --name-only main...HEAD` as + its own call, then pass the paths it printed: + ``` + %(TASKLESS_CLI)s check --json src/foo.ts src/deleted.ts + ``` + Paths that don't exist on disk are skipped, so you can pass every + path `git diff` printed without pre-filtering. The skipped paths + are named in a notice (`notices` under `--json`), so a mistyped + path is visible rather than reading as clean. + + Paths must be inside the project (the working directory, or + `--dir`). A path outside it, existing or not, is refused with + `INVALID_INPUT` and exit 1, and nothing is scanned. Run `check` + from the project that holds the files instead. + + When runtime rules were present but not run (e.g. logged out), the + JSON also carries `"skipped": [{ "rule": "", "reason": "…" }]` + alongside `success`/`results`. Surface it so CI can tell that runtime + rules did not execute. It never affects the exit code on its own; an + accompanying `entitlement` with a non-empty `withheld` does (see + [Withheld for the plan](#withheld-for-the-plan)), and so does a + `failures` entry for an edited rule (see + [An edited rule](#an-edited-rule)). + +3. **Parse the JSON output.** Shape: + ```json + { + "success": false, + "results": [ + { + "source": "ast-grep", + "ruleId": "no-eval", + "severity": "error", + "message": "Avoid eval()", + "note": null, + "file": "src/foo.ts", + "range": { + "start": { "line": 12, "column": 4 }, + "end": { "line": 12, "column": 14 } + }, + "matchedText": "eval(input)", + "fix": null + } + ], + "summary": { + "total": 1, + "files": 1, + "severities": { "error": 1, "warning": 0, "info": 0, "hint": 0 }, + "rules": [ + { "engine": "sg", "ruleId": "no-eval", "total": 1, "files": 1, + "severities": { "error": 1, "warning": 0, "info": 0, "hint": 0 } } + ] + } + } + ``` + + Each `results` entry has the same shape as a finding in + `verify --json`'s `rules[].findings` (which adds `bucket`), so one + parser reads both. `summary` is always present; its `rules` lists + only rules that fired, most findings first, keyed by `engine` and + `ruleId` as `verify` keys `rules[]`. + + **To measure a whole repository, add `--summary`.** `results` can run + to hundreds of kilobytes; `check --json --summary` returns only + `summary`, one entry per rule that fired. Do not count `results` + yourself, and do not read an absent `results` as clean: read + `summary.total`. + +4. **Report findings to the user.** Group by `file`. Show `severity`, + `message`, and `ruleId` for each finding; the `range.start` is the + useful line/column to surface. The `success` field reflects + error-severity findings: `success: false` means at least one + `severity: "error"` finding exists, runtime rules were withheld for + the plan (check `entitlement`), or a rule was edited, copied from an + issued rule, or unaccounted for (check `failures` and `integrity`, + and see [A copied or renamed rule](#a-copied-or-renamed-rule)) + (exit code 1); `success: true` with a non-empty `results` array + means there are only + warning/info/hint findings (exit code 0); `success: true` with an + empty `results` array means the codebase is clean, unless a notice + says the supplied paths were skipped, in which case nothing was + scanned. Findings are + never reported via the `{ ok: false, code, message }` envelope: + the envelope only appears when the scan itself fails (e.g. + `SCAN_FAILED`) and the normal results payload is absent. + `results` is whole even when `CI` is set: only the human output + hides findings below error there, and `--include-warnings` shows them. + +## Exit codes + +- `0`: All checks passed, no rules configured, or all supplied + paths missing (named in a notice) +- `1`: Errors detected, scan failed, runtime rules withheld because + the plan does not include them, an issued ast-grep or Vale rule was + edited or copied under a new id, a rule was unaccounted for, two + engines share a rule id, or a supplied path is outside the project + +## Errors + +When `--json` is set, failures emit `{ ok: false, code, message }`: + +| code | meaning | fix | +|-----------------|-------------------------------|-------------------------------------------| +| `SCAN_FAILED` | ast-grep scan errored | Report; check rule YAML validity | +| `INVALID_INPUT` | a path is outside the project | Run from the project that holds the files | + + ## What runs `check` never requires auth. What it verifies depends on whether you are @@ -57,7 +196,8 @@ logged in: reported with the command that repairs it: `%(TASKLESS_CLI)s rule restore `, or, when the organization's plan does not include restoring rules, the git steps that do instead -(see "When the plan does not include restoring rules"). +(see +[When the plan does not include restoring rules](#when-the-plan-does-not-include-restoring-rules)). Notices about skipped runtime rules are human-readable stderr only. Under `--json` they do NOT appear as warnings; instead an additive @@ -66,6 +206,7 @@ unchanged `{ success, results, summary }`, the not-verified notice is an entry in `notices`, and the CI backstop (`%(TASKLESS_CLI)s agent ci`) is the enforcement point. + ## An edited rule When `check` fails because a rule was edited, `failures` names the rule @@ -87,6 +228,7 @@ rewritten as a local rule under a new id. An edited rule is exactly what an agent tuning a rule until its own violation passes looks like, which is why `check` refuses it. + ## A copied or renamed rule A rule under a new id that still carries a file Taskless issued to @@ -129,6 +271,7 @@ rules the answer did not account for (`unaccounted`), and ids shared across engines (`duplicate`). Locally written ast-grep and Vale rules are never listed; they run. A copy is listed with its `copyOf`. + ## When the plan does not include restoring rules When the organization's plan is known not to include restoring rules, @@ -153,6 +296,7 @@ whose engine is not known is given as a quoted pathspec, `'.taskless/rules/*//*'`; pass it to git as written. For a rename, recover the source, then delete the copy, as above. + ## Withheld for the plan When the organization's Taskless plan does not include runtime rules, @@ -178,142 +322,7 @@ delete the rules to make `check` pass, and do not suggest `--dangerously-run-scripts` as a fix. Show the `upgradeUrl`. An `entitlement` with an empty `withheld` does not fail the run. -## Flags -- `--json`: machine output (`{ success, results, summary, skipped?, failures?, notices?, integrity?, entitlement? }`). -- `--summary`: per-rule counts instead of every finding. With `--json` - it omits `results` and keeps `summary`, so the output stays small on - any repository. Use it when the question is "how many", not "where". -- `--anonymous`: run only static rules; skip runtime rules. -- `--dangerously-run-scripts`: run runtime `check.ts` unverified. -- `--timeout `: per-runtime-check wall-clock bound (default 10). -- `--preserve-logs` / `-l`: keep this run's directory under - `.taskless/.run/` (the snapshot that ran, the engine configs, and - `engine.log`, `sg.log`, `vale.log`, `runtime.log`) instead of removing it. - Its path is printed on stderr, or returned as `runDirectory` under - `--json`. Use it to debug a rule that behaves unexpectedly; the logs hold - matched source, so treat the directory like any local build output. It - survives later runs until the `keepUntil` in its `preserve` file, a - day out in unix milliseconds. Raise it to keep the directory longer, or - delete the file to let the next run remove it. - -## Steps - -1. **Decide scope.** Default is a full project scan. If the user - specified files (or you're in a CI context with a known diff), - pass them as positional arguments. - -2. **Invoke the CLI.** Either: - ``` - %(TASKLESS_CLI)s check --json - ``` - or, scoped to specific paths: - ``` - %(TASKLESS_CLI)s check --json src/foo.ts src/bar.ts - ``` - or, against a git diff: run `git diff --name-only main...HEAD` as - its own call, then pass the paths it printed: - ``` - %(TASKLESS_CLI)s check --json src/foo.ts src/deleted.ts - ``` - Paths that don't exist on disk are skipped, so you can pass every - path `git diff` printed without pre-filtering. The skipped paths - are named in a notice (`notices` under `--json`), so a mistyped - path is visible rather than reading as clean. - - Paths must be inside the project (the working directory, or - `--dir`). A path outside it, existing or not, is refused with - `INVALID_INPUT` and exit 1, and nothing is scanned. Run `check` - from the project that holds the files instead. - - When runtime rules were present but not run (e.g. logged out), the - JSON also carries `"skipped": [{ "rule": "", "reason": "…" }]` - alongside `success`/`results`. Surface it so CI can tell that runtime - rules did not execute. It never affects the exit code on its own; an - accompanying `entitlement` with a non-empty `withheld` does (see - "Withheld for the plan"), and so does a `failures` entry for an edited - rule (see "An edited rule"). - -3. **Parse the JSON output.** Shape: - ```json - { - "success": false, - "results": [ - { - "source": "ast-grep", - "ruleId": "no-eval", - "severity": "error", - "message": "Avoid eval()", - "note": null, - "file": "src/foo.ts", - "range": { - "start": { "line": 12, "column": 4 }, - "end": { "line": 12, "column": 14 } - }, - "matchedText": "eval(input)", - "fix": null - } - ], - "summary": { - "total": 1, - "files": 1, - "severities": { "error": 1, "warning": 0, "info": 0, "hint": 0 }, - "rules": [ - { "engine": "sg", "ruleId": "no-eval", "total": 1, "files": 1, - "severities": { "error": 1, "warning": 0, "info": 0, "hint": 0 } } - ] - } - } - ``` - - Each `results` entry has the same shape as a finding in - `verify --json`'s `rules[].findings` (which adds `bucket`), so one - parser reads both. `summary` is always present; its `rules` lists - only rules that fired, most findings first, keyed by `engine` and - `ruleId` as `verify` keys `rules[]`. - - **To measure a whole repository, add `--summary`.** `results` can run - to hundreds of kilobytes; `check --json --summary` returns only - `summary`, one entry per rule that fired. Do not count `results` - yourself, and do not read an absent `results` as clean: read - `summary.total`. - -4. **Report findings to the user.** Group by `file`. Show `severity`, - `message`, and `ruleId` for each finding; the `range.start` is the - useful line/column to surface. The `success` field reflects - error-severity findings: `success: false` means at least one - `severity: "error"` finding exists, runtime rules were withheld for - the plan (check `entitlement`), or a rule was edited, copied from an - issued rule, or unaccounted for (check `failures` and `integrity`) - (exit code 1); `success: true` with a non-empty `results` array - means there are only - warning/info/hint findings (exit code 0); `success: true` with an - empty `results` array means the codebase is clean, unless a notice - says the supplied paths were skipped, in which case nothing was - scanned. Findings are - never reported via the `{ ok: false, code, message }` envelope: - the envelope only appears when the scan itself fails (e.g. - `SCAN_FAILED`) and the normal results payload is absent. - `results` is whole even when `CI` is set: only the human output - hides findings below error there, and `--include-warnings` shows them. - -## Exit codes - -- `0`: All checks passed, no rules configured, or all supplied - paths missing (named in a notice) -- `1`: Errors detected, scan failed, runtime rules withheld because - the plan does not include them, an issued ast-grep or Vale rule was - edited or copied under a new id, a rule was unaccounted for, two - engines share a rule id, or a supplied path is outside the project - -## Errors - -When `--json` is set, failures emit `{ ok: false, code, message }`: - -| code | meaning | fix | -|-----------------|-------------------------------|-------------------------------------------| -| `SCAN_FAILED` | ast-grep scan errored | Report; check rule YAML validity | -| `INVALID_INPUT` | a path is outside the project | Run from the project that holds the files | - + ## See Also - `%(TASKLESS_CLI)s agent route`: add a rule if none exist diff --git a/packages/cli/src/agent/ci.md b/packages/cli/src/agent/ci.md index 4c2d3602..f41bde1d 100644 --- a/packages/cli/src/agent/ci.md +++ b/packages/cli/src/agent/ci.md @@ -1,4 +1,4 @@ -# Topic: ci (CLI v%(CLI_VERSION)s / topic v11) +# Topic: ci (CLI v%(CLI_VERSION)s / topic v12) %(ONE_COMMAND_PER_CALL)s @@ -28,7 +28,8 @@ you recognize one not on the list, apply the same patterns. project runs a nightly; ask whether CI should run that nightly, and if so use `cli-nightly.mjs` wherever this recipe says `cli.mjs`. - No auth required for CI: `check` is unauthenticated by default. - (Optionally tokenized as a server-enforced backstop; see step 7.) + (Optionally tokenized as a server-enforced backstop; see + [Authentication in CI](#authentication-in-ci).) ## Steps @@ -36,8 +37,73 @@ you recognize one not on the list, apply the same patterns. Start from `%(TASKLESS_CLI)s detect --json`: its `ci` field names the CI systems configured at the repository root, with the files that matched. -It recognizes the systems below. If it reports none, or you recognize a -system it does not, look at the repo root yourself: +If it reports none, or you recognize a system it does not, look at the +repo root yourself, using [CI systems](#ci-systems) for the files each +system keeps there. + +Sum up what you found and confirm with the user. If zero match, ask +which CI they use. If multiple match, ask which should run Taskless. + +### 2. Agree on the scan pattern + +- **Full scan**: `%(TASKLESS_CLI)s check`. Scans everything. Best for runs + on the main/default branch. +- **Diff scan**: `%(TASKLESS_CLI)s check $(git diff --name-only ...)`. + Faster for PR builds. + +**Recommended default:** diff scan on PRs, full scan on pushes to +main. Read [Scan patterns](#scan-patterns) for each CI's target branch +variable and how `check` treats the diff's paths. + +### 3. Verify locally first + +Run `%(TASKLESS_CLI)s check`: +- Clean pass → proceed. +- "No rules configured" → stop. Fetch `%(TASKLESS_CLI)s agent route`. +- Findings → tell the user CI will fail; ask whether to fix, + suppress, or proceed knowing the first CI run will be red. +- Anything else in `failures`, a withheld-rules notice, or an edited + rule → read [Local check outcomes](#local-check-outcomes) before + going on. Never add a flag to the CI command to get green. + +### 4. Generate the config + +Rules: +1. Write a NEW standalone file. Never modify the user's existing + CI config. +2. Prefer the CI's native include mechanism. Tell the user the one + line they need to add to their main config. +3. If the file you'd write already exists, ask before overwriting. + +[Config paths](#config-paths) names the file to write for each CI. + +### 5. Write the job + +For GitHub Actions, start from [GitHub Actions template](#github-actions-template). +For any other CI, translate it with [Other CI systems](#other-ci-systems). +Either way the job runs `node .taskless/bin/cli.mjs check` from the +repository root, never through `npx` or another download-and-run +launcher, and caches `.taskless/.cache`. When the main pipeline's image +has no Node, read [Repositories that are not Node projects](#repositories-that-are-not-node-projects). + +### 6. Offer authentication only when asked + +The job needs no secrets. Read [Authentication in CI](#authentication-in-ci) +only when the user wants server-enforced rules or runtime rules in CI, or +asks to run authenticated commands there. + +### 7. Report back + +Show: +1. The path written and a 10-15 line excerpt. +2. For CIs needing manual wiring, the exact `include:` / reference + line for their main config. +3. `git status` so they can review before committing. +4. A note that the first CI run exercises rules, if existing + matches exist, CI will fail until fixed or suppressed. + + +## CI systems | File / directory | CI system | |---------------------------|---------------------| @@ -51,15 +117,11 @@ system it does not, look at the repo root yourself: | `.drone.yml` | Drone | | `.travis.yml` | Travis CI | -Sum up what you found and confirm with the user. If zero match, ask -which CI they use. If multiple match, ask which should run Taskless. - -### 2. Agree on the scan pattern + +## Scan patterns -- **Full scan**: `%(TASKLESS_CLI)s check`. Scans everything. Best for runs - on the main/default branch. -- **Diff scan**: `%(TASKLESS_CLI)s check $(git diff --name-only ...)`. - Faster for PR builds. Per-CI diff target var: +A diff scan, `%(TASKLESS_CLI)s check $(git diff --name-only ...)`, diffs +against the pull request's target branch. Per-CI diff target var: | CI | Target branch variable | |---------------------|-----------------------------------------------| @@ -70,21 +132,16 @@ which CI they use. If multiple match, ask which should run Taskless. | Azure Pipelines | `System.PullRequest.TargetBranch` | | Bitbucket Pipelines | `BITBUCKET_PR_DESTINATION_BRANCH` | - `%(TASKLESS_CLI)s check` skips paths that don't exist (naming them in a - notice), so raw `git diff --name-only` output can pipe in directly. - Run it from the repository root: `git diff` paths are relative to the - root, and a path outside the project is refused. - -**Recommended default:** diff scan on PRs, full scan on pushes to -main. +`%(TASKLESS_CLI)s check` skips paths that don't exist (naming them in a +notice), so raw `git diff --name-only` output can pipe in directly. +Run it from the repository root: `git diff` paths are relative to the +root, and a path outside the project is refused. -### 3. Verify locally first + +## Local check outcomes -Run `%(TASKLESS_CLI)s check`: -- Clean pass → proceed. -- "No rules configured" → stop. Fetch `%(TASKLESS_CLI)s agent route`. -- Findings → tell the user CI will fail; ask whether to fix, - suppress, or proceed knowing the first CI run will be red. +What the rest of a local `%(TASKLESS_CLI)s check`'s results mean for CI, +beyond a clean pass, "No rules configured", and findings (step 3): - Runtime rules withheld for the plan (a notice with an upgrade URL, or `entitlement.withheld` under `--json`) → tell the user CI will fail until the organization's plan includes runtime rules. This is @@ -103,16 +160,8 @@ Run `%(TASKLESS_CLI)s check`: - A rule service that cannot be reached does not fail the run; only a rejected token or a retired CLI version does. -### 4. Generate the config - -Rules: -1. Write a NEW standalone file. Never modify the user's existing - CI config. -2. Prefer the CI's native include mechanism. Tell the user the one - line they need to add to their main config. -3. If the file you'd write already exists, ask before overwriting. - -Canonical paths: + +## Config paths | CI | File path | |---------------------|---------------------------------------------------------------------| @@ -124,7 +173,8 @@ Canonical paths: | Bitbucket Pipelines | `.taskless/ci/bitbucket-pipelines.yml` (user merges manually) | | Other | `.taskless/ci/.` + clear wiring instructions | -### 5. GitHub Actions reference template + +## GitHub Actions template The reference template, translate the same shape (checkout with full history, set up Node, conditional check) for other CIs. @@ -190,7 +240,8 @@ Simplifications: - Full-scan only → drop the `if`, just run `node .taskless/bin/cli.mjs check`. - Diff-scan only → remove the `push:` trigger. -### 6. Translate to other CIs + +## Other CI systems The six universal steps: 1. Make Node available to the job (22.22 or later, which `22` resolves @@ -213,7 +264,18 @@ The six universal steps: YAML for GitHub/GitLab/Azure/Bitbucket; Groovy for Jenkins; different structure for CircleCI. The six steps stay the same. -### 7. Authentication in CI (optional backstop) + +## Repositories that are not Node projects + +The job needs Node and nothing else. When the repository's main +pipeline runs in an image without Node (a `python:3.12` image, for +example), keep Taskless in its own job with a Node image or setup step, +rather than installing Node into the main job. Do not add a +`package.json`, and do not add `@taskless/cli` as a dependency: the +launcher already pins the version through `.taskless/taskless.json`. + + +## Authentication in CI `%(TASKLESS_CLI)s check` does NOT require authentication. The generated CI config works out of the box with no secrets and scans all local rules. @@ -246,35 +308,18 @@ unauthenticated default remains fully supported. Also mention auth if the user explicitly asks to run authenticated commands (e.g. `rule create`/`rule improve`) in CI, uncommon. -### 8. Repositories that are not Node projects - -The job needs Node and nothing else. When the repository's main -pipeline runs in an image without Node (a `python:3.12` image, for -example), keep Taskless in its own job with a Node image or setup step, -rather than installing Node into the main job. Do not add a -`package.json`, and do not add `@taskless/cli` as a dependency: the -launcher already pins the version through `.taskless/taskless.json`. - -### 9. Report back - -Show: -1. The path written and a 10-15 line excerpt. -2. For CIs needing manual wiring, the exact `include:` / reference - line for their main config. -3. `git status` so they can review before committing. -4. A note that the first CI run exercises rules, if existing - matches exist, CI will fail until fixed or suppressed. - ## Errors - **No rules** → fetch `%(TASKLESS_CLI)s agent route`. Don't write CI config. - **Unrecognized CI** → produce a generic `.taskless/ci/check.sh` - script implementing the six universal steps. Be upfront it's a + script implementing the six universal steps of + [Other CI systems](#other-ci-systems). Be upfront it's a starting point. - **Target CI file exists, user declines overwrite** → stop. Show what you would have written; let them reconcile manually. + ## See Also - `%(TASKLESS_CLI)s agent check`: the command being wired into CI diff --git a/packages/cli/src/agent/create-remote-rule.md b/packages/cli/src/agent/create-remote-rule.md index 7bd74c40..425af1b5 100644 --- a/packages/cli/src/agent/create-remote-rule.md +++ b/packages/cli/src/agent/create-remote-rule.md @@ -1,4 +1,4 @@ -# Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v10) +# Topic: create-remote-rule (CLI v%(CLI_VERSION)s / topic v11) %(ONE_COMMAND_PER_CALL)s @@ -24,7 +24,8 @@ go looking for a second topic to perform the submission. - The project has a GitHub owner. **Check this yourself before collecting anything**, see below. - The request reached here through `route`, or through a local attempt - that failed and a user who confirmed the escalation. + that failed and a user who confirmed the escalation. If you are not + sure which, read [Cost, and when this path is right](#cost-and-when-this-path-is-right). ### Check the GitHub owner first @@ -40,7 +41,13 @@ succeed, and everything before it is wasted work for the user. Say the tier is unavailable and why, then route to a local recipe: `create-sg-rule`, `create-vale-rule`, or `create-legacy-rule` by the rule's shape, or `create-runtime-rule` if it is only expressible at -runtime. +runtime. **`auth login` does not fix it.** [No GitHub owner](#no-github-owner) +says why, and what each cause looks like. + + +## No GitHub owner + +Why the owner check under Preconditions stops the recipe. `route` already drops this destination when there is no owner, so normally you never arrive here without one. This check is for the times @@ -48,7 +55,7 @@ that does not hold: reached directly, from a cached plan, or from a copy of the routing guidance older than this constraint. An omission upstream is advisory; this is the point of use. -**`auth login` does not fix it.** No GitHub owner is a property of the +`auth login` does not fix it: no GitHub owner is a property of the project, not the session, so do not send the user to log in for it. The causes are: not a git repository, no `origin` remote, an `origin` that is not GitHub, and git not being installed at all. The first three each have @@ -58,6 +65,7 @@ the first, because the probe that would distinguish them is itself a directory *is* a repository, check whether `git` is on their `PATH` before believing the code. + ## Cost, and when this path is right A generation costs the user something and requires an account. That is @@ -91,77 +99,110 @@ Two ways to legitimately be here: 2. **Check for a rule that already covers this.** Scan `.taskless/rules/sg/` and read each rule's `message`, `note`, and `rule` fields. If one overlaps, show the user and ask whether they - would rather iterate on it, `%(TASKLESS_CLI)s agent improve-rule` refines an - existing rule and is usually the better answer than a second rule - that half-overlaps the first. - -3. **Gather the request.** Even when the user supplied a description, - ask: - - What exact pattern should be flagged? Get concrete examples. - - In what language? - - Where is the pattern acceptable: are there contexts to exclude? - - If you arrived from a failed local attempt, reuse the cases you - already gathered rather than asking again. - -4. **Enrich it.** This is the part that decides the quality of what - comes back: - - Search the codebase for real instances and show the user what you - found. Real matches beat invented ones. - - Ask for further success and failure cases, especially near-misses. - - Hold every case to the bar a fixture meets, since the generator - works from what you send. Copy cases from the - repository rather than inventing them. Write success cases that the - linters in `detect --json` would accept. Outside the construct the - rule checks, keep each case in the repository's idiom, with no - placeholder types and no nesting a reviewer would send back. - - Read `.gitignore`, the linter configs, and `tsconfig.json`'s - `exclude` for exclusion patterns the repo already believes in. - Present them as proposed defaults rather than applying them. - - Infer the primary language from the codebase and confirm it. Put it - in the `prompt` field. + would rather iterate on it with `%(TASKLESS_CLI)s agent improve-rule`. + +3. **Gather the request**: the exact pattern to flag, with concrete + examples, the language, and the contexts where it is acceptable. Ask + even when the user supplied a description. From a failed local + attempt, reuse the cases you already gathered. + +4. **Enrich it.** This decides the quality of what comes back. Follow + [Enriching the request](#enriching-the-request): real instances from + the codebase, near-miss cases, the repo's own exclusions, the primary + language. 5. **Confirm before spending the generation.** Summarize the full prompt, the success cases, the failure cases, and the exclusions. This is the last point at which a misunderstanding is free. 6. **Write the payload** to `.taskless/.tmp-rule-request.json`, matching - the input schema below. + [Input schema](#input-schema). 7. **Submit.** Run: ``` %(TASKLESS_CLI)s rule create --from .taskless/.tmp-rule-request.json --json ``` This may take 30-60 seconds while the service generates the rule. + On failure, look the `code` up in [Errors](#errors). 8. **Clean up.** Delete `.taskless/.tmp-rule-request.json` whether the call succeeded or failed. -9. **Report.** The CLI writes each generated rule, fixtures included, - to `.taskless/rules///`, replacing anything already in - that directory. These are the same paths and the same shape a - locally authored rule uses, so `check`, `improve-rule`, `validate`, and - `verify` treat them identically. Nothing is written under - `.taskless/rule-metadata/`. Show the user the paths and suggest - `%(TASKLESS_CLI)s agent check`. - - **`rules` in the `--json` output lists each written rule's id**, and - the id is the rule's directory name (for example - `no-eval-3fa9c21b`). It is what `rule improve`, `rule restore`, and - `rule rollback` take, and it is on disk, so nothing needs recording. - `requestId` names the generation request only. The one command that - takes it back is `rule create --resume ` (see Errors); - passing it to `rule improve` as a rule id fails with `RULE_NOT_FOUND`. - - **Read `notices` if it is present.** It is an optional array of - advisory messages about a delivery that was written anyway. A rule - arriving with no `tests/` fixtures is the one to expect. That rule works, but - nothing exercises it: `verify` reports a pass over zero cases, which - reads exactly like a rule proven to work. Surface any notice to the - user rather than reporting an unqualified success. Under `--json` - these do not go to stderr, so the envelope is the only place they - appear. - +9. **Report.** Show the user the paths of the rules the CLI wrote under + `.taskless/rules///`, surface any `notices`, and suggest + `%(TASKLESS_CLI)s agent check`. Read + [Reading the result](#reading-the-result) first: a rule id is not a + `requestId`, and a notice can mean nothing tests the rule. + + +## Enriching the request + +What steps 2 to 4 ask for in full. + +**Check for a rule that already covers this.** Scan +`.taskless/rules/sg/` and read each rule's `message`, `note`, and +`rule` fields. If one overlaps, show the user and ask whether they +would rather iterate on it, `%(TASKLESS_CLI)s agent improve-rule` refines an +existing rule and is usually the better answer than a second rule +that half-overlaps the first. + +**Gather the request.** Even when the user supplied a description, +ask: +- What exact pattern should be flagged? Get concrete examples. +- In what language? +- Where is the pattern acceptable: are there contexts to exclude? + +If you arrived from a failed local attempt, reuse the cases you +already gathered rather than asking again. + +**Enrich it.** This is the part that decides the quality of what +comes back: +- Search the codebase for real instances and show the user what you + found. Real matches beat invented ones. +- Ask for further success and failure cases, especially near-misses. +- Hold every case to the bar a fixture meets, since the generator + works from what you send. Copy cases from the + repository rather than inventing them. Write success cases that the + linters in `detect --json` would accept. Outside the construct the + rule checks, keep each case in the repository's idiom, with no + placeholder types and no nesting a reviewer would send back. +- Read `.gitignore`, the linter configs, and `tsconfig.json`'s + `exclude` for exclusion patterns the repo already believes in. + Present them as proposed defaults rather than applying them. +- Infer the primary language from the codebase and confirm it. Put it + in the `prompt` field. + + +## Reading the result + +What step 9 reports, in full. + +The CLI writes each generated rule, fixtures included, +to `.taskless/rules///`, replacing anything already in +that directory. These are the same paths and the same shape a +locally authored rule uses, so `check`, `improve-rule`, `validate`, and +`verify` treat them identically. Nothing is written under +`.taskless/rule-metadata/`. Show the user the paths and suggest +`%(TASKLESS_CLI)s agent check`. + +**`rules` in the `--json` output lists each written rule's id**, and +the id is the rule's directory name (for example +`no-eval-3fa9c21b`). It is what `rule improve`, `rule restore`, and +`rule rollback` take, and it is on disk, so nothing needs recording. +`requestId` names the generation request only. The one command that +takes it back is `rule create --resume ` (see [Errors](#errors)); +passing it to `rule improve` as a rule id fails with `RULE_NOT_FOUND`. + +**Read `notices` if it is present.** It is an optional array of +advisory messages about a delivery that was written anyway. A rule +arriving with no `tests/` fixtures is the one to expect. That rule works, but +nothing exercises it: `verify` reports a pass over zero cases, which +reads exactly like a rule proven to work. Surface any notice to the +user rather than reporting an unqualified success. Under `--json` +these do not go to stderr, so the envelope is the only place they +appear. + + ## Input schema The `--from` JSON file conforms to: @@ -182,6 +223,7 @@ Multi-line code goes in one string with literal newlines. rules under `.taskless/rules/sg/`, the same shape `create-sg-rule` produces locally. + ## Errors With `--json`, failures emit `{ ok: false, code, message }`: @@ -199,6 +241,7 @@ With `--json`, failures emit `{ ok: false, code, message }`: | `RULE_GENERATION_FAILED` | the service failed to generate | report the message; suggest enriching prompt | | `RULE_UNSUPPORTED` | plan lacks this generation type | tell the user to enable it; do not retry | + ## See Also - `%(TASKLESS_CLI)s agent route`: the routing decision that leads here diff --git a/packages/cli/src/agent/create-runtime-rule.md b/packages/cli/src/agent/create-runtime-rule.md index 59fa23e2..af29ce31 100644 --- a/packages/cli/src/agent/create-runtime-rule.md +++ b/packages/cli/src/agent/create-runtime-rule.md @@ -1,4 +1,4 @@ -# Topic: create-runtime-rule (CLI v%(CLI_VERSION)s / topic v9) +# Topic: create-runtime-rule (CLI v%(CLI_VERSION)s / topic v10) %(ONE_COMMAND_PER_CALL)s @@ -50,6 +50,7 @@ files at once, read git, resolve an alias, look outside the repository. That is also exactly why it is gated. + ## Why this tier is gated **Because it executes code, not because of what it can express.** @@ -89,6 +90,7 @@ runtime rule it finds is listed as skipped with the reason So a runtime rule you write now is inert until the user logs in. Say that plainly rather than letting them discover it from a silent check. + ## Testing a rule you wrote yourself needs the flag `%(TASKLESS_CLI)s verify` runs a runtime rule's fixtures by executing its @@ -134,10 +136,13 @@ never reaches `check.ts`, and is reported as a defect in the case rather than in the rule, in `pass/` as well as `fail/`: such a case proves the capture did not match, which is a fact about the fixture. + ## Capture quirks -A capture is an ast-grep rule, so the **Quirks** in -`%(RECIPE_FETCH)screate-sg-rule` apply to it as well. Four of them catch +A capture is an ast-grep rule, so the quirks of `create-sg-rule` apply +to it as well. Print them with +`%(RECIPE_FETCH)screate-sg-rule --section quirks` and +`%(RECIPE_FETCH)screate-sg-rule --section pattern-quirks`. Four of them catch captures most often. Each was measured against the ast-grep this CLI ships (v%(AST_GREP_VERSION)s). The first two decide what a capture matches; the last two decide where a match says it is. @@ -176,6 +181,7 @@ matches; the last two decide where a match says it is. the evidence, "this has to compare two files", "this reads git history", and then the consequence: it has to run code, so it needs an account. Do not present this as a limitation of the rule. + [Why this tier is gated](#why-this-tier-is-gated) has the detail. 2. **Point them at login.** Obtaining access is `auth`'s job, and this recipe deliberately does not restate it: @@ -207,7 +213,12 @@ matches; the last two decide where a match says it is. - Do not author a `check.ts` and leave it in the repository unmentioned. A skipped runtime rule reports nothing, which reads exactly like a passing one. +- If the user writes a runtime rule now anyway, read + [Capture quirks](#capture-quirks) and + [Testing a rule you wrote yourself needs the flag](#testing-a-rule-you-wrote-yourself-needs-the-flag) + first. + ## See Also - `%(RECIPE_FETCH)sauth`: log in, log out, check status diff --git a/packages/cli/src/agent/create-sg-rule.md b/packages/cli/src/agent/create-sg-rule.md index c9457cba..4738b9d8 100644 --- a/packages/cli/src/agent/create-sg-rule.md +++ b/packages/cli/src/agent/create-sg-rule.md @@ -1,4 +1,4 @@ -# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v17) +# Topic: create-sg-rule (CLI v%(CLI_VERSION)s / topic v18) %(ONE_COMMAND_PER_CALL)s @@ -12,15 +12,15 @@ and follow its decision rather than adapting this recipe. Produce a verified ast-grep rule and its test cases, locally, without contacting the Taskless service. You derive the rule yourself, write it in the canonical on-disk shape, and check it with `validate` and -`verify` in a feedback loop. The files you produce match exactly what the -service writes, so `check`, `improve-rule`, `validate`, and `verify` treat -them identically. +`verify` in a feedback loop. The files match what the service writes, +so every command treats them the same. ## Preconditions - `.taskless/` directory exists. - The agent can read/write files and run shell commands. - No auth required. + ## One rule, one directory Everything defining a rule lives in `.taskless/rules/sg//`. Writing @@ -41,17 +41,14 @@ whole rule. ## Steps -1. **Learn the ast-grep rule format.** Consult the ast-grep rule - reference at https://ast-grep.github.io/guide/rule-config.html for - valid fields and operators (`pattern`, `kind`, `regex`, - `any`/`all`/`has`/`inside`/`not`) and meta-variable syntax. This - recipe does not embed the schema. Read it from upstream rather than - writing a rule from memory. +1. **Learn the ast-grep rule format** from the upstream reference, + https://ast-grep.github.io/guide/rule-config.html: fields, operators + (`pattern`, `kind`, `regex`, `any`/`all`/`has`/`inside`/`not`) and + meta-variables. Do not write a rule from memory. -2. **Gather and confirm the pattern.** Make sure you have concrete - success cases (code that should pass) and failure cases (code that - should be flagged), the target language, and any exceptions. Search - the codebase for real instances and confirm exclusions with the user. +2. **Gather and confirm the pattern**: code that should pass, code that + should be flagged, the target language, and any exceptions. Search the + codebase for real instances and confirm exclusions with the user. 3. **Check for an existing rule that already covers this.** Scan `.taskless/rules/sg/` and read each rule's `message`, `note`, and @@ -61,9 +58,10 @@ whole rule. 4. **Author the rule in the canonical shape.** Write the rule to `.taskless/rules/sg//.yml`, where `` is kebab-case and - names both the directory and the file. At minimum: + names both the directory and the file + ([One rule, one directory](#one-rule-one-directory)). At minimum: - `id`: kebab-case, matching the filename (e.g. `no-eval`) - - `language`: the target language, in ast-grep's spelling (below) + - `language`: the target language, copied from [Languages](#languages) - `severity`: `error`, `warning`, `info`, or `hint` - `message`: a concise single-line explanation - `rule`: the ast-grep rule object @@ -71,92 +69,27 @@ whole rule. Optional but useful: `note` (multi-line guidance, supports markdown), `fix` (auto-fix pattern), `ignores` (file patterns to skip). - **Read every `kind:` off a real file.** A kind is a node name from - the language's grammar, and guessing one is how a rule ends up a - config error or matching nothing. Print the tree of a file the rule - targets: + **Read every `kind:` off a real file; never guess one.** See + [Reading kinds](#reading-kinds). - ``` - %(TASKLESS_CLI)s ast - %(TASKLESS_CLI)s ast --range 40:60 - ``` + A `language` spelling that only looks right can take every other sg + rule down with it, so copy it rather than typing it. - It parses the file with the ast-grep this CLI ships, as the language - its extension resolves to, which is the parser `check` uses for it. - It prints named nodes only, so every kind it shows is one `kind:` - accepts. `--range ` or `--range :` keeps the nodes on - those lines and the ones enclosing them, `--lang ` parses - as another language, and `--json` gives the same tree as data. There - is no need to locate an ast-grep binary yourself. - - **`language` is ast-grep's vocabulary.** The vendored rule schema - types the field as a bare string with no enum, so the schema layer - accepts anything. `validate` checks it separately: an off-list spelling - is an error naming the accepted list, and a spelling ast-grep resolves - but does not itself use (`typescript` for `TypeScript`) is a notice. - Get it right anyway rather than leaning on that check. An unrecognized - name aborts config parsing, which takes every other sg rule in the - project down with it and reports nothing. - ast-grep (v%(AST_GREP_VERSION)s) parses: - - %(AST_GREP_LANGUAGES)s - - Copy a spelling from that list rather than typing one that looks - right. Off-list spellings fail two different ways and neither is - caught locally: one ast-grep does not recognize at all takes the - whole scan down (`did not match any variant of untagged enum - SgLang`, so every other rule goes unreported too), and one it - recognizes but that names the wrong parser reports nothing and looks - like a clean codebase. - - Three more `language:` mistakes have their own entries under - **Quirks** below: copying a spelling from `detect --json`, treating - `Tsx` as an alias of `TypeScript`, and expecting `Markdown` to see - inside a line. - -5. **Check the rule against Quirks.** Read the **Quirks** section below - against what you just wrote, before writing any tests. Most entries - there get past `validate` and then either fail the whole scan or match - less than they should, and a test case written from the same - assumption passes anyway. The table says which ones `validate` does - catch. Look again at: - - a `$$$` next to a comma (the separator trap), - - any `kind:` you did not read off `%(TASKLESS_CLI)s ast`, - - a rule that keys on a call's callee, if callers write - `await f(...)`, - - a `has`, `inside`, `follows` or `precedes` without `stopBy`, or - a `not` that reuses a metavariable. +5. **Check the rule against the quirks.** Read [Quirks](#quirks), + [Pattern quirks](#pattern-quirks) and + [Relational quirks](#relational-quirks) against what you just wrote, before + writing any tests. Most get past `validate` and then fail the whole + scan or match less than they should. 6. **Write the tests.** Write at least two cases in each bucket under `.taskless/rules/sg//tests/`. These paths are the same ones the - service writes; do not invent a different layout. - - %(FIXTURE_LAYOUT)s - - Each case file takes the extension ast-grep parses as the rule's - `language:` (`.ts` for `TypeScript`, `.tsx` for `Tsx`, `.yml` for - `Yaml`), and sits at a path the rule's `files:` admits and its - `ignores:` does not. To show that `files:` or `ignores:` excludes a - path, put the offending file at that path in a `pass/` case beside a - clean file the scope admits, as the layout above describes. A `pass/` - case holding only the excluded file is never read and fails. - - %(FIXTURE_SOURCES)s + service writes; do not invent a different layout. Read + [Fixtures](#fixtures) for how cases are laid out and judged and where + to take them from, before writing the first one. **Both buckets must hold a case, and `verify` fails the rule if either - one is empty.** An empty `fail/` is not a neutral starting point to - fill in later: it is the only thing that demonstrates the rule can - fire, and a rule that matches nothing anywhere looks exactly like a - rule that works without it. - - Where the rule has an arity boundary (the separator trap under - Quirks), put a case on each side of it. A pattern that starts at two - arguments when it was meant to start at one passes a test suite whose - fixtures all have two. Where the rule keys on a callee that the - repository awaits with a type argument (`await f(...)`), put that - form among the `fail/` cases. Write it only for such a callee: an - awaited generic call to a function the repository never awaits is - not code it would accept, and the case would teach the wrong shape. + one is empty.** An empty `fail/` is the only thing that demonstrates + the rule can fire. 7. **Run the feedback loop.** Both commands take the rule's directory as their argument: @@ -164,35 +97,12 @@ whole rule. %(TASKLESS_CLI)s validate .taskless/rules/sg/ --json %(TASKLESS_CLI)s verify .taskless/rules/sg/ --json ``` - `validate` asks whether the rule is well-formed: the YAML matches the - ast-grep schema and every Taskless-required field is present. Run it - the moment the rule exists. `verify` runs - the cases, after running `validate` and stopping if that fails, so a - malformed rule reports the malformation rather than a test complaint. - - Both answer in the same shape: - ```json - {"ok":true,"rules":[{"engine":"sg","ruleId":"no-eval", - "status":"passed","ok":true,"errors":[],"ran":true}]} - ``` - - `ok: true` → go to step 8. - - `ok: false` → read `errors` and fix. Repeat up to 3 times. + `validate` checks the rule is well-formed; run it the moment the rule + exists. `verify` runs `validate` first, then the cases. -| what `errors` says | fix | -|-------------------------------------------------|-------------------------------------------------| -| the YAML doesn't match the schema | check field types against the upstream schema | -| a Taskless-required field is missing | add `id`/`language`/`severity`/`message`/`rule` | -| a case didn't behave as expected | fix the rule pattern OR the test case | -| the rule's scope excluded a case | move the fixture to a path `files:` admits | -| a pass case showing an exclusion was never read | add a clean file the scope admits to that case | - - Every `regex`, at any depth, needs a `kind` beside it, or - validation fails. That includes `constraints`, `utils`, and every - `not`, `has`, `inside`, `any` and `all` sub-rule; see **Quirks**. - - Pass a directory above a rule and every rule beneath it is checked, - one entry per rule in the report. `.taskless/rules/sg` covers every - ast-grep rule; no argument at all covers the project. + - `ok: true` → go to step 8. + - `ok: false` → read `errors` and fix, using + [Feedback loop](#feedback-loop) to read them. Repeat up to 3 times. 8. **On success, report.** Show the rule directory and what is in it, plus a one-line summary of what the rule detects. Suggest @@ -200,9 +110,8 @@ whole rule. 9. **On failure, escalate, with confirmation.** If after the feedback loop the rule still cannot capture the user's cases: - - Delete the candidate `.taskless/rules/sg//` directory so the - repo is not left with a broken rule. One `rm -rf` removes the rule - and its tests together. + - Delete the candidate `.taskless/rules/sg//` directory, rule + and tests together, so the repo is not left with a broken rule. - Tell the user the local rule could not capture the cases, and that generating via the Taskless service uses a generation and requires login. @@ -210,6 +119,115 @@ whole rule. `%(RECIPE_FETCH)screate-remote-rule` and follow it. Do not call the service silently. + +## Fixtures + +How step 6's cases are laid out and judged, then where to take them +from, then what is particular to ast-grep's. + + %(FIXTURE_LAYOUT)s + + %(FIXTURE_SOURCES)s + +Each case file takes the extension ast-grep parses as the rule's +`language:` (`.ts` for `TypeScript`, `.tsx` for `Tsx`, `.yml` for +`Yaml`), and sits at a path the rule's `files:` admits and its +`ignores:` does not. To show that `files:` or `ignores:` excludes a +path, put the offending file at that path in a `pass/` case beside a +clean file the scope admits, as the layout above describes. A `pass/` +case holding only the excluded file is never read and fails. + +An empty `fail/` is not a neutral starting point to fill in later: it +is the only thing that demonstrates the rule can fire, and a rule that +matches nothing anywhere looks exactly like a rule that works without +it. + +Where the rule has an arity boundary (the separator trap under +[Pattern quirks](#pattern-quirks)), put a case on each side of it. A +pattern that starts at two arguments when it was meant to start at one +passes a test suite whose fixtures all have two. Where the rule keys on +a callee that the repository awaits with a type argument +(`await f(...)`), put that form among the `fail/` cases. Write it +only for such a callee: an awaited generic call to a function the +repository never awaits is not code it would accept, and the case would +teach the wrong shape. + + +## Feedback loop + +How to read what step 7's `validate` and `verify` report. Both answer +in the same shape: + +```json +{"ok":true,"rules":[{"engine":"sg","ruleId":"no-eval", + "status":"passed","ok":true,"errors":[],"ran":true}]} +``` + +| what `errors` says | fix | +|-------------------------------------------------|-------------------------------------------------| +| the YAML doesn't match the schema | check field types against the upstream schema | +| a Taskless-required field is missing | add `id`/`language`/`severity`/`message`/`rule` | +| a case didn't behave as expected | fix the rule pattern OR the test case | +| the rule's scope excluded a case | move the fixture to a path `files:` admits | +| a pass case showing an exclusion was never read | add a clean file the scope admits to that case | + +Every `regex`, at any depth, needs a `kind` beside it, or validation +fails. That includes `constraints`, `utils`, and every `not`, `has`, +`inside`, `any` and `all` sub-rule; see [Quirks](#quirks). + +Pass a directory above a rule and every rule beneath it is checked, +one entry per rule in the report. `.taskless/rules/sg` covers every +ast-grep rule; no argument at all covers the project. + + +## Reading kinds + +A kind is a node name from the language's grammar, and guessing one is +how a rule ends up a config error or matching nothing. Print the tree +of a file the rule targets: + +``` +%(TASKLESS_CLI)s ast +%(TASKLESS_CLI)s ast --range 40:60 +``` + +It parses the file with the ast-grep this CLI ships, as the language +its extension resolves to, which is the parser `check` uses for it. +It prints named nodes only, so every kind it shows is one `kind:` +accepts. `--range ` or `--range :` keeps the nodes on +those lines and the ones enclosing them, `--lang ` parses +as another language, and `--json` gives the same tree as data. There +is no need to locate an ast-grep binary yourself. + + +## Languages + +**`language` is ast-grep's vocabulary.** The vendored rule schema +types the field as a bare string with no enum, so the schema layer +accepts anything. `validate` checks it separately: an off-list spelling +is an error naming the accepted list, and a spelling ast-grep resolves +but does not itself use (`typescript` for `TypeScript`) is a notice. +Get it right anyway rather than leaning on that check. An unrecognized +name aborts config parsing, which takes every other sg rule in the +project down with it and reports nothing. +ast-grep (v%(AST_GREP_VERSION)s) parses: + + %(AST_GREP_LANGUAGES)s + +Copy a spelling from that list rather than typing one that looks +right. Off-list spellings fail two different ways and neither is +caught locally: one ast-grep does not recognize at all takes the +whole scan down (`did not match any variant of untagged enum +SgLang`, so every other rule goes unreported too), and one it +recognizes but that names the wrong parser reports nothing and looks +like a clean codebase. + +Three more `language:` mistakes have their own entries under +[Quirks](#quirks): copying a spelling from `detect --json`, treating +`Tsx` as an alias of `TypeScript`, and expecting `Markdown` to see +inside a line. + + ## Quirks Each of these, except the first, comes from a grammar or from ast-grep @@ -287,7 +305,7 @@ several kinds, list them in a sibling `any:` as above. vocabulary. It says `C++` where ast-grep's list says `Cpp`. That one happens to resolve, and `validate` reports it as a notice, but a spelling ast-grep does not recognize at all is a config error. -Workaround: copy the spelling from the list in step 4. +Workaround: copy the spelling from the list in [Languages](#languages). ### `Tsx` and `TypeScript` are two parsers, not aliases @@ -311,6 +329,13 @@ Workaround: a rule about the words inside a line belongs to Vale, not here. ast-grep also has no count and no absence assertion, so "every doc has an h1" is not expressible as an `sg` rule at any severity. + +## Pattern quirks + +The rest of the [Quirks](#quirks) table: the ones that come from how a +pattern or a relational rule is written rather than from the language. +The relational ones are under [Relational quirks](#relational-quirks). + ### `$$$` next to a comma (the separator trap) A `$$$` next to a comma does not mean "zero or more". The `,` in the @@ -454,6 +479,13 @@ Add a `unary_expression` branch the same way if `!`, `void` or `typeof` matter. The wrapped match spans the `await` too, so a `fix:` on this rule rewrites it along with the call. + +## Relational quirks + +The [Pattern quirks](#pattern-quirks) that come from a relational rule +rather than a pattern: a relation that looks less far than it seems, +and a `not` that excludes more than it seems. + ### `follows` and `precedes` need `stopBy: end` to cross punctuation A sibling relation walks the immediate siblings, and in most grammars @@ -526,12 +558,12 @@ metavariable, it always finds it. ## Important Notes - Do NOT make any HTTP requests to taskless.io on this path. -- Do NOT write to `.taskless/rule-metadata/`: no rule has a metadata - sidecar, service-generated ones included, because the CLI never - writes that directory. Locally authored rules iterate via file edits. +- Do NOT write to `.taskless/rule-metadata/`. The CLI never writes it, + and a local rule iterates by editing its files. - The feedback loop is the quality gate. A clean failure is a legitimate reason to escalate, but only with the user's confirmation (step 9). + ## See Also - `%(RECIPE_FETCH)sroute`: re-decide the destination diff --git a/packages/cli/src/agent/create-vale-rule.md b/packages/cli/src/agent/create-vale-rule.md index 3f853113..0339752a 100644 --- a/packages/cli/src/agent/create-vale-rule.md +++ b/packages/cli/src/agent/create-vale-rule.md @@ -1,4 +1,4 @@ -# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v22) +# Topic: create-vale-rule (CLI v%(CLI_VERSION)s / topic v23) %(ONE_COMMAND_PER_CALL)s @@ -17,6 +17,7 @@ quiet on the prose it should not, scoped to the files it is about. - The agent can read/write files and run shell commands. - No auth required. + ## One rule, one directory Everything that defines a Vale rule lives in @@ -60,20 +61,96 @@ it. ## Steps 1. **Say what the rule reads, then pick an extension point.** Write one - sentence: "this fires when a document contains ___." Vale rules are - built by extending one of its twelve checks, and the sentence tells - you which. Twelve is measured, not counted off the docs: give Vale - v%(VALE_VERSION)s an `extends` it does not know and it names the whole - set back at you: + sentence: "this fires when a document contains ___." It names one of + Vale's twelve checks: pick the `extends` from + [Extension points](#extension-points). Then read the worked rule + closest to your intent, in [Worked rules](#worked-rules) or + [More worked rules](#more-worked-rules). Most mistakes shown there + fail silently. + +2. **Write the style file** to `.taskless/rules/vale//.yml`. + The id is kebab-case (word characters only for `consistency`), the + extension `.yml`, and the id must agree in all four places + [One rule, one directory](#one-rule-one-directory) lists. Read + [Style fields](#style-fields) for the fields, [Scopes](#scopes) and + [Scope quirks](#scope-quirks) before setting `scope` (a wrong one + fails silently), and [Messages](#messages) before writing `message`. + To exempt a passage, read [Exception zones](#exception-zones). + +3. **Know what you are writing: `tokens` and `swap` keys are patterns, + not literals.** Read [Patterns](#patterns) and + [Pattern quirks](#pattern-quirks) before writing one. A + `capitalization`, `occurrence` or `metric` rule has neither, skip to + step 4. + +4. **Scope the rule** by writing `.taskless/rules/vale//.vale.ini`. + A rule with no config is enabled nowhere: it parses, it runs, and it + reports nothing. The whole file, for a rule that applies to markdown: + + ```ini + # Which files this rule applies to. + [*.md] + tskl) rule = no-simply + no-simply.no-simply = YES + ``` + + Do not write `BasedOnStyles`, `StylesPath` or `MinAlertLevel`. Read + [Vale config](#vale-config) to exclude files, + [Config schema](#config-schema) for what `validate` rejects, and + [File reach](#file-reach) before widening a glob. + +5. **Write the fixtures** under `.taskless/rules/vale//tests/`, one + directory per case, at a path the rule's matchers admit. Read + [Fixtures](#fixtures) and [Near-misses](#near-misses), and for a + voice or tone rule [Voice rules](#voice-rules). + +6. **Validate, then verify**, from the project root: ``` - 'extends' key must be one of [capitalization conditional consistency - existence occurrence repetition substitution readability spelling - sequence metric script]. + %(TASKLESS_CLI)s validate .taskless/rules/vale/ --json + %(TASKLESS_CLI)s verify .taskless/rules/vale/ --json ``` - The docs enumerate eleven, folding `readability` into `metric`. They - are separate checks with separate fields. + `ok` is the answer and `errors` names what failed. Read the rendered + messages in `findings`. Use only the Vale shipped with this CLI, + never a bare `vale`. Read [Validate and verify](#validate-and-verify), + [Empty results](#empty-results) before trusting a `results: []`, and + [Troubleshooting](#troubleshooting) when a case misbehaves. + +7. **Report.** Show the rule directory you created and what is in it, a + one-line summary of what the rule flags, and the glob it is scoped + to. The scope is a decision the user should see rather than one + buried in a config. Note that a whole-project `%(TASKLESS_CLI)s check` skips + your fixtures: `.taskless/` is excluded from the project walk, by + design. `verify` is what exercises them. + +## Important Notes + +- Vale reads one document at a time and has no cross-document view. A + rule about consistency *between* documents cannot be written here. +- Prose inside code is still prose: comments and docstrings are Vale's + subject, and a rule about them belongs under a matcher whose glob + covers the source files. +- Do NOT add a `[*]` matcher to widen a rule that isn't firing. Matchers + from every rule are assembled into one config, so `[*]` applies this + rule to every file the walk reaches and turns one rule's scoping bug + into a flood of false positives. + + +## Extension points + +Vale rules are built by extending one of its twelve checks. Twelve is +measured, not counted off the docs: give Vale v%(VALE_VERSION)s an +`extends` it does not know and it names the whole set back at you: + +``` +'extends' key must be one of [capitalization conditional consistency +existence occurrence repetition substitution readability spelling +sequence metric script]. +``` + +The docs enumerate eleven, folding `readability` into `metric`. They +are separate checks with separate fields. | If the rule is about… | extends | |-----------------------------------------------------------------------------------------|------------------| @@ -90,75 +167,126 @@ it. | phrases that must appear in a fixed order | `sequence` | | anything the above cannot express (Tengo script) | `script` | - **`capitalization` is about a whole scope, not a word.** It asks - whether an entire heading or sentence matches a case pattern. It - cannot express "the word GitHub, wherever it appears, is spelled - thus". That is a `substitution`, because you are swapping a wrong - spelling for a right one. Reaching for `capitalization` on a product - name produces a rule that flags whole sentences: measured, a rule with - `match: GitHub` reports `We host on Github and it is fine. should be - GitHub`. - - For the five this recipe has no worked example of, `metric`, - `readability`, `spelling`, `sequence`, `script`, read - https://docs.vale.sh/styles before inventing something. Vale has no - facility for a rule that does not extend one of these twelve, and an - `extends` outside the set is not a rule that misbehaves: Vale exits 2 - and **every** Vale rule in the project goes unreported for that run. - `validate` rejects it before Vale is invoked, and names the twelve. - - **The other seven have a worked rule at the end of this recipe**, - nine rules between them, each with the near-miss that fails and why. - Read the one closest to your intent before writing anything, the - mistakes documented there are observed, and most of them fail - silently. - -2. **Write the style file** to - `.taskless/rules/vale//.yml`, where `` is kebab-case and - names both the directory and the file. - - **One exception: a `consistency` rule's id must be word characters - only** (`izeise`, not `ize-ise`). That check compiles the id into its - pattern as a regex group name, and a hyphen there fails the entire - Vale run. `validate` catches it. Kebab-case is correct for the other - eleven. - - Every rule carries: +**`capitalization` is about a whole scope, not a word.** It asks +whether an entire heading or sentence matches a case pattern. It +cannot express "the word GitHub, wherever it appears, is spelled +thus". That is a `substitution`, because you are swapping a wrong +spelling for a right one. Reaching for `capitalization` on a product +name produces a rule that flags whole sentences: measured, a rule with +`match: GitHub` reports `We host on Github and it is fine. should be +GitHub`. + +For the five this recipe has no worked example of, `metric`, +`readability`, `spelling`, `sequence`, `script`, read +https://docs.vale.sh/styles before inventing something. Vale has no +facility for a rule that does not extend one of these twelve, and an +`extends` outside the set is not a rule that misbehaves: Vale exits 2 +and **every** Vale rule in the project goes unreported for that run. +`validate` rejects it before Vale is invoked, and names the twelve. + +**The other seven have a worked rule**, nine rules between them in +[Worked rules](#worked-rules) and [More worked rules](#more-worked-rules), +each with the near-miss that fails and why. Read the one closest to your +intent before writing anything, the mistakes documented there are +observed, and most of them fail silently. + + +## Style fields + +The style file is `.taskless/rules/vale//.yml`, where `` is +kebab-case and names both the directory and the file. + +**One exception: a `consistency` rule's id must be word characters +only** (`izeise`, not `ize-ise`). That check compiles the id into its +pattern as a regex group name, and a hyphen there fails the entire +Vale run. `validate` catches it. Kebab-case is correct for the other +eleven. + +Every rule carries: | Field | Required | Notes | |-----------|----------|-----------------------------------------------| -| `extends` | yes | one of the twelve above | -| `message` | yes | shown to the user; see the `%%s` table below | +| `extends` | yes | one of the twelve checks | +| `message` | yes | shown to the user; see [Messages](#messages) | | `level` | no | `suggestion` (default), `warning`, or `error` | -| `scope` | no | narrow to part of a document; see below | +| `scope` | no | narrow to part of a document; see [Scopes](#scopes) | | `link` | no | a URL the reader can follow for the reasoning | | `limit` | no | cap findings from this rule per scope | - **`vocab` is not one of these.** It reads as though it were, but it is - a per-check field: measured, only `existence`, `substitution`, - `capitalization`, `conditional` and `repetition` accept it, and - `occurrence`, `metric`, `readability`, `script` and `sequence` reject - it. On one of those five it raises `E201` and takes every other Vale - rule in the project down with it. It is listed with the per-check - fields below. - - **The file extension must be `.yml`.** Measured: rename a working - style file to `.yaml` and Vale loads nothing, no error, no warning, - zero findings, and `. = YES` still parses. It is - indistinguishable from a rule whose pattern never matched. - - **`scope` decides where the rule looks**, so getting it wrong is a - silent under-fire rather than an error. An unrecognized scope is not - rejected by Vale: `scope: fenced` loads, runs, and matches nothing. - It is the worst of the three failures on this page, because unlike a - bad `extends` or a foreign field it does not even take the run down to - tell you. The rule is inert, forever. `validate` rejects a scope - outside the table below, which is the only layer that ever will. - - Every value below was measured against Vale v%(VALE_VERSION)s by - authoring a rule with that scope and a document the rule had to flag. - "Fires" means the finding appeared; a scope that never fired is not on - this list. +**`vocab` is not one of these.** It reads as though it were, but it is +a per-check field: measured, only `existence`, `substitution`, +`capitalization`, `conditional` and `repetition` accept it, and +`occurrence`, `metric`, `readability`, `script` and `sequence` reject +it. On one of those five it raises `E201` and takes every other Vale +rule in the project down with it. It is listed with the per-check +fields below. + +**The file extension must be `.yml`.** Measured: rename a working +style file to `.yaml` and Vale loads nothing, no error, no warning, +zero findings, and `. = YES` still parses. It is +indistinguishable from a rule whose pattern never matched. + +**Then the fields the extension point adds**. This is where the rule +actually lives, and each check reads only its own: + +| extends | its fields | +|------------------|------------------------------------------------------------------------------| +| `existence` | `tokens` (a list) or `raw`; `ignorecase`, `nonword`, `exceptions`, `append`, `vocab` | +| `substitution` | `swap` (a map of observed → expected); `ignorecase`, `nonword`, `exceptions`, `capitalize`, `pos`, `vocab` | +| `capitalization` | `match`; `style` (with `$title`), `exceptions`, `threshold`, `indicators`, `prefix`, `vocab` | +| `occurrence` | `token`, `max` and/or `min`; `ignorecase` | +| `repetition` | `tokens`; `alpha`, `ignorecase`, `exceptions`, `max`, `vocab` | +| `consistency` | `either` (a map of the two acceptable forms); `ignorecase`, `nonword` | +| `conditional` | `first`, `second`; `exceptions`, `ignorecase`, `vocab` | +| `metric` | `formula`, `condition` | +| `readability` | `metrics` (a list of formula names), `grade` | +| `spelling` | `aff`, `dic`, `custom`, `filters`, `ignore`, `threshold` | +| `sequence` | `tokens` (each a `pattern`/`tag` map); `ignorecase`, `exceptions` | +| `script` | `script` (Tengo source) | + +The list above is measured, not transcribed: every entry was added to +a minimal rule of that check and the run watched for `E201`. Three +corrections fall out of it, all against the published docs: +`capitalization` takes `prefix` (singular) and rejects both `prefixes` +and `suffixes`, it rejects `ignorecase`, and `occurrence` rejects +`exceptions` and `vocab`. + +**Field names are matched case-insensitively, but `extends`, `message` +and `level` are not.** Measured: `Tokens:` and `ignoreCase:` are read +exactly as their lowercase spellings, while `EXTENDS:` fails with +"Missing the required 'extends' key". Their *values* are case-sensitive +too, `level: WARNING` and `extends: Existence` are both rejected. +Write everything lowercase and none of this can bite you. + +**A field from the wrong check is the loudest failure Vale has.** +`tokens` on an `occurrence` check gives +`E201 … has invalid keys: 'tokens'`, exit 2, and, because Vale reads +one assembled config per run. **no** Vale rule in the project reports +anything. `validate` rejects the rule before Vale is invoked, so this +cannot reach `check`. + +**Two checks are exempt, and that is not a licence.** Measured, +`consistency` and `spelling` accept any key at all: `bananafield: +true` on either loads without complaint and is ignored. A misspelled +field on those two is a silent no-op instead of a loud one, so the +schema cannot catch a typo there and neither can Vale. Read the field +list twice when writing those two. + + +## Scopes + +**`scope` decides where the rule looks**, so getting it wrong is a +silent under-fire rather than an error. An unrecognized scope is not +rejected by Vale: `scope: fenced` loads, runs, and matches nothing. +It is the worst of the three failures on this page, because unlike a +bad `extends` or a foreign field it does not even take the run down to +tell you. The rule is inert, forever. `validate` rejects a scope +outside the table below, which is the only layer that ever will. + +Every value below was measured against Vale v%(VALE_VERSION)s by +authoring a rule with that scope and a document the rule had to flag. +"Fires" means the finding appeared; a scope that never fired is not on +this list. | `scope` | reaches | |------------------------|-----------------------------------------------------------| @@ -180,7 +308,7 @@ it. | `table.header` | header cells | | `table.cell` | body cells | | `table.caption` | a table's caption | -| `figure.caption` | a figure's caption, but see below | +| `figure.caption` | a figure's caption, but see [Scope quirks](#scope-quirks) | | `frontmatter` | every YAML front-matter value | | `frontmatter.` | one front-matter key's value | | `text.class.` | HTML elements carrying that class | @@ -189,217 +317,180 @@ it. | `comment.block` | `/* … */`-style comments | | `doc()` | elements matched by a CSS selector; see below | - **`doc()` picks part of a document by CSS selector**, the - same way in every markup format, and a heading with everything under - it is a `section`, so one section of a document is - `doc(section:has(> h2:contains("Decision")))`. Chain it to narrow an - ordinary scope to that element: `text & doc(...)` is prose inside it, - `sentence & doc(...)` one sentence at a time inside it, `~doc(...)` is - everything outside it. On its own, `doc(...)` lints what is INSIDE the - element as one block, which is what `occurrence` (a section must say - "we will") and `metric` (a section runs over budget) want. Measured: a - `metric` with `scope: doc(section:has(> h2:contains("Consequences")))` - and `formula: words` counts that section's words, not the document's. - - **A leaf element reads its own text either way.** `doc(h2)` alone and - `text & doc(h2)` both lint the heading's text, and the same holds for - `doc(p)` and `doc(li)`. Prefer the chained form anyway: it says which - text you mean, and a rule written for Vale 3.22.0 or earlier, where - the standalone form matched nothing, already uses it. - - **The selector is Vale's to check, not `validate`'s.** A selector Vale - cannot compile (`doc(h2[)`) fails the whole run at load with - `E201 invalid selector in 'doc(...)'`, which `verify` reports. A selector - that compiles and matches nothing is silent, like any scope with no - construct to find. `validate` checks that the term is `doc(` … `)` with - something between, and no more. - - **`raw` subsumes `code` and `text`.** Measured on one document holding - the token in prose, in an inline span, and in a fenced block: `text` - found one, `code` found one, `[code, text]` found two, `raw` found all - three. If you want prose and inline code but not fenced blocks, write - the list, `raw` is not "a bit wider", it is everything. - - **Front matter is text, to `raw` and to every other scope but its - own.** A product-casing rule under `scope: raw` flagged - `target: taskless` in a blog post's front matter, where the lowercase - value is a machine key the publish path reads, and capitalizing it - would have broken the corpus. That is not a `raw` peculiarity: - measured on Vale v%(VALE_VERSION)s with the same rule, `text`, the - default scope and `[raw, code, text]` all reported the value on the - front-matter line too, so dropping `scope: raw` on its own fixes - nothing. `paragraph` and `sentence` skip it, and `frontmatter.` - matches only that key and never leaks into body prose; `~frontmatter` - also left the key alone and fired on the body, but chained onto `raw` - (`raw & ~frontmatter`) it matched nothing at all, so the negation - does not rescue a `raw` rule. Three fixes, then: `scope: paragraph` - or `sentence` when the rule is about prose, `frontmatter.` when - it is about a field, and in either case a `pass/` fixture that - carries the machine key, so the rule cannot regress onto it without - `verify` saying so: - - ```markdown - --- - target: taskless - --- - Taskless compiles the correction into a rule. - ``` +**`doc()` picks part of a document by CSS selector**, the +same way in every markup format, and a heading with everything under +it is a `section`, so one section of a document is +`doc(section:has(> h2:contains("Decision")))`. Chain it to narrow an +ordinary scope to that element: `text & doc(...)` is prose inside it, +`sentence & doc(...)` one sentence at a time inside it, `~doc(...)` is +everything outside it. On its own, `doc(...)` lints what is INSIDE the +element as one block, which is what `occurrence` (a section must say +"we will") and `metric` (a section runs over budget) want. Measured: a +`metric` with `scope: doc(section:has(> h2:contains("Consequences")))` +and `formula: words` counts that section's words, not the document's. + +**A leaf element reads its own text either way.** `doc(h2)` alone and +`text & doc(h2)` both lint the heading's text, and the same holds for +`doc(p)` and `doc(li)`. Prefer the chained form anyway: it says which +text you mean, and a rule written for Vale 3.22.0 or earlier, where +the standalone form matched nothing, already uses it. + +**The selector is Vale's to check, not `validate`'s.** A selector Vale +cannot compile (`doc(h2[)`) fails the whole run at load with +`E201 invalid selector in 'doc(...)'`, which `verify` reports. A selector +that compiles and matches nothing is silent, like any scope with no +construct to find. `validate` checks that the term is `doc(` … `)` with +something between, and no more. + + +## Scope quirks + +**`raw` subsumes `code` and `text`.** Measured on one document holding +the token in prose, in an inline span, and in a fenced block: `text` +found one, `code` found one, `[code, text]` found two, `raw` found all +three. If you want prose and inline code but not fenced blocks, write +the list, `raw` is not "a bit wider", it is everything. + +**Front matter is text, to `raw` and to every other scope but its +own.** A product-casing rule under `scope: raw` flagged +`target: taskless` in a blog post's front matter, where the lowercase +value is a machine key the publish path reads, and capitalizing it +would have broken the corpus. That is not a `raw` peculiarity: +measured on Vale v%(VALE_VERSION)s with the same rule, `text`, the +default scope and `[raw, code, text]` all reported the value on the +front-matter line too, so dropping `scope: raw` on its own fixes +nothing. `paragraph` and `sentence` skip it, and `frontmatter.` +matches only that key and never leaks into body prose; `~frontmatter` +also left the key alone and fired on the body, but chained onto `raw` +(`raw & ~frontmatter`) it matched nothing at all, so the negation +does not rescue a `raw` rule. Three fixes, then: `scope: paragraph` +or `sentence` when the rule is about prose, `frontmatter.` when +it is about a field, and in either case a `pass/` fixture that +carries the machine key, so the rule cannot regress onto it without +`verify` saying so: + +```markdown +--- +target: taskless +--- +Taskless compiles the correction into a rule. +``` - The first line is a machine key, read by the publish path. The last - is prose, correctly cased. A rule that fires on this document is - reading the wrong one. - - The same reach is useful in the other direction. `occurrence` with - `min: 1` under `scope: raw` can require a front-matter field to - exist, because `raw` reads the key and the value as one unparsed - line. Measured: this fires on a document whose front matter has a - `title` and no `description`, fires on one with no front matter at - all, and stays quiet once the field is there. - - ```yaml - extends: occurrence - message: "A blog draft needs a description in its frontmatter." - level: error - scope: raw - min: 1 - token: '(?m)^description: .+$' - ``` +The first line is a machine key, read by the publish path. The last +is prose, correctly cased. A rule that fires on this document is +reading the wrong one. - **Vale drops everything inside a `
` element.** Measured: a - `
` nested in `
` is invisible to *every* scope, - `text` and `raw` included, so a `scope: figure.caption` rule over a - normally-marked-up figure reports nothing and looks like a bad scope. - A bare `
` is linted, and `figure.caption` fires on it. If a - fixture is not firing, check whether its subject is inside a `
` - before you touch the pattern. - - **`scope` also takes `~` and `&`.** `~code` is everything but inline - code; `text & ~code` chains two operands; a list (`[code, text]`) is a - union. All three parse and behave. - - **Negating an inline element removes its text from the paragraph, as - of Vale 3.22.0.** `~link`, `~strong`, `~emphasis` and `~code` are the - four. A rule with `text & ~link` still runs on every paragraph, and - the link text inside a paragraph is blanked before the rule sees it, - so a casing or wording rule can leave link text and bold terms alone - without giving up the sentence around them. Positions after the - blanked element do not move. Through 3.21.0 the same scope only kept - the rule off the link's own fragment, and the paragraph still carried - the link text, so a rule written this way now reports fewer findings. - The release note spells these `text.raw` and `paragraph.link`; those - dotted forms are not scopes on the binary, bare or negated, and - `validate` rejects them. Write the bare inline name. - - **A negation over a scope Vale does not know is a silent no-op.** - Measured: `~banana` and `text & ~banana` both fire on everything, - because there is no such scope to subtract. A typo inside a `~` does - not narrow the rule and does not widen it visibly. It removes the - exclusion you wrote the rule for. `validate` checks the operands inside - `~` and `&` as strictly as a bare one, for exactly this reason, the - one place it is deliberately stricter than Vale itself. - - **`scope` is per-rule, and rules do not interact.** Taskless assembles - every rule's matchers into one config for the run, which invites the - assumption that one rule's `scope` narrows another's, or that two - rules over the same file compete. They do not: each check carries its - own scope and is evaluated independently. If a rule is over-firing, - the cause is in that rule's own `scope` and glob, never in a - neighbour's. - - **A directive turns any rule off, `raw` included, as of Vale 3.20.0.** - `` opens a zone and `= YES` closes it. - Vale records the region each directive covers and suppresses any - alert located inside it, which reaches a `raw`-scoped rule too. - Through 3.19.0 a directive was applied to the *parsed* document and - `raw` reads the unparsed one, so a `raw` rule fired straight through - every zone: a rule about a shell command, a flag, or a package name - was exempt-or-remove with nothing in between. It is not any more. - - One thing to know before writing one over a `raw` rule: at `raw` - scope the directive line is itself linted text. A rule whose token - appears in its own id matches the marker that silences it, and - reports a finding on the directive. Name the rule so its own id does - not contain the word it looks for. - - **A zone is two lines, and no blank line may separate it from the - prose it wraps.** Measured on Vale 3.20.0: - - - Inline at a list item's continuation indent works, and this is the - form to reach for. The pair covers the lines between it and - nothing else. - - At column 0 it works too, and it ends any list it interrupts. - Blocks that then reparse as indented code are already outside a - prose rule's reach, so a zone placed there can move coverage - rather than restore it. - - A pair separated from its prose by blank lines, at an indent past - the code-block threshold, suppresses nothing: what it wraps is not - prose any more. Nothing reports that, either. - - Through 3.19.0 the first of those did nothing at all. An inline pair - was read once per block, so the `NO` and the `YES` cancelled out - before the paragraph was linted and the words stayed reported with - no error and no warning. A zone had to wrap a whole step or a whole - section at the margin, and exempting the two steps of this recipe - that quote hedging words would have put 176 lines of prose out of - reach to keep two words of an example. That price is gone. - - **A shown directive has to be inline or fenced, never a bare line.** - A directive on a line of its own now takes effect on the recipe at - any indent, and the CLI strips it before serving so a reader never - sees it. Measured: a directive inside backticks and a directive - inside a fenced block are both inert, which is why every directive - quoted in this file is one or the other. - - **Then the fields the extension point adds**. This is where the rule - actually lives, and each check reads only its own: +The same reach is useful in the other direction. `occurrence` with +`min: 1` under `scope: raw` can require a front-matter field to +exist, because `raw` reads the key and the value as one unparsed +line. Measured: this fires on a document whose front matter has a +`title` and no `description`, fires on one with no front matter at +all, and stays quiet once the field is there. -| extends | its fields | -|------------------|------------------------------------------------------------------------------| -| `existence` | `tokens` (a list) or `raw`; `ignorecase`, `nonword`, `exceptions`, `append`, `vocab` | -| `substitution` | `swap` (a map of observed → expected); `ignorecase`, `nonword`, `exceptions`, `capitalize`, `pos`, `vocab` | -| `capitalization` | `match`; `style` (with `$title`), `exceptions`, `threshold`, `indicators`, `prefix`, `vocab` | -| `occurrence` | `token`, `max` and/or `min`; `ignorecase` | -| `repetition` | `tokens`; `alpha`, `ignorecase`, `exceptions`, `max`, `vocab` | -| `consistency` | `either` (a map of the two acceptable forms); `ignorecase`, `nonword` | -| `conditional` | `first`, `second`; `exceptions`, `ignorecase`, `vocab` | -| `metric` | `formula`, `condition` | -| `readability` | `metrics` (a list of formula names), `grade` | -| `spelling` | `aff`, `dic`, `custom`, `filters`, `ignore`, `threshold` | -| `sequence` | `tokens` (each a `pattern`/`tag` map); `ignorecase`, `exceptions` | -| `script` | `script` (Tengo source) | +```yaml +extends: occurrence +message: "A blog draft needs a description in its frontmatter." +level: error +scope: raw +min: 1 +token: '(?m)^description: .+$' +``` - The list above is measured, not transcribed: every entry was added to - a minimal rule of that check and the run watched for `E201`. Three - corrections fall out of it, all against the published docs: - `capitalization` takes `prefix` (singular) and rejects both `prefixes` - and `suffixes`, it rejects `ignorecase`, and `occurrence` rejects - `exceptions` and `vocab`. - - **Field names are matched case-insensitively, but `extends`, `message` - and `level` are not.** Measured: `Tokens:` and `ignoreCase:` are read - exactly as their lowercase spellings, while `EXTENDS:` fails with - "Missing the required 'extends' key". Their *values* are case-sensitive - too, `level: WARNING` and `extends: Existence` are both rejected. - Write everything lowercase and none of this can bite you. - - **A field from the wrong check is the loudest failure Vale has.** - `tokens` on an `occurrence` check gives - `E201 … has invalid keys: 'tokens'`, exit 2, and, because Vale reads - one assembled config per run. **no** Vale rule in the project reports - anything. `validate` rejects the rule before Vale is invoked, so this - cannot reach `check`. - - **Two checks are exempt, and that is not a licence.** Measured, - `consistency` and `spelling` accept any key at all: `bananafield: - true` on either loads without complaint and is ignored. A misspelled - field on those two is a silent no-op instead of a loud one, so the - schema cannot catch a typo there and neither can Vale. Read the field - list twice when writing those two. - - **What `%%s` fills with depends on the extension point.** Getting this - wrong is the one mistake in this recipe that passes every check below -. The rule fires, the fixtures are green, and only a human reading the - message sees that it is nonsense. +**Vale drops everything inside a `
` element.** Measured: a +`
` nested in `
` is invisible to *every* scope, +`text` and `raw` included, so a `scope: figure.caption` rule over a +normally-marked-up figure reports nothing and looks like a bad scope. +A bare `
` is linted, and `figure.caption` fires on it. If a +fixture is not firing, check whether its subject is inside a `
` +before you touch the pattern. + +**`scope` also takes `~` and `&`.** `~code` is everything but inline +code; `text & ~code` chains two operands; a list (`[code, text]`) is a +union. All three parse and behave. + +**Negating an inline element removes its text from the paragraph, as +of Vale 3.22.0.** `~link`, `~strong`, `~emphasis` and `~code` are the +four. A rule with `text & ~link` still runs on every paragraph, and +the link text inside a paragraph is blanked before the rule sees it, +so a casing or wording rule can leave link text and bold terms alone +without giving up the sentence around them. Positions after the +blanked element do not move. Through 3.21.0 the same scope only kept +the rule off the link's own fragment, and the paragraph still carried +the link text, so a rule written this way now reports fewer findings. +The release note spells these `text.raw` and `paragraph.link`; those +dotted forms are not scopes on the binary, bare or negated, and +`validate` rejects them. Write the bare inline name. + +**A negation over a scope Vale does not know is a silent no-op.** +Measured: `~banana` and `text & ~banana` both fire on everything, +because there is no such scope to subtract. A typo inside a `~` does +not narrow the rule and does not widen it visibly. It removes the +exclusion you wrote the rule for. `validate` checks the operands inside +`~` and `&` as strictly as a bare one, for exactly this reason, the +one place it is deliberately stricter than Vale itself. + +**`scope` is per-rule, and rules do not interact.** Taskless assembles +every rule's matchers into one config for the run, which invites the +assumption that one rule's `scope` narrows another's, or that two +rules over the same file compete. They do not: each check carries its +own scope and is evaluated independently. If a rule is over-firing, +the cause is in that rule's own `scope` and glob, never in a +neighbour's. + + +## Exception zones + +**A directive turns any rule off, `raw` included, as of Vale 3.20.0.** +`` opens a zone and `= YES` closes it. +Vale records the region each directive covers and suppresses any +alert located inside it, which reaches a `raw`-scoped rule too. +Through 3.19.0 a directive was applied to the *parsed* document and +`raw` reads the unparsed one, so a `raw` rule fired straight through +every zone: a rule about a shell command, a flag, or a package name +was exempt-or-remove with nothing in between. It is not any more. + +One thing to know before writing one over a `raw` rule: at `raw` +scope the directive line is itself linted text. A rule whose token +appears in its own id matches the marker that silences it, and +reports a finding on the directive. Name the rule so its own id does +not contain the word it looks for. + +**A zone is two lines, and no blank line may separate it from the +prose it wraps.** Measured on Vale 3.20.0: + +- Inline at a list item's continuation indent works, and this is the + form to reach for. The pair covers the lines between it and + nothing else. +- At column 0 it works too, and it ends any list it interrupts. + Blocks that then reparse as indented code are already outside a + prose rule's reach, so a zone placed there can move coverage + rather than restore it. +- A pair separated from its prose by blank lines, at an indent past + the code-block threshold, suppresses nothing: what it wraps is not + prose any more. Nothing reports that, either. + +Through 3.19.0 the first of those did nothing at all. An inline pair +was read once per block, so the `NO` and the `YES` cancelled out +before the paragraph was linted and the words stayed reported with +no error and no warning. A zone had to wrap a whole step or a whole +section at the margin, and exempting the two steps of this recipe +that quote hedging words would have put 176 lines of prose out of +reach to keep two words of an example. That price is gone. + +**A shown directive has to be inline or fenced, never a bare line.** +A directive on a line of its own now takes effect on the recipe at +any indent, and the CLI strips it before serving so a reader never +sees it. Measured: a directive inside backticks and a directive +inside a fenced block are both inert, which is why every directive +quoted in this file is one or the other. + + +## Messages + +**What `%%s` fills with depends on the extension point.** Getting this +wrong is the one mistake in this recipe that passes every check. The +rule fires, the fixtures are green, and only a human reading the +message sees that it is nonsense. | extends | `%%s` count | fills with, left to right | |------------------|-------------|-------------------------------------------------------| @@ -407,55 +498,55 @@ it. | `substitution` | **two** | the **replacement**, then the matched text | | `capitalization` | one | the scope that failed (the whole heading or sentence) | - Measured: a `substitution` message with a single `%%s` interpolates the - *replacement*, not the match, so `"Use GitHub not %%s"` against the text - `Github` renders `Use GitHub not GitHub`. - - For the other nine, do not guess. Write the message, run step 6, and - read it back off the finding, no test you can write catches a wrong - `%%s`, so your own eyes on the rendered message are the check. - - ```yaml - # existence, flag these tokens wherever they appear - extends: existence - message: "Avoid '%%s', it hides the work from the reader" - level: warning - ignorecase: true - tokens: - - simply - - just - ``` +Measured: a `substitution` message with a single `%%s` interpolates the +*replacement*, not the match, so `"Use GitHub not %%s"` against the text +`Github` renders `Use GitHub not GitHub`. - ```yaml - # substitution, first %%s is the replacement, second is what was found - extends: substitution - message: "Use '%%s' instead of '%%s'" - level: warning - ignorecase: true - swap: - utilize: use - "in order to": to - ``` +For the other nine, do not guess. Write the message, run step 6, and +read it back off the finding, no test you can write catches a wrong +`%%s`, so your own eyes on the rendered message are the check. - ```yaml - # capitalization, a whole heading must be in sentence case - extends: capitalization - message: "'%%s' should be in sentence case" - level: warning - scope: heading - match: $sentence - exceptions: - - Taskless - - API - ``` +```yaml +# existence, flag these tokens wherever they appear +extends: existence +message: "Avoid '%%s', it hides the work from the reader" +level: warning +ignorecase: true +tokens: + - simply + - just +``` + +```yaml +# substitution, first %%s is the replacement, second is what was found +extends: substitution +message: "Use '%%s' instead of '%%s'" +level: warning +ignorecase: true +swap: + utilize: use + "in order to": to +``` + +```yaml +# capitalization, a whole heading must be in sentence case +extends: capitalization +message: "'%%s' should be in sentence case" +level: warning +scope: heading +match: $sentence +exceptions: + - Taskless + - API +``` - `match` takes `$sentence`, `$title`, `$lower`, or `$upper`. A literal - string is legal but means "this whole scope must read exactly that", - which is almost never what anyone wants. See step 1. +`match` takes `$sentence`, `$title`, `$lower`, or `$upper`. A literal +string is legal but means "this whole scope must read exactly that", +which is almost never what anyone wants. See step 1. - **`$sentence` means first word capitalized, everything else lowercase -, proper nouns included.** It is not "sentence case allowing proper - nouns". Measured with `exceptions: [Taskless, API]` on headings: +**`$sentence` means first word capitalized, everything else lowercase, +proper nouns included.** It is not "sentence case allowing proper +nouns". Measured with `exceptions: [Taskless, API]` on headings: | Heading | Result | |-----------------------------------|------------------------------------------------| @@ -466,387 +557,405 @@ it. | `getting started lowercase` | **fires**: the first word must be capitalized | | `Getting Started With Title Case` | **fires** | - So `exceptions` is not decoration: every proper noun, product name and - acronym the docs use has to be listed, or the rule flags correct - headings. Collect them from the docs before writing the rule, and - expect to add to the list. - -3. **Know what you are writing: `tokens` and `swap` keys are patterns, - not literals.** They compile as Go regular expressions. - - *This step is about `tokens` and `swap` only. A `capitalization`, - `occurrence` or `metric` rule has neither, skip to step 4.* - - - `(?:…)`, `[…]`, `|`, `+`, `?` all work. - - **Lookaround and backreferences work, and they are not free.** - Vale compiles every pattern with `regexp2` in RE2 compatibility - mode, so `(?=…)`, `(?<=…)` and `\1` are all available even - though Go's own `regexp` has none of them. (Older versions of - this topic said Vale tried Go's `regexp` first and fell back. - There is no fallback and never was; the conclusion was right and - the reason was not.) Measured on Vale v%(VALE_VERSION)s with - throwaway rules, each firing on its `fail/` fixture and quiet on - `pass/`: a repeated-word `\b(\w+) \1\b`, a `foo(?= bar)` - lookahead, and a `(?<=x )y` lookbehind. That engine backtracks - and is slower than a pattern without lookaround, so keep - lookaround off a rule that runs over every file in the project, - and prove any rule that uses one with a fixture rather than - trusting the syntax. "X but not when followed by Y" is therefore - writable as a single `substitution`, but splitting it or - narrowing with `scope` is still the cheaper rule when either - will do. - - Three measured limits sit on top of that, and all three are - silent: - - - **No capture group survives a `swap` key**, so a - backreference in one can never match. Vale compiles all of a - rule's swap keys into ONE alternation and wraps each key in a - capture group of its own, because the number of the group - that matched is how it knows which replacement to offer. Those - wrappers have to be numbered 1, 2, 3…, so any group you wrote - is rewritten to `(?:…)` first. Whichever way round, `\1` ends - up pointing at Vale's wrapper (the group still being matched) - and matches nothing. Measured silent, with nothing on - stderr and the rule loading cleanly: `(\w+) \1`, `(the) \1`, - `the \1` and `(?:\w+) \1`. **`%(TASKLESS_CLI)s validate` rejects a - `swap` key with a backreference**, because a rule that loads - and never fires is indistinguishable from a clean project. A - repeated-word check has to be an `existence` rule, where the - same pattern works under both `tokens` and `raw`. - - Two things this does NOT mean: - - - **`$1` in the swap VALUE works.** Vale expands it against - the original key, before the rewrite, so - `colour(s?): color$1` offers `colors` for `colours` and - `color` for `colour`. Only the key loses its groups. - - **`\1` inside a character class is fine**, because there it - is an octal escape rather than a backreference. `[\1a]bc` - matches `abc` and `validate` accepts it. - - - **A trailing lookahead in `tokens` or `swap` has to peek at a - non-word character.** The implicit `\b` is appended after the - lookahead (the lookahead is zero-width, so the position is - still where the match ended), which puts the boundary between - the match and the text peeked at. `foo(?= bar)` fires; - `foo(?=bar)` can never match, whatever the document says. Use - `raw` when the lookahead has to land on a word character. - - **A leading lookbehind has the same problem at the other - end.** The implicit `\b` goes on before it, so the boundary - is tested between what the lookbehind peeked at and the match. - `(?<=x )y` fires; `(?<=x)foo` is silent under `tokens` and - under `swap` on a document reading `xfoo`, and fires under - `raw`. Same fix: use `raw`. - - **Word boundaries are applied for you, around the whole pattern.** - Measured: `Github` does not fire inside `GithubToken`, and the - multi-word `click here` does not fire inside `Clicking here`. - - **A hyphen is a boundary, so a hyphenated compound is not - protected.** `obviously` fires inside `obviously-named`, while - `obviously_stale` is safe because `_` is a word character. Do not - reach for a hyphenated compound as a `pass/` near-miss; it is the - case most likely to fire. - - **Regex metacharacters in a real phrase are live.** "maybe?" is a - pattern meaning "mayb" followed by an optional "e". Escape it. - - **Overlapping alternatives resolve first-wins**, one finding per - match. If `can login` and `login with` both match a sentence, you - get whichever is written first, once, not both. - - `ignorecase: true` matches any casing **and still skips text that - already equals the replacement.** Measured with `Github: GitHub`: - `github` and `Github` are flagged, `GitHub` is not. You do not need - `ignorecase: false` to protect the correct spelling. - - `raw` takes a full regex, used verbatim, when `tokens` is too - restrictive; `nonword` removes the implicit boundaries from - `tokens`. - - **`raw` entries concatenate, they do not alternate.** `tokens` is - a list of alternatives. `raw` is not: Vale joins every entry into - one pattern, back to back, with nothing between them. Measured on - Vale v%(VALE_VERSION)s with two entries, `\bstops ` and `being\b`: - `It stops being.` fires, and a document holding either half alone - does not. So a second entry added to widen a rule never fires on - its own, and its `fail/` fixture fails, which is how this was - found. Alternation goes inside one entry, as `(a|b|c)`: - - ```yaml - # wrong: the second entry is appended to the first, so the new - # branch never fires on its own - extends: existence - raw: - - "\\bstops being\\b[^.!?\\n]{0,80}\\band becomes\\b" - - "[A-Z][^.!?\\n]{3,70}[.!?] (The|That|This|It)('s| is| was) the (problem|twist|point)\\b\\." - ``` - - ```yaml - # right: one entry, the branches alternated inside it - extends: existence - raw: - - "(\\bstops being\\b[^.!?\\n]{0,80}\\band becomes\\b|[A-Z][^.!?\\n]{3,70}[.!?] (The|That|This|It)('s| is| was) the (problem|twist|point)\\b\\.)" - ``` - - No `nonword` there, and none is needed: `nonword` governs - `tokens`, which Vale wraps in `\b…\b` unless it is set, and a - `raw` entry is used verbatim. Measured, `nonword: true` beside a - `raw` list changes nothing. Write the boundaries you want into the - pattern, as above. - - `validate` reports a `raw` list with more than one entry, on the - rule's `notice` rather than in `errors`: - - ``` - : raw has N entries; Vale joins them into one pattern with no separator, so the second never matches on its own. Write one entry with (a|b) unless the join is intended. - ``` - - If the join is intended, and the pieces are only split for - readability, the notice is yours to read and set aside. - - **A token made only of punctuation can never match without - `nonword: true`.** The boundaries above are `\b`, which needs a word - character on the inside. An em dash has none, on either side. - Measured against `This is a sentence, with an em dash.`: - - ```yaml - # fires on nothing, ever, and reports no error - extends: existence - message: "Use a comma, not an em dash" - tokens: - - ', ' - ``` - - ```yaml - # fires - extends: existence - message: "Use a comma, not an em dash" - nonword: true - tokens: - - ', ' - ``` - - The first rule validates, passes `verify` if its `fail/` fixture is - missing the dash, and reports nothing forever. Any token whose - pattern contains no `\w` (punctuation, an emoji, a bare symbol) - needs `nonword: true`. - - - **A bare word finds senses you did not mean.** `landed on` in a rule - - about jargon also matches "the plane landed on time"; `simply` in a - rule about hedging also matches "simply connected" in a maths doc. - - Narrow the token to the **collocation** you actually object to - (`landed on a decision`, not `landed on`), and check that you got it - right by writing the `pass/` fixture from the literal sense *first*: - put the innocent sentence in `pass/` before you write the guilty one - in `fail/`. A rule whose `pass/` bucket was written afterwards tends - to contain only sentences the author already knew were safe. - - Vale also understands the markup, which decides what counts as text - before your pattern ever runs. Measured in markdown: - - - **URLs and code spans are not prose.** A `Github` key fires on - `Plain Github here` and not on `https://Github.com/x` or - `` `Github/docs` ``. - - **Link text *is* prose.** In `[click here](https://example.com)`, - `click here` is matched. The URL is not. With no `scope`, a rule - fires on both link text and ordinary prose; `scope: link` narrows it - to link text alone. Measured: without a scope the token hit both the - link and the sentence; with `scope: link`, only the link. - -4. **Scope the rule** by writing `.taskless/rules/vale//.vale.ini`. - This is the step that is easy to skip and impossible to notice - skipping. A rule with no config is enabled nowhere: it parses, it - runs, and it reports nothing. - - The whole file, for a rule that applies to markdown: - - ```ini - # Which files this rule applies to. - [*.md] - tskl) rule = no-simply - no-simply.no-simply = YES - ``` - - Three lines, and each one earns its place: - - - `[*.md]` is a **matcher**: a glob over paths, deciding which files - this rule sees. Match it to the files the rule is actually about, - such as `[*.{md,markdown}]` or `[docs/**/*.md]`. A rule can declare - several matchers if it needs to. Before you widen a glob, check the - reach table below, what Vale does to a file it cannot parse is not - "nothing". - - `tskl) rule = ` is a breadcrumb Taskless reads to attribute the - matcher back to this rule after assembly interleaves every rule's - matchers into one file. Vale parses the key and ignores it. Write it - in every matcher you add, or the tooling loses track of who owns - what. - - `no-simply.no-simply = YES` turns the rule on. The first half is - the style, which is this rule's directory; the second is the check - inside it, which is the file. Both are the id. - - **Do not write `BasedOnStyles`, empty or otherwise.** Older versions - of this recipe put `BasedOnStyles =` in every matcher, on the belief - that it kept Vale's bundled styles from loading. It never did that - (no bundled style loads unless a run-level `BasedOnStyles` names one, - and the assembled header names none), and as of Vale 3.22.0 an empty - value has a meaning: it clears every setting the file inherited from - an earlier matcher. Taskless assembles every rule's matchers into one - file in id order, so a rule writing the line under `[docs/**]` - silences every alphabetically earlier rule under `docs/`, and - `[*.md]` beside another rule's `[*.{md,markdown}]` silences the first - on every `.md` file. Only two rules with byte-identical globs escape, - because Vale merges those into one section. `validate` rejects the key - in any matcher, and `%(TASKLESS_CLI)s init` deletes it from configs - written by the older recipe. - - **Scope a rule *out* with a second matcher, not a cleverer glob.** A - glob says which files a rule sees; it has no way to say "these but not - those". The exclusion is a second matcher that assigns `NO`, and - because precedence here is positional (a later matcher wins), the - exclusion goes **after** the inclusion: +So `exceptions` is not decoration: every proper noun, product name and +acronym the docs use has to be listed, or the rule flags correct +headings. Collect them from the docs before writing the rule, and +expect to add to the list. + + +## Patterns + +`tokens` and `swap` keys are patterns, not literals. They compile as Go +regular expressions. + +*This section is about `tokens` and `swap` only. A `capitalization`, +`occurrence` or `metric` rule has neither, skip to step 4.* + +- `(?:…)`, `[…]`, `|`, `+`, `?` all work. +- **Lookaround and backreferences work, and they are not free.** + Vale compiles every pattern with `regexp2` in RE2 compatibility + mode, so `(?=…)`, `(?<=…)` and `\1` are all available even + though Go's own `regexp` has none of them. (Older versions of + this topic said Vale tried Go's `regexp` first and fell back. + There is no fallback and never was; the conclusion was right and + the reason was not.) Measured on Vale v%(VALE_VERSION)s with + throwaway rules, each firing on its `fail/` fixture and quiet on + `pass/`: a repeated-word `\b(\w+) \1\b`, a `foo(?= bar)` + lookahead, and a `(?<=x )y` lookbehind. That engine backtracks + and is slower than a pattern without lookaround, so keep + lookaround off a rule that runs over every file in the project, + and prove any rule that uses one with a fixture rather than + trusting the syntax. "X but not when followed by Y" is therefore + writable as a single `substitution`, but splitting it or + narrowing with `scope` is still the cheaper rule when either + will do. + + Three measured limits sit on top of that, and all three are + silent: + + - **No capture group survives a `swap` key**, so a + backreference in one can never match. Vale compiles all of a + rule's swap keys into ONE alternation and wraps each key in a + capture group of its own, because the number of the group + that matched is how it knows which replacement to offer. Those + wrappers have to be numbered 1, 2, 3…, so any group you wrote + is rewritten to `(?:…)` first. Whichever way round, `\1` ends + up pointing at Vale's wrapper (the group still being matched) + and matches nothing. Measured silent, with nothing on + stderr and the rule loading cleanly: `(\w+) \1`, `(the) \1`, + `the \1` and `(?:\w+) \1`. **`%(TASKLESS_CLI)s validate` rejects a + `swap` key with a backreference**, because a rule that loads + and never fires is indistinguishable from a clean project. A + repeated-word check has to be an `existence` rule, where the + same pattern works under both `tokens` and `raw`. + + Two things this does NOT mean: + + - **`$1` in the swap VALUE works.** Vale expands it against + the original key, before the rewrite, so + `colour(s?): color$1` offers `colors` for `colours` and + `color` for `colour`. Only the key loses its groups. + - **`\1` inside a character class is fine**, because there it + is an octal escape rather than a backreference. `[\1a]bc` + matches `abc` and `validate` accepts it. + + - **A trailing lookahead in `tokens` or `swap` has to peek at a + non-word character.** The implicit `\b` is appended after the + lookahead (the lookahead is zero-width, so the position is + still where the match ended), which puts the boundary between + the match and the text peeked at. `foo(?= bar)` fires; + `foo(?=bar)` can never match, whatever the document says. Use + `raw` when the lookahead has to land on a word character. + - **A leading lookbehind has the same problem at the other + end.** The implicit `\b` goes on before it, so the boundary + is tested between what the lookbehind peeked at and the match. + `(?<=x )y` fires; `(?<=x)foo` is silent under `tokens` and + under `swap` on a document reading `xfoo`, and fires under + `raw`. Same fix: use `raw`. + + +## Pattern quirks + +- **Word boundaries are applied for you, around the whole pattern.** + Measured: `Github` does not fire inside `GithubToken`, and the + multi-word `click here` does not fire inside `Clicking here`. +- **A hyphen is a boundary, so a hyphenated compound is not + protected.** `obviously` fires inside `obviously-named`, while + `obviously_stale` is safe because `_` is a word character. Do not + reach for a hyphenated compound as a `pass/` near-miss; it is the + case most likely to fire. +- **Regex metacharacters in a real phrase are live.** "maybe?" is a + pattern meaning "mayb" followed by an optional "e". Escape it. +- **Overlapping alternatives resolve first-wins**, one finding per + match. If `can login` and `login with` both match a sentence, you + get whichever is written first, once, not both. +- `ignorecase: true` matches any casing **and still skips text that + already equals the replacement.** Measured with `Github: GitHub`: + `github` and `Github` are flagged, `GitHub` is not. You do not need + `ignorecase: false` to protect the correct spelling. +- `raw` takes a full regex, used verbatim, when `tokens` is too + restrictive; `nonword` removes the implicit boundaries from + `tokens`. +- **`raw` entries concatenate, they do not alternate.** `tokens` is + a list of alternatives. `raw` is not: Vale joins every entry into + one pattern, back to back, with nothing between them. Measured on + Vale v%(VALE_VERSION)s with two entries, `\bstops ` and `being\b`: + `It stops being.` fires, and a document holding either half alone + does not. So a second entry added to widen a rule never fires on + its own, and its `fail/` fixture fails, which is how this was + found. Alternation goes inside one entry, as `(a|b|c)`: + + ```yaml + # wrong: the second entry is appended to the first, so the new + # branch never fires on its own + extends: existence + raw: + - "\\bstops being\\b[^.!?\\n]{0,80}\\band becomes\\b" + - "[A-Z][^.!?\\n]{3,70}[.!?] (The|That|This|It)('s| is| was) the (problem|twist|point)\\b\\." + ``` + + ```yaml + # right: one entry, the branches alternated inside it + extends: existence + raw: + - "(\\bstops being\\b[^.!?\\n]{0,80}\\band becomes\\b|[A-Z][^.!?\\n]{3,70}[.!?] (The|That|This|It)('s| is| was) the (problem|twist|point)\\b\\.)" + ``` + + No `nonword` there, and none is needed: `nonword` governs + `tokens`, which Vale wraps in `\b…\b` unless it is set, and a + `raw` entry is used verbatim. Measured, `nonword: true` beside a + `raw` list changes nothing. Write the boundaries you want into the + pattern, as above. + + `validate` reports a `raw` list with more than one entry, on the + rule's `notice` rather than in `errors`: + + ``` + : raw has N entries; Vale joins them into one pattern with no separator, so the second never matches on its own. Write one entry with (a|b) unless the join is intended. + ``` + + If the join is intended, and the pieces are only split for + readability, the notice is yours to read and set aside. +- **A token made only of punctuation can never match without + `nonword: true`.** The boundaries above are `\b`, which needs a word + character on the inside. An em dash has none, on either side. + Measured against `This is a sentence, with an em dash.`: + + ```yaml + # fires on nothing, ever, and reports no error + extends: existence + message: "Use a comma, not an em dash" + tokens: + - ', ' + ``` + + ```yaml + # fires + extends: existence + message: "Use a comma, not an em dash" + nonword: true + tokens: + - ', ' + ``` + + The first rule validates, passes `verify` if its `fail/` fixture is + missing the dash, and reports nothing forever. Any token whose + pattern contains no `\w` (punctuation, an emoji, a bare symbol) + needs `nonword: true`. + +- **A bare word finds senses you did not mean.** `landed on` in a rule + + about jargon also matches "the plane landed on time"; `simply` in a + rule about hedging also matches "simply connected" in a maths doc. + + Narrow the token to the **collocation** you actually object to + (`landed on a decision`, not `landed on`), and check that you got it + right by writing the `pass/` fixture from the literal sense *first*: + put the innocent sentence in `pass/` before you write the guilty one + in `fail/`. A rule whose `pass/` bucket was written afterwards tends + to contain only sentences the author already knew were safe. + +Vale also understands the markup, which decides what counts as text +before your pattern ever runs. Measured in markdown: + +- **URLs and code spans are not prose.** A `Github` key fires on + `Plain Github here` and not on `https://Github.com/x` or + `` `Github/docs` ``. +- **Link text *is* prose.** In `[click here](https://example.com)`, + `click here` is matched. The URL is not. With no `scope`, a rule + fires on both link text and ordinary prose; `scope: link` narrows it + to link text alone. Measured: without a scope the token hit both the + link and the sentence; with `scope: link`, only the link. + + +## Vale config + +`.taskless/rules/vale//.vale.ini` scopes the rule. This is the step +that is easy to skip and impossible to notice skipping. A rule with no +config is enabled nowhere: it parses, it runs, and it reports nothing. + +The whole file, for a rule that applies to markdown: + +```ini +# Which files this rule applies to. +[*.md] +tskl) rule = no-simply +no-simply.no-simply = YES +``` - ```ini - # Every markdown file… - [*.md] - tskl) rule = no-simply - no-simply.no-simply = YES +Three lines, and each one earns its place: + +- `[*.md]` is a **matcher**: a glob over paths, deciding which files + this rule sees. Match it to the files the rule is actually about, + such as `[*.{md,markdown}]` or `[docs/**/*.md]`. A rule can declare + several matchers if it needs to. Before you widen a glob, check + [File reach](#file-reach), what Vale does to a file it cannot parse is not + "nothing". +- `tskl) rule = ` is a breadcrumb Taskless reads to attribute the + matcher back to this rule after assembly interleaves every rule's + matchers into one file. Vale parses the key and ignores it. Write it + in every matcher you add, or the tooling loses track of who owns + what. +- `no-simply.no-simply = YES` turns the rule on. The first half is + the style, which is this rule's directory; the second is the check + inside it, which is the file. Both are the id. + +**Do not write `BasedOnStyles`, empty or otherwise.** Older versions +of this recipe put `BasedOnStyles =` in every matcher, on the belief +that it kept Vale's bundled styles from loading. It never did that +(no bundled style loads unless a run-level `BasedOnStyles` names one, +and the assembled header names none), and as of Vale 3.22.0 an empty +value has a meaning: it clears every setting the file inherited from +an earlier matcher. Taskless assembles every rule's matchers into one +file in id order, so a rule writing the line under `[docs/**]` +silences every alphabetically earlier rule under `docs/`, and +`[*.md]` beside another rule's `[*.{md,markdown}]` silences the first +on every `.md` file. Only two rules with byte-identical globs escape, +because Vale merges those into one section. `validate` rejects the key +in any matcher, and `%(TASKLESS_CLI)s init` deletes it from configs +written by the older recipe. + +**Scope a rule *out* with a second matcher, not a cleverer glob.** A +glob says which files a rule sees; it has no way to say "these but not +those". The exclusion is a second matcher that assigns `NO`, and +because precedence here is positional (a later matcher wins), the +exclusion goes **after** the inclusion: + +```ini +# Every markdown file… +[*.md] +tskl) rule = no-simply +no-simply.no-simply = YES + +# …except the changelog, which quotes release notes verbatim. +[CHANGELOG.md] +tskl) rule = no-simply +no-simply.no-simply = NO +``` - # …except the changelog, which quotes release notes verbatim. - [CHANGELOG.md] - tskl) rule = no-simply - no-simply.no-simply = NO - ``` +Write the breadcrumb in the second matcher too, or `validate` rejects +the matcher as unattributed. Reversing the two blocks would silently +re-enable the rule on the file you meant to exempt, so the schema +rejects a `NO` matcher that precedes every `YES`: with no style +loaded the rule is off until something turns it on, which makes such +a `NO` either dead or overridden. Put the `NO` after the `YES` it +narrows. + +**Do NOT write `StylesPath` or `MinAlertLevel` here.** Those describe +the run rather than a rule, and the assembler supplies them. A copy in +a rule's config is rejected, not dropped: the file you write is the +file Vale reads, byte for byte, so nothing is edited on the way in. + +Keep assignments underneath a matcher. An assignment above the first +`[…]` line belongs to no matcher; Vale would ignore it after a +warning on stderr and leave the rule enabled nowhere, so the schema +rejects it instead. + + +## Config schema + +**The config is schema-checked.** `validate` parses `.vale.ini` into a +structure and validates it against a schema keyed by the rule's +directory name, naming the line and the `vale-config-*` constraint +behind each rejection. `check` runs the same schema before assembling +the run config, and a rejected config refuses the Vale engine for +that run: the failure names the rule and the line, the exit code is +non-zero, and the other engines still run. A rule is never quietly +left out. What is **rejected**: + +- anything assigned above the first matcher (`StylesPath`, + `MinAlertLevel`, or a rule assignment) +- a matcher without a `tskl) rule = ` breadcrumb naming this rule +- an assignment key other than `.` (a key naming another rule + is a cross-rule override, which a rule cannot do) +- a value other than `YES` or `NO` +- a `BasedOnStyles` assignment, with any value, empty included +- a config with no matcher, or one that never assigns `YES` +- a `NO` matcher that precedes every `YES` matcher + +What is **advised**, on the rule's notice, without rejecting: + +- the same key assigned twice inside one matcher (Vale keeps the last + assignment as of 3.21.0; 3.20.0 kept the first). The rejections + above judge each matcher by that final verdict, so a `YES` that a + later `NO` in the same matcher overrides does not count as enabling + the rule: if it was the only `YES`, the config is rejected, not + advised +- a `[*]` matcher, which reaches every file Vale can read +- a matcher under `.taskless/**`, which a whole-project `check` + already excludes, and which silences the rule over a fixture + bucket you name on purpose (step 6) + +Each advisory has a legitimate reading, which is what separates the +two lists. Fix a rejection before moving on; read an advisory and +decide. + + +## File reach + +**What a matcher's glob is allowed to catch.** Vale (v%(VALE_VERSION)s) +treats a file one of four ways, decided by extension. The lists are +rendered from the pinned Vale version, not written out here, so they +track the shipped binary. + +- **markup**: the document is prose and the format's own non-prose + constructs are skipped. This is the tier every `scope:` value + assumes; `scope: heading` has nothing to find outside it: + %(VALE_MARKUP_FORMATS)s +- **comment text only**: the comments are linted and the code body + + is invisible, which is exactly right for "comments must not say + 'obviously'". + + A comment addressed to a tool (`//nolint`, `# noqa`, + `eslint-disable`) and a docstring's `:param:` field are not read at + all, so a rule cannot police what a suppression comment says: + %(VALE_COMMENT_FORMATS)s +- **plaintext fallback**: everything else, `.yml` `.toml` `.sh` + `.sql` and every extension not named above included. There is no + parser, so the whole file is linted as prose: a rule matched to + YAML flags key names and values, not just the comments. If that is + not what the rule means, narrow the glob rather than accepting it. + These land here despite reading like markup, so a `scope:` value + has nothing to act on in them: %(VALE_PLAINTEXT_FORMATS)s +- **not supported**: Vale parses these only by shelling out to an + external program, and this build does not support any format that + needs one: + %(VALE_CONVERTER_FORMATS)s + + Do not tell the user to install the program. Taskless excludes these + files from the run whatever is installed, so that a repository + checks the same way on every machine; `.xml` could not work anyway, + since an XSLT stylesheet is specific to the document. + +**A single unreadable file fails the whole Vale pass.** Vale exits 2 +with an `E100` runtime error and abandons the run, `--no-exit` does +not suppress it, so every other Vale rule over every other file goes +unreported. `[*.{md,typ}]` is not a slightly wider `[*.md]`; it is a +matcher that takes `check` down the first time the repo grows a +`.typ` file. Never put one of those extensions in a glob. + +**A large file is linted, not skipped.** Vale's cost is linear in a +file's size as of 3.21.0 (a 3MB single-block document measures +~230ms), so no file is excluded on size and a broad matcher such as +`[*.md]` at the project root reaches a generated changelog or an +exported note like any other document. If such a file should not be +checked, narrow the section rather than expecting `check` to skip +it. - Write the breadcrumb in the second matcher too, or `validate` rejects - the matcher as unattributed. Reversing the two blocks would silently - re-enable the rule on the file you meant to exempt, so the schema - rejects a `NO` matcher that precedes every `YES`: with no style - loaded the rule is off until something turns it on, which makes such - a `NO` either dead or overridden. Put the `NO` after the `YES` it - narrows. - - **Do NOT write `StylesPath` or `MinAlertLevel` here.** Those describe - the run rather than a rule, and the assembler supplies them. A copy in - a rule's config is rejected, not dropped: the file you write is the - file Vale reads, byte for byte, so nothing is edited on the way in. - - Keep assignments underneath a matcher. An assignment above the first - `[…]` line belongs to no matcher; Vale would ignore it after a - warning on stderr and leave the rule enabled nowhere, so the schema - rejects it instead. - - **The config is schema-checked.** `validate` parses `.vale.ini` into a - structure and validates it against a schema keyed by the rule's - directory name, naming the line and the `vale-config-*` constraint - behind each rejection. `check` runs the same schema before assembling - the run config, and a rejected config refuses the Vale engine for - that run: the failure names the rule and the line, the exit code is - non-zero, and the other engines still run. A rule is never quietly - left out. What is **rejected**: - - - anything assigned above the first matcher (`StylesPath`, - `MinAlertLevel`, or a rule assignment) - - a matcher without a `tskl) rule = ` breadcrumb naming this rule - - an assignment key other than `.` (a key naming another rule - is a cross-rule override, which a rule cannot do) - - a value other than `YES` or `NO` - - a `BasedOnStyles` assignment, with any value, empty included - - a config with no matcher, or one that never assigns `YES` - - a `NO` matcher that precedes every `YES` matcher - - What is **advised**, on the rule's notice, without rejecting: - - - the same key assigned twice inside one matcher (Vale keeps the last - assignment as of 3.21.0; 3.20.0 kept the first). The rejections - above judge each matcher by that final verdict, so a `YES` that a - later `NO` in the same matcher overrides does not count as enabling - the rule: if it was the only `YES`, the config is rejected, not - advised - - a `[*]` matcher, which reaches every file Vale can read - - a matcher under `.taskless/**`, which a whole-project `check` - already excludes, and which silences the rule over a fixture - bucket you name on purpose (step 6) - - Each advisory has a legitimate reading, which is what separates the - two lists. Fix a rejection before moving on; read an advisory and - decide. - - **What a matcher's glob is allowed to catch.** Vale (v%(VALE_VERSION)s) - treats a file one of four ways, decided by extension. The lists are - rendered from the pinned Vale version, not written out here, so they - track the shipped binary. - - - **markup**: the document is prose and the format's own non-prose - constructs are skipped. This is the tier every `scope:` value - assumes; `scope: heading` has nothing to find outside it: - %(VALE_MARKUP_FORMATS)s - - **comment text only**: the comments are linted and the code body - - is invisible, which is exactly right for "comments must not say - 'obviously'". - - A comment addressed to a tool (`//nolint`, `# noqa`, - `eslint-disable`) and a docstring's `:param:` field are not read at - all, so a rule cannot police what a suppression comment says: - %(VALE_COMMENT_FORMATS)s - - **plaintext fallback**: everything else, `.yml` `.toml` `.sh` - `.sql` and every extension not named above included. There is no - parser, so the whole file is linted as prose: a rule matched to - YAML flags key names and values, not just the comments. If that is - not what the rule means, narrow the glob rather than accepting it. - These land here despite reading like markup, so a `scope:` value - has nothing to act on in them: %(VALE_PLAINTEXT_FORMATS)s - - **not supported**: Vale parses these only by shelling out to an - external program, and this build does not support any format that - needs one: - %(VALE_CONVERTER_FORMATS)s - - Do not tell the user to install the program. Taskless excludes these - files from the run whatever is installed, so that a repository - checks the same way on every machine; `.xml` could not work anyway, - since an XSLT stylesheet is specific to the document. - - **A single unreadable file fails the whole Vale pass.** Vale exits 2 - with an `E100` runtime error and abandons the run, `--no-exit` does - not suppress it, so every other Vale rule over every other file goes - unreported. `[*.{md,typ}]` is not a slightly wider `[*.md]`; it is a - matcher that takes `check` down the first time the repo grows a - `.typ` file. Never put one of those extensions in a glob. - - **A large file is linted, not skipped.** Vale's cost is linear in a - file's size as of 3.21.0 (a 3MB single-block document measures - ~230ms), so no file is excluded on size and a broad matcher such as - `[*.md]` at the project root reaches a generated changelog or an - exported note like any other document. If such a file should not be - checked, narrow the section rather than expecting `check` to skip - it. - - That example changed with Vale v3.18.0, which is the point: the - dangerous extension is whichever one the list above says needs a - program, not the one you remember. `.mdx` was the example until that - release parsed it natively, and `.typ` took its place. - - **`.mdx` is supported** as of Vale v3.18.0, which parses it natively - rather than shelling out. `[*.{md,mdx}]` is a legitimate matcher - again, the example this recipe used to warn about is no longer the - broken one. Check the lists above rather than reaching for that - memory: `.typ` moved the opposite way in the same release, so a - matcher covering Typst is now the one that takes the run down. - - **In `.mdx`, a component's children are prose as of v3.19.0.** Vale - reads a JSX element's children as the Markdown they are, so text - inside a wrapping component (``, ``, `