Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .sonarlint/sonar-local.globalconfig
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions .sonarlint/sonar-local.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project>

<!--
Local SonarCloud reproduction.

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
warnings are invisible locally and only surface after a push.

This file wires the same analyzers into a local build. It is NOT imported
automatically - point MSBuild at it explicitly:

dotnet build -p:CustomBeforeMicrosoftCommonProps=$PWD/.sonarlint/sonar-local.props

sonar-local.globalconfig (next to this file) raises the rules that CI reports but
that the analyzer package leaves off by default, so the local warning set matches
CI's. A clean run here means a clean Sonar run in CI.

Nothing in the repository imports this, so normal builds, the CI pipeline, and
packaging are all unaffected.
-->
<ItemGroup>
<PackageReference Include="SonarAnalyzer.CSharp" VersionOverride="10.18.0.131500" PrivateAssets="all" />
<EditorConfigFiles Include="$(MSBuildThisFileDirectory)sonar-local.globalconfig" />
</ItemGroup>

</Project>
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Project Sdk="...">` attribute form. The `ktsu.Sdk` projects use `<Project>` with `<Sdk Name="..." />` 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 |
Expand Down
51 changes: 27 additions & 24 deletions Semantics.Color/Color.Operations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/// <summary>
Expand All @@ -51,12 +51,7 @@ public AccessibilityLevel AccessibilityLevelAgainst(Color background, bool large
/// <returns>An adjusted color, clamped to gamut.</returns>
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)
{
Expand All @@ -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;
}
}

Expand All @@ -104,6 +93,20 @@ Color Candidate(Oklab source, double lightness) =>
FromOklab(new Oklab(lightness, source.A, source.B), alpha).Clamp();
}

/// <summary>
/// The WCAG contrast ratio a foreground/background pair must reach to satisfy
/// <paramref name="level"/>.
/// </summary>
/// <param name="level">The conformance level.</param>
/// <param name="largeText">True for large text, which has lower thresholds.</param>
/// <returns>The required contrast ratio; 1.0 for levels with no requirement.</returns>
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,
};

/// <summary>Computes the perceptual (Oklab Euclidean) distance to another color.</summary>
/// <param name="other">The other color.</param>
/// <returns>The Oklab distance.</returns>
Expand Down
76 changes: 76 additions & 0 deletions Semantics.Test/PatternValidationRuleTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Covers <see cref="PatternValidationRule"/>, which had no tests despite carrying the regex used
/// to validate caller-supplied values against caller-supplied patterns.
/// </summary>
[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<PlainString> { }

private static PlainString Value(string value) => SemanticString<PlainString>.Create<PlainString>(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);
}

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public void Validate_CatastrophicBacktracking_TimesOutRatherThanHanging()
{
PatternValidationRule rule = new("^(a+)+$");
PlainString value = Value(new string('a', 40) + "!");

Assert.ThrowsExactly<RegexMatchTimeoutException>(() => rule.Validate(value));
}
}
Loading