From 289e5979fe944606f8cbb6b9ebfe01fef2f96710 Mon Sep 17 00:00:00 2001 From: Bill Wagner Date: Fri, 18 Sep 2026 12:07:08 -0400 Subject: [PATCH] Fix shared non-ANTLR CI tooling Cap Checks API annotations and report malformed section links without crashing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 64c533dc-05e9-40c5-98b6-e1d7d50f36bd --- .../ReferenceUpdateProcessorTests.cs | 10 ++++++ tools/StandardAnchorTags/DiagnosticIDs.cs | 1 + .../ReferenceUpdateProcessor.cs | 29 +++++++++++++--- .../Utilities.Tests/StatusCheckLoggerTests.cs | 28 ++++++++++++++++ tools/Utilities.Tests/Utilities.Tests.csproj | 24 ++++++++++++++ tools/Utilities/Properties/AssemblyInfo.cs | 3 ++ tools/Utilities/StatusCheckLogger.cs | 33 ++++++++++++++----- tools/tools.sln | 14 ++++++++ 8 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 tools/Utilities.Tests/StatusCheckLoggerTests.cs create mode 100644 tools/Utilities.Tests/Utilities.Tests.csproj create mode 100644 tools/Utilities/Properties/AssemblyInfo.cs diff --git a/tools/StandardAnchorTags.Tests/ReferenceUpdateProcessorTests.cs b/tools/StandardAnchorTags.Tests/ReferenceUpdateProcessorTests.cs index dd08c0ec2..7f6957473 100644 --- a/tools/StandardAnchorTags.Tests/ReferenceUpdateProcessorTests.cs +++ b/tools/StandardAnchorTags.Tests/ReferenceUpdateProcessorTests.cs @@ -19,4 +19,14 @@ public void DoesNotTreatFenceMentionsAsDelimiters(string line) { Assert.That(ReferenceUpdateProcessor.IsCodeFenceDelimiter(line), Is.False); } + + [Test] + public void RejectsSectionLinkWithoutClosingParenthesis() + { + const string line = "[ยง15.17](classes.md#1517-record-class-and-non-record-class-differences."; + + var result = ReferenceUpdateProcessor.ExpandToIncludeExistingLink(line, new Range(1, 7)); + + Assert.That(result, Is.Null); + } } diff --git a/tools/StandardAnchorTags/DiagnosticIDs.cs b/tools/StandardAnchorTags/DiagnosticIDs.cs index e89613386..83eff87f0 100644 --- a/tools/StandardAnchorTags/DiagnosticIDs.cs +++ b/tools/StandardAnchorTags/DiagnosticIDs.cs @@ -3,4 +3,5 @@ internal class DiagnosticIDs { public const string TOC001 = nameof(TOC001); public const string TOC002 = nameof(TOC002); + public const string TOC003 = nameof(TOC003); } diff --git a/tools/StandardAnchorTags/ReferenceUpdateProcessor.cs b/tools/StandardAnchorTags/ReferenceUpdateProcessor.cs index 95bcc826d..7d5ec7648 100644 --- a/tools/StandardAnchorTags/ReferenceUpdateProcessor.cs +++ b/tools/StandardAnchorTags/ReferenceUpdateProcessor.cs @@ -83,7 +83,19 @@ private string ProcessSectionLinks(string line, int lineNumber, string path) linkText = linkMap[referenceText].FormattedMarkdownLink; } // expand the range for any existing link: - sectionReferenceRange = ExpandToIncludeExistingLink(line, sectionReferenceRange); + if (ExpandToIncludeExistingLink(line, sectionReferenceRange) is not Range expandedRange) + { + var diagnostic = new StatusCheckMessage( + path, + lineNumber, + lineNumber, + $"Malformed section link for `{referenceText}`; expected `[section](target)`", + DiagnosticIDs.TOC003); + logger.LogFailure(diagnostic); + returnedLine.Append(line[index..]); + return returnedLine.ToString(); + } + sectionReferenceRange = expandedRange; var textRangeToCopyUnchanged = new Range(index, sectionReferenceRange.Start); // Copy text up to replacement: @@ -128,7 +140,7 @@ private string ProcessSectionLinks(string line, int lineNumber, string path) return new Range(startIndex, endIndex); } - private static Range ExpandToIncludeExistingLink(string line, Range range) + internal static Range? ExpandToIncludeExistingLink(string line, Range range) { // If the character before the start of the range isn't the '[' character, // return => no existing link. @@ -138,10 +150,19 @@ private static Range ExpandToIncludeExistingLink(string line, Range range) // Start and the end of the range, look for "](", then ']'. int endIndex = range.End.Value; - if (line.Substring(endIndex, 2) != "](") throw new InvalidOperationException($"Unexpected link text >{line.Substring(endIndex, 2)}< in line >{line}<"); + if ((endIndex + 1 >= line.Length) || + (line[endIndex] != ']') || + (line[endIndex + 1] != '(')) + { + return null; + } endIndex += 2; - while (line[endIndex] != ')') endIndex++; + endIndex = line.IndexOf(')', endIndex); + if (endIndex == -1) + { + return null; + } return new Range(previous, endIndex + 1); } diff --git a/tools/Utilities.Tests/StatusCheckLoggerTests.cs b/tools/Utilities.Tests/StatusCheckLoggerTests.cs new file mode 100644 index 000000000..18efa12f8 --- /dev/null +++ b/tools/Utilities.Tests/StatusCheckLoggerTests.cs @@ -0,0 +1,28 @@ +using Utilities; + +namespace Utilities.Tests; + +public class StatusCheckLoggerTests +{ + [Test] + public void CheckRunLimitsAnnotationsAndSummarizesOmittedDiagnostics() + { + var output = new StringWriter(); + var logger = new StatusCheckLogger(output, ".", "Test tool"); + + for (int i = 1; i <= 55; i++) + { + logger.LogWarning(new StatusCheckMessage("file.md", i, i, $"Warning {i}", "TEST001")); + } + + var checkRun = logger.CreateCheckRun("head-sha"); + + Assert.Multiple(() => + { + Assert.That(checkRun.Output.Annotations, Has.Count.EqualTo(StatusCheckLogger.MaximumAnnotations)); + Assert.That(checkRun.Output.Summary, Does.Contain("55 diagnostics")); + Assert.That(checkRun.Output.Summary, Does.Contain("5 additional diagnostics")); + Assert.That(output.ToString(), Does.Contain("Warning 55")); + }); + } +} diff --git a/tools/Utilities.Tests/Utilities.Tests.csproj b/tools/Utilities.Tests/Utilities.Tests.csproj new file mode 100644 index 000000000..694a55b03 --- /dev/null +++ b/tools/Utilities.Tests/Utilities.Tests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + diff --git a/tools/Utilities/Properties/AssemblyInfo.cs b/tools/Utilities/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..0f0027416 --- /dev/null +++ b/tools/Utilities/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Utilities.Tests")] diff --git a/tools/Utilities/StatusCheckLogger.cs b/tools/Utilities/StatusCheckLogger.cs index dc8e83c4f..073a1ea44 100644 --- a/tools/Utilities/StatusCheckLogger.cs +++ b/tools/Utilities/StatusCheckLogger.cs @@ -24,6 +24,8 @@ public record StatusCheckMessage(string file, int StartLine, int EndLine, string /// The name of the tool that is running the check public class StatusCheckLogger(TextWriter writer, string pathToRoot, string toolName) { + internal const int MaximumAnnotations = 50; + private List annotations = []; public bool Success { get; private set; } = true; @@ -155,15 +157,7 @@ public void ExitOnFailure(StatusCheckMessage d) /// The full check run result object public async Task BuildCheckRunResult(string token, string owner, string repo, string sha) { - NewCheckRun result = new(toolName, sha) - { - Status = CheckStatus.Completed, - Conclusion = Success ? CheckConclusion.Success : CheckConclusion.Failure, - Output = new($"{toolName} Check Run results", $"{toolName} result is {(Success ? "success" : "failure")} with {annotations.Count} diagnostics.") - { - Annotations = annotations - } - }; + NewCheckRun result = CreateCheckRun(sha); var prodInformation = new ProductHeaderValue("TC49-TG2", "1.0.0"); var tokenAuth = new Credentials(token); @@ -183,4 +177,25 @@ public async Task BuildCheckRunResult(string token, string owner, string repo, s writer.WriteLine(e); } } + + internal NewCheckRun CreateCheckRun(string sha) + { + var annotationsToSubmit = annotations.Take(MaximumAnnotations).ToList(); + var omittedAnnotations = annotations.Count - annotationsToSubmit.Count; + var summary = $"{toolName} result is {(Success ? "success" : "failure")} with {annotations.Count} diagnostics."; + if (omittedAnnotations > 0) + { + summary += $" The first {MaximumAnnotations} diagnostics are annotated; {omittedAnnotations} additional diagnostics are available in the workflow log."; + } + + return new(toolName, sha) + { + Status = CheckStatus.Completed, + Conclusion = Success ? CheckConclusion.Success : CheckConclusion.Failure, + Output = new($"{toolName} Check Run results", summary) + { + Annotations = annotationsToSubmit + } + }; + } } diff --git a/tools/tools.sln b/tools/tools.sln index c259de718..3d4f2c541 100644 --- a/tools/tools.sln +++ b/tools/tools.sln @@ -24,6 +24,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleTester.Tests", "Exam EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StandardAnchorTags.Tests", "StandardAnchorTags.Tests\StandardAnchorTags.Tests.csproj", "{DD0FB88E-0E16-45CD-A833-8BDD8EE197F0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Utilities.Tests", "Utilities.Tests\Utilities.Tests.csproj", "{9C02C308-B903-4FBB-9015-710ADC57C86A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -142,6 +144,18 @@ Global {DD0FB88E-0E16-45CD-A833-8BDD8EE197F0}.Release|x64.Build.0 = Release|Any CPU {DD0FB88E-0E16-45CD-A833-8BDD8EE197F0}.Release|x86.ActiveCfg = Release|Any CPU {DD0FB88E-0E16-45CD-A833-8BDD8EE197F0}.Release|x86.Build.0 = Release|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Debug|x64.ActiveCfg = Debug|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Debug|x64.Build.0 = Debug|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Debug|x86.Build.0 = Debug|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Release|Any CPU.Build.0 = Release|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Release|x64.ActiveCfg = Release|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Release|x64.Build.0 = Release|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Release|x86.ActiveCfg = Release|Any CPU + {9C02C308-B903-4FBB-9015-710ADC57C86A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE