diff --git a/.sonarlint/sonar-local.globalconfig b/.sonarlint/sonar-local.globalconfig
new file mode 100644
index 00000000..b4f75128
--- /dev/null
+++ b/.sonarlint/sonar-local.globalconfig
@@ -0,0 +1,24 @@
+is_global = true
+
+# Rule severities for the local SonarCloud reproduction (see sonar-local.props).
+# Applied only when building with
+# -p:CustomBeforeMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props
+
+# CI's SonarCloud quality profile reports these, but the SonarAnalyzer NuGet package
+# ships them disabled by default. Raise them so a local run sees what CI sees.
+dotnet_diagnostic.S107.severity = warning
+dotnet_diagnostic.S1075.severity = warning
+dotnet_diagnostic.S1192.severity = warning
+dotnet_diagnostic.S1871.severity = warning
+dotnet_diagnostic.S2583.severity = warning
+dotnet_diagnostic.S2699.severity = warning
+dotnet_diagnostic.S3267.severity = warning
+dotnet_diagnostic.S3358.severity = warning
+dotnet_diagnostic.S3458.severity = warning
+dotnet_diagnostic.S3776.severity = warning
+dotnet_diagnostic.S6444.severity = warning
+
+# Enabled by default in the analyzer package but not reported by CI's quality profile.
+# Silenced so a clean local run means a clean CI run, rather than noise that would
+# train people to ignore the output.
+dotnet_diagnostic.S1481.severity = none
diff --git a/.sonarlint/sonar-local.props b/.sonarlint/sonar-local.props
new file mode 100644
index 00000000..578372c3
--- /dev/null
+++ b/.sonarlint/sonar-local.props
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
index ddedb972..ace392f9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,6 +15,22 @@ This is a multi-target .NET library using ktsu MSBuild SDKs. Strings and Paths t
Tests use MSTest. Generator output is emitted to `Semantics.Quantities/Generated/` (committed) so the project can be inspected without first running the generator.
+### Reproducing SonarCloud warnings locally
+
+CI analyses this repository with the SonarCloud scanner, which injects the Sonar analyzers into the compilation. A plain `dotnet build` does **not** run them, so Sonar findings are invisible locally and only surface after a push — a ~10 minute round trip per attempt. To run the same analyzers:
+
+```bash
+dotnet build -p:CustomBeforeMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props
+```
+
+```powershell
+dotnet build -p:CustomBeforeMicrosoftCommonProps=$PWD\.sonarlint\sonar-local.props
+```
+
+The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sonarlint/sonar-local.globalconfig` (rule severities — it raises the rules CI reports that the analyzer package ships disabled, and silences the ones CI's quality profile does not report). Nothing imports these automatically, so normal builds, the CI pipeline, and packaging are unaffected.
+
+**Known limitation:** this currently only reaches `Semantics.SourceGenerators`, the one project declaring its SDK with the `` attribute form. The `ktsu.Sdk` projects use `` with `` elements, and `CustomBeforeMicrosoftCommonProps` does not reach them. Findings in `Semantics.Strings`, `Paths`, `Music`, `Color` and `Quantities` still have to be read from SonarCloud.
+
## Project layout
| Project | Responsibility |
diff --git a/Semantics.Color/Color.Operations.cs b/Semantics.Color/Color.Operations.cs
index a8871145..3bb5ebc6 100644
--- a/Semantics.Color/Color.Operations.cs
+++ b/Semantics.Color/Color.Operations.cs
@@ -29,15 +29,15 @@ public double ContrastRatio(Color other)
public AccessibilityLevel AccessibilityLevelAgainst(Color background, bool largeText = false)
{
double contrast = ContrastRatio(background);
- double enhancedThreshold = largeText ? 4.5 : 7.0;
- double minimumThreshold = largeText ? 3.0 : 4.5;
- if (contrast >= enhancedThreshold)
+ if (contrast >= RequiredContrast(AccessibilityLevel.AAA, largeText))
{
return AccessibilityLevel.AAA;
}
- return contrast >= minimumThreshold ? AccessibilityLevel.AA : AccessibilityLevel.Fail;
+ return contrast >= RequiredContrast(AccessibilityLevel.AA, largeText)
+ ? AccessibilityLevel.AA
+ : AccessibilityLevel.Fail;
}
///
@@ -51,12 +51,7 @@ public AccessibilityLevel AccessibilityLevelAgainst(Color background, bool large
/// An adjusted color, clamped to gamut.
public Color AdjustForContrast(Color background, AccessibilityLevel target, bool largeText = false)
{
- double required = target switch
- {
- AccessibilityLevel.AAA => largeText ? 4.5 : 7.0,
- AccessibilityLevel.AA => largeText ? 3.0 : 4.5,
- _ => 1.0,
- };
+ double required = RequiredContrast(target, largeText);
if (ContrastRatio(background) >= required)
{
@@ -76,24 +71,18 @@ public Color AdjustForContrast(Color background, AccessibilityLevel target, bool
double mid = (lo + hi) / 2.0;
Color candidate = Candidate(lab, mid);
bool meets = candidate.ContrastRatio(background) >= required;
- if (goLighter)
- {
- if (meets)
- {
- hi = mid;
- }
- else
- {
- lo = mid;
- }
- }
- else if (meets)
+
+ // The interval always shrinks toward the end that satisfies the requirement. When
+ // lightening that is the upper bound if the midpoint already meets it; when darkening
+ // the roles swap. Both cases reduce to whether the midpoint landed on the goLighter
+ // side, so the four-way branch collapses to one comparison.
+ if (meets == goLighter)
{
- lo = mid;
+ hi = mid;
}
else
{
- hi = mid;
+ lo = mid;
}
}
@@ -104,6 +93,20 @@ Color Candidate(Oklab source, double lightness) =>
FromOklab(new Oklab(lightness, source.A, source.B), alpha).Clamp();
}
+ ///
+ /// The WCAG contrast ratio a foreground/background pair must reach to satisfy
+ /// .
+ ///
+ /// The conformance level.
+ /// True for large text, which has lower thresholds.
+ /// The required contrast ratio; 1.0 for levels with no requirement.
+ private static double RequiredContrast(AccessibilityLevel level, bool largeText) => level switch
+ {
+ AccessibilityLevel.AAA => largeText ? 4.5 : 7.0,
+ AccessibilityLevel.AA => largeText ? 3.0 : 4.5,
+ _ => 1.0,
+ };
+
/// Computes the perceptual (Oklab Euclidean) distance to another color.
/// The other color.
/// The Oklab distance.
diff --git a/Semantics.Test/PatternValidationRuleTests.cs b/Semantics.Test/PatternValidationRuleTests.cs
new file mode 100644
index 00000000..1aee96fd
--- /dev/null
+++ b/Semantics.Test/PatternValidationRuleTests.cs
@@ -0,0 +1,76 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.Semantics.Test;
+
+using System.Text.RegularExpressions;
+using ktsu.Semantics.Strings;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Covers , which had no tests despite carrying the regex used
+/// to validate caller-supplied values against caller-supplied patterns.
+///
+[TestClass]
+public class PatternValidationRuleTests
+{
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Used via generic type references")]
+ private sealed partial record PlainString : SemanticString { }
+
+ private static PlainString Value(string value) => SemanticString.Create(value);
+
+ [TestMethod]
+ public void Validate_MatchingValue_ReturnsTrue()
+ {
+ PatternValidationRule rule = new(@"^\d{3}-\d{4}$");
+
+ Assert.IsTrue(rule.Validate(Value("555-1234")));
+ }
+
+ [TestMethod]
+ public void Validate_NonMatchingValue_ReturnsFalse()
+ {
+ PatternValidationRule rule = new(@"^\d{3}-\d{4}$");
+
+ Assert.IsFalse(rule.Validate(Value("not-a-number")));
+ }
+
+ [TestMethod]
+ public void Validate_HonoursRegexOptions()
+ {
+ PatternValidationRule caseSensitive = new("^abc$");
+ PatternValidationRule caseInsensitive = new("^abc$", RegexOptions.IgnoreCase);
+
+ Assert.IsFalse(caseSensitive.Validate(Value("ABC")));
+ Assert.IsTrue(caseInsensitive.Validate(Value("ABC")));
+ }
+
+ [TestMethod]
+ public void Name_IsPattern() => Assert.AreEqual("Pattern", new PatternValidationRule("x").Name);
+
+ [TestMethod]
+ public void GetErrorMessage_NamesTheValueAndThePattern()
+ {
+ const string pattern = @"^\d+$";
+ PatternValidationRule rule = new(pattern);
+
+ string message = rule.GetErrorMessage(Value("abc"));
+
+ Assert.Contains("abc", message);
+ Assert.Contains(pattern, message);
+ }
+
+ ///
+ /// The rule matches caller-supplied values against caller-supplied patterns, so a pathological
+ /// combination must terminate rather than backtrack unboundedly. This pattern against a
+ /// non-matching run of 'a' is the classic catastrophic-backtracking case; without the match
+ /// timeout it does not finish in any practical time.
+ ///
+ [TestMethod]
+ public void Validate_CatastrophicBacktracking_TimesOutRatherThanHanging()
+ {
+ PatternValidationRule rule = new("^(a+)+$");
+ PlainString value = Value(new string('a', 40) + "!");
+
+ Assert.ThrowsExactly(() => rule.Validate(value));
+ }
+}