From 608cc534cea9a22385180b751ac01751c21fd91c Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 11 Nov 2026 18:28:17 -0800 Subject: [PATCH 1/2] feat(detect): recognize Dart and in-tree Roslyn analyzer projects Closes #680 --- .changeset/detect-dart-roslyn-projects.md | 5 + .../.openspec.yaml | 2 + .../proposal.md | 43 ++++ .../specs/cli-detect/spec.md | 66 ++++++ .../tasks.md | 13 ++ openspec/specs/cli-detect/spec.md | 65 ++++++ packages/cli/src/agent/detect.md | 12 +- packages/cli/src/detect/custom-rules.ts | 101 +++++++++ packages/cli/src/detect/scan.ts | 138 ++++++++++++- packages/cli/src/schemas/detect.ts | 4 +- packages/cli/test/detect.test.ts | 191 ++++++++++++++++++ 11 files changed, 632 insertions(+), 8 deletions(-) create mode 100644 .changeset/detect-dart-roslyn-projects.md create mode 100644 openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/.openspec.yaml create mode 100644 openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/proposal.md create mode 100644 openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/specs/cli-detect/spec.md create mode 100644 openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/tasks.md diff --git a/.changeset/detect-dart-roslyn-projects.md b/.changeset/detect-dart-roslyn-projects.md new file mode 100644 index 00000000..e08dff8f --- /dev/null +++ b/.changeset/detect-dart-roslyn-projects.md @@ -0,0 +1,5 @@ +--- +"@taskless/cli": patch +--- + +`taskless detect` now recognizes Dart and Flutter repositories and a C# repository's own Roslyn analyzers. Dart comes from a `pubspec.yaml`, and the new `dart-analyzer` linter from an `analysis_options.yaml`, a `lints`, `flutter_lints` or `very_good_analysis` package, or a CI or hook command that runs `dart analyze` or `flutter analyze`. The new `custom-analyzer` linter comes from a `ProjectReference` with `OutputItemType="Analyzer"` in a project file, `.props` or `.targets`; its evidence names the referenced project, and its `customRules` counts the `[DiagnosticAnalyzer]` classes in it. diff --git a/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/.openspec.yaml b/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/.openspec.yaml new file mode 100644 index 00000000..2a7773d6 --- /dev/null +++ b/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-11 diff --git a/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/proposal.md b/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/proposal.md new file mode 100644 index 00000000..e0580ce3 --- /dev/null +++ b/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/proposal.md @@ -0,0 +1,43 @@ +## Why + +`taskless detect --json` misses two setups that agents in the CLI PMF survey had to find by hand (#680). A Flutter repository reported only Ruby and Java, from the `Gemfile` and `build.gradle` in its `ios/` and `android/` platform folders, and never Dart or the Dart analyzer, although Dart is an ast-grep language route can send rules to. A C# solution whose real lint rules live in its own Roslyn analyzer project, wired in with ``, reported no C# linter, so route could not apply the legacy ladder and agents risked duplicating rules the repository already enforces. + +## What Changes + +- `detect` reports `Dart` among `languages` when a `pubspec.yaml` exists anywhere in the walk. +- A new `dart-analyzer` linter signal, read from an `analysis_options.yaml`, a `lints`, `flutter_lints` or `very_good_analysis` package in a `pubspec.yaml`'s `dependencies` or `dev_dependencies`, or a root CI or hook command that runs `dart analyze` or `flutter analyze`. +- A new `custom-analyzer` linter signal for C#: a `ProjectReference` in a `.csproj`, `.props` or `.targets` file whose `OutputItemType` is `Analyzer`, as an attribute or child metadata. The evidence names the referenced project and the file that references it. +- `custom-analyzer` reports `customRules`: the `[DiagnosticAnalyzer]` attributes in the `.cs` files under the referenced project's directory, outside `bin` and `obj`. A reference whose project cannot be found makes the count `"unknown"`. +- `.dart_tool` joins the directories the walk prunes. +- The `detect` recipe (topic v6) and the JSON schema's descriptions mention the new evidence. + +Nothing here is **BREAKING**. Pre-1.0, an added linter signal is a `patch`. + +## Non-goals + +- **Suppressing Ruby and Java in a Flutter app.** The `ios/` and `android/` platform folders really do hold a `Gemfile` and Gradle build; detect reports what is on disk. +- **Evaluating MSBuild.** An `Include` built from a property other than `$(MSBuildThisFileDirectory)` is matched to a scanned project by file name, and only when exactly one project has that name. +- **Custom lint packages for Dart** (`custom_lint`, analyzer plugins). They are a separate mechanism, so `dart-analyzer` reports no `customRules`. +- **A `create-legacy-rule` recipe** for either linter. Both appear in route's list of detected linters with no guide of their own. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `cli-detect`: new requirements cover Dart and the Dart analyzer, and a C# repository's own analyzer project. + +## Impact + +- `packages/cli/src/detect/scan.ts`: the Dart marker, `pubspec.yaml` reader, both signals, the analyzer-reference reader and its path resolution. +- `packages/cli/src/detect/custom-rules.ts`: the analyzer count. +- `packages/cli/src/schemas/detect.ts`, `packages/cli/src/agent/detect.md`: descriptions of the new evidence. +- `packages/cli/test/detect.test.ts`: positive and negative fixtures for both. +- Telemetry's root-only `languageStack` reads `LANGUAGE_MARKERS`, so it reports `Dart` for a root `pubspec.yaml`. + +## Delivery shape + +**Single PR.** Two signals with their 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-11-detect-dart-roslyn-projects/specs/cli-detect/spec.md b/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/specs/cli-detect/spec.md new file mode 100644 index 00000000..1eed0e43 --- /dev/null +++ b/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/specs/cli-detect/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: Detect recognizes Dart and the Dart analyzer + +`detect` SHALL report `Dart` among `languages` when a `pubspec.yaml` file exists in the scanned tree. + +`detect` SHALL report `dart-analyzer` when any of these exists: + +- An `analysis_options.yaml` file. +- A `lints`, `flutter_lints`, or `very_good_analysis` package under `dependencies` or `dev_dependencies` in a `pubspec.yaml`. +- A root CI or hook command that runs `dart analyze` or `flutter analyze`. + +A package named only in a YAML comment SHALL NOT be evidence, and a malformed `pubspec.yaml` SHALL contribute no dependencies without failing the scan. A repository being Dart SHALL NOT by itself report `dart-analyzer`, because the analyzer ships with the SDK. Files under `.dart_tool` SHALL NOT be read. + +#### Scenario: A Dart repository with no analyzer configuration + +- **WHEN** a repository has a `pubspec.yaml` and nothing configures, installs a lint set for, or runs the analyzer +- **THEN** `detect --json` SHALL report `Dart` among `languages` and SHALL NOT report `dart-analyzer` + +#### Scenario: A Flutter app + +- **WHEN** a repository has a `pubspec.yaml` with `flutter_lints` in `dev_dependencies`, an `analysis_options.yaml`, and `ios/Gemfile` and `android/build.gradle` platform folders +- **THEN** `detect --json` SHALL report `Dart` among `languages` +- **AND** SHALL report `dart-analyzer` with `analysis_options.yaml` and a dependency marker naming `pubspec.yaml` as evidence + +#### Scenario: The analyzer run from CI + +- **WHEN** a root GitHub Actions workflow runs `flutter analyze` +- **THEN** `detect --json` SHALL report `dart-analyzer` with evidence naming the workflow file and the command + +#### Scenario: A commented-out lint set is not evidence + +- **WHEN** the only mention of a lint set is a comment in `pubspec.yaml` +- **THEN** `detect --json` SHALL NOT report `dart-analyzer` + +### Requirement: Detect recognizes a C# repository's own analyzer project + +`detect` SHALL report `custom-analyzer` when a `.csproj`, `.props`, or `.targets` file has a `ProjectReference` whose `OutputItemType` is `Analyzer`, matched case-insensitively, whether as an attribute or as child metadata. Each such reference SHALL add evidence naming the referenced project and the file that references it, as `analyzer project ()`. + +`` SHALL be the scanned `.csproj` the reference names when it can be told without evaluating MSBuild: a path in a `.csproj` relative to that file, a `$(MSBuildThisFileDirectory)` path relative to the file that holds it, or otherwise the one scanned project with the same file name. When none of these finds exactly one project, `` SHALL be the `Include` as written. + +`custom-analyzer` SHALL report `customRules`, counting each `DiagnosticAnalyzer` attribute in the `.cs` files under each found project's directory once, outside `bin`, `obj`, and comments. A reference whose project is not found SHALL make the count `"unknown"`, unless the found projects already reach the custom-rule threshold. + +A `ProjectReference` without `OutputItemType="Analyzer"`, and one inside an XML comment, SHALL NOT be evidence. + +#### Scenario: An analyzer project wired in from Directory.Build.props + +- **WHEN** `Directory.Build.props` has `` and that project holds two `[DiagnosticAnalyzer]` classes +- **THEN** `detect --json` SHALL report `custom-analyzer` with the evidence `analyzer project analyzers/House.Analyzers/House.Analyzers.csproj (Directory.Build.props)` +- **AND** SHALL report a custom-rule count of two with `analyzers/House.Analyzers` as its evidence +- **AND** SHALL report `C#` among `languages` + +#### Scenario: A project referenced by several projects is counted once + +- **WHEN** two project files each reference the same analyzer project +- **THEN** `detect --json` SHALL report one evidence entry per referencing file and count that project's analyzers once + +#### Scenario: An analyzer project that cannot be found + +- **WHEN** the only analyzer reference is built from a property and no scanned project has its file name +- **THEN** `detect --json` SHALL report `custom-analyzer` with the `Include` as written in its evidence and a custom-rule count of `"unknown"` + +#### Scenario: An ordinary project reference is not evidence + +- **WHEN** a project file references another project without `OutputItemType="Analyzer"`, or only inside an XML comment +- **THEN** `detect --json` SHALL NOT report `custom-analyzer` diff --git a/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/tasks.md b/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/tasks.md new file mode 100644 index 00000000..1fca4e0d --- /dev/null +++ b/openspec/changes/archive/2026-11-11-detect-dart-roslyn-projects/tasks.md @@ -0,0 +1,13 @@ +## 1. Implementation + +- [x] 1.1 Add the `Dart` language marker, the `pubspec.yaml` reader, the `dart-analyzer` signal and `.dart_tool` pruning to `packages/cli/src/detect/scan.ts`. +- [x] 1.2 Read analyzer `ProjectReference`s in the MSBuild pass, resolve them to scanned projects, and add the `custom-analyzer` signal. +- [x] 1.3 Count `[DiagnosticAnalyzer]` classes for `custom-analyzer` in `packages/cli/src/detect/custom-rules.ts`. +- [x] 1.4 Add positive and negative fixtures to `packages/cli/test/detect.test.ts`. +- [x] 1.5 Describe the new evidence in `packages/cli/src/schemas/detect.ts` and the `detect` recipe. +- [x] 1.6 Add `.changeset/detect-dart-roslyn-projects.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 c53e9b75..05e0da85 100644 --- a/openspec/specs/cli-detect/spec.md +++ b/openspec/specs/cli-detect/spec.md @@ -392,3 +392,68 @@ NuGet references and config keys SHALL be read as they are for `roslynator`. - **WHEN** `.editorconfig` configures only `CA` diagnostic ids - **THEN** `detect --json` SHALL NOT report any of these analyzers + +### Requirement: Detect recognizes Dart and the Dart analyzer + +`detect` SHALL report `Dart` among `languages` when a `pubspec.yaml` file exists in the scanned tree. + +`detect` SHALL report `dart-analyzer` when any of these exists: + +- An `analysis_options.yaml` file. +- A `lints`, `flutter_lints`, or `very_good_analysis` package under `dependencies` or `dev_dependencies` in a `pubspec.yaml`. +- A root CI or hook command that runs `dart analyze` or `flutter analyze`. + +A package named only in a YAML comment SHALL NOT be evidence, and a malformed `pubspec.yaml` SHALL contribute no dependencies without failing the scan. A repository being Dart SHALL NOT by itself report `dart-analyzer`, because the analyzer ships with the SDK. Files under `.dart_tool` SHALL NOT be read. + +#### Scenario: A Dart repository with no analyzer configuration + +- **WHEN** a repository has a `pubspec.yaml` and nothing configures, installs a lint set for, or runs the analyzer +- **THEN** `detect --json` SHALL report `Dart` among `languages` and SHALL NOT report `dart-analyzer` + +#### Scenario: A Flutter app + +- **WHEN** a repository has a `pubspec.yaml` with `flutter_lints` in `dev_dependencies`, an `analysis_options.yaml`, and `ios/Gemfile` and `android/build.gradle` platform folders +- **THEN** `detect --json` SHALL report `Dart` among `languages` +- **AND** SHALL report `dart-analyzer` with `analysis_options.yaml` and a dependency marker naming `pubspec.yaml` as evidence + +#### Scenario: The analyzer run from CI + +- **WHEN** a root GitHub Actions workflow runs `flutter analyze` +- **THEN** `detect --json` SHALL report `dart-analyzer` with evidence naming the workflow file and the command + +#### Scenario: A commented-out lint set is not evidence + +- **WHEN** the only mention of a lint set is a comment in `pubspec.yaml` +- **THEN** `detect --json` SHALL NOT report `dart-analyzer` + +### Requirement: Detect recognizes a C# repository's own analyzer project + +`detect` SHALL report `custom-analyzer` when a `.csproj`, `.props`, or `.targets` file has a `ProjectReference` whose `OutputItemType` is `Analyzer`, matched case-insensitively, whether as an attribute or as child metadata. Each such reference SHALL add evidence naming the referenced project and the file that references it, as `analyzer project ()`. + +`` SHALL be the scanned `.csproj` the reference names when it can be told without evaluating MSBuild: a path in a `.csproj` relative to that file, a `$(MSBuildThisFileDirectory)` path relative to the file that holds it, or otherwise the one scanned project with the same file name. When none of these finds exactly one project, `` SHALL be the `Include` as written. + +`custom-analyzer` SHALL report `customRules`, counting each `DiagnosticAnalyzer` attribute in the `.cs` files under each found project's directory once, outside `bin`, `obj`, and comments. A reference whose project is not found SHALL make the count `"unknown"`, unless the found projects already reach the custom-rule threshold. + +A `ProjectReference` without `OutputItemType="Analyzer"`, and one inside an XML comment, SHALL NOT be evidence. + +#### Scenario: An analyzer project wired in from Directory.Build.props + +- **WHEN** `Directory.Build.props` has `` and that project holds two `[DiagnosticAnalyzer]` classes +- **THEN** `detect --json` SHALL report `custom-analyzer` with the evidence `analyzer project analyzers/House.Analyzers/House.Analyzers.csproj (Directory.Build.props)` +- **AND** SHALL report a custom-rule count of two with `analyzers/House.Analyzers` as its evidence +- **AND** SHALL report `C#` among `languages` + +#### Scenario: A project referenced by several projects is counted once + +- **WHEN** two project files each reference the same analyzer project +- **THEN** `detect --json` SHALL report one evidence entry per referencing file and count that project's analyzers once + +#### Scenario: An analyzer project that cannot be found + +- **WHEN** the only analyzer reference is built from a property and no scanned project has its file name +- **THEN** `detect --json` SHALL report `custom-analyzer` with the `Include` as written in its evidence and a custom-rule count of `"unknown"` + +#### Scenario: An ordinary project reference is not evidence + +- **WHEN** a project file references another project without `OutputItemType="Analyzer"`, or only inside an XML comment +- **THEN** `detect --json` SHALL NOT report `custom-analyzer` diff --git a/packages/cli/src/agent/detect.md b/packages/cli/src/agent/detect.md index 05adaf66..e04fa41e 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 v5) +# Topic: detect (CLI v%(CLI_VERSION)s / topic v6) %(ONE_COMMAND_PER_CALL)s @@ -58,8 +58,14 @@ routing flow reads `detect` to decide where a new rule should live. 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. + ships with a toolchain, like clippy or `dart-analyzer`, is reported + only when something configures or runs it. + - `custom-analyzer` is a C# repository's own Roslyn analyzers: a + project in the tree that an MSBuild file wires in as an analyzer. + Its evidence names that project and the file that references it + (`analyzer project analyzers/House.Analyzers/House.Analyzers.csproj + (Directory.Build.props)`), and its `customRules` counts the + `[DiagnosticAnalyzer]` classes in 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/custom-rules.ts b/packages/cli/src/detect/custom-rules.ts index 5294a5a4..26a83106 100644 --- a/packages/cli/src/detect/custom-rules.ts +++ b/packages/cli/src/detect/custom-rules.ts @@ -20,12 +20,26 @@ export interface CustomRules { evidence: string[]; } +/** + * A Roslyn analyzer project an MSBuild file wires into the build with + * `OutputItemType="Analyzer"`. `project` is the scanned `.csproj` it names, + * when that can be told without evaluating MSBuild. + */ +export interface AnalyzerReference { + /** The MSBuild file holding the `ProjectReference`. */ + declaredIn: string; + /** The reference's `Include`, as written. */ + include: string; + project?: string; +} + /** What the scan already found, so this module reads nothing twice. */ export interface CustomRuleContext { root: string; pathsFor: (basename: string) => string[]; pyprojects: Array<{ path: string; parsed?: Record }>; nodeDeps: Array<{ path: string; deps: Set }>; + analyzerReferences: AnalyzerReference[]; } export async function countCustomRules( @@ -45,6 +59,9 @@ export async function countCustomRules( case "flake8": { return countFlake8(context); } + case "custom-analyzer": { + return countRoslynAnalyzers(context); + } // Ruff has no custom-rule mechanism. A fixed zero rather than nothing, // because an absent count would read as "not checked". case "ruff": { @@ -444,3 +461,87 @@ async function countFlake8(context: CustomRuleContext): Promise { return { count, evidence }; } + +// --- Roslyn analyzer projects ------------------------------------------------ + +/** + * A `[DiagnosticAnalyzer(...)]` attribute, alone or beside others in one + * attribute list (`[Shared, DiagnosticAnalyzer(LanguageNames.CSharp)]`), with + * or without its namespace or `Attribute` suffix. + */ +const DIAGNOSTIC_ANALYZER_ATTRIBUTE = + /[[,]\s*(?:[\w.]+\.)?DiagnosticAnalyzer(?:Attribute)?\s*\(/g; + +/** Directories under an analyzer project that hold no source of its own. */ +const NOT_ANALYZER_SOURCE = new Set(["bin", "obj", "node_modules"]); + +/** How deep below the project directory the source walk descends. */ +const ANALYZER_SOURCE_DEPTH = 6; + +/** The `.cs` files under a project directory, skipping build output. */ +function csharpSources(directory: string, depth = 0): string[] { + let entries: string[]; + try { + entries = readdirSync(directory); + } catch { + return []; + } + const files: string[] = []; + for (const entry of entries) { + if (entry.startsWith(".") || NOT_ANALYZER_SOURCE.has(entry)) continue; + const path = join(directory, entry); + let isDirectory: boolean; + try { + isDirectory = statSync(path).isDirectory(); + } catch { + continue; + } + if (isDirectory) { + if (depth < ANALYZER_SOURCE_DEPTH) { + files.push(...csharpSources(path, depth + 1)); + } + } else if (entry.endsWith(".cs")) { + files.push(path); + } + } + return files; +} + +/** + * Analyzer classes in the repository's own analyzer projects: each + * `[DiagnosticAnalyzer]` attribute in the `.cs` files under a referenced + * project's directory is one. A reference whose project cannot be found makes + * the count `"unknown"`, unless the found ones already clear the threshold. + */ +async function countRoslynAnalyzers( + context: CustomRuleContext +): Promise { + let known = 0; + let unknown = false; + const evidence: string[] = []; + const counted = new Set(); + for (const { include, project } of context.analyzerReferences) { + if (project === undefined) { + unknown = true; + evidence.push(`${include} (project not found)`); + continue; + } + const directory = dirname(project); + if (counted.has(directory)) continue; + counted.add(directory); + let count = 0; + for (const file of csharpSources(resolve(context.root, directory))) { + const source = (await readText(file)) ?? ""; + const code = source + .replaceAll(/\/\*[\s\S]*?\*\//g, "") + .replaceAll(/\/\/.*$/gm, ""); + count += code.match(DIAGNOSTIC_ANALYZER_ATTRIBUTE)?.length ?? 0; + } + known += count; + evidence.push( + `${directory === "." ? project : directory} (${count} analyzers)` + ); + } + const count = unknown && known < CUSTOM_RULE_THRESHOLD ? "unknown" : known; + return { count, evidence }; +} diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 532def85..9ffc772d 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -1,8 +1,9 @@ import { existsSync, globSync } from "node:fs"; import { readFile as readFileNode } from "node:fs/promises"; -import { resolve } from "node:path"; +import { posix, resolve } from "node:path"; import { parse as parseToml } from "smol-toml"; +import { parse as parseYaml } from "yaml"; import { RULES_DIRECTORY } from "../rules/layout"; import { @@ -12,6 +13,7 @@ import { type DetectedAutomation, } from "./automation"; import { + type AnalyzerReference, countCustomRules, type CustomRules, ESLINT_CONFIG_FILES, @@ -82,6 +84,9 @@ const IGNORED_DIRECTORIES: ReadonlySet = new Set([ ".pytest_cache", ".tox", ".gradle", + // Dart's package resolution and build state. A dot-directory the walk would + // skip anyway, listed so that stays true if a pattern ever names it. + ".dart_tool", // .NET build output. `obj` holds restore artifacts generated from the // project files, which are not the repository's own declarations. "bin", @@ -109,7 +114,8 @@ 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`, a C# one in an MSBuild project file. Tagging by language lets the scan look for a + * a Swift one in `Package.swift`, a C# one in an MSBuild project file, a Dart + * one in `pubspec.yaml`. 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 @@ -150,6 +156,12 @@ interface LinterSignal { * and is not a dependency, such as clippy, which ships with the toolchain. */ invocations?: RegExp[]; + /** + * An analyzer project the repository builds itself, referenced from an + * MSBuild file as ``. The + * referenced project is the evidence. + */ + analyzerProjects?: boolean; } /** TOML manifests parsed for `manifestTables`. */ @@ -417,6 +429,24 @@ const LINTER_SIGNALS: readonly LinterSignal[] = [ configKeys: analyzerConfigKeys(/^[ \t]*dotnet_diagnostic\.s\d[^\s=]*/im), deps: ["sonaranalyzer.csharp"], }, + { + // The repository's own Roslyn analyzers, built from a project in the tree + // and wired into the build as an analyzer rather than a library. Named for + // what it is, not for a tool, because the rules are the repository's. + name: "custom-analyzer", + languages: ["C#"], + analyzerProjects: true, + }, + { + // The analyzer that ships with the Dart and Flutter SDKs. It reads only + // `analysis_options.yaml`, and the lint sets are packages in `pubspec.yaml`. + // Being a Dart repository is NOT evidence, as with clippy. + name: "dart-analyzer", + languages: ["Dart"], + configFiles: ["analysis_options.yaml"], + deps: ["lints", "flutter_lints", "very_good_analysis"], + invocations: [/\b(?:dart|flutter)\s+analyze\b/], + }, // Polyglot and prose linters: neither says anything about which languages // the repository is written in, so they contribute none. { @@ -460,6 +490,7 @@ export const LANGUAGE_MARKERS: ReadonlyArray<{ { language: "PHP", files: ["composer.json"] }, { language: "Java", files: ["pom.xml", "build.gradle", "build.gradle.kts"] }, { language: "Swift", files: ["Package.swift"] }, + { language: "Dart", files: ["pubspec.yaml"] }, ]; /** @@ -788,6 +819,86 @@ function nugetDepsFromToolManifest(raw: string | undefined): string[] { } } +/** + * Analyzer projects an MSBuild file references: each `ProjectReference` whose + * `OutputItemType` is `Analyzer`, as an attribute or as child metadata, with + * its `Include` as written. Comments are removed first, as for NuGet. + */ +function analyzerReferencesFromMsbuild(raw: string | undefined): string[] { + if (raw === undefined) return []; + const source = raw.replaceAll(//g, ""); + const includes: string[] = []; + for (const match of source.matchAll( + /]*?)(?:\/>|>([\s\S]*?)<\/ProjectReference\s*>)/gi + )) { + const attributes = match[1]!; + const isAnalyzer = + /\bOutputItemType\s*=\s*["']\s*Analyzer\s*["']/i.test(attributes) || + /\s*Analyzer\s*<\/OutputItemType>/i.test(match[2] ?? ""); + const include = /\bInclude\s*=\s*["']([^"']+)["']/i.exec(attributes)?.[1]; + if (!isAnalyzer || include === undefined) continue; + includes.push( + ...include + .split(";") + .map((path) => path.trim()) + .filter((path) => path !== "") + ); + } + return includes; +} + +/** + * The scanned project file an analyzer `Include` names, or `undefined` when it + * cannot be told. A project file resolves its includes against its own + * directory. An imported `.props` or `.targets` resolves them against the + * project that imports it, so only a `$(MSBuildThisFileDirectory)` path is + * resolved against the shared file itself. Anything else (another property, + * an ambiguous path) falls back to the one scanned project with that file + * name, if there is exactly one. + */ +function resolveAnalyzerProject( + declaredIn: string, + include: string, + projectPaths: readonly string[] +): string | undefined { + const written = include.replaceAll("\\", "/"); + const thisFile = /^\$\(MSBuildThisFileDirectory\)\/?/i; + const relative = thisFile.test(written) + ? written.replace(thisFile, "") + : declaredIn.endsWith(".csproj") + ? written + : undefined; + if (relative !== undefined && !relative.includes("$(")) { + const candidate = posix.normalize( + posix.join(posix.dirname(declaredIn), relative) + ); + if (projectPaths.includes(candidate)) return candidate; + } + const name = basenameOf(written); + const byName = projectPaths.filter((path) => basenameOf(path) === name); + return byName.length === 1 ? byName[0] : undefined; +} + +/** + * Package names a `pubspec.yaml` depends on, from `dependencies` and + * `dev_dependencies`. The lint sets are dev dependencies by convention, but a + * package that ships its own lints may list them as regular ones. + */ +function dartDepsFromPubspec(raw: string | undefined): string[] { + if (raw === undefined) return []; + let parsed: unknown; + try { + parsed = parseYaml(raw); + } catch { + return []; + } + const pubspec = asRecord(parsed); + return [ + ...plainObjectKeys(pubspec?.dependencies), + ...plainObjectKeys(pubspec?.dev_dependencies), + ]; +} + /** Python dependency names declared in a `requirements.txt`. */ function pythonDepsFromRequirements(raw: string | undefined): string[] { if (raw === undefined) return []; @@ -991,11 +1102,25 @@ export async function detectRepository(cwd: string): Promise { "packages.config", nugetDepsFromPackagesConfig ); + // Analyzer projects are read in the same pass, from the same files. + const projectPaths = msbuildPaths + .map((path) => path.replaceAll("\\", "/")) + .filter((path) => path.endsWith(".csproj")); + const analyzerReferences: AnalyzerReference[] = []; for (const relativePath of msbuildPaths) { + const raw = await readAt(relativePath); nugetManifests.push({ path: relativePath, - deps: new Set(nugetDepsFromMsbuild(await readAt(relativePath))), + deps: new Set(nugetDepsFromMsbuild(raw)), }); + const declaredIn = relativePath.replaceAll("\\", "/"); + for (const include of analyzerReferencesFromMsbuild(raw)) { + analyzerReferences.push({ + declaredIn, + include, + project: resolveAnalyzerProject(declaredIn, include, projectPaths), + }); + } } for (const relativePath of new Set(toolManifestPaths)) { nugetManifests.push({ @@ -1012,6 +1137,7 @@ export async function detectRepository(cwd: string): Promise { ["Go", await manifestsFrom("go.mod", goDepsFromGoModule)], ["Swift", await manifestsFrom("Package.swift", swiftDepsFromPackageSwift)], ["C#", nugetManifests], + ["Dart", await manifestsFrom("pubspec.yaml", dartDepsFromPubspec)], ]); // The commands the root CI and hook configs run, read once for every @@ -1106,6 +1232,11 @@ export async function detectRepository(cwd: string): Promise { .find((result) => result !== null); if (match) evidence.push(`${path} (${match[0]})`); } + if (signal.analyzerProjects) { + for (const { declaredIn, include, project } of analyzerReferences) { + evidence.push(`analyzer project ${project ?? include} (${declaredIn})`); + } + } if (evidence.length > 0) { const customRules = await countCustomRules(signal.name, { @@ -1113,6 +1244,7 @@ export async function detectRepository(cwd: string): Promise { pathsFor, pyprojects: tomlManifests.get("pyproject.toml") ?? [], nodeDeps: nodeManifests, + analyzerReferences, }); linters.push({ name: signal.name, diff --git a/packages/cli/src/schemas/detect.ts b/packages/cli/src/schemas/detect.ts index 8eecc272..df3bb3bd 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, 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)" + "On-disk evidence: config-file paths, a manifest table, section, or config key marker, a dependency marker from the language's package file, an in-tree analyzer project and the file that references it, or a root CI or hook config that runs the linter (not all entries are file paths)" ), customRules: z .object({ @@ -19,7 +19,7 @@ const detectedLinterSchema = z.object({ }) .optional() .describe( - "Present only for linters that can hold a custom rule (eslint, biome, pylint, flake8) and for ruff, which always reports zero" + "Present only for linters that can hold a custom rule (eslint, biome, pylint, flake8, custom-analyzer) and for ruff, which always reports zero" ), }); diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 1e505382..574b8ca1 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -85,6 +85,10 @@ function roslynatorEvidence(result: DetectJson): string[] | undefined { return result.linters.find((l) => l.name === "roslynator")?.evidence; } +function customAnalyzer(result: DetectJson) { + return result.linters.find((l) => l.name === "custom-analyzer"); +} + async function writeNested( root: string, path: string, @@ -869,6 +873,193 @@ describe("taskless detect", () => { }); }); + describe("Dart and Flutter", () => { + it("reports Dart from pubspec.yaml, and never the analyzer from that alone", async () => { + await writeNested(cwd, "pubspec.yaml", "name: app\n"); + const result = await detect(cwd); + expect(result.languages).toContain("Dart"); + expect(linterNames(result)).not.toContain("dart-analyzer"); + }); + + it("reports Dart and the analyzer for a Flutter app with platform folders", async () => { + await writeNested( + cwd, + "pubspec.yaml", + "name: app\ndependencies:\n flutter:\n sdk: flutter\ndev_dependencies:\n flutter_lints: ^5.0.0\n" + ); + await writeNested( + cwd, + "analysis_options.yaml", + "include: package:flutter_lints/flutter.yaml\n" + ); + await writeNested(cwd, "ios/Gemfile", 'source "https://rubygems.org"\n'); + await writeNested(cwd, "android/build.gradle", "\n"); + const result = await detect(cwd); + expect(result.languages).toContain("Dart"); + expect( + result.linters.find((l) => l.name === "dart-analyzer")?.evidence + ).toEqual([ + "analysis_options.yaml", + "dependency flutter_lints (pubspec.yaml)", + ]); + }); + + it.each([ + [ + "a lint set in dev_dependencies", + "packages/core/pubspec.yaml", + "name: core\ndev_dependencies:\n lints: ^5.0.0\n", + "dependency lints (packages/core/pubspec.yaml)", + ], + [ + "very_good_analysis", + "pubspec.yaml", + "name: app\ndev_dependencies:\n very_good_analysis: ^6.0.0\n", + "dependency very_good_analysis (pubspec.yaml)", + ], + [ + "a nested analysis_options.yaml", + "packages/core/analysis_options.yaml", + "linter:\n rules:\n - avoid_print\n", + "packages/core/analysis_options.yaml", + ], + [ + "a CI step that runs it", + ".github/workflows/ci.yml", + "jobs:\n lint:\n steps:\n - run: flutter analyze --fatal-infos\n", + ".github/workflows/ci.yml (flutter analyze)", + ], + ])( + "reports the analyzer from %s", + async (_label, path, content, expected) => { + await writeNested(cwd, path, content); + const result = await detect(cwd); + expect( + result.linters.find((l) => l.name === "dart-analyzer")?.evidence + ).toContain(expected); + expect(result.languages).toContain("Dart"); + } + ); + + it.each([ + [ + "a lint set named only in a comment", + "name: app\n# dev_dependencies:\n# lints: ^5.0.0\n", + ], + ["a malformed pubspec.yaml", "name: [app\n lints: ^5\n"], + ])("does not report the analyzer from %s", async (_label, content) => { + await writeNested(cwd, "pubspec.yaml", content); + expect(linterNames(await detect(cwd))).not.toContain("dart-analyzer"); + }); + }); + + describe("an analyzer project in the repository", () => { + const analyzerSource = + "using Microsoft.CodeAnalysis.Diagnostics;\n\n[DiagnosticAnalyzer(LanguageNames.CSharp)]\npublic sealed class NoFooAnalyzer : DiagnosticAnalyzer { }\n\n[Shared, DiagnosticAnalyzer(LanguageNames.CSharp)]\npublic sealed class NoBarAnalyzer : DiagnosticAnalyzer { }\n\n// [DiagnosticAnalyzer(LanguageNames.CSharp)] retired\npublic sealed class Helper { }\n"; + + async function writeAnalyzerProject(): Promise { + await writeNested( + cwd, + "analyzers/House.Analyzers/House.Analyzers.csproj", + '\n' + ); + await writeNested( + cwd, + "analyzers/House.Analyzers/Rules/Analyzers.cs", + analyzerSource + ); + await writeNested( + cwd, + "analyzers/House.Analyzers/bin/Debug/Generated.cs", + analyzerSource + ); + await writeNested( + cwd, + "src/App/App.csproj", + '\n' + ); + } + + it.each([ + [ + "Directory.Build.props, through $(MSBuildThisFileDirectory)", + "Directory.Build.props", + '\n \n \n \n\n', + ], + [ + "Directory.Build.props, through another property", + "Directory.Build.props", + '\n \n \n \n\n', + ], + [ + "a project file, as child metadata", + "src/App/App.csproj", + '\n \n \n Analyzer\n false\n \n \n\n', + ], + ])( + "reports it from %s, naming the project and counting its analyzers", + async (_label, path, content) => { + await writeAnalyzerProject(); + await writeNested(cwd, path, content); + const result = await detect(cwd); + expect(customAnalyzer(result)?.evidence).toEqual([ + `analyzer project analyzers/House.Analyzers/House.Analyzers.csproj (${path})`, + ]); + expect(customAnalyzer(result)?.customRules).toEqual({ + count: 2, + evidence: ["analyzers/House.Analyzers (2 analyzers)"], + }); + expect(result.languages).toContain("C#"); + } + ); + + it("counts a project referenced from several files once", async () => { + await writeAnalyzerProject(); + const reference = + '\n'; + await writeNested(cwd, "src/App/App.csproj", reference); + await writeNested(cwd, "src/Lib/Lib.csproj", reference); + const result = await detect(cwd); + expect(customAnalyzer(result)?.evidence).toHaveLength(2); + expect(customAnalyzer(result)?.customRules?.count).toBe(2); + }); + + it("reports a project it cannot find with an unknown count", async () => { + await writeNested( + cwd, + "Directory.Build.props", + '\n' + ); + const result = await detect(cwd); + expect(customAnalyzer(result)).toEqual({ + name: "custom-analyzer", + evidence: [ + "analyzer project $(AnalyzersRoot)Missing.Analyzers.csproj (Directory.Build.props)", + ], + customRules: { + count: "unknown", + evidence: [ + "$(AnalyzersRoot)Missing.Analyzers.csproj (project not found)", + ], + }, + }); + }); + + it.each([ + [ + "an ordinary project reference", + '\n', + ], + [ + "a commented-out analyzer reference", + '\n', + ], + ])("does not report it from %s", async (_label, content) => { + await writeNested(cwd, "src/App/App.csproj", content); + expect(linterNames(await detect(cwd))).not.toContain("custom-analyzer"); + }); + }); + describe("custom-rule counts", () => { it("counts the rules in a house ESLint rule directory", async () => { await writeFile(join(cwd, "eslint.config.js"), "export default [];"); From c8b6d128cc9f9f1e17152926c91c3d2d2241c22b Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Wed, 11 Nov 2026 18:50:52 -0800 Subject: [PATCH 2/2] fix(detect): dedupe analyzer references per file and attribute unresolved ones A ProjectReference repeated within one MSBuild file now yields one evidence line, and an unresolved reference's custom-rule evidence names the file that declares it, so the same Include from two files no longer reads as a duplicate. --- packages/cli/src/detect/custom-rules.ts | 4 ++-- packages/cli/src/detect/scan.ts | 5 +++-- packages/cli/test/detect.test.ts | 30 ++++++++++++++++++++++++- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/detect/custom-rules.ts b/packages/cli/src/detect/custom-rules.ts index 26a83106..2f96ef4d 100644 --- a/packages/cli/src/detect/custom-rules.ts +++ b/packages/cli/src/detect/custom-rules.ts @@ -520,10 +520,10 @@ async function countRoslynAnalyzers( let unknown = false; const evidence: string[] = []; const counted = new Set(); - for (const { include, project } of context.analyzerReferences) { + for (const { declaredIn, include, project } of context.analyzerReferences) { if (project === undefined) { unknown = true; - evidence.push(`${include} (project not found)`); + evidence.push(`${include} (project not found, ${declaredIn})`); continue; } const directory = dirname(project); diff --git a/packages/cli/src/detect/scan.ts b/packages/cli/src/detect/scan.ts index 9ffc772d..9799ab42 100644 --- a/packages/cli/src/detect/scan.ts +++ b/packages/cli/src/detect/scan.ts @@ -822,7 +822,8 @@ function nugetDepsFromToolManifest(raw: string | undefined): string[] { /** * Analyzer projects an MSBuild file references: each `ProjectReference` whose * `OutputItemType` is `Analyzer`, as an attribute or as child metadata, with - * its `Include` as written. Comments are removed first, as for NuGet. + * its `Include` as written, once each. Comments are removed first, as for + * NuGet. */ function analyzerReferencesFromMsbuild(raw: string | undefined): string[] { if (raw === undefined) return []; @@ -844,7 +845,7 @@ function analyzerReferencesFromMsbuild(raw: string | undefined): string[] { .filter((path) => path !== "") ); } - return includes; + return [...new Set(includes)]; } /** diff --git a/packages/cli/test/detect.test.ts b/packages/cli/test/detect.test.ts index 574b8ca1..e38f232f 100644 --- a/packages/cli/test/detect.test.ts +++ b/packages/cli/test/detect.test.ts @@ -1024,6 +1024,34 @@ describe("taskless detect", () => { expect(customAnalyzer(result)?.customRules?.count).toBe(2); }); + it("reports a reference repeated within one file once", async () => { + await writeAnalyzerProject(); + const reference = String.raw``; + await writeNested( + cwd, + "src/App/App.csproj", + `${reference}${reference}\n` + ); + const result = await detect(cwd); + expect(customAnalyzer(result)?.evidence).toEqual([ + "analyzer project analyzers/House.Analyzers/House.Analyzers.csproj (src/App/App.csproj)", + ]); + }); + + it("names the file holding each reference it cannot find", async () => { + const reference = + '\n'; + await writeNested(cwd, "src/App/App.csproj", reference); + await writeNested(cwd, "src/Lib/Lib.csproj", reference); + const result = await detect(cwd); + expect( + (customAnalyzer(result)?.customRules?.evidence ?? []).toSorted() + ).toEqual([ + "$(AnalyzersRoot)Missing.Analyzers.csproj (project not found, src/App/App.csproj)", + "$(AnalyzersRoot)Missing.Analyzers.csproj (project not found, src/Lib/Lib.csproj)", + ]); + }); + it("reports a project it cannot find with an unknown count", async () => { await writeNested( cwd, @@ -1039,7 +1067,7 @@ describe("taskless detect", () => { customRules: { count: "unknown", evidence: [ - "$(AnalyzersRoot)Missing.Analyzers.csproj (project not found)", + "$(AnalyzersRoot)Missing.Analyzers.csproj (project not found, Directory.Build.props)", ], }, });