diff --git a/docs/detectors/README.md b/docs/detectors/README.md index 6e4bf3c4d..b5047f80b 100644 --- a/docs/detectors/README.md +++ b/docs/detectors/README.md @@ -89,6 +89,12 @@ | NuGetProjectModelProjectCentricComponentDetector | Stable | | MSBuildBinaryLogComponentDetector | Experimental | +- [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/nuget/NuGetComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs index c4ccac918..e9afa4311 100644 --- a/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/nuget/NuGetComponentDetector.cs @@ -54,12 +54,31 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID { await this.ProcessAdditionalDirectoryAsync(processRequest, ignoreNugetConfig); } + else if ("paket.lock".Equals(stream.Pattern, StringComparison.OrdinalIgnoreCase) && IsPaketDetectorEnabled(detectorArgs)) + { + // The dedicated Paket detector is enabled and will process this file, so skip it here + // to avoid double-processing the same paket.lock with the legacy parser below. + this.Logger.LogDebug("Skipping paket.lock at {Location} because the Paket detector is enabled and will process it.", stream.Location); + } else { await this.ProcessFileAsync(processRequest); } } + /// + /// Determines whether the dedicated Paket detector has been explicitly enabled via detector args + /// (e.g. --DetectorArgs Paket=EnableIfDefaultOff). When enabled, the NuGet detector defers + /// paket.lock handling to it; otherwise the NuGet detector parses paket.lock with its legacy parser. + /// + private static bool IsPaketDetectorEnabled(IDictionary detectorArgs) + { + return detectorArgs != null + && detectorArgs.TryGetValue(Paket.PaketComponentDetector.DetectorId, out var value) + && (value.Equals("EnableIfDefaultOff", StringComparison.OrdinalIgnoreCase) + || value.Equals("Enable", StringComparison.OrdinalIgnoreCase)); + } + private async Task ProcessAdditionalDirectoryAsync(ProcessRequest processRequest, bool ignoreNugetConfig) { var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; diff --git a/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs new file mode 100644 index 000000000..6202b6b4c --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/paket/PaketComponentDetector.cs @@ -0,0 +1,408 @@ +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 +{ + /// + /// The detector id, exposed so other detectors (e.g. NuGet) can defer paket.lock handling + /// to this detector when it has been explicitly enabled. + /// + public const string DetectorId = "Paket"; + + /// + /// The companion file that declares the direct (top-level) dependencies for a Paket setup. + /// When present next to paket.lock it lets us classify direct vs. transitive dependencies precisely. + /// + public const string DependenciesFileName = "paket.dependencies"; + + 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", + }; + + private readonly IFileUtilityService fileUtilityService; + + /// + /// Initializes a new instance of the class. + /// + /// The factory for handing back component streams to File detectors. + /// The factory for creating directory walkers. + /// The service used to read the companion paket.dependencies file. + /// The logger to use. + public PaketComponentDetector( + IComponentStreamEnumerableFactory componentStreamEnumerableFactory, + IObservableDirectoryWalkerFactory walkerFactory, + IFileUtilityService fileUtilityService, + ILogger logger) + { + this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; + this.Scanner = walkerFactory; + this.fileUtilityService = fileUtilityService; + this.Logger = logger; + } + + /// + public override IList SearchPatterns => ["paket.lock"]; + + /// + public override string Id => DetectorId; + + /// + 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; + } + + /// + /// Normalizes a Paket group name so the default group is represented consistently. Paket's default + /// group can be written as the unnamed group (empty) or explicitly as "Main"; both are treated as the + /// same group so declarations in paket.dependencies line up with entries in paket.lock. + /// + /// The raw group name, or empty for the default group. + /// An empty string for the default/Main group; otherwise the original group name. + internal static string NormalizeGroupName(string? groupName) => + string.IsNullOrEmpty(groupName) || groupName.Equals("Main", StringComparison.OrdinalIgnoreCase) + ? string.Empty + : groupName; + + /// + /// Parses the direct (top-level) dependencies declared in a paket.dependencies file, keyed by group. + /// Only nuget declarations are considered; github, git, http, source and + /// option lines are ignored. Returns null when no NuGet declarations are found so callers can + /// fall back to the lock-graph heuristic. + /// + /// The contents of the paket.dependencies file. + /// The set of declared direct dependencies, or null if none were found. + internal static HashSet<(string Group, string Name)>? ParseDeclaredDirectDependencies(string content) + { + var declared = new HashSet<(string Group, string Name)>(GroupAndNameComparer.Instance); + var currentGroup = string.Empty; + + using var reader = new StringReader(content); + while (reader.ReadLine() is { } line) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith('#')) + { + continue; + } + + if (trimmed.StartsWith("group ", StringComparison.OrdinalIgnoreCase)) + { + currentGroup = NormalizeGroupName(trimmed[6..].Trim()); + continue; + } + + if (trimmed.StartsWith("nuget ", StringComparison.OrdinalIgnoreCase)) + { + // Format: "nuget [version/constraint] [options]" - the name is the first token. + var tokens = trimmed[6..].Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length > 0) + { + declared.Add((currentGroup, tokens[0])); + } + } + } + + return declared.Count > 0 ? declared : null; + } + + /// + /// Reads and parses the paket.dependencies file sitting next to the given paket.lock, if present. + /// Any IO/parse failure is swallowed (and logged) so direct/transitive classification can safely fall + /// back to the lock-graph heuristic. + /// + /// The full path to the paket.lock file being processed. + /// The declared direct dependencies, or null when unavailable. + private HashSet<(string Group, string Name)>? TryReadDeclaredDirectDependencies(string lockFileLocation) + { + try + { + var directory = Path.GetDirectoryName(lockFileLocation); + if (string.IsNullOrEmpty(directory)) + { + return null; + } + + var dependenciesPath = Path.Combine(directory, DependenciesFileName); + if (this.fileUtilityService?.Exists(dependenciesPath) != true) + { + return null; + } + + var content = this.fileUtilityService.ReadAllText(dependenciesPath); + return string.IsNullOrEmpty(content) ? null : ParseDeclaredDirectDependencies(content); + } + catch (Exception e) when (e is IOException or UnauthorizedAccessException or ArgumentException) + { + this.Logger.LogWarning( + e, + "Failed to read {DependenciesFile} next to {LockFile}; falling back to the lock-graph heuristic for direct/transitive classification.", + DependenciesFileName, + lockFileLocation); + return null; + } + } + + /// + 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. + // + // Direct vs. transitive classification: when a companion paket.dependencies file is present + // next to paket.lock, it lists the direct (top-level) dependencies that were explicitly + // declared, so we use it as the authoritative source. When it is absent (or unreadable) we + // fall back to a graph heuristic: packages that appear as a dependency of another package + // within the same group are treated as transitive, and the rest as explicit. The heuristic + // cannot perfectly distinguish a direct dependency that is also pulled in transitively. + + // 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; + + // The version capture group can match whitespace-only content (e.g. a malformed + // "Foo ( )" line). NuGetComponent requires a non-empty version, so skip such entries + // here instead of letting the constructor throw later and abort the whole file. + if (string.IsNullOrWhiteSpace(currentPackageVersion)) + { + this.Logger.LogWarning( + "Skipping paket package {PackageName} in group '{GroupName}' because it has no resolved version.", + currentPackageName, + currentGroupName); + singleFileComponentRecorder.RegisterPackageParseFailure(processRequest.ComponentStream.Location); + currentPackageName = null; + continue; + } + + 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)); + } + + // Prefer the explicit declarations from the companion paket.dependencies file when available. + var declaredDirectDependencies = this.TryReadDeclaredDirectDependencies(processRequest.ComponentStream.Location); + + // 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 isExplicit = declaredDirectDependencies != null + ? declaredDirectDependencies.Contains((NormalizeGroupName(group), name)) + : !transitiveDependencyNames.Contains((group, name)); + var component = new DetectedComponent(new NuGetComponent(name, version)); + singleFileComponentRecorder.RegisterUsage( + component, + isExplicitReferencedDependency: isExplicit, + 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 not OperationCanceledException) + { + // Catch all parsing/IO exceptions (e.g. a malformed line that yields an empty version and makes + // NuGetComponent throw) so a single bad paket.lock cannot fault the detector and fail the whole + // scan. Cancellation is intentionally allowed to propagate. + processRequest.SingleFileComponentRecorder.RegisterPackageParseFailure(processRequest.ComponentStream.Location); + this.Logger.LogWarning(e, "Failed to process 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 ff212c92f..e883baf93 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -18,6 +18,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; @@ -139,6 +140,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/NuGetComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetComponentDetectorTests.cs index 4eea4d4a2..1151376c6 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/NuGetComponentDetectorTests.cs @@ -173,6 +173,38 @@ public async Task TestNugetDetector_ReturnsValidPaketComponentAsync() Times.Once()); } + [TestMethod] + public async Task TestNugetDetector_SkipsPaketLockWhenPaketDetectorEnabledAsync() + { + var paketLock = @" +NUGET + remote: https://nuget.org/api/v2 + Castle.Core (3.3.0) + log4net (1.2.10) + "; + + var componentRecorder = new ComponentRecorder(); + var scanRequest = new ScanRequest( + new DirectoryInfo(Path.GetTempPath()), + null, + null, + new Dictionary { { "Paket", "EnableIfDefaultOff" } }, + null, + componentRecorder, + sourceFileRoot: new DirectoryInfo(Path.GetTempPath())); + + var (scanResult, recorder) = await this.detectorTestUtility + .WithFile("paket.lock", paketLock) + .WithScanRequest(scanRequest) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + // The dedicated Paket detector is enabled, so the NuGet detector must NOT parse paket.lock, + // otherwise the same file would be double-processed. + recorder.GetDetectedComponents().Should().BeEmpty(); + } + [TestMethod] public async Task TestNugetDetector_HandlesMalformedComponentsInComponentListAsync() { diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs new file mode 100644 index 000000000..048c00111 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/PaketComponentDetectorTests.cs @@ -0,0 +1,1109 @@ +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; +using Moq; + +[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 async Task TestPaketDetector_MalformedVersionDoesNotFaultDetector() + { + // A line with an empty/whitespace version would make NuGetComponent throw ArgumentNullException. + // The detector must catch it, record a parse failure, and still succeed (parsing the valid entries), + // rather than letting the exception fault the detector and fail the entire scan. + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + BadPackage ( ) + 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(); + + detectedComponents.Should().ContainSingle(c => c.Component.Id.Contains("ValidPackage 1.0.0")); + } + + [TestMethod] + public async Task TestPaketDetector_PaketDependenciesMarksDirectDependencyThatIsAlsoTransitive() + { + // PackageB is both declared directly (in paket.dependencies) AND pulled in transitively by PackageA. + // The lock-graph heuristic alone would misclassify it as transitive; paket.dependencies corrects it. + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + PackageA (1.0.0) + PackageB (>= 2.0.0) + PackageB (2.0.0) + PackageC (>= 3.0.0) + PackageC (3.0.0) +"; + + var paketDependencies = @"source https://api.nuget.org/v3/index.json + +nuget PackageA +nuget PackageB >= 2.0.0 +"; + + var componentRecorder = await this.RunWithPaketDependenciesAsync(paketLock, paketDependencies); + var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); + + // PackageA and PackageB are declared direct dependencies, so both are explicit. + dependencyGraph.IsComponentExplicitlyReferenced("PackageA 1.0.0 - NuGet").Should().BeTrue(); + dependencyGraph.IsComponentExplicitlyReferenced("PackageB 2.0.0 - NuGet").Should().BeTrue(); + + // PackageC is only transitive (not declared), so it stays transitive. + dependencyGraph.IsComponentExplicitlyReferenced("PackageC 3.0.0 - NuGet").Should().BeFalse(); + } + + [TestMethod] + public async Task TestPaketDetector_TopLevelLockEntryNotDeclaredIsTransitive() + { + // PackageZ has no parent in the lock graph (heuristic would call it explicit) but it is NOT + // declared in paket.dependencies, so with the companion file present it is classified transitive. + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + PackageA (1.0.0) + PackageZ (9.0.0) +"; + + var paketDependencies = @"source https://api.nuget.org/v3/index.json + +nuget PackageA +"; + + var componentRecorder = await this.RunWithPaketDependenciesAsync(paketLock, paketDependencies); + var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); + + dependencyGraph.IsComponentExplicitlyReferenced("PackageA 1.0.0 - NuGet").Should().BeTrue(); + dependencyGraph.IsComponentExplicitlyReferenced("PackageZ 9.0.0 - NuGet").Should().BeFalse(); + } + + [TestMethod] + public async Task TestPaketDetector_FallsBackToHeuristicWhenNoPaketDependencies() + { + // Without a paket.dependencies file (default mock reports it missing), the detector falls back to + // the lock-graph heuristic: PackageB is a dependency of PackageA, so it is treated as transitive. + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + PackageA (1.0.0) + PackageB (>= 2.0.0) + PackageB (2.0.0) +"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); + + dependencyGraph.IsComponentExplicitlyReferenced("PackageA 1.0.0 - NuGet").Should().BeTrue(); + dependencyGraph.IsComponentExplicitlyReferenced("PackageB 2.0.0 - NuGet").Should().BeFalse(); + } + + [TestMethod] + public async Task TestPaketDetector_PaketDependenciesRespectsGroups() + { + // A package declared direct in one group must not be considered direct in a different group. + var paketLock = @"NUGET + remote: https://api.nuget.org/v3/index.json + Serilog (4.2.0) + System.Memory (>= 4.6.0) + System.Memory (4.6.0) + +GROUP Test +NUGET + remote: https://api.nuget.org/v3/index.json + NUnit (4.3.2) +"; + + var paketDependencies = @"source https://api.nuget.org/v3/index.json +nuget Serilog + +group Test + source https://api.nuget.org/v3/index.json + nuget NUnit +"; + + var componentRecorder = await this.RunWithPaketDependenciesAsync(paketLock, paketDependencies); + var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); + + // Declared directs in their respective groups. + dependencyGraph.IsComponentExplicitlyReferenced("Serilog 4.2.0 - NuGet").Should().BeTrue(); + dependencyGraph.IsComponentExplicitlyReferenced("NUnit 4.3.2 - NuGet").Should().BeTrue(); + + // System.Memory is only transitive and not declared anywhere. + dependencyGraph.IsComponentExplicitlyReferenced("System.Memory 4.6.0 - NuGet").Should().BeFalse(); + } + + [TestMethod] + public async Task TestPaketDetector_PaketDependenciesDefaultGroupMatchesMainGroupInLock() + { + // paket.dependencies declares the default group while paket.lock writes it as "GROUP Main". + // Both must be treated as the same group so the declaration is matched. + var paketLock = @"GROUP Main +NUGET + remote: https://api.nuget.org/v3/index.json + Newtonsoft.Json (13.0.3) +"; + + var paketDependencies = @"source https://api.nuget.org/v3/index.json +nuget Newtonsoft.Json +"; + + var componentRecorder = await this.RunWithPaketDependenciesAsync(paketLock, paketDependencies); + var dependencyGraph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); + + dependencyGraph.IsComponentExplicitlyReferenced("Newtonsoft.Json 13.0.3 - NuGet").Should().BeTrue(); + } + + [TestMethod] + public void TestParseDeclaredDirectDependencies_ParsesNuGetLinesPerGroup() + { + var content = @"source https://api.nuget.org/v3/index.json +# a comment +framework: net8.0 +nuget Newtonsoft.Json >= 13.0 +nuget FSharp.Core +github owner/repo file.fs + +group Test + nuget NUnit ~> 4.0 +"; + + var declared = PaketComponentDetector.ParseDeclaredDirectDependencies(content); + + declared.Should().Contain((string.Empty, "Newtonsoft.Json")); + declared.Should().Contain((string.Empty, "FSharp.Core")); + declared.Should().Contain(("Test", "NUnit")); + + // github/source/framework/comment lines are not NuGet declarations. + declared.Should().NotContain(d => d.Name == "owner/repo"); + declared.Should().HaveCount(3); + } + + [TestMethod] + public void TestParseDeclaredDirectDependencies_ReturnsNullWhenNoNuGetLines() + { + var content = @"source https://api.nuget.org/v3/index.json +framework: net8.0 +"; + + PaketComponentDetector.ParseDeclaredDirectDependencies(content).Should().BeNull(); + } + + [TestMethod] + public void TestNormalizeGroupName_TreatsDefaultAndMainAsSame() + { + PaketComponentDetector.NormalizeGroupName(null).Should().BeEmpty(); + PaketComponentDetector.NormalizeGroupName(string.Empty).Should().BeEmpty(); + PaketComponentDetector.NormalizeGroupName("Main").Should().BeEmpty(); + PaketComponentDetector.NormalizeGroupName("main").Should().BeEmpty(); + PaketComponentDetector.NormalizeGroupName("Test").Should().Be("Test"); + } + + [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(); + } + + private async Task RunWithPaketDependenciesAsync(string paketLock, string paketDependencies) + { + var mockFileUtility = new Mock(); + mockFileUtility + .Setup(x => x.Exists(It.Is(p => p.EndsWith(PaketComponentDetector.DependenciesFileName)))) + .Returns(true); + mockFileUtility + .Setup(x => x.ReadAllText(It.Is(p => p.EndsWith(PaketComponentDetector.DependenciesFileName)))) + .Returns(paketDependencies); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("paket.lock", paketLock) + .AddServiceMock(mockFileUtility) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + return componentRecorder; + } +}