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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/detect-dart-roslyn-projects.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-11
Original file line number Diff line number Diff line change
@@ -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 `<ProjectReference ... OutputItemType="Analyzer" />`, 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.
Original file line number Diff line number Diff line change
@@ -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 <project> (<file>)`.

`<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, `<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 `<ProjectReference Include="$(MSBuildThisFileDirectory)analyzers\House.Analyzers\House.Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />` 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`
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions openspec/specs/cli-detect/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <project> (<file>)`.

`<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, `<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 `<ProjectReference Include="$(MSBuildThisFileDirectory)analyzers\House.Analyzers\House.Analyzers.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />` 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`
12 changes: 9 additions & 3 deletions packages/cli/src/agent/detect.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Topic: detect (CLI v%(CLI_VERSION)s / topic v5)
# Topic: detect (CLI v%(CLI_VERSION)s / topic v6)

%(ONE_COMMAND_PER_CALL)s

Expand Down Expand Up @@ -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.
Expand Down
101 changes: 101 additions & 0 deletions packages/cli/src/detect/custom-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }>;
nodeDeps: Array<{ path: string; deps: Set<string> }>;
analyzerReferences: AnalyzerReference[];
}

export async function countCustomRules(
Expand All @@ -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": {
Expand Down Expand Up @@ -444,3 +461,87 @@ async function countFlake8(context: CustomRuleContext): Promise<CustomRules> {

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<CustomRules> {
let known = 0;
let unknown = false;
const evidence: string[] = [];
const counted = new Set<string>();
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 };
}
Loading
Loading