diff --git a/.gitattributes b/.gitattributes index b7ad090e8..3be007a89 100644 --- a/.gitattributes +++ b/.gitattributes @@ -114,4 +114,7 @@ # Fix syntax highlighting on GitHub to allow comments -.vscode/*.json linguist-language=JSON-with-Comments \ No newline at end of file +.vscode/*.json linguist-language=JSON-with-Comments + +# Git hooks must use LF line endings (Git Bash on Windows requires it) +.githooks/** text eol=lf diff --git a/.githooks/hooks/Invoke-PreCommit.ps1 b/.githooks/hooks/Invoke-PreCommit.ps1 new file mode 100644 index 000000000..512e87ebd --- /dev/null +++ b/.githooks/hooks/Invoke-PreCommit.ps1 @@ -0,0 +1,103 @@ +[CmdletBinding()] +param() + +$repoRoot = git rev-parse --show-toplevel +. "$PSScriptRoot/../lib/LintHelpers.ps1" + +try { + # C# formatting (auto-fix + re-stage) + $csFiles = Get-StagedFiles -Extensions @('.cs') + if ($csFiles.Count -gt 0) { + $includePaths = ($csFiles | ForEach-Object { "--include", $_ }) + Invoke-AutoFix -Files $csFiles -FixCommand { + dotnet format "$repoRoot/Moq.Analyzers.sln" --verbosity quiet @includePaths 2>&1 | Out-Null + } + $output = dotnet format "$repoRoot/Moq.Analyzers.sln" --verify-no-changes --verbosity quiet @includePaths 2>&1 + if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "dotnet format" + Write-Host $output + } + } + + # Markdown linting (auto-fix + re-stage) + $mdFiles = Get-StagedFiles -Extensions @('.md') + if ($mdFiles.Count -gt 0) { + if (Test-ToolAvailable -Command "markdownlint-cli2" -InstallHint "npm install -g markdownlint-cli2") { + $fullPaths = $mdFiles | ForEach-Object { Join-Path $repoRoot $_ } + Invoke-AutoFix -Files $mdFiles -FixCommand { + markdownlint-cli2 --fix $fullPaths 2>&1 | Out-Null + } + $output = markdownlint-cli2 $fullPaths 2>&1 + if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "markdownlint-cli2" + Write-Host $output + } + } + } + + # YAML linting (lint only) + $yamlFiles = Get-StagedFiles -Extensions @('.yml', '.yaml') + if ($yamlFiles.Count -gt 0) { + if (Test-ToolAvailable -Command "yamllint" -InstallHint "pip install yamllint") { + $fullPaths = $yamlFiles | ForEach-Object { Join-Path $repoRoot $_ } + $output = yamllint -c (Join-Path $repoRoot ".yamllint.yml") $fullPaths 2>&1 + if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "yamllint" + Write-Host $output + } + } + } + + # JSON linting (lint only, exclude .verified.json) + $jsonFiles = Get-StagedFiles -Extensions @('.json') + $jsonFiles = $jsonFiles | Where-Object { $_ -notmatch '\.verified\.json$' } + if ($jsonFiles.Count -gt 0) { + if (Test-ToolAvailable -Command "python3" -InstallHint "https://www.python.org/downloads/") { + foreach ($file in $jsonFiles) { + $fullPath = Join-Path $repoRoot $file + $output = python3 -m json.tool $fullPath 2>&1 + if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "json: $file" + Write-Host $output + } + } + } + } + + # Shell script linting (lint only) + $shellFiles = Get-StagedFiles -Extensions @('.sh', '.bash') + if ($shellFiles.Count -gt 0) { + if (Test-ToolAvailable -Command "shellcheck" -InstallHint "https://github.com/koalaman/shellcheck#installing") { + $fullPaths = $shellFiles | ForEach-Object { Join-Path $repoRoot $_ } + $output = shellcheck $fullPaths 2>&1 + if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "shellcheck" + Write-Host $output + } + } + } + + # GitHub Actions linting (lint only, reuses $yamlFiles from YAML lint step) + $workflowFiles = $yamlFiles | Where-Object { $_ -match '^\.github/workflows/' } + if ($workflowFiles.Count -gt 0) { + if (Test-ToolAvailable -Command "actionlint" -InstallHint "https://github.com/rhysd/actionlint#install") { + $fullPaths = $workflowFiles | ForEach-Object { Join-Path $repoRoot $_ } + $output = actionlint $fullPaths 2>&1 + if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "actionlint" + Write-Host $output + } + } + } +} +catch { + Write-Host $_ -ForegroundColor Red + Write-Host $_.ScriptStackTrace + $script:HookExitCode = 1 +} + +if ($script:HookExitCode -ne 0) { + Write-Host "Bypass: git commit --no-verify" -ForegroundColor Yellow +} + +exit $script:HookExitCode diff --git a/.githooks/hooks/Invoke-PrePush.ps1 b/.githooks/hooks/Invoke-PrePush.ps1 new file mode 100644 index 000000000..a9722f47e --- /dev/null +++ b/.githooks/hooks/Invoke-PrePush.ps1 @@ -0,0 +1,36 @@ +[CmdletBinding()] +param() + +$repoRoot = git rev-parse --show-toplevel +. "$PSScriptRoot/../lib/LintHelpers.ps1" + +# Allow newer .NET runtimes to run tests targeting older TFMs +$env:DOTNET_ROLL_FORWARD = "LatestMajor" + +try { + dotnet build (Join-Path $repoRoot "Moq.Analyzers.sln") /p:PedanticMode=true --verbosity quiet 2>&1 + $buildPassed = $LASTEXITCODE -eq 0 + + if (-not $buildPassed) { + Set-HookFailed -Check "dotnet build" + Write-Host "Build failed. Skipping tests." + } + else { + $runSettings = Join-Path $repoRoot "build/targets/tests/test.runsettings" + dotnet test (Join-Path $repoRoot "Moq.Analyzers.sln") --no-build --settings $runSettings --verbosity quiet 2>&1 + if ($LASTEXITCODE -ne 0) { + Set-HookFailed -Check "dotnet test" + } + } +} +catch { + Write-Host $_ -ForegroundColor Red + Write-Host $_.ScriptStackTrace + $script:HookExitCode = 1 +} + +if ($script:HookExitCode -ne 0) { + Write-Host "Bypass: git push --no-verify" -ForegroundColor Yellow +} + +exit $script:HookExitCode diff --git a/.githooks/lib/LintHelpers.ps1 b/.githooks/lib/LintHelpers.ps1 new file mode 100644 index 000000000..bc0abbd72 --- /dev/null +++ b/.githooks/lib/LintHelpers.ps1 @@ -0,0 +1,93 @@ +# Shared helper functions for git hook scripts. +# Dot-source this file: . "$PSScriptRoot/../lib/LintHelpers.ps1" + +$script:HookExitCode = 0 + +function Test-ToolAvailable { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Command, + + [Parameter()] + [string]$InstallHint + ) + + if (Get-Command $Command -ErrorAction SilentlyContinue) { + return $true + } + + if ($InstallHint) { + Write-Warning "$Command not found. Install: $InstallHint" + } + + return $false +} + +function Get-StagedFiles { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string[]]$Extensions + ) + + $stagedFiles = git diff --cached --name-only --diff-filter=d + if (-not $stagedFiles) { + return @() + } + + $patterns = $Extensions | ForEach-Object { [regex]::Escape($_) + '$' } + $combined = ($patterns -join '|') + + $matched = $stagedFiles | Where-Object { $_ -match $combined } + if (-not $matched) { + return @() + } + + return @($matched) +} + +function Invoke-AutoFix { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [scriptblock]$FixCommand, + + [Parameter(Mandatory)] + [string[]]$Files + ) + + # Detect files with pre-existing unstaged changes (e.g., from partial staging via git add -p). + # These files should NOT be auto-staged to avoid inadvertently including changes the developer + # deliberately excluded from the commit. + $preExistingUnstaged = @(git diff --name-only -- $Files) + + & $FixCommand + + $modified = git diff --name-only -- $Files + if ($modified) { + $safeToStage = @($modified | Where-Object { $_ -notin $preExistingUnstaged }) + $unsafeToStage = @($modified | Where-Object { $_ -in $preExistingUnstaged }) + + if ($safeToStage) { + Write-Host "Auto-fixed: $($safeToStage -join ', ')" + git add $safeToStage + } + + if ($unsafeToStage) { + Write-Warning "Auto-fixed but NOT staged (had pre-existing unstaged changes): $($unsafeToStage -join ', ')" + Write-Warning "Please review and stage these files manually." + } + } +} + +function Set-HookFailed { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Check + ) + + Write-Host "FAIL: $Check" -ForegroundColor Red + $script:HookExitCode = 1 +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..c88561eba --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +REPO_ROOT="$(git rev-parse --show-toplevel)" +command -v pwsh >/dev/null 2>&1 || { echo "pwsh not found. Install PowerShell Core: https://aka.ms/powershell"; exit 1; } +exec pwsh -NoProfile -NonInteractive -File "$REPO_ROOT/.githooks/hooks/Invoke-PreCommit.ps1" diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 000000000..873f5658f --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +REPO_ROOT="$(git rev-parse --show-toplevel)" +command -v pwsh >/dev/null 2>&1 || { echo "pwsh not found. Install PowerShell Core: https://aka.ms/powershell"; exit 1; } +exec pwsh -NoProfile -NonInteractive -File "$REPO_ROOT/.githooks/hooks/Invoke-PrePush.ps1" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88754842c..88ae7846c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,38 @@ This project adheres to the [Contributor Covenant Code of Conduct](CODE-OF-CONDU 5. **Install linting tools** (for local validation): - [yamllint](https://yamllint.readthedocs.io/) (Python): `pip install yamllint` - - [markdownlint-cli](https://github.com/igorshubovych/markdownlint-cli) (Node.js): `npm install -g markdownlint-cli` + - [markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2) (Node.js): `npm install -g markdownlint-cli2` + +6. **Git hooks** auto-configure on first `dotnet build` or `dotnet restore`. + Hooks require [PowerShell Core](https://aka.ms/powershell) (`pwsh`). + + **What each hook checks:** + + | Hook | Check | Mode | Tool | + | ------ | ------- | ------ | ------ | + | pre-commit | C# formatting | Auto-fix + re-stage | `dotnet format` | + | pre-commit | Markdown lint | Auto-fix + re-stage | `markdownlint-cli2` | + | pre-commit | YAML lint | Lint only | `yamllint` | + | pre-commit | JSON validation | Lint only | `python3 -m json.tool` | + | pre-commit | Shell scripts | Lint only | `shellcheck` | + | pre-commit | GitHub Actions | Lint only | `actionlint` | + | pre-push | Build | Fail on error | `dotnet build` | + | pre-push | Tests | Fail on error | `dotnet test` | + + **Optional tool installation:** + + ```bash + npm install -g markdownlint-cli2 # Markdown auto-fix + pip install yamllint # YAML lint + # shellcheck: apt install shellcheck / brew install shellcheck + # actionlint: go install github.com/rhysd/actionlint/cmd/actionlint@latest + ``` + + Missing optional tools are skipped with a warning. C# formatting via `dotnet format` is always available. + + Bypass hooks for WIP commits: `git commit --no-verify` + + Manual setup (if auto-configure fails): `git config core.hooksPath .githooks` ## Universal Agent Success Principles for Project Maintainers diff --git a/Directory.Build.targets b/Directory.Build.targets index 80401b7c1..e477f0287 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -6,4 +6,5 @@ + diff --git a/build/targets/githooks/GitHooks.targets b/build/targets/githooks/GitHooks.targets new file mode 100644 index 000000000..9fbeb0244 --- /dev/null +++ b/build/targets/githooks/GitHooks.targets @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + +