Payload/scripts/kaden/harness/quality-gate/RepositoryQualityPrePush.psm1

#Requires -Version 5.1
<#
.SYNOPSIS
    Helpers for pre-push comprehensive-pester evidence reuse (ADR-0009).

.DESCRIPTION
    Builds evaluate-check evidence for comprehensive-pester, resolves the live
    Pester target set from tests/harness/run-harness-tests.ps1, and reads/writes
    the local .harness/quality-gate ledger. Does not run Pester by itself.
#>

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$script:PesterRuleIdentity = 'pester-runner-v1'
$script:EvidenceRelativePath = '.harness/quality-gate/comprehensive-pester.json'

function Get-RepositoryQualityRepoRoot {
    param([string]$StartPath = $PSScriptRoot)
    $probe = $StartPath
    for ($i = 0; $i -lt 8; $i++) {
        $gitDir = Join-Path $probe '.git'
        $invoke = Join-Path $probe (Join-Path 'scripts' (Join-Path 'kaden' 'harness-invoke.ps1'))
        if ((Test-Path -LiteralPath $gitDir) -or (Test-Path -LiteralPath $invoke)) {
            return (Resolve-Path -LiteralPath $probe).Path
        }
        $parent = Split-Path $probe -Parent
        if ([string]::IsNullOrWhiteSpace($parent) -or ($parent -eq $probe)) {
            break
        }
        $probe = $parent
    }
    throw "Unable to resolve repository root from $StartPath"
}

function Get-RepositoryQualityPesterEvidencePath {
    param([Parameter(Mandatory = $true)][string]$RepoRoot)
    return [System.IO.Path]::GetFullPath((Join-Path $RepoRoot ($script:EvidenceRelativePath -replace '/', [IO.Path]::DirectorySeparatorChar)))
}

