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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .changeset/detect-python-dev-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@
"@taskless/cli": patch
---

`taskless detect` now finds a Python linter installed as a dev dependency in `pyproject.toml`, so a repository that runs ruff on its defaults, with no `[tool.ruff]` table, no longer reads as having no linter. It reads PEP 735 `[dependency-groups]` (what `uv add --dev` writes), `[tool.uv] dev-dependencies`, `[tool.pdm.dev-dependencies]`, Hatch environment dependencies, and Poetry dependency groups. Poetry groups were previously read by group name rather than by the dependencies inside them.
`taskless detect` now finds a linter the way its ecosystem usually installs or runs it, not only from a dedicated config file, so a repository that lints on a tool's defaults no longer reads as having no linter.

- **Dependencies** are read from every ecosystem's manifest: `pyproject.toml` dev dependencies (PEP 735 `[dependency-groups]`, which `uv add --dev` writes, `[tool.uv] dev-dependencies`, `[tool.pdm.dev-dependencies]`, Hatch environments, and Poetry groups, which were previously read by group name), every pip requirements file such as `requirements-dev.txt` or `requirements/*.txt`, `Gemfile` gems, `composer.json` `require-dev`, `go.mod` `tool` directives, and `Package.swift` packages.
- **Config inside a manifest**: `[lints.clippy]` in `Cargo.toml`, a `[flake8]` section in `setup.cfg` or `tox.ini`, and the `prettier` and `stylelint` keys in `package.json`.
- **A root CI or hook config that runs the linter**, such as a workflow running `cargo clippy` or a pre-commit hook from `astral-sh/ruff-pre-commit`. Being a Rust repository is not on its own evidence of clippy.
- **New linters**: `standard`, `pint`, and `php-cs-fixer`. ESLint, Prettier, and stylelint gain their TypeScript and other missing config filenames.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-21
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Why

`taskless detect` recognised a linter mostly by its dedicated config file. Most ecosystems install a linter as a dependency and run it on its defaults, or keep its config inside a manifest, so a repository could run a linter and still read as having none. Fifteen fixtures modelled on each ecosystem's common setup all came back with no linters: clippy configured in `Cargo.toml` or run in CI, rubocop or standard as a gem, phpstan, phpcs or pint as a Composer dev dependency, golangci-lint as a Go `tool`, SwiftLint as a SwiftPM plugin, flake8 configured in `setup.cfg` or `tox.ini`, ruff in a `requirements-dev.txt` or a pre-commit hook, and prettier or stylelint configured in `package.json`. #574 fixed the same problem for `pyproject.toml` dev dependencies; this change covers the rest.

## What Changes

- **Every ecosystem's dependency manifest is read.** `Gemfile` gems for Ruby, `composer.json` `require` and `require-dev` for PHP, `go.mod` `require` and `tool` directives for Go (matched by module-path prefix, so `/v2/cmd/...` counts), and `Package.swift` package URLs for Swift. Python reads every pip requirements file (`requirements-dev.txt`, `dev-requirements.txt`, `requirements/*.txt`, and the `.in` sources pip-tools compiles), not only `requirements.txt`.
- **Config inside a manifest counts.** `[lints.clippy]` and `[workspace.lints.clippy]` in `Cargo.toml`, a `[flake8]` section in `setup.cfg` or `tox.ini`, and the `prettier` and `stylelint` keys in `package.json`, alongside the existing `[tool.*]` tables and `eslintConfig`.
- **A root CI or hook config that runs a linter is evidence**, read from the same root-only files `ci` and `hooks` report. Only commands count: the command fields of a YAML, JSON, or TOML config (`run`, `script`, `entry`, `uses`, `repo`, and the like) and the non-comment lines of a script, never a comment or a step's `name`. Evidence looks like `.github/workflows/ci.yml (cargo clippy)` or `.pre-commit-config.yaml (astral-sh/ruff-pre-commit)`. This is the only evidence for clippy run on its defaults, since clippy ships with the toolchain and is never a dependency. Being a Rust repository is not by itself evidence of clippy.
- **New linters**: `standard` (Ruby), `pint` and `php-cs-fixer` (PHP).
- **Missing config filenames**: `eslint.config.{mts,cts}`; prettier's `.prettierrc.{mjs,json5,toml,ts,mts,cts}` and `prettier.config.{ts,mts,cts}`; stylelint's `.stylelintrc.{mjs,ts}` and `stylelint.config.ts`. Each list was checked against the tool's own source (ESLint 9.39, Prettier 3.8.1, stylelint 17.16 via cosmiconfig 9).
- **Recipe**: `detect` (v4) describes the new evidence kinds.

