CycloneDX / CycloneDX/cyclonedx-dotnet

--exclude-filter removes all components for any .sln/.slnx/.slnf input, and for single-project input without --recursive

Open
#1,126 0 comments 0 reactions 0 assignees View on GitHub
triage
Dominant language
C#
Stars
294
Forks
123
PR merge metrics
No merged PRs in 30d

Description

# Summary

Passing any value to `--exclude-filter` — including a package name that doesn't exist in the dependency graph — causes the entire component list to be wiped out ("`Found 0 packages`") in two situations:
1. Any solution input (`.sln`, `.slnx`, `.slnf`), with or without `--recursive` (the flag has no effect here — see root cause).
2. A single project file (`.csproj` etc.) without `--recursive`.

It works correctly only for: a single project file with `--recursive`

Reproduced on `v6.2.0` (`latest`); traced back to the commit that introduced `--exclude-filter` (#939, 1615122, 2025-04-27) — present in every release since.

## Repro

### (1) Broken: solution filter, `--recursive` has no effect either way
`dotnet-CycloneDX MySolution.slnf --disable-package-restore --exclude-filter "PackageThatDoesNotExist" --filename bom.json`
`dotnet-CycloneDX MySolution.slnf --recursive --disable-package-restore --exclude-filter "PackageThatDoesNotExist" --filename bom.json`

### (2) Broken: single project, no `--recursive`
`dotnet-CycloneDX MyProject.csproj --disable-package-restore --exclude-filter "PackageThatDoesNotExist" --filename bom.json`

### (3) Works: single project, with `--recursive`
`dotnet-CycloneDX MyProject.csproj --recursive --disable-package-restore --exclude-filter "PackageThatDoesNotExist" --filename bom.json`

Cases (1) and (2) print, for every restored package:

The following orphaned packages have been removed:
-
Found 0 packages

Case (3) correctly prints `No orphaned packages were found.` and produces a full component list.

## Root cause

ExcludeFilterHelper.RemoveOrphanedPackages (ExcludeFilterHelper.cs) does a BFS starting only from packages with IsDirectReference == true, then intersects the package set with whatever's reachable:

var queue = new Queue(packages.Where(p => p.IsDirectReference));
...
packages.IntersectWith(reachablePackages);

This runs in Runner.cs right after packages are loaded:

if (!string.IsNullOrEmpty(options.DependencyExcludeFilter))
{
ExcludeFilterHelper.ExcludePackages(packages, options.DependencyExcludeFilter);
ExcludeFilterHelper.RemoveOrphanedPackages(packages); // ~line 251
}

But at this point in the flow, IsDirectReference has not been populated for most input types:

- ProjectFileService.GetProjectDotnetDependencysAsync (the method used for a plain single project without --recursive, and also used internally, per-project, by the solution path below) never sets IsDirectReference on anything.
- ProjectFileService.RecursivelyGetProjectDotnetDependencysAsync (used only for a single project with --recursive) does set it immediately on load — this is the one path that works.
- For .sln/.slnx/.slnf input, Runner.cs branches to SolutionFileService.GetSolutionDotnetDependencys before ever checking --recursive (Runner.cs:171-182), so the flag has no effect for solutions at all. That method does contain logic that looks like it should propagate direct-reference
status per project:

// SolutionFileService.cs:185-199
var projectPackages = await _projectFileService.GetProjectDotnetDependencysAsync(...);
directReferencePackages.UnionWith(projectPackages.Where(p => p.IsDirectReference));
...
foreach (var directPackage in directReferencePackages)
{
packages.TryGetValue(directPackage, out var package);
package.IsDirectReference = true;
}

- but since GetProjectDotnetDependencysAsync never sets IsDirectReference in the first place, directReferencePackages is always empty and this block is effectively dead code.
- The only place that actually populates IsDirectReference for these paths is Runner.cs's RemoveProjectReferencesAndMakeTheirDependenciesDirect(packages) call — but that runs after the exclude-filter block (~line 344 vs ~line 251).

So for every input type except "single project + --recursive", the BFS starts from an empty set, and the filter step deletes everything.

Suggested fix

Ensure IsDirectReference is populated before the exclude-filter block runs, for all input types — e.g. by moving/duplicating the RemoveProjectReferencesAndMakeTheirDependenciesDirect call (or equivalent) earlier in Runner.cs, and/or fixing GetProjectDotnetDependencysAsync to set the flag
on load so SolutionFileService's existing propagation logic actually does something.

Environment

- cyclonedx-dotnet (CycloneDX global tool) 6.2.0+55877e2ae058ae9686783ac084d2257d3fcedab1
- .NET SDK 10.0.301
- Reproduced against a single leaf .csproj, a single .csproj with --recursive, and a multi-project .slnf

Related

- #445 (a related but distinct issue: --exclude-dev correctly drops top-level PrivateAssets="All" packages but leaves their transitive-only children behind as orphans)

Contributor guide

Open the contributing guide

Research direction

Start with Runner.cs around the solution-input branch and the exclude-filter block near lines 171-182 and 251, then trace ExcludeFilterHelper.cs and the ProjectFileService and SolutionFileService dependency-loading methods. Run the three repro commands from the issue against a nonexistent package filter. Done means solution and non-recursive project inputs retain their components, while recursive project behavior remains correct.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
cli
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.