scripts/internal/lint-self-leak.ps1

# Self-leak firewall lint (F-198 / Proposal 205 W1, FR-033).
#
# Scans EXACTLY what ships to consumers - the module manifest's FileList
# (the deploy allowlist) - against the versioned deny-list of Specrew-SELF
# facts. A deny-listed term without an adjacent `specrew-self-ok: <reason>`
# annotation is a red build.
#
# Exit-code contract (public, CI keys off it):
# 0 = clean (annotated hits are listed with their reasons - visible, per
# the agent-action transparency NFR)
# 1 = unannotated findings present
# 2 = the deny-list or manifest is unreadable (fails LOUD - a broken rule
# file can never produce a silent green)
#
# Annotation semantics (same line, or the line immediately above the hit):
# .md <!-- specrew-self-ok: <reason> -->
# .ps1/.psd1/.yml # specrew-self-ok: <reason>
# A token with no reason text is treated as unannotated.

[CmdletBinding()]
param(
    [string]$ProjectRoot = (Get-Location).Path,
    [string]$DenyListPath,
    [string]$ManifestPath,
    # The consumer-deployed subset of the FileList: what init/update lands INSIDE
    # consumer projects (templates, squad runtime, the deployed extension). The
    # module's OWN docs/bin/engine scripts describe Specrew-the-product to its
    # users and legitimately name self-facts - they never enter a consumer tree.
    # (Proposal 205 W1 scope; the 204-W3/205-W3 deny-by-default manifest makes
    # this list the manifest's own truth in iteration 004.)
    [string[]]$ConsumerDeployedPrefixes = @('templates/', 'squad-templates/', 'extensions/specrew-speckit/'),
    [switch]$ListSurfaceOnly
)

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

$ruleDoc = 'docs/methodology/self-leak-firewall.md'

if ([string]::IsNullOrWhiteSpace($DenyListPath)) {
    $DenyListPath = Join-Path $ProjectRoot 'extensions/specrew-speckit/data/self-leak-deny-list.json'
}
if ([string]::IsNullOrWhiteSpace($ManifestPath)) {
    $ManifestPath = Join-Path $ProjectRoot 'Specrew.psd1'
}

function Read-SelfLeakDenyList {
    param([Parameter(Mandatory = $true)][string]$Path)

    if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
        throw ("Deny-list not found: {0}" -f $Path)
    }
    $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8
    $parsed = $raw | ConvertFrom-Json
    if ($null -eq $parsed.schema_version -or [string]::IsNullOrWhiteSpace([string]$parsed.schema_version)) {
        throw ("Deny-list at {0} has no schema_version." -f $Path)
    }
    # The REPO-side reader is version-LOCKED (the asymmetric contract): this lane and the list
    # ship from the same repo, so an unknown schema_version means changed semantics this reader
    # cannot honor - scanning anyway could false-green. Exit-2 loud. (The iteration-004
    # CONSUMER-side reader is the deliberately fail-open-WARN one; codex review catch,
    # run de8951f5.)
    $supportedSchemaVersions = @('1.0')
    if ([string]$parsed.schema_version -notin $supportedSchemaVersions) {
        throw ("Deny-list at {0} has schema_version '{1}' but this lane supports only: {2}. A newer list needs the matching lane." -f $Path, [string]$parsed.schema_version, ($supportedSchemaVersions -join ', '))
    }
    if ($null -eq $parsed.entries -or @($parsed.entries).Count -eq 0) {
        throw ("Deny-list at {0} has no entries." -f $Path)
    }
    $applicabilityKinds = @($parsed.applicability_annotation.kinds | ForEach-Object { [string]$_ })
    if ($applicabilityKinds.Count -ne 4 -or @('project-detected', 'profile-selected', 'provider-gated', 'example-only' | Where-Object { $_ -notin $applicabilityKinds }).Count -gt 0) {
        throw ("Deny-list at {0} does not declare the closed applicability kind set." -f $Path)
    }
    $compiled = @()
    foreach ($entry in $parsed.entries) {
        foreach ($required in @('pattern', 'class', 'reason', 'source', 'added')) {
            $value = $entry.PSObject.Properties[$required]
            if ($null -eq $value -or [string]::IsNullOrWhiteSpace([string]$value.Value)) {
                throw ("Deny-list entry missing required field '{0}' (pattern '{1}')." -f $required, $entry.pattern)
            }
        }
        $regex = [regex]::new([string]$entry.pattern)
        $compiled += [pscustomobject]@{
            Pattern = [string]$entry.pattern
            Regex   = $regex
            Class   = [string]$entry.class
            Reason  = [string]$entry.reason
        }
    }
    [pscustomobject]@{ SchemaVersion = [string]$parsed.schema_version; Entries = $compiled; ApplicabilityKinds = $applicabilityKinds }
}

