Skip to content

Commit e2dafbd

Browse files
committed
feat: default fromPath for problem matchers
1 parent f1b5b5b commit e2dafbd

File tree

4 files changed

+304
-3
lines changed

4 files changed

+304
-3
lines changed

docs/adrs/0276-problem-matchers.md

+36
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,42 @@ Two problem matchers can be used:
250250
}
251251
```
252252

253+
#### Default from path
254+
255+
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.
256+
257+
For example, given the following compiler output that doesn't include project file information:
258+
259+
```
260+
ClassLibrary.cs(16,24): warning CS0612: 'ClassLibrary.Helpers.MyHelper.Name' is obsolete
261+
```
262+
263+
A problem matcher with a default from path can be used:
264+
265+
```json
266+
{
267+
"problemMatcher": [
268+
{
269+
"owner": "csc-minimal",
270+
"fromPath": "ClassLibrary/ClassLibrary.csproj",
271+
"pattern": [
272+
{
273+
"regexp": "^(.+)\\((\\d+),(\\d+)\\): (error|warning) (.+): (.*)$",
274+
"file": 1,
275+
"line": 2,
276+
"column": 3,
277+
"severity": 4,
278+
"code": 5,
279+
"message": 6
280+
}
281+
]
282+
}
283+
]
284+
}
285+
```
286+
287+
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`.
288+
253289
#### Mitigate regular expression denial of service (ReDos)
254290

255291
If a matcher exceeds a 1 second timeout when processing a line, retry up to two three times total.

src/Runner.Worker/IssueMatcher.cs

+44-3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public MatcherChangedEventArgs(IssueMatcherConfig config)
2121
public sealed class IssueMatcher
2222
{
2323
private string _defaultSeverity;
24+
private string _defaultFromPath;
2425
private string _owner;
2526
private IssuePattern[] _patterns;
2627
private IssueMatch[] _state;
@@ -29,6 +30,7 @@ public IssueMatcher(IssueMatcherConfig config, TimeSpan timeout)
2930
{
3031
_owner = config.Owner;
3132
_defaultSeverity = config.Severity;
33+
_defaultFromPath = config.FromPath;
3234
_patterns = config.Patterns.Select(x => new IssuePattern(x, timeout)).ToArray();
3335
Reset();
3436
}
@@ -59,6 +61,19 @@ public string DefaultSeverity
5961
}
6062
}
6163

64+
public string DefaultFromPath
65+
{
66+
get
67+
{
68+
if (_defaultFromPath == null)
69+
{
70+
_defaultFromPath = string.Empty;
71+
}
72+
73+
return _defaultFromPath;
74+
}
75+
}
76+
6277
public IssueMatch Match(string line)
6378
{
6479
// Single pattern
@@ -69,7 +84,7 @@ public IssueMatch Match(string line)
6984

7085
if (regexMatch.Success)
7186
{
72-
return new IssueMatch(null, pattern, regexMatch.Groups, DefaultSeverity);
87+
return new IssueMatch(null, pattern, regexMatch.Groups, DefaultSeverity, DefaultFromPath);
7388
}
7489

7590
return null;
@@ -110,7 +125,7 @@ public IssueMatch Match(string line)
110125
}
111126

112127
// Return
113-
return new IssueMatch(runningMatch, pattern, regexMatch.Groups, DefaultSeverity);
128+
return new IssueMatch(runningMatch, pattern, regexMatch.Groups, DefaultSeverity, DefaultFromPath);
114129
}
115130
// Not the last pattern
116131
else
@@ -184,7 +199,7 @@ public IssuePattern(IssuePatternConfig config, TimeSpan timeout)
184199

185200
public sealed class IssueMatch
186201
{
187-
public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection groups, string defaultSeverity = null)
202+
public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection groups, string defaultSeverity = null, string defaultFromPath = null)
188203
{
189204
File = runningMatch?.File ?? GetValue(groups, pattern.File);
190205
Line = runningMatch?.Line ?? GetValue(groups, pattern.Line);
@@ -198,6 +213,11 @@ public IssueMatch(IssueMatch runningMatch, IssuePattern pattern, GroupCollection
198213
{
199214
Severity = defaultSeverity;
200215
}
216+
217+
if (string.IsNullOrEmpty(FromPath) && !string.IsNullOrEmpty(defaultFromPath))
218+
{
219+
FromPath = defaultFromPath;
220+
}
201221
}
202222

203223
public string File { get; }
@@ -282,6 +302,9 @@ public sealed class IssueMatcherConfig
282302
[DataMember(Name = "pattern")]
283303
private IssuePatternConfig[] _patterns;
284304

305+
[DataMember(Name = "fromPath")]
306+
private string _fromPath;
307+
285308
public string Owner
286309
{
287310
get
@@ -318,6 +341,24 @@ public string Severity
318341
}
319342
}
320343

