diff --git a/src/Microsoft.ComponentDetection.Common/DependencyGraph/ComponentRecorder.cs b/src/Microsoft.ComponentDetection.Common/DependencyGraph/ComponentRecorder.cs index 147c1ef07..b56aadc64 100644 --- a/src/Microsoft.ComponentDetection.Common/DependencyGraph/ComponentRecorder.cs +++ b/src/Microsoft.ComponentDetection.Common/DependencyGraph/ComponentRecorder.cs @@ -34,56 +34,142 @@ public TypedComponent GetComponent(string componentId) public IEnumerable GetDetectedComponents() { - IEnumerable detectedComponents; if (this.singleFileRecorders == null) { return []; } - detectedComponents = this.singleFileRecorders.Values - .SelectMany(singleFileRecorder => singleFileRecorder.GetDetectedComponents().Values) - .GroupBy(x => x.Component.Id) - .Select(grouping => - { - // We pick a winner here -- any stateful props could get lost at this point. - var winningDetectedComponent = grouping.First(); + var allComponents = this.singleFileRecorders.Values + .SelectMany(singleFileRecorder => singleFileRecorder.GetDetectedComponents().Values); - HashSet mergedLicenses = null; - HashSet mergedSuppliers = null; + // When both rich and bare entries exist for the same BaseId, rich entries are used as merge targets for bare entries. + var reconciledComponents = new List(); - foreach (var component in grouping.Skip(1)) - { - winningDetectedComponent.ContainerDetailIds.UnionWith(component.ContainerDetailIds); + foreach (var baseIdGroup in allComponents.GroupBy(x => x.Component.BaseId)) + { + var richEntries = new List(); + var bareEntries = new List(); - // Defensive: merge in case different file recorders set different values for the same component. - if (component.LicensesConcluded != null) - { - mergedLicenses ??= new HashSet(winningDetectedComponent.LicensesConcluded ?? [], StringComparer.OrdinalIgnoreCase); - mergedLicenses.UnionWith(component.LicensesConcluded); - } + // Sub-group by full Id first: merge duplicates of the same Id (existing behavior). + foreach (var idGroup in baseIdGroup.GroupBy(x => x.Component.Id)) + { + var merged = MergeDetectedComponentGroup(idGroup); - if (component.Suppliers != null) - { - mergedSuppliers ??= new HashSet(winningDetectedComponent.Suppliers ?? []); - mergedSuppliers.UnionWith(component.Suppliers); - } + if (merged.Component.Id == merged.Component.BaseId) + { + bareEntries.Add(merged); } - - if (mergedLicenses != null) + else { - winningDetectedComponent.LicensesConcluded = mergedLicenses.Where(x => x != null).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + richEntries.Add(merged); } + } - if (mergedSuppliers != null) + if (richEntries.Count > 0 && bareEntries.Count > 0) + { + // Merge each bare entry's metadata into every rich entry, then drop the bare. + foreach (var bare in bareEntries) { - winningDetectedComponent.Suppliers = mergedSuppliers.Where(s => s != null).OrderBy(s => s.Name).ThenBy(s => s.Type).ToList(); + foreach (var rich in richEntries) + { + MergeComponentMetadata(source: bare, target: rich); + } } - return winningDetectedComponent; - }) - .ToArray(); + reconciledComponents.AddRange(richEntries); + } + else + { + // No conflict: either all rich (different Ids kept separate) or all bare. + reconciledComponents.AddRange(richEntries); + reconciledComponents.AddRange(bareEntries); + } + } + + return reconciledComponents.ToArray(); + } + + /// + /// Merges component-level metadata from into . + /// + private static void MergeComponentMetadata(DetectedComponent source, DetectedComponent target) + { + target.ContainerDetailIds.UnionWith(source.ContainerDetailIds); + + foreach (var kvp in source.ContainerLayerIds) + { + if (target.ContainerLayerIds.TryGetValue(kvp.Key, out var existingLayers)) + { + target.ContainerLayerIds[kvp.Key] = existingLayers.Union(kvp.Value).Distinct().ToList(); + } + else + { + target.ContainerLayerIds[kvp.Key] = kvp.Value.ToList(); + } + } + + target.LicensesConcluded = MergeAndNormalizeLicenses(target.LicensesConcluded, source.LicensesConcluded); + target.Suppliers = MergeAndNormalizeSuppliers(target.Suppliers, source.Suppliers); + } + + private static IList MergeAndNormalizeLicenses(IList target, IList source) + { + if (target == null && source == null) + { + return null; + } + + var merged = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (target != null) + { + merged.UnionWith(target.Where(x => x != null)); + } + + if (source != null) + { + merged.UnionWith(source.Where(x => x != null)); + } + + return merged.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static IList MergeAndNormalizeSuppliers(IList target, IList source) + { + if (target == null && source == null) + { + return null; + } + + var merged = new HashSet(); + + if (target != null) + { + merged.UnionWith(target.Where(s => s != null)); + } + + if (source != null) + { + merged.UnionWith(source.Where(s => s != null)); + } + + return merged.OrderBy(s => s.Name).ThenBy(s => s.Type).ToList(); + } + + /// + /// Merges a group of s that share the same + /// into a single entry. + /// + private static DetectedComponent MergeDetectedComponentGroup(IEnumerable grouping) + { + var winner = grouping.First(); + + foreach (var component in grouping.Skip(1)) + { + MergeComponentMetadata(source: component, target: winner); + } - return detectedComponents; + return winner; } public IEnumerable GetSkippedComponents() diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Services/GraphTranslation/DefaultGraphTranslationService.cs b/src/Microsoft.ComponentDetection.Orchestrator/Services/GraphTranslation/DefaultGraphTranslationService.cs index 0616c0ce7..720a2b3f3 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Services/GraphTranslation/DefaultGraphTranslationService.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Services/GraphTranslation/DefaultGraphTranslationService.cs @@ -71,6 +71,16 @@ private static ConcurrentHashSet MergeTargetFrameworks(ConcurrentHashSet return left; } + /// + /// Checks whether the graph contains the component by its full Id, or by its BaseId + /// when the component is a rich entry (Id != BaseId) whose bare counterpart was registered in the graph. + /// + private static bool GraphContainsComponent(IDependencyGraph graph, TypedComponent component) + { + return graph.Contains(component.Id) || + (component.Id != component.BaseId && graph.Contains(component.BaseId)); + } + private void LogComponentScopeTelemetry(List components) { using var record = new DetectedComponentScopeRecord(); @@ -126,25 +136,30 @@ private IEnumerable GatherSetOfDetectedComponentsUnmerged(IEn } // Information about each component is relative to all of the graphs it is present in, so we take all graphs containing a given component and apply the graph data. - foreach (var graphKvp in dependencyGraphsByLocation.Where(x => x.Value.Contains(component.Component.Id))) + foreach (var graphKvp in dependencyGraphsByLocation.Where(x => GraphContainsComponent(x.Value, component.Component))) { var location = graphKvp.Key; var dependencyGraph = graphKvp.Value; + // Determine the Id stored in this graph — may be the rich Id or the bare BaseId. + var graphComponentId = dependencyGraph.Contains(component.Component.Id) + ? component.Component.Id + : component.Component.BaseId; + // Calculate roots of the component var rootStartTime = DateTime.UtcNow; - this.AddRootsToDetectedComponent(component, dependencyGraph, componentRecorder); + this.AddRootsToDetectedComponent(component, graphComponentId, dependencyGraph, componentRecorder); var rootEndTime = DateTime.UtcNow; totalTimeToAddRoots += rootEndTime - rootStartTime; // Calculate Ancestors of the component var ancestorStartTime = DateTime.UtcNow; - this.AddAncestorsToDetectedComponent(component, dependencyGraph, componentRecorder); + this.AddAncestorsToDetectedComponent(component, graphComponentId, dependencyGraph, componentRecorder); var ancestorEndTime = DateTime.UtcNow; totalTimeToAddAncestors += ancestorEndTime - ancestorStartTime; - component.DevelopmentDependency = this.MergeDevDependency(component.DevelopmentDependency, dependencyGraph.IsDevelopmentDependency(component.Component.Id)); - component.DependencyScope = DependencyScopeComparer.GetMergedDependencyScope(component.DependencyScope, dependencyGraph.GetDependencyScope(component.Component.Id)); + component.DevelopmentDependency = this.MergeDevDependency(component.DevelopmentDependency, dependencyGraph.IsDevelopmentDependency(graphComponentId)); + component.DependencyScope = DependencyScopeComparer.GetMergedDependencyScope(component.DependencyScope, dependencyGraph.GetDependencyScope(graphComponentId)); component.DetectedBy = detector; // Experiments uses this service to build the dependency graph for analysis. In this case, we do not want to update the locations of the component. @@ -269,7 +284,7 @@ private DetectedComponent MergeComponents(IEnumerable enumera return firstComponent; } - private void AddRootsToDetectedComponent(DetectedComponent detectedComponent, IDependencyGraph dependencyGraph, IComponentRecorder componentRecorder) + private void AddRootsToDetectedComponent(DetectedComponent detectedComponent, string graphComponentId, IDependencyGraph dependencyGraph, IComponentRecorder componentRecorder) { detectedComponent.DependencyRoots ??= new HashSet(new ComponentComparer()); if (dependencyGraph == null) @@ -277,10 +292,10 @@ private void AddRootsToDetectedComponent(DetectedComponent detectedComponent, ID return; } - detectedComponent.DependencyRoots.UnionWith(dependencyGraph.GetRootsAsTypedComponents(detectedComponent.Component.Id, componentRecorder.GetComponent)); + detectedComponent.DependencyRoots.UnionWith(dependencyGraph.GetRootsAsTypedComponents(graphComponentId, componentRecorder.GetComponent)); } - private void AddAncestorsToDetectedComponent(DetectedComponent detectedComponent, IDependencyGraph dependencyGraph, IComponentRecorder componentRecorder) + private void AddAncestorsToDetectedComponent(DetectedComponent detectedComponent, string graphComponentId, IDependencyGraph dependencyGraph, IComponentRecorder componentRecorder) { detectedComponent.AncestralDependencyRoots ??= new HashSet(new ComponentComparer()); if (dependencyGraph == null) @@ -288,7 +303,7 @@ private void AddAncestorsToDetectedComponent(DetectedComponent detectedComponent return; } - detectedComponent.AncestralDependencyRoots.UnionWith(dependencyGraph.GetAncestorsAsTypedComponents(detectedComponent.Component.Id, componentRecorder.GetComponent)); + detectedComponent.AncestralDependencyRoots.UnionWith(dependencyGraph.GetAncestorsAsTypedComponents(graphComponentId, componentRecorder.GetComponent)); } private HashSet MakeFilePathsRelative(ILogger logger, DirectoryInfo rootDirectory, HashSet filePaths) diff --git a/test/Microsoft.ComponentDetection.Common.Tests/ComponentRecorderTests.cs b/test/Microsoft.ComponentDetection.Common.Tests/ComponentRecorderTests.cs index 17278111f..7eb372d3a 100644 --- a/test/Microsoft.ComponentDetection.Common.Tests/ComponentRecorderTests.cs +++ b/test/Microsoft.ComponentDetection.Common.Tests/ComponentRecorderTests.cs @@ -428,4 +428,136 @@ public void GetAllDependencyGraphs_ReturnedGraphsAreImmutable() Action attemptedAdd = () => asCollection.Add("should't work"); attemptedAdd.Should().Throw(); } + + [TestMethod] + public void GetDetectedComponents_BareAndRichAcrossFiles_BareSubsumedIntoRich() + { + var recorder1 = this.componentRecorder.CreateSingleFileComponentRecorder("package.json"); + var recorder2 = this.componentRecorder.CreateSingleFileComponentRecorder("package-lock.json"); + + var bareComponent = new DetectedComponent(new NpmComponent("lodash", "4.17.23")); + var richComponent = new DetectedComponent(new NpmComponent("lodash", "4.17.23") { DownloadUrl = new Uri("https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz") }); + + recorder1.RegisterUsage(bareComponent); + recorder2.RegisterUsage(richComponent); + + var results = this.componentRecorder.GetDetectedComponents().ToList(); + + // Only the rich entry should remain + results.Should().ContainSingle(); + results[0].Component.Id.Should().Be(richComponent.Component.Id); + results[0].Component.Id.Should().NotBe(bareComponent.Component.Id); + } + + [TestMethod] + public void GetDetectedComponents_BareAndMultipleRichAcrossFiles_BareMergesIntoAllRich() + { + var recorder1 = this.componentRecorder.CreateSingleFileComponentRecorder("package.json"); + var recorder2 = this.componentRecorder.CreateSingleFileComponentRecorder("lockfile-a.json"); + var recorder3 = this.componentRecorder.CreateSingleFileComponentRecorder("lockfile-b.json"); + + var bareComponent = new DetectedComponent(new NpmComponent("lodash", "4.17.23")) + { + LicensesConcluded = ["MIT"], + }; + + var richA = new DetectedComponent(new NpmComponent("lodash", "4.17.23") { DownloadUrl = new Uri("https://registry-a.example.com/lodash-4.17.23.tgz") }); + var richB = new DetectedComponent(new NpmComponent("lodash", "4.17.23") { DownloadUrl = new Uri("https://registry-b.example.com/lodash-4.17.23.tgz") }); + + recorder1.RegisterUsage(bareComponent); + recorder2.RegisterUsage(richA); + recorder3.RegisterUsage(richB); + + var results = this.componentRecorder.GetDetectedComponents().ToList(); + + // Two rich entries, bare dropped + results.Should().HaveCount(2); + results.Should().NotContain(c => c.Component.Id == bareComponent.Component.Id); + + // Bare's license merged into both rich entries + results.Should().OnlyContain(c => c.LicensesConcluded != null && c.LicensesConcluded.Contains("MIT")); + } + + [TestMethod] + public void GetDetectedComponents_TwoRichDifferentUrls_BothKeptSeparate() + { + var recorder1 = this.componentRecorder.CreateSingleFileComponentRecorder("lockfile-a.json"); + var recorder2 = this.componentRecorder.CreateSingleFileComponentRecorder("lockfile-b.json"); + + var richA = new DetectedComponent(new NpmComponent("lodash", "4.17.23") { DownloadUrl = new Uri("https://registry-a.example.com/lodash-4.17.23.tgz") }); + var richB = new DetectedComponent(new NpmComponent("lodash", "4.17.23") { DownloadUrl = new Uri("https://registry-b.example.com/lodash-4.17.23.tgz") }); + + recorder1.RegisterUsage(richA); + recorder2.RegisterUsage(richB); + + var results = this.componentRecorder.GetDetectedComponents().ToList(); + + results.Should().HaveCount(2); + results.Should().Contain(c => c.Component.Id == richA.Component.Id); + results.Should().Contain(c => c.Component.Id == richB.Component.Id); + } + + [TestMethod] + public void GetDetectedComponents_BareOnlyAcrossFiles_MergesIntoSingleBare() + { + var recorder1 = this.componentRecorder.CreateSingleFileComponentRecorder("package.json"); + var recorder2 = this.componentRecorder.CreateSingleFileComponentRecorder("other-package.json"); + + var bare1 = new DetectedComponent(new NpmComponent("lodash", "4.17.23")) + { + LicensesConcluded = ["MIT"], + }; + + var bare2 = new DetectedComponent(new NpmComponent("lodash", "4.17.23")) + { + LicensesConcluded = ["Apache-2.0"], + }; + + recorder1.RegisterUsage(bare1); + recorder2.RegisterUsage(bare2); + + var results = this.componentRecorder.GetDetectedComponents().ToList(); + + // Same Id → merged into one + results.Should().ContainSingle(); + results[0].LicensesConcluded.Should().Contain("MIT"); + results[0].LicensesConcluded.Should().Contain("Apache-2.0"); + } + + [TestMethod] + public void GetDetectedComponents_BareAndRich_MetadataMergedCorrectly() + { + var recorder1 = this.componentRecorder.CreateSingleFileComponentRecorder("package.json"); + var recorder2 = this.componentRecorder.CreateSingleFileComponentRecorder("package-lock.json"); + + var bareComponent = new DetectedComponent(new NpmComponent("lodash", "4.17.23")) + { + LicensesConcluded = ["MIT"], + Suppliers = [new ActorInfo { Name = "Lodash Team", Type = "Organization" }], + }; + + var richComponent = new DetectedComponent(new NpmComponent("lodash", "4.17.23") { DownloadUrl = new Uri("https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz") }) + { + LicensesConcluded = ["Apache-2.0"], + Suppliers = [new ActorInfo { Name = "Contoso", Type = "Organization" }], + }; + + recorder1.RegisterUsage(bareComponent); + recorder2.RegisterUsage(richComponent); + + var results = this.componentRecorder.GetDetectedComponents().ToList(); + + results.Should().ContainSingle(); + var result = results[0]; + + // Licenses from both bare and rich should be merged + result.LicensesConcluded.Should().HaveCount(2); + result.LicensesConcluded.Should().Contain("Apache-2.0"); + result.LicensesConcluded.Should().Contain("MIT"); + + // Suppliers from both should be merged + result.Suppliers.Should().HaveCount(2); + result.Suppliers.Should().Contain(s => s.Name == "Lodash Team"); + result.Suppliers.Should().Contain(s => s.Name == "Contoso"); + } } diff --git a/test/Microsoft.ComponentDetection.Orchestrator.Tests/Services/DefaultGraphTranslationServiceTests.cs b/test/Microsoft.ComponentDetection.Orchestrator.Tests/Services/DefaultGraphTranslationServiceTests.cs index 93fcbfa14..bded6afa3 100644 --- a/test/Microsoft.ComponentDetection.Orchestrator.Tests/Services/DefaultGraphTranslationServiceTests.cs +++ b/test/Microsoft.ComponentDetection.Orchestrator.Tests/Services/DefaultGraphTranslationServiceTests.cs @@ -439,4 +439,129 @@ public void GenerateScanResultFromResult_WithCustomLocations_WithExperimentsDryR actualNpmComponent.Should().BeEquivalentTo(expectedNpmComponent); actualNugetComponent.Should().BeEquivalentTo(expectedNugetComponent); } + + [TestMethod] + public void GenerateScanResult_RichComponentPicksUpGraphDataFromBareIdGraph() + { + // A rich component (with DownloadUrl) should pick up roots, ancestors, devDep, and file paths + // from a graph that registered the same package under the bare Id. + var file1Path = Path.Join(this.sourceDirectory.FullName, "package.json"); + var file2Path = Path.Join(this.sourceDirectory.FullName, "package-lock.json"); + + var recorder1 = this.componentRecorder.CreateSingleFileComponentRecorder(file1Path); + var recorder2 = this.componentRecorder.CreateSingleFileComponentRecorder(file2Path); + + var root = new NpmComponent("app", "1.0.0"); + var bareComponent = new NpmComponent("lodash", "4.17.23"); + var richComponent = new NpmComponent("lodash", "4.17.23") { DownloadUrl = new System.Uri("https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz") }; + + // File 1 (package.json): root → bare lodash, marked as explicit reference + recorder1.RegisterUsage(new DetectedComponent(root), isExplicitReferencedDependency: true); + recorder1.RegisterUsage(new DetectedComponent(bareComponent), parentComponentId: root.Id, isDevelopmentDependency: true); + + // File 2 (lockfile): root → rich lodash + recorder2.RegisterUsage(new DetectedComponent(root), isExplicitReferencedDependency: true); + recorder2.RegisterUsage(new DetectedComponent(richComponent), parentComponentId: root.Id, isDevelopmentDependency: false); + + var processingResult = new DetectorProcessingResult + { + ResultCode = ProcessingResultCode.Success, + ContainersDetailsMap = [], + ComponentRecorders = [(this.componentDetectorMock.Object, this.componentRecorder)], + }; + + var result = this.serviceUnderTest.GenerateScanResultFromProcessingResult( + processingResult, new ScanSettings { SourceDirectory = this.sourceDirectory }); + + // After reconciliation: bare is subsumed into rich. Only root + rich lodash should remain. + var lodashResult = result.ComponentsFound.Single(c => ((NpmComponent)c.Component).Name == "lodash"); + lodashResult.Component.Id.Should().Be(richComponent.Id); + + // Rich component should have graph data from BOTH graphs (file1 bare-Id graph + file2 rich-Id graph) + lodashResult.LocationsFoundAt.Should().Contain(l => l.Contains("package.json")); + lodashResult.LocationsFoundAt.Should().Contain(l => l.Contains("package-lock.json")); + + // DevDep: bare graph said true, rich graph said false → AND logic = false + lodashResult.IsDevelopmentDependency.Should().BeFalse(); + + // Should have roots (app is the root referrer) + lodashResult.TopLevelReferrers.Should().NotBeEmpty(); + } + + [TestMethod] + public void GenerateScanResult_BareOnlyComponent_GraphDataPreserved() + { + // When no rich entry exists, the bare component keeps its graph data. + var filePath = Path.Join(this.sourceDirectory.FullName, "package.json"); + var recorder = this.componentRecorder.CreateSingleFileComponentRecorder(filePath); + + var root = new NpmComponent("app", "1.0.0"); + var bareComponent = new NpmComponent("lodash", "4.17.23"); + + recorder.RegisterUsage(new DetectedComponent(root), isExplicitReferencedDependency: true); + recorder.RegisterUsage(new DetectedComponent(bareComponent), parentComponentId: root.Id, isDevelopmentDependency: true); + + var processingResult = new DetectorProcessingResult + { + ResultCode = ProcessingResultCode.Success, + ContainersDetailsMap = [], + ComponentRecorders = [(this.componentDetectorMock.Object, this.componentRecorder)], + }; + + var result = this.serviceUnderTest.GenerateScanResultFromProcessingResult( + processingResult, new ScanSettings { SourceDirectory = this.sourceDirectory }); + + var lodashResult = result.ComponentsFound.Single(c => ((NpmComponent)c.Component).Name == "lodash"); + lodashResult.Component.Id.Should().Be(bareComponent.Id); + lodashResult.IsDevelopmentDependency.Should().BeTrue(); + lodashResult.TopLevelReferrers.Should().NotBeEmpty(); + lodashResult.LocationsFoundAt.Should().Contain(l => l.Contains("package.json")); + } + + [TestMethod] + public void GenerateScanResult_MultipleRichAndBare_BareGraphDataAbsorbedByAllRich() + { + // Two rich entries + one bare. The bare's graph data should be absorbed by both rich entries. + var file1Path = Path.Join(this.sourceDirectory.FullName, "package.json"); + var file2Path = Path.Join(this.sourceDirectory.FullName, "lockfile-a.json"); + var file3Path = Path.Join(this.sourceDirectory.FullName, "lockfile-b.json"); + + var recorder1 = this.componentRecorder.CreateSingleFileComponentRecorder(file1Path); + var recorder2 = this.componentRecorder.CreateSingleFileComponentRecorder(file2Path); + var recorder3 = this.componentRecorder.CreateSingleFileComponentRecorder(file3Path); + + var root = new NpmComponent("app", "1.0.0"); + var bareComponent = new NpmComponent("lodash", "4.17.23"); + var richA = new NpmComponent("lodash", "4.17.23") { DownloadUrl = new System.Uri("https://registry-a.example.com/lodash-4.17.23.tgz") }; + var richB = new NpmComponent("lodash", "4.17.23") { DownloadUrl = new System.Uri("https://registry-b.example.com/lodash-4.17.23.tgz") }; + + // File 1 (package.json): root → bare lodash + recorder1.RegisterUsage(new DetectedComponent(root), isExplicitReferencedDependency: true); + recorder1.RegisterUsage(new DetectedComponent(bareComponent), parentComponentId: root.Id); + + // File 2 (lockfile A): root → rich A + recorder2.RegisterUsage(new DetectedComponent(root), isExplicitReferencedDependency: true); + recorder2.RegisterUsage(new DetectedComponent(richA), parentComponentId: root.Id); + + // File 3 (lockfile B): root → rich B + recorder3.RegisterUsage(new DetectedComponent(root), isExplicitReferencedDependency: true); + recorder3.RegisterUsage(new DetectedComponent(richB), parentComponentId: root.Id); + + var processingResult = new DetectorProcessingResult + { + ResultCode = ProcessingResultCode.Success, + ContainersDetailsMap = [], + ComponentRecorders = [(this.componentDetectorMock.Object, this.componentRecorder)], + }; + + var result = this.serviceUnderTest.GenerateScanResultFromProcessingResult( + processingResult, new ScanSettings { SourceDirectory = this.sourceDirectory }); + + // Two rich entries, bare dropped + var lodashResults = result.ComponentsFound.Where(c => ((NpmComponent)c.Component).Name == "lodash").ToList(); + lodashResults.Should().HaveCount(2); + + // Both rich entries should have the bare graph's file path (package.json) + lodashResults.Should().OnlyContain(c => c.LocationsFoundAt.Any(l => l.Contains("package.json"))); + } }