The hardest failures in an older C# project are often not language errors. They occur when the build graph described by the project file no longer matches the repository: a source file exists but is absent from Compile, an assembly reference points to a vanished HintPath, or a post-build action assumes SolutionDir is always defined even when a .csproj is built directly. Old bin folders, shared output directories, and DLLs copied by another project can temporarily hide each defect, producing contradictory results across machines or between the first and second build.

This guide presents an MSBuild project integrity method that treats source membership, reference ownership, and build properties as three separate contracts. Each contract is tested in a clean, isolated output location before a repair is accepted. The search intent is informational troubleshooting at the MOFU stage. The North Star Metric is clean-environment first-build pass rate. Supporting KPIs include unresolved-reference count, missing compile-item count, builds that depend on residual output, and repository files changed by direct project builds.

Why can an older csproj ignore a C# file that clearly exists in the folder?

MSBuild project integrity starts with source membership: a source file participates in an older project only when project evaluation places it in the Compile item set. File-system presence is not membership, so committing a new .cs file without the corresponding project-file entry can leave the compiler completely unaware of the type.

Microsoft's .NET project SDK documentation explains that current SDK-style projects include Compile items from the project directory by default. It also documents EnableDefaultCompileItems, which can disable that behavior. The contrast matters: experience with modern implicit inclusion must not be projected onto an older project that explicitly lists every `` entry.

A common sequence is easy to misdiagnose. A developer adds a namespace and several classes, updates a caller, and commits all visible source files. Another checkout reports CS0234, CS0246, or CS0103. The first reaction may be to change using statements, reinstall a package, or clear an IDE cache. None of those actions help if the compiler never received the file as an input.

Start with project evaluation, not the editor's Solution Explorer. Compare the source change with the project-file history. If the source path appears in a commit but never appears in the project file, the defect is source membership. Add the precise relative paths rather than a broad wildcard that might capture backups, generated files, or experiments that do not belong in the build.


  
  

After editing, do not rely only on the IDE Build button. Use an isolated command-line build with fresh bin and obj locations. The pass condition is that the original missing-type error disappears and the process exits zero on the first clean build. If the project file historically uses a BOM and CRLF, preserve that representation as well. A two-line semantic repair should not become a whole-file encoding rewrite that obscures review and increases merge conflicts.

The same principle applies beyond C#. Resources, content, analyzers, and generated inputs all become MSBuild items after evaluation. A file being visible in a repository is only evidence that it exists. The evaluated item graph determines whether a compiler, copier, packer, or publisher receives it.

Why can a broken HintPath fail once and then appear to build successfully?

HintPath is a candidate location used during assembly resolution, not proof of the final file selected. If that location is invalid, a dirty output directory or a same-named DLL copied by another project can still satisfy resolution, making a later build look healthy for the wrong reason.

The MSBuild common items reference documents HintPath metadata on a Reference item. Microsoft's assembly-reference troubleshooting guidance recommends a binary log and inspection of ResolveAssemblyReference to understand where MSBuild searched and why a candidate was accepted or rejected. Together, these sources support a practical rule: the path written in a project file is not sufficient evidence of the resolved assembly.

Shared output directories amplify the risk. Project A may place a model DLL in a common directory. Project B has a stale HintPath to a deleted sibling repository, but its later build discovers the same-named DLL already present in output. A new output directory removes the accidental provider, and the unresolved reference finally becomes visible. That is why “the second build passed” should trigger investigation rather than confidence.

A clean reference test should allocate a unique OutDir for every project and exclude undeclared external locations from the test search paths without deleting their directories. Treat MSB3245 and related unresolved-reference warnings as failures even when a downstream target happens to emit an assembly. Warnings at the reference boundary often mean the build result depends on context that is not encoded in the repository.

When the library and consumer belong to the same available source graph, evaluate a ProjectReference. Microsoft's common items documentation describes ProjectReference as a reference to another project. This expresses build order and ownership more accurately than a file path pointing to one developer's workspace or a sibling directory that no longer exists.


  

