private/WayforgeGateHelpers.ps1

# Internal helpers for the Wayforge gate engine (Invoke-WayforgeGate).
# Kept in one file; each function is dot-sourced by PSWayforge.psm1.

function Get-WayforgeField {
    <#
    .SYNOPSIS
        Reads a field from a dictionary (powershell-yaml / minimal parser) or a
        PSCustomObject, returning a default when absent.
    #>

    param($Object, [string] $Name, $Default = $null)

    if ($null -eq $Object) { return $Default }
    if ($Object -is [System.Collections.IDictionary]) {
        if ($Object.Contains($Name)) { return $Object[$Name] }
        return $Default
    }
    $prop = $Object.PSObject.Properties[$Name]
    if ($prop) { return $prop.Value }
    return $Default
}

function Get-WayforgeGateSet {
    <#
    .SYNOPSIS
        Loads the gates and scopes from the selected workflow definition(s).
    #>

    param([string] $Root, [string] $WorkflowName)

    $definitionsDir = Join-Path $Root '.workflow/definitions'
    $names = if ($WorkflowName) {
        , $WorkflowName
    }
    elseif (Test-Path $definitionsDir) {
        Get-ChildItem -Path $definitionsDir -Filter '*.yaml' | ForEach-Object { $_.BaseName }
    }
    else {
        @()
    }

    $gates  = [System.Collections.Generic.List[object]]::new()
    $scopes = @{}
    foreach ($name in $names) {
        $wf = Get-WayforgeWorkflow -Name $name -ProjectPath $Root

        $wfScopes = Get-WayforgeField $wf 'scopes'
        if ($wfScopes -is [System.Collections.IDictionary]) {
            foreach ($key in $wfScopes.Keys) { $scopes[$key] = @(Get-WayforgeField $wfScopes $key) }
        }

        foreach ($gate in @(Get-WayforgeField $wf 'gates')) {
            if ($null -ne $gate) { $gates.Add($gate) }
        }
    }

    return [PSCustomObject]@{ Gates = $gates.ToArray(); Scopes = $scopes }
}

function Resolve-WayforgeGitRoot {
    <#
    .SYNOPSIS
        Returns the git top-level for a path, falling back to the resolved path.
    #>

    param([string] $Path)

    $p = (Resolve-Path -Path $Path -ErrorAction SilentlyContinue).Path
    if (-not $p) { $p = $Path }

    $top = & git -C $p rev-parse --show-toplevel 2>$null
    if ($LASTEXITCODE -eq 0 -and $top) { return $top.Trim() }
    return $p
}

function Get-WayforgeDefaultBase {
    <#
    .SYNOPSIS
        Resolves the CI base ref: the remote's default branch, else origin/main
        or origin/master if present. Callers fall back to the full tree if the
        returned ref is unreachable.
    #>

    param([string] $Root)

    $ref = & git -C $Root symbolic-ref --quiet refs/remotes/origin/HEAD 2>$null
    if ($LASTEXITCODE -eq 0 -and $ref) {
        return ($ref -replace '^refs/remotes/', '').Trim()
    }
    foreach ($candidate in 'origin/main', 'origin/master') {
        & git -C $Root rev-parse --verify --quiet $candidate > $null 2>&1
        if ($LASTEXITCODE -eq 0) { return $candidate }
    }
    return 'origin/main'
}

function Convert-WayforgeGlobToRegex {
    <#
    .SYNOPSIS
        Converts a path glob (supporting **, *, ?) into an anchored regex.
    .DESCRIPTION
        `**/` matches any number of leading path segments (including zero),
        `**` matches across separators, `*` matches within a segment, `?` one char.
    #>

    param([string] $Glob)

    $g = $Glob -replace '\\', '/'
    $e = [regex]::Escape($g)
    $e = $e -replace '\\\*\\\*/', '(?:.*/)?'   # **/ -> optional path prefix
    $e = $e -replace '\\\*\\\*', '.*'          # ** -> anything
    $e = $e -replace '\\\*', '[^/]*'           # * -> within a segment
    $e = $e -replace '\\\?', '[^/]'            # ? -> one non-separator char
    return '^' + $e + '$'
}

