diff --git a/README.md b/README.md index 146a92e..ec602f2 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,12 @@ Enable `GenerateVersionFile` to output version information during build: The file is written to `$(OutputPath)/version.json` and is included in `dotnet publish` output. +## Analyzers + +SimpleBranchVersioning includes built-in analyzers that provide build-time feedback about version detection and potential issues with branch names. + +See [AnalyzerReleases.Shipped.md](src/SimpleBranchVersioning/AnalyzerReleases.Shipped.md) for the list of available diagnostics. + ## Requirements - .NET SDK 8.0 or later diff --git a/src/SimpleBranchVersioning/AnalyzerReleases.Unshipped.md b/src/SimpleBranchVersioning/AnalyzerReleases.Unshipped.md index c903787..841e081 100644 --- a/src/SimpleBranchVersioning/AnalyzerReleases.Unshipped.md +++ b/src/SimpleBranchVersioning/AnalyzerReleases.Unshipped.md @@ -2,3 +2,5 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +SBV002 | SimpleBranchVersioning | Warning | Invalid NuGet prerelease characters in branch name +SBV003 | SimpleBranchVersioning | Warning | Excessive branch name length diff --git a/src/SimpleBranchVersioning/AppVersionGenerator.cs b/src/SimpleBranchVersioning/AppVersionGenerator.cs index 8517550..5cc483b 100644 --- a/src/SimpleBranchVersioning/AppVersionGenerator.cs +++ b/src/SimpleBranchVersioning/AppVersionGenerator.cs @@ -20,6 +20,22 @@ public sealed class AppVersionGenerator : IIncrementalGenerator defaultSeverity: DiagnosticSeverity.Info, isEnabledByDefault: true); + private static readonly DiagnosticDescriptor InvalidNuGetCharactersWarning = new( + id: "SBV002", + title: "Invalid NuGet prerelease characters", + messageFormat: "Branch name contains characters invalid in NuGet versions: {0}. These will cause package restore issues.", + category: "SimpleBranchVersioning", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + + private static readonly DiagnosticDescriptor ExcessiveLengthWarning = new( + id: "SBV003", + title: "Excessive branch name length", + messageFormat: "Branch name '{0}' ({1} chars) exceeds recommended maximum of {2} characters", + category: "SimpleBranchVersioning", + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true); + private const string AttributeSource = """ // #nullable enable @@ -152,6 +168,31 @@ public void Initialize(IncrementalGeneratorInitializationContext context) branch, commitId)); + // Validate branch name for NuGet compatibility (non-release branches only) + if (!VersionCalculator.IsReleaseBranch(branch)) + { + string normalizedBranch = branch.Replace('/', '.'); + + var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters(normalizedBranch); + if (hasInvalid) + { + ctx.ReportDiagnostic(Diagnostic.Create( + InvalidNuGetCharactersWarning, + Location.None, + invalidChars)); + } + + if (BranchNameValidator.IsExcessiveLength(branch)) + { + ctx.ReportDiagnostic(Diagnostic.Create( + ExcessiveLengthWarning, + Location.None, + branch, + branch.Length, + BranchNameValidator.MaxBranchLength)); + } + } + // Determine namespace: // 1. If config specifies namespace explicitly, use it (empty string = global namespace) // 2. If top-level statements detected and no config, use global namespace diff --git a/src/SimpleBranchVersioning/BranchNameValidator.cs b/src/SimpleBranchVersioning/BranchNameValidator.cs new file mode 100644 index 0000000..05e4db9 --- /dev/null +++ b/src/SimpleBranchVersioning/BranchNameValidator.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; + +namespace SimpleBranchVersioning; + +/// +/// Validates branch names for NuGet version compatibility. +/// +public static class BranchNameValidator +{ + /// + /// Maximum recommended branch name length for practical version strings. + /// + public const int MaxBranchLength = 128; + + // NuGet prerelease identifiers allow: [0-9A-Za-z-] + // After slash→dot normalization, dots are also valid + // This pattern matches any character that is NOT valid + private static readonly Regex InvalidNuGetCharsPattern = new( + @"[^0-9A-Za-z.\-]", + RegexOptions.Compiled | RegexOptions.CultureInvariant, + TimeSpan.FromSeconds(1)); + + /// + /// Validates that a normalized branch name contains only NuGet-compatible characters. + /// + /// Branch name with slashes replaced by dots. + /// + /// A tuple containing whether invalid characters were found and a string of the invalid characters. + /// + public static (bool HasInvalidChars, string? InvalidChars) ValidateCharacters(string normalizedBranch) + { + if (string.IsNullOrEmpty(normalizedBranch)) + { + return (false, null); + } + + var matches = InvalidNuGetCharsPattern.Matches(normalizedBranch); + if (matches.Count == 0) + { + return (false, null); + } + + // Collect unique invalid characters + // Cast required for .NET Standard 2.0 compatibility (MatchCollection doesn't implement IEnumerable) + var invalidChars = matches + .Cast() + .Select(m => m.Value) + .Distinct(StringComparer.Ordinal) + .OrderBy(c => c, StringComparer.Ordinal); + + return (true, string.Join(", ", invalidChars.Select(c => $"'{c}'"))); + } + + /// + /// Checks if a branch name exceeds the recommended maximum length. + /// + /// The original branch name. + /// True if the branch name is excessively long. + public static bool IsExcessiveLength(string branch) => !string.IsNullOrEmpty(branch) && branch.Length > MaxBranchLength; +} diff --git a/src/SimpleBranchVersioning/VersionCalculator.cs b/src/SimpleBranchVersioning/VersionCalculator.cs index 49390ec..57a639d 100644 --- a/src/SimpleBranchVersioning/VersionCalculator.cs +++ b/src/SimpleBranchVersioning/VersionCalculator.cs @@ -27,6 +27,13 @@ public static class VersionCalculator RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ExplicitCapture, TimeSpan.FromSeconds(1)); + /// + /// Determines if the branch name matches the release branch pattern. + /// + /// The git branch name. + /// True if this is a release branch. + public static bool IsReleaseBranch(string branch) => ReleasePattern.IsMatch(branch); + /// /// Calculates all version formats based on branch name and commit ID. /// diff --git a/tests/SimpleBranchVersioning.Tests/AppVersionGeneratorTests.cs b/tests/SimpleBranchVersioning.Tests/AppVersionGeneratorTests.cs index 8e734e8..de1b7f0 100644 --- a/tests/SimpleBranchVersioning.Tests/AppVersionGeneratorTests.cs +++ b/tests/SimpleBranchVersioning.Tests/AppVersionGeneratorTests.cs @@ -498,5 +498,98 @@ public async Task Generator_VersionDetectedDiagnostic_ContainsVersionInfo() await Assert.That(message).Contains("release/v2.0.0"); } + [Test] + [Arguments("feature/test_underscore", "'_'")] + [Arguments("feature/user@name", "'@'")] + [Arguments("feature/test+plus", "'+'")] + public async Task Generator_InvalidNuGetChars_ReportsSBV002Warning( + string branch, string expectedInvalidChar) + { + var result = GeneratorTestHelper.RunGenerator( + MinimalSource, + branchOverride: branch); + + var diagnostic = result.GeneratorDiagnostics + .FirstOrDefault(d => string.Equals(d.Id, "SBV002", StringComparison.Ordinal)); + + await Assert.That(diagnostic).IsNotNull(); + await Assert.That(diagnostic!.Severity).IsEqualTo(DiagnosticSeverity.Warning); + await Assert.That(diagnostic.GetMessage()).Contains(expectedInvalidChar); + } + + [Test] + [Arguments("feature/valid-name")] + [Arguments("bugfix/issue-42")] + [Arguments("main")] + public async Task Generator_ValidNuGetChars_DoesNotReportSBV002(string branch) + { + var result = GeneratorTestHelper.RunGenerator( + MinimalSource, + branchOverride: branch); + + var diagnostic = result.GeneratorDiagnostics + .FirstOrDefault(d => string.Equals(d.Id, "SBV002", StringComparison.Ordinal)); + + await Assert.That(diagnostic).IsNull(); + } + + [Test] + public async Task Generator_ReleaseBranch_DoesNotReportSBV002() + { + var result = GeneratorTestHelper.RunGenerator( + MinimalSource, + branchOverride: "release/v1.2.3"); + + var diagnostic = result.GeneratorDiagnostics + .FirstOrDefault(d => string.Equals(d.Id, "SBV002", StringComparison.Ordinal)); + + await Assert.That(diagnostic).IsNull(); + } + + [Test] + public async Task Generator_ExcessiveBranchLength_ReportsSBV003Warning() + { + string longBranch = "feature/" + new string('a', 150); + + var result = GeneratorTestHelper.RunGenerator( + MinimalSource, + branchOverride: longBranch); + + var diagnostic = result.GeneratorDiagnostics + .FirstOrDefault(d => string.Equals(d.Id, "SBV003", StringComparison.Ordinal)); + + await Assert.That(diagnostic).IsNotNull(); + await Assert.That(diagnostic!.Severity).IsEqualTo(DiagnosticSeverity.Warning); + await Assert.That(diagnostic.GetMessage()).Contains("128"); + } + + [Test] + public async Task Generator_NormalBranchLength_DoesNotReportSBV003() + { + var result = GeneratorTestHelper.RunGenerator( + MinimalSource, + branchOverride: "feature/normal-length-branch"); + + var diagnostic = result.GeneratorDiagnostics + .FirstOrDefault(d => string.Equals(d.Id, "SBV003", StringComparison.Ordinal)); + + await Assert.That(diagnostic).IsNull(); + } + + [Test] + public async Task Generator_ReleaseBranch_DoesNotReportSBV003() + { + string longReleaseBranch = "release/v1.2.3-" + new string('a', 150); + + var result = GeneratorTestHelper.RunGenerator( + MinimalSource, + branchOverride: longReleaseBranch); + + var diagnostic = result.GeneratorDiagnostics + .FirstOrDefault(d => string.Equals(d.Id, "SBV003", StringComparison.Ordinal)); + + await Assert.That(diagnostic).IsNull(); + } + #endregion } diff --git a/tests/SimpleBranchVersioning.Tests/BranchNameValidatorTests.cs b/tests/SimpleBranchVersioning.Tests/BranchNameValidatorTests.cs new file mode 100644 index 0000000..7c3814c --- /dev/null +++ b/tests/SimpleBranchVersioning.Tests/BranchNameValidatorTests.cs @@ -0,0 +1,120 @@ +namespace SimpleBranchVersioning.Tests; + +public class BranchNameValidatorTests +{ + #region ValidateCharacters Tests + + [Test] + [Arguments("feature.login", false)] + [Arguments("bugfix.issue-42", false)] + [Arguments("main", false)] + [Arguments("feature.nested.path", false)] + [Arguments("1.2.3-beta", false)] + public async Task ValidateCharacters_ValidBranchName_ReturnsNoInvalidChars( + string normalizedBranch, bool expectedHasInvalid) + { + var (hasInvalid, _) = BranchNameValidator.ValidateCharacters(normalizedBranch); + + await Assert.That(hasInvalid).IsEqualTo(expectedHasInvalid); + } + + [Test] + [Arguments("feature_login", true, "'_'")] + [Arguments("user@feature", true, "'@'")] + [Arguments("feature+test", true, "'+'")] + [Arguments("feature login", true, "' '")] + public async Task ValidateCharacters_InvalidChars_ReturnsInvalidCharsString( + string normalizedBranch, bool expectedHasInvalid, string expectedInvalidChars) + { + var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters(normalizedBranch); + + await Assert.That(hasInvalid).IsEqualTo(expectedHasInvalid); + await Assert.That(invalidChars).IsEqualTo(expectedInvalidChars); + } + + [Test] + public async Task ValidateCharacters_MultipleInvalidChars_ReturnsAllUniqueChars() + { + var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters("feature_test@user_name"); + + await Assert.That(hasInvalid).IsTrue(); + await Assert.That(invalidChars).Contains("'_'"); + await Assert.That(invalidChars).Contains("'@'"); + } + + [Test] + public async Task ValidateCharacters_EmptyString_ReturnsNoInvalidChars() + { + var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters(""); + + await Assert.That(hasInvalid).IsFalse(); + await Assert.That(invalidChars).IsNull(); + } + + [Test] + public async Task ValidateCharacters_NullString_ReturnsNoInvalidChars() + { + var (hasInvalid, invalidChars) = BranchNameValidator.ValidateCharacters(null!); + + await Assert.That(hasInvalid).IsFalse(); + await Assert.That(invalidChars).IsNull(); + } + + #endregion + + #region IsExcessiveLength Tests + + [Test] + [Arguments(50, false)] + [Arguments(100, false)] + [Arguments(128, false)] + [Arguments(129, true)] + [Arguments(200, true)] + public async Task IsExcessiveLength_VariousLengths_ReturnsExpectedResult( + int branchLength, bool expectedExcessive) + { + string branch = new('a', branchLength); + + bool isExcessive = BranchNameValidator.IsExcessiveLength(branch); + + await Assert.That(isExcessive).IsEqualTo(expectedExcessive); + } + + [Test] + public async Task IsExcessiveLength_EmptyString_ReturnsFalse() + { + bool isExcessive = BranchNameValidator.IsExcessiveLength(""); + + await Assert.That(isExcessive).IsFalse(); + } + + [Test] + public async Task IsExcessiveLength_NullString_ReturnsFalse() + { + bool isExcessive = BranchNameValidator.IsExcessiveLength(null!); + + await Assert.That(isExcessive).IsFalse(); + } + + #endregion + + #region IsReleaseBranch Tests (VersionCalculator) + + [Test] + [Arguments("release/v1.2.3", true)] + [Arguments("release/1.2.3", true)] + [Arguments("release/v0.0.1", true)] + [Arguments("release/v1.0.0-beta", true)] + [Arguments("main", false)] + [Arguments("feature/login", false)] + [Arguments("release/feature", false)] + [Arguments("release/main", false)] + public async Task IsReleaseBranch_ReturnsExpectedResult(string branch, bool expected) + { + bool isRelease = VersionCalculator.IsReleaseBranch(branch); + + await Assert.That(isRelease).IsEqualTo(expected); + } + + #endregion +}