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..2f96ef4d 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 { declaredIn, include, project } of context.analyzerReferences) {
+ if (project === undefined) {
+ unknown = true;
+ evidence.push(`${include} (project not found, ${declaredIn})`);
+ 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..9799ab42 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,87 @@ 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, once each. 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 [...new Set(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 +1103,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 +1138,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 +1233,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 +1245,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..e38f232f 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,221 @@ 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 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,
+ "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, Directory.Build.props)",
+ ],
+ },
+ });
+ });
+
+ 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 [];");