The JSON shape is unchanged: these are new `evidence` strings and new `name` values.

## Capabilities

### Modified Capabilities

- `cli-detect`: adds "Detect recognizes a linter its ecosystem installs or runs by default". The existing requirements are unchanged.

## Delivery Shape

**Stacked, merging forward**, on #574. Each PR only adds evidence `detect` reports, so either one is safe on `main` by itself. This is the tip, so it archives the change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
## ADDED Requirements

### Requirement: Detect recognizes a linter its ecosystem installs or runs by default

`detect` SHALL report a linter that a repository installs as a dependency, configures inside a manifest, or runs from its CI or commit-hook configuration, even when the linter has no dedicated config file. A dependency SHALL be read from the manifests of the linter's own languages: `package.json` for JavaScript and TypeScript; `pyproject.toml` and every pip requirements file for Python; `Gemfile` for Ruby; `composer.json` `require` and `require-dev` for PHP; `go.mod` `require` and `tool` directives for Go, matched by module-path prefix; and `Package.swift` package URLs for Swift.

A CI or commit-hook configuration SHALL be read only from the root-level files that `ci` and `hooks` are detected from, and SHALL be evidence only when one of its commands runs the linter: a command field of a YAML, JSON, or TOML config, or a non-comment line of a script. A comment or a label such as a step's `name` SHALL NOT be evidence. Each match SHALL be reported as the file path followed by the matched command.

A linter that ships with a language toolchain rather than being declared as a dependency, such as clippy, SHALL NOT be reported merely because that language is present.

#### Scenario: clippy configured in Cargo.toml

- **WHEN** a `Cargo.toml` declares `[lints.clippy]` or `[workspace.lints.clippy]`
- **THEN** `detect --json` SHALL report `clippy` with evidence naming that manifest and table

#### Scenario: clippy run from CI

- **WHEN** a root GitHub Actions workflow runs `cargo clippy`, with or without a `+toolchain` argument
- **THEN** `detect --json` SHALL report `clippy` with evidence naming the workflow file and the command

#### Scenario: A Rust repository that never configures or runs clippy

- **WHEN** a repository has a `Cargo.toml` and nothing configures or runs clippy
- **THEN** `detect --json` SHALL report `Rust` among `languages` and SHALL NOT report `clippy`

#### Scenario: A linter declared as a dependency in its ecosystem's manifest

- **WHEN** `rubocop` is a gem in a `Gemfile`, `phpstan/phpstan` is in `composer.json` `require-dev`, `github.com/golangci/golangci-lint/v2/cmd/golangci-lint` is a `go.mod` `tool`, or `ruff` is listed in `requirements-dev.txt`
- **THEN** `detect --json` SHALL report that linter with a dependency marker naming the manifest

#### Scenario: Config inside a shared file

- **WHEN** `setup.cfg` or `tox.ini` has a `[flake8]` section, or `package.json` has a top-level `prettier` or `stylelint` key
- **THEN** `detect --json` SHALL report that linter with evidence naming the file and the section or key

#### Scenario: A linter run from a pre-commit hook

- **WHEN** a root `.pre-commit-config.yaml` uses `astral-sh/ruff-pre-commit`
- **THEN** `detect --json` SHALL report `ruff` with evidence naming `.pre-commit-config.yaml`

#### Scenario: A CI config below the root is not evidence

- **WHEN** the only command that runs a linter is in a CI config inside a sub-directory
- **THEN** `detect --json` SHALL NOT report that linter from it

#### Scenario: A mention that is not a command is not evidence

- **WHEN** a root CI or hook config names a linter only in a comment, a step's `name`, or another field that is not a command
- **THEN** `detect --json` SHALL NOT report that linter from it
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## 1. Detection

- [x] 1.1 In `detect/scan.ts`, read dependencies from `Gemfile`, `composer.json`, `go.mod`, `Package.swift`, and every pip requirements file, keyed by the linter's languages.
- [x] 1.2 Generalize `pyprojectTables` to `manifestTables` (adding `Cargo.toml`), and add `iniSections`, `packageJsonKeys`, and `invocations` matched against the commands `automationCommands` extracts from the root `automationFiles`, never their comments or labels.
- [x] 1.3 Add `standard`, `pint`, and `php-cs-fixer`, and the missing ESLint, Prettier, and stylelint config filenames, checked against each tool's source.

## 2. Recipe and tests

