diff --git a/.changeset/detect-roslynator.md b/.changeset/detect-roslynator.md new file mode 100644 index 00000000..e7407aa6 --- /dev/null +++ b/.changeset/detect-roslynator.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless detect` now recognizes C# repositories and the Roslynator, StyleCop and SonarAnalyzer analyzers. C# comes from a `.csproj`. Roslynator comes from its NuGet packages in an MSBuild file, `packages.config` or `dotnet-tools.json`, its keys in `.editorconfig` or `.globalconfig`, a `.roslynatorconfig`, or a CI or hook command that runs it. StyleCop and SonarAnalyzer are each reported as their own linter, from their NuGet package, their config file, or their diagnostic ids. diff --git a/openspec/changes/archive/2026-11-06-detect-roslynator/.openspec.yaml b/openspec/changes/archive/2026-11-06-detect-roslynator/.openspec.yaml new file mode 100644 index 00000000..8a94d769 --- /dev/null +++ b/openspec/changes/archive/2026-11-06-detect-roslynator/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-06 diff --git a/openspec/changes/archive/2026-11-06-detect-roslynator/proposal.md b/openspec/changes/archive/2026-11-06-detect-roslynator/proposal.md new file mode 100644 index 00000000..4a7bb277 --- /dev/null +++ b/openspec/changes/archive/2026-11-06-detect-roslynator/proposal.md @@ -0,0 +1,45 @@ +## Why + +`taskless detect` has no notion of C#. A .NET repository reports no language and no linter, so `init`, `onboard` and the `route` recipe treat it as an unlinted project of unknown language, even though ast-grep rules work there through `CSharp`. Roslynator is a common Roslyn analyzer set, and because it is configured through `.editorconfig` and installed as a NuGet package, none of detect's existing readers can see it. + +## What Changes + +- `detect` reports `C#` among `languages` when a `.csproj` exists anywhere in the walk. +- A new `roslynator` linter signal, per https://josefpihrt.github.io/docs/roslynator/configuration, read from: + - NuGet references to `Roslynator.Analyzers`, `Roslynator.Formatting.Analyzers`, `Roslynator.CodeAnalysis.Analyzers` and `Roslynator.DotNet.Cli`, in any `.csproj`, `.props` or `.targets` file, a `packages.config`, or a `dotnet-tools.json`. + - Roslynator's keys in `.editorconfig` or `.globalconfig`: `roslynator_*`, `dotnet_diagnostic.RCS*`, and `dotnet_analyzer_diagnostic.category-roslynator.*`. + - A committed `.roslynatorconfig`. + - Root CI and hook commands that run or install it. +- `stylecop` and `sonaranalyzer` signals, separate from `roslynator` and from each other, per the StyleCop Analyzers configuration doc and the sonar-dotnet README. Each is read from its NuGet package, its own config file (`stylecop.json`, `.stylecop.json`, `SonarLint.xml`), and its `SA` or `S` diagnostic ids. Neither has CLI invocation evidence: both run inside the build. +- `configKeys` joins `LinterSignal`: keys in a shared config file, which any later analyzer configured through `.editorconfig` can reuse. +- `bin` and `obj` join the directories the walk prunes, as .NET build output. + +Nothing here is **BREAKING**. Pre-1.0, an added linter signal is a `patch`. + +## Non-goals + +- **A combined `roslyn` linter.** People know the tool they installed, not the platform under it, so each analyzer keeps its own name. +- **The SDK's own `CA` rules.** They ship with the toolchain, so they need signals of their own, as clippy did. +- **`customRules` for Roslynator.** It has no custom-rule mechanism of its own; a house analyzer is a Roslyn concern, independent of Roslynator. +- **A `create-legacy-rule` recipe.** Without one the legacy ladder does not apply, so a rule request in a Roslynator repository routes to a Taskless rule, as it does for rubocop and clippy. +- **The telemetry `languageStack` dimension.** It probes exact root paths and a project file has no fixed name, so it does not report C#. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-detect`: new requirements cover C# and Roslynator detection, and separate StyleCop and SonarAnalyzer linters. + +## Impact + +- `packages/cli/src/detect/scan.ts`: NuGet readers, `configKeys`, the signals, and two pruned directories. +- `packages/cli/src/schemas/detect.ts`, `packages/cli/src/agent/detect.md`: the evidence description gains config keys. +- `packages/cli/test/detect.test.ts`: positive and negative cases per signal. + +## Delivery shape + +**Single PR.** One signal and its readers, tests, spec delta and archive fit one reviewable diff and are safe in production on their own. diff --git a/openspec/changes/archive/2026-11-06-detect-roslynator/specs/cli-detect/spec.md b/openspec/changes/archive/2026-11-06-detect-roslynator/specs/cli-detect/spec.md new file mode 100644 index 00000000..641c58b8 --- /dev/null +++ b/openspec/changes/archive/2026-11-06-detect-roslynator/specs/cli-detect/spec.md @@ -0,0 +1,80 @@ +## ADDED Requirements + +### Requirement: Detect recognizes C# and Roslynator + +`detect` SHALL report `C#` among `languages` when a `.csproj` file exists in the scanned tree. A `.sln` file SHALL NOT mark C#, because a solution can hold projects in any language. + +`detect` SHALL report `roslynator` when any of these exists: + +- A NuGet reference to `Roslynator.Analyzers`, `Roslynator.Formatting.Analyzers`, `Roslynator.CodeAnalysis.Analyzers`, or `Roslynator.DotNet.Cli`, matched case-insensitively, as a `PackageReference` or `GlobalPackageReference` in a `.csproj`, `.props`, or `.targets` file, an entry in a `packages.config`, or a tool in a `dotnet-tools.json`. +- A key in `.editorconfig` or `.globalconfig` that begins `roslynator_`, `dotnet_diagnostic.rcs` followed by a digit, or `dotnet_analyzer_diagnostic.category-roslynator.`, matched case-insensitively. +- A `.roslynatorconfig` file. +- A root CI or hook command that runs `roslynator analyze`, `fix`, or `format`, or names `roslynator.dotnet.cli`. + +A key in a comment, a reference in an XML comment, and a `PackageVersion` entry SHALL NOT be evidence: central package management can pin a package no project uses. Files under `bin` or `obj` SHALL NOT be read. A repository being C# SHALL NOT by itself report `roslynator`. + +#### Scenario: A C# repository with no Roslynator + +- **WHEN** a repository has a `.csproj` and nothing references or configures Roslynator +- **THEN** `detect --json` SHALL report `C#` among `languages` and SHALL NOT report `roslynator` + +#### Scenario: Roslynator installed from a shared MSBuild file + +- **WHEN** `Directory.Build.props` has a `PackageReference` to `Roslynator.Analyzers` +- **THEN** `detect --json` SHALL report `roslynator` with a dependency marker naming that file +- **AND** SHALL report `C#` among `languages` + +#### Scenario: Roslynator configured in EditorConfig + +- **WHEN** `.editorconfig` sets `dotnet_diagnostic.RCS1044.severity` +- **THEN** `detect --json` SHALL report `roslynator` with evidence naming the file and the key + +#### Scenario: Roslynator run from CI + +- **WHEN** a root GitHub Actions workflow runs `roslynator analyze` +- **THEN** `detect --json` SHALL report `roslynator` with evidence naming the workflow file and the command + +#### Scenario: A central version pin is not evidence + +- **WHEN** the only mention of Roslynator is a `PackageVersion` in `Directory.Packages.props` +- **THEN** `detect --json` SHALL NOT report `roslynator` + +#### Scenario: A commented-out signal is not evidence + +- **WHEN** the only mention of Roslynator is an XML comment in a project file or a comment in `.editorconfig` +- **THEN** `detect --json` SHALL NOT report `roslynator` + +#### Scenario: Another analyzer's diagnostic id is not evidence + +- **WHEN** `.editorconfig` configures only `CA` or `SA` diagnostic ids +- **THEN** `detect --json` SHALL NOT report `roslynator` + +### Requirement: Detect keeps Roslyn analyzers as separate linters + +`detect` SHALL report each Roslyn analyzer under its own name and SHALL NOT report a combined `roslyn` linter. Being a C# repository SHALL NOT by itself report any of them. + +`detect` SHALL report `stylecop` from a `StyleCop.Analyzers` NuGet reference, a `stylecop.json` or `.stylecop.json` file, or a `dotnet_diagnostic.sa` key followed by a digit in `.editorconfig` or `.globalconfig`. + +`detect` SHALL report `sonaranalyzer` from a `SonarAnalyzer.CSharp` NuGet reference, a `SonarLint.xml` file, or a `dotnet_diagnostic.s` key followed by a digit in `.editorconfig` or `.globalconfig`. + +NuGet references and config keys SHALL be read as they are for `roslynator`. + +#### Scenario: Analyzers sharing one EditorConfig + +- **WHEN** one `.editorconfig` configures `RCS`, `SA` and `S` diagnostic ids +- **THEN** `detect --json` SHALL report `roslynator`, `stylecop` and `sonaranalyzer` as three linters + +#### Scenario: StyleCop configured by its own file + +- **WHEN** a repository has a `stylecop.json` +- **THEN** `detect --json` SHALL report `stylecop` with that path as evidence, and no other analyzer + +#### Scenario: SonarAnalyzer installed as a package + +- **WHEN** a project file has a `PackageReference` to `SonarAnalyzer.CSharp` +- **THEN** `detect --json` SHALL report `sonaranalyzer` with a dependency marker naming that file + +#### Scenario: An SDK analyzer id is not evidence + +- **WHEN** `.editorconfig` configures only `CA` diagnostic ids +- **THEN** `detect --json` SHALL NOT report any of these analyzers diff --git a/openspec/changes/archive/2026-11-06-detect-roslynator/tasks.md b/openspec/changes/archive/2026-11-06-detect-roslynator/tasks.md new file mode 100644 index 00000000..67828972 --- /dev/null +++ b/openspec/changes/archive/2026-11-06-detect-roslynator/tasks.md @@ -0,0 +1,12 @@ +## 1. Implementation + +- [x] 1.1 Read https://josefpihrt.github.io/docs/roslynator/configuration for the config files and keys, and the Roslynator and NuGet package pages for package ids and the CLI command. +- [x] 1.2 Add the NuGet readers, the `C#` language mark, `configKeys`, the `roslynator`, `stylecop` and `sonaranalyzer` signals and the `bin`/`obj` pruning to `packages/cli/src/detect/scan.ts`. +- [x] 1.3 Add positive and negative cases to `packages/cli/test/detect.test.ts`. +- [x] 1.4 Describe config-key evidence in `packages/cli/src/schemas/detect.ts` and the `detect` recipe. +- [x] 1.5 Add `.changeset/detect-roslynator.md` (`patch`). + +## 2. Verification + +- [x] 2.1 `pnpm typecheck`, `pnpm lint`, and `pnpm --filter @taskless/cli test` pass. +- [x] 2.2 `pnpm openspec validate --strict`, then archive. diff --git a/openspec/specs/cli-detect/spec.md b/openspec/specs/cli-detect/spec.md index b7aabbcb..0a635767 100644 --- a/openspec/specs/cli-detect/spec.md +++ b/openspec/specs/cli-detect/spec.md @@ -296,3 +296,82 @@ Optional extensions shipped with the tool, third-party plugin packages, presets, - **WHEN** the repository configures Ruff, however extensively - **THEN** `detect --json` SHALL report a custom-rule count of zero for Ruff + +### Requirement: Detect recognizes C# and Roslynator + +`detect` SHALL report `C#` among `languages` when a `.csproj` file exists in the scanned tree. A `.sln` file SHALL NOT mark C#, because a solution can hold projects in any language. + +`detect` SHALL report `roslynator` when any of these exists: + +- A NuGet reference to `Roslynator.Analyzers`, `Roslynator.Formatting.Analyzers`, `Roslynator.CodeAnalysis.Analyzers`, or `Roslynator.DotNet.Cli`, matched case-insensitively, as a `PackageReference` or `GlobalPackageReference` in a `.csproj`, `.props`, or `.targets` file, an entry in a `packages.config`, or a tool in a `dotnet-tools.json`. +- A key in `.editorconfig` or `.globalconfig` that begins `roslynator_`, `dotnet_diagnostic.rcs` followed by a digit, or `dotnet_analyzer_diagnostic.category-roslynator.`, matched case-insensitively. +- A `.roslynatorconfig` file. +- A root CI or hook command that runs `roslynator analyze`, `fix`, or `format`, or names `roslynator.dotnet.cli`. + +A key in a comment, a reference in an XML comment, and a `PackageVersion` entry SHALL NOT be evidence: central package management can pin a package no project uses. Files under `bin` or `obj` SHALL NOT be read. A repository being C# SHALL NOT by itself report `roslynator`. + +#### Scenario: A C# repository with no Roslynator + +- **WHEN** a repository has a `.csproj` and nothing references or configures Roslynator +- **THEN** `detect --json` SHALL report `C#` among `languages` and SHALL NOT report `roslynator` + +#### Scenario: Roslynator installed from a shared MSBuild file + +- **WHEN** `Directory.Build.props` has a `PackageReference` to `Roslynator.Analyzers` +- **THEN** `detect --json` SHALL report `roslynator` with a dependency marker naming that file +- **AND** SHALL report `C#` among `languages` + +#### Scenario: Roslynator configured in EditorConfig + +- **WHEN** `.editorconfig` sets `dotnet_diagnostic.RCS1044.severity` +- **THEN** `detect --json` SHALL report `roslynator` with evidence naming the file and the key + +#### Scenario: Roslynator run from CI + +- **WHEN** a root GitHub Actions workflow runs `roslynator analyze` +- **THEN** `detect --json` SHALL report `roslynator` with evidence naming the workflow file and the command + +#### Scenario: A central version pin is not evidence + +- **WHEN** the only mention of Roslynator is a `PackageVersion` in `Directory.Packages.props` +- **THEN** `detect --json` SHALL NOT report `roslynator` + +#### Scenario: A commented-out signal is not evidence + +- **WHEN** the only mention of Roslynator is an XML comment in a project file or a comment in `.editorconfig` +- **THEN** `detect --json` SHALL NOT report `roslynator` + +#### Scenario: Another analyzer's diagnostic id is not evidence + +- **WHEN** `.editorconfig` configures only `CA` or `SA` diagnostic ids +- **THEN** `detect --json` SHALL NOT report `roslynator` + +### Requirement: Detect keeps Roslyn analyzers as separate linters + +`detect` SHALL report each Roslyn analyzer under its own name and SHALL NOT report a combined `roslyn` linter. Being a C# repository SHALL NOT by itself report any of them. + +`detect` SHALL report `stylecop` from a `StyleCop.Analyzers` NuGet reference, a `stylecop.json` or `.stylecop.json` file, or a `dotnet_diagnostic.sa` key followed by a digit in `.editorconfig` or `.globalconfig`. + +`detect` SHALL report `sonaranalyzer` from a `SonarAnalyzer.CSharp` NuGet reference, a `SonarLint.xml` file, or a `dotnet_diagnostic.s` key followed by a digit in `.editorconfig` or `.globalconfig`. + +NuGet references and config keys SHALL be read as they are for `roslynator`. + +#### Scenario: Analyzers sharing one EditorConfig + +- **WHEN** one `.editorconfig` configures `RCS`, `SA` and `S` diagnostic ids +- **THEN** `detect --json` SHALL report `roslynator`, `stylecop` and `sonaranalyzer` as three linters + +#### Scenario: StyleCop configured by its own file + +- **WHEN** a repository has a `stylecop.json` +- **THEN** `detect --json` SHALL report `stylecop` with that path as evidence, and no other analyzer + +#### Scenario: SonarAnalyzer installed as a package + +- **WHEN** a project file has a `PackageReference` to `SonarAnalyzer.CSharp` +- **THEN** `detect --json` SHALL report `sonaranalyzer` with a dependency marker naming that file + +#### Scenario: An SDK analyzer id is not evidence + +- **WHEN** `.editorconfig` configures only `CA` diagnostic ids +- **THEN** `detect --json` SHALL NOT report any of these analyzers diff --git a/packages/cli/src/agent/detect.md b/packages/cli/src/agent/detect.md index 8126d7f4..05adaf66 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 v4) +# Topic: detect (CLI v%(CLI_VERSION)s / topic v5) %(ONE_COMMAND_PER_CALL)s @@ -54,6 +54,7 @@ routing flow reads `detect` to decide where a new rule should live. - `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 key in a shared config (`.editorconfig (roslynator_use_var)`), 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 diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 27ffe9e0..1430ae8f 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -20,8 +20,8 @@ import { export interface DetectedLinter { name: string; /** - * 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, + * On-disk evidence for this linter: a config-file path, a manifest table, + * INI section, or config key 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 @@ -81,6 +81,10 @@ const IGNORED_DIRECTORIES: ReadonlySet = new Set([ ".pytest_cache", ".tox", ".gradle", + // .NET build output. `obj` holds restore artifacts generated from the + // project files, which are not the repository's own declarations. + "bin", + "obj", // Taskless's own state. It holds an `sgconfig.yml` and a `.vale.ini` that // Taskless writes for itself, which are not the repository's linters: an // unpruned walk reports every installed repository as already running @@ -104,7 +108,7 @@ const MAX_DIRECTORY_DEPTH = 8; * is read from the right package file: a node dependency lives in * `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 + * a Swift one in `Package.swift`, a C# one in an MSBuild project file. 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 @@ -124,6 +128,12 @@ interface LinterSignal { 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 }>; + /** + * Keys in a shared config file the linter reads, e.g. `roslynator_*` in + * `.editorconfig`. Each pattern matches from the start of a line, so a + * commented-out key is not evidence, and the match is reported. + */ + configKeys?: Array<{ file: string; key: RegExp }>; /** Top-level `package.json` keys the linter reads its config from. */ packageJsonKeys?: string[]; /** @@ -144,6 +154,24 @@ interface LinterSignal { /** TOML manifests parsed for `manifestTables`. */ type TomlManifest = "pyproject.toml" | "Cargo.toml"; +/** + * The keys only Roslynator reads: its own options and refactorings, the `RCS` + * analyzer ids, and its severity category. EditorConfig keys are + * case-insensitive, and `RCS1234` is usually written in capitals. + */ +const ROSLYNATOR_CONFIG_KEY = + /^[ \t]*(?:roslynator_|dotnet_diagnostic\.rcs\d|dotnet_analyzer_diagnostic\.category-roslynator\.)[^\s=]*/im; + +/** + * Roslyn analyzers share their config files, so each is told apart by its own + * diagnostic ids, as `dotnet_diagnostic..severity`. Each analyzer stays its + * own linter rather than one `roslyn` entry: people know the tool they + * installed, not the platform under it. + */ +function analyzerConfigKeys(key: RegExp): LinterSignal["configKeys"] { + return [".editorconfig", ".globalconfig"].map((file) => ({ file, key })); +} + const LINTER_SIGNALS: readonly LinterSignal[] = [ { name: "eslint", @@ -370,6 +398,38 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ invocations: [/\bswiftlint\b/], }, { name: "checkstyle", languages: ["Java"], configFiles: ["checkstyle.xml"] }, + { + // Configured almost entirely through `.editorconfig`, which any language + // may have, so the keys are the signal rather than the file. The + // `.roslynatorconfig` it documents is user-wide, but one committed to a + // repository still says what the repository uses. Being a C# repository + // is NOT evidence, and neither are the analyzers the .NET SDK ships. + name: "roslynator", + languages: ["C#"], + configFiles: [".roslynatorconfig"], + configKeys: analyzerConfigKeys(ROSLYNATOR_CONFIG_KEY), + deps: [ + "roslynator.analyzers", + "roslynator.formatting.analyzers", + "roslynator.codeanalysis.analyzers", + "roslynator.dotnet.cli", + ], + invocations: [/\broslynator(?:\.dotnet\.cli|\s+(?:analyze|fix|format))\b/i], + }, + { + name: "stylecop", + languages: ["C#"], + configFiles: ["stylecop.json", ".stylecop.json"], + configKeys: analyzerConfigKeys(/^[ \t]*dotnet_diagnostic\.sa\d[^\s=]*/im), + deps: ["stylecop.analyzers"], + }, + { + name: "sonaranalyzer", + languages: ["C#"], + configFiles: ["SonarLint.xml"], + configKeys: analyzerConfigKeys(/^[ \t]*dotnet_diagnostic\.s\d[^\s=]*/im), + deps: ["sonaranalyzer.csharp"], + }, // Polyglot and prose linters: neither says anything about which languages // the repository is written in, so they contribute none. { @@ -415,15 +475,37 @@ export const LANGUAGE_MARKERS: ReadonlyArray<{ { language: "Swift", files: ["Package.swift"] }, ]; +/** + * MSBuild files that can declare a NuGet package. A shared `.props` or + * `.targets` is how a solution applies analyzers to every project. + * + * A pattern rather than a `LANGUAGE_MARKERS` entry because a project file has + * no fixed name, and the telemetry probe built on that constant checks exact + * paths. `.sln` is not a C# marker: it can hold any language's projects. + */ +const MSBUILD_PATTERN = "**/*.{csproj,props,targets}"; + +/** + * .NET local tool manifests. `dotnet new tool-manifest` writes the one under + * `.config/`, and the walk does not enter a dot-directory unless a pattern + * names it. + */ +const TOOL_MANIFEST_PATTERNS: readonly string[] = [ + "**/dotnet-tools.json", + "**/.config/dotnet-tools.json", +]; + /** Every basename the walk needs to find, deduped for a single glob pass. */ const DISCOVERABLE_FILES: readonly string[] = [ ...new Set([ "package.json", "tsconfig.json", + "packages.config", ...LANGUAGE_MARKERS.flatMap((marker) => marker.files), ...LINTER_SIGNALS.flatMap((signal) => [ ...(signal.configFiles ?? []), ...(signal.iniSections ?? []).map((ini) => ini.file), + ...(signal.configKeys ?? []).map((config) => config.file), ]), ]), ]; @@ -681,6 +763,44 @@ function swiftDepsFromPackageSwift(raw: string | undefined): string[] { ); } +/** + * NuGet package ids an MSBuild file references, lowercased because NuGet ids + * are case-insensitive. Comments are removed first, since disabling a package + * by commenting it out is common. A `` is deliberately not + * read: central package management pins a version there whether or not any + * project uses the package, and the project that does has a `PackageReference`. + */ +function nugetDepsFromMsbuild(raw: string | undefined): string[] { + if (raw === undefined) return []; + const source = raw.replaceAll(//g, ""); + const references = source.matchAll( + /<(?:Global)?PackageReference\b[^>]*?\bInclude\s*=\s*["']([^"']+)["']/gi + ); + return [...references].flatMap((match) => + match[1]!.split(";").map((id) => id.trim().toLowerCase()) + ); +} + +/** NuGet package ids from a legacy `packages.config`. */ +function nugetDepsFromPackagesConfig(raw: string | undefined): string[] { + if (raw === undefined) return []; + const source = raw.replaceAll(//g, ""); + return [ + ...source.matchAll(/]*?\bid\s*=\s*["']([^"']+)["']/gi), + ].map((match) => match[1]!.toLowerCase()); +} + +/** Tool package ids from a .NET local tool manifest (`dotnet-tools.json`). */ +function nugetDepsFromToolManifest(raw: string | undefined): string[] { + if (raw === undefined) return []; + try { + const parsed = JSON.parse(raw) as { tools?: unknown }; + return plainObjectKeys(parsed.tools).map((id) => id.toLowerCase()); + } catch { + return []; + } +} + /** Python dependency names declared in a `requirements.txt`. */ function pythonDepsFromRequirements(raw: string | undefined): string[] { if (raw === undefined) return []; @@ -789,6 +909,14 @@ export async function detectRepository(cwd: string): Promise { cwd: root, exclude: shouldExclude, }); + const msbuildPaths = globSync(MSBUILD_PATTERN, { + cwd: root, + exclude: shouldExclude, + }); + const toolManifestPaths = globSync([...TOOL_MANIFEST_PATTERNS], { + cwd: root, + exclude: shouldExclude, + }); const requirementsPaths = [ ...new Set( globSync([...REQUIREMENTS_PATTERNS], { @@ -873,6 +1001,22 @@ export async function detectRepository(cwd: string): Promise { } return manifests; }; + const nugetManifests = await manifestsFrom( + "packages.config", + nugetDepsFromPackagesConfig + ); + for (const relativePath of msbuildPaths) { + nugetManifests.push({ + path: relativePath, + deps: new Set(nugetDepsFromMsbuild(await readAt(relativePath))), + }); + } + for (const relativePath of new Set(toolManifestPaths)) { + nugetManifests.push({ + path: relativePath, + deps: new Set(nugetDepsFromToolManifest(await readAt(relativePath))), + }); + } const manifestsByLanguage = new Map([ ["JavaScript", nodeManifests], ["TypeScript", nodeManifests], @@ -881,6 +1025,7 @@ export async function detectRepository(cwd: string): Promise { ["PHP", await manifestsFrom("composer.json", phpDepsFromComposer)], ["Go", await manifestsFrom("go.mod", goDepsFromGoModule)], ["Swift", await manifestsFrom("Package.swift", swiftDepsFromPackageSwift)], + ["C#", nugetManifests], ]); // The commands the root CI and hook configs run, read once for every @@ -915,6 +1060,9 @@ export async function detectRepository(cwd: string): Promise { languages.add(marker.language); } } + if (msbuildPaths.some((path) => path.endsWith(".csproj"))) { + languages.add("C#"); + } // 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. @@ -942,6 +1090,12 @@ export async function detectRepository(cwd: string): Promise { } } } + for (const { file, key } of signal.configKeys ?? []) { + for (const relativePath of pathsFor(file)) { + const match = key.exec((await readAt(relativePath)) ?? ""); + if (match) evidence.push(`${relativePath} (${match[0].trim()})`); + } + } for (const key of signal.packageJsonKeys ?? []) { for (const manifest of nodeManifests) { if (manifest.keys.has(key)) evidence.push(`${manifest.path} (${key})`); diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index 1909b375..8eecc272 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 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)" + "On-disk evidence: config-file paths, a manifest table, section, or config key 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)" ), customRules: z .object({ diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index d86ac4b2..2044fb5b 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -81,6 +81,20 @@ function customRules(result: DetectJson, name: string) { return result.linters.find((l) => l.name === name)?.customRules; } +function roslynatorEvidence(result: DetectJson): string[] | undefined { + return result.linters.find((l) => l.name === "roslynator")?.evidence; +} + +async function writeNested( + root: string, + path: string, + content: string +): Promise { + const target = join(root, path); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, content, "utf8"); +} + describe("taskless detect", () => { let cwd: string; @@ -666,6 +680,194 @@ describe("taskless detect", () => { expect(result.linters).toEqual([]); }); + describe("Roslynator in a C# repository", () => { + const analyzerReference = + '\n \n \n all\n \n \n\n'; + + it("reports C# from a project file, and never Roslynator from that alone", async () => { + await writeNested( + cwd, + "src/App/App.csproj", + '\n' + ); + const result = await detect(cwd); + expect(result.languages).toContain("C#"); + expect(linterNames(result)).not.toContain("roslynator"); + }); + + it.each([ + [ + "a project PackageReference", + "src/App/App.csproj", + analyzerReference, + "dependency roslynator.analyzers (src/App/App.csproj)", + ], + [ + "a shared Directory.Build.props", + "Directory.Build.props", + analyzerReference, + "dependency roslynator.analyzers (Directory.Build.props)", + ], + [ + "a GlobalPackageReference in lowercase", + "Directory.Packages.props", + '\n', + "dependency roslynator.formatting.analyzers (Directory.Packages.props)", + ], + [ + "a legacy packages.config", + "src/App/packages.config", + '\n \n\n', + "dependency roslynator.analyzers (src/App/packages.config)", + ], + [ + "a local tool manifest", + ".config/dotnet-tools.json", + '{"version":1,"isRoot":true,"tools":{"roslynator.dotnet.cli":{"version":"0.10.0","commands":["roslynator"]}}}\n', + "dependency roslynator.dotnet.cli (.config/dotnet-tools.json)", + ], + [ + "an option key in .editorconfig", + ".editorconfig", + "root = true\n\n[*.cs]\nroslynator_use_var = always\n", + ".editorconfig (roslynator_use_var)", + ], + [ + "an analyzer id, capitalized as usually written", + "src/.editorconfig", + "[*.cs]\ndotnet_diagnostic.RCS1044.severity = warning\n", + "src/.editorconfig (dotnet_diagnostic.RCS1044.severity)", + ], + [ + "the severity category", + ".globalconfig", + "is_global = true\ndotnet_analyzer_diagnostic.category-roslynator.severity = warning\n", + ".globalconfig (dotnet_analyzer_diagnostic.category-roslynator.severity)", + ], + [ + "a committed .roslynatorconfig", + ".roslynatorconfig", + "is_global = true\nroslynator_analyzers.enabled_by_default = true\n", + ".roslynatorconfig", + ], + [ + "a CI step that runs it", + ".github/workflows/ci.yml", + "jobs:\n lint:\n steps:\n - run: dotnet tool run roslynator analyze App.sln\n", + ".github/workflows/ci.yml (roslynator analyze)", + ], + [ + "a CI step that installs it", + ".github/workflows/ci.yml", + "jobs:\n lint:\n steps:\n - run: dotnet tool install -g Roslynator.DotNet.Cli\n", + ".github/workflows/ci.yml (Roslynator.DotNet.Cli)", + ], + ])("reports it from %s", async (_label, path, content, expected) => { + await writeNested(cwd, path, content); + const result = await detect(cwd); + expect(roslynatorEvidence(result)).toContain(expected); + expect(result.languages).toContain("C#"); + }); + + it.each([ + [ + "a commented-out reference", + "App.csproj", + '\n', + ], + [ + "a version pin no project references", + "Directory.Packages.props", + '\n', + ], + [ + "a commented-out key", + ".editorconfig", + "[*.cs]\n# roslynator_use_var = always\n; dotnet_diagnostic.RCS1044.severity = none\n", + ], + [ + "another analyzer's diagnostic id", + ".editorconfig", + "[*.cs]\ndotnet_diagnostic.CA1000.severity = none\ndotnet_diagnostic.SA1000.severity = none\n", + ], + [ + "restore output under obj", + "src/App/obj/Generated.props", + analyzerReference, + ], + ["a malformed tool manifest", "dotnet-tools.json", "{ not json\n"], + ])("does not report it from %s", async (_label, path, content) => { + await writeNested(cwd, path, content); + const result = await detect(cwd); + expect(linterNames(result)).not.toContain("roslynator"); + }); + }); + + describe("other Roslyn analyzers, each its own linter", () => { + it.each([ + [ + "stylecop", + "App.csproj", + '\n', + "dependency stylecop.analyzers (App.csproj)", + ], + ["stylecop", "src/stylecop.json", "{}\n", "src/stylecop.json"], + ["stylecop", ".stylecop.json", "{}\n", ".stylecop.json"], + [ + "stylecop", + ".editorconfig", + "[*.cs]\ndotnet_diagnostic.SA1200.severity = none\n", + ".editorconfig (dotnet_diagnostic.SA1200.severity)", + ], + [ + "sonaranalyzer", + "App.csproj", + '\n', + "dependency sonaranalyzer.csharp (App.csproj)", + ], + [ + "sonaranalyzer", + "SonarLint.xml", + "\n", + "SonarLint.xml", + ], + [ + "sonaranalyzer", + ".globalconfig", + "is_global = true\ndotnet_diagnostic.S107.severity = warning\n", + ".globalconfig (dotnet_diagnostic.S107.severity)", + ], + ])("reports only %s from %s", async (name, path, content, expected) => { + await writeNested(cwd, path, content); + const result = await detect(cwd); + expect(linterNames(result)).toEqual([name]); + expect(result.linters[0]?.evidence).toContain(expected); + expect(result.languages).toContain("C#"); + }); + + it("reports each analyzer from one shared .editorconfig", async () => { + await writeNested( + cwd, + ".editorconfig", + "[*.cs]\ndotnet_diagnostic.RCS1044.severity = warning\ndotnet_diagnostic.SA1200.severity = none\ndotnet_diagnostic.S107.severity = none\n" + ); + expect(linterNames(await detect(cwd))).toEqual([ + "roslynator", + "stylecop", + "sonaranalyzer", + ]); + }); + + it("does not read an SDK analyzer id as any of them", async () => { + await writeNested( + cwd, + ".editorconfig", + "[*.cs]\ndotnet_diagnostic.CA1000.severity = none\n" + ); + expect(linterNames(await detect(cwd))).toEqual([]); + }); + }); + describe("custom-rule counts", () => { it("counts the rules in a house ESLint rule directory", async () => { await writeFile(join(cwd, "eslint.config.js"), "export default [];");