Skip to content

packages: add a find-unused verb for package references no source uses - #175

Merged
matt-edmondson merged 4 commits into
mainfrom
claude/packages-find-unused
Sep 22, 2026
Merged

matt-edmondson merged 4 commits into
mainfrom
claude/packages-find-unused

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #123

What this adds

ktsu packages find-unused --path . reports PackageReferences that the declaring project's own source never refers to. It scans every .csproj beneath the given path, so the same verb serves a single project, a solution, or a workspace of cached repos.

Scanned 17 project(s).
╭──────────────────────────────────────────────┬────────────────────────╮
│ Project                                      │ Package                │
├──────────────────────────────────────────────┼────────────────────────┤
│ KtsuTools.Core/KtsuTools.Core.csproj         │ ktsu.RunCommand        │
│ KtsuTools.Machine/KtsuTools.Machine.csproj   │ Humanizer.Core         │
│ KtsuTools.Test/KtsuTools.Test.csproj         │ FluentAssertions       │
│ …                                            │                        │
╰──────────────────────────────────────────────┴────────────────────────╯
24 package reference(s) appear unused.
Reported only. Removing a reference stays a human decision.

How "used" is decided

A package counts as used when a using directive, an MSBuild Using item, or the raw source names it. Package ids and the namespaces they ship rarely match exactly, so three shapes resolve:

Shape Example
Namespace beneath the id Spectre.Console.Rendering for Spectre.Console
Namespace is a parent of the id Microsoft.Extensions.Logging for …Logging.Abstractions
Id carries a glued suffix LibreHardwareMonitor.Hardware for LibreHardwareMonitorLib

The glued-suffix rule came out of running this against KtsuTools itself, where LibreHardwareMonitorLib was initially reported despite MachineMonitorService.cs using it. Stripping only ever adds a candidate, so it cannot lose a match.

Avoiding false positives

The acceptance criteria call out that the report is worthless if it is full of things that cannot appear in source. Those are classified BuildTimeOnly rather than unused and kept out of the main table, surfacing only under --show-build-time:

  • PrivateAssets="all" references
  • ExcludeAssets containing compile/all, or IncludeAssets that omits compile assets
  • Ids matching known build-time fragments — analyzers, source generators, StyleCop, Roslynator, Polyfill, SourceLink, coverlet, test adapters, MSBuild task packages

Orphaned PackageVersion entries in Directory.Packages.props are reported as a second section, since the issue notes CPM makes an unused entry invisible at the project level. Directory.Build.props/.targets count as referencing files, so the packages they add to every project (Polyfill, SourceLink here) are not mistaken for orphans.

Reports only — no removal, per the acceptance criteria.

Testing

149/149 passing, build clean with 0 warnings, new-code coverage 93.4% per SonarCloud.

UnusedPackageAnalyzerTests (15) pins the classification rules. Each was proven load-bearing by mutating the behaviour it guards and confirming that test, and only that test, failed:

Mutation Test that caught it
Build-time-only classification disabled HoldsBackAnalyzersAndBuildTimeOnlyReferences
bin/obj exclusion removed IgnoresBuildOutputUnderBinAndObj
Parent-namespace rule removed TreatsAParentNamespaceAsUse
Directory.Build.* scan removed DoesNotOrphanAPackageVersionThatDirectoryBuildPropsReferences
Glued-suffix candidate removed TreatsAGluedIdSuffixAsUse

PackagesServiceTests (8) and PackagesFindUnusedCommandTests (4) cover what the verb actually prints — which for a report-only command is the whole deliverable: both FindUnusedAsync overloads, findings listed, nothing found, no projects, a missing path, build-time-only hidden until asked for, the orphaned PackageVersion section, and the command's file-versus-directory dispatch.

The console-capture helper RepoServiceTests already had moved to a shared ConsoleCapture rather than being duplicated.

Note on the findings above

Running the verb against this repo surfaces 24 apparently-unused references, including ktsu.Semantics.Strings across 11 projects and the direct Semantics references on FileDedupe that #138 flagged as a possible tidy-up. Those are left alone here — this PR adds the reporting, and acting on it stays a separate, human call.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ApcD2dHnUMQG8DDb4ixJ6S

…s [minor]

Neither `packages update` nor `packages migrate-cpm` answers which
PackageReferences are declared but never actually used, and CPM makes an
unused entry invisible at the project level.

`ktsu packages find-unused --path .` scans every .csproj beneath a path, so
it works for a single project, a solution, or a workspace of cached repos.
A reference counts as used when a using directive, an MSBuild Using item, or
the raw source names the package, allowing for the namespace and the package
id rarely matching exactly: a namespace beneath the id, a parent of it, or an
id carrying a glued suffix such as LibreHardwareMonitorLib all resolve.

References that cannot appear in source by design are classified build-time
only rather than unused, so analyzers, MSBuild task packages, PrivateAssets
="all" references and any whose compile assets are suppressed stay out of the
report instead of filling it with false positives. They are listed only under
--show-build-time.

Orphaned PackageVersion entries are reported too, counting
Directory.Build.props/.targets as referencing files so the packages they add
to every project are not mistaken for orphans.

Reports only; removing a reference stays a human decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApcD2dHnUMQG8DDb4ixJ6S
Each test pins one rule: reporting a genuinely unused reference, the three
namespace-to-id shapes that count as use, the glued-suffix case, holding back
analyzers and build-time-only references, ignoring bin/obj output, scanning a
workspace versus a single project, and orphaned PackageVersion detection
including the Directory.Build.props exemption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApcD2dHnUMQG8DDb4ixJ6S
Comment thread KtsuTools.Packages/UnusedPackageAnalyzer.cs Fixed
Comment thread KtsuTools.Packages/UnusedPackageAnalyzer.cs Fixed
…body [patch]

Both scans walked every descendant and filtered inside the loop. Moving the
filter into the pipeline leaves each loop doing only its own work, and lets
the set take the results directly. Behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApcD2dHnUMQG8DDb4ixJ6S
…patch]

The analyzer was well covered but the service and command around it were not,
leaving new-code coverage at 59.9% against an 80% gate. What a report-only
verb prints is its whole deliverable, so the gap was in the part that matters
most.

PackagesServiceTests covers both FindUnusedAsync overloads and each branch of
the rendering: findings listed, nothing found, no projects, a missing path,
build-time-only references hidden until asked for, and the orphaned
PackageVersion section. PackagesFindUnusedCommandTests covers the verb's
file-versus-directory dispatch and its flag.

The console-capture helper RepoServiceTests already had moves to ConsoleCapture
so both classes share one copy rather than duplicating it.

New-code coverage measured locally at 93.6% (277/296 added lines).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApcD2dHnUMQG8DDb4ixJ6S
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit d4edab0 into main Sep 22, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/packages-find-unused branch September 22, 2026 00:35
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.

packages: detect unused PackageReferences across solutions

2 participants