- [x] 2.1 `detect` recipe (v4), the evidence description in `schemas/detect.ts`, and the `DetectedLinter` doc.
- [x] 2.2 Tests: one case per new evidence source, plus clippy not inferred from Rust alone and no invocation read below the root.
- [x] 2.3 `pnpm typecheck`, `pnpm lint`, and the detect tests pass.
48 changes: 48 additions & 0 deletions openspec/specs/cli-detect/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,51 @@ source, so the two cannot describe a tool differently.
- **WHEN** `glab` is present and applicable
- **THEN** its `agent` entry SHALL state that the output, not the exit code,
shows whether the user is signed in

### Requirement: Detect recognizes a linter its ecosystem installs or runs by default

`detect` SHALL report a linter that a repository installs as a dependency, configures inside a manifest, or runs from its CI or commit-hook configuration, even when the linter has no dedicated config file. A dependency SHALL be read from the manifests of the linter's own languages: `package.json` for JavaScript and TypeScript; `pyproject.toml` and every pip requirements file for Python; `Gemfile` for Ruby; `composer.json` `require` and `require-dev` for PHP; `go.mod` `require` and `tool` directives for Go, matched by module-path prefix; and `Package.swift` package URLs for Swift.

A CI or commit-hook configuration SHALL be read only from the root-level files that `ci` and `hooks` are detected from, and SHALL be evidence only when one of its commands runs the linter: a command field of a YAML, JSON, or TOML config, or a non-comment line of a script. A comment or a label such as a step's `name` SHALL NOT be evidence. Each match SHALL be reported as the file path followed by the matched command.

A linter that ships with a language toolchain rather than being declared as a dependency, such as clippy, SHALL NOT be reported merely because that language is present.

#### Scenario: clippy configured in Cargo.toml

- **WHEN** a `Cargo.toml` declares `[lints.clippy]` or `[workspace.lints.clippy]`
- **THEN** `detect --json` SHALL report `clippy` with evidence naming that manifest and table

#### Scenario: clippy run from CI

- **WHEN** a root GitHub Actions workflow runs `cargo clippy`, with or without a `+toolchain` argument
- **THEN** `detect --json` SHALL report `clippy` with evidence naming the workflow file and the command

#### Scenario: A Rust repository that never configures or runs clippy

- **WHEN** a repository has a `Cargo.toml` and nothing configures or runs clippy
- **THEN** `detect --json` SHALL report `Rust` among `languages` and SHALL NOT report `clippy`

#### Scenario: A linter declared as a dependency in its ecosystem's manifest

- **WHEN** `rubocop` is a gem in a `Gemfile`, `phpstan/phpstan` is in `composer.json` `require-dev`, `github.com/golangci/golangci-lint/v2/cmd/golangci-lint` is a `go.mod` `tool`, or `ruff` is listed in `requirements-dev.txt`
- **THEN** `detect --json` SHALL report that linter with a dependency marker naming the manifest

#### Scenario: Config inside a shared file

- **WHEN** `setup.cfg` or `tox.ini` has a `[flake8]` section, or `package.json` has a top-level `prettier` or `stylelint` key
- **THEN** `detect --json` SHALL report that linter with evidence naming the file and the section or key

#### Scenario: A linter run from a pre-commit hook

- **WHEN** a root `.pre-commit-config.yaml` uses `astral-sh/ruff-pre-commit`
- **THEN** `detect --json` SHALL report `ruff` with evidence naming `.pre-commit-config.yaml`

#### Scenario: A CI config below the root is not evidence

- **WHEN** the only command that runs a linter is in a CI config inside a sub-directory
- **THEN** `detect --json` SHALL NOT report that linter from it

#### Scenario: A mention that is not a command is not evidence

- **WHEN** a root CI or hook config names a linter only in a comment, a step's `name`, or another field that is not a command
- **THEN** `detect --json` SHALL NOT report that linter from it
13 changes: 9 additions & 4 deletions packages/cli/src/agent/detect.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: detect (CLI v%(CLI_VERSION)s / topic v3)
# Topic: detect (CLI v%(CLI_VERSION)s / topic v4)

%(ONE_COMMAND_PER_CALL)s