function Test-WayforgeGlobMatch {
    param([string] $Path, [string] $Glob)

    if (-not $Path -or -not $Glob) { return $false }
    $rx = Convert-WayforgeGlobToRegex -Glob $Glob
    return [regex]::IsMatch(($Path -replace '\\', '/'), $rx,
        [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
}

function Test-WayforgeChangeCondition {
    <#
    .SYNOPSIS
        Evaluates a gate `when` predicate against a changeset.
    .DESCRIPTION
        Supports: always, changes_touch(<scope>), changes_only(<scope>).
        Unknown predicates are treated as applicable (fail-safe for block gates).
    #>

    param([string] $Condition, [string[]] $ChangeSet, $Scopes)

    if ([string]::IsNullOrWhiteSpace($Condition) -or $Condition -eq 'always') {
        return $true
    }

    if ($Condition -match '^\s*changes_touch\(\s*(\w+)\s*\)\s*$') {
        $globs = @(if ($Scopes -is [System.Collections.IDictionary] -and $Scopes.Contains($matches[1])) { $Scopes[$matches[1]] })
        foreach ($f in $ChangeSet) {
            foreach ($glob in $globs) {
                if (Test-WayforgeGlobMatch -Path $f -Glob $glob) { return $true }
            }
        }
        return $false
    }

    if ($Condition -match '^\s*changes_only\(\s*(\w+)\s*\)\s*$') {
        if (-not $ChangeSet -or @($ChangeSet).Count -eq 0) { return $false }
        $globs = @(if ($Scopes -is [System.Collections.IDictionary] -and $Scopes.Contains($matches[1])) { $Scopes[$matches[1]] })
        foreach ($f in $ChangeSet) {
            $matched = $false
            foreach ($glob in $globs) {
                if (Test-WayforgeGlobMatch -Path $f -Glob $glob) { $matched = $true; break }
            }
            if (-not $matched) { return $false }
        }
        return $true
    }

    Write-Warning "Unknown gate predicate '$Condition'; treating as applicable."
    return $true
}

function Get-WayforgeChangeSet {
    <#
    .SYNOPSIS
        Derives the changeset (relative, forward-slash paths) for a stage.
    #>

    param([string] $Stage, [string] $Root, [string] $EventJson)

    switch ($Stage) {
        'pre-commit' {
            $out = & git -C $Root diff --cached --name-only --diff-filter=ACMR 2>$null
            return @($out | Where-Object { $_ })
        }
        'pre-push' {
            # The calling shim reads stdin once and passes it via -EventJson
            # (git's pre-push sends "<lref> <lsha> <rref> <rsha>" lines, not JSON).
            $files = [System.Collections.Generic.List[string]]::new()
            $emptyTree = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'   # git's canonical empty tree
            $stdin = if ($EventJson) { $EventJson } else { '' }
            foreach ($line in ($stdin -split "`r?`n")) {
                $parts = $line -split '\s+'
                if ($parts.Count -ge 4) {
                    $localSha = $parts[1]; $remoteSha = $parts[3]
                    if ($remoteSha -match '^0+$') {
                        # New remote branch: diff against the merge-base with the
                        # default branch, else the empty tree (all files). Never
                        # diff the tip alone, which compares against the (clean)
                        # working tree and yields no changes -> fail-open.
                        $base = Get-WayforgeDefaultBase -Root $Root
                        $mergeBase = (& git -C $Root merge-base $base $localSha 2>$null)
                        $from = if ($LASTEXITCODE -eq 0 -and $mergeBase) { $mergeBase.Trim() } else { $emptyTree }
                        $out = & git -C $Root diff --name-only $from $localSha 2>$null
                    }
                    else {
                        $out = & git -C $Root diff --name-only "$remoteSha..$localSha" 2>$null
                    }
                    $out | Where-Object { $_ } | ForEach-Object { $files.Add($_) }
                }
            }
            return @($files | Select-Object -Unique)
        }
        'ci' {
            $base = if ($env:WAYFORGE_BASE_REF) { $env:WAYFORGE_BASE_REF } else { Get-WayforgeDefaultBase -Root $Root }
            $out = & git -C $Root diff --name-only "$base...HEAD" 2>$null
            if ($LASTEXITCODE -ne 0) {
                # Base unreachable: fall back to the full tracked tree so gates
                # still evaluate (fail-safe) instead of an empty diff (fail-open).
                $out = & git -C $Root ls-files 2>$null
            }
            return @($out | Where-Object { $_ })
        }
        default {
            # pre-tool / stop paths come from Get-WayforgeActionContext, which
            # fails closed on a malformed payload rather than swallowing it.
            return @()
        }
    }
}

function Get-WayforgeActionContext {
    <#
    .SYNOPSIS
        Parses a harness event payload into { Paths, ToolName, Command } for the
        pre-tool / stop stages.
    .DESCRIPTION
        This is the single event-normalization boundary before the gate engine.
        Harnesses use different field names for the same concepts, so it accepts
        all known variants: the tool name (tool_name / toolName), the arguments
        container (tool_input / toolArgs / tool_args / input), the file path
        (file_path / filePath / path, plus multi-file arrays), and the command
        (in the args container or top-level, as Cursor's beforeShellExecution
        sends it). An absent payload yields an empty context; a non-empty but
        malformed payload throws, so the engine fails closed (deny).
    #>

    param([string] $EventJson, [string] $Root)

    $context = [PSCustomObject]@{ Paths = @(); ToolName = $null; Command = $null }
    if ([string]::IsNullOrWhiteSpace($EventJson)) { return $context }

    $ev = $EventJson | ConvertFrom-Json          # malformed -> throw -> fail-closed

    $context.ToolName = if ($ev.tool_name) { $ev.tool_name } elseif ($ev.toolName) { $ev.toolName } else { $null }

    # The tool's arguments live under different keys across harnesses.
    $toolArgs = $null
    foreach ($key in 'tool_input', 'toolArgs', 'tool_args', 'input') {
        $value = Get-WayforgeField $ev $key
        if ($null -ne $value) { $toolArgs = $value; break }
    }

    $rawPaths = [System.Collections.Generic.List[string]]::new()
    if ($toolArgs) {
        $context.Command = Get-WayforgeField $toolArgs 'command'
        foreach ($key in 'file_path', 'filePath', 'path', 'filepath') {
            $value = Get-WayforgeField $toolArgs $key
            if ($value) { $rawPaths.Add([string]$value) | Out-Null }
        }
        foreach ($key in 'file_paths', 'filePaths', 'paths', 'files') {
            $value = Get-WayforgeField $toolArgs $key
            if ($value) { foreach ($item in @($value)) { if ($item) { $rawPaths.Add([string]$item) | Out-Null } } }
        }
    }

    # Cursor's beforeShellExecution puts the command at the top level (no args container).
    if (-not $context.Command -and $ev.command) {
        $context.Command = $ev.command
        if (-not $context.ToolName) { $context.ToolName = 'Bash' }
    }

    if ($rawPaths.Count -gt 0) {
        $rootNorm = ($Root -replace '\\', '/').TrimEnd('/') + '/'
        $context.Paths = @($rawPaths | ForEach-Object {
                $rel = $_ -replace '\\', '/'
                if ($rel.StartsWith($rootNorm, [StringComparison]::OrdinalIgnoreCase)) { $rel = $rel.Substring($rootNorm.Length) }
                $rel
            })
    }

    return $context
}

function Get-WayforgeStages {
    <#
    .SYNOPSIS
        Returns a hashtable set of the stages referenced by any gate's `on` list.
    #>

    param($Gates)

    $stages = @{}
    foreach ($gate in $Gates) {
        foreach ($stage in @(Get-WayforgeField $gate 'on')) { if ($stage) { $stages[$stage] = $true } }
    }
    return $stages
}

function Get-WayforgeGateCommand {
    <#
    .SYNOPSIS
        Builds the shell command a harness hook runs to invoke the shared gate
        shim for a stage and dialect.
    #>

    param([string] $Stage, [string] $AsHook)
    return "pwsh -NoProfile -File .workflow/hooks/gate.ps1 -Stage $Stage -AsHook $AsHook"
}

function Merge-WayforgeJsonHooks {
    <#
    .SYNOPSIS
        Merges Wayforge-owned hook entries into a JSON hooks config, preserving
        the user's other events/entries. Our entries (identified by a marker in
        their serialized form, e.g. 'gate.ps1') are refreshed idempotently.
    #>

    param(
        [string] $Path,
        [hashtable] $OwnedByEvent,
        [string] $Marker = 'gate.ps1',
        [switch] $TopLevelVersion
    )

    $doc = @{}
    if (Test-Path $Path -PathType Leaf) {
        try { $doc = Get-Content $Path -Raw | ConvertFrom-Json -AsHashtable }
        catch { Write-Warning "Existing '$Path' is not valid JSON; it will be replaced."; $doc = @{} }
    }
    if ($null -eq $doc) { $doc = @{} }
    if ($TopLevelVersion -and -not $doc.ContainsKey('version')) { $doc['version'] = 1 }
    if ($doc['hooks'] -isnot [System.Collections.IDictionary]) { $doc['hooks'] = @{} }

    foreach ($event in $OwnedByEvent.Keys) {
        $kept = foreach ($entry in (@($doc['hooks'][$event]) | Where-Object { $_ })) {
            if (($entry | ConvertTo-Json -Depth 10 -Compress) -match [regex]::Escape($Marker)) { continue }
            $entry
        }
        $doc['hooks'][$event] = @(@($kept) + @($OwnedByEvent[$event])) | Where-Object { $_ }
    }

    $parent = Split-Path -Parent $Path
    if ($parent -and -not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
    ($doc | ConvertTo-Json -Depth 12) | Set-Content -Path $Path -Encoding utf8NoBOM
    return $Path
}

function ConvertTo-WayforgeArgList {
    <#
    .SYNOPSIS
        Splits a command line into argument tokens, honoring single/double quotes,
        without any shell or PowerShell interpretation.
    #>

    param([string] $CommandLine)

    $tokens = [System.Collections.Generic.List[string]]::new()
    $sb = [System.Text.StringBuilder]::new()
    $quote = $null
    foreach ($ch in $CommandLine.ToCharArray()) {
        if ($quote) {
            if ($ch -eq $quote) { $quote = $null } else { [void]$sb.Append($ch) }
        }
        elseif ($ch -eq '"' -or $ch -eq "'") { $quote = $ch }
        elseif ($ch -eq ' ' -or $ch -eq "`t") {
            if ($sb.Length -gt 0) { $tokens.Add($sb.ToString()) | Out-Null; [void]$sb.Clear() }
        }
        else { [void]$sb.Append($ch) }
    }
    if ($sb.Length -gt 0) { $tokens.Add($sb.ToString()) | Out-Null }
    return $tokens.ToArray()
}

function New-WayforgeGateResult {
    param([string] $Id, [string] $Severity, [string] $Status, [string] $Message, [string] $Detail)
    [PSCustomObject]@{
        PSTypeName = 'PSWayforge.GateResult'
        Id         = $Id
        Severity   = $Severity
        Status     = $Status
        Message    = $Message
        Detail     = $Detail
    }
}

function Invoke-WayforgeRunCheck {
    param([string] $Command, [string] $Shell = 'pwsh', [string] $Root)

    Push-Location $Root
    try {
        switch ($Shell) {
            'sh'     { & sh -c $Command 2>&1 | Out-Null; return $LASTEXITCODE }
            'native' {
                # Direct binary invocation - no shell or PowerShell interpretation
                # of metacharacters. Arguments are passed as a separated list.
                $argv = ConvertTo-WayforgeArgList -CommandLine $Command
                if ($argv.Count -eq 0) { return 0 }
                $exe  = $argv[0]
                $rest = if ($argv.Count -gt 1) { $argv[1..($argv.Count - 1)] } else { @() }
                & $exe @rest 2>&1 | Out-Null
                return $LASTEXITCODE
            }
            default  { & pwsh -NoProfile -Command $Command 2>&1 | Out-Null; return $LASTEXITCODE }
        }
    }
    catch { return 1 }
    finally { Pop-Location }
}

function Invoke-WayforgeCheck {
    <#
    .SYNOPSIS
        Evaluates one gate `check` (requires_artifact | run | forbid) and returns
        an object with Ok/Message/Detail.
    #>

    param(
        $Check, [string] $Root, [string] $Description,
        [string[]] $ChangeSet, [string] $ToolName, [string] $Command
    )

    if ($null -eq $Check) {
        return [PSCustomObject]@{ Ok = $true; Message = $Description; Detail = 'no check' }
    }

    $artifact = Get-WayforgeField $Check 'requires_artifact'
    $run      = Get-WayforgeField $Check 'run'
    $forbid   = Get-WayforgeField $Check 'forbid'

    if ($forbid) {
        # Filter nulls: @(Get-WayforgeField ... 'missing') would be @($null) with
        # Count 1, wrongly registering an (unmatched) dimension.
        $paths    = @(Get-WayforgeField $forbid 'path'    | Where-Object { $_ })
        $tools    = @(Get-WayforgeField $forbid 'tool'    | Where-Object { $_ })
        $commands = @(Get-WayforgeField $forbid 'command' | Where-Object { $_ })

        # AND over the dimensions evaluable in this context. `tool` and `command`
        # only bind mid-session (pre-tool, where a tool/command is in play); at
        # commit/push/ci only `path` binds. Block when every evaluable dimension
        # matches; a rule with no evaluable dimension here does not fire.
        $evaluable = [System.Collections.Generic.List[object]]::new()

        if ($paths.Count) {
            $hit = $null
            foreach ($file in $ChangeSet) {
                foreach ($glob in $paths) {
                    if (Test-WayforgeGlobMatch -Path $file -Glob $glob) { $hit = "path '$file' matches '$glob'"; break }
                }
                if ($hit) { break }
            }
            $evaluable.Add([PSCustomObject]@{ Matched = [bool]$hit; Detail = $hit }) | Out-Null
        }
        if ($tools.Count -and $ToolName) {
            $wanted  = $tools | ForEach-Object { "$_".ToLowerInvariant() }
            $matched = $wanted -contains $ToolName.ToLowerInvariant()
            $evaluable.Add([PSCustomObject]@{ Matched = $matched; Detail = "tool '$ToolName'" }) | Out-Null
        }
        if ($commands.Count -and $Command) {
            $matched = $false
            foreach ($pattern in $commands) { if ($Command -like $pattern) { $matched = $true; break } }
            $evaluable.Add([PSCustomObject]@{ Matched = $matched; Detail = "command '$Command'" }) | Out-Null
        }

        if ($evaluable.Count -gt 0 -and @($evaluable | Where-Object { -not $_.Matched }).Count -eq 0) {
            $why = ($evaluable | ForEach-Object { $_.Detail } | Where-Object { $_ }) -join '; '
            return [PSCustomObject]@{ Ok = $false; Message = $Description; Detail = "forbidden: $why" }
        }
        return [PSCustomObject]@{ Ok = $true; Message = $Description; Detail = 'no forbidden action' }
    }

    if ($artifact) {
        $schema  = Get-WayforgeField $Check 'schema'
        $artPath = Join-Path $Root '.workflow/artifacts' $artifact
        if (-not (Test-Path -Path $artPath -PathType Leaf)) {
            return [PSCustomObject]@{ Ok = $false; Message = $Description; Detail = "missing artifact .workflow/artifacts/$artifact" }
        }
        if ($schema) {
            try {
                $valid = Test-WayforgeSchema -Artifact $artPath -SchemaName $schema -ProjectPath $Root
            }
            catch {
                return [PSCustomObject]@{ Ok = $false; Message = $Description; Detail = "schema error: $($_.Exception.Message)" }
            }
            if (-not $valid) {
                return [PSCustomObject]@{ Ok = $false; Message = $Description; Detail = "artifact '$artifact' fails schema '$schema'" }
            }
        }
        return [PSCustomObject]@{ Ok = $true; Message = $Description; Detail = "artifact '$artifact' present/valid" }
    }

    if ($run) {
        $shell = Get-WayforgeField $Check 'shell' 'pwsh'
        $code  = Invoke-WayforgeRunCheck -Command $run -Shell $shell -Root $Root
        if ($code -eq 0) {
            return [PSCustomObject]@{ Ok = $true; Message = $Description; Detail = 'run passed' }
        }
        return [PSCustomObject]@{ Ok = $false; Message = $Description; Detail = "run exited ${code}: $run" }
    }

    return [PSCustomObject]@{ Ok = $true; Message = $Description; Detail = 'no-op check' }
}

function Write-WayforgeGateHuman {
    param($Report, [switch] $ToStdout)

    $header = "Wayforge gate [$($Report.Stage)]: " + $(if ($Report.Blocked) { 'BLOCKED' } else { 'passed' })
    $lines = @($header)
    foreach ($r in $Report.Results) {
        $mark = switch ($r.Status) {
            'pass' { '[+]' } 'warn' { '[!]' } 'skip' { '[ ]' } 'fail' { '[x]' } default { '[?]' }
        }
        $line = " $mark $($r.Id) ($($r.Status))"
        if ($r.Detail) { $line += " - $($r.Detail)" }
        $lines += $line
    }
    $text = $lines -join "`n"
    if ($ToStdout) { [Console]::Out.WriteLine($text) } else { [Console]::Error.WriteLine($text) }
}

function Write-WayforgeGateDialect {
    <#
    .SYNOPSIS
        Emits the caller's dialect (stdout/stderr) and returns the exit code
        the calling shim should exit with.
    #>

    param($Report, [string] $AsHook)

    $blocked = $Report.Blocked
    $failed  = @($Report.Results | Where-Object Status -eq 'fail')
    $reason  = ($failed | ForEach-Object { "$($_.Id): $($_.Message)" }) -join '; '
    if (-not $reason) { $reason = 'Wayforge gate failed.' }

    switch ($AsHook) {
        { $_ -in 'claude', 'codex', 'kimi', 'grok', 'copilot' } {
            if ($blocked) {
                $evt = switch ($Report.Stage) { 'stop' { 'Stop' } default { 'PreToolUse' } }
                $json = @{ hookSpecificOutput = @{ hookEventName = $evt; permissionDecision = 'deny'; permissionDecisionReason = $reason } } |
                    ConvertTo-Json -Depth 5 -Compress
                [Console]::Out.WriteLine($json)
                return 2
            }
            return 0
        }
        'cursor' {
            if ($blocked) {
                [Console]::Out.WriteLine((@{ permission = 'deny'; user_message = $reason; agent_message = $reason } | ConvertTo-Json -Compress))
                return 2
            }
            return 0
        }
        { $_ -in 'opencode', 'pi' } {
            if ($blocked) {
                [Console]::Out.WriteLine((@{ decision = 'deny'; reason = $reason } | ConvertTo-Json -Compress))
                return 2
            }
            return 0
        }
        'ci' {
            # CI: human-readable report to stdout so it lands in the job log.
            Write-WayforgeGateHuman -Report $Report -ToStdout
            return [int][bool]$blocked
        }
        default {
            # git, none: human-readable to stderr; nonzero exit blocks.
            Write-WayforgeGateHuman -Report $Report
            return [int][bool]$blocked
        }
    }
}