From 2d097a150f271d076c0b3c81e004d49b05469c12 Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Wed, 29 Oct 2025 12:25:17 +0000 Subject: [PATCH 1/5] Separated paket.lock handling from NuGetComponentDetector to PaketComponentDetector. --- docs/detectors/README.md | 8 +- docs/detectors/nuget.md | 2 +- docs/detectors/paket.md | 60 +++ .../nuget/NuGetComponentDetector.cs | 2 +- .../paket/PaketComponentDetector.cs | 142 ++++++ .../Extensions/ServiceCollectionExtensions.cs | 4 + .../PaketComponentDetectorTests.cs | 431 ++++++++++++++++++ 7 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 docs/detectors/paket.md create mode 100644 src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs create mode 100644 test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs diff --git a/docs/detectors/README.md b/docs/detectors/README.md index 16c205400..2eae42658 100644 --- a/docs/detectors/README.md +++ b/docs/detectors/README.md @@ -58,7 +58,7 @@ | NpmLockFileDetector | Stable | | NpmLockFile3Detector | Experimental | -- NuGet +- [NuGet](nuget.md) | Detector | Status | | ------------------------------------------------ | ------ | @@ -66,6 +66,12 @@ | NugetPackagesConfigDetector | Stable | | NuGetProjectModelProjectCentricComponentDetector | Stable | +- [Paket](paket.md) + +| Detector | Status | +| --------------------- | ------ | +| PaketComponentDetector | Stable | + - [Pip](pip.md) | Detector | Status | diff --git a/docs/detectors/nuget.md b/docs/detectors/nuget.md index f1e8f0670..1b198824e 100644 --- a/docs/detectors/nuget.md +++ b/docs/detectors/nuget.md @@ -6,7 +6,7 @@ NuGet Detection depends on the following to successfully run: - One or more `*.nuspec`, `*.nupkg`, `*.packages.config`, or `.*csproj` files. - The files each NuGet detector searches for: - - [The `NuGet` detector looks for `*.nupkg`, `*.nuspec`, `nuget.config`, `paket.lock`][1] + - [The `NuGet` detector looks for `*.nupkg`, `*.nuspec`, `nuget.config`][1] - [The `NuGetPackagesConfig` detector looks for `packages.config`][2] - [The `NuGetProjectCentric` detector looks for `project.assets.json`][3] diff --git a/docs/detectors/paket.md b/docs/detectors/paket.md new file mode 100644 index 000000000..9f2c46231 --- /dev/null +++ b/docs/detectors/paket.md @@ -0,0 +1,60 @@ +# Paket Detection + +## Requirements + +Paket Detection depends on the following to successfully run: + +- One or more `paket.lock` files. +- The Paket detector looks for [`paket.lock`][1] files. + +[1]: https://github.com/microsoft/component-detection/blob/main/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs + +## Detection Strategy + +Paket Detection is performed by parsing any `paket.lock` files found under the scan directory. + +The `paket.lock` file is a lock file that records the concrete dependency resolution of all direct and transitive dependencies of your project. It is generated by [Paket][2], an alternative dependency manager for .NET that is popular in both large-scale C# projects and small-scale F# projects. + +[2]: https://fsprojects.github.io/Paket/ + +## What is Paket? + +Paket is a dependency manager for .NET and Mono projects that provides: +- Precise control over package dependencies +- Reproducible builds through lock files +- Support for multiple package sources (NuGet, GitHub, HTTP, Git) +- Better resolution algorithm compared to legacy NuGet + +The `paket.lock` file structure is straightforward and human-readable: +``` +NUGET + remote: https://api.nuget.org/v3/index.json + PackageName (1.0.0) + DependencyName (>= 2.0.0) +``` + +## Paket Detector + +The Paket detector parses `paket.lock` files to extract: +- Package names and versions +- Direct dependencies (packages explicitly listed) +- Transitive dependencies (dependencies of dependencies) +- Dependency relationships between packages + +Currently, the detector focuses on the `NUGET` section of the lock file, which contains NuGet package dependencies. Other dependency types (GITHUB, HTTP, GIT) are not currently supported. + +## How It Works + +The detector: +1. Locates `paket.lock` files in the scan directory +2. Parses the file line by line +3. Identifies packages (4-space indentation) and their versions +4. Identifies dependencies (6-space indentation) and their version constraints +5. Records all packages as NuGet components +6. Establishes parent-child relationships between packages and their dependencies + +## Known Limitations + +- Only NuGet dependencies from the `NUGET` section are detected +- GitHub, HTTP, and Git dependencies are not currently supported +- The detector assumes the lock file format follows the standard Paket conventions diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs index f852b43f8..36c883ff0 100644 --- a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs @@ -38,7 +38,7 @@ public NuGetComponentDetector( public override IEnumerable Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet)]; - public override IList SearchPatterns { get; } = ["*.nupkg", "*.nuspec", NugetConfigFileName, "paket.lock"]; + public override IList SearchPatterns { get; } = ["*.nupkg", "*.nuspec", NugetConfigFileName]; public override IEnumerable SupportedComponentTypes { get; } = [ComponentType.NuGet]; diff --git a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs new file mode 100644 index 000000000..c707fb399 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs @@ -0,0 +1,142 @@ +namespace Microsoft.ComponentDetection.Detectors.Paket; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.Internal; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.Extensions.Logging; + +/// +/// Detects NuGet packages in paket.lock files. +/// Paket is a dependency manager for .NET that provides better control over package dependencies. +/// +public sealed class PaketComponentDetector : FileComponentDetector +{ + private static readonly Regex PackageLineRegex = new(@"^\s{4}(\S+)\s+\(([^\)]+)\)", RegexOptions.Compiled); + private static readonly Regex DependencyLineRegex = new(@"^\s{6}(\S+)\s+\((.+)\)", RegexOptions.Compiled); + + /// + /// Initializes a new instance of the class. + /// + /// The factory for handing back component streams to File detectors. + /// The factory for creating directory walkers. + /// The logger to use. + public PaketComponentDetector( + IComponentStreamEnumerableFactory componentStreamEnumerableFactory, + IObservableDirectoryWalkerFactory walkerFactory, + ILogger logger) + { + this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; + this.Scanner = walkerFactory; + this.Logger = logger; + } + + /// + public override IList SearchPatterns => ["paket.lock"]; + + /// + public override string Id => "Paket"; + + /// + public override IEnumerable Categories => + [Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet)]; + + /// + public override IEnumerable SupportedComponentTypes => [ComponentType.NuGet]; + + /// + public override int Version => 1; + + /// + protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) + { + try + { + var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; + using var reader = new StreamReader(processRequest.ComponentStream.Stream); + + var currentSection = string.Empty; + string currentPackageName = null; + string currentPackageVersion = null; + DetectedComponent currentComponent = null; + + while (await reader.ReadLineAsync(cancellationToken) is { } line) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + // Check if this is a section header (e.g., NUGET, GITHUB, HTTP) + if (!line.StartsWith(' ') && line.Trim().Length > 0) + { + currentSection = line.Trim(); + currentPackageName = null; + currentPackageVersion = null; + currentComponent = null; + continue; + } + + // Only process NUGET section for now + if (!currentSection.Equals("NUGET", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Check if this is a remote line (source URL) + if (line.TrimStart().StartsWith("remote:", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Check if this is a package line (4 spaces indentation) + var packageMatch = PackageLineRegex.Match(line); + if (packageMatch.Success) + { + currentPackageName = packageMatch.Groups[1].Value; + currentPackageVersion = packageMatch.Groups[2].Value; + + currentComponent = new DetectedComponent( + new NuGetComponent(currentPackageName, currentPackageVersion)); + + singleFileComponentRecorder.RegisterUsage( + currentComponent, + isExplicitReferencedDependency: true); + + continue; + } + + // Check if this is a dependency line (6 spaces indentation) + var dependencyMatch = DependencyLineRegex.Match(line); + if (dependencyMatch.Success && currentComponent != null) + { + var dependencyName = dependencyMatch.Groups[1].Value; + var dependencyVersionSpec = dependencyMatch.Groups[2].Value; + + // Extract the actual version from the version specification + // Version specs can be like ">= 3.3.0" or "1.2.10" + var versionMatch = Regex.Match(dependencyVersionSpec, @"[\d\.]+"); + if (versionMatch.Success) + { + var dependencyComponent = new DetectedComponent( + new NuGetComponent(dependencyName, versionMatch.Value)); + + singleFileComponentRecorder.RegisterUsage( + dependencyComponent, + isExplicitReferencedDependency: false, + parentComponentId: currentComponent.Component.Id); + } + } + } + } + catch (Exception e) when (e is IOException or InvalidOperationException) + { + this.Logger.LogWarning(e, "Failed to read paket.lock file {File}", processRequest.ComponentStream.Location); + } + } +} diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index 1ee0f776b..447cdbce1 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -14,6 +14,7 @@ namespace Microsoft.ComponentDetection.Orchestrator.Extensions; using Microsoft.ComponentDetection.Detectors.Maven; using Microsoft.ComponentDetection.Detectors.Npm; using Microsoft.ComponentDetection.Detectors.NuGet; +using Microsoft.ComponentDetection.Detectors.Paket; using Microsoft.ComponentDetection.Detectors.Pip; using Microsoft.ComponentDetection.Detectors.Pnpm; using Microsoft.ComponentDetection.Detectors.Poetry; @@ -116,6 +117,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s services.AddSingleton(); services.AddSingleton(); + // Paket + services.AddSingleton(); + // PIP services.AddSingleton(); services.AddSingleton(); diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs new file mode 100644 index 000000000..08d8530df --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs @@ -0,0 +1,431 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Detectors.Paket; +using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[TestClass] +public class PaketComponentDetectorTests : BaseDetectorTest +{ + [TestMethod] + public async Task TestPaketDetector_SimpleNuGetPackages() + { + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + Castle.Core (3.3.0) + log4net (1.2.10) + Castle.Core-log4net (3.3.0) + Castle.Core (>= 3.3.0) + log4net (>= 1.2.10) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCount(3); + + var castleCore = detectedComponents.Single(c => c.Component.Id.Contains("Castle.Core 3.3.0")); + castleCore.Should().NotBeNull(); + + var log4net = detectedComponents.Single(c => c.Component.Id.Contains("log4net 1.2.10")); + log4net.Should().NotBeNull(); + + var castleCoreLog4Net = detectedComponents.Single(c => c.Component.Id.Contains("Castle.Core-log4net 3.3.0")); + castleCoreLog4Net.Should().NotBeNull(); + } + + [TestMethod] + public async Task TestPaketDetector_WithDependencies() + { + var paketLock = @"NUGET + remote: https://nuget.org/api/v2 + Castle.Windsor (3.3.0) + Castle.Core (>= 3.3.0) + Rx-Main (2.2.5) + Rx-Interfaces (>= 2.2.5) + Rx-Core (>= 2.2.5) + Rx-Linq (>= 2.2.5) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCountGreaterThanOrEqualTo(2); + + var castleWindsor = detectedComponents.Single(c => c.Component.Id.Contains("Castle.Windsor 3.3.0")); + castleWindsor.Should().NotBeNull(); + + var rxMain = detectedComponents.Single(c => c.Component.Id.Contains("Rx-Main 2.2.5")); + rxMain.Should().NotBeNull(); + } + + [TestMethod] + public async Task TestPaketDetector_ComplexLockFile() + { + var paketLock = @"NUGET + remote: https://nuget.org/api/v2 + Castle.Core (3.3.0) + Castle.Core-log4net (3.3.0) + Castle.Core (>= 3.3.0) + log4net (1.2.10) + Castle.LoggingFacility (3.3.0) + Castle.Core (>= 3.3.0) + Castle.Windsor (>= 3.3.0) + Castle.Windsor (3.3.0) + Castle.Core (>= 3.3.0) + Castle.Windsor-log4net (3.3.0) + Castle.Core-log4net (>= 3.3.0) + Castle.LoggingFacility (>= 3.3.0) + Rx-Core (2.2.5) + Rx-Interfaces (>= 2.2.5) + Rx-Interfaces (2.2.5) + Rx-Linq (2.2.5) + Rx-Interfaces (>= 2.2.5) + Rx-Core (>= 2.2.5) + Rx-Main (2.2.5) + Rx-Interfaces (>= 2.2.5) + Rx-Core (>= 2.2.5) + Rx-Linq (>= 2.2.5) + Rx-PlatformServices (>= 2.2.5) + Rx-PlatformServices (2.2.5) + Rx-Interfaces (>= 2.2.5) + Rx-Core (>= 2.2.5) + log4net (1.2.10) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCountGreaterThanOrEqualTo(11); + + // Verify some key packages + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Core 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Windsor 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Main 2.2.5")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("log4net 1.2.10")); + } + + [TestMethod] + public async Task TestPaketDetector_EmptyFile() + { + var paketLock = string.Empty; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().BeEmpty(); + } + + [TestMethod] + public async Task TestPaketDetector_OnlyNuGetSection() + { + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + Newtonsoft.Json (13.0.1) + +GITHUB + remote: owner/repo + src/File.fs (abc123) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Should only detect the NuGet package, not the GitHub dependency + detectedComponents.Should().ContainSingle(c => c.Component.Id.Contains("Newtonsoft.Json 13.0.1")); + } + + [TestMethod] + public async Task TestPaketDetector_MultipleRemoteSources() + { + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + Newtonsoft.Json (13.0.1) + remote: https://www.myget.org/F/myfeed/api/v3/index.json + MyPackage (1.0.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCount(2); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Newtonsoft.Json 13.0.1")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("MyPackage 1.0.0")); + } + + [TestMethod] + public async Task TestPaketDetector_VersionWithBuildMetadata() + { + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + MyPackage (1.0.0-beta.1) + AnotherPackage (2.3.4+build.5678) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCount(2); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("MyPackage 1.0.0-beta.1")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("AnotherPackage 2.3.4")); + } + + [TestMethod] + public async Task TestPaketDetector_DependenciesWithDifferentVersionConstraints() + { + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + PackageA (1.0.0) + PackageB (>= 2.0.0) + PackageC (< 3.0.0) + PackageD (~> 1.5) + PackageE (1.2.3) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCountGreaterThanOrEqualTo(1); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("PackageA 1.0.0")); + } + + [TestMethod] + public async Task TestPaketDetector_PackageWithNoVersion() + { + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + InvalidPackage + ValidPackage (1.0.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Should only detect the valid package + detectedComponents.Should().ContainSingle(c => c.Component.Id.Contains("ValidPackage 1.0.0")); + } + + [TestMethod] + public async Task TestPaketDetector_RealWorldExample() + { + var paketLock = @"RESTRICTION: == net8.0 +NUGET + remote: https://api.nuget.org/v3/index.json + FSharp.Core (8.0.200) + Microsoft.Extensions.Logging.Abstractions (8.0.1) + Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.1) + Microsoft.Extensions.DependencyInjection.Abstractions (8.0.1) + Newtonsoft.Json (13.0.3) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCountGreaterThanOrEqualTo(4); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core 8.0.200")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.Extensions.Logging.Abstractions 8.0.1")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.Extensions.DependencyInjection.Abstractions 8.0.1")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Newtonsoft.Json 13.0.3")); + } + + [TestMethod] + public async Task TestPaketDetector_WithMultipleGroups() + { + var paketLock = @"GROUP Build +RESTRICTION: == net6.0 +NUGET + remote: https://api.nuget.org/v3/index.json + FSharp.Core (9.0.300) + Newtonsoft.Json (13.0.3) + +GROUP Server +STORAGE: NONE +NUGET + remote: https://api.nuget.org/v3/index.json + Azure.Core (1.46.1) + FSharp.Core (9.0.303) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Should detect packages from both groups + detectedComponents.Should().HaveCountGreaterThanOrEqualTo(3); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Newtonsoft.Json 13.0.3")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Azure.Core 1.46.1")); + } + + [TestMethod] + public async Task TestPaketDetector_WithDependencyRestrictions() + { + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + Azure.Core (1.46.1) - restriction: || (&& (>= net462) (>= netstandard2.0)) (>= net8.0) + Microsoft.Bcl.AsyncInterfaces (>= 8.0) - restriction: || (>= net462) (>= netstandard2.0) + System.Memory.Data (>= 6.0.1) - restriction: || (>= net462) (>= netstandard2.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Should detect the main package and its dependencies despite restrictions + detectedComponents.Should().HaveCountGreaterThanOrEqualTo(1); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Azure.Core 1.46.1")); + } + + [TestMethod] + public async Task TestPaketDetector_IgnoresHttpAndGitHubSections() + { + var paketLock = @"GROUP Clientside +GITHUB + remote: zurb/bower-foundation + css/foundation.css (15d98294916c50ce8e6838bc035f4f136d4dc704) + js/foundation.min.js (15d98294916c50ce8e6838bc035f4f136d4dc704) +HTTP + remote: https://cdn.jsdelivr.net + jquery.signalR.js (/npm/signalr@2.4.3/jquery.signalR.js) + lodash.min.js (/npm/lodash@4.17.21/lodash.min.js) +NUGET + remote: https://api.nuget.org/v3/index.json + Newtonsoft.Json (13.0.3) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Should only detect NuGet packages, not GITHUB or HTTP dependencies + detectedComponents.Should().ContainSingle(c => c.Component.Id.Contains("Newtonsoft.Json 13.0.3")); + } + + [TestMethod] + public async Task TestPaketDetector_WithStorageDirective() + { + var paketLock = @"GROUP Server +STORAGE: NONE +NUGET + remote: https://api.nuget.org/v3/index.json + FSharp.Core (9.0.303) + Oxpecker (1.3) + FSharp.Core (>= 9.0.201) - restriction: >= net8.0 +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Should detect packages regardless of STORAGE directive + detectedComponents.Should().HaveCountGreaterThanOrEqualTo(2); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core 9.0.303")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Oxpecker 1.3")); + } + + [TestMethod] + public async Task TestPaketDetector_ComplexRealWorldFile() + { + var paketLock = @"GROUP Build +RESTRICTION: == net6.0 +NUGET + remote: https://api.nuget.org/v3/index.json + Fake.Core.Target (6.1.3) + Fake.Core.CommandLineParsing (>= 6.1.3) + Fake.Core.Context (>= 6.1.3) + FSharp.Core (>= 8.0.301) + FSharp.Core (9.0.300) + +GROUP Server +STORAGE: NONE +NUGET + remote: https://api.nuget.org/v3/index.json + FSharp.Data (6.6) + FSharp.Core (>= 6.0.1) - restriction: >= netstandard2.0 + FSharp.Data.Csv.Core (>= 6.6) - restriction: >= netstandard2.0 + Microsoft.AspNetCore.SignalR (1.2) + Microsoft.AspNetCore.Http.Connections (>= 1.2) - restriction: >= netstandard2.0 + Serilog (4.2) - restriction: || (>= net462) (>= netstandard2.0) + System.Diagnostics.DiagnosticSource (>= 8.0.1) - restriction: || (&& (>= net462) (< netstandard2.0)) (&& (< net462) (< net6.0) (>= netstandard2.0)) (>= net471) + +GROUP Test +NUGET + remote: https://api.nuget.org/v3/index.json + NUnit (4.3.2) + System.Memory (>= 4.6) - restriction: >= net462 + NUnit3TestAdapter (5.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Should detect packages from all groups with various restriction formats + detectedComponents.Should().HaveCountGreaterThanOrEqualTo(6); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Fake.Core.Target 6.1.3")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Data 6.6")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.AspNetCore.SignalR 1.2")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Serilog 4.2")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("NUnit 4.3.2")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("NUnit3TestAdapter 5.0")); + } +} From 1059a11111039c9faa42eb205211d449f28114bb Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Tue, 24 Mar 2026 11:09:47 +0000 Subject: [PATCH 2/5] Addressed to the code-review feedback of FernandoRojo --- docs/detectors/README.md | 6 +- docs/detectors/nuget.md | 2 +- docs/detectors/paket.md | 2 + .../nuget/NuGetComponentDetector.cs | 2 +- .../paket/PaketComponentDetector.cs | 88 ++++++----- .../PaketComponentDetectorTests.cs | 139 ++++++++++++++---- 6 files changed, 173 insertions(+), 66 deletions(-) diff --git a/docs/detectors/README.md b/docs/detectors/README.md index f82308d9e..5b78b705f 100644 --- a/docs/detectors/README.md +++ b/docs/detectors/README.md @@ -78,9 +78,9 @@ - [Paket](paket.md) -| Detector | Status | -| --------------------- | ------ | -| PaketComponentDetector | Stable | +| Detector | Status | +| --------------------- | ---------- | +| PaketComponentDetector | DefaultOff | - [Pip](pip.md) diff --git a/docs/detectors/nuget.md b/docs/detectors/nuget.md index 1b198824e..f1e8f0670 100644 --- a/docs/detectors/nuget.md +++ b/docs/detectors/nuget.md @@ -6,7 +6,7 @@ NuGet Detection depends on the following to successfully run: - One or more `*.nuspec`, `*.nupkg`, `*.packages.config`, or `.*csproj` files. - The files each NuGet detector searches for: - - [The `NuGet` detector looks for `*.nupkg`, `*.nuspec`, `nuget.config`][1] + - [The `NuGet` detector looks for `*.nupkg`, `*.nuspec`, `nuget.config`, `paket.lock`][1] - [The `NuGetPackagesConfig` detector looks for `packages.config`][2] - [The `NuGetProjectCentric` detector looks for `project.assets.json`][3] diff --git a/docs/detectors/paket.md b/docs/detectors/paket.md index 9f2c46231..e60fb52b5 100644 --- a/docs/detectors/paket.md +++ b/docs/detectors/paket.md @@ -55,6 +55,8 @@ The detector: ## Known Limitations +- This detector is currently **DefaultOff** and must be explicitly enabled - Only NuGet dependencies from the `NUGET` section are detected - GitHub, HTTP, and Git dependencies are not currently supported +- Without cross-referencing the `paket.dependencies` file, the detector cannot reliably distinguish between direct and transitive dependencies; it uses the dependency graph within the lock file to approximate this - The detector assumes the lock file format follows the standard Paket conventions diff --git a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs index 434edf5ce..c4ccac918 100644 --- a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs @@ -39,7 +39,7 @@ public NuGetComponentDetector( public override IEnumerable Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet)]; - public override IList SearchPatterns { get; } = ["*.nupkg", "*.nuspec", NugetConfigFileName]; + public override IList SearchPatterns { get; } = ["*.nupkg", "*.nuspec", NugetConfigFileName, "paket.lock"]; public override IEnumerable SupportedComponentTypes { get; } = [ComponentType.NuGet]; diff --git a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs index c707fb399..b15c833a8 100644 --- a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs @@ -15,10 +15,11 @@ namespace Microsoft.ComponentDetection.Detectors.Paket; /// Detects NuGet packages in paket.lock files. /// Paket is a dependency manager for .NET that provides better control over package dependencies. /// -public sealed class PaketComponentDetector : FileComponentDetector +// TODO: Promote to default-on (remove IDefaultOffComponentDetector) once validated in real-world usage. +public sealed class PaketComponentDetector : FileComponentDetector, IDefaultOffComponentDetector { private static readonly Regex PackageLineRegex = new(@"^\s{4}(\S+)\s+\(([^\)]+)\)", RegexOptions.Compiled); - private static readonly Regex DependencyLineRegex = new(@"^\s{6}(\S+)\s+\((.+)\)", RegexOptions.Compiled); + private static readonly Regex DependencyLineRegex = new(@"^\s{6}(\S+)\s+\(([^)]+)\)", RegexOptions.Compiled); /// /// Initializes a new instance of the class. @@ -60,10 +61,18 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; using var reader = new StreamReader(processRequest.ComponentStream.Stream); + // First pass: collect all resolved packages and their dependency relationships. + // In paket.lock, 4-space indented lines are resolved packages with pinned versions. + // 6-space indented lines are dependency specifications (version constraints) of the parent + // package; they are NOT resolved versions. The actual resolved version for each dependency + // will appear as its own 4-space entry elsewhere in the file. + // Limitation: without cross-referencing paket.dependencies, we cannot distinguish between + // direct and transitive dependencies. All 4-space packages are registered as explicit for now. + var resolvedPackages = new Dictionary(StringComparer.OrdinalIgnoreCase); + var dependencyRelationships = new List<(string ParentName, string DependencyName)>(); + var currentSection = string.Empty; string currentPackageName = null; - string currentPackageVersion = null; - DetectedComponent currentComponent = null; while (await reader.ReadLineAsync(cancellationToken) is { } line) { @@ -72,13 +81,11 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID continue; } - // Check if this is a section header (e.g., NUGET, GITHUB, HTTP) + // Check if this is a section header (e.g., NUGET, GITHUB, HTTP, GROUP) if (!line.StartsWith(' ') && line.Trim().Length > 0) { currentSection = line.Trim(); currentPackageName = null; - currentPackageVersion = null; - currentComponent = null; continue; } @@ -94,43 +101,58 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID continue; } - // Check if this is a package line (4 spaces indentation) + // Check if this is a package line (4 spaces indentation) - these are resolved packages var packageMatch = PackageLineRegex.Match(line); if (packageMatch.Success) { currentPackageName = packageMatch.Groups[1].Value; - currentPackageVersion = packageMatch.Groups[2].Value; - - currentComponent = new DetectedComponent( - new NuGetComponent(currentPackageName, currentPackageVersion)); - - singleFileComponentRecorder.RegisterUsage( - currentComponent, - isExplicitReferencedDependency: true); + var currentPackageVersion = packageMatch.Groups[2].Value; + // Use TryAdd so the first occurrence wins (in case of multiple groups) + resolvedPackages.TryAdd(currentPackageName, currentPackageVersion); continue; } - // Check if this is a dependency line (6 spaces indentation) + // Check if this is a dependency line (6 spaces indentation) - these are version constraints var dependencyMatch = DependencyLineRegex.Match(line); - if (dependencyMatch.Success && currentComponent != null) + if (dependencyMatch.Success && currentPackageName != null) { var dependencyName = dependencyMatch.Groups[1].Value; - var dependencyVersionSpec = dependencyMatch.Groups[2].Value; - - // Extract the actual version from the version specification - // Version specs can be like ">= 3.3.0" or "1.2.10" - var versionMatch = Regex.Match(dependencyVersionSpec, @"[\d\.]+"); - if (versionMatch.Success) - { - var dependencyComponent = new DetectedComponent( - new NuGetComponent(dependencyName, versionMatch.Value)); - - singleFileComponentRecorder.RegisterUsage( - dependencyComponent, - isExplicitReferencedDependency: false, - parentComponentId: currentComponent.Component.Id); - } + dependencyRelationships.Add((currentPackageName, dependencyName)); + } + } + + // Build a set of package names that appear as dependencies of other packages + var transitiveDependencyNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (_, dependencyName) in dependencyRelationships) + { + transitiveDependencyNames.Add(dependencyName); + } + + // Register all resolved packages + foreach (var (name, version) in resolvedPackages) + { + var component = new DetectedComponent(new NuGetComponent(name, version)); + singleFileComponentRecorder.RegisterUsage( + component, + isExplicitReferencedDependency: !transitiveDependencyNames.Contains(name)); + } + + // Register parent-child relationships using the dependency specifications + foreach (var (parentName, dependencyName) in dependencyRelationships) + { + if (resolvedPackages.ContainsKey(dependencyName) && resolvedPackages.ContainsKey(parentName)) + { + var parentVersion = resolvedPackages[parentName]; + var parentComponentId = new NuGetComponent(parentName, parentVersion).Id; + + var depVersion = resolvedPackages[dependencyName]; + var depComponent = new DetectedComponent(new NuGetComponent(dependencyName, depVersion)); + + singleFileComponentRecorder.RegisterUsage( + depComponent, + isExplicitReferencedDependency: false, + parentComponentId: parentComponentId); } } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs index 08d8530df..8dc758d86 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs @@ -1,9 +1,9 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; -using System.Linq; using System.Threading.Tasks; -using FluentAssertions; +using AwesomeAssertions; using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.Paket; using Microsoft.ComponentDetection.TestsUtilities; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -30,16 +30,36 @@ public async Task TestPaketDetector_SimpleNuGetPackages() scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); + // Only 3 resolved packages (4-space lines), not 5 (which would include 6-space dependency specs) detectedComponents.Should().HaveCount(3); - var castleCore = detectedComponents.Single(c => c.Component.Id.Contains("Castle.Core 3.3.0")); - castleCore.Should().NotBeNull(); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Core 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("log4net 1.2.10")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Core-log4net 3.3.0")); + } - var log4net = detectedComponents.Single(c => c.Component.Id.Contains("log4net 1.2.10")); - log4net.Should().NotBeNull(); + [TestMethod] + public async Task TestPaketDetector_DependencyRelationshipsAreBuilt() + { + var paketLock = @"NUGET + remote: https://nuget.org/api/v2 + Castle.Core (3.3.0) + Castle.Windsor (3.3.0) + Castle.Core (>= 3.3.0) +"; - var castleCoreLog4Net = detectedComponents.Single(c => c.Component.Id.Contains("Castle.Core-log4net 3.3.0")); - castleCoreLog4Net.Should().NotBeNull(); + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Only 2 resolved packages + detectedComponents.Should().HaveCount(2); + + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Windsor 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Core 3.3.0")); } [TestMethod] @@ -47,8 +67,15 @@ public async Task TestPaketDetector_WithDependencies() { var paketLock = @"NUGET remote: https://nuget.org/api/v2 + Castle.Core (3.3.0) Castle.Windsor (3.3.0) Castle.Core (>= 3.3.0) + Rx-Core (2.2.5) + Rx-Interfaces (>= 2.2.5) + Rx-Interfaces (2.2.5) + Rx-Linq (2.2.5) + Rx-Interfaces (>= 2.2.5) + Rx-Core (>= 2.2.5) Rx-Main (2.2.5) Rx-Interfaces (>= 2.2.5) Rx-Core (>= 2.2.5) @@ -62,13 +89,15 @@ public async Task TestPaketDetector_WithDependencies() scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - detectedComponents.Should().HaveCountGreaterThanOrEqualTo(2); + // 6 resolved packages + detectedComponents.Should().HaveCount(6); - var castleWindsor = detectedComponents.Single(c => c.Component.Id.Contains("Castle.Windsor 3.3.0")); - castleWindsor.Should().NotBeNull(); - - var rxMain = detectedComponents.Single(c => c.Component.Id.Contains("Rx-Main 2.2.5")); - rxMain.Should().NotBeNull(); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Windsor 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Core 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Main 2.2.5")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Core 2.2.5")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Interfaces 2.2.5")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Linq 2.2.5")); } [TestMethod] @@ -112,12 +141,19 @@ public async Task TestPaketDetector_ComplexLockFile() scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - detectedComponents.Should().HaveCountGreaterThanOrEqualTo(11); + // 11 resolved packages (4-space lines only) + detectedComponents.Should().HaveCount(11); - // Verify some key packages detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Core 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Core-log4net 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.LoggingFacility 3.3.0")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Windsor 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Windsor-log4net 3.3.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Core 2.2.5")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Interfaces 2.2.5")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Linq 2.2.5")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Main 2.2.5")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-PlatformServices 2.2.5")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("log4net 1.2.10")); } @@ -182,7 +218,7 @@ public async Task TestPaketDetector_MultipleRemoteSources() } [TestMethod] - public async Task TestPaketDetector_VersionWithBuildMetadata() + public async Task TestPaketDetector_VersionWithPreReleaseAndBuildMetadata() { var paketLock = @"NUGET remote: https://api.nuget.org/v3/index.json @@ -212,6 +248,10 @@ public async Task TestPaketDetector_DependenciesWithDifferentVersionConstraints( PackageC (< 3.0.0) PackageD (~> 1.5) PackageE (1.2.3) + PackageB (2.1.0) + PackageC (2.9.0) + PackageD (1.5.3) + PackageE (1.2.3) "; var (scanResult, componentRecorder) = await this.DetectorTestUtility @@ -221,8 +261,13 @@ public async Task TestPaketDetector_DependenciesWithDifferentVersionConstraints( scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - detectedComponents.Should().HaveCountGreaterThanOrEqualTo(1); + // All 5 resolved packages should be detected with their actual resolved versions + detectedComponents.Should().HaveCount(5); detectedComponents.Should().Contain(c => c.Component.Id.Contains("PackageA 1.0.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("PackageB 2.1.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("PackageC 2.9.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("PackageD 1.5.3")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("PackageE 1.2.3")); } [TestMethod] @@ -252,9 +297,9 @@ public async Task TestPaketDetector_RealWorldExample() NUGET remote: https://api.nuget.org/v3/index.json FSharp.Core (8.0.200) + Microsoft.Extensions.DependencyInjection.Abstractions (8.0.1) Microsoft.Extensions.Logging.Abstractions (8.0.1) Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.1) - Microsoft.Extensions.DependencyInjection.Abstractions (8.0.1) Newtonsoft.Json (13.0.3) "; @@ -265,7 +310,7 @@ public async Task TestPaketDetector_RealWorldExample() scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - detectedComponents.Should().HaveCountGreaterThanOrEqualTo(4); + detectedComponents.Should().HaveCount(4); detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core 8.0.200")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.Extensions.Logging.Abstractions 8.0.1")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.Extensions.DependencyInjection.Abstractions 8.0.1")); @@ -297,8 +342,8 @@ GROUP Server scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - // Should detect packages from both groups - detectedComponents.Should().HaveCountGreaterThanOrEqualTo(3); + // FSharp.Core appears in both groups; TryAdd keeps the first occurrence (9.0.300) + detectedComponents.Should().HaveCount(3); detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Newtonsoft.Json 13.0.3")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Azure.Core 1.46.1")); @@ -312,6 +357,8 @@ public async Task TestPaketDetector_WithDependencyRestrictions() Azure.Core (1.46.1) - restriction: || (&& (>= net462) (>= netstandard2.0)) (>= net8.0) Microsoft.Bcl.AsyncInterfaces (>= 8.0) - restriction: || (>= net462) (>= netstandard2.0) System.Memory.Data (>= 6.0.1) - restriction: || (>= net462) (>= netstandard2.0) + Microsoft.Bcl.AsyncInterfaces (8.0.0) + System.Memory.Data (6.0.1) "; var (scanResult, componentRecorder) = await this.DetectorTestUtility @@ -321,9 +368,11 @@ public async Task TestPaketDetector_WithDependencyRestrictions() scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - // Should detect the main package and its dependencies despite restrictions - detectedComponents.Should().HaveCountGreaterThanOrEqualTo(1); + // All 3 resolved packages detected with correct versions + detectedComponents.Should().HaveCount(3); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Azure.Core 1.46.1")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.Bcl.AsyncInterfaces 8.0.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("System.Memory.Data 6.0.1")); } [TestMethod] @@ -373,8 +422,8 @@ public async Task TestPaketDetector_WithStorageDirective() scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - // Should detect packages regardless of STORAGE directive - detectedComponents.Should().HaveCountGreaterThanOrEqualTo(2); + // Should detect 2 resolved packages regardless of STORAGE directive + detectedComponents.Should().HaveCount(2); detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core 9.0.303")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Oxpecker 1.3")); } @@ -386,6 +435,8 @@ public async Task TestPaketDetector_ComplexRealWorldFile() RESTRICTION: == net6.0 NUGET remote: https://api.nuget.org/v3/index.json + Fake.Core.CommandLineParsing (6.1.3) + Fake.Core.Context (6.1.3) Fake.Core.Target (6.1.3) Fake.Core.CommandLineParsing (>= 6.1.3) Fake.Core.Context (>= 6.1.3) @@ -399,10 +450,13 @@ GROUP Server FSharp.Data (6.6) FSharp.Core (>= 6.0.1) - restriction: >= netstandard2.0 FSharp.Data.Csv.Core (>= 6.6) - restriction: >= netstandard2.0 + FSharp.Data.Csv.Core (6.6) + Microsoft.AspNetCore.Http.Connections (1.2) Microsoft.AspNetCore.SignalR (1.2) Microsoft.AspNetCore.Http.Connections (>= 1.2) - restriction: >= netstandard2.0 Serilog (4.2) - restriction: || (>= net462) (>= netstandard2.0) System.Diagnostics.DiagnosticSource (>= 8.0.1) - restriction: || (&& (>= net462) (< netstandard2.0)) (&& (< net462) (< net6.0) (>= netstandard2.0)) (>= net471) + System.Diagnostics.DiagnosticSource (8.0.1) GROUP Test NUGET @@ -410,6 +464,7 @@ GROUP Test NUnit (4.3.2) System.Memory (>= 4.6) - restriction: >= net462 NUnit3TestAdapter (5.0) + System.Memory (4.6) "; var (scanResult, componentRecorder) = await this.DetectorTestUtility @@ -419,13 +474,41 @@ GROUP Test scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - // Should detect packages from all groups with various restriction formats - detectedComponents.Should().HaveCountGreaterThanOrEqualTo(6); + // Should detect all resolved packages from all groups detectedComponents.Should().Contain(c => c.Component.Id.Contains("Fake.Core.Target 6.1.3")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Fake.Core.CommandLineParsing 6.1.3")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Fake.Core.Context 6.1.3")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Data 6.6")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Data.Csv.Core 6.6")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.AspNetCore.SignalR 1.2")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.AspNetCore.Http.Connections 1.2")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Serilog 4.2")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("System.Diagnostics.DiagnosticSource 8.0.1")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("NUnit 4.3.2")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("NUnit3TestAdapter 5.0")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("System.Memory 4.6")); + } + + [TestMethod] + public async Task TestPaketDetector_UnresolvedDependencyIsIgnored() + { + // If a 6-space dependency doesn't have a corresponding 4-space resolved entry, + // it should be silently ignored (not registered with a fake version from the constraint) + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + PackageA (1.0.0) + NonExistentPackage (>= 2.0.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // Only the resolved package should be detected, not the unresolved dependency + detectedComponents.Should().ContainSingle(c => c.Component.Id.Contains("PackageA 1.0.0")); + detectedComponents.Should().NotContain(c => ((NuGetComponent)c.Component).Name == "NonExistentPackage"); } } From 7e062258728e43e2e603f2b5d7256d293fe5117f Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Tue, 24 Mar 2026 11:17:05 +0000 Subject: [PATCH 3/5] Copilot suggestion commit Copilot suggestion commit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../paket/PaketComponentDetector.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs index b15c833a8..8aefa9565 100644 --- a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs @@ -158,6 +158,8 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID } catch (Exception e) when (e is IOException or InvalidOperationException) { + var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; + singleFileComponentRecorder.RegisterPackageParseFailure(processRequest.ComponentStream.Location); this.Logger.LogWarning(e, "Failed to read paket.lock file {File}", processRequest.ComponentStream.Location); } } From 2b73aa3dde572444cb3492469f36aa14bdbf2d8a Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Tue, 24 Mar 2026 12:12:35 +0000 Subject: [PATCH 4/5] New CoPilot feedback addressed as well --- docs/detectors/paket.md | 8 ++-- .../paket/PaketComponentDetector.cs | 24 +++++++++--- .../PaketComponentDetectorTests.cs | 38 +++++++++++++++++++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/docs/detectors/paket.md b/docs/detectors/paket.md index e60fb52b5..4fac39328 100644 --- a/docs/detectors/paket.md +++ b/docs/detectors/paket.md @@ -36,10 +36,10 @@ NUGET ## Paket Detector The Paket detector parses `paket.lock` files to extract: -- Package names and versions -- Direct dependencies (packages explicitly listed) -- Transitive dependencies (dependencies of dependencies) -- Dependency relationships between packages +- Resolved package names and versions recorded in the lock file +- Dependency relationships between packages as represented in the lock file + +The detector does not authoritatively distinguish which packages were explicitly requested (from `paket.dependencies`) versus brought in transitively; it approximates this by treating packages that appear as dependencies of other packages as transitive. Currently, the detector focuses on the `NUGET` section of the lock file, which contains NuGet package dependencies. Other dependency types (GITHUB, HTTP, GIT) are not currently supported. diff --git a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs index b15c833a8..2abc352b5 100644 --- a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs @@ -45,7 +45,7 @@ public PaketComponentDetector( /// public override IEnumerable Categories => - [Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet)]; + [Enum.GetName(typeof(DetectorClass), DetectorClass.NuGet)!]; /// public override IEnumerable SupportedComponentTypes => [ComponentType.NuGet]; @@ -66,13 +66,15 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID // 6-space indented lines are dependency specifications (version constraints) of the parent // package; they are NOT resolved versions. The actual resolved version for each dependency // will appear as its own 4-space entry elsewhere in the file. - // Limitation: without cross-referencing paket.dependencies, we cannot distinguish between - // direct and transitive dependencies. All 4-space packages are registered as explicit for now. + // Limitation: without cross-referencing paket.dependencies, we cannot perfectly distinguish + // between direct and transitive dependencies. We initially register all 4-space resolved packages, + // then use the dependency graph to approximate: packages that appear as dependencies of other + // packages are marked as transitive, and the rest are treated as explicit. var resolvedPackages = new Dictionary(StringComparer.OrdinalIgnoreCase); var dependencyRelationships = new List<(string ParentName, string DependencyName)>(); var currentSection = string.Empty; - string currentPackageName = null; + string? currentPackageName = null; while (await reader.ReadLineAsync(cancellationToken) is { } line) { @@ -108,8 +110,17 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID currentPackageName = packageMatch.Groups[1].Value; var currentPackageVersion = packageMatch.Groups[2].Value; - // Use TryAdd so the first occurrence wins (in case of multiple groups) - resolvedPackages.TryAdd(currentPackageName, currentPackageVersion); + // TryAdd keeps the first occurrence. If the same package appears in multiple GROUPs + // with different versions, only the first is registered. This is a known simplification; + // full GROUP-aware tracking could be added in a future iteration. + if (!resolvedPackages.TryAdd(currentPackageName, currentPackageVersion)) + { + this.Logger.LogDebug( + "Duplicate package {PackageName} found with version {Version}; keeping previously resolved version {ExistingVersion}", + currentPackageName, + currentPackageVersion, + resolvedPackages[currentPackageName]); + } continue; } @@ -159,6 +170,7 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID catch (Exception e) when (e is IOException or InvalidOperationException) { this.Logger.LogWarning(e, "Failed to read paket.lock file {File}", processRequest.ComponentStream.Location); + processRequest.SingleFileComponentRecorder.RegisterPackageParseFailure(processRequest.ComponentStream.Location); } } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs index 8dc758d86..3bb8abe7a 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs @@ -1,5 +1,6 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; +using System.Linq; using System.Threading.Tasks; using AwesomeAssertions; using Microsoft.ComponentDetection.Contracts; @@ -60,6 +61,23 @@ public async Task TestPaketDetector_DependencyRelationshipsAreBuilt() detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Windsor 3.3.0")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Castle.Core 3.3.0")); + + // Validate dependency graph + var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); + + // Castle.Windsor is a root (not a dependency of anything) + dependencyGraph.IsComponentExplicitlyReferenced("Castle.Windsor 3.3.0 - NuGet").Should().BeTrue(); + + // Castle.Core is a dependency of Castle.Windsor, so it's transitive + dependencyGraph.IsComponentExplicitlyReferenced("Castle.Core 3.3.0 - NuGet").Should().BeFalse(); + + // Castle.Windsor depends on Castle.Core + dependencyGraph.GetDependenciesForComponent("Castle.Windsor 3.3.0 - NuGet") + .Should().Contain("Castle.Core 3.3.0 - NuGet"); + + // Castle.Core is a leaf + dependencyGraph.GetDependenciesForComponent("Castle.Core 3.3.0 - NuGet") + .Should().BeEmpty(); } [TestMethod] @@ -98,6 +116,26 @@ public async Task TestPaketDetector_WithDependencies() detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Core 2.2.5")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Interfaces 2.2.5")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Rx-Linq 2.2.5")); + + // Validate dependency graph edges + var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); + + // Rx-Main depends on Rx-Interfaces, Rx-Core, and Rx-Linq + dependencyGraph.GetDependenciesForComponent("Rx-Main 2.2.5 - NuGet") + .Should().BeEquivalentTo(["Rx-Interfaces 2.2.5 - NuGet", "Rx-Core 2.2.5 - NuGet", "Rx-Linq 2.2.5 - NuGet"]); + + // Rx-Core depends on Rx-Interfaces + dependencyGraph.GetDependenciesForComponent("Rx-Core 2.2.5 - NuGet") + .Should().BeEquivalentTo(["Rx-Interfaces 2.2.5 - NuGet"]); + + // Castle.Windsor depends on Castle.Core + dependencyGraph.GetDependenciesForComponent("Castle.Windsor 3.3.0 - NuGet") + .Should().BeEquivalentTo(["Castle.Core 3.3.0 - NuGet"]); + + // Explicit roots: Castle.Windsor and Rx-Main (not depended on by anything) + var explicitRoots = dependencyGraph.GetAllExplicitlyReferencedComponents(); + explicitRoots.Should().Contain("Castle.Windsor 3.3.0 - NuGet"); + explicitRoots.Should().Contain("Rx-Main 2.2.5 - NuGet"); } [TestMethod] From 7b72d3f9f4f08b699886b99635c4ca8212e2cb07 Mon Sep 17 00:00:00 2001 From: Tuomas Hietanen Date: Wed, 25 Mar 2026 10:13:46 +0000 Subject: [PATCH 5/5] Add isDevelopmentDependency detection for Paket. --- docs/detectors/paket.md | 27 +- .../paket/PaketComponentDetector.cs | 161 ++++++-- .../PaketComponentDetectorTests.cs | 345 +++++++++++++++++- 3 files changed, 495 insertions(+), 38 deletions(-) diff --git a/docs/detectors/paket.md b/docs/detectors/paket.md index 4fac39328..153b77e9f 100644 --- a/docs/detectors/paket.md +++ b/docs/detectors/paket.md @@ -31,6 +31,11 @@ NUGET remote: https://api.nuget.org/v3/index.json PackageName (1.0.0) DependencyName (>= 2.0.0) + +GROUP Test +NUGET + remote: https://api.nuget.org/v3/index.json + NUnit (4.3.2) ``` ## Paket Detector @@ -38,6 +43,7 @@ NUGET The Paket detector parses `paket.lock` files to extract: - Resolved package names and versions recorded in the lock file - Dependency relationships between packages as represented in the lock file +- Development dependency classification based on Paket group names The detector does not authoritatively distinguish which packages were explicitly requested (from `paket.dependencies`) versus brought in transitively; it approximates this by treating packages that appear as dependencies of other packages as transitive. @@ -47,11 +53,27 @@ Currently, the detector focuses on the `NUGET` section of the lock file, which c The detector: 1. Locates `paket.lock` files in the scan directory -2. Parses the file line by line -3. Identifies packages (4-space indentation) and their versions +2. Parses the file line by line, tracking the current GROUP context +3. Identifies packages (4-space indentation) and their versions, keyed by group 4. Identifies dependencies (6-space indentation) and their version constraints 5. Records all packages as NuGet components 6. Establishes parent-child relationships between packages and their dependencies +7. Classifies packages as development dependencies based on their group name + +## Development Dependency Classification + +Paket organizes dependencies into groups within `paket.lock`. The detector uses group names to classify packages as development (`isDevelopmentDependency: true`) or production (`isDevelopmentDependency: false`) dependencies. + +**Well-known development groups** (case-insensitive): +- Exact matches: `Test`, `Tests`, `Docs`, `Documentation`, `Build`, `Analyzers`, `Fake`, `Benchmark`, `Benchmarks`, `Samples`, `DesignTime` +- Suffix matches: any group name ending with `Test` or `Tests` (e.g., `UnitTest`, `IntegrationTests`, `AcceptanceTests`, `E2ETest`) + +**Production groups**: +- The default/unnamed group (packages before any `GROUP` line) +- `Main` +- Any group name not matching the well-known patterns above (e.g., `Server`, `Client`, `Shared`) + +When the same package appears in multiple groups (e.g., `FSharp.Core` in both `Build` and `Server`), both occurrences are registered. The framework's merge logic ensures that if a package appears in **any** production group, it is ultimately classified as a production dependency. ## Known Limitations @@ -59,4 +81,5 @@ The detector: - Only NuGet dependencies from the `NUGET` section are detected - GitHub, HTTP, and Git dependencies are not currently supported - Without cross-referencing the `paket.dependencies` file, the detector cannot reliably distinguish between direct and transitive dependencies; it uses the dependency graph within the lock file to approximate this +- Development dependency classification is based on group names only; it does not cross-reference `paket.references` files to verify which packages are actually used by test vs. production projects (planned for a future iteration) - The detector assumes the lock file format follows the standard Paket conventions diff --git a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs index 991309d84..030bf51ef 100644 --- a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs @@ -21,6 +21,19 @@ public sealed class PaketComponentDetector : FileComponentDetector, IDefaultOffC private static readonly Regex PackageLineRegex = new(@"^\s{4}(\S+)\s+\(([^\)]+)\)", RegexOptions.Compiled); private static readonly Regex DependencyLineRegex = new(@"^\s{6}(\S+)\s+\(([^)]+)\)", RegexOptions.Compiled); + /// + /// Well-known Paket group names that indicate development-time dependencies. + /// Exact matches (case-insensitive): test, tests, docs, documentation, build, analyzers, fake, + /// benchmark, benchmarks, samples, designtime. + /// Suffix matches (case-insensitive): groups ending with "test" or "tests" to cover names like + /// "unittest", "unittests", "integrationtest", "integrationtests", etc. + /// + private static readonly HashSet ExactDevGroupNames = new(StringComparer.OrdinalIgnoreCase) + { + "test", "tests", "docs", "documentation", "build", "analyzers", "fake", + "benchmark", "benchmarks", "samples", "designtime", + }; + /// /// Initializes a new instance of the class. /// @@ -51,7 +64,35 @@ public PaketComponentDetector( public override IEnumerable SupportedComponentTypes => [ComponentType.NuGet]; /// - public override int Version => 1; + public override int Version => 2; + + /// + /// Determines whether a Paket group name represents a development-time dependency group. + /// The unnamed/default group and "Main" are considered production groups. + /// + /// The group name from the paket.lock file, or empty string for the default group. + /// true if the group is a well-known development group; false otherwise. + internal static bool IsDevelopmentDependencyGroup(string groupName) + { + if (string.IsNullOrEmpty(groupName) || groupName.Equals("Main", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + if (ExactDevGroupNames.Contains(groupName)) + { + return true; + } + + // Suffix matches: *test, *tests (e.g., UnitTest, IntegrationTests) + if (groupName.EndsWith("test", StringComparison.OrdinalIgnoreCase) || + groupName.EndsWith("tests", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } /// protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) @@ -61,19 +102,30 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; using var reader = new StreamReader(processRequest.ComponentStream.Stream); - // First pass: collect all resolved packages and their dependency relationships. + // First pass: collect all resolved packages and their dependency relationships, keyed by group. // In paket.lock, 4-space indented lines are resolved packages with pinned versions. // 6-space indented lines are dependency specifications (version constraints) of the parent // package; they are NOT resolved versions. The actual resolved version for each dependency // will appear as its own 4-space entry elsewhere in the file. - // Limitation: without cross-referencing paket.dependencies, we cannot perfectly distinguish - // between direct and transitive dependencies. We initially register all 4-space resolved packages, - // then use the dependency graph to approximate: packages that appear as dependencies of other + // + // Packages are tracked per group because the same package may appear in multiple groups + // (e.g., FSharp.Core in both "Build" and "Server") potentially with different versions. + // Group names are also used to classify packages as development dependencies: well-known + // group names like "Test", "Build", "Docs", etc. indicate development-time dependencies. + // + // Limitation: without cross-referencing paket.dependencies or paket.references, we cannot + // perfectly distinguish between direct and transitive dependencies. We use the dependency + // graph within each group to approximate: packages that appear as dependencies of other // packages are marked as transitive, and the rest are treated as explicit. - var resolvedPackages = new Dictionary(StringComparer.OrdinalIgnoreCase); - var dependencyRelationships = new List<(string ParentName, string DependencyName)>(); + + // Key: (groupName, packageName) -> version + var resolvedPackages = new Dictionary<(string Group, string Name), string>(GroupAndNameComparer.Instance); + + // (groupName, parentName, dependencyName) + var dependencyRelationships = new List<(string Group, string ParentName, string DependencyName)>(); var currentSection = string.Empty; + var currentGroupName = string.Empty; // empty string = default/unnamed group string? currentPackageName = null; while (await reader.ReadLineAsync(cancellationToken) is { } line) @@ -83,11 +135,26 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID continue; } - // Check if this is a section header (e.g., NUGET, GITHUB, HTTP, GROUP) + // Check if this is a section header (e.g., NUGET, GITHUB, HTTP, GROUP, RESTRICTION, STORAGE) if (!line.StartsWith(' ') && line.Trim().Length > 0) { - currentSection = line.Trim(); - currentPackageName = null; + var trimmed = line.Trim(); + + // GROUP lines set the current group context; they are not a "section" like NUGET. + // The format is "GROUP " and subsequent sections (NUGET, GITHUB, etc.) + // belong to this group until the next GROUP line. + if (trimmed.StartsWith("GROUP ", StringComparison.OrdinalIgnoreCase) && trimmed.Length > 6) + { + currentGroupName = trimmed[6..].Trim(); + currentSection = string.Empty; + currentPackageName = null; + } + else + { + currentSection = trimmed; + currentPackageName = null; + } + continue; } @@ -110,17 +177,17 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID currentPackageName = packageMatch.Groups[1].Value; var currentPackageVersion = packageMatch.Groups[2].Value; - // TryAdd keeps the first occurrence. If the same package appears in multiple GROUPs - // with different versions, only the first is registered. This is a known simplification; - // full GROUP-aware tracking could be added in a future iteration. - if (!resolvedPackages.TryAdd(currentPackageName, currentPackageVersion)) + var key = (currentGroupName, currentPackageName); + if (!resolvedPackages.TryAdd(key, currentPackageVersion)) { this.Logger.LogDebug( - "Duplicate package {PackageName} found with version {Version}; keeping previously resolved version {ExistingVersion}", + "Duplicate package {PackageName} found in group '{GroupName}' with version {Version}; keeping previously resolved version {ExistingVersion}", currentPackageName, + currentGroupName, currentPackageVersion, - resolvedPackages[currentPackageName]); + resolvedPackages[key]); } + continue; } @@ -129,50 +196,80 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID if (dependencyMatch.Success && currentPackageName != null) { var dependencyName = dependencyMatch.Groups[1].Value; - dependencyRelationships.Add((currentPackageName, dependencyName)); + dependencyRelationships.Add((currentGroupName, currentPackageName, dependencyName)); } } - // Build a set of package names that appear as dependencies of other packages - var transitiveDependencyNames = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var (_, dependencyName) in dependencyRelationships) + // Build a set of package names (per group) that appear as dependencies of other packages + var transitiveDependencyNames = new HashSet<(string Group, string Name)>(GroupAndNameComparer.Instance); + foreach (var (group, _, dependencyName) in dependencyRelationships) { - transitiveDependencyNames.Add(dependencyName); + transitiveDependencyNames.Add((group, dependencyName)); } - // Register all resolved packages - foreach (var (name, version) in resolvedPackages) + // Register all resolved packages with group-aware isDevelopmentDependency. + // If a package appears in multiple groups, it will be registered multiple times with + // potentially different isDevelopmentDependency values. The framework's AND-merge + // semantics ensure that if ANY registration says false (production), the final result + // is false -- preventing accidental hiding of production dependencies. + foreach (var ((group, name), version) in resolvedPackages) { + var isDev = IsDevelopmentDependencyGroup(group); var component = new DetectedComponent(new NuGetComponent(name, version)); singleFileComponentRecorder.RegisterUsage( component, - isExplicitReferencedDependency: !transitiveDependencyNames.Contains(name)); + isExplicitReferencedDependency: !transitiveDependencyNames.Contains((group, name)), + isDevelopmentDependency: isDev); } // Register parent-child relationships using the dependency specifications - foreach (var (parentName, dependencyName) in dependencyRelationships) + foreach (var (group, parentName, dependencyName) in dependencyRelationships) { - if (resolvedPackages.ContainsKey(dependencyName) && resolvedPackages.ContainsKey(parentName)) + var parentKey = (group, parentName); + var depKey = (group, dependencyName); + + if (resolvedPackages.ContainsKey(depKey) && resolvedPackages.ContainsKey(parentKey)) { - var parentVersion = resolvedPackages[parentName]; + var isDev = IsDevelopmentDependencyGroup(group); + var parentVersion = resolvedPackages[parentKey]; var parentComponentId = new NuGetComponent(parentName, parentVersion).Id; - var depVersion = resolvedPackages[dependencyName]; + var depVersion = resolvedPackages[depKey]; var depComponent = new DetectedComponent(new NuGetComponent(dependencyName, depVersion)); singleFileComponentRecorder.RegisterUsage( depComponent, isExplicitReferencedDependency: false, - parentComponentId: parentComponentId); + parentComponentId: parentComponentId, + isDevelopmentDependency: isDev); } } } catch (Exception e) when (e is IOException or InvalidOperationException) { - var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; - singleFileComponentRecorder.RegisterPackageParseFailure(processRequest.ComponentStream.Location); - this.Logger.LogWarning(e, "Failed to read paket.lock file {File}", processRequest.ComponentStream.Location); processRequest.SingleFileComponentRecorder.RegisterPackageParseFailure(processRequest.ComponentStream.Location); + this.Logger.LogWarning(e, "Failed to read paket.lock file {File}", processRequest.ComponentStream.Location); + } + } + + /// + /// Case-insensitive equality comparer for (Group, Name) tuples used as dictionary keys. + /// + private sealed class GroupAndNameComparer : IEqualityComparer<(string Group, string Name)> + { + public static readonly GroupAndNameComparer Instance = new(); + + public bool Equals((string Group, string Name) x, (string Group, string Name) y) + { + return StringComparer.OrdinalIgnoreCase.Equals(x.Group, y.Group) && + StringComparer.OrdinalIgnoreCase.Equals(x.Name, y.Name); + } + + public int GetHashCode((string Group, string Name) obj) + { + return HashCode.Combine( + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Group), + StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name)); } } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs index 3bb8abe7a..ab2839a2c 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs @@ -6,6 +6,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.Paket; +using Microsoft.ComponentDetection.Detectors.Tests.Utilities; using Microsoft.ComponentDetection.TestsUtilities; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -380,11 +381,19 @@ GROUP Server scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - // FSharp.Core appears in both groups; TryAdd keeps the first occurrence (9.0.300) - detectedComponents.Should().HaveCount(3); - detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core")); + // FSharp.Core appears in both groups with different versions; both are registered. + // Build group has 9.0.300, Server group has 9.0.303. + detectedComponents.Should().HaveCount(4); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core 9.0.300")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core 9.0.303")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Newtonsoft.Json 13.0.3")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Azure.Core 1.46.1")); + + // Build is a well-known dev group; Server is not + componentRecorder.GetEffectiveDevDependencyValue("FSharp.Core 9.0.300 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("Newtonsoft.Json 13.0.3 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("Azure.Core 1.46.1 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("FSharp.Core 9.0.303 - NuGet").Should().BeFalse(); } [TestMethod] @@ -489,6 +498,7 @@ GROUP Server FSharp.Core (>= 6.0.1) - restriction: >= netstandard2.0 FSharp.Data.Csv.Core (>= 6.6) - restriction: >= netstandard2.0 FSharp.Data.Csv.Core (6.6) + FSharp.Core (9.0.303) Microsoft.AspNetCore.Http.Connections (1.2) Microsoft.AspNetCore.SignalR (1.2) Microsoft.AspNetCore.Http.Connections (>= 1.2) - restriction: >= netstandard2.0 @@ -512,12 +522,16 @@ GROUP Test scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); var detectedComponents = componentRecorder.GetDetectedComponents(); - // Should detect all resolved packages from all groups + // Should detect all resolved packages from all groups. + // FSharp.Core appears in Build (9.0.300) and Server (9.0.303) with different versions. + detectedComponents.Should().HaveCount(14); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Fake.Core.Target 6.1.3")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Fake.Core.CommandLineParsing 6.1.3")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Fake.Core.Context 6.1.3")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core 9.0.300")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Data 6.6")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Data.Csv.Core 6.6")); + detectedComponents.Should().Contain(c => c.Component.Id.Contains("FSharp.Core 9.0.303")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.AspNetCore.SignalR 1.2")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Microsoft.AspNetCore.Http.Connections 1.2")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("Serilog 4.2")); @@ -525,6 +539,26 @@ GROUP Test detectedComponents.Should().Contain(c => c.Component.Id.Contains("NUnit 4.3.2")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("NUnit3TestAdapter 5.0")); detectedComponents.Should().Contain(c => c.Component.Id.Contains("System.Memory 4.6")); + + // Build group is a well-known dev group + componentRecorder.GetEffectiveDevDependencyValue("Fake.Core.Target 6.1.3 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("Fake.Core.CommandLineParsing 6.1.3 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("Fake.Core.Context 6.1.3 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("FSharp.Core 9.0.300 - NuGet").Should().BeTrue(); + + // Server group is NOT a well-known dev group + componentRecorder.GetEffectiveDevDependencyValue("FSharp.Data 6.6 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("FSharp.Data.Csv.Core 6.6 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("FSharp.Core 9.0.303 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("Microsoft.AspNetCore.SignalR 1.2 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("Microsoft.AspNetCore.Http.Connections 1.2 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("Serilog 4.2 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("System.Diagnostics.DiagnosticSource 8.0.1 - NuGet").Should().BeFalse(); + + // Test group is a well-known dev group + componentRecorder.GetEffectiveDevDependencyValue("NUnit 4.3.2 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("NUnit3TestAdapter 5.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("System.Memory 4.6 - NuGet").Should().BeTrue(); } [TestMethod] @@ -549,4 +583,307 @@ public async Task TestPaketDetector_UnresolvedDependencyIsIgnored() detectedComponents.Should().ContainSingle(c => c.Component.Id.Contains("PackageA 1.0.0")); detectedComponents.Should().NotContain(c => ((NuGetComponent)c.Component).Name == "NonExistentPackage"); } + + [TestMethod] + public async Task TestPaketDetector_DefaultGroupIsNotDevDependency() + { + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + Newtonsoft.Json (13.0.3) + FSharp.Core (8.0.200) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCount(2); + + // Default (unnamed) group packages are production dependencies + componentRecorder.GetEffectiveDevDependencyValue("Newtonsoft.Json 13.0.3 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("FSharp.Core 8.0.200 - NuGet").Should().BeFalse(); + } + + [TestMethod] + public async Task TestPaketDetector_MainGroupIsNotDevDependency() + { + var paketLock = @"GROUP Main +NUGET + remote: https://api.nuget.org/v3/index.json + Newtonsoft.Json (13.0.3) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + componentRecorder.GetEffectiveDevDependencyValue("Newtonsoft.Json 13.0.3 - NuGet").Should().BeFalse(); + } + + [TestMethod] + public async Task TestPaketDetector_TestGroupIsDevDependency() + { + var paketLock = @"GROUP Test +NUGET + remote: https://api.nuget.org/v3/index.json + NUnit (4.3.2) + Moq (4.20.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + componentRecorder.GetEffectiveDevDependencyValue("NUnit 4.3.2 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("Moq 4.20.0 - NuGet").Should().BeTrue(); + } + + [TestMethod] + public async Task TestPaketDetector_SuffixTestGroupIsDevDependency() + { + // Groups ending with "test" or "tests" should be dev dependencies (e.g., UnitTest, IntegrationTests) + var paketLock = @"GROUP UnitTest +NUGET + remote: https://api.nuget.org/v3/index.json + xunit (2.9.0) + +GROUP IntegrationTests +NUGET + remote: https://api.nuget.org/v3/index.json + FluentAssertions (6.12.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + componentRecorder.GetEffectiveDevDependencyValue("xunit 2.9.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("FluentAssertions 6.12.0 - NuGet").Should().BeTrue(); + } + + [TestMethod] + public async Task TestPaketDetector_AllWellKnownDevGroupNames() + { + // Verify all well-known group names are recognized as dev dependencies + var paketLock = @"GROUP Tests +NUGET + remote: https://api.nuget.org/v3/index.json + PkgTests (1.0.0) + +GROUP Docs +NUGET + remote: https://api.nuget.org/v3/index.json + PkgDocs (1.0.0) + +GROUP Documentation +NUGET + remote: https://api.nuget.org/v3/index.json + PkgDocumentation (1.0.0) + +GROUP Build +NUGET + remote: https://api.nuget.org/v3/index.json + PkgBuild (1.0.0) + +GROUP Analyzers +NUGET + remote: https://api.nuget.org/v3/index.json + PkgAnalyzers (1.0.0) + +GROUP Fake +NUGET + remote: https://api.nuget.org/v3/index.json + PkgFake (1.0.0) + +GROUP Benchmark +NUGET + remote: https://api.nuget.org/v3/index.json + PkgBenchmark (1.0.0) + +GROUP Benchmarks +NUGET + remote: https://api.nuget.org/v3/index.json + PkgBenchmarks (1.0.0) + +GROUP Samples +NUGET + remote: https://api.nuget.org/v3/index.json + PkgSamples (1.0.0) + +GROUP DesignTime +NUGET + remote: https://api.nuget.org/v3/index.json + PkgDesignTime (1.0.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + detectedComponents.Should().HaveCount(10); + + // All well-known dev group packages should be dev dependencies + componentRecorder.GetEffectiveDevDependencyValue("PkgTests 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgDocs 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgDocumentation 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgBuild 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgAnalyzers 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgFake 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgBenchmark 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgBenchmarks 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgSamples 1.0.0 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("PkgDesignTime 1.0.0 - NuGet").Should().BeTrue(); + } + + [TestMethod] + public async Task TestPaketDetector_UnknownGroupIsNotDevDependency() + { + // Non-well-known group names should not be treated as dev dependencies + var paketLock = @"GROUP Server +NUGET + remote: https://api.nuget.org/v3/index.json + Giraffe (6.0.0) + +GROUP Client +NUGET + remote: https://api.nuget.org/v3/index.json + Fable.Core (4.0.0) + +GROUP Shared +NUGET + remote: https://api.nuget.org/v3/index.json + Thoth.Json (7.0.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + componentRecorder.GetEffectiveDevDependencyValue("Giraffe 6.0.0 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("Fable.Core 4.0.0 - NuGet").Should().BeFalse(); + componentRecorder.GetEffectiveDevDependencyValue("Thoth.Json 7.0.0 - NuGet").Should().BeFalse(); + } + + [TestMethod] + public async Task TestPaketDetector_SamePackageSameVersionInDevAndProdGroups() + { + // When the same package with the same version appears in both a dev group and a prod group, + // the framework's AND-merge ensures the final result is false (production wins). + var paketLock = @"GROUP Main +NUGET + remote: https://api.nuget.org/v3/index.json + FSharp.Core (9.0.300) + +GROUP Test +NUGET + remote: https://api.nuget.org/v3/index.json + FSharp.Core (9.0.300) + NUnit (4.3.2) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + // FSharp.Core appears in both Main (prod) and Test (dev) with the same version. + // The AND-merge means it's NOT a dev dependency (production usage wins). + componentRecorder.GetEffectiveDevDependencyValue("FSharp.Core 9.0.300 - NuGet").Should().BeFalse(); + + // NUnit only appears in Test, so it remains a dev dependency + componentRecorder.GetEffectiveDevDependencyValue("NUnit 4.3.2 - NuGet").Should().BeTrue(); + } + + [TestMethod] + public async Task TestPaketDetector_DevGroupNameMatchingIsCaseInsensitive() + { + var paketLock = @"GROUP TEST +NUGET + remote: https://api.nuget.org/v3/index.json + NUnit (4.3.2) + +GROUP build +NUGET + remote: https://api.nuget.org/v3/index.json + Fake.Core.Target (6.1.3) + +GROUP INTEGRATIONTESTS +NUGET + remote: https://api.nuget.org/v3/index.json + FluentAssertions (6.12.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + componentRecorder.GetEffectiveDevDependencyValue("NUnit 4.3.2 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("Fake.Core.Target 6.1.3 - NuGet").Should().BeTrue(); + componentRecorder.GetEffectiveDevDependencyValue("FluentAssertions 6.12.0 - NuGet").Should().BeTrue(); + } + + [TestMethod] + public void TestIsDevelopmentDependencyGroup_WellKnownNames() + { + // Exact matches + PaketComponentDetector.IsDevelopmentDependencyGroup("test").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Test").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("TEST").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("tests").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Tests").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("docs").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Docs").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("documentation").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Documentation").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("build").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Build").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("analyzers").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Analyzers").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("fake").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Fake").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("benchmark").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("benchmarks").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("samples").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("designtime").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("DesignTime").Should().BeTrue(); + + // Suffix matches + PaketComponentDetector.IsDevelopmentDependencyGroup("UnitTest").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("unittest").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("IntegrationTest").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("UnitTests").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("IntegrationTests").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("AcceptanceTests").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("E2ETest").Should().BeTrue(); + PaketComponentDetector.IsDevelopmentDependencyGroup("SmokeTests").Should().BeTrue(); + + // Non-dev groups + PaketComponentDetector.IsDevelopmentDependencyGroup(string.Empty).Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Main").Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("main").Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Server").Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Client").Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Shared").Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Web").Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Api").Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Core").Should().BeFalse(); + PaketComponentDetector.IsDevelopmentDependencyGroup("Infrastructure").Should().BeFalse(); + } }