344+
public string FromPath
345+
{
346+
get
347+
{
348+
if (_fromPath == null)
349+
{
350+
_fromPath = string.Empty;
351+
}
352+
353+
return _fromPath;
354+
}
355+
356+
set
357+
{
358+
_fromPath = value;
359+
}
360+
}
361+
321362
public IssuePatternConfig[] Patterns
322363
{
323364
get

src/Test/L0/Worker/IssueMatcherL0.cs

+168
Original file line numberDiff line numberDiff line change
@@ -896,5 +896,173 @@ public void Matcher_SinglePattern_ExtractsProperties()
896896
Assert.Equal("not-working", match.Message);
897897
Assert.Equal("my-project.proj", match.FromPath);
898898
}
899+
900+
[Fact]
901+
[Trait("Level", "L0")]
902+
[Trait("Category", "Worker")]
903+
public void Matcher_SinglePattern_DefaultFromPath()
904+
{
905+
var config = JsonUtility.FromString<IssueMatchersConfig>(@"
906+
{
907+
""problemMatcher"": [
908+
{
909+
""owner"": ""myMatcher"",
910+
""fromPath"": ""subdir/default-project.csproj"",
911+
""pattern"": [
912+
{
913+
""regexp"": ""^file:(.+) line:(.+) column:(.+) severity:(.+) code:(.+) message:(.+)$"",
914+
""file"": 1,
915+
""line"": 2,
916+
""column"": 3,
917+
""severity"": 4,
918+
""code"": 5,
919+
""message"": 6
920+
}
921+
]
922+
}
923+
]
924+
}
925+
");
926+
config.Validate();
927+
var matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1));
928+
929+
var match = matcher.Match("file:my-file.cs line:123 column:45 severity:real-bad code:uh-oh message:not-working");
930+
Assert.Equal("my-file.cs", match.File);
931+
Assert.Equal("123", match.Line);
932+
Assert.Equal("45", match.Column);
933+
Assert.Equal("real-bad", match.Severity);
934+
Assert.Equal("uh-oh", match.Code);
935+
Assert.Equal("not-working", match.Message);
936+
Assert.Equal("subdir/default-project.csproj", match.FromPath);
937+
938+
// Test that a pattern-specific fromPath overrides the default
939+
config = JsonUtility.FromString<IssueMatchersConfig>(@"
940+
{
941+
""problemMatcher"": [
942+
{
943+
""owner"": ""myMatcher"",
944+
""fromPath"": ""subdir/default-project.csproj"",
945+
""pattern"": [
946+
{
947+
""regexp"": ""^file:(.+) line:(.+) column:(.+) severity:(.+) code:(.+) message:(.+) fromPath:(.+)$"",
948+
""file"": 1,
949+
""line"": 2,
950+
""column"": 3,
951+
""severity"": 4,
952+
""code"": 5,
953+
""message"": 6,
954+
""fromPath"": 7
955+
}
956+
]
957+
}
958+
]
959+
}
960+
");
961+
config.Validate();
962+
matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1));
963+
964+
match = matcher.Match("file:my-file.cs line:123 column:45 severity:real-bad code:uh-oh message:not-working fromPath:my-project.proj");
965+
Assert.Equal("my-file.cs", match.File);
966+
Assert.Equal("123", match.Line);
967+
Assert.Equal("45", match.Column);
968+
Assert.Equal("real-bad", match.Severity);
969+
Assert.Equal("uh-oh", match.Code);
970+
Assert.Equal("not-working", match.Message);
971+
Assert.Equal("my-project.proj", match.FromPath);
972+
}
973+
974+
[Fact]
975+
[Trait("Level", "L0")]
976+
[Trait("Category", "Worker")]
977+
public void Matcher_MultiplePatterns_DefaultFromPath()
978+
{
979+
var config = JsonUtility.FromString<IssueMatchersConfig>(@"
980+
{
981+
""problemMatcher"": [
982+
{
983+
""owner"": ""myMatcher"",
984+
""fromPath"": ""subdir/default-project.csproj"",
985+
""pattern"": [
986+
{
987+
""regexp"": ""^file:(.+)$"",
988+
""file"": 1,
989+
},
990+
{
991+
""regexp"": ""^severity:(.+)$"",
992+
""severity"": 1
993+
},
994+
{
995+
""regexp"": ""^line:(.+) column:(.+) code:(.+) message:(.+)$"",
996+
""line"": 1,
997+
""column"": 2,
998+
""code"": 3,
999+
""message"": 4
1000+
}
1001+
]
1002+
}
1003+
]
1004+
}
1005+
");
1006+
config.Validate();
1007+
var matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1));
1008+
1009+
var match = matcher.Match("file:my-file.cs");
1010+
Assert.Null(match);
1011+
match = matcher.Match("severity:real-bad");
1012+
Assert.Null(match);
1013+
match = matcher.Match("line:123 column:45 code:uh-oh message:not-working");
1014+
Assert.Equal("my-file.cs", match.File);
1015+
Assert.Equal("123", match.Line);
1016+
Assert.Equal("45", match.Column);
1017+
Assert.Equal("real-bad", match.Severity);
1018+
Assert.Equal("uh-oh", match.Code);
1019+
Assert.Equal("not-working", match.Message);
1020+
Assert.Equal("subdir/default-project.csproj", match.FromPath);
1021+
1022+
// Test that pattern-specific fromPath overrides the default
1023+
config = JsonUtility.FromString<IssueMatchersConfig>(@"
1024+
{
1025+
""problemMatcher"": [
1026+
{
1027+
""owner"": ""myMatcher"",
1028+
""fromPath"": ""subdir/default-project.csproj"",
1029+
""pattern"": [
1030+
{
1031+
""regexp"": ""^file:(.+) fromPath:(.+)$"",
1032+
""file"": 1,
1033+
""fromPath"": 2
1034+
},
1035+
{
1036+
""regexp"": ""^severity:(.+)$"",
1037+
""severity"": 1
1038+
},
1039+
{
1040+
""regexp"": ""^line:(.+) column:(.+) code:(.+) message:(.+)$"",
1041+
""line"": 1,
1042+
""column"": 2,
1043+
""code"": 3,
1044+
""message"": 4
1045+
}
1046+
]
1047+
}
1048+
]
1049+
}
1050+
");
1051+
config.Validate();
1052+
matcher = new IssueMatcher(config.Matchers[0], TimeSpan.FromSeconds(1));
1053+
1054+
match = matcher.Match("file:my-file.cs fromPath:my-project.proj");
1055+
Assert.Null(match);
1056+
match = matcher.Match("severity:real-bad");
1057+
Assert.Null(match);
1058+
match = matcher.Match("line:123 column:45 code:uh-oh message:not-working");
1059+
Assert.Equal("my-file.cs", match.File);
1060+
Assert.Equal("123", match.Line);
1061+
Assert.Equal("45", match.Column);
1062+
Assert.Equal("real-bad", match.Severity);
1063+
Assert.Equal("uh-oh", match.Code);
1064+
Assert.Equal("not-working", match.Message);
1065+
Assert.Equal("my-project.proj", match.FromPath);
1066+
}
8991067
}
9001068
}

