Skip to content

Add command to find redundant package references and version conflicts in NuGet - #282

Open
ChrisonSimtian wants to merge 2 commits into
dennisdoomen:mainfrom
ChrisonSimtian:feature/dependency-hygiene-analyzer
Open

ChrisonSimtian wants to merge 2 commits into
dennisdoomen:mainfrom
ChrisonSimtian:feature/dependency-hygiene-analyzer

Conversation

@ChrisonSimtian

@ChrisonSimtian ChrisonSimtian commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor

The analysis engine and the packageguard dependencies command for #281. That issue carries the motivation and three open questions; this is the working code behind it, so you can judge the shape rather than the description.

Problem

PackageGuard already reads every project's restore graph. The same graph answers two questions it does not ask yet:

  • Is this PackageReference needed? It is redundant when a referenced project, or another direct package, already brings the same package in.
  • Does one package resolve to different versions across projects? A common cause of "works in one project, fails in another", invisible until it breaks at runtime.

Both are dependency hygiene rather than policy or risk.

Change

Engine. DependencyHygieneAnalyzer in PackageGuard.Core takes the NuGet.ProjectModel.LockFile that DotNetLockFileLoader already produces. No new input path, no second restore, no network access.

Two rules are worth calling out, because they took a real run to get right:

  • A PrivateAssets="all" or AutoReferenced package is never a provider, and is never reported itself. It is not a dependency of what the project ships, so removing another reference because it pulls it in would drop a shipped dependency. Ignores_a_package_that_is_only_there_at_build_time covers it.
  • A reference is only safe to remove when the resolved version does not change. When the project asks for a higher version than any transitive requester, the finding says so and names the version it would drop to.

Version conflicts are grouped per target framework first, so a solution that resolves differently per framework on purpose is not reported.

Command. packageguard dependencies [--path <path>] [--conflicts] [--severity error] [--exclude <package>], plus the usual restore options (-i, -f, -s, -v).

  • Conflicts are only reported with --conflicts.
  • --severity error exits with code 1 when a reported finding exists, so the exit code never depends on a finding the output hides. The default, warning, always exits with 0.
  • The command does not use ProjectAnalyzer. A new LockFileCollector finds the projects and loads their lock files, and nothing else. It skips feed and license lookups, the package cache, policy loading and the npm scan. dotnet restore only runs when project.assets.json is missing or out of date.

Risk report. With analyze --report-risk, the same findings appear in the HTML report as a separate Dependency Hygiene section, and as one summary line on the console. They reuse the lock files the scan already loaded, so there is no extra restore or network access. They are not scored: they never change risk scores, zones, the overall status, violations or the exit code. SARIF is unchanged on purpose, because findings there would become code-scanning alerts and redundancy findings have no file location.

Docs. website/docs/dependencies.md, linked from usage.md, risk-metrics.md and the README.

Self-scan. RunPackageGuard now runs the command on this repo. It is informational only, because the command is run without --severity error. It currently reports 24 redundancy findings here, many of them false positives (for example JetBrains.Annotations in PackageGuard, which the code uses directly). That is the "the graph is not the source code" caveat showing up on our own solution. Making it a gate needs a baseline option or --exclude entries first.

Still open from #281

The command name, the DependencyHygieneAnalyzer name, and one finding type versus two are still to be decided. The command layer is small, so a rename is cheap.

Tests

New specs for the analyzer (in-memory LockFile through LockFileBuilder, so no restore runs), for LockFileCollector (no restore, project without a lock file) for the command settings (defaults, and which findings are reported), and for the report section (rendering, encoding, and an unchanged risk summary).

On this branch, rebased onto main: 454 specs pass on net9.0 and net10.0, and the API verification test passes. PackageGuard.Core builds with zero warnings under TreatWarningsAsErrors.