Do not convert every binary reference automatically. A reviewed external assembly or package is a valid boundary when the consumer is intentionally built against a released artifact. The decision depends on ownership. Use a project reference for code built from the same controlled source graph. Use a versioned package or controlled file reference across a release boundary. The invalid fourth category is a same-named DLL whose origin is unknown but happens to be present in a dirty directory.

A ProjectReference repair also needs cycle analysis. Confirm that the referenced project does not depend on the consumer through another path. Build both the referenced library and consumer in an empty sandbox, verify the expected DLL appears in the consumer output, and record the actual source project. The goal is not merely to eliminate a compiler error; it is to make the dependency graph explicit and reproducible.

Why is SolutionDir unsafe to assume during a direct project build?

SolutionDir is a solution-level macro. The linked archived Build Events page defines it as the solution directory; it does not guarantee availability during a direct project invocation. A direct .csproj build therefore cannot safely depend on it unless the caller supplies a reviewed value or the target handles its absence.

Older project files often contain a post-build command that copies $(TargetPath) into $(SolutionDir)Libraries\Internal. The command works when invoked through a particular .sln, then fails when a developer builds one project, a test harness injects a custom output, or automation evaluates the project independently. Depending on the toolchain and command, the property can be empty or otherwise unsuitable for path construction.

A missing property should fail closed before any copy begins. Validate both the property and the resolved destination. The resolved path must remain under an approved sandbox root. Never let an empty variable broaden the operation or turn a verification build into a write against the working repository.

There are three levels of repair. First, remove the post-build copy if project dependencies, packaging, or the release workflow can own artifact movement. Second, if the legacy behavior cannot yet be removed, add an explicit condition that rejects an absent destination. Third, for direct-project verification, provide isolated SolutionDir and OutDir values so every side effect lands in a disposable location.

dotnet msbuild SolutionDirProbe.proj -nologo -v:minimal
dotnet msbuild SolutionDirProbe.proj -nologo -v:minimal \
  -p:SolutionDir="C:/sandbox/build-root/" \
  -p:OutDir="C:/sandbox/output/"

The first command should fail when the probe requires SolutionDir. The second should succeed only after the isolated directories are supplied. After either test, inspect version-control status. Any new or modified repository artifact means the build still has an uncontrolled side effect.

This is also a design signal. A project that cannot be built without knowing a particular solution layout is less reusable in tests, dependency analysis, and incremental modernization. Removing the assumption improves composability even if the application remains on an older project format.

How should a clean build be designed so residual output cannot lie?

A trustworthy clean build allocates fresh output and intermediate directories, rejects unresolved-reference warnings, records the final assembly source, and verifies that no target writes outside the isolated root. Deleting the ordinary bin folder alone is not enough.

Standard Clean targets do not necessarily remove custom copy destinations, shared library folders, files produced outside the project, or artifacts manually placed in a common output directory. A stronger procedure creates a new sandbox for every verification run and confirms that each destination does not exist beforehand. Each project receives a unique output location, so one project cannot silently provide an undeclared input to another.

For assembly-resolution analysis, add -bl and inspect the binary log with MSBuild Structured Log Viewer. Search for ResolveAssemblyReference, the assembly name, HintPath, and the final resolved file. The log can distinguish a valid hint from a fallback search path or an unresolved candidate. Do not publish machine-specific absolute paths; retain them only in local evidence when needed for diagnosis.

The acceptance record should cover all of these points:

  • Every expected source appears in the evaluated Compile items.
  • Every required assembly resolves from an approved project, package, or binary location.
  • Unresolved-reference warnings fail the verification instead of being ignored.
  • A referenced project builds before the consumer and its output is copied as expected.
  • Post-build activity writes only beneath the isolated root.
  • The first and second builds have the same outcome; success only on the second run is a defect.
  • Version-control status is unchanged by the build.

The clean build should also separate diagnosis from repair. A temporary target import can prove that adding a missing item or reference is sufficient without editing the project under investigation. Once the hypothesis is confirmed, make the smallest reviewed project-file change, preserve encoding conventions, and repeat the isolated build without the temporary injection. This prevents a speculative edit from becoming evidence for itself.

