CycloneDX / CycloneDX/cyclonedx-dotnet
--exclude-filter removes all components for any .sln/.slnx/.slnf input, and for single-project input without --recursive
- 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
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