diff --git a/.docfx/docfx.json b/.docfx/docfx.json index a7ad870..f714667 100644 --- a/.docfx/docfx.json +++ b/.docfx/docfx.json @@ -64,7 +64,8 @@ "fileMetadataFiles": [], "template": [ "default", - "modern" + "modern", + "templates/modern" ], "overwrite": [ { diff --git a/.docfx/templates/modern/public/main.js b/.docfx/templates/modern/public/main.js new file mode 100644 index 0000000..6f678c6 --- /dev/null +++ b/.docfx/templates/modern/public/main.js @@ -0,0 +1,12 @@ +export default { + start: () => { + const script = document.createElement('script'); + script.src = 'https://context7.com/widget.js'; + script.setAttribute('data-library', '/codebeltnet/xunit'); + script.setAttribute('data-color', '#059669'); + script.setAttribute('data-position', 'bottom-right'); + script.setAttribute('data-placeholder', 'Ask about Codebelt.Extensions.Xunit...'); + script.async = true; + document.body.appendChild(script); + } +}; diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..85dcc19 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,187 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# Default: prefer spaces for data/markup +indent_style = space +indent_size = 2 +tab_width = 2 + +# This style rule concern the use of the range operator, which is available in C# 8.0 and later. +# https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0057 +[*.{cs,vb}] +dotnet_diagnostic.IDE0057.severity = none + +# This style rule concerns the use of switch expressions versus switch statements. +# https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0066 +[*.{cs,vb}] +dotnet_diagnostic.IDE0066.severity = none + +# Performance rules +# https://docs.microsoft.com/da-dk/dotnet/fundamentals/code-analysis/quality-rules/performance-warnings +[*.{cs,vb}] +dotnet_analyzer_diagnostic.category-Performance.severity = none # Because many of the suggestions by performance analyzers are not compatible with .NET Standard 2.0 + +# This style rule concerns the use of using statements without curly braces, also known as using declarations. This alternative syntax was introduced in C# 8.0. +# https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0063 +[*.{cs,vb}] +dotnet_diagnostic.IDE0063.severity = none + +# This style rule concerns with simplification of interpolated strings to improve code readability. It recommends removal of certain explicit method calls, such as ToString(), when the same method would be implicitly invoked by the compiler if the explicit method call is removed. +# https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0071 +[*.{cs,vb}] +dotnet_diagnostic.IDE0071.severity = none + +# S3267: Loops should be simplified with "LINQ" expressions +# https://rules.sonarsource.com/csharp/RSPEC-3267 +dotnet_diagnostic.S3267.severity = none + +# CA1859: Use concrete types when possible for improved performance +# This is a violation of Framework Design Guidelines. +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1859 +[*.{cs,vb}] +dotnet_diagnostic.CA1859.severity = none + +# IDE0008: Use explicit type +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0007-ide0008 +[*.{cs,vb}] +dotnet_diagnostic.IDE0008.severity = none + +[*.{cs,vb}] +indent_style = space +indent_size = 4 + +# IDE0161: Namespace declaration preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0161 +# Prefer file-scoped namespaces for new files; existing block-scoped files should not be converted unless explicitly asked +[*.cs] +csharp_style_namespace_declarations = file_scoped:suggestion + +# Top-level statements: DO NOT USE +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0210-ide0211 +# This is enforced via a style preference set to error severity, not a language-level prohibition. +# Always use explicit class declarations with a proper namespace and Main method where applicable. +[*.cs] +csharp_style_prefer_top_level_statements = false:error + +[*.xml] +indent_style = space +indent_size = 2 + +# IDE0078: Use pattern matching +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0078 +[*.{cs,vb}] +dotnet_diagnostic.IDE0078.severity = none + +# IDE0290: Use primary constructor +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0290 +[*.{cs,vb}] +dotnet_diagnostic.IDE0290.severity = none + +# CA1200: Avoid using cref tags with a prefix +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1200 +[*.{cs,vb}] +dotnet_diagnostic.CA1200.severity = none + +# IDE0305: Use collection expression for fluent +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0305 +[*.{cs,vb}] +dotnet_diagnostic.IDE0305.severity = none + +# IDE0011: Add braces +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0011 +[*.{cs,vb}] +dotnet_diagnostic.IDE0011.severity = none + +# IDE0028: Use collection initializers or expressions +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0028 +[*.{cs,vb}] +dotnet_diagnostic.IDE0028.severity = none + +# IDE0039: Use collection expression for array +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0300 +[*.{cs,vb}] +dotnet_diagnostic.IDE0300.severity = none + +# IDE0031: Use collection expression for empty +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0301 +[*.{cs,vb}] +dotnet_diagnostic.IDE0301.severity = none + +# IDE0046: Use conditional expression for return +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0046 +[*.{cs,vb}] +dotnet_diagnostic.IDE0046.severity = none + +# IDE0047: Parentheses preferences +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0047-ide0048 +[*.{cs,vb}] +dotnet_diagnostic.IDE0047.severity = none + +# CA1716: Identifiers should not match keywords +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1716 +[*.{cs,vb}] +dotnet_diagnostic.CA1716.severity = none + +# CA1720: Identifiers should not contain type names +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1720 +[*.{cs,vb}] +dotnet_diagnostic.CA1720.severity = none + +# CA1846: Prefer AsSpan over Substring +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1846 +# Excluded while TFMs include netstandard2.0 +[*.{cs,vb}] +dotnet_diagnostic.CA1846.severity = none + +# CA1847: Use String.Contains(char) instead of String.Contains(string) with single characters +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1847 +# Excluded while TFMs include netstandard2.0 +[*.{cs,vb}] +dotnet_diagnostic.CA1847.severity = none + +# CA1865-CA1867: Use 'string.Method(char)' instead of 'string.Method(string)' for string with single char +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1865-ca1867 +# Excluded while TFMs include netstandard2.0 +[*.{cs,vb}] +dotnet_diagnostic.CA1865.severity = none +dotnet_diagnostic.CA1866.severity = none +dotnet_diagnostic.CA1867.severity = none + +# CA2263: Prefer generic overload when type is known +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2263 +# Excluded while TFMs include netstandard2.0 +[*.{cs,vb}] +dotnet_diagnostic.CA2263.severity = none + +# CA2249: Consider using String.Contains instead of String.IndexOf +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2249 +# Excluded while TFMs include netstandard2.0 +[*.{cs,vb}] +dotnet_diagnostic.CA2249.severity = none + +# IDE0022: Use expression body for methods +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0022 +[*.{cs,vb}] +dotnet_diagnostic.IDE0022.severity = none + +# IDE0032: Use auto-property +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0032 +[*.{cs,vb}] +dotnet_diagnostic.IDE0032.severity = none + +# Order modifiers +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0036 +# Excluded because of inconsistency with other analyzers +[*.{cs,vb}] +dotnet_diagnostic.IDE0036.severity = none + +# Use 'System.Threading.Lock' +# https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0330 +# Excluded while TFMs are less than net9.0 +[*.{cs,vb}] +dotnet_diagnostic.IDE0330.severity = none diff --git a/.github/dispatch-targets.json b/.github/dispatch-targets.json new file mode 100644 index 0000000..f510e0c --- /dev/null +++ b/.github/dispatch-targets.json @@ -0,0 +1,7 @@ +[ + "benchmarkdotnet", + "newtonsoft-json", + "aws-signature-v4", + "unitify", + "yamldotnet" +] diff --git a/.github/scripts/bump-nuget.py b/.github/scripts/bump-nuget.py new file mode 100644 index 0000000..375f41a --- /dev/null +++ b/.github/scripts/bump-nuget.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Bumps PackageVersion entries in Directory.Packages.props. +- For packages published by the triggering source repo: uses the known TRIGGER_VERSION. +- For all other packages: queries NuGet API for latest stable version. +- Preserves all XML formatting via regex (no ElementTree rewriting). +""" + +import re, os, urllib.request, json, sys +from typing import Dict, List + +TRIGGER_SOURCE = os.environ.get("TRIGGER_SOURCE", "") +TRIGGER_VERSION = os.environ.get("TRIGGER_VERSION", "") + +# Maps source repo name → NuGet package ID prefixes published by that repo. +# Keep this aligned with what each repo actually publishes. +SOURCE_PACKAGE_MAP: Dict[str, List[str]] = { + "cuemon": [ + "Cuemon.", + ], + "xunit": [ + "Codebelt.Extensions.Xunit", + ], + "benchmarkdotnet": [ + "Codebelt.Extensions.BenchmarkDotNet", + ], + "bootstrapper": [ + "Codebelt.Bootstrapper", + ], + "newtonsoft-json": [ + "Codebelt.Extensions.Newtonsoft.Json", + "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft", + ], + "aws-signature-v4": [ + "Codebelt.Extensions.AspNetCore.Authentication.AwsSignature", + ], + "unitify": [ + "Codebelt.Unitify", + ], + "yamldotnet": [ + "Codebelt.Extensions.YamlDotNet", + "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Text.Yaml", + ], + "globalization": [ + "Codebelt.Extensions.Globalization", + ], + "asp-versioning": [ + "Codebelt.Extensions.Asp.Versioning", + ], + "swashbuckle-aspnetcore": [ + "Codebelt.Extensions.Swashbuckle", + ], + "savvyio": [ + "Savvyio.", + ], + "shared-kernel": [], +} + + +def nuget_latest(pkg: str) -> str | None: + """Query NuGet flat container API for the latest stable version.""" + try: + url = f"https://api.nuget.org/v3-flatcontainer/{pkg.lower()}/index.json" + with urllib.request.urlopen(url, timeout=15) as r: + versions = json.loads(r.read())["versions"] + stable = [v for v in versions if not re.search(r"-", v)] + return stable[-1] if stable else None + except Exception as e: + print(f" SKIP {pkg}: {e}", file=sys.stderr) + return None + + +def triggered_version(pkg: str) -> str | None: + """Return TRIGGER_VERSION if pkg is published by the triggering source repo.""" + if not TRIGGER_VERSION or not TRIGGER_SOURCE: + return None + prefixes = SOURCE_PACKAGE_MAP.get(TRIGGER_SOURCE, []) + if any(pkg.startswith(p) for p in prefixes): + return TRIGGER_VERSION + return None + + +with open("Directory.Packages.props", "r") as f: + content = f.read() + +changes = [] + + +def replace_version(m: re.Match) -> str: + pkg = m.group(1) + current = m.group(2) + + new_ver = triggered_version(pkg) or nuget_latest(pkg) or current + + if new_ver != current: + changes.append(f" {pkg}: {current} → {new_ver}") + return m.group(0).replace(f'Version="{current}"', f'Version="{new_ver}"') + + +pattern = re.compile( + r']*\bInclude="([^"]+)")' + r'(?=[^>]*\bVersion="([^"]+)")' + r'[^>]*>', + re.DOTALL, +) +new_content = pattern.sub(replace_version, content) + +if changes: + print(f"Bumped {len(changes)} package(s):") + print("\n".join(changes)) +else: + print("All packages already at target versions.") + +with open("Directory.Packages.props", "w") as f: + f.write(new_content) diff --git a/.github/workflows/service-update.yml b/.github/workflows/service-update.yml new file mode 100644 index 0000000..e2e5202 --- /dev/null +++ b/.github/workflows/service-update.yml @@ -0,0 +1,162 @@ +name: Service Update + +on: + repository_dispatch: + types: [codebelt-service-update] + workflow_dispatch: + inputs: + source_repo: + description: 'Triggering source repo name (e.g. cuemon)' + required: false + default: '' + source_version: + description: 'Version released by source (e.g. 10.3.0)' + required: false + default: '' + dry_run: + type: boolean + description: 'Dry run — show changes but do not commit or open PR' + default: false + +permissions: + contents: write + pull-requests: write + +jobs: + service-update: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve trigger inputs + id: trigger + run: | + SOURCE="${{ github.event.client_payload.source_repo || github.event.inputs.source_repo }}" + VERSION="${{ github.event.client_payload.source_version || github.event.inputs.source_version }}" + echo "source=$SOURCE" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Determine new version for this repo + id: newver + run: | + CURRENT=$(grep -oP '(?<=## \[)[\d.]+(?=\])' CHANGELOG.md | head -1) + NEW=$(echo "$CURRENT" | awk -F. '{printf "%s.%s.%d", $1, $2, $3+1}') + BRANCH="v${NEW}/service-update" + echo "current=$CURRENT" >> $GITHUB_OUTPUT + echo "new=$NEW" >> $GITHUB_OUTPUT + echo "branch=$BRANCH" >> $GITHUB_OUTPUT + + - name: Generate codebelt-aicia token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.CODEBELT_AICIA_APP_ID }} + private-key: ${{ secrets.CODEBELT_AICIA_PRIVATE_KEY }} + owner: codebeltnet + + - name: Bump NuGet packages + run: python3 .github/scripts/bump-nuget.py + env: + TRIGGER_SOURCE: ${{ steps.trigger.outputs.source }} + TRIGGER_VERSION: ${{ steps.trigger.outputs.version }} + + - name: Update PackageReleaseNotes.txt + run: | + NEW="${{ steps.newver.outputs.new }}" + for f in .nuget/*/PackageReleaseNotes.txt; do + [ -f "$f" ] || continue + TFM=$(grep -m1 "^Availability:" "$f" | sed 's/Availability: //' || echo ".NET 10, .NET 9 and .NET Standard 2.0") + ENTRY="Version ${NEW}\nAvailability: ${TFM}\n \n# ALM\n- CHANGED Dependencies have been upgraded to the latest compatible versions for all supported target frameworks (TFMs)\n \n" + { printf "$ENTRY"; cat "$f"; } > "$f.tmp" && mv "$f.tmp" "$f" + done + + - name: Update CHANGELOG.md + run: | + python3 - <<'EOF' + import os, re + from datetime import date + new_ver = os.environ['NEW_VERSION'] + today = date.today().isoformat() + entry = f"## [{new_ver}] - {today}\n\nThis is a service update that focuses on package dependencies.\n\n" + with open("CHANGELOG.md") as f: + content = f.read() + idx = content.find("## [") + content = (content[:idx] + entry + content[idx:]) if idx != -1 else (content + entry) + with open("CHANGELOG.md", "w") as f: + f.write(content) + print(f"CHANGELOG updated for v{new_ver}") + EOF + env: + NEW_VERSION: ${{ steps.newver.outputs.new }} + + - name: Bump test runner Docker tag + run: | + [ -f testenvironments.json ] || exit 0 + LATEST=$(curl -sf "https://hub.docker.com/v2/repositories/codebeltnet/ubuntu-testrunner/tags/?page_size=10&ordering=last_updated" \ + | python3 -c " + import sys, json + tags = [t['name'] for t in json.load(sys.stdin)['results'] if 'latest' not in t['name']] + print(tags[0] if tags else '') + ") + [ -n "$LATEST" ] && \ + sed -i "s|codebeltnet/ubuntu-testrunner:[^\"]*|codebeltnet/ubuntu-testrunner:${LATEST}|g" \ + testenvironments.json && echo "Bumped to $LATEST" + + - name: Bump NGINX Alpine version + run: | + [ -f .docfx/Dockerfile.docfx ] || exit 0 + LATEST=$(curl -sf "https://hub.docker.com/v2/repositories/library/nginx/tags/?page_size=50&name=alpine&ordering=last_updated" \ + | python3 -c " + import sys, json, re + data = json.load(sys.stdin) + tags = [t['name'] for t in data['results'] if re.match(r'^\d+\.\d+\.\d+-alpine$', t['name'])] + if tags: + print(sorted(tags, key=lambda v: list(map(int, v.replace('-alpine','').split('.'))))[-1]) + ") + [ -n "$LATEST" ] && \ + sed -i "s|NGINX_VERSION=.*|NGINX_VERSION=${LATEST}|g" \ + .docfx/Dockerfile.docfx && echo "Bumped NGINX to $LATEST" + + - name: Show diff (dry run) + if: ${{ github.event.inputs.dry_run == 'true' }} + run: git diff + + - name: Create branch and open PR + if: ${{ github.event.inputs.dry_run != 'true' }} + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + NEW="${{ steps.newver.outputs.new }}" + BRANCH="${{ steps.newver.outputs.branch }}" + SOURCE="${{ steps.trigger.outputs.source }}" + SRC_VER="${{ steps.trigger.outputs.version }}" + + git config user.name "codebelt-aicia[bot]" + git config user.email "codebelt-aicia[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add -A + git diff --cached --quiet && echo "Nothing changed - skipping PR." && exit 0 + git commit -m "V${NEW}/service update" + git push origin "$BRANCH" + + echo "This is a service update that focuses on package dependencies." > pr_body.txt + echo "" >> pr_body.txt + echo "Automated changes:" >> pr_body.txt + echo "- NuGet package versions bumped to latest compatible" >> pr_body.txt + echo "- PackageReleaseNotes.txt updated for v${NEW}" >> pr_body.txt + echo "- CHANGELOG.md entry added for v${NEW}" >> pr_body.txt + echo "- Docker test runner tag bumped (if applicable)" >> pr_body.txt + echo "- NGINX Alpine version bumped (if applicable)" >> pr_body.txt + echo "" >> pr_body.txt + echo "Generated by codebelt-aicia" >> pr_body.txt + if [ -n "$SOURCE" ] && [ -n "$SRC_VER" ]; then + echo "Triggered by: ${SOURCE} @ ${SRC_VER}" >> pr_body.txt + else + echo "Triggered by: manual workflow dispatch" >> pr_body.txt + fi + + gh pr create --title "V${NEW}/service update" --body-file pr_body.txt --base main --head "$BRANCH" --assignee gimlichael diff --git a/.github/workflows/trigger-downstream.yml b/.github/workflows/trigger-downstream.yml new file mode 100644 index 0000000..29eb29c --- /dev/null +++ b/.github/workflows/trigger-downstream.yml @@ -0,0 +1,78 @@ +name: Trigger Downstream Service Updates + +on: + release: + types: [published] + +jobs: + dispatch: + if: github.event.release.prerelease == false + runs-on: ubuntu-24.04 + permissions: + contents: read + + steps: + - name: Checkout (to read dispatch-targets.json) + uses: actions/checkout@v4 + + - name: Check for dispatch targets + id: check + run: | + if [ ! -f .github/dispatch-targets.json ]; then + echo "No dispatch-targets.json found, skipping." + echo "has_targets=false" >> $GITHUB_OUTPUT + exit 0 + fi + COUNT=$(python3 -c "import json; print(len(json.load(open('.github/dispatch-targets.json'))))") + echo "has_targets=$([ $COUNT -gt 0 ] && echo true || echo false)" >> $GITHUB_OUTPUT + + - name: Extract version from release tag + if: steps.check.outputs.has_targets == 'true' + id: version + run: | + VERSION="${{ github.event.release.tag_name }}" + VERSION="${VERSION#v}" + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Generate codebelt-aicia token + if: steps.check.outputs.has_targets == 'true' + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ vars.CODEBELT_AICIA_APP_ID }} + private-key: ${{ secrets.CODEBELT_AICIA_PRIVATE_KEY }} + owner: codebeltnet + + - name: Dispatch to downstream repos + if: steps.check.outputs.has_targets == 'true' + run: | + python3 - <<'EOF' + import json, urllib.request, os, sys + + targets = json.load(open('.github/dispatch-targets.json')) + token = os.environ['GH_TOKEN'] + version = os.environ['VERSION'] + source = os.environ['SOURCE_REPO'] + + for repo in targets: + url = f'https://api.github.com/repos/codebeltnet/{repo}/dispatches' + payload = json.dumps({ + 'event_type': 'codebelt-service-update', + 'client_payload': { + 'source_repo': source, + 'source_version': version + } + }).encode() + req = urllib.request.Request(url, data=payload, method='POST', headers={ + 'Authorization': f'Bearer {token}', + 'Accept': 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28' + }) + with urllib.request.urlopen(req) as r: + print(f'✓ Dispatched to {repo}: HTTP {r.status}') + EOF + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + VERSION: ${{ steps.version.outputs.version }} + SOURCE_REPO: ${{ github.event.repository.name }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5083095 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,262 @@ +# AGENTS.md - Codebelt.Extensions.Xunit + +Guide for AI agents working on this .NET xUnit extensions library. + +## Project Overview + +A .NET library providing extensions for xUnit v3 testing framework. Supports multi-targeting: `net10.0`, `net9.0`, `netstandard2.0` (source) and `net48` (tests on Windows). + +## Repository Layout + +- Solution: `Codebelt.Extensions.Xunit.slnx` in repo root. +- `src/` — NuGet packages (shipped to nuget.org). +- `test/` — xUnit v3 unit and functional tests. +- `tuning/` — BenchmarkDotNet benchmarks. +- `tooling/` — internal CLI tools. +- `.nuget//` — per-package `README.md` and `PackageReleaseNotes.txt`. + +## Toolchain + +- .NET SDK with `LangVersion=latest`. +- Source TFMs: `net10.0;net9.0;netstandard2.0`. +- Test TFMs: `net10.0;net9.0` on Linux; adds `net48` on Windows. +- Benchmark TFMs: `net10.0;net9.0;netstandard2.0`. +- Central package management via `Directory.Packages.props` (`ManagePackageVersionsCentrally=true`). +- CI runs on Linux (ubuntu-24.04) and Windows (windows-2025), both X64 and ARM64. +- TFM compatibility is mandatory: proposals and code changes must work for all source TFMs. Do not assume `net9.0`/`net10.0` APIs exist in `netstandard2.0`; use conditional compilation (`#if NET9_0_OR_GREATER`) or compatible fallbacks where needed. + +## Build Commands + +```bash +# Build entire solution +dotnet build Codebelt.Extensions.Xunit.slnx + +# Build Release configuration +dotnet build Codebelt.Extensions.Xunit.slnx -c Release + +# Build with skipped assembly signing (for CI/external contributors) +dotnet build -p:SkipSignAssembly=true + +# Restore packages +dotnet restore Codebelt.Extensions.Xunit.slnx + +# Pack NuGet packages +dotnet pack -c Release +``` + +## Lint / Analyzers + +- No separate lint step; code style is enforced during build (`EnforceCodeStyleInBuild=true` for source projects). +- Analyzers are **disabled** for test and benchmark projects (`RunAnalyzers=false`, `AnalysisLevel=none`). +- Run `dotnet build -c Release` on source projects to surface style violations. + +## Test Commands + +```bash +# Run all tests +dotnet test + +# Run tests for specific project +dotnet test test/Codebelt.Extensions.Xunit.Tests + +# Run single test by fully qualified name +dotnet test --filter "FullyQualifiedName~TestTest" + +# Run tests with specific trait +dotnet test --filter "Category=Unit" + +# Run tests with coverage +dotnet test --collect:"XPlat Code Coverage" +``` + +### Benchmarks + +- Live under `tuning/`; run with `tooling/benchmark-runner`. +- Not unit tests; do not include in test runs. + +## Cursor / Copilot Rules + +- No Cursor rules (`.cursor/rules/` and `.cursorrules` are absent). +- Copilot rules live in `.github/copilot-instructions.md` — **must follow**. + +## Code Style and Conventions + +### General Principles +- Follow Framework Design Guidelines and Microsoft Engineering Guidelines. +- Adhere to SOLID, DRY, separation of concerns. +- Apply the boy scout rule; do not duplicate code. + +### Formatting +- 4 spaces for `.cs` files; 2 spaces for `.xml` (`.editorconfig`). +- Keep existing style in files; many modern analyzers are explicitly disabled. + +### Namespace Style +- **Prefer file-scoped namespaces** (`namespace Codebelt.Extensions.Xunit;`) for new files. +- The current majority of the codebase uses **block-scoped namespaces** — do not convert existing files unless explicitly asked. +- When editing an existing file, follow whichever style that file already uses. +- **Never use top-level statements.** Always use explicit class declarations with a proper namespace. + +### Disabled Analyzers (key rules — do NOT introduce these patterns) + +| Rule | What it forces | Why disabled | +|------|---------------|--------------| +| IDE0066 | switch expressions | style consistency | +| IDE0063 | using declarations | style consistency | +| IDE0290 | primary constructors | style consistency | +| IDE0022 | expression-bodied methods | style consistency | +| IDE0300/0301/0028/0305 | collection expressions | netstandard2.0 compat | +| CA1846/1847/1865-1867 | Span/char overloads | netstandard2.0 compat | +| IDE0330 | `System.Threading.Lock` | requires net9.0+ | +| Performance category | various | netstandard2.0 compat | + +### Namespaces +- **CRITICAL**: Test namespaces MUST match the System Under Test (SUT) exactly +- Do NOT append `.Tests` or `.Benchmarks` to namespaces +- Example: SUT `Codebelt.Extensions.Xunit` → Tests `Codebelt.Extensions.Xunit` (not `Codebelt.Extensions.Xunit.Tests`) +- Override `RootNamespace` in `.csproj` to match SUT namespace + +### Test Classes +- Always inherit from `Test` base class from `Codebelt.Extensions.Xunit` +- Constructor must accept `ITestOutputHelper output` and pass to base +- Class names end with `Test` (e.g., `DateSpanTest`) + +```csharp +using Codebelt.Extensions.Xunit; +using Xunit; + +namespace Codebelt.Extensions.Xunit // Same as SUT +{ + public class YourTestClass : Test + { + public YourTestClass(ITestOutputHelper output) : base(output) { } + } +} +``` + +### Imports +- Use `using Xunit;` - NOT `Xunit.Abstractions` (xUnit v3 removed this namespace) +- Use `using Xunit.v3;` when needed for xUnit v3 specific types +- Place System.* usings first, then third-party, then project +- Keep `using` directives explicit and minimal. +- Follow existing ordering; do not auto-reorder. + +### Naming Conventions +- Test methods: Use descriptive names with `Should` prefix + - Pattern: `Should{ExpectedResult}_When{Condition}` + - Example: `ShouldReturnTrue_WhenConditionIsMet` +- Use `[Fact]` for standard tests, `[Theory]` for parameterized tests +- Benchmark classes: End with `Benchmark` + +### Types and `var` +- Do not blindly enforce `var`; use explicit types when it improves clarity. +- IDE0008 (use explicit type) is disabled — either form is acceptable. + +### Error Handling +- Use guard clauses with `ArgumentNullException`, `ArgumentOutOfRangeException` +- **Always use `Validator` pattern** (e.g., `Validator.ThrowIfNull(param)`) +- Pattern: `if (param == null) { throw new ArgumentNullException(nameof(param)); }` only when Validator is not available +- Use `ArgumentOutOfRangeException` for validation with actual/expected values +- Prefer deterministic, testable error paths; never swallow exceptions. + +### XML Documentation +- Document all public/protected APIs with XML comments +- Use `` for type references +- Include `` tags for thrown exceptions +- Follow existing documentation style (see Hash.cs example) + +## Testing Guidelines + +### Test Doubles +- Preferred: dummies, fakes, stubs, spies +- Mocks allowed: Moq library for special circumstances only +- Never mock `IMarshaller`; use `JsonMarshaller` instance instead + +### Internal Members +- Do NOT use `InternalsVisibleTo` +- Test internal logic via public APIs that consume them +- Use Public Facade Testing pattern + +### Async Tests +- Override `InitializeAsync()` for async setup +- Use `ValueTask` for async operations (not `Task`) +- Implement `IAsyncLifetime` when needed via `Test` base class + +## Benchmark Guidelines + +- Place in `tuning/` folder or `*.Benchmarks` projects +- Namespaces follow same rule as tests (no `.Benchmarks` suffix) +- Use `[MemoryDiagnoser]` and `[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]` attributes. +- Use `[GlobalSetup]` for expensive prep; keep measured methods focused. +- Use `[Params]` for multiple input sizes; use deterministic data; avoid external systems. +- Mark one method `Baseline = true`; use descriptive `Description` values. + +## Package Management + +- Uses Central Package Management (`Directory.Packages.props`) +- Do not add version numbers in individual `.csproj` files +- Test frameworks are centrally managed + +## CI/Build Notes + +- Assembly signing uses `xunit.snk` (skip for external builds) +- MinVer handles versioning based on Git tags +- Code coverage via coverlet +- SonarCloud and CodeQL analysis enabled + +## Release Notes + +- Per-package notes in `.nuget//PackageReleaseNotes.txt`. +- Keep updated for public API changes. + +## Commit Style (Gitmoji) + +This repo uses **gitmoji** commit messages — do **not** use Conventional Commits (`feat:`, `fix:`, etc.). + +Format: ` ` + +**Always use the actual Unicode emoji character**, not the GitHub shortcode (e.g., use `✨` not `:sparkles:`). + +Example: `✨ Add Test.Match wildcard overload` + +### Common Gitmojis + +| Emoji | Use for | +|-------|---------| +| ✨ | New feature | +| 🐛 | Bug fix | +| ♻️ | Refactoring | +| ✅ | Adding / updating unit test / functional test | +| 📝 | Documentation | +| ⚡ | Performance improvement | +| 🎨 | Code style / formatting | +| 🔥 | Removing code or files | +| 🚧 | Work in progress | +| 📦 | Package / dependency update | +| 🔧 | Configuration / tooling | +| 🚚 | Moving / renaming files | +| 💥 | Breaking change | +| 🩹 | Non-critical fix | + +### Rules + +1. **One emoji per commit** — each commit has exactly one primary gitmoji. +2. **Be specific** — choose the most appropriate emoji, not a generic one. +3. **Consistent scope** — use consistent scope names across commits. +4. **Clear messages** — the subject line should be understandable without a body. +5. **Atomic commits** — each commit should be independently buildable and testable. + +## Agent Workflow + +1. Identify the correct project area (`src/`, `test/`, `tuning/`, `tooling/`). +2. Follow namespace and naming rules **before** writing any code. +3. Before potentially refactoring any code, verify the code in question is well tested; if coverage is missing, add or update tests first to reduce regression risk. +4. Build the affected source project to check for style violations. +5. Run targeted tests when changing logic. +6. Keep changes minimal and consistent with existing local style. + +## Copilot Instructions + +See `.github/copilot-instructions.md` for detailed guidelines on: +- Writing unit tests +- Writing performance tests (BenchmarkDotNet) +- Writing XML documentation