A binary log is useful, but it is not the only gate. The actual output should be inspected for the referenced DLL. If the application expects a compile item, the compiler response file or evaluated project should contain it. If a target expects a property, the log should show the value used. Reproducibility comes from combining graph evidence, file evidence, and observable process results.

Verification and reproduction with a pinned .NET 10 MSBuild sandbox

This run used .NET SDK 10.0.400 in an isolated Windows sandbox and executed six checks that reproduce missing Compile membership, a broken HintPath, and an absent SolutionDir, followed by the corresponding repaired cases.

The fixture uses SDK-style projects with EnableDefaultCompileItems=false to emulate explicit source membership. It verifies MSBuild item and reference behavior; it does not claim that the scheduler rebuilt any private .NET Framework repository or exercised every Visual Studio-specific target.

  1. Create a new test root and a global.json that pins SDK 10.0.400 with roll-forward disabled.
  2. Create CompileBroken with only Program.cs in its Compile list while MessageProvider.cs exists on disk. Build it and require nonzero exit plus CS0103.
  3. Create CompileFixed with both source files listed. Build it and require exit code zero.
  4. Create BuildModel, then create ReferenceBroken with a HintPath to a missing DLL. Require nonzero exit plus CS0246. Create ReferenceFixed with ProjectReference; require exit zero and BuildModel.dll in the consumer output.
  5. Run a project that requires SolutionDir without supplying it and require SOLUTIONDIR_EMPTY. Run it again with a sandbox path and require exit zero plus a marker beneath that path.
  6. Pass the suite only if every expected exit code, diagnostic string, and output file matches. An unexpected success in a broken case indicates leaked state.

The central repaired project shape is:


  
    Exe
    net10.0
    false
  
  
    
    
    
  

Create these complete files at the shown relative paths in a new test root. They match the files used in the executed fixture.

global.json

{
  "sdk": {
    "version": "10.0.400",
    "rollForward": "disable"
  }
}

CompileBroken/CompileBroken.csproj


  
    Exe
    net10.0
    disable
    enable
    false
  
  
    
  

CompileBroken/Program.cs

using System;

Console.WriteLine(MessageProvider.GetMessage());

CompileBroken/MessageProvider.cs

internal static class MessageProvider
{
    internal static string GetMessage() => "explicit compile item included";
}

CompileFixed/CompileFixed.csproj


  
    Exe
    net10.0
    disable
    enable
    false
  
  
    
    
  

CompileFixed/Program.cs

using System;

Console.WriteLine(MessageProvider.GetMessage());

CompileFixed/MessageProvider.cs

internal static class MessageProvider
{
    internal static string GetMessage() => "explicit compile item included";
}

BuildModel/BuildModel.csproj


  
    net10.0
    disable
    enable
  

BuildModel/BuildMessage.cs

namespace BuildModel;

public static class BuildMessage
{
    public static string Value => "project reference resolved";
}

ReferenceBroken/ReferenceBroken.csproj


  
    Exe
    net10.0
    disable
    enable
  
  
    
      ..\missing\BuildModel.dll
    
  

ReferenceBroken/Program.cs

using System;
using BuildModel;

Console.WriteLine(BuildMessage.Value);

ReferenceFixed/ReferenceFixed.csproj


  
    Exe
    net10.0
    disable
    enable
  
  
    
  

ReferenceFixed/Program.cs

using System;
using BuildModel;

Console.WriteLine(BuildMessage.Value);

SolutionDirProbe.proj


  
    
    
    
    
  

Probe boundary: the probe rejects an empty SolutionDir only. It does not enforce sandbox containment or reject every undefined-value sentinel used by older toolchains. The test caller supplies the isolated path; this is not a production path-security component. Replace the illustrative C:/sandbox paths with a fresh disposable directory, never an existing project. The earlier OutDir argument illustrates property passing only; this probe does not consume it.

