Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions scripts/linting/Validate-HookManifests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,69 @@ $script:HookAllowedCommandProps = @('type', 'command', 'bash', 'powershell', 'wi
$script:HookCommandProps = @('command', 'bash', 'powershell', 'windows', 'linux', 'osx')
$script:HookSchemaRelativePath = 'scripts/linting/schemas/hook-manifest.schema.json'

# Hosts may launch the 'powershell' branch with Windows PowerShell 5.1, which
# fails at parse time on PowerShell 7 syntax. Token kinds are matched instead of
# raw text so operators inside string literals are not flagged.
$script:HookPowerShell7OnlyTokens = [ordered]@{
QuestionMark = "ternary operator '? :'"
QuestionQuestion = "null-coalescing operator '??'"
QuestionQuestionEquals = "null-coalescing assignment '??='"
QuestionDot = "null-conditional member access '?.'"
QuestionLBracket = "null-conditional index '?['"
AndAnd = "pipeline chain operator '&&'"
OrOr = "pipeline chain operator '||'"
}

#endregion Contract

#region Validation Helpers

function Get-HookPowerShellIncompatibility {
<#
.SYNOPSIS
Detects PowerShell 7-only syntax in a hook command string.

.DESCRIPTION
Tokenizes the command and reports constructs that Windows PowerShell 5.1
cannot parse. Hosts may launch the 'powershell' branch with 5.1, where
such a command fails before the hook script runs.

.PARAMETER Command
The hook command string to inspect.

.OUTPUTS
[string[]] Descriptions of PowerShell 7-only constructs. Empty when compatible.

.EXAMPLE
Get-HookPowerShellIncompatibility -Command "& (Join-Path ($a ? 'x' : 'y') 'z.ps1')"
#>
[CmdletBinding()]
[OutputType([string[]])]
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$Command
)

if ([string]::IsNullOrWhiteSpace($Command)) {
return @()
}

$tokens = $null
$parseErrors = $null
$null = [System.Management.Automation.Language.Parser]::ParseInput($Command, [ref]$tokens, [ref]$parseErrors)

$found = [System.Collections.Generic.List[string]]::new()
foreach ($token in $tokens) {
$kind = [string]$token.Kind
if ($script:HookPowerShell7OnlyTokens.Contains($kind) -and -not $found.Contains($script:HookPowerShell7OnlyTokens[$kind])) {
$found.Add($script:HookPowerShell7OnlyTokens[$kind])
}
}

return $found.ToArray()
}

function Test-HookManifest {
<#
.SYNOPSIS
Expand Down Expand Up @@ -182,6 +241,12 @@ function Test-HookManifest {
}
}

if ($entry.ContainsKey('powershell')) {
foreach ($construct in (Get-HookPowerShellIncompatibility -Command ([string]$entry['powershell']))) {
$errors.Add("$label property 'powershell' uses PowerShell 7-only syntax ($construct); Windows PowerShell 5.1 hosts fail to parse it")
}
}

if (-not $hasCommand) {
$errors.Add("$label must define at least one command property ($($script:HookCommandProps -join ', '))")
}
Expand Down
41 changes: 41 additions & 0 deletions scripts/tests/linting/Validate-HookManifests.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,47 @@ Describe 'Test-HookManifest - command entry errors' {
}
}

Describe 'Test-HookManifest - Windows PowerShell 5.1 compatibility' {
It 'Rejects a ternary operator in a powershell command' {
$command = "& (Join-Path ([string]::IsNullOrWhiteSpace(`$env:CLAUDE_PLUGIN_ROOT) ? '.' : `$env:CLAUDE_PLUGIN_ROOT) 'a.ps1')"
$manifest = @{ version = 1; hooks = @{ stop = @(@{ type = 'command'; powershell = $command }) } }

((Test-HookManifest -Manifest $manifest) -join "`n") | Should -BeLike "*property 'powershell' uses PowerShell 7-only syntax (ternary operator '? :')*"
}

It 'Accepts the equivalent if-expression form' {
$command = "& (Join-Path `$(if ([string]::IsNullOrWhiteSpace(`$env:CLAUDE_PLUGIN_ROOT)) { '.' } else { `$env:CLAUDE_PLUGIN_ROOT }) 'a.ps1')"
$manifest = @{ version = 1; hooks = @{ stop = @(@{ type = 'command'; powershell = $command }) } }

Test-HookManifest -Manifest $manifest | Should -BeNullOrEmpty
}

It 'Does not flag PowerShell 7-only operators that appear inside string literals' {
$manifest = @{ version = 1; hooks = @{ stop = @(@{ type = 'command'; powershell = "Write-Output 'a ? b : c && d ?? e'" }) } }

Test-HookManifest -Manifest $manifest | Should -BeNullOrEmpty
}

It 'Does not inspect the bash command for PowerShell syntax' {
$manifest = @{ version = 1; hooks = @{ stop = @(@{ type = 'command'; bash = 'a && b || c' }) } }

Test-HookManifest -Manifest $manifest | Should -BeNullOrEmpty
}

It 'Reports each PowerShell 7-only construct' -ForEach @(
@{ Command = '$a ?? $b'; Expected = "null-coalescing operator '??'" }
@{ Command = '$a ??= $b'; Expected = "null-coalescing assignment '??='" }
@{ Command = '${a}?.Length'; Expected = "null-conditional member access '?.'" }
@{ Command = '${a}?[0]'; Expected = "null-conditional index '?['" }
@{ Command = 'a.exe && b.exe'; Expected = "pipeline chain operator '&&'" }
@{ Command = 'a.exe || b.exe'; Expected = "pipeline chain operator '||'" }
) {
$manifest = @{ version = 1; hooks = @{ stop = @(@{ type = 'command'; powershell = $Command }) } }

((Test-HookManifest -Manifest $manifest) -join "`n").Contains($Expected) | Should -BeTrue
}
}

Describe 'Invoke-HookManifestValidation' {
It 'Succeeds when no hooks directory exists' {
$repoRoot = Join-Path $TestDrive 'repo-no-hooks'
Expand Down
Loading