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
+}