function Get-RepositoryQualityPesterTargetSet {
    <#
    .SYNOPSIS
        Relative test paths registered in tests/harness/run-harness-tests.ps1.
    #>

    param([Parameter(Mandatory = $true)][string]$RepoRoot)
    $runner = Join-Path $RepoRoot (Join-Path 'tests' (Join-Path 'harness' 'run-harness-tests.ps1'))
    if (-not (Test-Path -LiteralPath $runner)) {
        throw "Pester runner not found: $runner"
    }
    $raw = Get-Content -LiteralPath $runner -Raw
    $regexHits = [regex]::Matches(
        $raw,
        'Join-Path\s+\$PSScriptRoot\s+''(?<file>[^'']+\.Tests\.ps1)'''
    )
    $targets = [System.Collections.Generic.List[string]]::new()
    foreach ($m in $regexHits) {
        $file = [string]$m.Groups['file'].Value
        $rel = ('tests/harness/' + $file).Replace('\', '/')
        if (-not ($targets -contains $rel)) {
            [void]$targets.Add($rel)
        }
    }
    if ($targets.Count -eq 0) {
        throw "No Pester test paths parsed from $runner"
    }
    return @($targets.ToArray())
}

function Get-RepositoryQualityPushChangedPath {
    <#
    .SYNOPSIS
        Paths changed in the unpushed range (upstream..HEAD), or HEAD vs empty tree.
    #>

    param(
        [Parameter(Mandatory = $true)][string]$RepoRoot,
        [string]$UpstreamRef = ''
    )
    $git = Get-Command git.exe -ErrorAction SilentlyContinue
    if (-not $git) {
        $git = Get-Command git -ErrorAction Stop
    }
    Push-Location $RepoRoot
    try {
        $range = $UpstreamRef
        if ([string]::IsNullOrWhiteSpace($range)) {
            $upstream = & $git.Source rev-parse --abbrev-ref '@{upstream}' 2>$null
            if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($upstream)) {
                $range = '@{upstream}..HEAD'
            }
            else {
                # No upstream: compare against the empty tree so first push still sees commit trees.
                $empty = '4b825dc642cb6eb9a060e54bf8d6927bfc89f564'
                $range = "$empty..HEAD"
            }
        }
        $names = & $git.Source -c core.quotepath=false diff --name-only $range
        if ($LASTEXITCODE -ne 0) {
            throw "git diff --name-only failed for range $range"
        }
        $result = [System.Collections.Generic.List[string]]::new()
        foreach ($line in @($names)) {
            $text = [string]$line
            if ([string]::IsNullOrWhiteSpace($text)) {
                continue
            }
            [void]$result.Add($text.Replace('\', '/'))
        }
        return @($result.ToArray())
    }
    finally {
        Pop-Location
    }
}

function Get-RepositoryQualityHeadRevision {
    param([Parameter(Mandatory = $true)][string]$RepoRoot)
    $git = Get-Command git.exe -ErrorAction SilentlyContinue
    if (-not $git) {
        $git = Get-Command git -ErrorAction Stop
    }
    Push-Location $RepoRoot
    try {
        $sha = (& $git.Source rev-parse HEAD).Trim()
        if ([string]::IsNullOrWhiteSpace($sha)) {
            throw 'git rev-parse HEAD returned empty'
        }
        return $sha
    }
    finally {
        Pop-Location
    }
}

function Read-RepositoryQualityPesterEvidence {
    param([Parameter(Mandatory = $true)][string]$RepoRoot)
    $path = Get-RepositoryQualityPesterEvidencePath -RepoRoot $RepoRoot
    if (-not (Test-Path -LiteralPath $path)) {
        return $null
    }
    $doc = Get-Content -LiteralPath $path -Raw | ConvertFrom-Json
    $targets = @()
    if ($null -ne $doc.TargetSet) {
        $targets = @($doc.TargetSet | ForEach-Object { [string]$_ })
    }
    $completed = $null
    if ($doc.CompletedAt) {
        $completed = [datetime]::Parse([string]$doc.CompletedAt, $null, [System.Globalization.DateTimeStyles]::RoundtripKind)
        if ($completed.Kind -eq [DateTimeKind]::Unspecified) {
            $completed = [datetime]::SpecifyKind($completed, [DateTimeKind]::Utc)
        }
        else {
            $completed = $completed.ToUniversalTime()
        }
    }
    return @{
        RuleIdentity        = [string]$doc.RuleIdentity
        TargetSet           = $targets
        FailedCount         = [int]$doc.FailedCount
        CompletedAt         = $completed
        ProtectedRevision   = [string]$doc.ProtectedRevision
        RecordedRevision    = [string]$doc.ProtectedRevision
    }
}

function Write-RepositoryQualityPesterEvidence {
    param(
        [Parameter(Mandatory = $true)][string]$RepoRoot,
        [Parameter(Mandatory = $true)][string[]]$TargetSet,
        [Parameter(Mandatory = $true)][datetime]$CompletedAt,
        [Parameter(Mandatory = $true)][string]$ProtectedRevision,
        [int]$FailedCount = 0
    )
    $path = Get-RepositoryQualityPesterEvidencePath -RepoRoot $RepoRoot
    $dir = Split-Path $path -Parent
    if (-not (Test-Path -LiteralPath $dir)) {
        New-Item -ItemType Directory -Path $dir -Force | Out-Null
    }
    $payload = [ordered]@{
        schema            = 'kaden.quality-gate.comprehensive-pester/v1'
        RuleIdentity      = $script:PesterRuleIdentity
        TargetSet         = @($TargetSet)
        FailedCount       = $FailedCount
        CompletedAt       = $CompletedAt.ToUniversalTime().ToString('o')
        ProtectedRevision = $ProtectedRevision
    }
    $json = ($payload | ConvertTo-Json -Depth 6)
    [System.IO.File]::WriteAllText($path, $json, [System.Text.UTF8Encoding]::new($false))
    return $path
}

function New-RepositoryQualityPesterCheckEvidence {
    <#
    .SYNOPSIS
        Build CheckEvidence hashtable for evaluate-check comprehensive-pester.
    #>

    [CmdletBinding()]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        'PSUseShouldProcessForStateChangingFunctions',
        '',
        Justification = 'Builds an in-memory evidence hashtable for the decision port; no durable side effects.'
    )]
    param(
        [Parameter(Mandatory = $true)][string[]]$CurrentTargetSet,
        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]$ChangedPaths,
        [Parameter(Mandatory = $true)][datetime]$RequestedAt,
        [Parameter(Mandatory = $true)][string]$ProtectedRevision,
        $PriorEvidence = $null
    )
    $hasPrior = ($null -ne $PriorEvidence) -and ($null -ne $PriorEvidence.CompletedAt)
    $targetSet = @($CurrentTargetSet)
    $failedCount = 0
    $completedAt = $RequestedAt
    $recordedRevision = $ProtectedRevision
    $presentedAgain = $false
    if ($hasPrior) {
        $targetSet = @($PriorEvidence.TargetSet)
        if (@($targetSet).Count -eq 0) {
            $targetSet = @($CurrentTargetSet)
        }
        $failedCount = [int]$PriorEvidence.FailedCount
        $completedAt = [datetime]$PriorEvidence.CompletedAt
        $recordedRevision = [string]$PriorEvidence.ProtectedRevision
        $presentedAgain = $true
    }

    return @{
        PresentedAgain       = $presentedAgain
        TargetSet            = @($targetSet)
        ScannedTargetSet     = @($CurrentTargetSet)
        CurrentTargetSet     = @($CurrentTargetSet)
        RuleIdentity         = $script:PesterRuleIdentity
        CurrentRuleIdentity  = $script:PesterRuleIdentity
        CompletedAt          = $completedAt
        RequestedAt          = $RequestedAt
        TargetCount          = @($CurrentTargetSet).Count
        FailedCount          = $failedCount
        BlockingFindingCount = 0
        ErrorFindingCount    = 0
        SecretFindingCount   = 0
        AnalyzerFindingCount = 0
        EncodingFaultCount   = 0
        ChangedPaths         = @($ChangedPaths)
        ProtectedRevision    = $ProtectedRevision
        RecordedRevision     = $recordedRevision
        AffectedTarget       = $null
        Condition            = $null
    }
}

function Test-RepositoryQualityPesterReuse {
    <#
    .SYNOPSIS
        Ask the decision port whether comprehensive-pester evidence may be reused.
    #>

    param(
        [Parameter(Mandatory = $true)]$CheckEvidence
    )
    return Invoke-RepositoryQualityDecision -Request evaluate-check -Check comprehensive-pester -CheckEvidence $CheckEvidence
}

Export-ModuleMember -Function @(
    'Get-RepositoryQualityRepoRoot'
    'Get-RepositoryQualityPesterEvidencePath'
    'Get-RepositoryQualityPesterTargetSet'
    'Get-RepositoryQualityPushChangedPath'
    'Get-RepositoryQualityHeadRevision'
    'Read-RepositoryQualityPesterEvidence'
    'Write-RepositoryQualityPesterEvidence'
    'New-RepositoryQualityPesterCheckEvidence'
    'Test-RepositoryQualityPesterReuse'
)