Run each command separately in Windows Git Bash. Do not chain the expected-failure cases with &&:

dotnet --version
dotnet build CompileBroken/CompileBroken.csproj --configuration Release --nologo --verbosity minimal
dotnet build CompileFixed/CompileFixed.csproj --configuration Release --nologo --verbosity minimal
dotnet build ReferenceBroken/ReferenceBroken.csproj --configuration Release --nologo --verbosity minimal
dotnet build ReferenceFixed/ReferenceFixed.csproj --configuration Release --nologo --verbosity minimal
dotnet msbuild SolutionDirProbe.proj -nologo -v:minimal
dotnet msbuild SolutionDirProbe.proj -nologo -v:minimal -p:SolutionDir="C:/sandbox/isolated-solution/"

Expected acceptance summary (not verbatim console output):

CompileBroken: exit 1, CS0103
CompileFixed: exit 0
ReferenceBroken: exit 1, MSB3245 and CS0246
ReferenceFixed: exit 0, BuildModel.dll present
SolutionDir absent: exit 1, SOLUTIONDIR_EMPTY
SolutionDir supplied: exit 0, SOLUTIONDIR_OK, probe.txt present

All six checks produced the expected real results. The missing Compile case exited nonzero with CS0103; adding the item exited zero. The missing HintPath case emitted MSB3245 and failed with CS0246; the ProjectReference case exited zero and copied BuildModel.dll to the consumer output. The direct property probe failed with SOLUTIONDIR_EMPTY; the isolated SolutionDir run exited zero and created its marker under the sandbox.

The failure conditions are deliberately strict. If a broken project succeeds, an undeclared input is still available. If a fixed project fails, the repair is incomplete. If the referenced DLL is absent from output, the dependency graph is not producing the expected consumer state. If the marker appears outside the sandbox, path isolation has failed. No benchmark, availability figure, or performance gain is inferred from this correctness test.

Readers reproducing the test on another machine should keep the SDK and project files fixed, use a new directory, and compare exit codes and named diagnostics rather than localized success text. Visual Studio MSBuild and older .NET Framework toolsets can produce different wording, so error codes and observable files are the stable acceptance signals.

What review checklist should gate an MSBuild project-file repair?

Accept the repair only when a fresh output can be reconstructed from controlled inputs on the first attempt. An IDE without red markers, a second successful build, or an unexplained same-named DLL is not sufficient evidence.

Before approving a change, verify the following:

  • Every added or moved .cs file is represented in explicit Compile items where the project format requires it.
  • The project file preserves its established encoding, BOM, and line-ending policy.
  • Every HintPath target exists and is either controlled source output or a reviewed binary artifact.
  • Libraries in the same source graph have been evaluated for ProjectReference, including cycle checks.
  • The first build into a fresh OutDir passes without borrowing old bin contents.
  • Direct .csproj builds do not assume SolutionDir exists; any supplied value points to an isolated root.
  • Post-build copies cannot overwrite tracked repository binaries or escape the approved directory.
  • A binary log identifies the final source of each critical assembly.
  • Version-control status is unchanged after the verification build, except for the intended project-file edit.

This search intent is distinct from the existing article on .NET DLL deployment verification. That page governs release candidate sets, hashes, assembly versions, and runtime load identity after binaries have been built. This article governs the build graph before that stage: source membership, assembly-reference ownership, and solution-property assumptions. It is also distinct from the GitLab IIS deployment guide, which focuses on staging, service state, and directory promotion.

The durable rule is to treat a project file as executable build specification rather than incidental IDE metadata. Each source item, reference, and path-bearing property needs an explicit owner and a reproducible test. Once the first clean build is the acceptance boundary, “works on the second attempt” stops being reassurance and becomes the actionable evidence that it should have been all along.

Official references and claim scope

Reopened on 2026-10-17. Original verifiedReferences are retained for provenance. The archived Build Events page supports the macro definition, not the full initialization-timing assertion in the original metadata. The missing-value behavior is evidenced by this limited probe, not claimed for every toolchain.