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/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. 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())