diff --git a/.changeset/detect-python-dev-dependencies.md b/.changeset/detect-python-dev-dependencies.md index 74e4a35b..c52bc561 100644 --- a/.changeset/detect-python-dev-dependencies.md +++ b/.changeset/detect-python-dev-dependencies.md @@ -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. diff --git a/openspec/changes/archive/2026-10-21-improve-linter-detection/.openspec.yaml b/openspec/changes/archive/2026-10-21-improve-linter-detection/.openspec.yaml new file mode 100644 index 00000000..dc36fb1b --- /dev/null +++ b/openspec/changes/archive/2026-10-21-improve-linter-detection/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-21 diff --git a/openspec/changes/archive/2026-10-21-improve-linter-detection/proposal.md b/openspec/changes/archive/2026-10-21-improve-linter-detection/proposal.md new file mode 100644 index 00000000..3c99d7ac --- /dev/null +++ b/openspec/changes/archive/2026-10-21-improve-linter-detection/proposal.md @@ -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. diff --git a/openspec/changes/archive/2026-10-21-improve-linter-detection/specs/cli-detect/spec.md b/openspec/changes/archive/2026-10-21-improve-linter-detection/specs/cli-detect/spec.md new file mode 100644 index 00000000..774ec942 --- /dev/null +++ b/openspec/changes/archive/2026-10-21-improve-linter-detection/specs/cli-detect/spec.md @@ -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 diff --git a/openspec/changes/archive/2026-10-21-improve-linter-detection/tasks.md b/openspec/changes/archive/2026-10-21-improve-linter-detection/tasks.md new file mode 100644 index 00000000..d97183c4 --- /dev/null +++ b/openspec/changes/archive/2026-10-21-improve-linter-detection/tasks.md @@ -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. diff --git a/openspec/specs/cli-detect/spec.md b/openspec/specs/cli-detect/spec.md index 5cf0a375..341d80fc 100644 --- a/openspec/specs/cli-detect/spec.md +++ b/openspec/specs/cli-detect/spec.md @@ -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 diff --git a/packages/cli/src/agent/detect.md b/packages/cli/src/agent/detect.md index b2ccaa47..8126d7f4 100644 --- a/packages/cli/src/agent/detect.md +++ b/packages/cli/src/agent/detect.md @@ -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 @@ -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. diff --git a/packages/cli/src/detect/automation.ts b/packages/cli/src/detect/automation.ts index 7f010e9f..1bf1279d 100644 --- a/packages/cli/src/detect/automation.ts +++ b/packages/cli/src/detect/automation.ts @@ -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 @@ -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 = 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); + } + } else if (isCommandMap(file)) { + collectStrings(parsed, commands); + } else { + collectCommands(parsed, commands); + } + return commands; +} diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 100da90e..eab90341 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -5,13 +5,19 @@ import { resolve } from "node:path"; import { parse as parseToml } from "smol-toml"; import { RULES_DIRECTORY } from "../rules/layout"; -import { detectAutomation, type DetectedAutomation } from "./automation"; +import { + automationCommands, + automationFiles, + detectAutomation, + type DetectedAutomation, +} from "./automation"; export interface DetectedLinter { name: string; /** - * On-disk evidence for this linter: a config-file path, a `pyproject.toml` - * table marker, or a dependency marker from the language's package file. Each + * On-disk evidence for this linter: a config-file path, a manifest table or + * INI section marker, a dependency marker from the language's package file, + * or a root CI or hook config that runs it. Each * entry carries the path it was found at, so monorepo evidence * (`packages/api/.eslintrc.json`) is attributable. Not all entries are file * paths. @@ -86,9 +92,11 @@ const MAX_DIRECTORY_DEPTH = 8; * * Each linter is tagged with the language(s) it serves so dependency evidence * is read from the right package file: a node dependency lives in - * `package.json`, a Python dependency in `pyproject.toml`/`requirements.txt`. - * Tagging by language lets the scan look for a linter's dependency only in its - * own ecosystem's manifest instead of conflating the two. Config-file presence + * `package.json`, a Python dependency in `pyproject.toml`/`requirements*.txt`, + * a Ruby one in `Gemfile`, a PHP one in `composer.json`, a Go one in `go.mod`, + * a Swift one in `Package.swift`. Tagging by language lets the scan look for a + * linter's dependency only in its own ecosystem's manifest instead of + * conflating them. Config-file presence * is honored unconditionally, per the detect spec ("a recognized linter config * ... SHALL report each configured linter it found"), so a lone `.eslintrc.json` * still detects eslint. @@ -99,12 +107,33 @@ interface LinterSignal { languages: string[]; /** Fixed config filenames; presence on disk is direct evidence. */ configFiles?: string[]; - /** `[tool.]` tables in `pyproject.toml` (Python linters). */ - pyprojectTables?: string[]; - /** Dependency names, matched against the manifest of this linter's language. */ + /** + * Dotted TOML tables in a manifest the linter reads its config from, e.g. + * `tool.ruff` in `pyproject.toml` or `lints.clippy` in `Cargo.toml`. + */ + manifestTables?: Array<{ manifest: TomlManifest; table: string }>; + /** `[section]` headers in an INI file the linter reads, e.g. `[flake8]` in `setup.cfg`. */ + iniSections?: Array<{ file: string; section: string }>; + /** Top-level `package.json` keys the linter reads its config from. */ + packageJsonKeys?: string[]; + /** + * Dependency names, matched against the manifests of this linter's + * languages. A Go module matches by path prefix, so the `/v2` and `/cmd/...` + * forms of a module count; a Swift package is its lowercased repository path. + */ deps?: string[]; + /** + * Commands that run this linter, matched against the commands the root CI + * and commit-hook configs run (`automationCommands`), never their comments + * or step names. This is the only evidence for a linter that runs on its defaults + * and is not a dependency, such as clippy, which ships with the toolchain. + */ + invocations?: RegExp[]; } +/** TOML manifests parsed for `manifestTables`. */ +type TomlManifest = "pyproject.toml" | "Cargo.toml"; + const LINTER_SIGNALS: readonly LinterSignal[] = [ { name: "eslint", @@ -121,7 +150,10 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ "eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", + "eslint.config.mts", + "eslint.config.cts", ], + packageJsonKeys: ["eslintConfig"], deps: ["eslint"], }, { @@ -147,17 +179,22 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ { name: "stylelint", languages: ["JavaScript", "TypeScript"], + // cosmiconfig's default search places for `stylelint` (cosmiconfig 9). configFiles: [ ".stylelintrc", ".stylelintrc.js", ".stylelintrc.cjs", + ".stylelintrc.mjs", + ".stylelintrc.ts", ".stylelintrc.json", ".stylelintrc.yml", ".stylelintrc.yaml", "stylelint.config.js", "stylelint.config.cjs", "stylelint.config.mjs", + "stylelint.config.ts", ], + packageJsonKeys: ["stylelint"], deps: ["stylelint"], }, { @@ -167,45 +204,77 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ ".prettierrc", ".prettierrc.js", ".prettierrc.cjs", + ".prettierrc.mjs", + ".prettierrc.ts", + ".prettierrc.mts", + ".prettierrc.cts", ".prettierrc.json", + ".prettierrc.json5", ".prettierrc.yml", ".prettierrc.yaml", + ".prettierrc.toml", "prettier.config.js", "prettier.config.cjs", "prettier.config.mjs", + "prettier.config.ts", + "prettier.config.mts", + "prettier.config.cts", ], + packageJsonKeys: ["prettier"], deps: ["prettier"], }, { name: "ruff", languages: ["Python"], configFiles: ["ruff.toml", ".ruff.toml"], - pyprojectTables: ["ruff"], + manifestTables: [{ manifest: "pyproject.toml", table: "tool.ruff" }], deps: ["ruff"], + invocations: [ + /\bruff\s+(?:check|format)\b/, + /\bastral-sh\/ruff-(?:pre-commit|action)\b/, + ], }, { name: "flake8", languages: ["Python"], configFiles: [".flake8"], + // flake8 does not read `pyproject.toml`, so this is where its config + // usually lives. + iniSections: [ + { file: "setup.cfg", section: "flake8" }, + { file: "tox.ini", section: "flake8" }, + ], deps: ["flake8"], + invocations: [/\bflake8\b/], }, { name: "pylint", languages: ["Python"], configFiles: [".pylintrc", "pylintrc"], - pyprojectTables: ["pylint"], + manifestTables: [{ manifest: "pyproject.toml", table: "tool.pylint" }], deps: ["pylint"], + invocations: [/\bpylint\b/], }, { name: "black", languages: ["Python"], - pyprojectTables: ["black"], + manifestTables: [{ manifest: "pyproject.toml", table: "tool.black" }], deps: ["black"], + invocations: [/\bblack\s+--check\b/, /\bpsf\/black\b/], }, { name: "rubocop", languages: ["Ruby"], configFiles: [".rubocop.yml", ".rubocop.yaml"], + deps: ["rubocop"], + invocations: [/\brubocop\b/], + }, + { + name: "standard", + languages: ["Ruby"], + configFiles: [".standard.yml"], + deps: ["standard"], + invocations: [/\bstandardrb\b/], }, { name: "golangci-lint", @@ -216,16 +285,29 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ ".golangci.toml", ".golangci.json", ], + // A `tool` directive (Go 1.24+) or the older tools.go `require`. + deps: ["github.com/golangci/golangci-lint"], + invocations: [/\bgolangci-lint\b/], }, { + // Ships with rustup, so it is never a dependency, and most repositories + // run it with no config file. Being a Rust repository is NOT evidence: + // clippy counts only when it is configured or something runs it. name: "clippy", languages: ["Rust"], configFiles: ["clippy.toml", ".clippy.toml"], + manifestTables: [ + { manifest: "Cargo.toml", table: "lints.clippy" }, + { manifest: "Cargo.toml", table: "workspace.lints.clippy" }, + ], + invocations: [/\bcargo\s+(?:\+\S+\s+)?clippy\b/], }, { name: "phpstan", languages: ["PHP"], configFiles: ["phpstan.neon", "phpstan.neon.dist", "phpstan.dist.neon"], + deps: ["phpstan/phpstan"], + invocations: [/\bphpstan\b/], }, { name: "php_codesniffer", @@ -236,17 +318,46 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ ".phpcs.xml", ".phpcs.xml.dist", ], + deps: ["squizlabs/php_codesniffer"], + invocations: [/\bphpcs\b/], }, { name: "psalm", languages: ["PHP"], configFiles: ["psalm.xml", "psalm.xml.dist"], + deps: ["vimeo/psalm"], + invocations: [/\bvendor\/bin\/psalm\b/], + }, + { + name: "pint", + languages: ["PHP"], + configFiles: ["pint.json"], + deps: ["laravel/pint"], + invocations: [/\bvendor\/bin\/pint\b/], + }, + { + name: "php-cs-fixer", + languages: ["PHP"], + configFiles: [".php-cs-fixer.php", ".php-cs-fixer.dist.php"], + deps: ["friendsofphp/php-cs-fixer"], + invocations: [/\bphp-cs-fixer\b/], + }, + { + name: "clang-tidy", + languages: ["C", "C++"], + configFiles: [".clang-tidy"], + invocations: [/\bclang-tidy\b/], }, - { name: "clang-tidy", languages: ["C", "C++"], configFiles: [".clang-tidy"] }, { name: "swiftlint", languages: ["Swift"], configFiles: [".swiftlint.yml", ".swiftlint.yaml"], + // The SwiftPM plugin, from either of its two published repositories. + deps: [ + "github.com/realm/swiftlint", + "github.com/simplydanny/swiftlintplugins", + ], + invocations: [/\bswiftlint\b/], }, { name: "checkstyle", languages: ["Java"], configFiles: ["checkstyle.xml"] }, // Polyglot and prose linters: neither says anything about which languages @@ -300,10 +411,24 @@ const DISCOVERABLE_FILES: readonly string[] = [ "package.json", "tsconfig.json", ...LANGUAGE_MARKERS.flatMap((marker) => marker.files), - ...LINTER_SIGNALS.flatMap((signal) => signal.configFiles ?? []), + ...LINTER_SIGNALS.flatMap((signal) => [ + ...(signal.configFiles ?? []), + ...(signal.iniSections ?? []).map((ini) => ini.file), + ]), ]), ]; +/** + * pip requirements files beyond the bare `requirements.txt` marker: the + * `requirements-dev.txt` / `dev-requirements.txt` split, a `requirements/` + * directory of them, and pip-tools `.in` sources. Dev tools such as linters + * live in exactly these. + */ +const REQUIREMENTS_PATTERNS: readonly string[] = [ + "**/*requirements*.{txt,in}", + "**/requirements/*.{txt,in}", +]; + async function readFileSafe(path: string): Promise { try { return await readFileNode(path, "utf8"); @@ -316,7 +441,6 @@ interface PackageJson { dependencies?: Record; devDependencies?: Record; peerDependencies?: Record; - eslintConfig?: unknown; } /** Object keys, but only for a plain object (a malformed manifest field that @@ -338,12 +462,13 @@ function nodeDependencyNames(packageJson: PackageJson): Set { } /** - * Parse `pyproject.toml` with a real TOML parser. A malformed file yields - * `undefined` rather than throwing — detect degrades gracefully, losing only - * the pyproject-derived signals (the file's mere presence still marks Python, - * and config files like `ruff.toml` are independent tells). + * Parse a TOML manifest (`pyproject.toml`, `Cargo.toml`) with a real TOML + * parser. A malformed file yields `undefined` rather than throwing — detect + * degrades gracefully, losing only the manifest-derived signals (the file's + * mere presence still marks the language, and config files like `ruff.toml` + * are independent tells). */ -function parsePyproject( +function parseTomlManifest( raw: string | undefined ): Record | undefined { if (raw === undefined) return undefined; @@ -362,17 +487,29 @@ function asRecord(value: unknown): Record | undefined { } /** - * Whether a parsed `pyproject.toml` declares a `[tool.]` table (or a - * nested table under it, e.g. `[tool.ruff.lint]`). A real parser makes this + * Whether a parsed TOML manifest declares the dotted `table` (or a nested table + * under it: `tool.ruff` matches `[tool.ruff.lint]`). A real parser makes this * exact: a similarly-named sibling like `[tool.ruff-lsp]` is a distinct key and * does not match. */ -function pyprojectHasTable( - pyproject: Record | undefined, +function hasTomlTable( + manifest: Record | undefined, table: string ): boolean { - const tool = asRecord(pyproject?.tool); - return tool !== undefined && Object.hasOwn(tool, table); + let node: Record | undefined = manifest; + for (const key of table.split(".")) { + if (node === undefined || !Object.hasOwn(node, key)) return false; + node = asRecord(node[key]); + } + return node !== undefined; +} + +/** Whether an INI file (`setup.cfg`, `tox.ini`) has a `[section]` header. */ +function hasIniSection(raw: string | undefined, section: string): boolean { + if (raw === undefined) return false; + return raw + .split("\n") + .some((line) => line.trim().toLowerCase() === `[${section}]`); } /** Strip a PEP 508 requirement string down to its package name. */ @@ -459,6 +596,81 @@ function pythonDepsFromPyproject( return names; } +/** Gem names declared in a `Gemfile` (`gem "rubocop", require: false`). */ +function rubyDepsFromGemfile(raw: string | undefined): string[] { + if (raw === undefined) return []; + return [...raw.matchAll(/^\s*gem\s+["']([^"']+)["']/gm)].map((match) => + match[1]!.toLowerCase() + ); +} + +/** Package names in a `composer.json`'s `require` and `require-dev`. */ +function phpDepsFromComposer(raw: string | undefined): string[] { + if (raw === undefined) return []; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + const composer = asRecord(parsed); + return [ + ...plainObjectKeys(composer?.require), + ...plainObjectKeys(composer?.["require-dev"]), + ].map((name) => name.toLowerCase()); +} + +/** + * Module paths named in a `go.mod`'s `require` and `tool` directives, single + * or block form, with every `/`-prefix of each, so a signal's + * `github.com/golangci/golangci-lint` matches the + * `github.com/golangci/golangci-lint/v2/cmd/golangci-lint` a tool line names. + */ +function goDepsFromGoModule(raw: string | undefined): string[] { + if (raw === undefined) return []; + const paths: string[] = []; + let block: string | undefined; + for (const line of raw.split("\n")) { + const tokens = line + .replace(/\/\/.*$/, "") + .trim() + .split(/\s+/); + if (tokens[0] === ")") { + block = undefined; + continue; + } + let path: string | undefined; + if (tokens[0] === "require" || tokens[0] === "tool") { + if (tokens[1] === "(") block = tokens[0]; + else path = tokens[1]; + } else if (block !== undefined) { + path = tokens[0]; + } + if (path === undefined || !path.includes("/")) continue; + const segments = path.split("/"); + for (let index = 2; index <= segments.length; index++) { + paths.push(segments.slice(0, index).join("/")); + } + } + return paths; +} + +/** + * Repository paths of the packages a `Package.swift` depends on, lowercased + * and normalized from URL or SSH form (`github.com/realm/swiftlint`). + */ +function swiftDepsFromPackageSwift(raw: string | undefined): string[] { + if (raw === undefined) return []; + return [...raw.matchAll(/\.package\(\s*url:\s*"([^"]+)"/g)].map((match) => + match[1]! + .toLowerCase() + .replace(/^[a-z+]+:\/\//, "") + .replace(/^git@([^:]+):/, "$1/") + .replace(/\.git$/, "") + .replace(/\/$/, "") + ); +} + /** Python dependency names declared in a `requirements.txt`. */ function pythonDepsFromRequirements(raw: string | undefined): string[] { if (raw === undefined) return []; @@ -489,16 +701,15 @@ function shouldExclude(relativePath: string): boolean { return IGNORED_DIRECTORIES.has(segments.at(-1) ?? ""); } -interface NodeManifest { +/** A package file and the dependency names it declares. */ +interface Manifest { path: string; deps: Set; - hasEslintConfigKey: boolean; } -interface PythonManifest { - path: string; - parsed?: Record; - deps: Set; +interface NodeManifest extends Manifest { + /** Top-level keys, for linters configured inside `package.json`. */ + keys: Set; } /** @@ -572,6 +783,14 @@ export async function detectRepository(cwd: string): Promise { cwd: root, exclude: shouldExclude, }); + const requirementsPaths = [ + ...new Set( + globSync([...REQUIREMENTS_PATTERNS], { + cwd: root, + exclude: shouldExclude, + }) + ), + ]; const pathsByBasename = new Map(); for (const relativePath of foundPaths) { @@ -582,11 +801,13 @@ export async function detectRepository(cwd: string): Promise { } const pathsFor = (basename: string): string[] => pathsByBasename.get(basename) ?? []; + const readAt = (relativePath: string): Promise => + readFileSafe(resolve(root, relativePath)); // Node manifests → JS/TS dependency names, per location. const nodeManifests: NodeManifest[] = []; for (const relativePath of pathsFor("package.json")) { - const raw = await readFileSafe(resolve(root, relativePath)); + const raw = await readAt(relativePath); if (raw === undefined) continue; let parsed: PackageJson; try { @@ -597,31 +818,76 @@ export async function detectRepository(cwd: string): Promise { nodeManifests.push({ path: relativePath, deps: nodeDependencyNames(parsed), - hasEslintConfigKey: parsed.eslintConfig !== undefined, + keys: new Set(plainObjectKeys(parsed)), }); } + // TOML manifests, parsed once: Python dependencies and `manifestTables` + // both read them. + const tomlManifests = new Map< + TomlManifest, + Array<{ path: string; parsed?: Record }> + >(); + for (const manifest of ["pyproject.toml", "Cargo.toml"] as const) { + const parsedManifests = []; + for (const relativePath of pathsFor(manifest)) { + parsedManifests.push({ + path: relativePath, + parsed: parseTomlManifest(await readAt(relativePath)), + }); + } + tomlManifests.set(manifest, parsedManifests); + } + // Python manifests → Python dependency names, per location. - const pythonManifests: PythonManifest[] = []; - for (const relativePath of pathsFor("pyproject.toml")) { - const parsed = parsePyproject( - await readFileSafe(resolve(root, relativePath)) - ); + const pythonManifests: Manifest[] = ( + tomlManifests.get("pyproject.toml") ?? [] + ).map(({ path, parsed }) => ({ + path, + deps: new Set(pythonDepsFromPyproject(parsed)), + })); + for (const relativePath of requirementsPaths) { pythonManifests.push({ path: relativePath, - parsed, - deps: new Set(pythonDepsFromPyproject(parsed)), + deps: new Set(pythonDepsFromRequirements(await readAt(relativePath))), }); } - for (const relativePath of pathsFor("requirements.txt")) { - pythonManifests.push({ - path: relativePath, - deps: new Set( - pythonDepsFromRequirements( - await readFileSafe(resolve(root, relativePath)) - ) - ), - }); + + // The other ecosystems' manifests, one reader each. + const manifestsFrom = async ( + basename: string, + read: (raw: string | undefined) => string[] + ): Promise => { + const manifests: Manifest[] = []; + for (const relativePath of pathsFor(basename)) { + manifests.push({ + path: relativePath, + deps: new Set(read(await readAt(relativePath))), + }); + } + return manifests; + }; + const manifestsByLanguage = new Map([ + ["JavaScript", nodeManifests], + ["TypeScript", nodeManifests], + ["Python", pythonManifests], + ["Ruby", await manifestsFrom("Gemfile", rubyDepsFromGemfile)], + ["PHP", await manifestsFrom("composer.json", phpDepsFromComposer)], + ["Go", await manifestsFrom("go.mod", goDepsFromGoModule)], + ["Swift", await manifestsFrom("Package.swift", swiftDepsFromPackageSwift)], + ]); + + // The commands the root CI and hook configs run, read once for every + // signal's `invocations`. + const automation: Array<{ path: string; commands: string[] }> = []; + for (const relativePath of automationFiles(root)) { + const raw = await readAt(relativePath); + if (raw !== undefined) { + automation.push({ + path: relativePath, + commands: automationCommands(relativePath, raw), + }); + } } // Languages: manifest/marker files first, then JS/TS from node manifests. @@ -643,46 +909,56 @@ export async function detectRepository(cwd: string): Promise { languages.add(marker.language); } } + // Any pip requirements file marks Python, not only the bare + // `requirements.txt` marker: a repository may keep only + // `requirements-dev.txt` or a `requirements/` directory. + if (requirementsPaths.length > 0) languages.add("Python"); // Linters: config files unconditionally; dependency evidence from the - // manifests of the linter's own language. A detected linter contributes its - // language(s). + // manifests of the linter's own languages; invocations from the root CI and + // hook configs. A detected linter contributes its language(s). const linters: DetectedLinter[] = []; for (const signal of LINTER_SIGNALS) { const evidence: string[] = []; - const servesPython = signal.languages.includes("Python"); - const servesNode = - signal.languages.includes("JavaScript") || - signal.languages.includes("TypeScript"); for (const configFile of signal.configFiles ?? []) { evidence.push(...pathsFor(configFile)); } - for (const table of signal.pyprojectTables ?? []) { - for (const manifest of pythonManifests) { - if (pyprojectHasTable(manifest.parsed, table)) { - evidence.push(`${manifest.path} [tool.${table}]`); + for (const { manifest, table } of signal.manifestTables ?? []) { + for (const { path, parsed } of tomlManifests.get(manifest) ?? []) { + if (hasTomlTable(parsed, table)) evidence.push(`${path} [${table}]`); + } + } + for (const { file, section } of signal.iniSections ?? []) { + for (const relativePath of pathsFor(file)) { + if (hasIniSection(await readAt(relativePath), section)) { + evidence.push(`${relativePath} [${section}]`); } } } + for (const key of signal.packageJsonKeys ?? []) { + for (const manifest of nodeManifests) { + if (manifest.keys.has(key)) evidence.push(`${manifest.path} (${key})`); + } + } + // JavaScript and TypeScript share `package.json`; read it once. + const manifests = new Set( + signal.languages.flatMap( + (language) => manifestsByLanguage.get(language) ?? [] + ) + ); for (const dep of signal.deps ?? []) { - const manifests = servesPython - ? pythonManifests - : servesNode - ? nodeManifests - : []; for (const manifest of manifests) { if (manifest.deps.has(dep)) { evidence.push(`dependency ${dep} (${manifest.path})`); } } } - if (signal.name === "eslint") { - for (const manifest of nodeManifests) { - if (manifest.hasEslintConfigKey) { - evidence.push(`${manifest.path} (eslintConfig)`); - } - } + for (const { path, commands } of automation) { + const match = (signal.invocations ?? []) + .flatMap((pattern) => commands.map((command) => pattern.exec(command))) + .find((result) => result !== null); + if (match) evidence.push(`${path} (${match[0]})`); } if (evidence.length > 0) { diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index 39d09b98..a1d4be12 100644 --- a/packages/cli/src/schemas/detect.ts +++ b/packages/cli/src/schemas/detect.ts @@ -6,7 +6,7 @@ const detectedLinterSchema = z.object({ evidence: z .array(z.string()) .describe( - "On-disk evidence: config-file paths, a pyproject table marker, or a dependency marker from the language's package file (not all entries are file paths)" + "On-disk evidence: config-file paths, a manifest table or section marker, a dependency marker from the language's package file, or a root CI or hook config that runs the linter (not all entries are file paths)" ), }); diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index c74a5e5f..f1a5913d 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -246,6 +246,228 @@ describe("taskless detect", () => { expect(linterNames(result)).not.toContain("ruff"); }); + describe("a linter installed or run the way its ecosystem defaults to", () => { + // Each fixture is the whole repository: no dedicated config file, which is + // how these tools usually run. + it.each([ + [ + "clippy", + "Cargo.toml [lints.clippy]", + { + "Cargo.toml": + '[package]\nname = "x"\n\n[lints.clippy]\npedantic = "warn"\n', + }, + ], + [ + "clippy", + "Cargo.toml [workspace.lints.clippy]", + { + "Cargo.toml": + '[workspace]\nmembers = []\n\n[workspace.lints.clippy]\nall = "deny"\n', + }, + ], + [ + "clippy", + ".github/workflows/ci.yml (cargo +nightly clippy)", + { + "Cargo.toml": '[package]\nname = "x"\n', + ".github/workflows/ci.yml": + "jobs:\n lint:\n steps:\n - run: cargo +nightly clippy -- -D warnings\n", + }, + ], + [ + "rubocop", + "dependency rubocop (Gemfile)", + { + Gemfile: + 'source "https://rubygems.org"\ngem "rubocop", require: false\n', + }, + ], + [ + "standard", + "dependency standard (Gemfile)", + { Gemfile: "gem 'standard'\n" }, + ], + [ + "phpstan", + "dependency phpstan/phpstan (composer.json)", + { "composer.json": '{"require-dev":{"phpstan/phpstan":"^2"}}' }, + ], + [ + "pint", + "dependency laravel/pint (composer.json)", + { "composer.json": '{"require-dev":{"laravel/pint":"^1"}}' }, + ], + [ + "golangci-lint", + "dependency github.com/golangci/golangci-lint (go.mod)", + { + "go.mod": + "module x\n\ngo 1.24\n\ntool (\n\tgithub.com/golangci/golangci-lint/v2/cmd/golangci-lint\n)\n", + }, + ], + [ + "golangci-lint", + "dependency github.com/golangci/golangci-lint (go.mod)", + { + "go.mod": + "module x\n\ngo 1.24\n\nrequire github.com/golangci/golangci-lint v1.64.8 // indirect\n", + }, + ], + [ + "phpstan", + "dependency phpstan/phpstan (composer.json)", + { "composer.json": '{"require":{"phpstan/phpstan":"^2"}}' }, + ], + [ + "swiftlint", + "dependency github.com/realm/swiftlint (Package.swift)", + { + "Package.swift": + 'dependencies: [\n .package(url: "https://github.com/realm/SwiftLint.git", from: "0.57.0"),\n]\n', + }, + ], + [ + "flake8", + "setup.cfg [flake8]", + { "setup.cfg": "[flake8]\nmax-line-length = 100\n" }, + ], + [ + "flake8", + "tox.ini [flake8]", + { "tox.ini": "[tox]\n\n[flake8]\nextend-ignore = E203\n" }, + ], + [ + "ruff", + "dependency ruff (requirements/dev.txt)", + { "requirements/dev.txt": "ruff==0.6.0\n" }, + ], + [ + "ruff", + "dependency ruff (requirements-dev.txt)", + { "requirements-dev.txt": "-r requirements.txt\nruff\n" }, + ], + [ + "ruff", + ".pre-commit-config.yaml (astral-sh/ruff-pre-commit)", + { + ".pre-commit-config.yaml": + "repos:\n - repo: https://github.com/astral-sh/ruff-pre-commit\n hooks:\n - id: ruff\n", + }, + ], + [ + "prettier", + "package.json (prettier)", + { "package.json": '{"prettier":{"semi":false}}' }, + ], + [ + "stylelint", + "package.json (stylelint)", + { "package.json": '{"stylelint":{}}' }, + ], + [ + "prettier", + "prettier.config.ts", + { "prettier.config.ts": "export default {};\n" }, + ], + [ + "eslint", + "eslint.config.mts", + { "eslint.config.mts": "export default [];\n" }, + ], + ])("detects %s from %s", async (name, expected, files) => { + for (const [path, content] of Object.entries(files)) { + await mkdir(join(cwd, path, ".."), { recursive: true }); + await writeFile(join(cwd, path), content, "utf8"); + } + const result = await detect(cwd); + expect( + result.linters.find((linter) => linter.name === name)?.evidence + ).toContain(expected); + }); + + it("reports Python for a repository whose only manifest is a requirements file", async () => { + await mkdir(join(cwd, "requirements"), { recursive: true }); + await writeFile(join(cwd, "requirements/docs.txt"), "sphinx\n", "utf8"); + const result = await detect(cwd); + expect(result.languages).toContain("Python"); + }); + + it("does not report clippy for a Rust repository that never configures or runs it", async () => { + // clippy ships with the toolchain, so being Rust is not evidence of it. + await writeFile( + join(cwd, "Cargo.toml"), + '[package]\nname = "x"\n', + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("clippy"); + expect(result.languages).toContain("Rust"); + }); + + it.each([ + [ + "a workflow comment and step name", + ".github/workflows/ci.yml", + "# TODO: switch to golangci-lint\njobs:\n test:\n steps:\n - name: golangci-lint is not used here\n run: go test ./...\n", + ], + [ + "a commented-out hook command", + ".husky/pre-commit", + "#!/bin/sh\n# golangci-lint run\ngo vet ./...\n", + ], + [ + "a Jenkinsfile comment", + "Jenkinsfile", + "// golangci-lint runs elsewhere\npipeline { stages { stage('t') { steps { sh 'go test ./...' } } } }\n", + ], + ])("does not read an invocation from %s", async (_case, path, content) => { + await mkdir(join(cwd, path, ".."), { recursive: true }); + await writeFile(join(cwd, path), content, "utf8"); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("golangci-lint"); + }); + + it.each([ + [".gitlab-ci.yml", "lint:\n script:\n - golangci-lint run\n"], + [ + ".circleci/config.yml", + "jobs:\n lint:\n steps:\n - run:\n command: golangci-lint run\n", + ], + [".husky/pre-commit", "#!/bin/sh\ngolangci-lint run\n"], + [".lintstagedrc", '{"*.go": "golangci-lint run"}'], + [ + ".husky/pre-commit", + '#!/bin/sh\necho "checking # lint rules" && golangci-lint run\n', + ], + [ + "Jenkinsfile", + "pipeline { stages { stage('l') { steps { sh \"echo 'see //lint' && golangci-lint run\" } } } }\n", + ], + ])("reads an invocation from the commands in %s", async (path, content) => { + await mkdir(join(cwd, path, ".."), { recursive: true }); + await writeFile(join(cwd, path), content, "utf8"); + const result = await detect(cwd); + expect( + result.linters.find((linter) => linter.name === "golangci-lint") + ?.evidence + ).toContain(`${path} (golangci-lint)`); + }); + + it("does not read an invocation from a CI config below the root", async () => { + await mkdir(join(cwd, "vendor-tool/.github/workflows"), { + recursive: true, + }); + await writeFile( + join(cwd, "vendor-tool/.github/workflows/ci.yml"), + "steps:\n - run: golangci-lint run\n", + "utf8" + ); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("golangci-lint"); + }); + }); + it("detects flake8 from a requirements.txt entry", async () => { await writeFile( join(cwd, "requirements.txt"),