Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/adrs/0276-problem-matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 44 additions & 3 deletions src/Runner.Worker/IssueMatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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; }
Expand Down Expand Up @@ -282,6 +302,9 @@ public sealed class IssueMatcherConfig
[DataMember(Name = "pattern")]
private IssuePatternConfig[] _patterns;

[DataMember(Name = "fromPath")]
private string _fromPath;

public string Owner
{
get
Expand Down Expand Up @@ -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
Expand Down
168 changes: 168 additions & 0 deletions src/Test/L0/Worker/IssueMatcherL0.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IssueMatchersConfig>(@"
{
""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<IssueMatchersConfig>(@"
{
""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<IssueMatchersConfig>(@"
{
""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<IssueMatchersConfig>(@"
{
""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);
}
}
}
56 changes: 56 additions & 0 deletions src/Test/L0/Worker/OutputManagerL0.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down