diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index eb7fb90..46602ad 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,21 +1,25 @@ -name: .NET - -on: - push: - branches: [ main ] - pull_request: - branches: [ main ] - -jobs: - build: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - name: Setup .NET - uses: actions/setup-dotnet@v4 - with: - global-json-file: global.json - - name: Restore source-indexer.sln - run: dotnet restore src/source-indexer.sln - - name: Build source-indexer.sln - run: dotnet build --no-restore src/source-indexer.sln +name: .NET + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: Restore source-indexer.sln + run: dotnet restore src/source-indexer.sln + - name: Restore SourceBrowser.sln + run: dotnet restore src/SourceBrowser/SourceBrowser.sln + - name: Build & test source-indexer.sln + run: dotnet test --no-restore src/source-indexer.sln + - name: Build & test SourceBrowser.sln + run: dotnet test --no-restore src/SourceBrowser/SourceBrowser.sln diff --git a/README.md b/README.md index 8a0b506..e64b2d7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ # source-indexer This repo contains the code for building http://source.dot.net +## Documentation +- [Source Selection Algorithm](docs/source-selection-algorithm.md) - How the indexer chooses the best implementation when multiple builds exist for the same assembly + ## Build Status [![Build Status](https://dev.azure.com/dnceng/internal/_apis/build/status/dotnet-source-indexer/dotnet-source-indexer%20CI?branchName=main)](https://dev.azure.com/dnceng/internal/_build/latest?definitionId=612&branchName=main) diff --git a/docs/source-selection-algorithm.md b/docs/source-selection-algorithm.md new file mode 100644 index 0000000..ae05353 --- /dev/null +++ b/docs/source-selection-algorithm.md @@ -0,0 +1,75 @@ +# Source Selection Algorithm + +When the source indexer processes multiple builds for the same assembly (e.g., generic builds, platform-specific builds, or builds with different target frameworks), it uses a scoring algorithm to select the "best" implementation to include in the final source index. + +## Overview + +The deduplication process groups all compiler invocations by `AssemblyName` and then calculates a score for each build. The build with the highest score is selected and included in the generated solution file. + +## Scoring Priorities + +The scoring algorithm evaluates builds using the following criteria, ordered by priority from highest to lowest: + +### 1. UseForSourceIndex Property (Highest Priority) +- **Score**: `int.MaxValue` (2,147,483,647) +- **Description**: When a project explicitly sets the `UseForSourceIndex` property to `true`, it receives the maximum possible score, ensuring it will always be selected regardless of other factors. +- **Use Case**: Provides an escape hatch for projects that should definitely be included in the source index. + +### 2. Platform Support Status (Second Priority) +- **Score**: `-10,000` penalty for platform-not-supported assemblies +- **Description**: If a project has the `IsPlatformNotSupportedAssembly` property set to `true`, it receives a heavy penalty. +- **Use Case**: Ensures that stub implementations containing mostly `PlatformNotSupportedException` are avoided in favor of real implementations. + +### 3. Target Framework Version (Third Priority) +- **Score**: `Major * 1000 + Minor * 100` +- **Description**: Newer framework versions receive higher scores. For example: + - .NET 8.0 = 8,000 + 0 = 8,000 points + - .NET 6.0 = 6,000 + 0 = 6,000 points + - .NET Framework 4.8 = 4,000 + 80 = 4,080 points +- **Use Case**: Prefers more recent implementations that are likely to contain the latest features and bug fixes. + +### 4. Platform Specificity (Fourth Priority) +- **Score**: `+500` for platform-specific frameworks +- **Additional**: `+100` bonus for Linux platforms, `+50` bonus for Unix platforms +- **Description**: Platform-specific builds (e.g., `net8.0-linux`, `net8.0-windows`) receive bonuses over generic builds. +- **Use Case**: Platform-specific implementations often contain more complete functionality than generic implementations. + +### 5. Source File Count (Lowest Priority) +- **Score**: `+1` per source file +- **Description**: Builds with more source files receive higher scores. +- **Use Case**: Acts as a tiebreaker when other factors are equal, assuming more source files indicate a more complete implementation. + +## Example Scoring + +Consider these hypothetical builds for `System.Net.NameResolution`: + +| Build | UseForSourceIndex | IsPlatformNotSupported | Framework | Platform | Source Files | Total Score | +|-------|-------------------|------------------------|-----------|----------|--------------|-------------| +| Generic Build | false | true | net8.0 | none | 45 | -1,955 | +| Linux Build | false | false | net8.0-linux | linux | 127 | 8,727 | +| Windows Build | false | false | net8.0-windows | windows | 98 | 8,598 | +| Override Build | true | false | net6.0 | none | 23 | 2,147,483,647 | + +In this example: +- The **Override Build** would be selected due to `UseForSourceIndex=true` +- Without the override, the **Linux Build** would be selected with the highest score +- The **Generic Build** receives a massive penalty for being platform-not-supported + +## Implementation Details + +The scoring logic is implemented in the `CalculateInvocationScore` method in `BinLogToSln/Program.cs`. The method: + +1. Reads project properties from the binlog file +2. Applies scoring rules in priority order +3. Handles parsing errors gracefully +4. Returns a base score of 1 for builds that fail scoring to avoid complete exclusion + +## Configuration + +The algorithm can be influenced through MSBuild project properties: + +- **UseForSourceIndex**: Set to `true` to force selection of this build +- **IsPlatformNotSupportedAssembly**: Set to `true` to indicate this is a stub implementation +- **TargetFramework**: Automatically detected from the project file + +These properties are captured from the binlog during the build analysis phase. \ No newline at end of file diff --git a/src/SourceBrowser/SourceBrowser.sln b/src/SourceBrowser/SourceBrowser.sln index 47214ad..b688053 100644 --- a/src/SourceBrowser/SourceBrowser.sln +++ b/src/SourceBrowser/SourceBrowser.sln @@ -19,7 +19,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SourceIndexServer.Tests", " EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BinLogParser", "src\BinLogParser\BinLogParser.csproj", "{4EF5052C-7D88-49C6-B940-5190CECD070D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BinLogToSln", "src\BinLogToSln\BinLogToSln.csproj", "{E73D1784-F1BC-4F01-B68E-03623CFBFB8E}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BinLogToSln", "src\BinLogToSln\BinLogToSln.csproj", "{E73D1784-F1BC-4F01-B68E-03623CFBFB8E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BinLogToSln.Tests", "src\BinLogToSln.Tests\BinLogToSln.Tests.csproj", "{A6F4AA1E-2B2A-4E48-9C3E-4A1B2D3C5E7F}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C0B9CC1C-1EF1-4086-9532-E8679CBA4E62}" ProjectSection(SolutionItems) = preProject @@ -65,10 +67,14 @@ Global {4EF5052C-7D88-49C6-B940-5190CECD070D}.Debug|Any CPU.Build.0 = Debug|Any CPU {4EF5052C-7D88-49C6-B940-5190CECD070D}.Release|Any CPU.ActiveCfg = Release|Any CPU {4EF5052C-7D88-49C6-B940-5190CECD070D}.Release|Any CPU.Build.0 = Release|Any CPU - {E73D1784-F1BC-4F01-B68E-03623CFBFB8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E73D1784-F1BC-4F01-B68E-03623CFBFB8E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E73D1784-F1BC-4F01-B68E-03623CFBFB8E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E73D1784-F1BC-4F01-B68E-03623CFBFB8E}.Release|Any CPU.Build.0 = Release|Any CPU + {E73D1784-F1BC-4F01-B68E-03623CFBFB8E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E73D1784-F1BC-4F01-B68E-03623CFBFB8E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E73D1784-F1BC-4F01-B68E-03623CFBFB8E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E73D1784-F1BC-4F01-B68E-03623CFBFB8E}.Release|Any CPU.Build.0 = Release|Any CPU + {A6F4AA1E-2B2A-4E48-9C3E-4A1B2D3C5E7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A6F4AA1E-2B2A-4E48-9C3E-4A1B2D3C5E7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A6F4AA1E-2B2A-4E48-9C3E-4A1B2D3C5E7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A6F4AA1E-2B2A-4E48-9C3E-4A1B2D3C5E7F}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/SourceBrowser/src/BinLogParser/BinLogReader.cs b/src/SourceBrowser/src/BinLogParser/BinLogReader.cs index 6d68e54..917e799 100644 --- a/src/SourceBrowser/src/BinLogParser/BinLogReader.cs +++ b/src/SourceBrowser/src/BinLogParser/BinLogReader.cs @@ -1,9 +1,13 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using Microsoft.Build.Framework; +using Microsoft.Build.Framework; +using Microsoft.Build.Logging.StructuredLogger; using Microsoft.CodeAnalysis; +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; namespace Microsoft.SourceBrowser.BinLogParser { @@ -33,18 +37,39 @@ public static IEnumerable ExtractInvocations(string binLogFi var lazyResult = m_binlogInvocationMap.GetOrAdd(binLogFilePath, new Lazy>(() => { + // for old format logs, use the legacy reader - this is less desireable because it loads everything into memory if (binLogFilePath.EndsWith(".buildlog", StringComparison.OrdinalIgnoreCase)) { return ExtractInvocationsFromBuild(binLogFilePath); } + // for new format logs, replay the log to avoid loading everything into memory var invocations = new List(); var reader = new Microsoft.Build.Logging.StructuredLogger.BinLogReader(); var taskIdToInvocationMap = new Dictionary<(int, int), CompilerInvocation>(); + var projectEvaluationToPropertiesMap = new Dictionary>(); + var projectInstanceToEvaluationMap = new Dictionary(); void TryGetInvocationFromEvent(object sender, BuildEventArgs args) { - var invocation = TryGetInvocationFromRecord(args, taskIdToInvocationMap); + Dictionary projectProperties = null; + if (projectInstanceToEvaluationMap.TryGetValue(args.BuildEventContext?.ProjectInstanceId ?? -1, out var evaluationId) && + projectEvaluationToPropertiesMap.TryGetValue(evaluationId, out var properties)) + { + projectProperties = properties; + + if (args is PropertyReassignmentEventArgs propertyReassignment) + { + properties[propertyReassignment.PropertyName] = propertyReassignment.NewValue; + } + else if (args is PropertyInitialValueSetEventArgs propertyInitialValueSet) + { + properties[propertyInitialValueSet.PropertyName] = propertyInitialValueSet.PropertyValue; + } + } + + + var invocation = TryGetInvocationFromRecord(args, taskIdToInvocationMap, projectProperties); if (invocation != null) { invocation.SolutionRoot = Path.GetDirectoryName(binLogFilePath); @@ -52,7 +77,40 @@ void TryGetInvocationFromEvent(object sender, BuildEventArgs args) } } - reader.TargetStarted += TryGetInvocationFromEvent; + reader.StatusEventRaised += (object sender, BuildStatusEventArgs e) => + { + if (e?.BuildEventContext?.EvaluationId >= 0 && + e is ProjectEvaluationFinishedEventArgs projectEvalArgs) + { + if (projectEvalArgs?.Properties is IDictionary propertiesDict) + { + projectEvaluationToPropertiesMap[e.BuildEventContext.EvaluationId] = + new Dictionary(propertiesDict, StringComparer.OrdinalIgnoreCase); + } + else + { + var properties = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (KeyValuePair property in projectEvalArgs.Properties) + { + properties[property.Key] = property.Value; + } + + projectEvaluationToPropertiesMap[e.BuildEventContext.EvaluationId] = properties; + } + } + }; + + reader.ProjectStarted += (object sender, ProjectStartedEventArgs e) => + { + if (e?.BuildEventContext?.EvaluationId >= 0 && + e?.BuildEventContext?.ProjectInstanceId >= 0) + { + projectInstanceToEvaluationMap[e.BuildEventContext.ProjectInstanceId] = e.BuildEventContext.EvaluationId; + } + }; + + reader.TargetStarted += TryGetInvocationFromEvent; reader.MessageRaised += TryGetInvocationFromEvent; reader.Replay(binLogFilePath); @@ -65,56 +123,61 @@ void TryGetInvocationFromEvent(object sender, BuildEventArgs args) return result; } - private static List ExtractInvocationsFromBuild(string logFilePath) - { - var build = Microsoft.Build.Logging.StructuredLogger.Serialization.Read(logFilePath); - var invocations = new List(); - build.VisitAllChildren(t => - { - var invocation = TryGetInvocationFromTask(t); - if (invocation != null) - { - invocations.Add(invocation); - } - }); - - return invocations; + private static List ExtractInvocationsFromBuild(string logFilePath) + { + var build = Microsoft.Build.Logging.StructuredLogger.Serialization.Read(logFilePath); + var invocations = new List(); + build.VisitAllChildren(t => + { + var invocation = TryGetInvocationFromTask(t, build); + if (invocation != null) + { + invocations.Add(invocation); + } + }); + + return invocations; } - private static CompilerInvocation TryGetInvocationFromRecord(BuildEventArgs args, Dictionary<(int, int), CompilerInvocation> taskIdToInvocationMap) - { - int targetId = args.BuildEventContext?.TargetId ?? -1; - int projectId = args.BuildEventContext?.ProjectInstanceId ?? -1; - if (targetId < 0) - { - return null; - } - - var targetStarted = args as TargetStartedEventArgs; - if (targetStarted != null && targetStarted.TargetName == "CoreCompile") - { - var invocation = new CompilerInvocation(); - taskIdToInvocationMap[(targetId, projectId)] = invocation; - invocation.ProjectFilePath = targetStarted.ProjectFile; - return null; - } - - var commandLine = GetCommandLineFromEventArgs(args, out var language); - if (commandLine == null) - { - return null; - } - - CompilerInvocation compilerInvocation; - if (taskIdToInvocationMap.TryGetValue((targetId, projectId), out compilerInvocation)) - { - compilerInvocation.Language = language == CompilerKind.CSharp ? LanguageNames.CSharp : LanguageNames.VisualBasic; - compilerInvocation.CommandLineArguments = commandLine; - Populate(compilerInvocation); - taskIdToInvocationMap.Remove((targetId, projectId)); - } - - return compilerInvocation; + private static CompilerInvocation TryGetInvocationFromRecord(BuildEventArgs args, + Dictionary<(int, int), CompilerInvocation> taskIdToInvocationMap, + Dictionary projectProperties) + { + int targetId = args.BuildEventContext?.TargetId ?? -1; + int projectId = args.BuildEventContext?.ProjectInstanceId ?? -1; + + if (targetId < 0) + { + return null; + } + + if (args is TargetStartedEventArgs targetStarted && targetStarted.TargetName == "CoreCompile") + { + var invocation = new CompilerInvocation() + { + ProjectFilePath = targetStarted.ProjectFile, + ProjectProperties = projectProperties, + }; + taskIdToInvocationMap[(targetId, projectId)] = invocation; + return null; + } + + var commandLine = GetCommandLineFromEventArgs(args, out var language); + if (commandLine == null) + { + return null; + } + + CompilerInvocation compilerInvocation; + if (taskIdToInvocationMap.TryGetValue((targetId, projectId), out compilerInvocation)) + { + compilerInvocation.Language = language == CompilerKind.CSharp ? LanguageNames.CSharp : LanguageNames.VisualBasic; + compilerInvocation.CommandLineArguments = commandLine; + Populate(compilerInvocation); + taskIdToInvocationMap.Remove((targetId, projectId)); + } + + return compilerInvocation; } private static void Populate(CompilerInvocation compilerInvocation) @@ -125,25 +188,33 @@ private static void Populate(CompilerInvocation compilerInvocation) } } - private static CompilerInvocation TryGetInvocationFromTask(Microsoft.Build.Logging.StructuredLogger.Task task) - { - var name = task.Name; - if (name != "Csc" && name != "Vbc" || ((task.Parent as Microsoft.Build.Logging.StructuredLogger.Target)?.Name != "CoreCompile")) - { - return null; - } - - var language = name == "Csc" ? LanguageNames.CSharp : LanguageNames.VisualBasic; - var commandLine = task.CommandLineArguments; - commandLine = TrimCompilerExeFromCommandLine(commandLine, name == "Csc" - ? CompilerKind.CSharp - : CompilerKind.VisualBasic); - return new CompilerInvocation - { - Language = language, - CommandLineArguments = commandLine, - ProjectFilePath = task.GetNearestParent()?.ProjectFile - }; + private static CompilerInvocation TryGetInvocationFromTask(Microsoft.Build.Logging.StructuredLogger.Task task, Microsoft.Build.Logging.StructuredLogger.Build build) + { + var name = task.Name; + if (name != "Csc" && name != "Vbc" || ((task.Parent as Microsoft.Build.Logging.StructuredLogger.Target)?.Name != "CoreCompile")) + { + return null; + } + + var language = name == "Csc" ? LanguageNames.CSharp : LanguageNames.VisualBasic; + var commandLine = task.CommandLineArguments; + commandLine = TrimCompilerExeFromCommandLine(commandLine, name == "Csc" + ? CompilerKind.CSharp + : CompilerKind.VisualBasic); + + // Get the project once and reuse it + var project = task.GetNearestParent(); + + var invocation = new CompilerInvocation + { + Language = language, + CommandLineArguments = commandLine, + ProjectFilePath = project?.ProjectFile, + ProjectProperties = project?.GetEvaluation(build)?.GetProperties() ?? new Dictionary(), + }; + + + return invocation; } public static string TrimCompilerExeFromCommandLine(string commandLine, CompilerKind language) diff --git a/src/SourceBrowser/src/BinLogParser/CompilerInvocation.cs b/src/SourceBrowser/src/BinLogParser/CompilerInvocation.cs index 8995c2b..cbfc9d2 100644 --- a/src/SourceBrowser/src/BinLogParser/CompilerInvocation.cs +++ b/src/SourceBrowser/src/BinLogParser/CompilerInvocation.cs @@ -9,14 +9,15 @@ namespace Microsoft.SourceBrowser.BinLogParser { - public class CompilerInvocation - { - public string ProjectFilePath { get; set; } - public string ProjectDirectory => ProjectFilePath == null ? "" : Path.GetDirectoryName(ProjectFilePath); - public string OutputAssemblyPath { get; set; } - public string CommandLineArguments { get; set; } - public string SolutionRoot { get; set; } - public IEnumerable TypeScriptFiles { get; set; } + public class CompilerInvocation + { + public string ProjectFilePath { get; set; } + public string ProjectDirectory => ProjectFilePath == null ? "" : Path.GetDirectoryName(ProjectFilePath); + public string OutputAssemblyPath { get; set; } + public string CommandLineArguments { get; set; } + public string SolutionRoot { get; set; } + public IEnumerable TypeScriptFiles { get; set; } + public Dictionary ProjectProperties { get; set; } = new Dictionary(); public string AssemblyName => Path.GetFileNameWithoutExtension(OutputAssemblyPath); @@ -81,9 +82,25 @@ public CommandLineArguments Parsed } } - public override string ToString() - { - return CommandLineArguments; - } - } + public CompilerInvocation Clone() + { + return new CompilerInvocation + { + ProjectFilePath = this.ProjectFilePath, + OutputAssemblyPath = this.OutputAssemblyPath, + CommandLineArguments = this.CommandLineArguments, + SolutionRoot = this.SolutionRoot, + TypeScriptFiles = this.TypeScriptFiles?.ToList(), // Create a new list if not null + ProjectProperties = this.ProjectProperties != null + ? new Dictionary(this.ProjectProperties) + : new Dictionary(), + language = this.language // Copy the backing field if set + }; + } + + public override string ToString() + { + return CommandLineArguments; + } + } } diff --git a/src/SourceBrowser/src/BinLogToSln.Tests/BinLogToSln.Tests.csproj b/src/SourceBrowser/src/BinLogToSln.Tests/BinLogToSln.Tests.csproj new file mode 100644 index 0000000..675a7d0 --- /dev/null +++ b/src/SourceBrowser/src/BinLogToSln.Tests/BinLogToSln.Tests.csproj @@ -0,0 +1,20 @@ + + + + net9.0 + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SourceBrowser/src/BinLogToSln.Tests/InvocationScoringTests.cs b/src/SourceBrowser/src/BinLogToSln.Tests/InvocationScoringTests.cs new file mode 100644 index 0000000..65dc883 --- /dev/null +++ b/src/SourceBrowser/src/BinLogToSln.Tests/InvocationScoringTests.cs @@ -0,0 +1,265 @@ +using System.Collections.Generic; +using Microsoft.SourceBrowser.BinLogParser; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace BinLogToSln.Tests +{ + [TestClass] + public class InvocationScoringTests + { + private static CompilerInvocation CreateBaseInvocation() + { + return new CompilerInvocation + { + ProjectFilePath = "/test/project.csproj", + OutputAssemblyPath = "/test/output.dll", + CommandLineArguments = "/noconfig /nowarn:1701,1702 /nostdlib+ /target:library /out:output.dll File1.cs", + ProjectProperties = new Dictionary + { + ["TargetFramework"] = "net8.0" + } + }; + } + + [TestMethod] + public void CalculateInvocationScore_UseForSourceIndex_ScoresHigherThanWithout() + { + // Arrange + var baseInvocation = CreateBaseInvocation(); + var withUseForSourceIndex = baseInvocation.Clone(); + withUseForSourceIndex.ProjectProperties["UseForSourceIndex"] = "true"; + + var withoutUseForSourceIndex = baseInvocation.Clone(); + + // Act + var scoreWith = Program.CalculateInvocationScore(withUseForSourceIndex); + var scoreWithout = Program.CalculateInvocationScore(withoutUseForSourceIndex); + + // Assert + Assert.IsTrue(scoreWith > scoreWithout, "UseForSourceIndex should score higher than without it"); + } + + [TestMethod] + public void CalculateInvocationScore_PlatformNotSupported_ScoresLowerThanSupported() + { + // Arrange + var baseInvocation = CreateBaseInvocation(); + var platformNotSupported = baseInvocation.Clone(); + platformNotSupported.ProjectProperties["IsPlatformNotSupportedAssembly"] = "true"; + + var platformSupported = baseInvocation.Clone(); + + // Act + var notSupportedScore = Program.CalculateInvocationScore(platformNotSupported); + var supportedScore = Program.CalculateInvocationScore(platformSupported); + + // Assert + Assert.IsTrue(supportedScore > notSupportedScore, "Platform supported assembly should score higher than platform not supported"); + } + + [TestMethod] + public void CalculateInvocationScore_FrameworkVersion_NewerScoresHigher() + { + // Test that newer framework versions score higher than older ones + var baseInvocation = CreateBaseInvocation(); + var newerFramework = baseInvocation.Clone(); + newerFramework.ProjectProperties["TargetFramework"] = "net8.0"; + + var olderFramework = baseInvocation.Clone(); + olderFramework.ProjectProperties["TargetFramework"] = "net6.0"; + + // Act + var newerScore = Program.CalculateInvocationScore(newerFramework); + var olderScore = Program.CalculateInvocationScore(olderFramework); + + // Assert + Assert.IsTrue(newerScore > olderScore, "Newer framework version should score higher than older version"); + + // Test that net48 scores higher than netstandard2.1 + var net48Framework = baseInvocation.Clone(); + net48Framework.ProjectProperties["TargetFramework"] = "net48"; + + var netstandardFramework = baseInvocation.Clone(); + netstandardFramework.ProjectProperties["TargetFramework"] = "netstandard2.1"; + + var net48Score = Program.CalculateInvocationScore(net48Framework); + var netstandardScore = Program.CalculateInvocationScore(netstandardFramework); + + Assert.IsTrue(net48Score > netstandardScore, "net48 should score higher than netstandard2.1"); + } + + [TestMethod] + public void CalculateInvocationScore_PlatformSpecific_ScoresHigherThanGeneric() + { + // Test that platform-specific frameworks score higher than generic ones + var baseInvocation = CreateBaseInvocation(); + var platformSpecific = baseInvocation.Clone(); + platformSpecific.ProjectProperties["TargetFramework"] = "net8.0-linux"; + + var generic = baseInvocation.Clone(); + generic.ProjectProperties["TargetFramework"] = "net8.0"; + + // Act + var platformScore = Program.CalculateInvocationScore(platformSpecific); + var genericScore = Program.CalculateInvocationScore(generic); + + // Assert + Assert.IsTrue(platformScore > genericScore, "Platform-specific framework should score higher than generic framework"); + + // Test that linux scores higher than windows for same framework + var linuxFramework = baseInvocation.Clone(); + linuxFramework.ProjectProperties["TargetFramework"] = "net8.0-linux"; + + var windowsFramework = baseInvocation.Clone(); + windowsFramework.ProjectProperties["TargetFramework"] = "net8.0-windows"; + + var linuxScore = Program.CalculateInvocationScore(linuxFramework); + var windowsScore = Program.CalculateInvocationScore(windowsFramework); + + Assert.IsTrue(linuxScore > windowsScore, "Linux framework should score higher than Windows framework"); + } + + [TestMethod] + public void CalculateInvocationScore_SourceFileCount_MoreFilesScoreHigher() + { + // Arrange + var baseInvocation = CreateBaseInvocation(); + var moreSourceFiles = baseInvocation.Clone(); + moreSourceFiles.CommandLineArguments = "/noconfig /nowarn:1701,1702 /nostdlib+ /target:library /out:output.dll Class1.cs Class2.cs Class3.cs"; + + var fewerSourceFiles = baseInvocation.Clone(); + fewerSourceFiles.CommandLineArguments = "/noconfig /nowarn:1701,1702 /nostdlib+ /target:library /out:output.dll Class1.cs"; + + // Act + var moreFilesScore = Program.CalculateInvocationScore(moreSourceFiles); + var fewerFilesScore = Program.CalculateInvocationScore(fewerSourceFiles); + + // Assert + Assert.IsTrue(moreFilesScore > fewerFilesScore, "Invocation with more source files should score higher"); + } + + [TestMethod] + public void CalculateInvocationScore_ComplexScenario_ScoresHigherThanSimple() + { + // Arrange - Linux platform with multiple source files + var baseInvocation = CreateBaseInvocation(); + var complexScenario = baseInvocation.Clone(); + complexScenario.CommandLineArguments = "/noconfig /nowarn:1701,1702 /nostdlib+ /target:library /out:output.dll File1.cs File2.cs File3.cs File4.cs File5.cs"; + complexScenario.ProjectProperties["TargetFramework"] = "net8.0-linux"; + + var simpleScenario = baseInvocation.Clone(); + simpleScenario.CommandLineArguments = "/noconfig /nowarn:1701,1702 /nostdlib+ /target:library /out:output.dll File1.cs"; + simpleScenario.ProjectProperties["TargetFramework"] = "net8.0"; + + // Act + var complexScore = Program.CalculateInvocationScore(complexScenario); + var simpleScore = Program.CalculateInvocationScore(simpleScenario); + + // Assert + Assert.IsTrue(complexScore > simpleScore, "Complex scenario (platform-specific with more files) should score higher than simple scenario"); + } + + [TestMethod] + public void CalculateInvocationScore_NoProjectProperties_ScoresLowerThanWithProperties() + { + // Arrange + var baseInvocation = CreateBaseInvocation(); + var noProperties = baseInvocation.Clone(); + noProperties.ProjectProperties = null; + + var withProperties = baseInvocation.Clone(); + + // Act + var noPropertiesScore = Program.CalculateInvocationScore(noProperties); + var withPropertiesScore = Program.CalculateInvocationScore(withProperties); + + // Assert + Assert.IsTrue(withPropertiesScore > noPropertiesScore, "Invocation with project properties should score higher than one without"); + } + + [TestMethod] + public void CalculateInvocationScore_EmptyProjectProperties_ScoresLowerThanWithFramework() + { + // Arrange + var baseInvocation = CreateBaseInvocation(); + var emptyProperties = baseInvocation.Clone(); + emptyProperties.ProjectProperties.Clear(); + emptyProperties.CommandLineArguments = "/noconfig /nowarn:1701,1702 /nostdlib+ /target:library /out:output.dll File1.cs File2.cs"; + + var withFramework = baseInvocation.Clone(); + withFramework.CommandLineArguments = "/noconfig /nowarn:1701,1702 /nostdlib+ /target:library /out:output.dll File1.cs File2.cs"; + + // Act + var emptyPropertiesScore = Program.CalculateInvocationScore(emptyProperties); + var withFrameworkScore = Program.CalculateInvocationScore(withFramework); + + // Assert + Assert.IsTrue(withFrameworkScore > emptyPropertiesScore, "Invocation with framework should score higher than one with empty properties"); + } + + [TestMethod] + public void CalculateInvocationScore_InvalidTargetFramework_ScoresLowerThanValid() + { + // Arrange + var baseInvocation = CreateBaseInvocation(); + var invalidFramework = baseInvocation.Clone(); + invalidFramework.ProjectProperties["TargetFramework"] = "invalid-framework-name"; + + var validFramework = baseInvocation.Clone(); + validFramework.ProjectProperties["TargetFramework"] = "net8.0"; + + // Act + var invalidScore = Program.CalculateInvocationScore(invalidFramework); + var validScore = Program.CalculateInvocationScore(validFramework); + + // Assert + Assert.IsTrue(validScore > invalidScore, "Valid framework should score higher than invalid framework"); + } + + [TestMethod] + public void CalculateInvocationScore_PrioritiesWork_CorrectOrdering() + { + // Create invocations with different priority features + var baseInvocation = CreateBaseInvocation(); + + var useForSourceIndexInvocation = baseInvocation.Clone(); + useForSourceIndexInvocation.ProjectFilePath = "/test/project1.csproj"; + useForSourceIndexInvocation.OutputAssemblyPath = "/test/output1.dll"; + useForSourceIndexInvocation.CommandLineArguments = "/noconfig /target:library /out:output1.dll"; + useForSourceIndexInvocation.ProjectProperties["UseForSourceIndex"] = "true"; + useForSourceIndexInvocation.ProjectProperties["IsPlatformNotSupportedAssembly"] = "true"; // Should be ignored due to UseForSourceIndex + useForSourceIndexInvocation.ProjectProperties["TargetFramework"] = "net6.0"; + + var notSupportedInvocation = baseInvocation.Clone(); + notSupportedInvocation.ProjectFilePath = "/test/project2.csproj"; + notSupportedInvocation.OutputAssemblyPath = "/test/output2.dll"; + notSupportedInvocation.CommandLineArguments = "/noconfig /target:library /out:output2.dll File1.cs File2.cs File3.cs File4.cs File5.cs"; + notSupportedInvocation.ProjectProperties["IsPlatformNotSupportedAssembly"] = "true"; + notSupportedInvocation.ProjectProperties["TargetFramework"] = "net8.0"; + + var newerFrameworkInvocation = baseInvocation.Clone(); + newerFrameworkInvocation.ProjectFilePath = "/test/project3.csproj"; + newerFrameworkInvocation.OutputAssemblyPath = "/test/output3.dll"; + newerFrameworkInvocation.CommandLineArguments = "/noconfig /target:library /out:output3.dll File1.cs"; + newerFrameworkInvocation.ProjectProperties["TargetFramework"] = "net8.0"; + + var olderFrameworkInvocation = baseInvocation.Clone(); + olderFrameworkInvocation.ProjectFilePath = "/test/project4.csproj"; + olderFrameworkInvocation.OutputAssemblyPath = "/test/output4.dll"; + olderFrameworkInvocation.CommandLineArguments = "/noconfig /target:library /out:output4.dll File1.cs"; + olderFrameworkInvocation.ProjectProperties["TargetFramework"] = "net6.0"; + + // Act + var useForSourceIndexScore = Program.CalculateInvocationScore(useForSourceIndexInvocation); + var notSupportedScore = Program.CalculateInvocationScore(notSupportedInvocation); + var newerFrameworkScore = Program.CalculateInvocationScore(newerFrameworkInvocation); + var olderFrameworkScore = Program.CalculateInvocationScore(olderFrameworkInvocation); + + // Assert - Higher scores should be better + Assert.IsTrue(useForSourceIndexScore > notSupportedScore, "UseForSourceIndex should beat platform not supported"); + Assert.IsTrue(useForSourceIndexScore > newerFrameworkScore, "UseForSourceIndex should beat newer framework"); + Assert.IsTrue(newerFrameworkScore > notSupportedScore, "Newer framework should beat platform not supported"); + Assert.IsTrue(newerFrameworkScore > olderFrameworkScore, "Newer framework should beat older framework"); + } + } +} \ No newline at end of file diff --git a/src/SourceBrowser/src/BinLogToSln/BinLogToSln.csproj b/src/SourceBrowser/src/BinLogToSln/BinLogToSln.csproj index bb477b8..37b0df1 100644 --- a/src/SourceBrowser/src/BinLogToSln/BinLogToSln.csproj +++ b/src/SourceBrowser/src/BinLogToSln/BinLogToSln.csproj @@ -9,10 +9,11 @@ 1.0.1 - - - - + + + + + diff --git a/src/SourceBrowser/src/BinLogToSln/Program.cs b/src/SourceBrowser/src/BinLogToSln/Program.cs index ba3d974..5def968 100644 --- a/src/SourceBrowser/src/BinLogToSln/Program.cs +++ b/src/SourceBrowser/src/BinLogToSln/Program.cs @@ -1,21 +1,151 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Linq; -using System.Numerics; -using System.Reflection.Metadata; -using System.Reflection.PortableExecutable; -using LibGit2Sharp; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.SourceBrowser.BinLogParser; -using Mono.Options; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Numerics; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; +using LibGit2Sharp; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.SourceBrowser.BinLogParser; +using Mono.Options; +using NuGet.Frameworks; + +[assembly: InternalsVisibleTo("BinLogToSln.Tests")] -namespace BinLogToSln -{ - class Program - { +namespace BinLogToSln +{ + class Program + { + private static CompilerInvocation SelectBestInvocation(IGrouping invocationGroup) + { + var invocations = invocationGroup.ToList(); + if (invocations.Count == 1) + { + return invocations[0]; + } + + Console.WriteLine($"Found {invocations.Count} candidates for assembly '{invocationGroup.Key}', selecting best..."); + + // Score each invocation based on our criteria + var scoredInvocations = invocations.Select(inv => new + { + Invocation = inv, + Score = CalculateInvocationScore(inv) + }).ToList(); + + // Select the highest scored invocation + var best = scoredInvocations.OrderByDescending(x => x.Score).First(); + + Console.WriteLine($"Selected '{best.Invocation.ProjectFilePath}' (score: {best.Score})"); + return best.Invocation; + } + + internal static int CalculateInvocationScore(CompilerInvocation invocation) + { + int score = 0; + + try + { + if (invocation.ProjectProperties is null) + { + Console.WriteLine($"Warning: No project properties for {invocation.ProjectFilePath}."); + } + + // 1. UseForSourceIndex (highest priority) + if (invocation.ProjectProperties?.TryGetValue("UseForSourceIndex", out var useForSourceIndex) == true && + bool.TryParse(useForSourceIndex, out var shouldUse) && shouldUse) + { + return int.MaxValue; // Highest possible score + } + + // 2. Not IsPlatformNotSupportedAssembly (second priority) + if (invocation.ProjectProperties?.TryGetValue("IsPlatformNotSupportedAssembly", out var isPlatformNotSupported) == true && + bool.TryParse(isPlatformNotSupported, out var isNotSupported) && isNotSupported) + { + score -= 10000; // Heavy penalty for platform not supported assemblies + } + + // 3. Newest TargetFramework version (third priority) + if (invocation.ProjectProperties?.TryGetValue("TargetFramework", out var targetFramework) == true && + !string.IsNullOrEmpty(targetFramework)) + { + try + { + var framework = NuGetFramework.Parse(targetFramework); + + // Prefer newer frameworks (high weight) + if (framework.Version != null) + { + score += (int)(framework.Version.Major * 1000 + framework.Version.Minor * 100); + } + + // 4. Has a platform (fourth priority) + // Prefer platform-specific frameworks + if (framework.HasPlatform) + { + score += 500; + + if (framework.Platform.Equals("linux", StringComparison.OrdinalIgnoreCase)) + { + score += 100; // Linux is preferred over other platforms + } + else if (framework.Platform.Equals("unix", StringComparison.OrdinalIgnoreCase)) + { + score += 50; // Unix is also preferred, but less than Linux + } + } + + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Could not parse TargetFramework '{targetFramework}': {ex.Message}"); + } + } + + // 5. More source files (lowest priority) + var sourceFiles = invocation.Parsed?.SourceFiles; + if (sourceFiles.HasValue) + { + int totalSourceFiles = sourceFiles.Value.Length; + score += totalSourceFiles; // Lower weight than other factors + } + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Error calculating score for {invocation.ProjectFilePath}: {ex.Message}"); + // Return a base score so we don't exclude this invocation entirely + score = 1; + } + + return score; + } + + private static bool ShouldExcludeInvocation(CompilerInvocation invocation) + { + if (string.IsNullOrEmpty(invocation.ProjectDirectory)) + { + return true; + } + + string projectFolder = Path.GetFileName(invocation.ProjectDirectory); + if (projectFolder == "ref" || projectFolder == "stubs") + { + Console.WriteLine($"Skipping Ref Assembly project {invocation.ProjectFilePath}"); + return true; + } + + if (Path.GetFileName(Path.GetDirectoryName(invocation.ProjectDirectory)) == "cycle-breakers") + { + Console.WriteLine($"Skipping Wpf Cycle-Breaker project {invocation.ProjectFilePath}"); + return true; + } + + return false; + } static void Main(string[] args) { string binlog = null; @@ -74,37 +204,32 @@ static void Main(string[] args) using var sln = new StreamWriter(slnFile); WriteSolutionHeader(sln); - IEnumerable invocations = BinLogCompilerInvocationsReader.ExtractInvocations(binlog); - var processed = new HashSet(); - foreach (CompilerInvocation invocation in invocations) - { - if (string.IsNullOrEmpty(invocation.ProjectDirectory)) - { - continue; - } - - string projectFolder = Path.GetFileName(invocation.ProjectDirectory); - if (projectFolder == "ref" || projectFolder == "stubs") - { - Console.WriteLine($"Skipping Ref Assembly project {invocation.ProjectFilePath}"); - continue; + IEnumerable invocations = BinLogCompilerInvocationsReader.ExtractInvocations(binlog); + + // Group invocations by assembly name and select the best one for each + var invocationGroups = invocations + .Where(invocation => !ShouldExcludeInvocation(invocation)) + .GroupBy(invocation => invocation.AssemblyName) + .Select(group => SelectBestInvocation(group)); + + var processed = new HashSet(); + foreach (CompilerInvocation invocation in invocationGroups) + { + if (invocation == null) + { + continue; + } + + if (!processed.Add(invocation.ProjectFilePath)) + { + continue; + } + + if (!processed.Add(Path.GetFileNameWithoutExtension(invocation.ProjectFilePath))) + { + continue; } - if (Path.GetFileName(Path.GetDirectoryName(invocation.ProjectDirectory)) == "cycle-breakers") - { - Console.WriteLine($"Skipping Wpf Cycle-Breaker project {invocation.ProjectFilePath}"); - continue; - } - - if (!processed.Add(invocation.ProjectFilePath)) - { - continue; - } - - if (!processed.Add(Path.GetFileNameWithoutExtension(invocation.ProjectFilePath))) - { - continue; - } Console.WriteLine($"Converting Project: {invocation.ProjectFilePath}"); string repoRelativeProjectPath = Path.GetRelativePath(repoRoot, invocation.ProjectFilePath); diff --git a/src/SourceBrowser/src/Directory.Packages.props b/src/SourceBrowser/src/Directory.Packages.props index 8f14ad0..9c77671 100644 --- a/src/SourceBrowser/src/Directory.Packages.props +++ b/src/SourceBrowser/src/Directory.Packages.props @@ -42,7 +42,7 @@ - + @@ -71,12 +71,13 @@ - + +