Expand Down Expand Up @@ -51,9 +51,14 @@ routing flow reads `detect` to decide where a new rule should live.
]
}
```
- `linters`: each has a `name` and `evidence` (config-file paths,
a pyproject table marker, or a dependency marker from the
language's package file; not every entry is a path).
- `linters`: each has a `name` and `evidence`: a config-file path,
a table or section marker in a manifest (`pyproject.toml
[tool.ruff]`, `Cargo.toml [lints.clippy]`, `setup.cfg [flake8]`),
a dependency marker from the language's package file, or a root CI
or hook config that runs the linter (`.github/workflows/ci.yml
(cargo clippy)`). Not every entry is a path. A linter that only
ships with a toolchain, like clippy, is reported only when
something configures or runs it.
- `languages`: inferred from manifests and the detected linters.
- `ruleStyles`: how the repo authors its own rules, surfaced for
downstream reuse.
Expand Down
133 changes: 132 additions & 1 deletion packages/cli/src/detect/automation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { existsSync, globSync, readdirSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { basename, extname, resolve } from "node:path";

import { parse as parseToml } from "smol-toml";
import { parse as parseYaml } from "yaml";

/**
* Something that runs commands on the repository's behalf without anyone
Expand Down Expand Up @@ -213,3 +216,131 @@ export async function detectAutomation(
hooks: matchSignals(root, HOOK_SIGNALS, packageJson),
};
}

/**
* Keys whose values are commands, or name what a step runs, across the CI and
* hook formats above: GitHub Actions `run`/`uses`, GitLab and Travis
* `script`, CircleCI `run`/`command`, Buildkite and Drone `commands`, Azure
* `bash`/`pwsh`, lefthook `run`, pre-commit `entry` and `repo`. Everything
* beneath one is collected, so `run: { command }` and script arrays count.
* Labels (`name`, `id`, `description`) are deliberately absent.
*/
const COMMAND_KEYS: ReadonlySet<string> = new Set([
"run",
"uses",
"script",
"before_script",
"after_script",
"before_install",
"install",
"command",
"commands",
"bash",
"pwsh",
"powershell",
"sh",
"entry",
"repo",
]);

/** Configs whose every value is a command: lint-staged maps globs to them, simple-git-hooks maps hook names. */
function isCommandMap(file: string): boolean {
return /^\.?(?:lintstagedrc|lint-staged|simple-git-hooks)/.test(
basename(file)
);
}

function collectStrings(value: unknown, into: string[]): void {
if (typeof value === "string") into.push(value);
else if (Array.isArray(value)) {
for (const item of value) collectStrings(item, into);
} else if (typeof value === "object" && value !== null) {
for (const item of Object.values(value)) collectStrings(item, into);
}
}

function collectCommands(value: unknown, into: string[]): void {
if (Array.isArray(value)) {
for (const item of value) collectCommands(item, into);
} else if (typeof value === "object" && value !== null) {
for (const [key, item] of Object.entries(value)) {
if (COMMAND_KEYS.has(key)) collectStrings(item, into);
else collectCommands(item, into);
}
}
}

/** A structured config parsed by its extension; `undefined` when it is not one, `null` when malformed. */
function parseStructured(file: string, raw: string): unknown {
const extension = extname(file);
try {
if (extension === ".yml" || extension === ".yaml") return parseYaml(raw);
if (extension === ".json") return JSON.parse(raw);
if (extension === ".toml") return parseToml(raw);
} catch {
return null;
}
// `.lintstagedrc` with no extension is JSON or YAML, and YAML reads both.
if (extension === "" && isCommandMap(file)) {
try {
return parseYaml(raw);
} catch {
return null;
}
}
return undefined;
}

/**
* A line of shell or JavaScript with its trailing comment removed. A comment
* starts at a `#` or `//` that opens the line or follows whitespace, and only
* outside a quoted string: `echo "a # b" && golangci-lint run` keeps its
* invocation. Backslash escapes inside double quotes and backticks; single
* quotes are literal, as in shell.
*/
function stripLineComment(line: string): string {
let quote: string | undefined;
for (let index = 0; index < line.length; index++) {
const char = line[index];
if (quote !== undefined) {
if (char === "\\" && quote !== "'") index++;
else if (char === quote) quote = undefined;
continue;
}
if (char === '"' || char === "'" || char === "`") {
quote = char;
continue;
}
const opensComment =
char === "#" || (char === "/" && line[index + 1] === "/");
if (opensComment && (index === 0 || /\s/.test(line[index - 1] ?? ""))) {
return line.slice(0, index);
}
}
return line;
}

/**
* The commands a CI or hook config runs, so a linter is evidenced by being
* RUN rather than by being mentioned. A structured config contributes only
* the values of its command keys; a step's name or a comment is not a
* command. Anything else (a husky script, a Jenkinsfile, a JavaScript
* config) is code, and contributes its lines with comments removed. A
* malformed config contributes nothing.
*/
export function automationCommands(file: string, raw: string): string[] {
const parsed = parseStructured(file, raw);
if (parsed === null) return [];
const commands: string[] = [];
if (parsed === undefined) {
for (const line of raw.split("\n")) {
const code = stripLineComment(line).trim();
if (code !== "") commands.push(code);
Comment thread
theCodeDrift marked this conversation as resolved.
}
} else if (isCommandMap(file)) {
collectStrings(parsed, commands);
} else {
collectCommands(parsed, commands);
}
return commands;
}
Loading
Loading