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

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

# HARD dependency (DRIFT-198-I009-018): absent, digest denial and machinery dedup silently
# compared with a DIFFERENT case rule instead of the volume's own.
if (-not (Get-Command -Name 'Get-ContinuousCoReviewPathCaseSensitive' -ErrorAction SilentlyContinue)) {
    . (Join-Path $PSScriptRoot 'path-identity.ps1')
}

# 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`),
# minus methodology/runtime machinery and explicit human scope exclusions. Gitignored
# untracked files are excluded by Git's own repository policy; tracked files remain reviewable
# even when a later ignore rule matches them. The gate's freshness
# check is "current worktree tree-id == a passing run's recorded tree-id". This structurally
# closes the non-ignored untracked blind spot, the empty-diff trust (the empty tree has the
# well-known id below), and the diff path-parsing nits without pulling build/runtime artifacts
# into the review candidate.

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/**',
        # T017 INTERIM (2026-07-12): the SIX known review-closeout scaffolder staging
        # byproducts, path-and-name specific under specs/*/iterations/*. This classifier
        # remains narrow for reviewer-bundle compatibility; the digest now respects
        # `.gitignore` for every untracked path and never force-adds ignored content.
        # T017 REALIZED the ONE machinery source (Get-ContinuousCoReviewMachineryPaths, consumed by BOTH the digest
        # AND the worktree strip - see the $machineryPatterns wiring below). These .pending patterns are
        # digest-specific scaffolder-BYPRODUCT hygiene (NOT host machinery), so they correctly stay here, not in the
        # shared machinery list.
        'specs/*/iterations/*/code-map.md.pending',
        'specs/*/iterations/*/coverage-evidence.md.pending',
        'specs/*/iterations/*/dashboard.md.pending',
        'specs/*/iterations/*/dependency-report.md.pending',
        'specs/*/iterations/*/review-diagrams.md.pending',
        'specs/*/iterations/*/reviewer-index.md.pending'
    )
}

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 by normal Git semantics.
    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 = @(),

        # LITERAL repository identities (machinery paths). Matched by exact identity and subtree
        # only - never as globs, because a legal directory name holding wildcard metacharacters
        # (`generated[1]`) would otherwise match unrelated source (`generated1`) and drop real
        # reviewable code out of the tree identity, which is the false-allow this list prevents.
        [string[]] $LiteralPath = @(),

        # Root whose VOLUME decides case semantics. Case sensitivity is a volume property, not an
        # OS-family one; undetermined resolves to 'distinct' so this predicate strips LESS and can
        # never remove a case-distinct reviewable path from the identity.
        [string] $CaseRoot = '.'
    )

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

    $leaf = $normalized.Split('/')[-1]
    $comparison = Get-ContinuousCoReviewPathComparison -Path $CaseRoot -WhenUndetermined 'distinct'
    $wildcardOptions = if ($comparison -eq [System.StringComparison]::OrdinalIgnoreCase) { [System.Management.Automation.WildcardOptions]::IgnoreCase } else { [System.Management.Automation.WildcardOptions]::None }

    foreach ($literal in @($LiteralPath)) {
        if ([string]::IsNullOrWhiteSpace($literal)) { continue }
        $normalizedLiteral = ([string]$literal -replace '\\', '/').Trim('/')
        if ([string]::IsNullOrWhiteSpace($normalizedLiteral)) { continue }
        if ($normalized.Equals($normalizedLiteral, $comparison) -or $normalized.StartsWith("$normalizedLiteral/", $comparison)) {
            return $true
        }
    }

    foreach ($pattern in @($Denylist)) {
        if ([string]::IsNullOrWhiteSpace($pattern)) {
            continue
        }

        $normalizedPattern = ($pattern -replace '\\', '/')
        if ($normalizedPattern.EndsWith('/**')) {
            $prefix = $normalizedPattern.Substring(0, $normalizedPattern.Length - 3)
            if ($normalized.Equals($prefix, $comparison) -or $normalized.StartsWith("$prefix/", $comparison)) {
                return $true
            }
            continue
        }

        $wildcard = [System.Management.Automation.WildcardPattern]::new($normalizedPattern, $wildcardOptions)
        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,

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

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

    $canonicalExclusions = @($ExcludedPathPatterns | ForEach-Object {
        $normalized = ([string]$_ -replace '\\', '/').Trim()
        while ($normalized.StartsWith('./', [StringComparison]::Ordinal)) { $normalized = $normalized.Substring(2) }
        $normalized
    })
    $canonicalExclusions = Get-ContinuousCoReviewOrdinalUniquePath -Path $canonicalExclusions
    # Dedup is load-bearing, and it must be ORDINAL. Sort-Object -Unique folds case by default, so on a
    # case-sensitive worktree `Foo/**` and `foo/**` - two DIFFERENT operator authorities - collapsed
    # to one, and the discarded subtree stayed in the reviewed digest and the materialized target
    # even though the operator explicitly excluded it (co-review finding, run
    # run-f198-i009-aab37c3b-codex-2). Keeping both spellings is safe on every volume: where the
    # volume folds case they select the same files, and where it does not they are genuinely
    # distinct authorities. Never fold here - that direction can only ever under-exclude.
    # `-CaseSensitive` was the earlier fix and was NOT enough: it flips only the case flag and leaves
    # the comparison culture-aware, so composed vs decomposed Unicode still collapsed
    # (DRIFT-198-I009-033). The primitive above is Ordinal in both dedup and ordering.
    $exclusionBytes = [Text.Encoding]::UTF8.GetBytes(($canonicalExclusions -join "`n"))
    $exclusionSha256 = [Convert]::ToHexString([Security.Cryptography.SHA256]::HashData($exclusionBytes)).ToLowerInvariant()
    return [pscustomobject][ordered]@{
        schema_version         = '1.0'
        ok                     = $Ok
        tree_id                = $TreeId
        is_empty               = ($Ok -and $TreeId -eq (Get-ContinuousCoReviewEmptyTreeId))
        included_ignored_count = $IncludedIgnoredCount
        machinery_paths        = @($MachineryPaths)
        excluded_path_patterns = @($canonicalExclusions)
        excluded_path_patterns_sha256 = $exclusionSha256
        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 }
    # These are LITERAL repository identities from `git ls-files`, not user globs. Without literal
    # pathspec magic a legal name holding metacharacters (`generated[1]`) would select unrelated
    # source and strip it from - or stage it into - the reviewed identity.
    # Called directly: the file-scope load above guarantees the primitive. The former per-path
    # `Get-Command` guard carried a hand-copied second implementation of it, which is the
    # duplicate-rule problem the primitive exists to remove (DRIFT-198-I009-018).
    $literalPaths = @($Paths | ForEach-Object { ConvertTo-ContinuousCoReviewLiteralPathspec -Path $_ })
    for ($i = 0; $i -lt $literalPaths.Count; $i += $ChunkSize) {
        $end = [Math]::Min($i + $ChunkSize, $literalPaths.Count) - 1
        $chunk = @($literalPaths[$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
    # T017 (FR-012): the METHODOLOGY MACHINERY excluded from the digest identity is the SAME single source the
    # WORKTREE strip uses - Get-ContinuousCoReviewMachineryPaths (core tool dirs + marker-detected + host-mirror
    # subdirs, context-aware). By construction the digest and worktree strip the SAME machinery, so they cannot
    # drift, and the identity covers EXACTLY the reviewable content the reviewer sees (machinery stripped from the
    # worktree is also out of the identity - NOT a false-allow: the reviewer never sees machinery, so it is not
    # reviewed source; .github/workflows and all non-machinery source stay IN both). Converted to strip patterns
    # (<path> for a file, <path>/** for a subtree). Applied to BOTH digest lists.
    # The ONE machinery source (Get-ContinuousCoReviewMachineryPaths) lives in worktree-reviewer.ps1, which _load.ps1
    # does NOT dot-source (it loads only shared leaf-modules). BOOTSTRAP it if absent (same pattern as the T100
    # process-tree helper). FAIL LOUDLY if it cannot be LOADED or EXECUTED - a silent no-strip would let the digest
    # identity DIVERGE from the worktree strip (both must derive machinery from the SAME resolver; maintainer
    # acceptance 2026-07-12). The worktree strip likewise throws if the resolver fails.
    if (-not (Get-Command -Name 'Get-ContinuousCoReviewMachineryPaths' -ErrorAction SilentlyContinue)) {
        $wrPath = Join-Path $PSScriptRoot 'worktree-reviewer.ps1'
        if (Test-Path -LiteralPath $wrPath -PathType Leaf) { try { . $wrPath } catch { $null = $_ } }
    }
    if (-not (Get-Command -Name 'Get-ContinuousCoReviewMachineryPaths' -ErrorAction SilentlyContinue)) {
        return New-ContinuousCoReviewDigestResult -Ok $false -FailureReason 'machinery-resolver-unavailable (the ONE FR-012 machinery resolver could not be loaded - refusing a digest that would diverge from the worktree strip)'
    }
    $machineryPaths = @()
    try {
        foreach ($m in @(Get-ContinuousCoReviewMachineryPaths -RepoRoot $resolvedRepoRoot)) {
            if ([string]::IsNullOrWhiteSpace($m)) { continue }
            $normalized = ([string]$m -replace '\\', '/').Trim('/')
            if ([string]::IsNullOrWhiteSpace($normalized)) { continue }
            $machineryPaths += $normalized
        }
        # Dedup on the worktree's real case rule. Sort-Object -Unique folds case by default, which
        # discarded one of two case-distinct machinery directories on a case-sensitive worktree and
        # left its files in the frozen candidate.
        $machineryComparer = Get-ContinuousCoReviewPathComparer -Path $resolvedRepoRoot -WhenUndetermined 'distinct'
        $machinerySet = [Collections.Generic.HashSet[string]]::new($machineryComparer)
        foreach ($candidate in $machineryPaths) { $null = $machinerySet.Add($candidate) }
        $machineryPaths = @($machinerySet | Sort-Object)
    }
    catch {
        return New-ContinuousCoReviewDigestResult -Ok $false -FailureReason ('machinery-resolver-failed: ' + [string]$_.Exception.Message)
    }
    $canonicalExclusions = @($ExcludedPathPatterns | ForEach-Object {
        $normalized = ([string]$_ -replace '\\', '/').Trim()
        while ($normalized.StartsWith('./', [StringComparison]::Ordinal)) { $normalized = $normalized.Substring(2) }
        $normalized
    })
    $canonicalExclusions = Get-ContinuousCoReviewOrdinalUniquePath -Path $canonicalExclusions
    # Dedup is load-bearing, and it must be ORDINAL. Sort-Object -Unique folds case by default, so on a
    # case-sensitive worktree `Foo/**` and `foo/**` - two DIFFERENT operator authorities - collapsed
    # to one, and the discarded subtree stayed in the reviewed digest and the materialized target
    # even though the operator explicitly excluded it (co-review finding, run
    # run-f198-i009-aab37c3b-codex-2). Keeping both spellings is safe on every volume: where the
    # volume folds case they select the same files, and where it does not they are genuinely
    # distinct authorities. Never fold here - that direction can only ever under-exclude.
    # `-CaseSensitive` was the earlier fix and was NOT enough: it flips only the case flag and leaves
    # the comparison culture-aware, so composed vs decomposed Unicode still collapsed
    # (DRIFT-198-I009-033). The primitive above is Ordinal in both dedup and ordering.
    # Machinery identities stay LITERAL and are passed separately; only the shipped strip globs and
    # the human's exclusion patterns are glob-evaluated.
    $stripList = @(Get-ContinuousCoReviewDigestRuntimeStripList) + @($canonicalExclusions)
    $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 {
        # core.filemode=false hosts (the Windows default): the filesystem carries NO executable bit,
        # so git preserves modes from the PRIOR index entry — but this digest stages into a FRESH
        # EMPTY index, where no prior entry exists. `git add -A` then stages every file as 100644,
        # silently stripping the bit from tracked 100755 entrypoints (bin/*, install.sh), and the
        # reviewer's baseline->digest diff fabricates a mode regression on every shipped Unix
        # wrapper (the recurring co-review phantom / DRIFT-198-I001-001). Capture the REAL index's
        # 100755 paths BEFORE switching indexes, and restore them after staging. Applied only when
        # filemode is off: on Unix the filesystem bit is authoritative and a deliberate working-tree
        # chmod must keep flowing into the digest. (Reused verbatim from Devin ec90e1b6, T034b partial.)
        $execBitPaths = @()
        $coreFilemode = ([string](& git config --get core.filemode 2>$null)).Trim()
        if ($coreFilemode -ieq 'false') {
            $rawIndexEntries = & git ls-files -z -s 2>$null
            if ($LASTEXITCODE -eq 0) {
                foreach ($indexEntry in (ConvertFrom-ContinuousCoReviewNulList -Raw $rawIndexEntries)) {
                    if ($indexEntry -match '^100755 [0-9a-f]{40,64} \d\t(.+)$') { $execBitPaths += $Matches[1] }
                }
            }
        }

        # Seed the temporary index from the repository's real index. This preserves every
        # tracked path (including a tracked file later matched by .gitignore), staged additions,
        # and executable modes without mutating the caller's index. `git add -A` then overlays
        # the current working tree and adds only untracked NON-IGNORED files. Starting from an
        # empty temporary index would incorrectly drop tracked-but-ignored paths.
        $realIndexOutput = & git rev-parse --git-path index 2>$null
        if ($LASTEXITCODE -ne 0) {
            return New-ContinuousCoReviewDigestResult -Ok $false -FailureReason 'git-index-path-unavailable' -ExcludedPathPatterns $canonicalExclusions
        }
        $realIndexPath = ([string](@($realIndexOutput) | Select-Object -First 1)).Trim()
        if (-not [IO.Path]::IsPathRooted($realIndexPath)) {
            $realIndexPath = [IO.Path]::GetFullPath((Join-Path $resolvedRepoRoot $realIndexPath))
        }
        if ([IO.File]::Exists($realIndexPath)) {
            Copy-Item -LiteralPath $realIndexPath -Destination $tempIndex -Force
        }
        $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' -ExcludedPathPatterns $canonicalExclusions
        }
        if ($execBitPaths.Count -gt 0) {
            # Only restore paths still present in the working tree: update-index aborts a whole
            # chunk on the first missing path (deleted-in-worktree file), and the batch helper
            # swallows that failure — which would leave later paths in the chunk unrestored.
            $execBitPaths = @($execBitPaths | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf })
            Invoke-ContinuousCoReviewGitPathBatch -GitArgs @('update-index', '--chmod=+x') -Paths $execBitPaths
        }

        # Deliberately do not force-add ignored files. `.gitignore` is the repository's product
        # source boundary for untracked content; build outputs, local settings, and runtime
        # databases must never inflate or destabilize a review candidate.
        $included = 0

        # 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 -LiteralPath $machineryPaths -CaseRoot $resolvedRepoRoot) {
                    $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' -ExcludedPathPatterns $canonicalExclusions
        }
        $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' -ExcludedPathPatterns $canonicalExclusions
        }

        return New-ContinuousCoReviewDigestResult -Ok $true -TreeId $treeId -IncludedIgnoredCount $included -MachineryPaths $machineryPaths -ExcludedPathPatterns $canonicalExclusions
    }
    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
    }
}