scripts/internal/continuous-co-review/reviewed-state-digest.ps1

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

# T065 / FR-025 / SEC-002: content-addressed reviewed-state identity.
#
# A co-review run records a digest of the EXACT worktree content it reviewed, computed via
# a TEMPORARY git index (GIT_INDEX_FILE) so the real index/HEAD are never touched. The
# digest is a git tree-id over: tracked + untracked-non-ignored content (git add -A) PLUS
# gitignored SOURCE (git add -f), minus a secret/ambient denylist. The gate's freshness
# check is "current worktree tree-id == a passing run's recorded tree-id". This structurally
# closes HOLE A (gitignored source is IN the tree-id and its drift flips it), the untracked
# blind spot, the empty-diff trust (the empty tree has the well-known id below), and the
# diff path-parsing nits. Validated empirically 2026-06-20.

function Get-ContinuousCoReviewSecretAmbientDenylist {
    # The DIGEST-IDENTITY denylist: paths kept OUT of the content-addressed tree-id.
    #
    # F1 (145 adversarial review): this list must exclude ONLY genuine non-source -
    # runtime/ambient directories and true secret/credential FILES (by exact name or
    # secret-file extension). It MUST NOT substring-match source names: a `*secret*` or
    # `*credential*` glob strips legitimate source like `src/credentials.ts` or
    # `lib/secret-rotation.go` from the gate identity, so a post-pass edit to that source
    # is invisible to freshness == a false-allow on un-reviewed source (the exact FR-025
    # defect this feature exists to prevent). Those two substring globs are intentionally
    # ABSENT here. (Confidentiality - not showing a secret FILE to the reviewer - is a
    # separate, broader concern owned by the reviewer-bundle path, not the gate identity.)
    return @(
        '.env', '.env.*', '*.pem', '*.pfx', '*.p12', '*.key', '*.token',
        'id_rsa', 'id_rsa.*', 'id_ed25519', 'id_ed25519.*', '.netrc', '.npmrc', '.pypirc',
        'node_modules/**', 'dist/**', 'build/**', 'out/**', 'target/**', 'bin/**', 'obj/**',
        '.venv/**', 'venv/**', '__pycache__/**', '.tox/**', '.gradle/**', '.next/**',
        '.git/**', '.specrew/**', '.squad/**', '.specify/**', '.scratch/**'
    )
}

function Get-ContinuousCoReviewDigestRuntimeStripList {
    # The DIGEST-IDENTITY strip list: paths removed from the FINAL index (the tree-id).
    #
    # 145 correctness review: anything excluded from the identity is a FALSE-ALLOW vector (a
    # post-pass edit to an excluded path leaves the tree-id unchanged -> the gate allows
    # un-reviewed source). So this list excludes ONLY genuinely-non-source paths by anchored
    # subtree: the tool's own runtime trees and package-manager-managed dirs. It MUST NOT
    # contain secret-FILE/extension globs (`*.key`/`*.token`/`*.pem` strip real source like
    # `src/keymap.key`) or ambiguous build-output dirs (`bin/`/`obj/`/`dist/` are committed
    # source in polyglot repos). Secret CONFIDENTIALITY is the reviewer-bundle path's concern,
    # not the gate identity. (Gitignored ambient/secret junk is kept out of the tree by the
    # broader inclusion denylist below, applied only to the `git add -f` step.)
    return @(
        '.git/**', '.specrew/**', '.squad/**', '.specify/**', '.scratch/**',
        'node_modules/**', '.venv/**', 'venv/**', '__pycache__/**', '.tox/**', '.gradle/**', '.next/**'
    )
}

