extensions/specrew-speckit/scripts/test-consumer-assumptions.ps1

# specrew-self-ok: provenance comment citing the self-host feature that established this consumer firewall
# Consumer-side technology and delivery assumption advisory (F-198 / Proposal 205 W7-W8).
#
# This reader uses the same shipped deny-list as the repository lint, but scans the
# consumer project's authored instruction/governance surfaces. Findings are advisory:
# consumer files are never rewritten by this command. A damaged or newer rule surface
# warns instead of pretending to have checked semantics that this reader cannot know.

[CmdletBinding()]
param(
    [string]$ProjectPath = (Get-Location).Path,
    [string]$DenyListPath,
    [switch]$PassThru
)

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

$root = (Resolve-Path -LiteralPath $ProjectPath).Path
if ([string]::IsNullOrWhiteSpace($DenyListPath)) {
    $DenyListPath = Join-Path $root '.specify/extensions/specrew-speckit/data/self-leak-deny-list.json'
}

function Get-WholeCommentApplicability {
    param(
        [string[]]$Lines,
        [int]$LineIndex,
        [string]$Path,
        [string[]]$AllowedKinds
    )

    $extension = [System.IO.Path]::GetExtension($Path)
    $pattern = switch -Regex ($extension) {
        '^\.(md|markdown)$' { '<!--\s*specrew-applicability:\s*(?<kind>[a-z-]+)\s*;(?<reason>([^-]|-(?!->))*)-->' ; break }
        '^\.(ps1|psd1|psm1|yml|yaml|sh|py|toml)$' { '^\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 }
    }
    [pscustomobject]@{ Valid = $true; Failure = $null; Kind = $kind; Reason = $reason }
}

function Get-ConsumerAssumptionSurface {
    param([string]$Root)

    $extensions = @('.md', '.markdown', '.yml', '.yaml', '.ps1', '.psd1', '.psm1', '.sh', '.py', '.toml', '.json')
    $files = [System.Collections.ArrayList]::new()
    foreach ($relativeRoot in @('.github', '.specify', '.squad', 'specs', 'docs')) {
        $candidate = Join-Path $Root $relativeRoot
        if (-not (Test-Path -LiteralPath $candidate -PathType Container)) { continue }
        foreach ($file in @(Get-ChildItem -LiteralPath $candidate -Recurse -File -ErrorAction SilentlyContinue)) {
            $normalized = $file.FullName.Replace('\', '/')
            if ($normalized -match '/(?:\.git|node_modules|\.specrew/review)/') { continue }
            if ($normalized -match '/\.specify/extensions/specrew-speckit/(?:data|scripts)/') { continue }
            if ($file.Extension.ToLowerInvariant() -notin $extensions) { continue }
            $null = $files.Add($file)
        }
    }
    foreach ($relative in @('AGENTS.md', 'CLAUDE.md')) {
        $candidate = Join-Path $Root $relative
        if (Test-Path -LiteralPath $candidate -PathType Leaf) { $null = $files.Add((Get-Item -LiteralPath $candidate)) }
    }
    # Dedupe file IDENTITIES ordinally. `Sort-Object FullName -Unique` is case-insensitive AND
    # culture-aware, so on a case-sensitive worktree holding `docs/Policy.md` and `docs/policy.md` one
    # file was silently dropped before scanning - and an unqualified technology or delivery mandate in
    # the discarded file then produced no advisory finding at all, defeating the FR-046 applicability
    # firewall (DRIFT-198-I009-040). Ordering stays `Sort-Object` because it is presentation only: the
    # scan result does not depend on it, whereas MEMBERSHIP does.
    $seenFullName = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
    $uniqueFiles = [System.Collections.Generic.List[object]]::new()
    foreach ($candidateFile in $files) {
        if ($null -eq $candidateFile) { continue }
        if ($seenFullName.Add([string]$candidateFile.FullName)) { $uniqueFiles.Add($candidateFile) }
    }
    @($uniqueFiles | Sort-Object FullName)
}

$warnings = [System.Collections.ArrayList]::new()
$findings = [System.Collections.ArrayList]::new()
$scannedFiles = 0
$rulesValid = $false

try {
    if (-not (Test-Path -LiteralPath $DenyListPath -PathType Leaf)) { throw "deny-list not found: $DenyListPath" }
    $list = Get-Content -LiteralPath $DenyListPath -Raw -Encoding UTF8 | ConvertFrom-Json
    if ([string]$list.schema_version -ne '1.0') { throw "unsupported deny-list schema_version '$($list.schema_version)'" }
    $allowedKinds = @($list.applicability_annotation.kinds | ForEach-Object { [string]$_ })
    if ($allowedKinds.Count -ne 4 -or @('project-detected', 'profile-selected', 'provider-gated', 'example-only' | Where-Object { $_ -notin $allowedKinds }).Count -gt 0) {
        throw 'deny-list does not declare the closed applicability kind set'
    }
    $rules = @($list.entries | Where-Object { [string]$_.class -in @('stack-assumption', 'delivery-assumption') } | ForEach-Object {
            [pscustomobject]@{ Class = [string]$_.class; Regex = [regex]::new([string]$_.pattern); Reason = [string]$_.reason }
        })
    if ($rules.Count -ne 2) { throw "expected one stack-assumption and one delivery-assumption rule; found $($rules.Count)" }
    $rulesValid = $true

    foreach ($file in @(Get-ConsumerAssumptionSurface -Root $root)) {
        $scannedFiles++
        $lines = @(Get-Content -LiteralPath $file.FullName -Encoding UTF8)
        for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
            foreach ($rule in $rules) {
                $match = $rule.Regex.Match($lines[$lineIndex])
                if (-not $match.Success) { continue }
                $annotation = Get-WholeCommentApplicability -Lines $lines -LineIndex $lineIndex -Path $file.FullName -AllowedKinds $allowedKinds
                if ($annotation.Valid) { continue }
                $relative = [System.IO.Path]::GetRelativePath($root, $file.FullName).Replace('\', '/')
                $null = $findings.Add([pscustomobject]@{
                        path = $relative
                        line = $lineIndex + 1
                        class = $rule.Class
                        term = $match.Value
                        failure = $annotation.Failure
                    })
            }
        }
    }
}
catch {
    $null = $warnings.Add("Consumer assumption rules were not evaluated: $($_.Exception.Message)")
}

$result = [pscustomobject]@{
    schema_version = '1.0'
    advisory = $true
    rules_valid = $rulesValid
    scanned_files = $scannedFiles
    finding_count = $findings.Count
    findings = @($findings)
    warnings = @($warnings)
}

foreach ($warning in $warnings) { Write-Warning $warning }
foreach ($finding in $findings) {
    Write-Warning ("Consumer assumption: {0}:{1} [{2}] '{3}' ({4}). Add exactly one adjacent 'specrew-applicability: <kind>; <reason>' whole-comment marker or remove the mandate." -f $finding.path, $finding.line, $finding.class, $finding.term, $finding.failure)
}
if ($rulesValid -and $findings.Count -eq 0) {
    Write-Host ("Consumer assumption advisory: clean ({0} files scanned)." -f $scannedFiles) -ForegroundColor Green
}
elseif ($rulesValid) {
    Write-Host ("Consumer assumption advisory: {0} finding(s); user-authored files were not changed." -f $findings.Count) -ForegroundColor Yellow
}

if ($PassThru) { $result }
exit 0