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();
+ }
+}