diff --git a/.devcontainer/scripts/on-create.sh b/.devcontainer/scripts/on-create.sh index 4f25faa1f..a39a62052 100644 --- a/.devcontainer/scripts/on-create.sh +++ b/.devcontainer/scripts/on-create.sh @@ -145,7 +145,7 @@ main() { rm /tmp/uv.tar.gz echo "Syncing Python environments for skills..." - find .github/skills -name pyproject.toml -type f -execdir uv sync \; + find .github/skills -name pyproject.toml -type f -execdir uv sync --locked \; echo "Syncing Python environment for moderation eval..." (cd scripts/evals/moderation && uv sync --locked) diff --git a/docs/architecture/workflows.md b/docs/architecture/workflows.md index facc775ff..9bf167c67 100644 --- a/docs/architecture/workflows.md +++ b/docs/architecture/workflows.md @@ -3,7 +3,7 @@ title: Build Workflows description: GitHub Actions CI/CD pipeline architecture for validation, security, and release automation sidebar_position: 3 author: WilliamBerryiii -ms.date: 2026-08-10 +ms.date: 2026-08-13 ms.topic: overview --- @@ -120,7 +120,7 @@ Individual validation workflows called by orchestration workflows: | `plugin-package.yml` | Plugin packaging | N/A | | `plugin-validation.yml` | Marketplace package metadata and closure | `npm run lint:marketplace` | | `extension-marketplace-publish.yml` | Extension marketplace publishing | N/A | -| `python-lint.yml` | Python linting (ruff) | `npm run lint:py` | +| `python-lint.yml` | Python lint and format checks (ruff) | `npm run lint:py` | | `pytest-tests.yml` | Python unit tests | `npm run test:py` | | `pip-audit.yml` | Python dependency auditing | N/A (pip-audit direct) | | `fuzz-tests.yml` | Python fuzz testing | N/A (pytest direct) | @@ -388,7 +388,7 @@ Workflows invoke validation through npm scripts defined in `package.json`: | `extension:package:prerelease` | `Package-Extension.ps1 -PreRelease` | extension-package.yml | | `plugin:generate` | `Generate-Plugins.ps1` + post-process | plugin-package.yml | | `plugin:validate` | Marketplace package metadata and closure validation | plugin-validation.yml | -| `lint:py` | `ruff check` | python-lint.yml | +| `lint:py` | `ruff check` + `ruff format --check` | python-lint.yml | | `lint:models` | `Validate-ModelReferences.ps1` | model-validation.yml | | `lint:ai-artifacts` | `Validate-PlannerArtifacts.ps1 -FailOnMissing` | ai-artifact-validation.yml | | `lint:permissions` | `Test-WorkflowPermissions.ps1` | workflow-permissions-scan.yml | @@ -427,6 +427,14 @@ Workflows invoke validation through npm scripts defined in `package.json`: | `ci:eval:agent:report` | Runs `ci:eval:agent:matrix` then `ci:eval:agent:dashboard` | CI-owned noninteractive report lane | | `ci:eval:agent:report:dryrun` | Runs `ci:eval:agent:matrix:dryrun` then `ci:eval:agent:dashboard` | CI-owned noninteractive dry-run report lane | +### Python Lint Parity + +`npm run lint:py` runs the same command set as `python-lint.yml`: `ruff check` followed by the non-mutating `ruff format --check`. Execution conditions still differ in three ways: + +* Provisioning: the hosted lane runs `uv sync --locked` itself, while the local runner only verifies that a project committing `uv.lock` already provides that exact ruff version. When it does not, the local run fails before ruff executes and reports `uv sync --locked` as the setup action. Projects without a `uv.lock` fall back to the project `.venv` ruff and then a global ruff, with no version guarantee. +* Project scope: local discovery covers every directory containing a `pyproject.toml`, including projects that `pr-validation.yml` excludes from its per-PR matrix. +* Execution gate: the hosted lane defaults to running only when a pull request changes `.py` or `.pyi` files, while the local lane always scans every discovered project. + ## Related Documentation * [Testing Architecture](testing.md) - PowerShell Pester test infrastructure diff --git a/scripts/linting/Invoke-PythonLint.ps1 b/scripts/linting/Invoke-PythonLint.ps1 index 41a81b5d1..91c965f1f 100644 --- a/scripts/linting/Invoke-PythonLint.ps1 +++ b/scripts/linting/Invoke-PythonLint.ps1 @@ -5,9 +5,12 @@ # Invoke-PythonLint.ps1 # # Purpose: Python lint runner. Discovers Python skills via pyproject.toml and -# invokes ruff against each. Defaults to read-only `ruff check` for CI -# gating. With `-Fix`, applies `ruff check --fix` followed by -# `ruff format` (mutates source; intended for local developer use). +# invokes ruff against each. Defaults to read-only `ruff check` plus +# `ruff format --check` for CI-equivalent gating. With `-Fix`, applies +# `ruff check --fix` followed by `ruff format` (mutates source; +# intended for local developer use). Projects that commit uv.lock must +# already provide a ruff binary matching the locked version; the runner +# verifies it and never synchronizes dependencies. # Author: HVE Core Team #Requires -Version 7.4 @@ -69,20 +72,27 @@ function Invoke-PythonLint { if ($Fix) { Write-Host "`nRunning ruff --fix and ruff format in $skillPath..." -ForegroundColor Cyan } else { - Write-Host "`nRunning ruff in $skillPath..." -ForegroundColor Cyan + Write-Host "`nRunning ruff check and ruff format --check in $skillPath..." -ForegroundColor Cyan } Push-Location $skillPath try { - $ruffCmd = Resolve-RuffCommand -SkillPath $skillPath -GlobalRuffAvailable $globalRuffAvailable + $resolution = Resolve-ProjectRuff -SkillPath $skillPath -GlobalRuffAvailable $globalRuffAvailable + $ruffCmd = $resolution.command if (-not $ruffCmd) { - Write-Host '❌ ruff not available (no .venv and not installed globally)' -ForegroundColor Red + Write-Host "❌ ruff not available: $($resolution.reason)" -ForegroundColor Red $results.success = $false $results.errors += $skillPath continue } + if ($resolution.resolutionMode -eq 'locked') { + Write-Host " using ruff $($resolution.resolvedVersion) pinned by uv.lock" -ForegroundColor Gray + } else { + Write-Host ' using unlocked ruff fallback (no uv.lock in this project)' -ForegroundColor Gray + } + if ($Fix) { # Step 1: autofix lint rules $fixOutput = & $ruffCmd check . --fix 2>&1 @@ -101,6 +111,9 @@ function Invoke-PythonLint { output = $combinedOutput fixExitCode = $fixExit formatExitCode = $formatExit + resolutionMode = $resolution.resolutionMode + lockedVersion = $resolution.lockedVersion + resolvedVersion = $resolution.resolvedVersion } $results.details += $result @@ -123,28 +136,45 @@ function Invoke-PythonLint { Write-Host '✓ Autofix and format complete' -ForegroundColor Green } } else { - $output = & $ruffCmd check . 2>&1 - $exitCode = $LASTEXITCODE + # Both gates always run so a lint failure never hides a formatting failure. + $checkOutput = & $ruffCmd check . 2>&1 + $checkExit = $LASTEXITCODE + + $formatCheckOutput = & $ruffCmd format --check . 2>&1 + $formatCheckExit = $LASTEXITCODE + + $combinedOutput = (@($checkOutput) + @($formatCheckOutput)) | Out-String + $passed = ($checkExit -eq 0 -and $formatCheckExit -eq 0) $result = @{ path = $skillPath - passed = ($exitCode -eq 0) - output = $output | Out-String + passed = $passed + output = $combinedOutput + checkExitCode = $checkExit + formatExitCode = $formatCheckExit + resolutionMode = $resolution.resolutionMode + lockedVersion = $resolution.lockedVersion + resolvedVersion = $resolution.resolvedVersion } $results.details += $result $results.skillsChecked++ - if ($exitCode -ne 0) { - Write-Host "$output" -ForegroundColor Red - Write-Host '❌ Linting issues found' -ForegroundColor Red + if (-not $passed) { + Write-Host "$combinedOutput" -ForegroundColor Red + if ($checkExit -ne 0) { + Write-Host '❌ Linting issues found' -ForegroundColor Red + } + if ($formatCheckExit -ne 0) { + Write-Host "❌ Formatting issues found (run 'npm run lint:py:fix' to apply)" -ForegroundColor Red + } $results.success = $false $results.errors += $skillPath } else { - if ($output) { - Write-Host "$output" + if ($combinedOutput.Trim()) { + Write-Host "$combinedOutput" } - Write-Host '✓ No linting issues' -ForegroundColor Green + Write-Host '✓ No linting or formatting issues' -ForegroundColor Green } } } catch { diff --git a/scripts/linting/Modules/PythonLintHelpers.psm1 b/scripts/linting/Modules/PythonLintHelpers.psm1 index d111ba1ad..9db3e0250 100644 --- a/scripts/linting/Modules/PythonLintHelpers.psm1 +++ b/scripts/linting/Modules/PythonLintHelpers.psm1 @@ -59,7 +59,8 @@ function Resolve-RuffCommand { .DESCRIPTION Prefers the skill's own .venv ruff binary (Linux or Windows path), then falls back to a globally installed ruff. Returns $null when neither is - available. + available. This selector makes no version guarantee; callers that must + honor a committed uv.lock use Resolve-ProjectRuff instead. .PARAMETER SkillPath Skill directory to inspect. @@ -89,6 +90,190 @@ function Resolve-RuffCommand { return $null } +function Get-LockedRuffVersion { + <# + .SYNOPSIS + Reads the ruff version recorded in a uv.lock file. + + .DESCRIPTION + Parses the [[package]] entries of a uv.lock file and returns the version + string of the ruff package. Returns $null when the file is missing, cannot + be read, does not lock ruff, or records ruff without a parsable version. + + .PARAMETER LockPath + Path to the uv.lock file. + + .OUTPUTS + Version string, or $null when no locked ruff version is available. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$LockPath + ) + + if (-not (Test-Path $LockPath)) { return $null } + + try { + $lines = Get-Content -Path $LockPath -ErrorAction Stop + } catch { + return $null + } + + $inRuffPackage = $false + foreach ($line in $lines) { + $trimmed = $line.Trim() + + if ($trimmed -eq '[[package]]') { + $inRuffPackage = $false + continue + } + + # Any other table header ends the current [[package]] block. + if ($trimmed -match '^\[' ) { + $inRuffPackage = $false + continue + } + + if ($trimmed -match '^name\s*=\s*"([^"]+)"$') { + $inRuffPackage = ($Matches[1] -eq 'ruff') + continue + } + + if ($inRuffPackage -and $trimmed -match '^version\s*=\s*"([^"]+)"$') { + return $Matches[1] + } + } + + return $null +} + +function Get-RuffVersionString { + <# + .SYNOPSIS + Reports the version of a ruff binary. + + .DESCRIPTION + Invokes ` --version` and extracts the semantic version from its + output. Returns $null when the binary cannot be executed or its output + contains no recognizable version. + + .PARAMETER RuffCommand + Path to a ruff binary, or 'ruff' for the binary on PATH. + + .OUTPUTS + Version string, or $null when the version cannot be determined. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string]$RuffCommand + ) + + try { + $output = & $RuffCommand --version 2>&1 | Out-String + } catch { + return $null + } + + if ($output -match '(\d+\.\d+\.\d+[^\s]*)') { return $Matches[1] } + return $null +} + +function Resolve-ProjectRuff { + <# + .SYNOPSIS + Resolves the ruff binary a Python project must use, honoring uv.lock. + + .DESCRIPTION + When the project commits a uv.lock that records ruff, only a binary whose + reported version exactly matches that locked version is accepted. Candidates + are evaluated in order: the project's own .venv ruff (Linux then Windows + layout), then a globally installed ruff. No candidate is executed for + linting when none matches, and no dependency synchronization is performed; + the caller reports the required version and the `uv sync --locked` setup + action instead. + + When the project has no uv.lock, resolution falls back to the historical + behavior of preferring the project's .venv ruff over a global ruff, and the + unlocked resolution mode is reported so callers can surface it. + + .PARAMETER SkillPath + Project directory to inspect. + + .PARAMETER GlobalRuffAvailable + Whether ruff is available on PATH. + + .OUTPUTS + Hashtable with command, resolutionMode, lockedVersion, resolvedVersion, + mismatches, and reason keys. command is $null when resolution fails. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string]$SkillPath, + + [Parameter(Mandatory = $true)] + [bool]$GlobalRuffAvailable + ) + + $resolution = @{ + command = $null + resolutionMode = $null + lockedVersion = $null + resolvedVersion = $null + mismatches = @() + reason = $null + } + + $lockPath = Join-Path $SkillPath 'uv.lock' + + if (-not (Test-Path $lockPath)) { + $resolution.resolutionMode = 'unlocked-fallback' + $resolution.command = Resolve-RuffCommand -SkillPath $SkillPath -GlobalRuffAvailable $GlobalRuffAvailable + if (-not $resolution.command) { + $resolution.reason = 'ruff not available (no .venv ruff and no global ruff), and no uv.lock pins a version' + } + return $resolution + } + + $resolution.resolutionMode = 'locked' + $lockedVersion = Get-LockedRuffVersion -LockPath $lockPath + + if (-not $lockedVersion) { + $resolution.reason = 'uv.lock does not record a usable ruff version (ruff is not locked or the lock is malformed)' + return $resolution + } + + $resolution.lockedVersion = $lockedVersion + + $candidates = @( + (Join-Path $SkillPath '.venv/bin/ruff') + (Join-Path $SkillPath '.venv/Scripts/ruff.exe') + ) | Where-Object { Test-Path $_ } + + if ($GlobalRuffAvailable) { $candidates = @($candidates) + 'ruff' } + + foreach ($candidate in $candidates) { + $candidateVersion = Get-RuffVersionString -RuffCommand $candidate + if ($candidateVersion -eq $lockedVersion) { + $resolution.command = $candidate + $resolution.resolvedVersion = $candidateVersion + return $resolution + } + + $reported = if ($candidateVersion) { $candidateVersion } else { 'unknown version' } + $resolution.mismatches += "$candidate ($reported)" + } + + $found = if ($resolution.mismatches) { "found $($resolution.mismatches -join ', ')" } else { 'found no ruff candidate' } + $resolution.reason = "uv.lock requires ruff $lockedVersion but $found. Run 'uv sync --locked' in this project." + return $resolution +} + function Write-PythonLintResults { <# .SYNOPSIS @@ -144,4 +329,4 @@ function Write-PythonLintResults { return $OutputPath } -Export-ModuleMember -Function Get-PythonSkill, Resolve-RuffCommand, Write-PythonLintResults +Export-ModuleMember -Function Get-PythonSkill, Resolve-RuffCommand, Get-LockedRuffVersion, Get-RuffVersionString, Resolve-ProjectRuff, Write-PythonLintResults diff --git a/scripts/linting/README.md b/scripts/linting/README.md index c853be94b..a37c2cbde 100644 --- a/scripts/linting/README.md +++ b/scripts/linting/README.md @@ -2,7 +2,7 @@ title: Linting Scripts description: PowerShell scripts for code quality validation and documentation checks author: HVE Core Team -ms.date: 2026-08-06 +ms.date: 2026-08-13 ms.topic: reference keywords: - powershell @@ -446,16 +446,19 @@ Purpose: Flag documentation files whose `ms.date` exceeds a configurable stalene #### `Invoke-PythonLint.ps1` -Lints Python skills using ruff. +Lints and format-checks Python skills using ruff. Purpose: Enforce Python code quality standards across all Python skills in the repository by dynamically discovering and linting each skill. ##### Features * Discovers Python skills via `pyproject.toml` file search -* Verifies ruff availability before running +* Resolves ruff per project: a project committing `uv.lock` must already provide a ruff binary matching the locked version, preferring its own `.venv` over a global install +* Fails a project before running ruff when no exact-version binary is present, reporting the required version and `uv sync --locked` as the setup action; it never installs or synchronizes dependencies +* Falls back to the project `.venv` ruff and then a global ruff, without a version guarantee, for projects that have no `uv.lock` +* Default mode runs `ruff check` followed by the non-mutating `ruff format --check`, always running both so a lint failure cannot hide a formatting failure * Lints each skill directory independently -* Reports per-skill pass/fail results +* Reports per-skill pass/fail results with separate lint and format exit codes * Supports optional JSON output * `-Fix` mode applies `ruff check --fix` followed by `ruff format`; writes results to `python-lint-fix-results.json` instead of `python-lint-results.json` @@ -463,12 +466,12 @@ Purpose: Enforce Python code quality standards across all Python skills in the r * `-RepoRoot` (string) - Repository root path (default: current directory) * `-OutputPath` (string) - Optional path for JSON results -* `-Fix` (switch) - Applies `ruff check --fix` + `ruff format` to each skill directory; intended for local developer use, not CI gating +* `-Fix` (switch) - Applies `ruff check --fix` + `ruff format` to each skill directory using the same locked ruff version as the default mode; intended for local developer use, not CI gating ##### Usage ```powershell -# Lint all Python skills +# Lint and format-check all Python skills ./scripts/linting/Invoke-PythonLint.ps1 # Lint from a specific repository root diff --git a/scripts/tests/linting/Invoke-PythonLint.Tests.ps1 b/scripts/tests/linting/Invoke-PythonLint.Tests.ps1 index 234c84889..860cb0e4c 100644 --- a/scripts/tests/linting/Invoke-PythonLint.Tests.ps1 +++ b/scripts/tests/linting/Invoke-PythonLint.Tests.ps1 @@ -175,7 +175,8 @@ Describe 'Ruff Lint Execution' -Tag 'Unit' { Context 'Lint passes' { BeforeEach { - Mock ruff { $global:LASTEXITCODE = 0; '' } + $script:RuffCalls = [System.Collections.Generic.List[string]]::new() + Mock ruff { $script:RuffCalls.Add(($args -join ' ')); $global:LASTEXITCODE = 0; '' } } It 'Returns success when ruff reports no issues' { @@ -192,9 +193,28 @@ Describe 'Ruff Lint Execution' -Tag 'Unit' { $result = Invoke-PythonLint -RepoRoot $TestDrive $result.errors | Should -HaveCount 0 } + + It 'Records both zero exit codes in details' { + $result = Invoke-PythonLint -RepoRoot $TestDrive + $result.details[0].checkExitCode | Should -Be 0 + $result.details[0].formatExitCode | Should -Be 0 + } + + It 'Invokes ruff check before the formatter check with exact arguments' { + Invoke-PythonLint -RepoRoot $TestDrive + $script:RuffCalls | Should -HaveCount 2 + $script:RuffCalls[0] | Should -Be 'check .' + $script:RuffCalls[1] | Should -Be 'format --check .' + } + + It 'Never invokes a mutating ruff operation in default mode' { + Invoke-PythonLint -RepoRoot $TestDrive + Should -Invoke ruff -ParameterFilter { $args -contains '--fix' } -Times 0 -Exactly + ($script:RuffCalls | Where-Object { $_ -like 'format*' -and $_ -notlike '*--check*' }) | Should -BeNullOrEmpty + } } - Context 'Lint fails' { + Context 'Lint and formatting both fail' { BeforeEach { Mock ruff { $global:LASTEXITCODE = 1; 'error: E501 line too long' } } @@ -213,6 +233,78 @@ Describe 'Ruff Lint Execution' -Tag 'Unit' { $result = Invoke-PythonLint -RepoRoot $TestDrive $result.details[0].passed | Should -BeFalse } + + It 'Records both nonzero exit codes as a single skill failure' { + $result = Invoke-PythonLint -RepoRoot $TestDrive + $result.details[0].checkExitCode | Should -Be 1 + $result.details[0].formatExitCode | Should -Be 1 + $result.errors | Should -HaveCount 1 + } + } + + Context 'Only lint fails' { + BeforeEach { + Mock ruff { + if ($args[0] -eq 'format') { $global:LASTEXITCODE = 0; '' } + else { $global:LASTEXITCODE = 1; 'error: E501 line too long' } + } + Mock Write-Host {} + } + + It 'Fails with a lint-specific diagnostic while the formatter still runs' { + $result = Invoke-PythonLint -RepoRoot $TestDrive + $result.success | Should -BeFalse + $result.details[0].checkExitCode | Should -Be 1 + $result.details[0].formatExitCode | Should -Be 0 + Should -Invoke ruff -ParameterFilter { $args[0] -eq 'format' } -Times 1 -Exactly + Should -Invoke Write-Host -ParameterFilter { $Object -match 'Linting issues found' } -Times 1 -Exactly + } + } + + Context 'Only formatting fails' { + BeforeEach { + Mock ruff { + if ($args[0] -eq 'format') { $global:LASTEXITCODE = 1; 'Would reformat: sample.py' } + else { $global:LASTEXITCODE = 0; '' } + } + Mock Write-Host {} + } + + It 'Fails with a format-specific diagnostic and no mutating invocation' { + $result = Invoke-PythonLint -RepoRoot $TestDrive + $result.success | Should -BeFalse + $result.details[0].checkExitCode | Should -Be 0 + $result.details[0].formatExitCode | Should -Be 1 + $result.details[0].output | Should -Match 'Would reformat' + Should -Invoke ruff -ParameterFilter { $args -contains '--check' } -Times 1 -Exactly + Should -Invoke Write-Host -ParameterFilter { $Object -match 'Formatting issues found' } -Times 1 -Exactly + } + } + + Context 'Locked ruff resolution fails' { + BeforeEach { + Mock ruff { $global:LASTEXITCODE = 0; '' } + Mock Resolve-ProjectRuff { + @{ + command = $null + resolutionMode = 'locked' + lockedVersion = '0.16.2' + resolvedVersion = $null + mismatches = @('ruff (0.15.4)') + reason = "uv.lock requires ruff 0.16.2 but found ruff (0.15.4). Run 'uv sync --locked' in this project." + } + } + Mock Write-Host {} + } + + It 'Fails the skill without invoking ruff and reports the setup action' { + $result = Invoke-PythonLint -RepoRoot $TestDrive + $result.success | Should -BeFalse + $result.skillsChecked | Should -Be 0 + $result.errors | Should -Contain $script:SkillDir + Should -Invoke ruff -Times 0 -Exactly + Should -Invoke Write-Host -ParameterFilter { $Object -match 'uv sync --locked' } -Times 1 -Exactly + } } Context 'Ruff throws exception' { @@ -233,7 +325,8 @@ Describe 'Ruff Lint Execution' -Tag 'Unit' { Context 'Fix mode with -Fix switch' { BeforeEach { - Mock ruff { $global:LASTEXITCODE = 0; '' } + $script:RuffCalls = [System.Collections.Generic.List[string]]::new() + Mock ruff { $script:RuffCalls.Add(($args -join ' ')); $global:LASTEXITCODE = 0; '' } } It 'Invokes ruff with --fix argument' { @@ -251,6 +344,13 @@ Describe 'Ruff Lint Execution' -Tag 'Unit' { Should -Invoke ruff -ParameterFilter { $args -contains 'format' } } + It 'Preserves the exact fix command sequence' { + Invoke-PythonLint -Fix -RepoRoot $TestDrive + $script:RuffCalls | Should -HaveCount 2 + $script:RuffCalls[0] | Should -Be 'check . --fix' + $script:RuffCalls[1] | Should -Be 'format .' + } + It 'Records formatExitCode in skill detail' { $result = Invoke-PythonLint -Fix -RepoRoot $TestDrive $result.details[0].formatExitCode | Should -Be 0 @@ -287,6 +387,15 @@ Describe 'Output Persistence' -Tag 'Unit' { Invoke-PythonLint -RepoRoot $TestDrive -OutputPath $outputPath { Get-Content $outputPath -Raw | ConvertFrom-Json } | Should -Not -Throw } + + It 'Serializes per-phase exit codes and the resolution mode' { + $outputPath = Join-Path $TestDrive 'lint-results3.json' + Invoke-PythonLint -RepoRoot $TestDrive -OutputPath $outputPath + $serialized = Get-Content $outputPath -Raw | ConvertFrom-Json + $serialized.details[0].checkExitCode | Should -Be 0 + $serialized.details[0].formatExitCode | Should -Be 0 + $serialized.details[0].resolutionMode | Should -Be 'unlocked-fallback' + } } Context 'OutputPath not specified' { diff --git a/scripts/tests/linting/PythonLintHelpers.Tests.ps1 b/scripts/tests/linting/PythonLintHelpers.Tests.ps1 index a0e17d71f..d0b51631a 100644 --- a/scripts/tests/linting/PythonLintHelpers.Tests.ps1 +++ b/scripts/tests/linting/PythonLintHelpers.Tests.ps1 @@ -8,6 +8,8 @@ Covers shared helper functions used by Invoke-PythonLint(Fix).ps1: - Get-PythonSkill discovers pyproject.toml directories. - Resolve-RuffCommand selects venv ruff, global ruff, or $null. + - Get-LockedRuffVersion reads the ruff version pinned by uv.lock. + - Resolve-ProjectRuff enforces exact locked versions and unlocked fallback. - Write-PythonLintResults creates parent directory and writes JSON. #> @@ -160,6 +162,235 @@ Describe 'Resolve-RuffCommand' -Tag 'Unit' { } } +Describe 'Get-LockedRuffVersion' -Tag 'Unit' { + Context 'When uv.lock records a ruff package' { + It 'Returns the locked ruff version' { + $project = Join-Path $TestDrive 'lock-valid' + New-Item -ItemType Directory -Path $project -Force | Out-Null + Set-Content -Path (Join-Path $project 'uv.lock') -Value @' +[[package]] +name = "pytest" +version = "8.4.1" + +[[package]] +name = "ruff" +version = "0.16.2" +source = { registry = "https://pypi.org/simple" } +'@ + + Get-LockedRuffVersion -LockPath (Join-Path $project 'uv.lock') | Should -Be '0.16.2' + } + } + + Context 'When uv.lock does not lock ruff' { + It 'Returns $null' { + $project = Join-Path $TestDrive 'lock-no-ruff' + New-Item -ItemType Directory -Path $project -Force | Out-Null + Set-Content -Path (Join-Path $project 'uv.lock') -Value @' +[[package]] +name = "pytest" +version = "8.4.1" +'@ + + Get-LockedRuffVersion -LockPath (Join-Path $project 'uv.lock') | Should -BeNullOrEmpty + } + } + + Context 'When uv.lock is malformed' { + It 'Returns $null when the ruff entry has no version' { + $project = Join-Path $TestDrive 'lock-malformed' + New-Item -ItemType Directory -Path $project -Force | Out-Null + Set-Content -Path (Join-Path $project 'uv.lock') -Value @' +[[package]] +name = "ruff" +'@ + + Get-LockedRuffVersion -LockPath (Join-Path $project 'uv.lock') | Should -BeNullOrEmpty + } + + It 'Does not attribute a later package version to ruff' { + $project = Join-Path $TestDrive 'lock-version-bleed' + New-Item -ItemType Directory -Path $project -Force | Out-Null + Set-Content -Path (Join-Path $project 'uv.lock') -Value @' +[[package]] +name = "ruff" + +[[package]] +name = "pytest" +version = "8.4.1" +'@ + + Get-LockedRuffVersion -LockPath (Join-Path $project 'uv.lock') | Should -BeNullOrEmpty + } + } + + Context 'When uv.lock is missing' { + It 'Returns $null' { + $project = Join-Path $TestDrive 'lock-missing' + New-Item -ItemType Directory -Path $project -Force | Out-Null + + Get-LockedRuffVersion -LockPath (Join-Path $project 'uv.lock') | Should -BeNullOrEmpty + } + } +} + +Describe 'Resolve-ProjectRuff' -Tag 'Unit' { + BeforeAll { + function New-LockedProject { + param( + [string]$Name, + [string]$Version = '0.16.2', + [switch]$LinuxVenv, + [switch]$WindowsVenv + ) + + $project = Join-Path $TestDrive $Name + New-Item -ItemType Directory -Path $project -Force | Out-Null + Set-Content -Path (Join-Path $project 'uv.lock') -Value @" +[[package]] +name = "ruff" +version = "$Version" +"@ + + if ($LinuxVenv) { + $bin = Join-Path $project '.venv/bin' + New-Item -ItemType Directory -Path $bin -Force | Out-Null + Set-Content -Path (Join-Path $bin 'ruff') -Value '' + } + + if ($WindowsVenv) { + $scripts = Join-Path $project '.venv/Scripts' + New-Item -ItemType Directory -Path $scripts -Force | Out-Null + Set-Content -Path (Join-Path $scripts 'ruff.exe') -Value '' + } + + return $project + } + } + + Context 'When the project venv ruff matches the locked version' { + It 'Selects the Linux venv binary and reports locked resolution' { + $project = New-LockedProject -Name 'locked-linux-match' -LinuxVenv + Mock Get-RuffVersionString { '0.16.2' } -ModuleName PythonLintHelpers + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $false + + $resolution.command | Should -Match 'bin[\\/]ruff$' + $resolution.resolutionMode | Should -Be 'locked' + $resolution.lockedVersion | Should -Be '0.16.2' + $resolution.resolvedVersion | Should -Be '0.16.2' + } + + It 'Selects the Windows venv binary when it is the matching candidate' { + $project = New-LockedProject -Name 'locked-windows-match' -WindowsVenv + Mock Get-RuffVersionString { '0.16.2' } -ModuleName PythonLintHelpers + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $false + + $resolution.command | Should -Match 'Scripts[\\/]ruff\.exe$' + $resolution.resolutionMode | Should -Be 'locked' + } + } + + Context 'When the project venv ruff does not match the locked version' { + It 'Falls through to an exactly matching global ruff' { + $project = New-LockedProject -Name 'locked-global-fallback' -LinuxVenv + Mock Get-RuffVersionString { + if ($RuffCommand -eq 'ruff') { '0.16.2' } else { '0.15.4' } + } -ModuleName PythonLintHelpers + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $true + + $resolution.command | Should -Be 'ruff' + $resolution.resolvedVersion | Should -Be '0.16.2' + ($resolution.mismatches -join ';') | Should -Match '0\.15\.4' + } + + It 'Rejects a mismatched global ruff and reports the setup action' { + $project = New-LockedProject -Name 'locked-global-mismatch' -LinuxVenv + Mock Get-RuffVersionString { '0.15.4' } -ModuleName PythonLintHelpers + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $true + + $resolution.command | Should -BeNullOrEmpty + $resolution.lockedVersion | Should -Be '0.16.2' + $resolution.reason | Should -Match 'requires ruff 0\.16\.2' + $resolution.reason | Should -Match "uv sync --locked" + } + } + + Context 'When no ruff candidate exists for a locked project' { + It 'Fails with the required version and setup action' { + $project = New-LockedProject -Name 'locked-no-candidate' + Mock Get-RuffVersionString { $null } -ModuleName PythonLintHelpers + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $false + + $resolution.command | Should -BeNullOrEmpty + $resolution.resolutionMode | Should -Be 'locked' + $resolution.reason | Should -Match 'found no ruff candidate' + } + } + + Context 'When uv.lock does not record ruff' { + It 'Fails without executing any candidate' { + $project = Join-Path $TestDrive 'locked-without-ruff' + New-Item -ItemType Directory -Path $project -Force | Out-Null + Set-Content -Path (Join-Path $project 'uv.lock') -Value @' +[[package]] +name = "pytest" +version = "8.4.1" +'@ + Mock Get-RuffVersionString { '0.16.2' } -ModuleName PythonLintHelpers + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $true + + $resolution.command | Should -BeNullOrEmpty + $resolution.reason | Should -Match 'does not record a usable ruff version' + Should -Invoke Get-RuffVersionString -ModuleName PythonLintHelpers -Times 0 -Exactly + } + } + + Context 'When the project has no uv.lock' { + It 'Prefers the project venv ruff and reports unlocked fallback' { + $project = Join-Path $TestDrive 'unlocked-with-venv' + $bin = Join-Path $project '.venv/bin' + New-Item -ItemType Directory -Path $bin -Force | Out-Null + Set-Content -Path (Join-Path $bin 'ruff') -Value '' + Mock Get-RuffVersionString { '0.15.4' } -ModuleName PythonLintHelpers + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $true + + $resolution.command | Should -Match 'bin[\\/]ruff$' + $resolution.resolutionMode | Should -Be 'unlocked-fallback' + $resolution.lockedVersion | Should -BeNullOrEmpty + Should -Invoke Get-RuffVersionString -ModuleName PythonLintHelpers -Times 0 -Exactly + } + + It 'Falls back to global ruff without a version claim' { + $project = Join-Path $TestDrive 'unlocked-global' + New-Item -ItemType Directory -Path $project -Force | Out-Null + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $true + + $resolution.command | Should -Be 'ruff' + $resolution.resolutionMode | Should -Be 'unlocked-fallback' + $resolution.resolvedVersion | Should -BeNullOrEmpty + } + + It 'Fails when no ruff is available anywhere' { + $project = Join-Path $TestDrive 'unlocked-no-ruff' + New-Item -ItemType Directory -Path $project -Force | Out-Null + + $resolution = Resolve-ProjectRuff -SkillPath $project -GlobalRuffAvailable $false + + $resolution.command | Should -BeNullOrEmpty + $resolution.resolutionMode | Should -Be 'unlocked-fallback' + $resolution.reason | Should -Match 'ruff not available' + } + } +} + Describe 'Write-PythonLintResults' -Tag 'Unit' { Context 'When OutputPath is not provided' { It 'Writes JSON to logs/ under RepoRoot' {