function Get-ContinuousCoReviewEmptyTreeId {
    # The well-known git SHA-1 of the empty tree; the no-content guard for the gate (NEW-2).
    return '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
}

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

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

    $normalized = ($Path -replace '\\', '/').TrimEnd('/')
    if ([string]::IsNullOrWhiteSpace($normalized)) {
        return $true
    }

    $leaf = $normalized.Split('/')[-1]
    foreach ($pattern in @($Denylist)) {
        if ([string]::IsNullOrWhiteSpace($pattern)) {
            continue
        }

        $normalizedPattern = ($pattern -replace '\\', '/')
        if ($normalizedPattern.EndsWith('/**')) {
            $prefix = $normalizedPattern.Substring(0, $normalizedPattern.Length - 3)
            if (($normalized -eq $prefix) -or $normalized.StartsWith("$prefix/", [System.StringComparison]::OrdinalIgnoreCase)) {
                return $true
            }
            continue
        }

        $wildcard = [System.Management.Automation.WildcardPattern]::new($normalizedPattern, [System.Management.Automation.WildcardOptions]::IgnoreCase)
        if ($wildcard.IsMatch($normalized) -or $wildcard.IsMatch($leaf)) {
            return $true
        }
    }

    return $false
}

function New-ContinuousCoReviewDigestResult {
    param(
        [Parameter(Mandatory)]
        [bool] $Ok,

        [AllowNull()]
        [string] $TreeId,

        [AllowNull()]
        [string] $FailureReason,

        [int] $IncludedIgnoredCount = 0
    )

    return [pscustomobject][ordered]@{
        schema_version         = '1.0'
        ok                     = $Ok
        tree_id                = $TreeId
        is_empty               = ($Ok -and $TreeId -eq (Get-ContinuousCoReviewEmptyTreeId))
        included_ignored_count = $IncludedIgnoredCount
        failure_reason         = $FailureReason
    }
}

function ConvertFrom-ContinuousCoReviewNulList {
    param(
        [AllowNull()]
        $Raw
    )

    $text = if ($null -eq $Raw) { '' } elseif ($Raw -is [array]) { $Raw -join "`n" } else { [string] $Raw }
    return @($text -split "`0" | Where-Object { $_ -ne '' })
}

