diff --git a/docs/detectors/README.md b/docs/detectors/README.md index 3309e9eed..5b78b705f 100644 --- a/docs/detectors/README.md +++ b/docs/detectors/README.md @@ -76,6 +76,12 @@ | NuGetPackagesConfigDetector | Stable | | NuGetProjectModelProjectCentricComponentDetector | Stable | +- [Paket](paket.md) + +| Detector | Status | +| --------------------- | ---------- | +| PaketComponentDetector | DefaultOff | + - [Pip](pip.md) | Detector | Status | diff --git a/docs/detectors/paket.md b/docs/detectors/paket.md new file mode 100644 index 000000000..153b77e9f --- /dev/null +++ b/docs/detectors/paket.md @@ -0,0 +1,85 @@ +# 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) + +GROUP Test +NUGET + remote: https://api.nuget.org/v3/index.json + NUnit (4.3.2) +``` + +## Paket Detector + +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. + +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, 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 + +- 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 +- 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 new file mode 100644 index 000000000..030bf51ef --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs @@ -0,0 +1,275 @@ +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. +/// +// 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); + + /// + /// 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. + /// + /// 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 => 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) + { + try + { + var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; + using var reader = new StreamReader(processRequest.ComponentStream.Stream); + + // 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. + // + // 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. + + // 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) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + // Check if this is a section header (e.g., NUGET, GITHUB, HTTP, GROUP, RESTRICTION, STORAGE) + if (!line.StartsWith(' ') && line.Trim().Length > 0) + { + 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; + } + + // 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) - these are resolved packages + var packageMatch = PackageLineRegex.Match(line); + if (packageMatch.Success) + { + currentPackageName = packageMatch.Groups[1].Value; + var currentPackageVersion = packageMatch.Groups[2].Value; + + var key = (currentGroupName, currentPackageName); + if (!resolvedPackages.TryAdd(key, currentPackageVersion)) + { + this.Logger.LogDebug( + "Duplicate package {PackageName} found in group '{GroupName}' with version {Version}; keeping previously resolved version {ExistingVersion}", + currentPackageName, + currentGroupName, + currentPackageVersion, + resolvedPackages[key]); + } + + continue; + } + + // Check if this is a dependency line (6 spaces indentation) - these are version constraints + var dependencyMatch = DependencyLineRegex.Match(line); + if (dependencyMatch.Success && currentPackageName != null) + { + var dependencyName = dependencyMatch.Groups[1].Value; + dependencyRelationships.Add((currentGroupName, currentPackageName, dependencyName)); + } + } + + // 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((group, dependencyName)); + } + + // 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((group, name)), + isDevelopmentDependency: isDev); + } + + // Register parent-child relationships using the dependency specifications + foreach (var (group, parentName, dependencyName) in dependencyRelationships) + { + var parentKey = (group, parentName); + var depKey = (group, dependencyName); + + if (resolvedPackages.ContainsKey(depKey) && resolvedPackages.ContainsKey(parentKey)) + { + var isDev = IsDevelopmentDependencyGroup(group); + var parentVersion = resolvedPackages[parentKey]; + var parentComponentId = new NuGetComponent(parentName, parentVersion).Id; + + var depVersion = resolvedPackages[depKey]; + var depComponent = new DetectedComponent(new NuGetComponent(dependencyName, depVersion)); + + singleFileComponentRecorder.RegisterUsage( + depComponent, + isExplicitReferencedDependency: false, + parentComponentId: parentComponentId, + isDevelopmentDependency: isDev); + } + } + } + catch (Exception e) when (e is IOException or InvalidOperationException) + { + 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/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index ab86692c6..6ac09a260 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -16,6 +16,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; @@ -132,6 +133,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..ab2839a2c --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs @@ -0,0 +1,889 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System.Linq; +using System.Threading.Tasks; +using AwesomeAssertions; +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; + +[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(); + + // Only 3 resolved packages (4-space lines), not 5 (which would include 6-space dependency specs) + detectedComponents.Should().HaveCount(3); + + 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")); + } + + [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 (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")); + + // 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] + 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) + 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(); + + // 6 resolved packages + detectedComponents.Should().HaveCount(6); + + 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")); + + // 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] + 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(); + + // 11 resolved packages (4-space lines only) + detectedComponents.Should().HaveCount(11); + + 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")); + } + + [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_VersionWithPreReleaseAndBuildMetadata() + { + 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) + PackageB (2.1.0) + PackageC (2.9.0) + PackageD (1.5.3) + 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(); + + // 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] + 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.DependencyInjection.Abstractions (8.0.1) + Microsoft.Extensions.Logging.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().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")); + 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(); + + // 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] + 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) + Microsoft.Bcl.AsyncInterfaces (8.0.0) + System.Memory.Data (6.0.1) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // 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] + 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 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")); + } + + [TestMethod] + public async Task TestPaketDetector_ComplexRealWorldFile() + { + var paketLock = @"GROUP Build +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) + 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 + 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 + 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 + remote: https://api.nuget.org/v3/index.json + NUnit (4.3.2) + System.Memory (>= 4.6) - restriction: >= net462 + NUnit3TestAdapter (5.0) + System.Memory (4.6) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var detectedComponents = componentRecorder.GetDetectedComponents(); + + // 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")); + 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")); + + // 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] + 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"); + } + + [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(); + } +}