From aa308454fb23d04d87a2c5ead92a5a51290b674e Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 6 Nov 2026 15:35:56 -0800 Subject: [PATCH 1/3] fix(route): name every detected linter, and add an Oxlint guide route printed "The linters detect found here" and listed only linters with a guide paragraph, so a repository running Oxlint read as though it did not. With Oxlint alone it said steps 1 and 2 of the legacy ladder did not apply, which contradicts the requirement that step 1 is taken whenever a detected linter could host the rule. route now names every detected tool that can host a rule, and sends one with no paragraph to the generic recipe. Formatters and Taskless's own engines stay out. Oxlint gets its own create-legacy-rule recipe. --- .changeset/route-names-every-linter.md | 5 ++ .../src/agent/create-legacy-rule.oxlint.md | 78 +++++++++++++++++ packages/cli/src/agent/route.md | 2 +- packages/cli/src/prompts/recipes.ts | 84 ++++++++++++++----- packages/cli/test/agent-extensions.test.ts | 2 +- packages/cli/test/legacy-ladder.test.ts | 16 +++- .../legacy-linter-vendor-contract.test.ts | 13 +++ packages/cli/test/prompts.test.ts | 33 +++++++- 8 files changed, 207 insertions(+), 26 deletions(-) create mode 100644 .changeset/route-names-every-linter.md create mode 100644 packages/cli/src/agent/create-legacy-rule.oxlint.md diff --git a/.changeset/route-names-every-linter.md b/.changeset/route-names-every-linter.md new file mode 100644 index 00000000..e40f65e2 --- /dev/null +++ b/.changeset/route-names-every-linter.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`agent route` no longer reads as though a detected linter is missing. It now names every detected tool that can host a rule, and Oxlint gets its own `create-legacy-rule --tool=oxlint` guide. diff --git a/packages/cli/src/agent/create-legacy-rule.oxlint.md b/packages/cli/src/agent/create-legacy-rule.oxlint.md new file mode 100644 index 00000000..89826713 --- /dev/null +++ b/packages/cli/src/agent/create-legacy-rule.oxlint.md @@ -0,0 +1,78 @@ +# Topic: create-legacy-rule (oxlint) (CLI v%(CLI_VERSION)s / topic v1) + +%(ONE_COMMAND_PER_CALL)s + +## You are here +This is `create-legacy-rule` for Oxlint. It solves a request by enabling +or configuring a rule Oxlint already ships. When nothing here fits, +re-run `%(TASKLESS_CLI)s agent route` and continue from step 3 of its legacy +ladder. + +## Goal +Find the Oxlint rule that makes the problem go away, confirm it in the +installed version, and enable it in the repository's Oxlint configuration. + +## Preconditions +- `%(TASKLESS_CLI)s detect --json` reports `oxlint`. +- No auth required. + +## Steps + +1. **Always check the catch-all rules first.** They take your own list, + and a search for the request's subject will not surface them: + - `no-restricted-imports`: bans modules or named imports, with a + message, through `paths` and `patterns`. + - `no-restricted-globals`: bans global names, such as `event`. Its + documented form is a plain list of names, with no custom message. + Both do nothing until they are given a list, so a repo that has not + configured them is not using them. + + Oxlint has no `no-restricted-syntax`. A request for an AST selector + is not solved here, so go back to `route`. + +2. **Otherwise, find a shipped rule.** Search the rules page, + https://oxc.rs/docs/guide/usage/linter/rules.html, for the behavior + the request describes. Many rules port a well-known ESLint rule under + the same name. The docs site also has an index at + https://oxc.rs/llms.txt. + +3. **Confirm the rule against the installed version.** Run + `oxlint --rules`, which lists the rules the installed build has. A + rule belonging to a plugin only fires when that plugin is on, and + setting `plugins` replaces the default set, so read the config's + `plugins` before relying on one. + +4. **Enable it where the repo configures Oxlint.** Oxlint reads + `.oxlintrc.json`, `.oxlintrc.jsonc`, `oxlint.config.ts`, or + `oxlint.config.mts`. Each file uses the nearest config, and a nested + config replaces its parent rather than merging with it, unless it + says `extends`. So change the config that governs the files in + question, which `oxlint --print-config` shows. Rules go under + `rules`, as a severity or a `[severity, options]` pair: + ```json + "rules": { + "no-restricted-imports": ["error", { + "paths": [{ "name": "lodash", "message": "Use the helpers in src/util." }] + }] + } + ``` + Use `overrides` when the rule should apply to some paths only. + +5. **Report, and say who runs it.** Show the file you changed and the + command that shows the rule firing, e.g. `oxlint `. + `%(TASKLESS_CLI)s check` does NOT run Oxlint. + +## Important Notes + +- Do not write an Oxlint plugin. JavaScript plugins exist, but they are + in alpha and Taskless does not teach authoring them. A request no + shipped rule solves is a Taskless rule. +- A repo running ESLint beside Oxlint may load `eslint-plugin-oxlint`, + whose `buildFromOxlintConfigFile` turns off in ESLint the rules the + Oxlint config enables. When a rule exists in both, enable it in + Oxlint. Enabling it in ESLint there has no effect. + +## See Also + +- `%(TASKLESS_CLI)s agent route`: continue down the legacy ladder when no Oxlint rule fits +- `%(TASKLESS_CLI)s agent create-sg-rule`: ast-grep parses JavaScript and TypeScript diff --git a/packages/cli/src/agent/route.md b/packages/cli/src/agent/route.md index 07edaf74..f29347fa 100644 --- a/packages/cli/src/agent/route.md +++ b/packages/cli/src/agent/route.md @@ -1,4 +1,4 @@ -# Topic: route (CLI v%(CLI_VERSION)s / topic v8) +# Topic: route (CLI v%(CLI_VERSION)s / topic v9) %(ONE_COMMAND_PER_CALL)s diff --git a/packages/cli/src/prompts/recipes.ts b/packages/cli/src/prompts/recipes.ts index b8bf03d9..6fff4187 100644 --- a/packages/cli/src/prompts/recipes.ts +++ b/packages/cli/src/prompts/recipes.ts @@ -650,6 +650,13 @@ plugin the repo already has installed.`, built-in rule set. Always check whether an existing rule makes the problem go away. \`noRestrictedImports\` and \`noRestrictedGlobals\` are the catch-alls.`, + ], + [ + "oxlint", + `**Oxlint** lints JavaScript and TypeScript with a large built-in rule +set, including many ESLint rules under the same names. Always check +\`no-restricted-imports\` and \`no-restricted-globals\`, the catch-alls. +It has no \`no-restricted-syntax\`.`, ], [ "ruff", @@ -672,39 +679,78 @@ It is a plugin package, so the repo may need to add it.`, ], ]; +/** + * Detected tools that are not a legacy destination: formatters, which enforce + * no rule a request could ask for, and Taskless's own two engines, which are + * destinations in their own right further down `route`. + * + * Held here rather than on `LINTER_SIGNALS` because that table imports host + * capabilities and the prompts bundle must not. + */ +const NOT_A_LEGACY_HOST: ReadonlySet = new Set([ + "prettier", + "black", + "pint", + "php-cs-fixer", + "ast-grep", + "vale", +]); + /** * The linters `route` describes, as one block. * * `undefined` means nobody scanned, which renders every paragraph: a * consumer of `@taskless/cli/prompts` has no repository to describe, and - * omitting a linter there would read as "this one does not apply". An empty - * match is a measured answer and says so in one line. + * omitting a linter there would read as "this one does not apply". + * + * A scan renders everything it found that can host a rule, with or without a + * paragraph. A tool with no paragraph is named on one line and sent to the + * generic recipe, because the ladder's first step applies to it all the same. + * Leaving it out would tell the agent the repository does not run it. */ function legacyLintersBlock( linters: string[] | undefined, fetchPrefix: string | undefined ): string { + const fetchFor = (name: string): string => + fetchPrefix === undefined + ? `Fetch \`create-legacy-rule\` with tool \`${name}\`.` + : `Fetch \`${fetchPrefix}create-legacy-rule --tool=${name}\`.`; const described = LEGACY_LINTER_GUIDE.filter( ([name]) => linters === undefined || linters.includes(name) ); - if (described.length === 0) { - return ` \`detect\` found no linter here with a legacy recipe, so step 1 and - step 2 do not apply. Continue from step 3.`; - } - const heading = - linters === undefined - ? " Linters with a legacy recipe, and what step 1 always checks:" - : " The linters `detect` found here, and what step 1 always checks:"; - const items = described.map(([name, paragraph]) => { - const fetch = - fetchPrefix === undefined - ? `Fetch \`create-legacy-rule\` with tool \`${name}\`.` - : `Fetch \`${fetchPrefix}create-legacy-rule --tool=${name}\`.`; - return ` - ${indentBlock(`${paragraph}\n${fetch}`, " ")}`; - }); - return `${heading} + const guided = new Set(LEGACY_LINTER_GUIDE.map(([name]) => name)); + const unguided = (linters ?? []).filter( + (name) => !guided.has(name) && !NOT_A_LEGACY_HOST.has(name) + ); + const unrecognized = ` \`detect\` recognizes a fixed list of tools, so this repository may run + others it does not know about.`; + if (described.length === 0 && unguided.length === 0) { + return ` \`detect\` found no linter here that can host a rule, so step 1 and + step 2 do not apply. Continue from step 3. -${items.join("\n")}`; +${unrecognized}`; + } + const sections: string[] = []; + if (described.length > 0) { + const heading = + linters === undefined + ? " Linters with a legacy recipe, and what step 1 always checks:" + : " The linters `detect` found here, and what step 1 always checks:"; + const items = described.map( + ([name, paragraph]) => + ` - ${indentBlock(`${paragraph}\n${fetchFor(name)}`, " ")}` + ); + sections.push(`${heading}\n\n${items.join("\n")}`); + } + if (unguided.length > 0) { + const names = unguided.map((name) => `\`${name}\``).join(", "); + const fetches = unguided.map((name) => fetchFor(name)).join(" "); + sections.push(` Also detected here, with no guide of their own: ${names}. Steps 1 and + 2 still apply to them. ${fetches}`); + } + if (linters !== undefined) sections.push(unrecognized); + return sections.join("\n\n"); } /** The state a passage conditioned on one tool renders from. */ diff --git a/packages/cli/test/agent-extensions.test.ts b/packages/cli/test/agent-extensions.test.ts index cf745d03..21b6cf4b 100644 --- a/packages/cli/test/agent-extensions.test.ts +++ b/packages/cli/test/agent-extensions.test.ts @@ -563,7 +563,7 @@ describe("taskless agent --tool (variant lookup)", () => { await rm(cwd, { recursive: true, force: true }); }); - it.each(["ruff", "eslint", "biome", "pylint", "flake8"])( + it.each(["ruff", "eslint", "biome", "oxlint", "pylint", "flake8"])( "serves the %s variant of create-legacy-rule", async (tool) => { const result = await runCli([ diff --git a/packages/cli/test/legacy-ladder.test.ts b/packages/cli/test/legacy-ladder.test.ts index 9e59df17..fa247e40 100644 --- a/packages/cli/test/legacy-ladder.test.ts +++ b/packages/cli/test/legacy-ladder.test.ts @@ -17,8 +17,8 @@ function tool(name: string): string { describe("route's legacy ladder", () => { const route = recipe("route"); - it("is topic v8", () => { - expect(route).toMatch(/^# Topic: route {5}\(CLI v.+ \/ topic v8\)/m); + it("is topic v9", () => { + expect(route).toMatch(/^# Topic: route {5}\(CLI v.+ \/ topic v9\)/m); }); it("orders shipped rule, repo examples, Taskless static, Taskless runtime", () => { @@ -158,6 +158,16 @@ describe("the per-tool legacy recipes", () => { expect(biome).toContain("Do not write a Biome plugin."); }); + it("has Oxlint check its catch-alls and confirm with oxlint --rules", () => { + const oxlint = tool("oxlint"); + expect(oxlint).toContain("`no-restricted-imports`"); + expect(oxlint).toContain("`no-restricted-globals`"); + expect(oxlint).toContain("Oxlint has no `no-restricted-syntax`."); + expect(oxlint).toContain("`oxlint --rules`"); + expect(oxlint).toContain("a nested\n config replaces its parent"); + expect(oxlint).toContain("Do not write an Oxlint plugin."); + }); + it("has pylint load an extension before ruling its message out", () => { expect(tool("pylint")).toContain( "Load the extension\n before concluding a message does not exist." @@ -170,7 +180,7 @@ describe("the per-tool legacy recipes", () => { expect(flake8).not.toContain("--list"); }); - it.each(["ruff", "eslint", "biome", "pylint", "flake8"])( + it.each(["ruff", "eslint", "biome", "oxlint", "pylint", "flake8"])( "has the %s recipe say Taskless does not run the linter", (name) => { expect(tool(name)).toMatch(/check` does NOT run /); diff --git a/packages/cli/test/legacy-linter-vendor-contract.test.ts b/packages/cli/test/legacy-linter-vendor-contract.test.ts index 530c56ee..2563b846 100644 --- a/packages/cli/test/legacy-linter-vendor-contract.test.ts +++ b/packages/cli/test/legacy-linter-vendor-contract.test.ts @@ -26,6 +26,7 @@ const PYLINT = "pylint==4.1.3"; const FLAKE8 = "flake8==7.4.1"; const FLAKE8_TIDY_IMPORTS = "flake8-tidy-imports"; const BIOME = "@biomejs/biome@2.6.2"; +const OXLINT = "oxlint@1.82.0"; let workdir: string; @@ -188,4 +189,16 @@ describe.skipIf(!ENABLED)("legacy linter vendor contract", () => { ).not.toBe(0); }); }); + + describe("oxlint", () => { + it("lists both catch-all rules and has no no-restricted-syntax", () => { + const { status, output } = run("pnpm", ["dlx", OXLINT, "--rules"]); + expect(status).toBe(0); + for (const rule of ["no-restricted-imports", "no-restricted-globals"]) { + expect(output).toContain(rule); + expect(recipe("oxlint")).toContain(`\`${rule}\``); + } + expect(output).not.toContain("no-restricted-syntax"); + }); + }); }); diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index ec624a4c..17c3d5ad 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -627,6 +627,7 @@ describe("route's per-linter passage", () => { const LINTER_HEADINGS = [ "**ESLint**", "**Biome**", + "**Oxlint**", "**Ruff**", "**pylint**", "**flake8**", @@ -641,12 +642,40 @@ describe("route's per-linter passage", () => { } }); - it("says so when no detected linter has a legacy recipe", () => { + it("says so when no detected tool can host a rule", () => { const route = getPrompt("route", { linters: ["prettier"] }); for (const heading of LINTER_HEADINGS) { expect(route).not.toContain(heading); } - expect(route).toContain("found no linter here with a legacy recipe"); + expect(route).toContain("found no linter here that can host a rule"); + expect(route).not.toContain("`prettier`"); + }); + + it("describes oxlint when it is the only linter detected", () => { + const route = getPrompt("route", { linters: ["oxlint"] }); + expect(route).toContain("**Oxlint**"); + expect(route).not.toContain("found no linter here"); + }); + + it("never drops a detected linter that has no guide of its own", () => { + const route = getPrompt("route", { linters: ["eslint", "stylelint"] }); + expect(route).toContain("**ESLint**"); + expect(route).toContain("with no guide of their own: `stylelint`"); + expect(route).toContain("create-legacy-rule --tool=stylelint`."); + expect(route).not.toContain("found no linter here"); + }); + + it("keeps a tool that is not a legacy host out of that list", () => { + const route = getPrompt("route", { + linters: ["eslint", "prettier", "vale"], + }); + expect(route).not.toContain("with no guide of their own"); + }); + + it("says detect is a fixed list only when something was scanned", () => { + const fixedList = "`detect` recognizes a fixed list of tools"; + expect(getPrompt("route", { linters: ["ruff"] })).toContain(fixedList); + expect(getPrompt("route")).not.toContain(fixedList); }); it("describes every linter when nothing was scanned", () => { From 824e7209e6e480a1b0538bf5a7c58f6b59936488 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 6 Nov 2026 15:36:00 -0800 Subject: [PATCH 2/3] spec(route): archive route-names-every-detected-linter --- .../proposal.md | 27 ++++++++ .../specs/cli-legacy-rule-recipes/spec.md | 67 +++++++++++++++++++ .../specs/cli-rule-routing/spec.md | 37 ++++++++++ .../tasks.md | 11 +++ .../specs/cli-legacy-rule-recipes/spec.md | 5 +- openspec/specs/cli-rule-routing/spec.md | 21 ++++++ 6 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/archive/2026-11-06-route-names-every-detected-linter/proposal.md create mode 100644 openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-legacy-rule-recipes/spec.md create mode 100644 openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-rule-routing/spec.md create mode 100644 openspec/changes/archive/2026-11-06-route-names-every-detected-linter/tasks.md diff --git a/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/proposal.md b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/proposal.md new file mode 100644 index 00000000..118c25a4 --- /dev/null +++ b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/proposal.md @@ -0,0 +1,27 @@ +## Why + +`route` prints "The linters `detect` found here" and then lists only linters with a guide paragraph, so a repository running Oxlint and ESLint reads as ESLint-only. In a repository running only Oxlint, `route` says steps 1 and 2 of the legacy ladder do not apply, which contradicts the requirement that step 1 is taken whenever a detected linter could host the rule. Found on NoMercyLabs/nomnomzbot, where `detect` reports Oxlint correctly and `route` omits it. + +## What Changes + +- Add an Oxlint recipe, `create-legacy-rule --tool=oxlint`, and a paragraph for it in `route`. +- `route` names every detected tool that can host a rule. One with no paragraph is listed with the fetch command for the generic recipe, and the ladder still applies to it. +- Formatters and Taskless's own engines stay out of that list. +- A scanned `route` says `detect` recognizes a fixed list of tools, so the repository may run others. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-rule-routing`: `route` no longer implies a detected linter is absent. +- `cli-legacy-rule-recipes`: Oxlint joins the per-tool recipes. + +## Impact + +- `packages/cli/src/prompts/recipes.ts`, `src/agent/route.md` (topic v9), and the new `src/agent/create-legacy-rule.oxlint.md`. +- The custom-rule count from a repository root, and C# language detection, are separate work. +- Delivery shape: **single PR**. The recipe and the routing that points at it fit one review. diff --git a/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-legacy-rule-recipes/spec.md b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-legacy-rule-recipes/spec.md new file mode 100644 index 00000000..5d067582 --- /dev/null +++ b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-legacy-rule-recipes/spec.md @@ -0,0 +1,67 @@ +## MODIFIED Requirements + +### Requirement: Legacy recipes use what a tool ships before anything is written + +Every `create-legacy-rule` recipe, generic or per-tool, SHALL direct the agent to first establish whether a rule the tool already ships solves the request, including the tool's configurable catch-all rules. A recipe SHALL name the catch-all rules for its tool explicitly, because a search for the request's subject does not surface a catch-all rule. + +A per-tool recipe SHALL state the check its tool always performs first: + +- Ruff: whether `banned-api` (TID251) covers the request. +- ESLint: whether a core rule covers it, with `no-restricted-syntax` and `no-restricted-imports` (and their `@typescript-eslint/` counterparts) named as the catch-alls, before any plugin. +- Biome: whether an existing rule makes the problem go away, with `noRestrictedImports` and `noRestrictedGlobals` named as the catch-alls. +- Oxlint: whether an existing rule makes the problem go away, with `no-restricted-imports` and `no-restricted-globals` named as the catch-alls. The recipe SHALL state that Oxlint has no `no-restricted-syntax`. +- pylint: whether a shipped message or the `deprecated-modules`, `preferred-modules`, or `bad-functions` options cover it. +- flake8: whether `banned-modules` from `flake8-tidy-imports` covers it. The flake8 recipe SHALL NOT include a discovery step. + +#### Scenario: Ruff tries banned-api first + +- **WHEN** the agent follows the Ruff recipe for a request to forbid an API +- **THEN** the recipe SHALL direct it to check `banned-api` before any other step + +#### Scenario: ESLint tries a core rule before a plugin + +- **WHEN** the agent follows the ESLint recipe +- **THEN** it SHALL check core rules and the named catch-alls before considering any plugin from the curated list + +#### Scenario: flake8 goes straight to its catch-all + +- **WHEN** the agent follows the flake8 recipe +- **THEN** it SHALL be directed to `banned-modules` without a rule-discovery step + +### Requirement: Rule discovery is confirmed against the installed version + +Where a tool's rule set is too large to match a request against locally, the recipe SHALL direct the agent to find candidate rules on the tool's rules page by web search or fetch, then confirm each candidate against the installed version with the tool's own command before enabling it. The Ruff recipe SHALL use `ruff rule `, the Biome recipe `biome explain `, and the Oxlint recipe `oxlint --rules`. + +The Ruff recipe SHALL state that a preview-only rule does not fire unless preview mode is enabled, and SHALL direct the agent to check the rule's preview status before enabling it. The pylint recipe SHALL state that `--help-msg` reports a message from an optional extension as nonexistent unless that extension is passed with `--load-plugins`. + +#### Scenario: A Ruff rule is confirmed locally + +- **WHEN** the agent finds a candidate Ruff rule on the web +- **THEN** it SHALL confirm it with `ruff rule` against the installed version before enabling it + +#### Scenario: A preview-only Ruff rule is not enabled silently + +- **WHEN** the confirmed Ruff rule is preview-only +- **THEN** the agent SHALL either enable preview mode for it with the user's knowledge or choose another rule, and SHALL NOT enable it in a configuration where it cannot fire + +#### Scenario: A pylint extension message is looked up with its extension loaded + +- **WHEN** the agent looks up a message that belongs to a pylint optional extension +- **THEN** it SHALL pass that extension with `--load-plugins` before concluding the message does not exist + +### Requirement: A new legacy rule is written only from the repository's own examples + +A legacy recipe SHALL permit the agent to write a new custom rule in a linter only when the repository already has at least five custom rules for that linter, as counted by `detect --json`. The agent SHALL use those rules as its reference for structure and conventions. + +The recipes SHALL NOT teach custom-rule authoring for any tool. They SHALL NOT carry instructions for writing an ESLint rule, a pylint checker, a flake8 plugin, a Biome plugin, or an Oxlint plugin of any kind. Below the threshold, a request no shipped rule solves SHALL return to `route`, which sends it to a Taskless rule. + +#### Scenario: Five examples permit a new rule + +- **WHEN** no shipped rule solves the request AND `detect` counts five or more custom rules for the linter +- **THEN** the agent MAY write a new custom rule modeled on those rules + +#### Scenario: Fewer examples send the request back to Taskless + +- **WHEN** no shipped rule solves the request AND `detect` counts fewer than five custom rules for the linter +- **THEN** the agent SHALL NOT write a custom rule in that linter +- **AND** it SHALL continue down the `route` ladder to a Taskless rule diff --git a/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-rule-routing/spec.md b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-rule-routing/spec.md new file mode 100644 index 00000000..4049b172 --- /dev/null +++ b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-rule-routing/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: Route describes each detected linter + +The `route` recipe SHALL include, for each linter `detect` reports that can host a legacy rule, one paragraph saying what the tool is for, what the agent always checks first in it, and the recipe command to fetch for it. The paragraphs SHALL be rendered when the recipe is served, and SHALL appear only for linters detected in the repository. + +A detected tool that can host a legacy rule and has no paragraph SHALL be named in the recipe with the command to fetch the generic recipe for it, and the recipe SHALL state that steps 1 and 2 of the legacy ladder still apply to it. `route` SHALL NOT state or imply that a linter `detect` reported is absent. Formatters and Taskless's own engines are not legacy hosts and SHALL NOT be named this way. + +When the recipe is rendered from a scan, it SHALL state that `detect` recognizes a fixed list of tools, so the repository may run others. + +#### Scenario: Only detected linters are described + +- **WHEN** `detect` reports Ruff and no other linter +- **THEN** the served `route` recipe SHALL contain the Ruff paragraph +- **AND** SHALL NOT contain a paragraph for any other linter + +#### Scenario: A tool with no legacy recipe gets no paragraph + +- **WHEN** `detect` reports only `prettier` +- **THEN** the served `route` recipe SHALL contain no linter paragraph + +#### Scenario: Oxlint is described when detected + +- **WHEN** `detect` reports `oxlint` and no other linter +- **THEN** the served `route` recipe SHALL contain the Oxlint paragraph +- **AND** SHALL NOT say that no linter was found + +#### Scenario: A detected linter with no paragraph is still named + +- **WHEN** `detect` reports `eslint` and `stylelint` +- **THEN** the served `route` recipe SHALL contain the ESLint paragraph +- **AND** SHALL name `stylelint` with the command to fetch `create-legacy-rule` for it + +#### Scenario: A formatter is not offered as a legacy destination + +- **WHEN** `detect` reports `eslint` and `prettier` +- **THEN** the served `route` recipe SHALL NOT name `prettier` as a linter to fetch a recipe for diff --git a/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/tasks.md b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/tasks.md new file mode 100644 index 00000000..c4a9a474 --- /dev/null +++ b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/tasks.md @@ -0,0 +1,11 @@ +## 1. Recipe + +- [x] 1.1 Add `create-legacy-rule.oxlint.md`, with facts taken from the Oxlint docs +- [x] 1.2 Pin its catch-all claims in the legacy linter vendor contract + +## 2. Routing + +- [x] 2.1 Add the Oxlint paragraph to `route` +- [x] 2.2 Name detected tools that have no paragraph, excluding formatters and Taskless's engines +- [x] 2.3 State that `detect` recognizes a fixed list of tools +- [x] 2.4 Bump `route` to topic v9 diff --git a/openspec/specs/cli-legacy-rule-recipes/spec.md b/openspec/specs/cli-legacy-rule-recipes/spec.md index cf5192fb..aea435ed 100644 --- a/openspec/specs/cli-legacy-rule-recipes/spec.md +++ b/openspec/specs/cli-legacy-rule-recipes/spec.md @@ -15,6 +15,7 @@ A per-tool recipe SHALL state the check its tool always performs first: - Ruff: whether `banned-api` (TID251) covers the request. - ESLint: whether a core rule covers it, with `no-restricted-syntax` and `no-restricted-imports` (and their `@typescript-eslint/` counterparts) named as the catch-alls, before any plugin. - Biome: whether an existing rule makes the problem go away, with `noRestrictedImports` and `noRestrictedGlobals` named as the catch-alls. +- Oxlint: whether an existing rule makes the problem go away, with `no-restricted-imports` and `no-restricted-globals` named as the catch-alls. The recipe SHALL state that Oxlint has no `no-restricted-syntax`. - pylint: whether a shipped message or the `deprecated-modules`, `preferred-modules`, or `bad-functions` options cover it. - flake8: whether `banned-modules` from `flake8-tidy-imports` covers it. The flake8 recipe SHALL NOT include a discovery step. @@ -35,7 +36,7 @@ A per-tool recipe SHALL state the check its tool always performs first: ### Requirement: Rule discovery is confirmed against the installed version -Where a tool's rule set is too large to match a request against locally, the recipe SHALL direct the agent to find candidate rules on the tool's rules page by web search or fetch, then confirm each candidate against the installed version with the tool's own command before enabling it. The Ruff recipe SHALL use `ruff rule ` and the Biome recipe `biome explain `. +Where a tool's rule set is too large to match a request against locally, the recipe SHALL direct the agent to find candidate rules on the tool's rules page by web search or fetch, then confirm each candidate against the installed version with the tool's own command before enabling it. The Ruff recipe SHALL use `ruff rule `, the Biome recipe `biome explain `, and the Oxlint recipe `oxlint --rules`. The Ruff recipe SHALL state that a preview-only rule does not fire unless preview mode is enabled, and SHALL direct the agent to check the rule's preview status before enabling it. The pylint recipe SHALL state that `--help-msg` reports a message from an optional extension as nonexistent unless that extension is passed with `--load-plugins`. @@ -76,7 +77,7 @@ The list names rule sources, not individual rules. The recipe SHALL NOT embed a A legacy recipe SHALL permit the agent to write a new custom rule in a linter only when the repository already has at least five custom rules for that linter, as counted by `detect --json`. The agent SHALL use those rules as its reference for structure and conventions. -The recipes SHALL NOT teach custom-rule authoring for any tool. They SHALL NOT carry instructions for writing an ESLint rule, a pylint checker, a flake8 plugin, or a Biome plugin of any kind. Below the threshold, a request no shipped rule solves SHALL return to `route`, which sends it to a Taskless rule. +The recipes SHALL NOT teach custom-rule authoring for any tool. They SHALL NOT carry instructions for writing an ESLint rule, a pylint checker, a flake8 plugin, a Biome plugin, or an Oxlint plugin of any kind. Below the threshold, a request no shipped rule solves SHALL return to `route`, which sends it to a Taskless rule. #### Scenario: Five examples permit a new rule diff --git a/openspec/specs/cli-rule-routing/spec.md b/openspec/specs/cli-rule-routing/spec.md index 2862d17d..f2df2da7 100644 --- a/openspec/specs/cli-rule-routing/spec.md +++ b/openspec/specs/cli-rule-routing/spec.md @@ -341,6 +341,10 @@ Step 1 SHALL be taken whenever a detected linter could host the rule, regardless The `route` recipe SHALL include, for each linter `detect` reports that can host a legacy rule, one paragraph saying what the tool is for, what the agent always checks first in it, and the recipe command to fetch for it. The paragraphs SHALL be rendered when the recipe is served, and SHALL appear only for linters detected in the repository. +A detected tool that can host a legacy rule and has no paragraph SHALL be named in the recipe with the command to fetch the generic recipe for it, and the recipe SHALL state that steps 1 and 2 of the legacy ladder still apply to it. `route` SHALL NOT state or imply that a linter `detect` reported is absent. Formatters and Taskless's own engines are not legacy hosts and SHALL NOT be named this way. + +When the recipe is rendered from a scan, it SHALL state that `detect` recognizes a fixed list of tools, so the repository may run others. + #### Scenario: Only detected linters are described - **WHEN** `detect` reports Ruff and no other linter @@ -352,6 +356,23 @@ The `route` recipe SHALL include, for each linter `detect` reports that can host - **WHEN** `detect` reports only `prettier` - **THEN** the served `route` recipe SHALL contain no linter paragraph +#### Scenario: Oxlint is described when detected + +- **WHEN** `detect` reports `oxlint` and no other linter +- **THEN** the served `route` recipe SHALL contain the Oxlint paragraph +- **AND** SHALL NOT say that no linter was found + +#### Scenario: A detected linter with no paragraph is still named + +- **WHEN** `detect` reports `eslint` and `stylelint` +- **THEN** the served `route` recipe SHALL contain the ESLint paragraph +- **AND** SHALL name `stylelint` with the command to fetch `create-legacy-rule` for it + +#### Scenario: A formatter is not offered as a legacy destination + +- **WHEN** `detect` reports `eslint` and `prettier` +- **THEN** the served `route` recipe SHALL NOT name `prettier` as a linter to fetch a recipe for + ### Requirement: Route asks the user only when the ladder does not decide The `route` recipe SHALL present the viable options to the user with their From a6f9a3e844113c6a542069a80e1fe1b8500633d1 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Fri, 6 Nov 2026 16:36:59 -0800 Subject: [PATCH 3/3] fix(legacy): have the Oxlint guide ask for a rule list that prints Under an agent or GitHub Actions, Oxlint picks an output format with no rule list, so `oxlint --rules` prints nothing and exits 0. The guide's confirm step would have read as "this rule does not exist". Pass `--format default`, which the contract check now pins. Drop the claim that `--print-config` shows which config governs a file: it prints the root config. Also cover `route` with only unguided linters. --- .github/workflows/legacy-linter-contract.yml | 2 +- .../specs/cli-legacy-rule-recipes/spec.md | 2 +- openspec/specs/cli-legacy-rule-recipes/spec.md | 2 +- .../cli/src/agent/create-legacy-rule.oxlint.md | 14 ++++++++------ packages/cli/test/legacy-ladder.test.ts | 4 ++-- .../cli/test/legacy-linter-vendor-contract.test.ts | 11 ++++++++++- packages/cli/test/prompts.test.ts | 6 ++++++ 7 files changed, 29 insertions(+), 12 deletions(-) diff --git a/.github/workflows/legacy-linter-contract.yml b/.github/workflows/legacy-linter-contract.yml index d560af5e..392a34f5 100644 --- a/.github/workflows/legacy-linter-contract.yml +++ b/.github/workflows/legacy-linter-contract.yml @@ -1,5 +1,5 @@ # Pins what the `create-legacy-rule` recipes say about Ruff, pylint, flake8, -# and Biome, by asking each linter at a pinned version. See +# Biome, and Oxlint, by asking each linter at a pinned version. See # `packages/cli/test/legacy-linter-vendor-contract.test.ts`. # # Separate from `Validate` on purpose: it downloads linters from PyPI and npm, diff --git a/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-legacy-rule-recipes/spec.md b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-legacy-rule-recipes/spec.md index 5d067582..ef57a657 100644 --- a/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-legacy-rule-recipes/spec.md +++ b/openspec/changes/archive/2026-11-06-route-names-every-detected-linter/specs/cli-legacy-rule-recipes/spec.md @@ -30,7 +30,7 @@ A per-tool recipe SHALL state the check its tool always performs first: ### Requirement: Rule discovery is confirmed against the installed version -Where a tool's rule set is too large to match a request against locally, the recipe SHALL direct the agent to find candidate rules on the tool's rules page by web search or fetch, then confirm each candidate against the installed version with the tool's own command before enabling it. The Ruff recipe SHALL use `ruff rule `, the Biome recipe `biome explain `, and the Oxlint recipe `oxlint --rules`. +Where a tool's rule set is too large to match a request against locally, the recipe SHALL direct the agent to find candidate rules on the tool's rules page by web search or fetch, then confirm each candidate against the installed version with the tool's own command before enabling it. The Ruff recipe SHALL use `ruff rule `, the Biome recipe `biome explain `, and the Oxlint recipe `oxlint --rules --format default`. The Ruff recipe SHALL state that a preview-only rule does not fire unless preview mode is enabled, and SHALL direct the agent to check the rule's preview status before enabling it. The pylint recipe SHALL state that `--help-msg` reports a message from an optional extension as nonexistent unless that extension is passed with `--load-plugins`. diff --git a/openspec/specs/cli-legacy-rule-recipes/spec.md b/openspec/specs/cli-legacy-rule-recipes/spec.md index aea435ed..52327790 100644 --- a/openspec/specs/cli-legacy-rule-recipes/spec.md +++ b/openspec/specs/cli-legacy-rule-recipes/spec.md @@ -36,7 +36,7 @@ A per-tool recipe SHALL state the check its tool always performs first: ### Requirement: Rule discovery is confirmed against the installed version -Where a tool's rule set is too large to match a request against locally, the recipe SHALL direct the agent to find candidate rules on the tool's rules page by web search or fetch, then confirm each candidate against the installed version with the tool's own command before enabling it. The Ruff recipe SHALL use `ruff rule `, the Biome recipe `biome explain `, and the Oxlint recipe `oxlint --rules`. +Where a tool's rule set is too large to match a request against locally, the recipe SHALL direct the agent to find candidate rules on the tool's rules page by web search or fetch, then confirm each candidate against the installed version with the tool's own command before enabling it. The Ruff recipe SHALL use `ruff rule `, the Biome recipe `biome explain `, and the Oxlint recipe `oxlint --rules --format default`. The Ruff recipe SHALL state that a preview-only rule does not fire unless preview mode is enabled, and SHALL direct the agent to check the rule's preview status before enabling it. The pylint recipe SHALL state that `--help-msg` reports a message from an optional extension as nonexistent unless that extension is passed with `--load-plugins`. diff --git a/packages/cli/src/agent/create-legacy-rule.oxlint.md b/packages/cli/src/agent/create-legacy-rule.oxlint.md index 89826713..8facd546 100644 --- a/packages/cli/src/agent/create-legacy-rule.oxlint.md +++ b/packages/cli/src/agent/create-legacy-rule.oxlint.md @@ -37,17 +37,19 @@ installed version, and enable it in the repository's Oxlint configuration. https://oxc.rs/llms.txt. 3. **Confirm the rule against the installed version.** Run - `oxlint --rules`, which lists the rules the installed build has. A - rule belonging to a plugin only fires when that plugin is on, and - setting `plugins` replaces the default set, so read the config's - `plugins` before relying on one. + `oxlint --rules --format default`, which lists the rules the installed + build has. Keep `--format default`: without it, Oxlint prints nothing + when it detects an agent or GitHub Actions, and still exits 0, so an + empty list says nothing about the rule. A rule belonging to a plugin + only fires when that plugin is on, and setting `plugins` replaces the + default set, so read the config's `plugins` before relying on one. 4. **Enable it where the repo configures Oxlint.** Oxlint reads `.oxlintrc.json`, `.oxlintrc.jsonc`, `oxlint.config.ts`, or `oxlint.config.mts`. Each file uses the nearest config, and a nested config replaces its parent rather than merging with it, unless it - says `extends`. So change the config that governs the files in - question, which `oxlint --print-config` shows. Rules go under + says `extends`. So change the config nearest the files in question, + not necessarily the one at the repository root. Rules go under `rules`, as a severity or a `[severity, options]` pair: ```json "rules": { diff --git a/packages/cli/test/legacy-ladder.test.ts b/packages/cli/test/legacy-ladder.test.ts index fa247e40..e6c677c1 100644 --- a/packages/cli/test/legacy-ladder.test.ts +++ b/packages/cli/test/legacy-ladder.test.ts @@ -158,12 +158,12 @@ describe("the per-tool legacy recipes", () => { expect(biome).toContain("Do not write a Biome plugin."); }); - it("has Oxlint check its catch-alls and confirm with oxlint --rules", () => { + it("has Oxlint check its catch-alls and confirm with oxlint --rules --format default", () => { const oxlint = tool("oxlint"); expect(oxlint).toContain("`no-restricted-imports`"); expect(oxlint).toContain("`no-restricted-globals`"); expect(oxlint).toContain("Oxlint has no `no-restricted-syntax`."); - expect(oxlint).toContain("`oxlint --rules`"); + expect(oxlint).toContain("`oxlint --rules --format default`"); expect(oxlint).toContain("a nested\n config replaces its parent"); expect(oxlint).toContain("Do not write an Oxlint plugin."); }); diff --git a/packages/cli/test/legacy-linter-vendor-contract.test.ts b/packages/cli/test/legacy-linter-vendor-contract.test.ts index 2563b846..7d5b7db9 100644 --- a/packages/cli/test/legacy-linter-vendor-contract.test.ts +++ b/packages/cli/test/legacy-linter-vendor-contract.test.ts @@ -192,8 +192,17 @@ describe.skipIf(!ENABLED)("legacy linter vendor contract", () => { describe("oxlint", () => { it("lists both catch-all rules and has no no-restricted-syntax", () => { - const { status, output } = run("pnpm", ["dlx", OXLINT, "--rules"]); + // `--format default` is part of the contract: under GitHub Actions or + // an agent, Oxlint picks a format that lists no rules, and exits 0. + const { status, output } = run("pnpm", [ + "dlx", + OXLINT, + "--rules", + "--format", + "default", + ]); expect(status).toBe(0); + expect(recipe("oxlint")).toContain("`oxlint --rules --format default`"); for (const rule of ["no-restricted-imports", "no-restricted-globals"]) { expect(output).toContain(rule); expect(recipe("oxlint")).toContain(`\`${rule}\``); diff --git a/packages/cli/test/prompts.test.ts b/packages/cli/test/prompts.test.ts index 17c3d5ad..cbf509f8 100644 --- a/packages/cli/test/prompts.test.ts +++ b/packages/cli/test/prompts.test.ts @@ -665,6 +665,12 @@ describe("route's per-linter passage", () => { expect(route).not.toContain("found no linter here"); }); + it("names a detected linter even when none of them has a guide", () => { + const route = getPrompt("route", { linters: ["stylelint"] }); + expect(route).toContain("with no guide of their own: `stylelint`"); + expect(route).not.toContain("found no linter here"); + }); + it("keeps a tool that is not a legacy host out of that list", () => { const route = getPrompt("route", { linters: ["eslint", "prettier", "vale"],