function Get-SelfLeakScanSurface {
    param(
        [Parameter(Mandatory = $true)][string]$ManifestPath,
        [Parameter(Mandatory = $true)][string]$ProjectRoot,
        [Parameter(Mandatory = $true)][string]$DenyListPath,
        [Parameter(Mandatory = $true)][string[]]$ConsumerDeployedPrefixes
    )

    if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) {
        throw ("Manifest not found: {0}" -f $ManifestPath)
    }
    $manifest = Import-PowerShellDataFile -LiteralPath $ManifestPath
    if ($null -eq $manifest.FileList -or @($manifest.FileList).Count -eq 0) {
        throw ("Manifest at {0} has no FileList - the lint's scan surface IS the deploy allowlist." -f $ManifestPath)
    }
    $denyListFull = (Resolve-Path -LiteralPath $DenyListPath -ErrorAction SilentlyContinue)?.Path
    $surface = @()
    foreach ($relative in $manifest.FileList) {
        $normalized = ([string]$relative) -replace '\\', '/'
        $isConsumerDeployed = $false
        foreach ($prefix in $ConsumerDeployedPrefixes) {
            if ($normalized.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { $isConsumerDeployed = $true; break }
        }
        if (-not $isConsumerDeployed) { continue }
        $full = Join-Path $ProjectRoot $relative
        if (-not (Test-Path -LiteralPath $full -PathType Leaf)) { continue }
        $resolved = (Resolve-Path -LiteralPath $full).Path
        if ($denyListFull -and ($resolved -eq $denyListFull)) { continue } # the rule file itself is never its own finding
        $surface += [pscustomobject]@{ Relative = [string]$relative; Full = $resolved }
    }
    $surface
}