src/Test/L0/Worker/OutputManagerL0.cs

+56
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,62 @@ public async void MatcherFromPath()
937937
}
938938
}
939939

940+
[Fact]
941+
[Trait("Level", "L0")]
942+
[Trait("Category", "Worker")]
943+
public async void MatcherDefaultFromPath()
944+
{
945+
var matchers = new IssueMatchersConfig
946+
{
947+
Matchers =
948+
{
949+
new IssueMatcherConfig
950+
{
951+
Owner = "my-matcher-1",
952+
FromPath = "some-project/some-project.proj"
953+
Patterns = new[]
954+
{
955+
new IssuePatternConfig
956+
{
957+
Pattern = @"(.+): (.+)",
958+
File = 1,
959+
Message = 2
960+
},
961+
},
962+
},
963+
},
964+
};
965+
using (var hostContext = Setup(matchers: matchers))
966+
using (_outputManager)
967+
{
968+
// Setup github.workspace, github.repository
969+
var workDirectory = hostContext.GetDirectory(WellKnownDirectory.Work);
970+
ArgUtil.NotNullOrEmpty(workDirectory, nameof(workDirectory));
971+
Directory.CreateDirectory(workDirectory);
972+
var workspaceDirectory = Path.Combine(workDirectory, "workspace");
973+
Directory.CreateDirectory(workspaceDirectory);
974+
_executionContext.Setup(x => x.GetGitHubContext("workspace")).Returns(workspaceDirectory);
975+
_executionContext.Setup(x => x.GetGitHubContext("repository")).Returns("my-org/workflow-repo");
976+
977+
// Setup a git repository
978+
var repositoryPath = Path.Combine(workspaceDirectory, "workflow-repo");
979+
await CreateRepository(hostContext, repositoryPath, "https://github.com/my-org/workflow-repo");
980+
981+
// Create a test file
982+
var filePath = Path.Combine(repositoryPath, "some-project", "some-directory", "some-file.txt");
983+
Directory.CreateDirectory(Path.GetDirectoryName(filePath));
984+
File.WriteAllText(filePath, "");
985+
986+
// Process
987+
Process("some-directory/some-file.txt: some error");
988+
Assert.Equal(1, _issues.Count);
989+
Assert.Equal("some error", _issues[0].Item1.Message);
990+
Assert.Equal("some-project/some-directory/some-file.txt", _issues[0].Item1.Data["file"]);
991+
Assert.Equal(0, _commands.Count);
992+
Assert.Equal(0, _messages.Count);
993+
}
994+
}
995+
940996
[Fact]
941997
[Trait("Level", "L0")]
942998
[Trait("Category", "Worker")]

0 commit comments

Comments
 (0)