scripts/internal/continuous-co-review/checkpoint-diff-provider.ps1

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

function ConvertTo-ContinuousCoReviewRelativePath {
    param(
        [Parameter(Mandatory)]
        [string] $Path
    )

    return $Path.Trim().Replace('\', '/')
}

function Test-ContinuousCoReviewPathExcluded {
    param(
        [Parameter(Mandatory)]
        [string] $Path,

        [string[]] $ExcludedPathPatterns = @()
    )

    $normalizedPath = ConvertTo-ContinuousCoReviewRelativePath -Path $Path
    foreach ($pattern in @($ExcludedPathPatterns)) {
        if ([string]::IsNullOrWhiteSpace($pattern)) {
            continue
        }

        $normalizedPattern = ConvertTo-ContinuousCoReviewRelativePath -Path $pattern
        if ($normalizedPattern.EndsWith('/**')) {
            $prefix = $normalizedPattern.Substring(0, $normalizedPattern.Length - 3)
            if (($normalizedPath -eq $prefix) -or $normalizedPath.StartsWith("$prefix/")) {
                return $true
            }
        }

        if ([System.Management.Automation.WildcardPattern]::new($normalizedPattern, [System.Management.Automation.WildcardOptions]::IgnoreCase).IsMatch($normalizedPath)) {
            return $true
        }
    }

    return $false
}

function Get-ContinuousCoReviewProviderConfigScalar {
    # Read a single scalar value from .specrew/config.yml (quote-strip + inline-comment-tolerant grammar,
    # mirroring Get-ContinuousCoReviewNavigatorTimeoutSeconds). Returns $null when the key is absent or the
    # config is unreadable (fail-open to the caller's default). Used for the co-review exclusion override +
    # the diff byte budget, so a project can tune both without code changes.
    param(
        [Parameter(Mandatory)] [string] $RepoRoot,
        [Parameter(Mandatory)] [string] $Key
    )
    $configPath = Join-Path $RepoRoot '.specrew/config.yml'
    if (-not (Test-Path -LiteralPath $configPath -PathType Leaf)) { return $null }
    try {
        foreach ($line in Get-Content -LiteralPath $configPath -Encoding UTF8) {
            if ($line -match ("^\s*" + [regex]::Escape($Key) + ":\s*[''""]?(?<value>[^''""#]*?)[''""]?\s*(?:#.*)?$")) {
                return ([string]$Matches['value']).Trim()
            }
        }
    }
    catch { $null = $_ }
    return $null
}

function Get-ContinuousCoReviewDefaultExcludedPathPatterns {
    # The PRINCIPLED default exclusion for the auto-fired co-review: paths that SPECREW OR A HOST DEPLOYS
    # into a governed project (scaffolding / tool config / governance tooling) rather than the project's own
    # work. Without this the navigator reviewed the WHOLE deployed tree - on a real dogfood (EnglishIntake)
    # 706 files / 5.1 MB, ~690 of it deployed scaffolding, which blew the reviewer host's input limit and
    # yielded an unparseable verdict. Patterns are PROJECT-RELATIVE (matched against the subtree-relative
    # path, so they are nesting-agnostic and behave identically for an own-repo or a nested governance root).
    #
    # CONFIG OVERRIDE (the safety valve, NOT polish - SEC/green-but-inert guard): the generic default is
    # provably wrong for two real cases - `scripts/internal/continuous-co-review/**` is PRODUCT SOURCE in the
    # Specrew repo itself (self-host co-review would go blind on exactly this code), and `.github/**` is
    # commonly USER-OWNED CI elsewhere. So a project tunes the list via .specrew/config.yml:
    # co_review_excluded_paths_add: "extra/**, more/**" (added to the default)
    # co_review_excluded_paths_remove: "scripts/internal/continuous-co-review/**, .github/**" (dropped)
    # The `scripts/internal` pattern is NARROW (only the deployed co-review copy, the one EnglishIntake
    # actually carries) not all of scripts/internal/**, so the self-host collision is shrunk to one dir a
    # remove-override clears.
    param([Parameter(Mandatory)] [string] $RepoRoot)

    $defaults = @(
        '.specify/**', '.squad/**', '.github/**', '.claude/**', '.agents/**', '.specrew/**',
        '.cursor/**', '.copilot/**', '.vscode/**', '.antigravity/**', '.gemini/**',
        'scripts/internal/continuous-co-review/**',
        'CLAUDE.md', 'AGENTS.md', 'GEMINI.md', '.markdownlint.json'
    )

    $splitList = {
        param($raw)
        if ([string]::IsNullOrWhiteSpace([string]$raw)) { return @() }
        @(([string]$raw) -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }
    $add = & $splitList (Get-ContinuousCoReviewProviderConfigScalar -RepoRoot $RepoRoot -Key 'co_review_excluded_paths_add')
    $remove = & $splitList (Get-ContinuousCoReviewProviderConfigScalar -RepoRoot $RepoRoot -Key 'co_review_excluded_paths_remove')

    $removeSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($r in @($remove)) { [void]$removeSet.Add(([string]$r).Replace('\', '/').Trim()) }

    $merged = New-Object System.Collections.Generic.List[string]
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($p in (@($defaults) + @($add))) {
        $norm = ([string]$p).Replace('\', '/').Trim()
        if ([string]::IsNullOrWhiteSpace($norm)) { continue }
        if ($removeSet.Contains($norm)) { continue }
        if ($seen.Add($norm)) { [void]$merged.Add($norm) }
    }
    return @($merged.ToArray())
}

function Get-ContinuousCoReviewDiffByteBudget {
    # The per-review diff_inline byte budget. The reviewer host caps its piped input (claude -p: a hard
    # 10 MB stdin limit) and the composed prompt embeds the diff ~3x (raw + JSON-escaped in the request),
    # so an unbounded diff blows the limit -> the reviewer exits without a verdict. This bounds the COMMON
    # case under the host guard so reviews actually run; the adapter's prompt-size guard is the hard,
    # host-accurate bound. Default 2 MB (-> ~6 MB prompt contribution, comfortably under 10 MB); override
    # via .specrew/config.yml co_review_diff_byte_budget. <= 0 disables the cap.
    param([Parameter(Mandatory)] [string] $RepoRoot, [int] $Default = 2000000)
    $raw = Get-ContinuousCoReviewProviderConfigScalar -RepoRoot $RepoRoot -Key 'co_review_diff_byte_budget'
    if ([string]::IsNullOrWhiteSpace([string]$raw)) { return $Default }
    $parsed = 0
    if ([int]::TryParse(([string]$raw).Trim(), [ref]$parsed)) { return $parsed }
    return $Default
}

function Invoke-ContinuousCoReviewGit {
    param(
        [Parameter(Mandatory)]
        [string] $RepoRoot,

        [Parameter(Mandatory)]
        [string[]] $Arguments
    )

    # Robust git invocation IMMUNE to the ambient [Console]::OutputEncoding state. PowerShell's `& git`
    # throws "StandardOutputEncoding is only supported when standard output is redirected" in the hook
    # provider context (the dispatcher's providers set a non-console UTF-8 [Console]::OutputEncoding and
    # the provider's stdout is itself redirected). A dedicated Process with EXPLICIT redirect + UTF-8
    # output encoding dodges that entirely. Caught by the F-197 iter-005 navigator dogfood; same lesson
    # as the launcher's spawn. (Contract preserved: ExitCode + Output = lines, stdout then stderr.)
    $psi = [System.Diagnostics.ProcessStartInfo]::new()
    $psi.FileName = 'git'
    foreach ($a in $Arguments) { [void]$psi.ArgumentList.Add([string]$a) }
    $psi.WorkingDirectory = $RepoRoot
    $psi.UseShellExecute = $false
    $psi.CreateNoWindow = $true
    $psi.RedirectStandardOutput = $true
    $psi.RedirectStandardError = $true
    $psi.StandardOutputEncoding = [System.Text.UTF8Encoding]::new($false)
    $psi.StandardErrorEncoding = [System.Text.UTF8Encoding]::new($false)

    $proc = [System.Diagnostics.Process]::new()
    $proc.StartInfo = $psi
    [void]$proc.Start()
    $stdout = $proc.StandardOutput.ReadToEnd()
    $stderr = $proc.StandardError.ReadToEnd()
    $proc.WaitForExit()
    $exitCode = $proc.ExitCode
    $proc.Dispose()

    $toLines = {
        param($s)
        if ([string]::IsNullOrEmpty($s)) { return @() }
        $l = @(($s -replace "`r`n", "`n") -split "`n")
        if ($l.Count -gt 0 -and $l[-1] -eq '') { $l = @($l[0..($l.Count - 2)]) }
        return $l
    }
    $output = @(& $toLines $stdout) + @(& $toLines $stderr)

    return [pscustomobject]@{
        ExitCode = $exitCode
        Output   = @($output)
    }
}

function New-ContinuousCoReviewSkippedRun {
    param(
        [Parameter(Mandatory)]
        [string] $RunId,

        [Parameter(Mandatory)]
        [string] $CheckpointId,

        [Parameter(Mandatory)]
        [string] $BaselineRef,

        [Parameter(Mandatory)]
        [string] $DiffHash
    )

    return [pscustomobject][ordered]@{
        schema_version = '1.0'
        run_id         = $RunId
        checkpoint_id  = $CheckpointId
        baseline_ref   = $BaselineRef
        reason         = 'no-reviewable-diff'
        diff_hash      = $DiffHash
    }
}

function Get-ContinuousCoReviewSha256Hex {
    param(
        [AllowNull()]
        [string] $Text
    )

    $resolvedText = if ($null -eq $Text) { '' } else { $Text }
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($resolvedText)
    $hashBytes = [System.Security.Cryptography.SHA256]::HashData($bytes)
    return ([System.BitConverter]::ToString($hashBytes) -replace '-', '').ToLowerInvariant()
}

function Get-ContinuousCoReviewCheckpointDiff {
    param(
        [Parameter(Mandatory)]
        [string] $RepoRoot,

        [Parameter(Mandatory)]
        [string] $BaselineRef,

        [Parameter(Mandatory)]
        [string] $CheckpointId,

        [string[]] $ExcludedPathPatterns = @(),

        [string] $RunId
    )

    $resolvedRepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path
    $resolvedRunId = if ([string]::IsNullOrWhiteSpace($RunId)) {
        "run-$CheckpointId"
    }
    else {
        $RunId
    }

    # SUBDIR FIX (Proposal-145 review, iter-007 / subtree-scoping un-deferred): the change-set MUST be
    # computed from the git TOPLEVEL, scoped to the governance SUBTREE, with consistent repo-root-relative
    # paths. A governance root that is a SUBDIR of a larger repo (a Specrew project NESTED in a monorepo,
    # e.g. EnglishIntake under iTeach-Avatar) broke this TWO ways: (1) UNSCOPED `git diff --name-only -- `
    # returned the WHOLE monorepo's divergence (727 files), not the project's 706; (2) `git diff` run from
    # the subdir cwd emits REPO-ROOT-relative paths, but the batched `git diff -- <those paths>` ALSO run
    # from the subdir reinterpreted them as subdir-relative pathspecs -> Tools/EnglishIntake/Tools/
    # EnglishIntake/... -> ZERO matches (only a path existing at BOTH levels survived -> the reviewer saw
    # 1 of 706 files, none the actual work). Running from the TOPLEVEL + scoping to the prefix makes
    # name-only and the batched diff share one repo-root-relative frame. Own-repo projects (toplevel ==
    # governance root, empty prefix) are unaffected: $gitCwd == $resolvedRepoRoot, scope = whole repo.
    $topLevelResult = Invoke-ContinuousCoReviewGit -RepoRoot $resolvedRepoRoot -Arguments @('rev-parse', '--show-toplevel')
    $gitCwd = if ($topLevelResult.ExitCode -eq 0 -and -not [string]::IsNullOrWhiteSpace(($topLevelResult.Output -join ''))) { ([string]($topLevelResult.Output -join '')).Trim() } else { $resolvedRepoRoot }
    $prefixResult = Invoke-ContinuousCoReviewGit -RepoRoot $resolvedRepoRoot -Arguments @('rev-parse', '--show-prefix')
    $subtreePrefix = if ($prefixResult.ExitCode -eq 0) { ([string]($prefixResult.Output -join '')).Trim().TrimEnd('/') } else { '' }
    $subtreeScope = if ([string]::IsNullOrWhiteSpace($subtreePrefix)) { @() } else { @("$subtreePrefix/") }

    $baselineCheck = Invoke-ContinuousCoReviewGit -RepoRoot $resolvedRepoRoot -Arguments @('rev-parse', '--verify', "$BaselineRef^{commit}")
    if ($baselineCheck.ExitCode -ne 0) {
        return [pscustomobject][ordered]@{
            schema_version = '1.0'
            run_id         = $resolvedRunId
            checkpoint_id  = $CheckpointId
            baseline_ref   = $BaselineRef
            status         = 'infrastructure_failure'
            failure        = New-ContinuousCoReviewInfrastructureFailure `
                -RunId $resolvedRunId `
                -Category 'command-invocation-failure' `
                -Message 'Checkpoint baseline could not be resolved as a git commit.' `
                -SafeDetails ([pscustomobject]@{ baseline_ref = $BaselineRef; checkpoint_id = $CheckpointId })
        }
    }

    # T069 (NEW-5): the gate no longer keys on diff_hash (it uses the content-addressed
    # reviewed-state tree-id), so the former full `git diff` whose output was discarded (only
    # its exit code probed) is removed. The name-only call below is the exit probe AND drives
    # changed_paths; the reviewable diff further down produces diff_inline (the reviewer's
    # context) and a provenance diff_hash.
    $nameResult = Invoke-ContinuousCoReviewGit -RepoRoot $gitCwd -Arguments (@('diff', '--name-only', '--no-ext-diff', $BaselineRef, '--') + $subtreeScope)
    if ($nameResult.ExitCode -ne 0) {
        return [pscustomobject][ordered]@{
            schema_version = '1.0'
            run_id         = $resolvedRunId
            checkpoint_id  = $CheckpointId
            baseline_ref   = $BaselineRef
            status         = 'infrastructure_failure'
            failure        = New-ContinuousCoReviewInfrastructureFailure `
                -RunId $resolvedRunId `
                -Category 'command-invocation-failure' `
                -Message 'Checkpoint changed paths could not be produced.' `
                -SafeDetails ([pscustomobject]@{ baseline_ref = $BaselineRef; checkpoint_id = $CheckpointId })
        }
    }

    $changedPaths = [System.Collections.Generic.List[string]]::new()
    $excludedPaths = [System.Collections.Generic.List[string]]::new()

    # Trust-boundary decision (maintainer, 2026-06-20): the reviewer is a TRUSTED component
    # (SEC-001) inside Specrew's boundary and needs the FULL diff (including secret/config
    # content) plus repo read access + inherited env to review correctly and run tests. So the
    # change-set is NOT secret-stripped; only caller-supplied --exclude-path applies. See the
    # SEC-002 trust-boundary clause in spec.md.
    foreach ($path in @($nameResult.Output)) {
        if ([string]::IsNullOrWhiteSpace([string] $path)) {
            continue
        }

        $normalizedPath = ConvertTo-ContinuousCoReviewRelativePath -Path ([string] $path)
        # Exclusion is tested against the PROJECT-RELATIVE path (the subtree prefix stripped) so patterns
        # are nesting-agnostic - `.specrew/**` matches whether the governance root is the git root (empty
        # prefix -> no-op strip) or a subdir (e.g. Tools/EnglishIntake/.specrew/...). changed_paths /
        # excluded_paths keep the TOPLEVEL-relative path: the batched `git diff -- <paths>` below runs from
        # the toplevel and needs repo-root-relative pathspecs (the 81b7070e subtree frame).
        $projectRelativePath = $normalizedPath
        if (-not [string]::IsNullOrWhiteSpace($subtreePrefix) -and $normalizedPath.StartsWith("$subtreePrefix/")) {
            $projectRelativePath = $normalizedPath.Substring($subtreePrefix.Length + 1)
        }
        if (Test-ContinuousCoReviewPathExcluded -Path $projectRelativePath -ExcludedPathPatterns $ExcludedPathPatterns) {
            $excludedPaths.Add($normalizedPath)
        }
        else {
            $changedPaths.Add($normalizedPath)
        }
    }

    # Reviewable (post-exclusion) diff -> diff_inline (the reviewer's context) + a provenance
    # diff_hash. (F7: keyed to exactly the reviewable change-set; no longer the gate freshness
    # key - see T069 above.)
    $diffText = if ($changedPaths.Count -gt 0) {
        # Batch the post-exclusion paths so a real repo's large change-set does not blow the OS
        # command-line limit ("filename or extension is too long"). git diff has NO --pathspec-from-file,
        # so we chunk the explicit `-- <paths>` form. A small set (the common case) is ONE batch =
        # the original single command (byte-identical output + hash); larger sets concatenate batches
        # deterministically. (Caught by the F-197 iter-005 navigator dogfood on the real repo.)
        $batches = New-Object System.Collections.Generic.List[object]
        $cur = New-Object System.Collections.Generic.List[string]
        $curLen = 0
        foreach ($p in $changedPaths) {
            if ($cur.Count -gt 0 -and ($curLen + $p.Length + 1) -gt 20000) {
                $batches.Add(@($cur.ToArray())); $cur = New-Object System.Collections.Generic.List[string]; $curLen = 0
            }
            $cur.Add([string]$p); $curLen += $p.Length + 1
        }
        if ($cur.Count -gt 0) { $batches.Add(@($cur.ToArray())) }
        $parts = foreach ($batch in $batches) {
            $r = Invoke-ContinuousCoReviewGit -RepoRoot $gitCwd -Arguments (@('diff', '--no-ext-diff', '--src-prefix=a/', '--dst-prefix=b/', $BaselineRef, '--') + @($batch))
            ($r.Output -join "`n")
        }
        (@($parts) -join "`n")
    }
    else {
        ''
    }

    # LARGE-DIFF GRACEFUL CAP (independent of the exclusion above): bound diff_inline so the composed
    # reviewer prompt cannot blow the host's input limit and SILENTLY yield no verdict (the EnglishIntake
    # dogfood: a 14.9 MB prompt made `claude -p` exit 1 with empty stdout - "piped stdin input exceeds
    # 10MB"). When over budget, truncate on a BYTE boundary at a newline (0x0A) and append an explicit
    # marker; the FULL changed-paths list stays in the change-set, so the reviewer sees every changed file
    # + the shown diff + a stated reason -> a parseable verdict with HONEST partial coverage, never a silent
    # unparseable. Byte-accurate truncation is load-bearing: the reviewed tree carries multi-byte UTF-8
    # (Hebrew), so a char-index cut at a byte budget could overshoot; cutting at a 0x0A byte also never
    # splits a multi-byte sequence. The adapter prompt-size guard is the hard host-accurate bound; this keeps
    # the common case under it so reviews still run. <=0 budget disables.
    $diffBudget = Get-ContinuousCoReviewDiffByteBudget -RepoRoot $resolvedRepoRoot
    $diffBytes = [System.Text.Encoding]::UTF8.GetBytes($diffText)
    $diffFullBytes = $diffBytes.Length
    $diffTruncated = $false
    if ($diffBudget -gt 0 -and $diffFullBytes -gt $diffBudget) {
        $cutBytes = $diffBudget
        for ($i = [Math]::Min($diffBudget, $diffFullBytes) - 1; $i -ge 0; $i--) {
            if ($diffBytes[$i] -eq 10) { $cutBytes = $i; break }
        }
        $shown = [System.Text.Encoding]::UTF8.GetString($diffBytes, 0, $cutBytes)
        $marker = "`n`n[specrew co-review: diff truncated at $cutBytes of $diffFullBytes bytes to fit the reviewer input budget ($diffBudget bytes). The FULL list of $($changedPaths.Count) changed file(s) is in change_set.changed_paths - review the shown diff plus that file list and record PARTIAL coverage in your verdict.]`n"
        $diffText = $shown + $marker
        $diffTruncated = $true
    }
    $diffHash = "sha256:$(Get-ContinuousCoReviewSha256Hex -Text $diffText)"

    $status = if ($changedPaths.Count -eq 0) { 'skipped' } else { 'reviewable' }
    $changeSet = [ordered]@{
        schema_version         = '1.0'
        run_id                 = $resolvedRunId
        checkpoint_id          = $CheckpointId
        baseline_ref           = $BaselineRef
        status                 = $status
        review_kind            = 'code-change-set'
        diff_inline            = $diffText
        diff_hash              = $diffHash
        diff_truncated         = $diffTruncated
        diff_full_bytes        = $diffFullBytes
        diff_budget_bytes      = $diffBudget
        changed_paths          = @($changedPaths)
        reviewable_path_count  = $changedPaths.Count
        excluded_paths         = @($excludedPaths)
        excluded_path_patterns = @($ExcludedPathPatterns)
    }

    if ($status -eq 'skipped') {
        $changeSet['skip_reason'] = 'no-reviewable-diff'
        $changeSet['skipped_run'] = New-ContinuousCoReviewSkippedRun -RunId $resolvedRunId -CheckpointId $CheckpointId -BaselineRef $BaselineRef -DiffHash $diffHash
    }

    return [pscustomobject] $changeSet
}