function Invoke-ContinuousCoReviewGitPathBatch {
    # Run `git <GitArgs> -- <paths>` in CHUNKS from the CURRENT location (+ the ambient GIT_INDEX_FILE).
    # Replaces an O(files) subprocess-PER-PATH fan-out: the reviewed-state digest staged/stripped one
    # path per git call, which was ~24s on a real .specify-deployed tree (172 files) -> the navigator
    # blew the dispatcher's ~20s provider budget and NEVER fired (the iter-006 live-e2e third first-run
    # failure). Identity-preserving: the SAME paths reach the index, so git write-tree yields the SAME
    # tree-id. Chunked to stay under the OS command-line length limit.
    param(
        [Parameter(Mandatory)]
        [string[]] $GitArgs,

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

        [int] $ChunkSize = 200
    )

    if ($null -eq $Paths -or $Paths.Count -eq 0) { return }
    for ($i = 0; $i -lt $Paths.Count; $i += $ChunkSize) {
        $end = [Math]::Min($i + $ChunkSize, $Paths.Count) - 1
        $chunk = @($Paths[$i..$end])
        & git @GitArgs -- @chunk 2>$null | Out-Null
    }
}

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

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

    $resolvedRepoRoot = (Resolve-Path -LiteralPath $RepoRoot).Path
    # Two distinct lists: the BROAD inclusion denylist decides which GITIGNORED paths to
    # add (keeps ambient/secret junk out of the tree), while the MINIMAL strip list decides
    # what to remove from the FINAL index (only genuinely-non-source, so no tracked source is
    # ever stripped - the false-allow fix).
    $inclusionDenylist = @(Get-ContinuousCoReviewSecretAmbientDenylist) + @($ExcludedPathPatterns)
    $stripList = @(Get-ContinuousCoReviewDigestRuntimeStripList) + @($ExcludedPathPatterns)
    $tempIndex = Join-Path ([System.IO.Path]::GetTempPath()) ('ccr-idx-' + [System.Guid]::NewGuid().ToString('N'))

    $hadPreviousIndex = Test-Path env:GIT_INDEX_FILE
    $previousIndex = if ($hadPreviousIndex) { $env:GIT_INDEX_FILE } else { $null }

    Push-Location -LiteralPath $resolvedRepoRoot
    try {
        # A fresh (non-existent) GIT_INDEX_FILE is an EMPTY index, so `git add -A` stages
        # the full current working tree (every non-ignored file as an addition) WITHOUT
        # reading or writing the real .git/index. No HEAD dependency (works pre-commit).
        $env:GIT_INDEX_FILE = $tempIndex

        & git add -A 2>$null | Out-Null
        if ($LASTEXITCODE -ne 0) {
            return New-ContinuousCoReviewDigestResult -Ok $false -FailureReason 'git-add-all-failed'
        }

        $rawIgnored = & git ls-files -z --others --ignored --exclude-standard --directory 2>$null
        if ($LASTEXITCODE -ne 0) {
            return New-ContinuousCoReviewDigestResult -Ok $false -FailureReason 'git-ls-ignored-failed'
        }
        # Collect the non-denied gitignored SOURCE, then force-add it in BATCHED git calls (NOT one
        # subprocess per entry - see Invoke-ContinuousCoReviewGitPathBatch).
        $toInclude = @()
        foreach ($entry in (ConvertFrom-ContinuousCoReviewNulList -Raw $rawIgnored)) {
            if (-not (Test-ContinuousCoReviewDigestPathDenied -Path $entry -Denylist $inclusionDenylist)) {
                $toInclude += $entry
            }
        }
        $included = $toInclude.Count
        Invoke-ContinuousCoReviewGitPathBatch -GitArgs @('add', '-f') -Paths $toInclude

        # Strip only the genuinely-non-source runtime/dep paths from the final index (e.g. the
        # gate's own .specrew/review evidence, which must NEVER perturb the digest it checks).
        # This uses the MINIMAL strip list, NOT the broad denylist, so tracked SOURCE - even a
        # file named `keymap.key` or a script in `bin/` - stays in the tree-id and its drift is
        # detected (the 145 correctness false-allow fix).
        $rawStaged = & git ls-files -z 2>$null
        if ($LASTEXITCODE -eq 0) {
            # Collect the genuinely-non-source staged paths, then drop them from the index in BATCHED
            # git calls (NOT one `git rm --cached` per path - the ~24s O(files) fan-out on .specify).
            $toStrip = @()
            foreach ($staged in (ConvertFrom-ContinuousCoReviewNulList -Raw $rawStaged)) {
                if (Test-ContinuousCoReviewDigestPathDenied -Path $staged -Denylist $stripList) {
                    $toStrip += $staged
                }
            }
            Invoke-ContinuousCoReviewGitPathBatch -GitArgs @('rm', '--cached', '--quiet') -Paths $toStrip
        }

        $treeOutput = & git write-tree 2>$null
        if ($LASTEXITCODE -ne 0) {
            return New-ContinuousCoReviewDigestResult -Ok $false -FailureReason 'git-write-tree-failed'
        }
        $treeId = ([string] (@($treeOutput) | Select-Object -First 1)).Trim()
        if ($treeId -notmatch '^[0-9a-f]{40}$') {
            return New-ContinuousCoReviewDigestResult -Ok $false -FailureReason 'git-write-tree-malformed'
        }

        return New-ContinuousCoReviewDigestResult -Ok $true -TreeId $treeId -IncludedIgnoredCount $included
    }
    catch {
        return New-ContinuousCoReviewDigestResult -Ok $false -FailureReason 'digest-exception'
    }
    finally {
        Pop-Location
        if ($hadPreviousIndex) {
            $env:GIT_INDEX_FILE = $previousIndex
        }
        else {
            Remove-Item env:GIT_INDEX_FILE -ErrorAction SilentlyContinue
        }
        Remove-Item -LiteralPath $tempIndex -Force -ErrorAction SilentlyContinue
    }
}