Skip to content
Open
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
8 changes: 5 additions & 3 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,27 @@ dotnet_diagnostic.CS1573.severity = warning
# Testing Rules
dotnet_diagnostic.xUnit1051.severity = warning

# Compiler Jump / Label Errors (Zero Tolerance)
dotnet_diagnostic.CS0159.severity = error
dotnet_diagnostic.CS159.severity = error

# ─────────────────────────────────────────────────────────────────────────────
# Test & Benchmark Projects: Living Executable Specifications
# As established in ADR-026, test methods follow the Osherove naming pattern:
# [Method/UnitOfWork]_[Scenario/StateUnderTest]_[ExpectedBehavior]
# CA1707, IDE1006, and CS1591 are locally disabled for test clarity in CI/CD reports.
# CA1707 and IDE1006 are locally adjusted for test clarity in CI/CD reports.
# ─────────────────────────────────────────────────────────────────────────────
[{tests,benchmarks}/**/*.cs]
dotnet_diagnostic.IDE1006.severity = none
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA1515.severity = none
dotnet_diagnostic.CS1591.severity = none
dotnet_diagnostic.CS0618.severity = error
dotnet_diagnostic.CS0619.severity = error

[{**/*Tests.cs,**/*IntegrationTests.cs,**/*Benchmarks.cs}]
dotnet_diagnostic.IDE1006.severity = none
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA1515.severity = none
dotnet_diagnostic.CS1591.severity = none
dotnet_diagnostic.CS0618.severity = error
dotnet_diagnostic.CS0619.severity = error

Expand Down
5 changes: 4 additions & 1 deletion .github/ISSUE_TEMPLATE/bug-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ A clear and concise description of what you expected to happen.
- Package(s) affected:
- [ ] `EricksonLopez.Specification` 1.0.0
- [ ] `EricksonLopez.Specification.Abstractions`
- [ ] `EricksonLopez.Specification.Linq`
- [ ] `EricksonLopez.Specification.Sql`
- [ ] `EricksonLopez.Specification.PostgreSql`
- [ ] `EricksonLopez.Specification.MsSql`
Expand All @@ -35,12 +36,14 @@ A clear and concise description of what you expected to happen.
- [ ] `EricksonLopez.Specification.Sqlite`
- [ ] `EricksonLopez.Specification.Oracle`
- [ ] `EricksonLopez.Specification.Dapper`
- [ ] `EricksonLopez.Specification.DapperExtensions`
- [ ] `EricksonLopez.Specification.EntityFrameworkCore`
- [ ] `EricksonLopez.Specification.MongoDB`
- [ ] `EricksonLopez.Specification.Result`
- [ ] `EricksonLopez.Specification.Analyzers`
- [ ] `EricksonLopez.Specification.Generators`
- [ ] Other: ___
- Storage/Infrastructure: [e.g., PostgreSQL 16 via Dapper 2.1.66 / EF Core 9.0.2 / MongoDB 7]
- Storage/Infrastructure: [e.g., PostgreSQL 16 via Dapper 2.1.79 / EF Core 9.0.2 / MongoDB 7]

**Generated SQL (if applicable)**
If the bug involves SQL generation, paste the generated SQL string and bound parameters here.
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/feature-request.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Please indicate the area your request relates to (check all that apply):
A clear and concise description of any alternative solutions or features you've considered, including whether this might violate an existing ADR (see [ADR index](../../docs/adr/README.md)).

**Why this should NOT be rejected as an ADR**
Review the [what-not-to-build.md](../../what-not-to-build.md) and the existing [ADRs](../../docs/adr/README.md). Briefly explain why this feature is compatible with the library's architectural boundaries (zero-ORM domain purity, AOT-first, immutability).
Review the [what-not-to-build.md](../../docs/what-not-to-build.md) and the existing [ADRs](../../docs/adr/README.md). Briefly explain why this feature is compatible with the library's architectural boundaries (zero-ORM domain purity, AOT-first, immutability).

**Additional context**
Add any other context, code examples, or references here.
6 changes: 3 additions & 3 deletions .github/workflows/aot-smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ jobs:

steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: |
8.0.x
Expand Down Expand Up @@ -94,7 +94,7 @@ jobs:
# ─── Upload binary for debugging if needed ────────────────────────────
- name: Upload AOT artifacts (on failure)
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: aot-output-${{ github.run_id }}
path: ./aot-output/
Expand Down
118 changes: 19 additions & 99 deletions .github/workflows/benchmark-regression-gate.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
# Copyright © Erickson Lopez. MIT License.
# Copyright © Erickson Lopez. MIT License.
name: Benchmark Regression Gate

# ─── Purpose ─────────────────────────────────────────────────────────────────
# Runs BenchmarkDotNet against the PR branch and compares the results against
# the baseline captured on `main` (stored in benchmarks/results/).
#
# If any benchmark regresses by more than REGRESSION_THRESHOLD (default: 10%),
# the CI gate FAILS with a detailed report showing the exact delta.
# ─────────────────────────────────────────────────────────────────────────────
# ─── Purpose ─────────────────────────────────────────────────────────────────
# Runs BenchmarkDotNet against the PR branch and validates against strict gates:
# 1. Heap Invariant: Zero-allocation on hot path combinators (0 B allocated).
# 2. Latency Threshold: Mean latency regression must not exceed 5% vs baseline.
# ─────────────────────────────────────────────────────────────────────────────

on:
pull_request:
Expand All @@ -18,15 +16,15 @@ on:
workflow_dispatch:
inputs:
threshold:
description: "Regression threshold in percent (e.g. 10 for +10%)"
description: "Regression threshold in percent (default: 5)"
required: false
default: "10"
default: "5"
type: string

env:
DOTNET_NOLOGO: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
REGRESSION_THRESHOLD: ${{ inputs.threshold || '10' }}
REGRESSION_THRESHOLD: ${{ inputs.threshold || '5' }}

jobs:
benchmark-gate:
Expand All @@ -35,12 +33,12 @@ jobs:
timeout-minutes: 45

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: |
8.0.x
Expand Down Expand Up @@ -70,99 +68,21 @@ jobs:
--
--filter "*"
--job short
--runtimes net8.0 net10.0
--exporters json
--memory
--artifacts ./benchmarks/pr-results

- name: Check for baseline results
id: baseline-check
- name: Evaluate Benchmark Regression Gate
shell: pwsh
run: |
if ls benchmarks/results/**/*.json 2>/dev/null | head -1 | grep -q .; then
echo "has_baseline=true" >> "$GITHUB_OUTPUT"
else
echo "has_baseline=false" >> "$GITHUB_OUTPUT"
echo "::warning::No benchmark baseline found in benchmarks/results/. Skipping regression check. Run the 'Weekly Benchmarks' workflow on main to establish a baseline."
fi

- name: Compare results vs baseline
if: steps.baseline-check.outputs.has_baseline == 'true'
run: |
python3 - <<'PYEOF'
import json, os, glob, sys

THRESHOLD = float(os.environ.get("REGRESSION_THRESHOLD", "10")) / 100.0
baseline_files = glob.glob("benchmarks/results/**/*.json", recursive=True)
pr_files = glob.glob("benchmarks/pr-results/**/*.json", recursive=True)

if not baseline_files:
print("No baseline JSON files found. Skipping comparison.")
sys.exit(0)
if not pr_files:
print("No PR benchmark JSON files found.")
sys.exit(1)

def load_benchmarks(files):
results = {}
for f in files:
try:
data = json.load(open(f))
for bench in data.get("Benchmarks", []):
name = bench.get("FullName", bench.get("Method", ""))
mean = bench.get("Statistics", {}).get("Mean", None)
if name and mean:
results[name] = mean
except Exception as e:
print(f"Warning: Could not parse {f}: {e}")
return results

baseline = load_benchmarks(baseline_files)
pr = load_benchmarks(pr_files)

regressions = []
improvements = []
for name, pr_mean in pr.items():
if name in baseline:
base_mean = baseline[name]
if base_mean > 0:
delta_pct = (pr_mean - base_mean) / base_mean * 100
if delta_pct > THRESHOLD * 100:
regressions.append((name, base_mean, pr_mean, delta_pct))
elif delta_pct < -5:
improvements.append((name, base_mean, pr_mean, delta_pct))

summary_lines = []
summary_lines.append(f"## Benchmark Regression Report (threshold: {THRESHOLD*100:.0f}%)\n")

if improvements:
summary_lines.append("### ✅ Improvements")
for name, base, pr_val, delta in improvements:
summary_lines.append(f"- **{name}**: {base/1000000:.3f}ms → {pr_val/1000000:.3f}ms ({delta:+.1f}%)")
summary_lines.append("")

if regressions:
summary_lines.append("### ❌ Regressions (above threshold)")
for name, base, pr_val, delta in regressions:
summary_lines.append(f"- **{name}**: {base/1000000:.3f}ms → {pr_val/1000000:.3f}ms ({delta:+.1f}%) ← REGRESSION")
summary_lines.append("")
else:
summary_lines.append("### ✅ No regressions detected\n")

report = "\n".join(summary_lines)
print(report)

with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f:
f.write(report + "\n")

if regressions:
print(f"\n❌ {len(regressions)} benchmark(s) regressed beyond {THRESHOLD*100:.0f}% threshold. Failing build.")
sys.exit(1)
else:
print(f"\n✅ All benchmarks within {THRESHOLD*100:.0f}% regression threshold.")
PYEOF
./scripts/verify-benchmark-gate.ps1 `
-ReportDir ./benchmarks/pr-results `
-BaselinePath ./benchmarks/results/baseline.json `
-MaxLatencyRegressionPercent ([double]$env:REGRESSION_THRESHOLD)

- name: Upload PR benchmark results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-benchmark-results-${{ github.run_id }}
path: benchmarks/pr-results/
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,10 @@ jobs:
timeout-minutes: 60

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: |
8.0.x
Expand Down Expand Up @@ -81,7 +81,7 @@ jobs:

- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: benchmark-results-${{ github.run_id }}
path: benchmarks/results/
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/dotnet-build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,12 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0

- name: Setup .NET
uses: actions/setup-dotnet@v4
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ inputs.dotnet-version }}

Expand All @@ -64,11 +64,11 @@ jobs:
SNK_KEY: ${{ secrets.SNK_KEY }}
run: |
if [ -n "$SNK_KEY" ]; then
echo "$SNK_KEY" | tr -d '\n\r ' | base64 --decode > EricksonLopez.Specifications.snk
echo "$SNK_KEY" | tr -d '\n\r ' | base64 --decode > EricksonLopez.snk
fi

- name: Setup Java (for SonarScanner)
uses: actions/setup-java@v3
uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1
with:
java-version: '17'
distribution: 'zulu'
Expand Down Expand Up @@ -117,14 +117,14 @@ jobs:

- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ inputs.artifact-name }}
path: ./test-results/

- name: Upload coverage to Codecov
if: always() && inputs.upload-coverage
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: "./test-results/**/coverage.cobertura.xml"
Expand Down
Loading