diff --git a/docs/adrs/0276-problem-matchers.md b/docs/adrs/0276-problem-matchers.md index bed1f75ff1e..5d7a034d179 100644 --- a/docs/adrs/0276-problem-matchers.md +++ b/docs/adrs/0276-problem-matchers.md @@ -250,6 +250,42 @@ Two problem matchers can be used: } ``` +#### Default from path + +The problem matcher can specify a `fromPath` property at the top level, which applies when a specific pattern doesn't provide a value for `fromPath`. This is useful for tools that don't include project file information in their output. + +For example, given the following compiler output that doesn't include project file information: + +``` +ClassLibrary.cs(16,24): warning CS0612: 'ClassLibrary.Helpers.MyHelper.Name' is obsolete +``` + +A problem matcher with a default from path can be used: + +```json +{ + "problemMatcher": [ + { + "owner": "csc-minimal", + "fromPath": "ClassLibrary/ClassLibrary.csproj", + "pattern": [ + { + "regexp": "^(.+)\\((\\d+),(\\d+)\\): (error|warning) (.+): (.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "code": 5, + "message": 6 + } + ] + } + ] +} +``` + +This ensures that the file is rooted to the correct path when there's not enough information in the error messages to extract a `fromPath`. + #### Mitigate regular expression denial of service (ReDos) If a matcher exceeds a 1 second timeout when processing a line, retry up to two three times total. diff --git a/src/Runner.Worker/IssueMatcher.cs b/src/Runner.Worker/IssueMatcher.cs index 35c1f881ccf..4089d93da2d 100644 --- a/src/Runner.Worker/IssueMatcher.cs +++ b/src/Runner.Worker/IssueMatcher.cs @@ -21,6 +21,7 @@ public MatcherChangedEventArgs(IssueMatcherConfig config) public sealed class IssueMatcher { private string _defaultSeverity; + private string _defaultFromPath; private string _owner; private IssuePattern[] _patterns; private IssueMatch[] _state; @@ -29,6 +30,7 @@ public IssueMatcher(IssueMatcherConfig config, TimeSpan timeout) { _owner = config.Owner; _defaultSeverity = config.Severity; + _defaultFromPath = config.FromPath; _patterns = config.Patterns.Select(x => new IssuePattern(x, timeout)).ToArray(); Reset(); } @@ -59,6 +61,19 @@ public string DefaultSeverity } } + public string DefaultFromPath + { + get + { + if (_defaultFromPath == null) + { + _defaultFromPath = string.Empty; + } + + return _defaultFromPath; + } + } + public IssueMatch Match(string line) { // Single pattern @@ -69,7 +84,7 @@ public IssueMatch Match(string line) if (regexMatch.Success) { - return new IssueMatch(null, pattern, regexMatch.Groups, DefaultSeverity); + return new IssueMatch(null, pattern, regexMatch.Groups, DefaultSeverity, DefaultFromPath); } return null; @@ -110,7 +125,7 @@ public IssueMatch Match(string line) } // Return - return new IssueMatch(runningMatch, pattern, regexMatch.Groups, DefaultSeverity); + return new IssueMatch(runningMatch, pattern, regexMatch.Groups, DefaultSeverity, DefaultFromPath); } // Not the last pattern else @@ -184,7 +199,7 @@ public IssuePattern(IssuePatternConfig config, TimeSpan timeout) public sealed class IssueMatch { - public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection groups, string defaultSeverity = null) + public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection groups, string defaultSeverity = null, string defaultFromPath = null) { File = runningMatch?.File ?? GetValue(groups, pattern.File); Line = runningMatch?.Line ?? GetValue(groups, pattern.Line); @@ -198,6 +213,11 @@ public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection { Severity = defaultSeverity; } + + if (string.IsNullOrEmpty(FromPath) && !string.IsNullOrEmpty(defaultFromPath)) + { + FromPath = defaultFromPath; + } } public string File { get; } @@ -282,6 +302,9 @@ public sealed class IssueMatcherConfig [DataMember(Name = "pattern")] private IssuePatternConfig[] _patterns; + [DataMember(Name = "fromPath")] + private string _fromPath; + public string Owner { get @@ -318,6 +341,24 @@ public string Severity } } + public string FromPath + { + get + { + if (_fromPath == null) + { + _fromPath = string.Empty; + } + + return _fromPath; + } + + set + { + _fromPath = value; + } + } + public IssuePatternConfig[] Patterns { get diff --git a/src/Test/L0/Worker/IssueMatcherL0.cs b/src/Test/L0/Worker/IssueMatcherL0.cs index 777772a8484..177dd6de20c 100644 --- a/src/Test/L0/Worker/IssueMatcherL0.cs +++ b/src/Test/L0/Worker/IssueMatcherL0.cs @@ -896,5 +896,173 @@ public void Matcher_SinglePattern_ExtractsProperties() Assert.Equal("not-working", match.Message); Assert.Equal("my-project.proj", match.FromPath); } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Matcher_SinglePattern_DefaultFromPath() + { + var config = JsonUtility.FromString(@" +{ + ""problemMatcher"": [ + { + ""owner"": ""myMatcher"", + ""fromPath"": ""subdir/default-project.csproj"", + ""pattern"": [ + { + ""regexp"": ""^file:(.+) line:(.+) column:(.+) severity:(.+) code:(.+) message:(.+)$"", + ""file"": 1, + ""line"": 2, + ""column"": 3, + ""severity"": 4, + ""code"": 5, + ""message"": 6 + } + ] + } + ] +} +"); + config.Validate(); + var matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1)); + + var match = matcher.Match("file:my-file.cs line:123 column:45 severity:real-bad code:uh-oh message:not-working"); + Assert.Equal("my-file.cs", match.File); + Assert.Equal("123", match.Line); + Assert.Equal("45", match.Column); + Assert.Equal("real-bad", match.Severity); + Assert.Equal("uh-oh", match.Code); + Assert.Equal("not-working", match.Message); + Assert.Equal("subdir/default-project.csproj", match.FromPath); + + // Test that a pattern-specific fromPath overrides the default + config = JsonUtility.FromString(@" +{ + ""problemMatcher"": [ + { + ""owner"": ""myMatcher"", + ""fromPath"": ""subdir/default-project.csproj"", + ""pattern"": [ + { + ""regexp"": ""^file:(.+) line:(.+) column:(.+) severity:(.+) code:(.+) message:(.+) fromPath:(.+)$"", + ""file"": 1, + ""line"": 2, + ""column"": 3, + ""severity"": 4, + ""code"": 5, + ""message"": 6, + ""fromPath"": 7 + } + ] + } + ] +} +"); + config.Validate(); + matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1)); + + match = matcher.Match("file:my-file.cs line:123 column:45 severity:real-bad code:uh-oh message:not-working fromPath:my-project.proj"); + Assert.Equal("my-file.cs", match.File); + Assert.Equal("123", match.Line); + Assert.Equal("45", match.Column); + Assert.Equal("real-bad", match.Severity); + Assert.Equal("uh-oh", match.Code); + Assert.Equal("not-working", match.Message); + Assert.Equal("my-project.proj", match.FromPath); + } + + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public void Matcher_MultiplePatterns_DefaultFromPath() + { + var config = JsonUtility.FromString(@" +{ + ""problemMatcher"": [ + { + ""owner"": ""myMatcher"", + ""fromPath"": ""subdir/default-project.csproj"", + ""pattern"": [ + { + ""regexp"": ""^file:(.+)$"", + ""file"": 1, + }, + { + ""regexp"": ""^severity:(.+)$"", + ""severity"": 1 + }, + { + ""regexp"": ""^line:(.+) column:(.+) code:(.+) message:(.+)$"", + ""line"": 1, + ""column"": 2, + ""code"": 3, + ""message"": 4 + } + ] + } + ] +} +"); + config.Validate(); + var matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1)); + + var match = matcher.Match("file:my-file.cs"); + Assert.Null(match); + match = matcher.Match("severity:real-bad"); + Assert.Null(match); + match = matcher.Match("line:123 column:45 code:uh-oh message:not-working"); + Assert.Equal("my-file.cs", match.File); + Assert.Equal("123", match.Line); + Assert.Equal("45", match.Column); + Assert.Equal("real-bad", match.Severity); + Assert.Equal("uh-oh", match.Code); + Assert.Equal("not-working", match.Message); + Assert.Equal("subdir/default-project.csproj", match.FromPath); + + // Test that pattern-specific fromPath overrides the default + config = JsonUtility.FromString(@" +{ + ""problemMatcher"": [ + { + ""owner"": ""myMatcher"", + ""fromPath"": ""subdir/default-project.csproj"", + ""pattern"": [ + { + ""regexp"": ""^file:(.+) fromPath:(.+)$"", + ""file"": 1, + ""fromPath"": 2 + }, + { + ""regexp"": ""^severity:(.+)$"", + ""severity"": 1 + }, + { + ""regexp"": ""^line:(.+) column:(.+) code:(.+) message:(.+)$"", + ""line"": 1, + ""column"": 2, + ""code"": 3, + ""message"": 4 + } + ] + } + ] +} +"); + config.Validate(); + matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1)); + + match = matcher.Match("file:my-file.cs fromPath:my-project.proj"); + Assert.Null(match); + match = matcher.Match("severity:real-bad"); + Assert.Null(match); + match = matcher.Match("line:123 column:45 code:uh-oh message:not-working"); + Assert.Equal("my-file.cs", match.File); + Assert.Equal("123", match.Line); + Assert.Equal("45", match.Column); + Assert.Equal("real-bad", match.Severity); + Assert.Equal("uh-oh", match.Code); + Assert.Equal("not-working", match.Message); + Assert.Equal("my-project.proj", match.FromPath); + } } } diff --git a/src/Test/L0/Worker/OutputManagerL0.cs b/src/Test/L0/Worker/OutputManagerL0.cs index 9d7f5d3f2eb..7005547b56e 100644 --- a/src/Test/L0/Worker/OutputManagerL0.cs +++ b/src/Test/L0/Worker/OutputManagerL0.cs @@ -937,6 +937,62 @@ public async void MatcherFromPath() } } + [Fact] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + public async void MatcherDefaultFromPath() + { + var matchers = new IssueMatchersConfig + { + Matchers = + { + new IssueMatcherConfig + { + Owner = "my-matcher-1", + FromPath = "workflow-repo/some-project/some-project.proj", + Patterns = new[] + { + new IssuePatternConfig + { + Pattern = @"(.+): (.+)", + File = 1, + Message = 2, + }, + }, + }, + }, + }; + using (var hostContext = Setup(matchers: matchers)) + using (_outputManager) + { + // Setup github.workspace, github.repository + var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work); + ArgUtil.NotNullOrEmpty(workDirectory, nameof(workDirectory)); + Directory.CreateDirectory(workDirectory); + var workspaceDirectory = Path.Combine(workDirectory, "workspace"); + Directory.CreateDirectory(workspaceDirectory); + _executionContext.Setup(x => x.GetGitHubContext("workspace")).Returns(workspaceDirectory); + _executionContext.Setup(x => x.GetGitHubContext("repository")).Returns("my-org/workflow-repo"); + + // Setup a git repository + var repositoryPath = Path.Combine(workspaceDirectory, "workflow-repo"); + await CreateRepository(hostContext, repositoryPath, "https://github.com/my-org/workflow-repo"); + + // Create a test file + var filePath = Path.Combine(repositoryPath, "some-project", "some-directory", "some-file.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)); + File.WriteAllText(filePath, ""); + + // Process + Process("some-directory/some-file.txt: some error"); + Assert.Equal(1, _issues.Count); + Assert.Equal("some error", _issues[0].Item1.Message); + Assert.Equal("some-project/some-directory/some-file.txt", _issues[0].Item1.Data["file"]); + Assert.Equal(0, _commands.Count); + Assert.Equal(0, _messages.Count); + } + } + [Fact] [Trait("Level", "L0")] [Trait("Category", "Worker")]