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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+