Public/Invoke-AvmPrCheck.ps1

function Invoke-AvmPrCheck {
    <#
    .SYNOPSIS
        Run the full pull-request gauntlet against the resolved module:
        sync -> format -> transform -> lint -> check policy ->
        check convention -> validate -> unit test -> docs.

    .DESCRIPTION
        Composition cmdlet. Resolves the module context once with
        Get-AvmModuleContext, then invokes the full Phase 1 verb chain in
        sequence against that same module root. Each step's structured
        result is captured. The overall Status is 'pass' only when every
        executed step reports Status='pass' (or didn't throw, for verbs
        that don't carry a Status field).

        This is the broader sibling of Invoke-AvmPreCommit: where
        pre-commit runs the fast, locally meaningful checks (format,
        lint, validate, docs), pr-check runs **every check that runs in
        CI** (per plan section 4), including the convention-policy
        steps that compare the module against the AVM specs.

        The 'validate' step is a build-validation pass ('terraform
        validate' / 'bicep build'), NOT a test run - it is named for
        what it does. The 'unit test' step that follows is the real
        thing: it runs the tests/unit tier and reports run counts, so a
        module with no tests cannot read as a green gauntlet. It is
        included because the unit tier is credential-free and therefore
        the only tier that can honestly run anywhere; the integration
        and e2e tiers need a live subscription and stay out. For bicep
        the unit-test step throws AvmConfigurationException and is
        skipped.

        The chain opens with the managed-files sync step (terraform
        only) in **drift-check mode** (-CheckDrift): unlike pre-commit,
        which reconciles the governed files by writing them, pr-check
        writes nothing and instead treats any needed add/update/remove
        as Status='fail'. This makes stale governed files a hard CI
        failure so the module is refreshed before merge rather than
        silently rewritten in CI. For bicep the sync step throws
        AvmConfigurationException and is skipped.

        Status semantics (same as Invoke-AvmPreCommit):
          - 'pass' : step returned Status='pass' (or didn't throw for
                        format).
          - 'fail' : step returned Status='fail'.
          - 'error' : step threw any exception other than
                        AvmConfigurationException; the chain aborts.
          - 'skipped' : step threw AvmConfigurationException - the engine
                        is a deliberate placeholder for a future slice
                        (e.g. bicep-docs, transform, check policy,
                        check convention). The chain CONTINUES and
                        overall status is NOT marked failed by a skip.

        By default the gauntlet is fail-soft: a step that returns
        Status='fail' does NOT abort subsequent steps - the caller gets
        the full picture in one run. A step that THROWS (non-
        AvmConfigurationException) IS fatal and aborts the rest of the
        chain. Set -StopOnFail to abort on the first Status='fail'
        instead.

        Routed by the dispatcher: 'avm pr-check'.

    .PARAMETER Path
        Working directory whose enclosing module to validate. Defaults to
        the current location.

    .PARAMETER Ecosystem
        Force the ecosystem selector. Defaults to 'auto'.

    .PARAMETER AllowPathFallback
        When set, accept a PATH-resolved tool binary that self-reports the
        lock-pinned version. Forwarded to each step.

    .PARAMETER StopOnFail
        When set, abort the chain on the first step whose Status is 'fail'.
        A throwing step is always fatal regardless of this flag.

    .OUTPUTS
        pscustomobject with:
          - Path : the resolved module root
          - Ecosystem : bicep | terraform
          - Status : pass | fail | error
          - Steps : array of { Step, Status, Error?, Result?, DurationMs }
          - DurationMs : total wall-clock cost

    .EXAMPLE
        avm pr-check

    .EXAMPLE
        Invoke-AvmPrCheck -Path C:\repos\my-module -StopOnFail
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Position = 0)]
        [string] $Path = $PWD.Path,

        [ValidateSet('auto', 'bicep', 'terraform')]
        [string] $Ecosystem = 'auto',

        [switch] $AllowPathFallback,

        [switch] $StopOnFail
    )

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

    $startTime = [datetime]::UtcNow
    $sw = [System.Diagnostics.Stopwatch]::StartNew()

    $context = Get-AvmModuleContext -Path $Path -Ecosystem $Ecosystem

    $stepDefs = @(
        [pscustomobject]@{ Name = 'sync'; Cmdlet = 'Invoke-AvmSync'; ExtraArgs = @{ CheckDrift = $true } }
        [pscustomobject]@{ Name = 'format'; Cmdlet = 'Invoke-AvmFormat'; ExtraArgs = @{ CheckDrift = $true } }
        [pscustomobject]@{ Name = 'transform'; Cmdlet = 'Invoke-AvmTransform'; ExtraArgs = @{ CheckDrift = $true } }
        [pscustomobject]@{ Name = 'lint'; Cmdlet = 'Invoke-AvmLint' }
        [pscustomobject]@{ Name = 'check policy'; Cmdlet = 'Invoke-AvmCheckPolicy' }
        [pscustomobject]@{ Name = 'check convention'; Cmdlet = 'Invoke-AvmCheckConvention' }
        [pscustomobject]@{ Name = 'validate'; Cmdlet = 'Invoke-AvmTest' }
        [pscustomobject]@{ Name = 'unit test'; Cmdlet = 'Invoke-AvmTestUnit' }
        [pscustomobject]@{ Name = 'docs'; Cmdlet = 'Invoke-AvmDocs'; ExtraArgs = @{ CheckDrift = $true } }
    )

    $steps = New-Object System.Collections.Generic.List[object]
    $overall = 'pass'
    $stepIndex = 0

    foreach ($def in $stepDefs) {
        $stepStatus = 'pass'
        $stepError = $null
        $stepResult = $null
        $stepIndex++
        $stepStart = [datetime]::UtcNow
        $stepSw = [System.Diagnostics.Stopwatch]::StartNew()

        Write-AvmLog ('step {0}/{1}: {2} (started {3})' -f $stepIndex, $stepDefs.Count, $def.Name, (Format-AvmTimestamp -Timestamp $stepStart)) -Level Info

        try {
            $extraArgs = if ($def.PSObject.Properties.Name -contains 'ExtraArgs' -and $def.ExtraArgs) { $def.ExtraArgs } else { @{} }
            $stepResult = & $def.Cmdlet `
                -Path $context.Root `
                -Ecosystem $context.Ecosystem `
                -AllowPathFallback:$AllowPathFallback `
                @extraArgs

            if ($stepResult -and $stepResult.PSObject.Properties.Name -contains 'Status') {
                $stepStatus = $stepResult.Status
            }
        }
        catch [AvmNotSupportedException] {
            # Verb genuinely does not apply to this ecosystem. Continue the
            # chain; do not flip overall status.
            $stepStatus = 'skipped'
            $stepError = $_.Exception.Message
        }
        catch [AvmConfigurationException] {
            # The repo is misconfigured, not unsupported. This must fail rather
            # than skip: a skip renders as a benign gauntlet pass, which is how
            # a step that never actually ran gets to look green.
            $stepStatus = 'fail'
            $stepError = $_.Exception.Message
        }
        catch {
            $stepStatus = 'error'
            $stepError = $_.Exception.Message
        }
        $stepSw.Stop()
        $stepEnd = $stepStart.AddMilliseconds($stepSw.Elapsed.TotalMilliseconds)

        Write-AvmLog ('step {0}/{1}: {2} -> {3} ({4})' -f $stepIndex, $stepDefs.Count, $def.Name, $stepStatus, (Format-AvmDuration -Duration $stepSw.Elapsed)) -Level Info

        if ($stepStatus -in @('fail', 'error') -and -not [string]::IsNullOrWhiteSpace($stepError)) {
            # F41: narration only. Assert-AvmCommandSuccess promotes the same
            # text to the single GitHub Actions annotation for the run.
            Write-AvmLog (' {0}: {1}' -f $def.Name, $stepError) -Level Info
        }

        $steps.Add([pscustomobject][ordered]@{
                Step       = $def.Name
                Status     = $stepStatus
                Error      = $stepError
                Result     = $stepResult
                StartTime  = $stepStart
                EndTime    = $stepEnd
                DurationMs = [int]$stepSw.Elapsed.TotalMilliseconds
            })

        if ($stepStatus -eq 'fail' -or $stepStatus -eq 'error') { $overall = $stepStatus }
        if ($stepStatus -eq 'error') { break }
        if ($StopOnFail -and $stepStatus -eq 'fail') { break }
    }

    $sw.Stop()

    return [pscustomobject][ordered]@{
        Path       = $context.Root
        Ecosystem  = $context.Ecosystem
        Status     = $overall
        Steps      = $steps.ToArray()
        StartTime  = $startTime
        EndTime    = $startTime.AddMilliseconds($sw.Elapsed.TotalMilliseconds)
        DurationMs = [int]$sw.Elapsed.TotalMilliseconds
    }
}