function Test-SelfLeakAnnotated {
    param(
        [Parameter(Mandatory = $true)][AllowEmptyString()][AllowEmptyCollection()][string[]]$Lines,
        [Parameter(Mandatory = $true)][int]$LineIndex,
        [Parameter(Mandatory = $true)][string]$FilePath
    )

    # The annotation FORM is part of the contract (a malformed suppression is a false-green
    # authorization path — independent-review catch, run b12861a6): .md sanctions ONLY inside an
    # HTML comment; hash-comment kinds (.ps1/.psd1/.psm1/.yml/.yaml/.sh + extensionless shell
    # wrappers) ONLY as a WHOLE-LINE `#` comment — the line's first non-whitespace character must be
    # the hash (review finding f4, run 20260714T190233598: an annotation-looking token inside a QUOTED
    # VALUE, e.g. $x = 'text # specrew-self-ok: r', previously matched and suppressed a deny-listed
    # leak; a mid-line hash is not provably comment syntax without a language parser, so the sanctioned
    # form is one that cannot sit inside a same-line string value. Residual ceiling: a here-string /
    # YAML block-scalar LINE can still start with '#' — a full parser is out of scope for this lint;
    # every sanctioned annotation in the deploy surface is a whole-line comment). File kinds with no
    # sanctioned form cannot be annotated at all (fail-closed: no bypass without a validatable form).
    $extension = [System.IO.Path]::GetExtension($FilePath)
    $pattern = switch -Regex ($extension) {
        '^\.(md|markdown)$' { '<!--\s*specrew-self-ok:(?<reason>([^-]|-(?!->))*)-->' ; break }
        '^\.(ps1|psd1|psm1|yml|yaml|sh)$' { '^\s*#[^#]*?specrew-self-ok:(?<reason>.+)$' ; break }
        '^$' { '^\s*#[^#]*?specrew-self-ok:(?<reason>.+)$' ; break }   # extensionless shell wrappers
        default { $null }
    }
    if ($null -eq $pattern) { return $false }

    foreach ($candidateIndex in @($LineIndex, ($LineIndex - 1))) {
        if ($candidateIndex -lt 0 -or $candidateIndex -ge $Lines.Count) { continue }
        $match = [regex]::Match($Lines[$candidateIndex], $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
        if (-not $match.Success) { continue }
        $reason = $match.Groups['reason'].Value.Trim()
        # Return the HUMAN's reason text (not just a boolean): the reason is the audit trail, and
        # the annotated output must surface it (agent-action transparency - a sanction whose
        # recorded WHY never reaches the operator is form without meaning; copilot review catch,
        # run 7b55bbc8).
        if (-not [string]::IsNullOrWhiteSpace($reason)) { return $reason }
    }
    return $null
}

function Get-SelfLeakApplicabilityAnnotation {
    param(
        [Parameter(Mandatory = $true)][AllowEmptyString()][AllowEmptyCollection()][string[]]$Lines,
        [Parameter(Mandatory = $true)][int]$LineIndex,
        [Parameter(Mandatory = $true)][string]$FilePath,
        [Parameter(Mandatory = $true)][string[]]$AllowedKinds
    )

    $extension = [System.IO.Path]::GetExtension($FilePath)
    $pattern = switch -Regex ($extension) {
        '^\.(md|markdown)$' { '<!--\s*specrew-applicability:\s*(?<kind>[a-z-]+)\s*;(?<reason>([^-]|-(?!->))*)-->' ; break }
        '^\.(ps1|psd1|psm1|yml|yaml|sh)$' { '^\s*#[^#]*?specrew-applicability:\s*(?<kind>[a-z-]+)\s*;(?<reason>.+)$' ; break }
        '^$' { '^\s*#[^#]*?specrew-applicability:\s*(?<kind>[a-z-]+)\s*;(?<reason>.+)$' ; break }
        default { $null }
    }
    if ($null -eq $pattern) { return [pscustomobject]@{ Valid = $false; Failure = 'file kind has no applicability annotation form'; Kind = $null; Reason = $null } }

    $found = [System.Collections.ArrayList]::new()
    foreach ($candidateIndex in @($LineIndex, ($LineIndex - 1))) {
        if ($candidateIndex -lt 0 -or $candidateIndex -ge $Lines.Count) { continue }
        foreach ($match in [regex]::Matches($Lines[$candidateIndex], $pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)) {
            $null = $found.Add($match)
        }
    }
    if ($found.Count -ne 1) {
        return [pscustomobject]@{ Valid = $false; Failure = $(if ($found.Count -eq 0) { 'missing applicability marker' } else { 'multiple applicability markers' }); Kind = $null; Reason = $null }
    }

    $kind = $found[0].Groups['kind'].Value.Trim().ToLowerInvariant()
    $reason = $found[0].Groups['reason'].Value.Trim()
    if ($kind -notin $AllowedKinds) {
        return [pscustomobject]@{ Valid = $false; Failure = "unsupported applicability kind '$kind'"; Kind = $kind; Reason = $reason }
    }
    if ([string]::IsNullOrWhiteSpace($reason)) {
        return [pscustomobject]@{ Valid = $false; Failure = 'applicability marker has no reason'; Kind = $kind; Reason = $reason }
    }
    if ($kind -eq 'example-only' -and $Lines[$LineIndex] -notmatch '(?i)\b(example|illustrative|non-binding|not\s+a\s+mandate)\b') {
        return [pscustomobject]@{ Valid = $false; Failure = 'example-only statement is not visibly non-binding'; Kind = $kind; Reason = $reason }
    }

    return [pscustomobject]@{ Valid = $true; Failure = $null; Kind = $kind; Reason = $reason }
}

try {
    $denyList = Read-SelfLeakDenyList -Path $DenyListPath
    $surface = Get-SelfLeakScanSurface -ManifestPath $ManifestPath -ProjectRoot $ProjectRoot -DenyListPath $DenyListPath -ConsumerDeployedPrefixes $ConsumerDeployedPrefixes
}
catch {
    Write-Host ("[self-leak-lint] UNREADABLE RULE SURFACE: {0}" -f $_.Exception.Message) -ForegroundColor Red
    Write-Host ("[self-leak-lint] A broken deny-list or manifest can never produce a silent green. See {0}." -f $ruleDoc) -ForegroundColor Red
    exit 2
}

if ($ListSurfaceOnly) {
    $surface | ForEach-Object { $_.Relative }
    exit 0
}

$findings = @()
$annotated = @()
foreach ($file in $surface) {
    $lines = @(Get-Content -LiteralPath $file.Full -Encoding UTF8)
    # An annotation on the hit line, or the line immediately above, sanctions the hit.
    for ($i = 0; $i -lt $lines.Count; $i++) {
        foreach ($entry in $denyList.Entries) {
            $match = $entry.Regex.Match($lines[$i])
            if (-not $match.Success) { continue }
            $isApplicabilityFinding = $entry.Class -in @('stack-assumption', 'delivery-assumption')
            $applicability = if ($isApplicabilityFinding) {
                Get-SelfLeakApplicabilityAnnotation -Lines $lines -LineIndex $i -FilePath $file.Full -AllowedKinds $denyList.ApplicabilityKinds
            }
            else { $null }
            $annotationReason = if ($isApplicabilityFinding) {
                if ($applicability.Valid) { '[{0}] {1}' -f $applicability.Kind, $applicability.Reason } else { $null }
            }
            else {
                Test-SelfLeakAnnotated -Lines $lines -LineIndex $i -FilePath $file.Full
            }
            $record = [pscustomobject]@{
                File             = $file.Relative
                Line             = $i + 1
                Matched          = $match.Value
                Class            = $entry.Class
                Reason           = $entry.Reason
                AnnotationReason = $annotationReason
                ApplicabilityFailure = if ($isApplicabilityFinding -and -not $applicability.Valid) { $applicability.Failure } else { $null }
            }
            if ($null -ne $annotationReason) {
                $annotated += $record
            }
            else {
                $findings += $record
            }
        }
    }
}

if (@($annotated).Count -gt 0) {
    Write-Host ("[self-leak-lint] {0} annotated hit(s) (sanctioned, reasons recorded):" -f @($annotated).Count) -ForegroundColor DarkYellow
    foreach ($hit in $annotated) {
        Write-Host (" [annotated] {0}:{1} '{2}' ({3}) - reason: {4}" -f $hit.File, $hit.Line, $hit.Matched, $hit.Class, $hit.AnnotationReason)
    }
}

if (@($findings).Count -gt 0) {
    Write-Host ("[self-leak-lint] RED: {0} unannotated Specrew-self fact(s) in the deploy surface:" -f @($findings).Count) -ForegroundColor Red
    foreach ($finding in $findings) {
        Write-Host (" {0}:{1}" -f $finding.File, $finding.Line) -ForegroundColor Red
        Write-Host (" matched: '{0}' class: {1}" -f $finding.Matched, $finding.Class) -ForegroundColor Red
        Write-Host (" why this is a self-fact: {0}" -f $finding.Reason) -ForegroundColor Red
        if ($finding.ApplicabilityFailure) {
            Write-Host (" applicability: {0}" -f $finding.ApplicabilityFailure) -ForegroundColor Red
        }
    }
    Write-Host ""
    Write-Host ("[self-leak-lint] To sanction an intentional self-reference, annotate the hit line (or the line above):") -ForegroundColor Yellow
    Write-Host (" .md: <!-- specrew-self-ok: <reason> -->") -ForegroundColor Yellow
    Write-Host (" .ps1/.psd1/.yml: # specrew-self-ok: <reason> (a WHOLE-LINE comment - the line must start with #)") -ForegroundColor Yellow
    Write-Host (" assumption classes: specrew-applicability: project-detected|profile-selected|provider-gated|example-only; <reason>") -ForegroundColor Yellow
    Write-Host ("[self-leak-lint] The rule and the resolution-point teaching live in {0}." -f $ruleDoc) -ForegroundColor Yellow
    exit 1
}

Write-Host ("[self-leak-lint] clean: {0} shipped files scanned, {1} deny-list entries (schema {2}), {3} annotated hit(s)." -f @($surface).Count, @($denyList.Entries).Count, $denyList.SchemaVersion, @($annotated).Count) -ForegroundColor Green
exit 0