Skip to content
6 changes: 6 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ insert_final_newline = false
tab_width = unset
trim_trailing_whitespace = false

# PowerShell scripts - LF required for cross-platform compatibility (ADR-010)
[*.{ps1,psm1,psd1}]
end_of_line = lf
indent_size = 4
insert_final_newline = true

# C# files
[*.cs]

Expand Down
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@
*.ksh text eol=lf
*.sh text eol=lf
*.zsh text eol=lf
# PowerShell scripts must use LF; PowerShell 7+ is cross-platform and
# CRLF causes parse failures in block comments (<# ... #>). See #1081.
*.ps1 text eol=lf
*.psm1 text eol=lf
*.psd1 text eol=lf
# Likewise, force cmd and batch scripts to always use crlf
*.bat text eol=crlf
*.cmd text eol=crlf
Expand Down
10 changes: 5 additions & 5 deletions .githooks/hooks/Invoke-PreCommit.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ try {
$csFiles = Get-StagedFiles -Extensions @('.cs')
if ($csFiles.Count -gt 0) {
$slnPath = Join-Path $repoRoot "Moq.Analyzers.sln"
$includePaths = ($csFiles | ForEach-Object { "--include", (Join-Path $repoRoot $_) })
$includePaths = @($csFiles | ForEach-Object { "--include", (Join-Path $repoRoot $_) })
Invoke-AutoFix -Files $csFiles -FixCommand {
dotnet format $slnPath --verbosity quiet @includePaths 2>&1 | Out-Null
}
Expand All @@ -31,7 +31,7 @@ try {
$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 $_ }
$fullPaths = @($mdFiles | ForEach-Object { Join-Path $repoRoot $_ })
Invoke-AutoFix -Files $mdFiles -FixCommand {
& markdownlint-cli2 --fix @fullPaths 2>&1 | Out-Null
}
Expand All @@ -47,7 +47,7 @@ try {
$yamlFiles = Get-StagedFiles -Extensions @('.yml', '.yaml')
if ($yamlFiles.Count -gt 0) {
if (Test-ToolAvailable -Command "yamllint" -InstallHint "pipx install yamllint") {
$fullPaths = $yamlFiles | ForEach-Object { Join-Path $repoRoot $_ }
$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"
Expand All @@ -59,7 +59,7 @@ try {
$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 $_ }
$fullPaths = @($workflowFiles | ForEach-Object { Join-Path $repoRoot $_ })
$output = & actionlint @fullPaths 2>&1
if ($LASTEXITCODE -ne 0) {
Set-HookFailed -Check "actionlint"
Expand Down Expand Up @@ -94,7 +94,7 @@ try {
$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 $_ }
$fullPaths = @($shellFiles | ForEach-Object { Join-Path $repoRoot $_ })
$output = & shellcheck @fullPaths 2>&1
if ($LASTEXITCODE -ne 0) {
Set-HookFailed -Check "shellcheck"
Expand Down
1 change: 1 addition & 0 deletions .serena/memories/architecture-decision-records.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Location: `docs/architecture/ADR-NNN-subject-in-kebab-case.md`
| ADR-007 | Prefer RegisterOperationAction Over RegisterSyntaxNodeAction | Accepted |
| ADR-008 | BenchmarkDotNet and PerfDiff for Performance Regression Detection | Accepted |
| ADR-009 | xUnit with Roslyn Test Infrastructure | Accepted |
| ADR-010 | Use eol=lf for PowerShell Files in .gitattributes | Accepted |

## ADR Format

Expand Down
74 changes: 74 additions & 0 deletions docs/architecture/ADR-010-eol-lf-for-powershell-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: "ADR-010: Use eol=lf for PowerShell Files in .gitattributes"
status: "Accepted"
date: "2026-03-15"
Comment thread
rjmurillo marked this conversation as resolved.
authors: "moq.analyzers maintainers"
tags: ["architecture", "decision", "git", "powershell", "line-endings"]
supersedes: ""
superseded_by: ""
---

## Status

Accepted

## Context

The repository uses PowerShell scripts in Git hooks and build automation (e.g., `build/scripts/todo-scanner/Scan-TodoComments.ps1`). Issue #1081 reported that the pre-push hook fails with a PowerShell parse error. The root cause: CRLF line endings cause `<# ... #>` block comment terminators to include a trailing `\r`, which PowerShell cannot parse when invoked from Git Bash or Unix shells.

The `.gitattributes` file already enforces `eol=lf` for `.githooks/**`. However, PowerShell scripts under `build/scripts/` were not covered by any explicit rule. They inherited `text=auto`, which produces CRLF on Windows checkouts. All pushes were blocked unless contributors bypassed the hook with `--no-verify`.

PowerShell 7+ reads both CRLF and LF correctly on all platforms (PowerShell/PowerShell Discussion #16569). The only known scenario where LF causes problems is Authenticode-signed scripts (PowerShell/PowerShell#3361, PowerShell/PowerShell#25246). This repository does not use Authenticode signing.

This repository targets PowerShell 7+ (pwsh) for all script execution. Windows PowerShell 5.1 is not a supported runtime for repository scripts.

## Decision

Set `*.ps1 text eol=lf`, `*.psm1 text eol=lf`, and `*.psd1 text eol=lf` in `.gitattributes`. This applies globally to all PowerShell files in the repository, regardless of directory.

The global rule was chosen over path-specific rules (e.g., `build/scripts/**`) because path-specific rules are fragile. New PowerShell files added in other directories would silently inherit `text=auto` and could reintroduce the same bug.

## Consequences

### Positive

- **POS-001**: PowerShell scripts parse correctly on Unix, macOS, and Windows. Block comment terminators no longer include a trailing `\r`.
- **POS-002**: Pre-push hooks and build scripts execute without parse errors from Git Bash on Windows.
- **POS-003**: Consistent with the existing `.githooks/** text eol=lf` precedent in this repository.
- **POS-004**: New PowerShell files added anywhere in the repo automatically inherit the correct line ending. No per-directory maintenance required.

### Negative

- **NEG-001**: If Authenticode script signing is ever required, this decision must be revisited. LF line endings break signature verification on Windows PowerShell 5.1.
- **NEG-002**: Contributors must run `git add --renormalize . && git checkout .` after pulling the fix to update their working tree. Without this step, locally cached CRLF copies persist until the file is next checked out.
- **NEG-003**: Deviates from the conventional guidance that recommends CRLF for PowerShell files. Contributors familiar with that convention may question this choice.

## Alternatives Considered

### eol=crlf (Conventional Windows Default)

- **ALT-001**: Set `*.ps1 text eol=crlf` to match conventional gitattributes guidance for PowerShell files. PowerShell is historically Windows-native, and most template repositories use CRLF. **Rejected**: CRLF causes parse failures when scripts are invoked from Git Bash or Unix shells. The `\r` appended to `#>` breaks block comment parsing. This is the exact bug reported in issue #1081.

### Path-Specific Rules Only

- **ALT-002**: Add `build/scripts/** text eol=lf` to target only the known problematic directory, leaving other PowerShell files at `text=auto`. **Rejected**: Fragile. New PowerShell files in other directories would silently inherit `text=auto` and could reintroduce the bug. Requires ongoing maintenance as the directory structure evolves.

### Do Nothing

- **ALT-003**: Leave `.gitattributes` unchanged and document the workaround (use `--no-verify` or manually convert line endings). **Rejected**: Blocks all contributors from pushing without a workaround. Undermines the purpose of Git hooks. Does not fix the root cause.

## Implementation Notes

- **IMP-001**: Add the three glob rules (`*.ps1`, `*.psm1`, `*.psd1`) to `.gitattributes` in the file-type section alongside existing extension-based rules.
- **IMP-001a**: Add a corresponding `[*.{ps1,psm1,psd1}]` section to `.editorconfig` with `end_of_line = lf` so editors enforce LF on save, preventing CRLF from being introduced during editing.
- **IMP-002**: After pulling the merged fix, contributors should run `git add --renormalize . && git checkout .` to apply the new line ending rules. Alternatively, a fresh clone applies the rules automatically. Document this in the PR description.
- **IMP-003**: Verify the fix by running the pre-push hook on Windows (Git Bash), macOS, and Linux. The `Scan-TodoComments.ps1` script must parse without errors on all three platforms.

## References

- **REF-001**: GitHub Issue #1081 -- Pre-push hook fails with PowerShell parse error in `Scan-TodoComments.ps1`
- **REF-002**: PowerShell/PowerShell Discussion #16569 -- PowerShell 7+ handles LF on all platforms
- **REF-003**: PowerShell/PowerShell#3361 -- LF breaks Authenticode signature verification
- **REF-004**: PowerShell/PowerShell#25246 -- Additional Authenticode/LF interaction
- **REF-005**: Scott Hanselman, "Carriage Returns and Line Feeds Will Ultimately Bite You" -- cross-platform eol guidance
- **REF-006**: Existing `.gitattributes` rule: `.githooks/** text eol=lf`
Loading