Two notes

  • AcceptApiChanges.sh does the wrong thing on .NET 10. Verify writes net8.0.DotNet10_0.received.txt while the approved file is net8.0.verified.txt, so the script's s/received/verified/ creates a new runtime-specific file and leaves the real one stale. I updated net8.0.verified.txt by hand. A separate fix would be welcome.
  • Project references are matched by .csproj file name. A project whose AssemblyName differs from its file name is not matched, so a redundancy through it is missed. That is a missing finding, never wrong advice, and it is listed under the risks in [API Proposal]: Find redundant package references and version conflicts #281.

https://claude.ai/code/session_01TK6dHqkZwhjWUrZNnnb8MG

🤖 Generated with Claude Code

@dennisdoomen
dennisdoomen force-pushed the feature/dependency-hygiene-analyzer branch from e8d307c to d1c6551 Compare October 11, 2026 06:39
@dennisdoomen dennisdoomen changed the title Find redundant package references and version conflicts in the restored graph Add command to find redundant package references and version conflicts in the restored graph Oct 11, 2026
@dennisdoomen dennisdoomen changed the title Add command to find redundant package references and version conflicts in the restored graph Add command to find redundant package references and version conflicts in NuGet Oct 11, 2026
/// <summary>
/// Gets the lowest version the project asked for, normalized from its version range.
/// </summary>
public string DeclaredVersion { get; init; } = "";
NuGetVersion? highestTransitive = target.Libraries
.SelectMany(library => library.Dependencies)
.Where(edge => string.Equals(edge.Id, dependency.Name, StringComparison.OrdinalIgnoreCase))
.Select(edge => edge.VersionRange?.MinVersion)
@@ -0,0 +1,193 @@
using System.Collections.Generic;
using System.Linq;
public void Does_not_report_a_conflict_when_the_frameworks_differ()
{
// Arrange
var api = new LockFileBuilder("Api", "net9.0").WithPackageReference("Serilog", "3.1.1").Build();
@@ -0,0 +1,133 @@
using System.Collections.Generic;
using System.Collections.Immutable;
/// <summary>
/// Gets or sets the logger used to report progress and diagnostics.
/// </summary>
public ILogger Logger { get; set; } = NullLogger.Instance;
/// <summary>
/// Gets or sets whether the .NET restore may prompt for input, such as feed credentials.
/// </summary>
public bool InteractiveRestore { get; set; } = true;
/// <summary>
/// Gets or sets whether to restore even if the lock file is up-to-date.
/// </summary>
public bool ForceRestore { get; set; }
/// <summary>
/// Gets or sets whether to never restore, even if the lock file is missing or out-of-date.
/// </summary>
public bool SkipRestore { get; set; }
/// <summary>
/// Gets the names of the direct dependencies that could already provide another package.
/// </summary>
public IEnumerable<string> ProviderRoots =>
… conflicts

Add DependencyHygieneAnalyzer, which reads the restore graph that
DotNetLockFileLoader already loads and reports:

- package references that a referenced project or another direct package
  already provides, and whether removing them keeps the resolved version
- packages that resolve to different versions across projects, grouped per
  target framework

A PrivateAssets="all" or auto-referenced package is never treated as a
provider, so a shipped dependency is not reported as removable.

Expose it as `packageguard dependencies` with --conflicts, --severity,
--exclude and the restore options. The command loads lock files through a new
LockFileCollector instead of the full policy analysis, so it skips feed and
license lookups, the package cache, policy loading and the npm scan.

Run the command from the RunPackageGuard build target, without
--severity error, so it is exercised but cannot fail the build yet.

Co-Authored-By: Chrison Simtian <csimon@chrison.dev>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@dennisdoomen
dennisdoomen force-pushed the feature/dependency-hygiene-analyzer branch from d1c6551 to 77d2f5f Compare October 11, 2026 06:48
When analyze runs with --report-risk, reuse the lock files the scan already
loaded to find redundant references and version conflicts, and list them in a
separate Dependency Hygiene section. They are not scored, so they never change
the risk zones, the overall status or the exit code. SARIF is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants