Skills msbuild-antipatterns
Catalog of MSBuild anti-patterns with detection rules and fix recipes. Only activate in MSBuild/.NET build context. USE FOR: reviewing, auditing, or cleaning up .csproj, .vbproj, .fsproj, .props, .targets, or .proj files. Each anti-pattern has a symptom, explanation, and concrete BAD→GOOD transformation. Covers Exec-instead-of-built-in-task, unquoted conditions, hardcoded paths, restating SDK defaults, scattered package versions, and more. DO NOT USE FOR: non-MSBuild build systems (npm, Maven, CMake, etc.), project migration to SDK-style (use msbuild-modernization).
git clone https://github.com/dotnet/skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/dotnet/skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/plugins/dotnet-msbuild/skills/msbuild-antipatterns" ~/.claude/skills/dotnet-skills-msbuild-antipatterns && rm -rf "$T"
plugins/dotnet-msbuild/skills/msbuild-antipatterns/SKILL.mdMSBuild Anti-Pattern Catalog
A numbered catalog of common MSBuild anti-patterns. Each entry follows the format:
- Smell: What to look for
- Why it's bad: Impact on builds, maintainability, or correctness
- Fix: Concrete transformation
Use this catalog when scanning project files for improvements.
AP-01: <Exec>
for Operations That Have Built-in Tasks
<Exec>Smell:
<Exec Command="mkdir ..." />, <Exec Command="copy ..." />, <Exec Command="del ..." />
Why it's bad: Built-in tasks are cross-platform, support incremental build, emit structured logging, and handle errors consistently.
<Exec> is opaque to MSBuild.
<!-- BAD --> <Target Name="PrepareOutput"> <Exec Command="mkdir $(OutputPath)logs" /> <Exec Command="copy config.json $(OutputPath)" /> <Exec Command="del $(IntermediateOutputPath)*.tmp" /> </Target> <!-- GOOD --> <Target Name="PrepareOutput"> <MakeDir Directories="$(OutputPath)logs" /> <Copy SourceFiles="config.json" DestinationFolder="$(OutputPath)" /> <Delete Files="@(TempFiles)" /> </Target>
Built-in task alternatives:
| Shell Command | MSBuild Task |
|---|---|
| |
/ | |
/ | |
/ | |
| |
| |
| with item globs |
AP-02: Unquoted Condition Expressions
Smell:
Condition="$(Foo) == Bar" — either side of a comparison is unquoted.
Why it's bad: If the property is empty or contains spaces/special characters, the condition evaluates incorrectly or throws a parse error. MSBuild requires single-quoted strings for reliable comparisons.
<!-- BAD --> <PropertyGroup Condition="$(Configuration) == Release"> <Optimize>true</Optimize> </PropertyGroup> <!-- GOOD --> <PropertyGroup Condition="'$(Configuration)' == 'Release'"> <Optimize>true</Optimize> </PropertyGroup>
Rule: Always quote both sides of
== and != comparisons with single quotes.
AP-03: Hardcoded Absolute Paths
Smell: Paths like
C:\tools\, D:\packages\, /usr/local/bin/ in project files.
Why it's bad: Breaks on other machines, CI environments, and other operating systems. Not relocatable.
<!-- BAD --> <PropertyGroup> <ToolPath>C:\tools\mytool\mytool.exe</ToolPath> </PropertyGroup> <Import Project="C:\repos\shared\common.props" /> <!-- GOOD --> <PropertyGroup> <ToolPath>$(MSBuildThisFileDirectory)tools\mytool\mytool.exe</ToolPath> </PropertyGroup> <Import Project="$(RepoRoot)eng\common.props" />
Preferred path properties:
| Property | Meaning |
|---|---|
| Directory of the current .props/.targets file |
| Directory of the .csproj |
| Walk up to find a marker file |
| Combine and normalize path segments |
AP-04: Restating SDK Defaults
Smell: Properties set to values that the .NET SDK already provides by default.
Why it's bad: Adds noise, hides intentional overrides, and makes it harder to identify what's actually customized. When defaults change in newer SDKs, the redundant properties may silently pin old behavior.
<!-- BAD: All of these are already the default --> <PropertyGroup> <OutputType>Library</OutputType> <EnableDefaultItems>true</EnableDefaultItems> <EnableDefaultCompileItems>true</EnableDefaultCompileItems> <RootNamespace>MyLib</RootNamespace> <!-- matches project name --> <AssemblyName>MyLib</AssemblyName> <!-- matches project name --> <AppendTargetFrameworkToOutputPath>true</AppendTargetFrameworkToOutputPath> </PropertyGroup> <!-- GOOD: Only non-default values --> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> </PropertyGroup>
AP-05: Manual File Listing in SDK-Style Projects
Smell:
<Compile Include="File1.cs" />, <Compile Include="File2.cs" /> in SDK-style projects.
Why it's bad: SDK-style projects automatically glob
**/*.cs (and other file types). Explicit listing is redundant, creates merge conflicts, and new files may be accidentally missed if not added to the list.
<!-- BAD --> <ItemGroup> <Compile Include="Program.cs" /> <Compile Include="Services\MyService.cs" /> <Compile Include="Models\User.cs" /> </ItemGroup> <!-- GOOD: Remove entirely — SDK includes all .cs files by default. Only use Remove/Exclude when you need to opt out: --> <ItemGroup> <Compile Remove="LegacyCode\**" /> </ItemGroup>
Exception: Non-SDK-style (legacy) projects require explicit file includes. If migrating, see
msbuild-modernization skill.
AP-06: Using <Reference>
with HintPath for NuGet Packages
<Reference>Smell:
<Reference Include="..." HintPath="..\packages\SomePackage\lib\..." />
Why it's bad: This is the legacy
packages.config pattern. It doesn't support transitive dependencies, version conflict resolution, or automatic restore. The packages/ folder must be committed or restored separately.
<!-- BAD --> <ItemGroup> <Reference Include="Newtonsoft.Json"> <HintPath>..\packages\Newtonsoft.Json.13.0.3\lib\netstandard2.0\Newtonsoft.Json.dll</HintPath> </Reference> </ItemGroup> <!-- GOOD --> <ItemGroup> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> </ItemGroup>
Note:
<Reference> without HintPath is still valid for .NET Framework GAC assemblies like WindowsBase, PresentationCore, etc.
AP-07: Missing PrivateAssets="all"
on Analyzer/Tool Packages
PrivateAssets="all"Smell:
<PackageReference Include="StyleCop.Analyzers" Version="..." /> without PrivateAssets="all".
Why it's bad: Without
PrivateAssets="all", analyzer and build-tool packages flow as transitive dependencies to consumers of your library. Consumers get unwanted analyzers or build-time tools they didn't ask for.
See
for BAD/GOOD examples and the full list of packages that need this.references/private-assets.md
AP-08: Copy-Pasted Properties Across Multiple .csproj Files
Smell: The same
<PropertyGroup> block appears in 3+ project files.
Why it's bad: Maintenance burden — a change must be made in every file. Inconsistencies creep in over time.
<!-- BAD: Repeated in every .csproj --> <!-- ProjectA.csproj, ProjectB.csproj, ProjectC.csproj all have: --> <PropertyGroup> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> <!-- GOOD: Define once in Directory.Build.props at the repo/src root --> <!-- Directory.Build.props --> <Project> <PropertyGroup> <LangVersion>latest</LangVersion> <Nullable>enable</Nullable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> <ImplicitUsings>enable</ImplicitUsings> </PropertyGroup> </Project>
See
directory-build-organization skill for full guidance on structuring Directory.Build.props / Directory.Build.targets.
AP-09: Scattered Package Versions Without Central Package Management
Smell:
<PackageReference Include="X" Version="1.2.3" /> with different versions of the same package across projects.
Why it's bad: Version drift — different projects use different versions of the same package, leading to runtime mismatches, unexpected behavior, or diamond dependency conflicts.
<!-- BAD: Version specified in each project, can drift --> <!-- ProjectA.csproj --> <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> <!-- ProjectB.csproj --> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
Fix: Use Central Package Management. See https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management for details.
AP-10: Monolithic Targets (Too Much in One Target)
Smell: A single
<Target> with 50+ lines doing multiple unrelated things.
Why it's bad: Can't skip individual steps via incremental build, hard to debug, hard to extend, and the target name becomes meaningless.
<!-- BAD --> <Target Name="PrepareRelease" BeforeTargets="Build"> <WriteLinesToFile File="version.txt" Lines="$(Version)" Overwrite="true" /> <Copy SourceFiles="LICENSE" DestinationFolder="$(OutputPath)" /> <Exec Command="signtool sign /f cert.pfx $(OutputPath)*.dll" /> <MakeDir Directories="$(OutputPath)docs" /> <Copy SourceFiles="@(DocFiles)" DestinationFolder="$(OutputPath)docs" /> <!-- ... 30 more lines ... --> </Target> <!-- GOOD: Single-responsibility targets --> <Target Name="WriteVersionFile" BeforeTargets="CoreCompile" Inputs="$(MSBuildProjectFile)" Outputs="$(IntermediateOutputPath)version.txt"> <WriteLinesToFile File="$(IntermediateOutputPath)version.txt" Lines="$(Version)" Overwrite="true" /> </Target> <Target Name="CopyLicense" AfterTargets="Build"> <Copy SourceFiles="LICENSE" DestinationFolder="$(OutputPath)" SkipUnchangedFiles="true" /> </Target> <Target Name="SignAssemblies" AfterTargets="Build" DependsOnTargets="CopyLicense" Condition="'$(SignAssemblies)' == 'true'"> <Exec Command="signtool sign /f cert.pfx %(AssemblyFiles.Identity)" /> </Target>
AP-11: Custom Targets Missing Inputs
and Outputs
InputsOutputsSmell:
<Target Name="MyTarget" BeforeTargets="Build"> with no Inputs / Outputs attributes.
Why it's bad: The target runs on every build, even when nothing changed. This defeats incremental build and slows down no-op builds.
See
for BAD/GOOD examples and the full pattern including FileWrites registration.references/incremental-build-inputs-outputs.md
See
incremental-build skill for deep guidance on Inputs/Outputs, FileWrites, and up-to-date checks.
AP-12: Setting Defaults in .targets Instead of .props
Smell:
<PropertyGroup> with default values inside a .targets file.
Why it's bad:
.targets files are imported late (after project files). By the time they set defaults, other .targets files may have already used the empty/undefined value. .props files are imported early and are the correct place for defaults.
<!-- BAD: custom.targets --> <PropertyGroup> <MyToolVersion>2.0</MyToolVersion> </PropertyGroup> <Target Name="RunMyTool"> <Exec Command="mytool --version $(MyToolVersion)" /> </Target> <!-- GOOD: Split into .props (defaults) + .targets (logic) --> <!-- custom.props (imported early) --> <PropertyGroup> <MyToolVersion Condition="'$(MyToolVersion)' == ''">2.0</MyToolVersion> </PropertyGroup> <!-- custom.targets (imported late) --> <Target Name="RunMyTool"> <Exec Command="mytool --version $(MyToolVersion)" /> </Target>
Rule:
.props = defaults and settings (evaluated early). .targets = build logic and targets (evaluated late).
AP-13: Import Without Exists()
Guard
Exists()Smell:
<Import Project="some-file.props" /> without a Condition="Exists('...')" check.
Why it's bad: If the file doesn't exist (not yet created, wrong path, deleted), the build fails with a confusing error. Optional imports should always be guarded.
<!-- BAD --> <Import Project="$(RepoRoot)eng\custom.props" /> <!-- GOOD: Guard optional imports --> <Import Project="$(RepoRoot)eng\custom.props" Condition="Exists('$(RepoRoot)eng\custom.props')" /> <!-- ALSO GOOD: Sdk attribute imports don't need guards (they're required by design) --> <Project Sdk="Microsoft.NET.Sdk">
Exception: Imports that are required for the build to work correctly should fail fast — don't guard those. Guard imports that are optional or environment-specific (e.g., local developer overrides, CI-specific settings).
AP-14: Using Backslashes in Paths (Cross-Platform Issue)
Smell:
<Import Project="$(RepoRoot)\eng\common.props" /> with backslash separators in .props/.targets files meant to be cross-platform.
Why it's bad: Backslashes work on Windows but fail on Linux/macOS. MSBuild normalizes forward slashes on all platforms.
<!-- BAD: Breaks on Linux/macOS --> <Import Project="$(RepoRoot)\eng\common.props" /> <Content Include="assets\images\**" /> <!-- GOOD: Forward slashes work everywhere --> <Import Project="$(RepoRoot)/eng/common.props" /> <Content Include="assets/images/**" />
Note:
$(MSBuildThisFileDirectory) already ends with a platform-appropriate separator, so $(MSBuildThisFileDirectory)tools/mytool works on both platforms.
AP-15: Unconditional Property Override in Multiple Scopes
Smell: A property set unconditionally in both
Directory.Build.props and a .csproj — last write wins silently.
Why it's bad: Hard to trace which value is actually used. Makes the build fragile and confusing for anyone reading the project files.
<!-- BAD: Directory.Build.props sets it, csproj silently overrides --> <!-- Directory.Build.props --> <PropertyGroup> <OutputPath>bin\custom\</OutputPath> </PropertyGroup> <!-- MyProject.csproj --> <PropertyGroup> <OutputPath>bin\other\</OutputPath> </PropertyGroup> <!-- GOOD: Use a condition so overrides are intentional --> <!-- Directory.Build.props --> <PropertyGroup> <OutputPath Condition="'$(OutputPath)' == ''">bin\custom\</OutputPath> </PropertyGroup> <!-- MyProject.csproj can now intentionally override or leave the default -->
For additional anti-patterns (AP-16 through AP-21) and a quick-reference checklist, see additional-antipatterns.md.