diff --git a/docs/conventions/ecosystem-commands/ecosystem.schema.json b/docs/conventions/ecosystem-commands/ecosystem.schema.json index 5299bfdb7a..10974a4175 100644 --- a/docs/conventions/ecosystem-commands/ecosystem.schema.json +++ b/docs/conventions/ecosystem-commands/ecosystem.schema.json @@ -45,7 +45,7 @@ }, "opt-in": { "type": "string", - "description": "The config file or condition whose presence signals the repo uses this toolchain (e.g. 'biome.json'). Informational for resolvers deciding applicability." + "description": "A condition resolvers evaluate to decide whether to run this ecosystem's lint phase — either a single condition governing the whole check-cmd (e.g. 'a ruff config (ruff.toml, .ruff.toml, or pyproject.toml [tool.ruff])'), or a multi-clause value where each clause names a sub-tool and its own condition (e.g. 'shellcheck always applies to shell files; shfmt only when .editorconfig declares shell style'). Unmet -> resolvers report a visible skip rather than silently omitting the ecosystem, and never impose the tool's built-in defaults on a repo that hasn't configured it." }, "install-hint": { "type": "string", diff --git a/docs/conventions/ecosystem-commands/examples/dotnet.yaml b/docs/conventions/ecosystem-commands/examples/dotnet.yaml index d0c4dc8a92..eb92513045 100644 --- a/docs/conventions/ecosystem-commands/examples/dotnet.yaml +++ b/docs/conventions/ecosystem-commands/examples/dotnet.yaml @@ -6,6 +6,7 @@ build-cmd: 'dotnet build ""' test-cmd: 'dotnet test "" --no-build' check-cmd: 'dotnet format "" --verify-no-changes' fix-cmd: 'dotnet format ""' +opt-in: ".editorconfig with a [*] or C#-glob section, walked from the changed file up to the repo root or a `root = true` marker (whichever comes first) — otherwise dotnet format applies Roslyn's built-in defaults unconditionally" install-hint: "Install .NET SDK from https://dot.net" gates: - name: nuget-lockfile-drift diff --git a/plugins/toolchain/.claude-plugin/plugin.json b/plugins/toolchain/.claude-plugin/plugin.json index 35c948ff60..ec49d5f1a6 100644 --- a/plugins/toolchain/.claude-plugin/plugin.json +++ b/plugins/toolchain/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "toolchain", - "version": "0.5.2", + "version": "0.6.0", "description": "Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).", "author": { "name": "Melodic Software", diff --git a/plugins/toolchain/CHANGELOG.md b/plugins/toolchain/CHANGELOG.md index 987074b236..34e2da30ab 100644 --- a/plugins/toolchain/CHANGELOG.md +++ b/plugins/toolchain/CHANGELOG.md @@ -3,6 +3,41 @@ All notable changes to the `toolchain` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.0] + +### Added + +- **dotnet ecosystem `opt-in` key** (`.editorconfig` with a `[*]` or C#-glob + section, walked from the changed file up to the repo root or a + `root = true` marker, whichever comes first — empirically verified against + dotnet SDK 10.0.302 that a universal `[*]` section governs `dotnet format`'s + output on `.cs` files just as a `[*.cs]` section would, that a + `.editorconfig` with only unrelated globs has zero effect, and that a + nested `root = true` marker genuinely stops EditorConfig discovery before + it reaches an outer section) — closes the one lint-bearing ecosystem gap + where a config-presence opt-in didn't exist. +- **`/toolchain:check` now honors `opt-in`** for the lint phase (it never did + before — `dotnet format --verify-no-changes` ran unconditionally whenever + `.cs`/`.csproj`/etc. files changed, regardless of whether the repo + configured any style/analyzer preferences). Build and test are unaffected; + only the lint phase is gated. This binary run/skip treatment applies to + single-condition ecosystems (dotnet, python); multi-tool ecosystems whose + `opt-in` bundles several sub-tools into one opaque command string (bash, + cross-cutting) are unchanged from prior behavior — a bundled command + cannot be partially suppressed, a known limitation documented in + `check/SKILL.md`'s Gotchas. +- **Visible `skip (opt-in unmet: ...)` status** in both `/toolchain:check` + and `/toolchain:lint` results tables — a single-condition ecosystem whose + `opt-in` isn't met is now reported, not silently dropped from output as + it previously was in `/toolchain:lint` for every opt-in-bearing ecosystem. + +### Fixed + +- dotnet's lint/format check no longer imposes Roslyn's built-in formatting + defaults on a repo that never configured `.editorconfig`/analyzer + preferences — matching the same "never impose an unconfigured opinion" + posture already applied at the hook layer by `ruff-format`/`typos-format`. + ## [0.5.2] ### Changed diff --git a/plugins/toolchain/reference/ecosystems/dotnet.yaml b/plugins/toolchain/reference/ecosystems/dotnet.yaml index 566ad7a6d4..4c8c1916ef 100644 --- a/plugins/toolchain/reference/ecosystems/dotnet.yaml +++ b/plugins/toolchain/reference/ecosystems/dotnet.yaml @@ -2,10 +2,34 @@ # .claude/ecosystems/dotnet.yaml overrides this key-by-key; this file is never # written into a consumer repo). Ecosystem-commands contract + schema: # https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/ecosystem-commands/README.md +# +# opt-in rationale: dotnet format's own docs state preferences are read from +# .editorconfig "if present, otherwise a default set of preferences will be +# used" (https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-format) — +# verified empirically (dotnet SDK 10.0.302) that a project with NO +# .editorconfig still gets `dotnet format --verify-no-changes` failures +# (Roslyn's built-in whitespace conventions apply unconditionally). Gated on +# a section that actually reaches .cs files (a universal `[*]`, or a +# C#-specific glob like `[*.cs]`/`[*.{cs,vb}]`), not bare file presence, +# mirroring bash.yaml's "shfmt only when .editorconfig declares shell style" +# precision — a .editorconfig with ONLY unrelated globs (e.g. `[*.md]`, no +# `[*]`) verifiably has zero effect on dotnet format's output (empirically +# confirmed: exit 0, no diagnostics) and shouldn't count as configured .NET +# style. EnableNETAnalyzers was considered and rejected as a signal: it +# defaults to true for .NET 5+, so its presence is not meaningful. +# +# The walk stops at a `root = true` marker even when that file has no +# C#-relevant section — EditorConfig discovery itself stops there (verified +# empirically: an outer repo-root `[*]` section has zero effect on a nested +# .cs file once a `root = true` .editorconfig sits between them), so a +# parent section beyond that marker is never actually consulted by dotnet +# format and must not count toward this opt-in either. Same discipline as +# bash-format.sh's shell_editorconfig_opt_in() walk elsewhere in this repo. globs: ["*.cs", "*.csproj", "*.sln", "*.slnx", "*.props", "*.targets"] anchor: "the solution file at repo root (*.slnx or *.sln); when none exists, the nearest *.csproj above the changed files" build-cmd: 'dotnet build ""' test-cmd: 'dotnet test "" --no-build' check-cmd: 'dotnet format "" --verify-no-changes' fix-cmd: 'dotnet format ""' +opt-in: ".editorconfig with a [*] or C#-glob section, walked from the changed file up to the repo root or a `root = true` marker (whichever comes first) — otherwise dotnet format applies Roslyn's built-in defaults unconditionally" install-hint: "Install .NET SDK from https://dot.net" diff --git a/plugins/toolchain/skills/check/SKILL.md b/plugins/toolchain/skills/check/SKILL.md index 290652607a..357e1be9ff 100644 --- a/plugins/toolchain/skills/check/SKILL.md +++ b/plugins/toolchain/skills/check/SKILL.md @@ -109,6 +109,14 @@ Run build → test → lint in order per ecosystem. Stop that ecosystem on first Tool presence: before each ecosystem runs, verify the tool is on `PATH`. If missing, report `skip` with the ecosystem's `install-hint` from the ecosystem config — never report `FAIL` for a missing tool. +**Opt-in gate (lint phase only)**: before running an ecosystem's `check-cmd`, evaluate its resolved `opt-in` condition (if present) against the repo. Build and test always run regardless of `opt-in` — only the lint phase is gated, since compiling and testing don't depend on style configuration. + +This binary gate applies cleanly when `opt-in` describes ONE condition governing the whole `check-cmd` (e.g. dotnet, python): unmet → report the ecosystem's Lint column as `skip (opt-in unmet: )` — visible, not silently omitted — and do not run `check-cmd`. Met → run `check-cmd` normally. + +When `opt-in` instead describes MULTIPLE independent per-tool conditions bundled into one opaque command string (e.g. bash's `"shellcheck always applies to shell files; shfmt only when .editorconfig declares shell style"`, where `check-cmd` is `shellcheck ... && shfmt -d `), this gate does NOT apply — `check-cmd` is a single opaque string (per the ecosystem-commands contract) with no way to run one sub-tool's portion without the other. Run `check-cmd` as before (unchanged from prior behavior) and report its real output; do not attempt a partial skip. See Gotchas below for the known atomicity limitation this leaves open. + +An opt-in-unmet skip (single-condition case) counts toward the table's total ecosystem count but never toward the FAIL count, the same precedent as a missing-tool skip. This is ecosystem-generic (reads the resolved `opt-in` key), not dotnet-specific — it applies to every current and future single-condition opt-in-bearing ecosystem `/toolchain:check` covers. Project-declared CI-parity gates (below) are unaffected — they already run independent of `check-cmd`. + **Project-declared CI-parity gates** — when the consuming project documents extra local checks that mirror CI gates plain build / test / lint don't catch (lockfile drift, generated-artifact freshness, schema regeneration), run the ones whose trigger files changed. These live in the consumer's own conventions (its `CLAUDE.md` / rules / commands reference) — this plugin ships none of its own. For ecosystem-specific gotchas (xUnit `--nologo` trap, `dotnet test --project`, etc.), read the corresponding `context/.md` file. @@ -126,7 +134,7 @@ For ecosystem-specific gotchas (xUnit `--nologo` trap, `dotnet test --project`, Overall: FAIL (1 of 2 ecosystems failed) ``` -Use `pass`, `FAIL`, `skip`, or `—` (not applicable — for ecosystems where the corresponding command is null in the ecosystem config). Show failing command output below the table. +Use `pass`, `FAIL`, `skip` (tool missing) or `skip (opt-in unmet: ...)` (config condition not met), or `—` (not applicable — for ecosystems where the corresponding command is null in the ecosystem config). Show failing command output below the table. If any project-declared CI-parity gates fired, summarize each by name + outcome below the per-ecosystem block, with the remediation pointer on failure. @@ -143,4 +151,6 @@ When composing `/toolchain:check` from another skill (like `/verification:confir - **CWD drift** — the #1 source of false failures. Always use absolute paths - **Missing tools** — report as `skip` with reason, not as failure (e.g., `uv` not installed) +- **Opt-in unmet** — report as `skip (opt-in unmet: ...)` with the condition, not as failure and not silently omitted (e.g., dotnet with no C#-relevant `.editorconfig`) +- **Multi-tool `check-cmd` atomicity** — when a multi-tool ecosystem's `check-cmd` bundles a gated sub-tool and an unconditional sub-tool in one shell string (e.g. bash's `shellcheck ... && shfmt -d `), the opt-in gate cannot suppress just the gated sub-tool's contribution — both run whenever the unconditional sub-tool's condition holds, per the ecosystem-commands contract's own "opaque shell string" rule. Splitting a multi-tool `check-cmd` into separately gateable ecosystem keys would need a schema change; not addressed here - **Multiple projects in same ecosystem** — ecosystems with an `anchor` use that as the scoping anchor; ecosystems with `project-discovery` patterns walk each discovered project root diff --git a/plugins/toolchain/skills/check/context/dotnet.md b/plugins/toolchain/skills/check/context/dotnet.md index 7fdc85af0b..0c3d0ea969 100644 --- a/plugins/toolchain/skills/check/context/dotnet.md +++ b/plugins/toolchain/skills/check/context/dotnet.md @@ -33,6 +33,12 @@ dotnet test --project "$REPO_ROOT/path/to/Project.Tests.csproj" ## Lint / Format +Opt-in gated: only runs when a governing `.editorconfig` is present (see the +`opt-in` key and its header-comment rationale in +`reference/ecosystems/dotnet.yaml`) — otherwise skipped visibly rather than +imposing Roslyn's built-in formatting defaults on a repo that never +configured any. + ```bash # Check formatting (CI mode — fails on violations) dotnet format "$REPO_ROOT/" --verify-no-changes diff --git a/plugins/toolchain/skills/lint/SKILL.md b/plugins/toolchain/skills/lint/SKILL.md index 39662f90b2..f31e4c96bc 100644 --- a/plugins/toolchain/skills/lint/SKILL.md +++ b/plugins/toolchain/skills/lint/SKILL.md @@ -86,13 +86,13 @@ Auto-detection algorithm: 1. Resolve each covered ecosystem's surface per [`${CLAUDE_PLUGIN_ROOT}/reference/resolution-ladder.md`](${CLAUDE_PLUGIN_ROOT}/reference/resolution-ladder.md) (consumer `.claude/ecosystems/.yaml` when present, else the bundled default; a malformed consumer file warns and degrades to inference, never a hard stop). Skip any ecosystem whose resolved `enabled` is `false` (a consumer opt-out) — excluded even under `all` 2. For each ecosystem, match its `globs` against the changed-files list -3. Run every ecosystem with ≥1 glob match whose `opt-in` condition holds, plus cross-cutting when any text file changed +3. Run every ecosystem with ≥1 glob match whose `opt-in` condition holds, plus cross-cutting when any text file changed. This binary run/skip treatment applies cleanly when `opt-in` describes a SINGLE condition for the whole `check-cmd` (dotnet, python): unmet → excluded from the run but still reported (see sections 2 and 3 below, `skip (opt-in unmet: ...)`; never silently omitted). When `opt-in` instead describes MULTIPLE independent per-tool conditions bundled into one opaque command string (bash's shellcheck-always/shfmt-conditional split; cross-cutting's per-tool config-file list), this rule does not apply — `check-cmd`/`fix-cmd` is a single opaque string with no way to run one sub-tool's portion without the other, so run it as before (unchanged from prior behavior) and report its real output. See `/toolchain:check`'s Gotchas for the known atomicity limitation this leaves open. If neither detection path yields changes and no filter specified: report "No changes found (working tree clean, no branch diff vs the default branch). Use `/toolchain:lint all` to check the full repo, or `/toolchain:lint ` for a specific filter." and stop. ### 2. Run linters per ecosystem -Run each ecosystem's resolved `check-cmd` (or `fix-cmd` with `--fix`). Honor each ecosystem's `opt-in` — skip tools the project hasn't configured. +Run each ecosystem's resolved `check-cmd` (or `fix-cmd` with `--fix`). Honor each ecosystem's `opt-in`: for a single-condition ecosystem (dotnet, python), an unmet condition skips the whole ecosystem, reporting `skip (opt-in unmet: )` visibly (never a silent omission) in every column that ecosystem's row has — this skip counts toward the table's total ecosystem count but never toward the FAIL count, the same precedent as a missing-tool skip. For a multi-tool ecosystem (bash, cross-cutting) whose `check-cmd` bundles multiple sub-tools into one opaque string, this binary treatment doesn't apply — run and report `check-cmd`/`fix-cmd` as before (unchanged from prior behavior); see `/toolchain:check`'s Gotchas for the known atomicity limitation. For ecosystem-specific gotchas, reference `/toolchain:check` — its `context/.md` files own the per-ecosystem prose detail. @@ -130,7 +130,7 @@ fi Overall: FAIL (1 of 4 ecosystems failed) ``` -Use `pass`, `FAIL`, `skip` (tool not installed), or `—` (not applicable). Split lint and format into separate columns where the ecosystem has both (dotnet, python, bash). Use a single "Lint" column for ecosystems with only one tool (markdown, yaml, powershell). +Use `pass`, `FAIL`, `skip` (tool not installed) or `skip (opt-in unmet: ...)` (config condition not met), or `—` (not applicable). Split lint and format into separate columns where the ecosystem has both (dotnet, python, bash). Use a single "Lint" column for ecosystems with only one tool (markdown, yaml, powershell). An opt-in-unmet skip fills every column that ecosystem's row has. If fix mode was used, note which ecosystems were auto-fixed vs which have no auto-fix. @@ -144,6 +144,7 @@ When `--fix` is used, auto-fix capability is derived from the config: an ecosyst - **No git changes but `/toolchain:lint all`**: run all applicable ecosystems (useful after rebase or pull) - **Missing tools**: report as `skip` with tool name and install hint, not as failure +- **Opt-in unmet**: report as `skip (opt-in unmet: ...)` with the condition, not as failure and not a silent omission (e.g., dotnet with no C#-relevant `.editorconfig`) - **Multiple projects in same ecosystem**: run per-project (each `pyproject.toml`, each `package.json`) - **File outside any ecosystem**: silently skip (no noise for binary files, images, etc.) - **CWD drift**: always use absolute paths from `$REPO_ROOT`