From e76ad99a5786a15b9c9dc5c6c0e171a36dbb4e79 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 21 Jun 2026 00:14:10 +0200 Subject: [PATCH 1/2] Refactor: Adapt GitHub automation for ModularityKit.Mutator Added - Repo specific label definitions for abstractions, runtime, examples, tests, benchmark, documentation, architecture, ci, and performance - Labeler rules for the current folder layout under src/, Examples/, Benchmarks/, Tests/, Docs/, and .github/ - Local Python helper for release asset uploads under scripts/releases/ - Benchmark project files and test placeholder tracked in the repo Changed - Publish workflows now package src/ModularityKit.Mutator.csproj as NuGet artifact - Release workflows now download ModularityKit.Mutator-nupkg runtime artifacts - PR automation now targets the lower case performance label consistently --- .github/config/auto-assign.yml | 7 ++ .github/config/labeler.yml | 43 +++++++ .github/config/labels.yml | 51 +++++++++ .github/config/pr-title-checker.json | 20 ++++ .github/workflows/labels-sync.yml | 27 +++++ .github/workflows/pr-automation.yml | 97 ++++++++++++++++ .github/workflows/pr-check.yml | 31 +++++ .github/workflows/publish-artifacts.yml | 59 ++++++++++ .github/workflows/publish-attested.yml | 42 +++++++ .github/workflows/release-drafter.yml | 69 ++++++++++++ Tests/.gitkeep | 1 + scripts/__init__.py | 1 + scripts/releases/__init__.py | 1 + scripts/releases/upload_release_assets.py | 131 ++++++++++++++++++++++ 14 files changed, 580 insertions(+) create mode 100644 .github/config/auto-assign.yml create mode 100644 .github/config/labeler.yml create mode 100644 .github/config/labels.yml create mode 100644 .github/config/pr-title-checker.json create mode 100644 .github/workflows/labels-sync.yml create mode 100644 .github/workflows/pr-automation.yml create mode 100644 .github/workflows/pr-check.yml create mode 100644 .github/workflows/publish-artifacts.yml create mode 100644 .github/workflows/publish-attested.yml create mode 100644 .github/workflows/release-drafter.yml create mode 100644 Tests/.gitkeep create mode 100644 scripts/__init__.py create mode 100644 scripts/releases/__init__.py create mode 100644 scripts/releases/upload_release_assets.py diff --git a/.github/config/auto-assign.yml b/.github/config/auto-assign.yml new file mode 100644 index 0000000..652f35e --- /dev/null +++ b/.github/config/auto-assign.yml @@ -0,0 +1,7 @@ +addAssignees: author + +addReviewers: false + +skipKeywords: + - wip + - draft \ No newline at end of file diff --git a/.github/config/labeler.yml b/.github/config/labeler.yml new file mode 100644 index 0000000..e9fe5d5 --- /dev/null +++ b/.github/config/labeler.yml @@ -0,0 +1,43 @@ +abstractions: + - changed-files: + - any-glob-to-any-file: + - src/Abstractions/** + +runtime: + - changed-files: + - any-glob-to-any-file: + - src/Runtime/** + +examples: + - changed-files: + - any-glob-to-any-file: + - Examples/** + +tests: + - changed-files: + - any-glob-to-any-file: + - Tests/** + - '**/*Tests.cs' + +benchmark: + - changed-files: + - any-glob-to-any-file: + - Benchmarks/** + +documentation: + - changed-files: + - any-glob-to-any-file: + - Docs/** + - '*.md' + - readme.md + +architecture: + - changed-files: + - any-glob-to-any-file: + - Docs/Decision/** + - .github/config/** + +ci: + - changed-files: + - any-glob-to-any-file: + - .github/** diff --git a/.github/config/labels.yml b/.github/config/labels.yml new file mode 100644 index 0000000..48e0913 --- /dev/null +++ b/.github/config/labels.yml @@ -0,0 +1,51 @@ +- name: bug + color: "d73a4a" + description: Something is broken or incorrect + +- name: enhancement + color: "a2eeef" + description: New functionality or behavior + +- name: documentation + color: "0075ca" + description: Documentation updates and additions + +- name: architecture + color: "8B5E3C" + description: Design, structure, and API-shape changes + +- name: abstractions + color: "5B4B8A" + description: Public abstractions and contracts + +- name: runtime + color: "8A4F7D" + description: Runtime implementation and execution flow + +- name: examples + color: "1d76db" + description: Runnable examples and sample apps + +- name: ci + color: "5319e7" + description: CI/CD and repository automation changes + +- name: tests + color: "fbca04" + description: Test coverage and test changes + +- name: performance + color: "0e4d92" + description: Performance improvements or regressions + +- name: benchmark + color: "5319e7" + description: Benchmark coverage and performance measurement changes + +- name: breaking-change + color: "b60205" + description: Introduces a breaking change + +- name: skip-changelog + color: "ededed" + description: Exclude this change from release notes diff --git a/.github/config/pr-title-checker.json b/.github/config/pr-title-checker.json new file mode 100644 index 0000000..9541c2d --- /dev/null +++ b/.github/config/pr-title-checker.json @@ -0,0 +1,20 @@ +{ + "LABEL": { + "name": "", + "color": "EEEEEE" + }, + "CHECKS": { + "regexp": "^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\\([a-z0-9-]+\\))?: .+$", + "regexpFlags": "i", + "ignoreLabels": [ + "State One", + "State Two" + ], + "alwaysPassCI": false + }, + "MESSAGES": { + "success": "PR title matches the required format.", + "failure": "PR title must match: type(optional-scope): description", + "notice": "Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore." + } +} diff --git a/.github/workflows/labels-sync.yml b/.github/workflows/labels-sync.yml new file mode 100644 index 0000000..f3073e2 --- /dev/null +++ b/.github/workflows/labels-sync.yml @@ -0,0 +1,27 @@ +name: Labels Sync + +on: + push: + branches: [ main ] + paths: + - .github/config/labels.yml + - .github/workflows/labels-sync.yml + workflow_dispatch: + +permissions: + issues: write + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Sync repository labels + uses: EndBug/label-sync@v2 + with: + config-file: .github/config/labels.yml + delete-other-labels: true diff --git a/.github/workflows/pr-automation.yml b/.github/workflows/pr-automation.yml new file mode 100644 index 0000000..57eed27 --- /dev/null +++ b/.github/workflows/pr-automation.yml @@ -0,0 +1,97 @@ +name: Pull Request Automation + +on: + pull_request_target: + types: [ opened, edited, synchronize, reopened, ready_for_review, labeled, unlabeled ] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + assign-author: + if: github.event.action == 'opened' || github.event.action == 'ready_for_review' + runs-on: ubuntu-latest + + steps: + - name: Assign PR author + uses: kentaro-m/auto-assign-action@v2.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/config/auto-assign.yml + + title-check: + runs-on: ubuntu-latest + + steps: + - name: Check PR title + uses: thehanimo/pr-title-checker@v1.4.3 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + pass_on_octokit_error: false + configuration_path: .github/config/pr-title-checker.json + + labeler: + runs-on: ubuntu-latest + + steps: + - name: Label changed files + uses: actions/labeler@v6 + with: + configuration-path: .github/config/labeler.yml + sync-labels: true + + performance-labeler: + runs-on: ubuntu-latest + + steps: + - name: Apply Performance label from issues or PR text + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const text = `${pr.title}\n${pr.body || ''}`.toLowerCase(); + const keywordMatch = /(perf|performance|benchmark|optimiz|allocation|memory pressure|throughput|latency)/i.test(text); + const issueNumbers = new Set(); + + for (const match of text.matchAll(/(?:closes|fixes|related to)\s+#(\d+)/gi)) { + issueNumbers.add(Number(match[1])); + } + + for (const match of text.matchAll(/#(\d+)/g)) { + issueNumbers.add(Number(match[1])); + } + + let issueMatch = false; + + for (const number of issueNumbers) { + const issue = await github.rest.issues.get({ owner, repo, issue_number: number }); + const labels = issue.data.labels.map(label => label.name); + + if (labels.includes('performance')) { + issueMatch = true; + break; + } + } + + if (!keywordMatch && !issueMatch) { + core.info('Performance label not applicable for this PR.'); + return; + } + + const currentLabels = pr.labels.map(label => label.name); + if (currentLabels.includes('performance')) { + core.info('Performance label already present.'); + return; + } + + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: pr.number, + labels: ['performance'] + }); + core.info('Performance label added.'); diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..2484cae --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,31 @@ +name: PR Check + +on: + pull_request: + push: + branches: [ main, master, develop ] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + configuration: [ Debug, Release ] + + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore ModularityKit.Mutator.slnx + + - name: Build ${{ matrix.configuration }} + run: dotnet build ModularityKit.Mutator.slnx -c ${{ matrix.configuration }} --no-restore diff --git a/.github/workflows/publish-artifacts.yml b/.github/workflows/publish-artifacts.yml new file mode 100644 index 0000000..a8180be --- /dev/null +++ b/.github/workflows/publish-artifacts.yml @@ -0,0 +1,59 @@ +name: Publish Artifacts + +on: + workflow_call: + inputs: + package_version: + description: "Optional package version, usually the release tag without the leading v." + required: false + type: string + +permissions: + contents: read + +jobs: + package: + name: Pack library + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Restore + run: dotnet restore src/ModularityKit.Mutator.csproj + + - name: Resolve package version + id: version + env: + PACKAGE_VERSION: ${{ inputs.package_version }} + REF_NAME: ${{ github.ref_name }} + run: | + version="$PACKAGE_VERSION" + if [ -z "$version" ]; then + version="$REF_NAME" + fi + version="${version#v}" + if ! printf '%s' "$version" | grep -Eq '^[0-9]+(\.[0-9]+){1,2}([-+][0-9A-Za-z.-]+)?$'; then + version="0.1.0" + fi + echo "package_version=$version" >> "$GITHUB_OUTPUT" + + - name: Pack package + run: > + dotnet pack src/ModularityKit.Mutator.csproj + -c Release + --no-restore + -o nupkg + -p:PackageVersion=${{ steps.version.outputs.package_version }} + + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: ModularityKit.Mutator-nupkg + path: nupkg/*.nupkg diff --git a/.github/workflows/publish-attested.yml b/.github/workflows/publish-attested.yml new file mode 100644 index 0000000..e5171aa --- /dev/null +++ b/.github/workflows/publish-attested.yml @@ -0,0 +1,42 @@ +name: Publish Attested + +on: + workflow_dispatch: + +permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + +jobs: + publish: + uses: ./.github/workflows/publish-artifacts.yml + + release: + name: Upload artifacts to draft release + runs-on: ubuntu-latest + needs: publish + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download published artifacts + uses: actions/download-artifact@v6 + with: + pattern: ModularityKit.Mutator-nupkg + path: dist + merge-multiple: true + + - name: Create or update draft release + env: + GITHUB_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + DIST_DIR: dist + FIND_DRAFT: "true" + ENSURE_DRAFT: "true" + FAIL_MESSAGE: "No draft release found. Release Drafter must create the draft before artifacts can be uploaded." + ASSET_PATTERNS: | + * + run: python3 -m scripts.releases.upload_release_assets diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml new file mode 100644 index 0000000..39487da --- /dev/null +++ b/.github/workflows/release-drafter.yml @@ -0,0 +1,69 @@ +name: Release Drafter + +on: + workflow_dispatch: + inputs: + version: + description: Release version without the leading "v" + required: false + type: string + push: + branches: [ main ] + +permissions: + contents: write + pull-requests: read + id-token: write + attestations: write + artifact-metadata: write + +jobs: + update-release-draft: + runs-on: ubuntu-latest + outputs: + tag_name: ${{ steps.release_drafter.outputs.tag_name }} + html_url: ${{ steps.release_drafter.outputs.html_url }} + + steps: + - name: Update release draft + id: release_drafter + uses: release-drafter/release-drafter@v7 + with: + config-name: release-drafter.yml + version: ${{ github.event_name == 'workflow_dispatch' && inputs.version || '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish: + needs: update-release-draft + uses: ./.github/workflows/publish-artifacts.yml + + upload-release-assets: + name: Upload artifacts to release draft + runs-on: ubuntu-latest + needs: + - update-release-draft + - publish + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download published artifacts + uses: actions/download-artifact@v6 + with: + pattern: ModularityKit.Mutator-nupkg + path: dist + merge-multiple: true + + - name: Upload assets to Release Drafter draft + env: + GITHUB_TOKEN: ${{ github.token }} + REPOSITORY: ${{ github.repository }} + RELEASE_TAG: ${{ needs.update-release-draft.outputs.tag_name }} + DIST_DIR: dist + ENSURE_DRAFT: "true" + FAIL_MESSAGE: "Release Drafter did not return a tag name." + ASSET_PATTERNS: | + * + run: python3 -m scripts.releases.upload_release_assets diff --git a/Tests/.gitkeep b/Tests/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Tests/.gitkeep @@ -0,0 +1 @@ + diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ + diff --git a/scripts/releases/__init__.py b/scripts/releases/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/scripts/releases/__init__.py @@ -0,0 +1 @@ + diff --git a/scripts/releases/upload_release_assets.py b/scripts/releases/upload_release_assets.py new file mode 100644 index 0000000..780df63 --- /dev/null +++ b/scripts/releases/upload_release_assets.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys +from typing import Iterable + + +def env(name: str, default: str = "") -> str: + return os.environ.get(name, default) + + +def require_env(name: str) -> str: + value = env(name) + if not value: + print(f"Missing required environment variable: {name}", file=sys.stderr) + raise SystemExit(1) + return value + + +def as_bool(value: str) -> bool: + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def gh_json(args: list[str]) -> object: + completed = subprocess.run( + ["gh", *args], + check=True, + capture_output=True, + text=True, + env=os.environ.copy(), + ) + return json.loads(completed.stdout) + + +def iter_assets(dist_dir: pathlib.Path, patterns: str) -> list[pathlib.Path]: + assets: list[pathlib.Path] = [] + seen: set[pathlib.Path] = set() + + for raw_pattern in patterns.splitlines(): + pattern = raw_pattern.strip() + if not pattern: + continue + + for match in sorted(dist_dir.glob(pattern)): + if match.is_file() and match not in seen: + seen.add(match) + assets.append(match) + + return assets + + +def find_release(repository: str, release_tag: str, find_draft: bool) -> dict[str, object] | None: + releases = gh_json(["api", f"repos/{repository}/releases?per_page=100"]) + if not isinstance(releases, list): + raise RuntimeError("Unexpected GitHub API response for releases") + + if release_tag: + for release in releases: + if isinstance(release, dict) and release.get("tag_name") == release_tag: + return release + return None + + if find_draft: + for release in releases: + if isinstance(release, dict) and release.get("draft"): + return release + return None + + return None + + +def upload_assets(repository: str, tag_name: str, assets: Iterable[pathlib.Path]) -> int: + asset_args = [str(asset) for asset in assets] + if not asset_args: + print("No assets matched the configured patterns.", file=sys.stderr) + return 1 + + subprocess.run( + [ + "gh", + "release", + "upload", + tag_name, + *asset_args, + "--clobber", + "--repo", + repository, + ], + check=True, + env=os.environ.copy(), + ) + return 0 + + +def main() -> int: + repository = require_env("REPOSITORY") + dist_dir = pathlib.Path(env("DIST_DIR", "dist")) + asset_patterns = env("ASSET_PATTERNS", "*") + release_tag = env("RELEASE_TAG") + find_draft = as_bool(env("FIND_DRAFT", "false")) + ensure_draft = as_bool(env("ENSURE_DRAFT", "false")) + fail_message = env("FAIL_MESSAGE", "No matching release found.") + + if not dist_dir.is_dir(): + print(f"Distribution directory not found: {dist_dir}", file=sys.stderr) + return 1 + + release = find_release(repository, release_tag, find_draft) + if release is None: + if ensure_draft or release_tag or find_draft: + print(fail_message, file=sys.stderr) + return 1 + print("No release target configured.", file=sys.stderr) + return 1 + + tag_name = release.get("tag_name") + if not isinstance(tag_name, str) or not tag_name: + print("Resolved release has no tag name.", file=sys.stderr) + return 1 + + assets = iter_assets(dist_dir, asset_patterns) + return upload_assets(repository, tag_name, assets) + + +if __name__ == "__main__": + raise SystemExit(main()) From 510dc67eb8401068e6e5187783730cd754ca4979 Mon Sep 17 00:00:00 2001 From: "Rian.be" Date: Sun, 21 Jun 2026 00:16:48 +0200 Subject: [PATCH 2/2] Perf: Add benchmark suite for mutation engine Added - BenchmarkDotNet project for ModularityKit.Mutator under Benchmarks/ - Engine benchmarks covering commit, validate only, simulate, and batch paths - Console entrypoint and benchmark README for running the suite locally Changed - Benchmark project references the flattened src/ModularityKit.Mutator.csproj layout - Benchmark outputs stay isolated from the library and example projects Result The repository now has dedicated benchmark suite that exercises the current mutation engine paths and can be run independently from the main library build. --- .../ModularityKit.Mutator.Benchmarks.csproj | 25 +++ Benchmarks/MutationEngineBenchmarks.cs | 161 ++++++++++++++++++ Benchmarks/Program.cs | 16 ++ Benchmarks/README.md | 29 ++++ 4 files changed, 231 insertions(+) create mode 100644 Benchmarks/ModularityKit.Mutator.Benchmarks.csproj create mode 100644 Benchmarks/MutationEngineBenchmarks.cs create mode 100644 Benchmarks/Program.cs create mode 100644 Benchmarks/README.md diff --git a/Benchmarks/ModularityKit.Mutator.Benchmarks.csproj b/Benchmarks/ModularityKit.Mutator.Benchmarks.csproj new file mode 100644 index 0000000..1383676 --- /dev/null +++ b/Benchmarks/ModularityKit.Mutator.Benchmarks.csproj @@ -0,0 +1,25 @@ + + + + Exe + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + diff --git a/Benchmarks/MutationEngineBenchmarks.cs b/Benchmarks/MutationEngineBenchmarks.cs new file mode 100644 index 0000000..e7cf79a --- /dev/null +++ b/Benchmarks/MutationEngineBenchmarks.cs @@ -0,0 +1,161 @@ +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.DependencyInjection; +using ModularityKit.Mutator.Abstractions; +using ModularityKit.Mutator.Abstractions.Changes; +using ModularityKit.Mutator.Abstractions.Context; +using ModularityKit.Mutator.Abstractions.Engine; +using ModularityKit.Mutator.Abstractions.Intent; +using ModularityKit.Mutator.Abstractions.Policies; +using ModularityKit.Mutator.Abstractions.Results; +using ModularityKit.Mutator.Runtime; + +namespace ModularityKit.Mutator.Benchmarks; + +[MemoryDiagnoser] +[InProcess] +public class MutationEngineBenchmarks +{ + private const string StateId = "benchmark-counter"; + + private IMutationEngine _performanceEngine = null!; + private IMutationEngine _strictEngine = null!; + private CounterState _state = null!; + private IncrementCounterMutation _commitMutation = null!; + private IncrementCounterMutation _simulateMutation = null!; + private IncrementCounterMutation _validateMutation = null!; + private IReadOnlyList> _batchMutations = null!; + + [GlobalSetup] + public void Setup() + { + _performanceEngine = BuildEngine(MutationEngineOptions.Performance, addAllowPolicy: false); + _strictEngine = BuildEngine(MutationEngineOptions.Strict, addAllowPolicy: true); + + _state = new CounterState(42); + _commitMutation = CreateMutation(MutationMode.Commit, "commit-one"); + _simulateMutation = CreateMutation(MutationMode.Simulate, "simulate-one"); + _validateMutation = CreateMutation(MutationMode.Validate, "validate-one"); + _batchMutations = Enumerable.Range(0, 10) + .Select(i => CreateMutation(MutationMode.Commit, $"batch-{i}")) + .ToArray(); + } + + [Benchmark(Baseline = true)] + public async Task Commit_Performance_NoPolicy() + { + var result = await _performanceEngine.ExecuteAsync(_commitMutation, _state); + GC.KeepAlive(result); + } + + [Benchmark] + public async Task Commit_Strict_WithPolicy() + { + var result = await _strictEngine.ExecuteAsync(_commitMutation, _state); + GC.KeepAlive(result); + } + + [Benchmark] + public async Task Simulate_Strict_WithPolicy() + { + var result = await _strictEngine.ExecuteAsync(_simulateMutation, _state); + GC.KeepAlive(result); + } + + [Benchmark] + public async Task ValidateOnly_Strict_WithPolicy() + { + var result = await _strictEngine.ExecuteAsync(_validateMutation, _state); + GC.KeepAlive(result); + } + + [Benchmark] + public async Task Batch_Commit_Performance_NoPolicy() + { + var result = await _performanceEngine.ExecuteBatchAsync(_batchMutations, _state); + GC.KeepAlive(result); + } + + private static IMutationEngine BuildEngine( + MutationEngineOptions options, + bool addAllowPolicy) + { + var services = new ServiceCollection(); + services.AddMutators(options); + var provider = services.BuildServiceProvider(); + var engine = provider.GetRequiredService(); + + if (addAllowPolicy) + engine.RegisterPolicy(new AllowAllCounterPolicy()); + + return engine; + } + + private static IncrementCounterMutation CreateMutation(MutationMode mode, string operationSuffix) + { + var context = MutationContext.System("benchmark") + with + { + StateId = StateId, + Mode = mode, + CorrelationId = $"{StateId}:{operationSuffix}" + }; + + return new IncrementCounterMutation(context); + } + + private sealed record CounterState(int Value); + + private sealed class IncrementCounterMutation(MutationContext context) : IMutation + { + public MutationIntent Intent { get; } = new() + { + OperationName = "IncrementCounter", + Category = "Benchmark", + Description = "Increment the benchmark counter by one", + RiskLevel = MutationRiskLevel.Low, + IsReversible = true + }; + + public MutationContext Context { get; } = context; + + public MutationResult Apply(CounterState state) + { + var next = state with { Value = state.Value + 1 }; + + return MutationResult.Success( + next, + ChangeSet.Single(StateChange.Modified(nameof(CounterState.Value), state.Value, next.Value))); + } + + public ValidationResult Validate(CounterState state) + { + var result = ValidationResult.Success(); + + if (state.Value < 0) + result.AddError(nameof(CounterState.Value), "Counter value must be non-negative."); + + return result; + } + + public MutationResult Simulate(CounterState state) + { + var next = state with { Value = state.Value + 1 }; + + return MutationResult.Success( + next, + ChangeSet.Single(StateChange.Modified(nameof(CounterState.Value), state.Value, next.Value))); + } + } + + private sealed class AllowAllCounterPolicy : IMutationPolicy + { + public string Name => nameof(AllowAllCounterPolicy); + + public int Priority => 0; + + public string? Description => "Always allows the benchmark counter mutation."; + + public PolicyDecision Evaluate(IMutation mutation, CounterState state) + => PolicyDecision.Allow(Name, "Benchmark policy allows all mutations."); + } +} diff --git a/Benchmarks/Program.cs b/Benchmarks/Program.cs new file mode 100644 index 0000000..59830de --- /dev/null +++ b/Benchmarks/Program.cs @@ -0,0 +1,16 @@ +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Running; + +var artifactsPath = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "obj", + "BenchmarkDotNet.Artifacts")); + +var config = ManualConfig.CreateMinimumViable() + .WithOptions(ConfigOptions.DisableLogFile) + .WithArtifactsPath(artifactsPath); + +BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); diff --git a/Benchmarks/README.md b/Benchmarks/README.md new file mode 100644 index 0000000..d7862d6 --- /dev/null +++ b/Benchmarks/README.md @@ -0,0 +1,29 @@ +# Benchmarks + +This folder contains BenchmarkDotNet measurements for `ModularityKit.Mutator`. + +## What is benchmarked + +- commit execution without policy pressure +- strict engine execution with policy checks +- simulate and validate only paths +- batch execution overhead + +## Run + +Build first: + +```bash +dotnet build Benchmarks/ModularityKit.Mutator.Benchmarks.csproj -c Release +``` + +Run a specific benchmark: + +```bash +dotnet Benchmarks/bin/Release/net10.0/ModularityKit.Mutator.Benchmarks.dll --filter '*MutationEngineBenchmarks.Commit_Performance_NoPolicy*' +``` + +## Notes + +- The benchmark harness is configured for the current environment. +- Results are emitted by BenchmarkDotNet when the runner can write artifacts.