extensions/specrew-speckit/scripts/run-mechanical-checks.ps1

[CmdletBinding()]
param(
    [string]$ProjectPath = (Get-Location).Path,
    [string]$FeaturePath,
    [string]$IterationPath,
    [string]$SpecPath,
    [string]$DispositionPath,
    [ValidateSet('Object', 'Json')]
    [string]$OutputFormat = 'Object'
)

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

$sharedGovernancePath = Join-Path $PSScriptRoot 'shared-governance.ps1'
if (-not (Test-Path -LiteralPath $sharedGovernancePath -PathType Leaf)) {
    throw "Shared governance helper not found at '$sharedGovernancePath'."
}
. $sharedGovernancePath

function Convert-ToRepoRelativePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$BasePath,

        [Parameter(Mandatory = $true)]
        [string]$TargetPath
    )

    # Cross-platform safe replacement for the legacy [System.Uri] MakeRelativeUri pattern,
    # which fails on Linux for bare absolute paths.
    $baseFull = [System.IO.Path]::GetFullPath($BasePath)
    $targetFull = [System.IO.Path]::GetFullPath($TargetPath)
    return ([System.IO.Path]::GetRelativePath($baseFull, $targetFull)) -replace '\\', '/'
}

function Get-MarkdownSectionTable {
    param(
        [AllowEmptyString()]
        [string[]]$Lines,
        [string]$Heading
    )

    $headingPattern = '^#{2,3}\s+' + [regex]::Escape($Heading) + '\b'
    $startIndex = -1
    for ($index = 0; $index -lt $Lines.Count; $index++) {
        if ($Lines[$index] -match $headingPattern) {
            $startIndex = $index
            break
        }
    }

    if ($startIndex -lt 0) {
        return @()
    }

    $tableLines = New-Object System.Collections.Generic.List[string]
    for ($index = $startIndex + 1; $index -lt $Lines.Count; $index++) {
        $currentLine = $Lines[$index]
        if ($currentLine -match '^#{2,3}\s+') {
            break
        }

        if ($currentLine.Trim().StartsWith('|')) {
            $null = $tableLines.Add($currentLine)
        }
    }

    if ($tableLines.Count -lt 2) {
        return @()
    }

    $headers = ($tableLines[0].Trim('|') -split '\|') | ForEach-Object { $_.Trim() }
    $rows = New-Object System.Collections.Generic.List[object]
    for ($rowIndex = 1; $rowIndex -lt $tableLines.Count; $rowIndex++) {
        $cells = ($tableLines[$rowIndex].Trim('|') -split '\|') | ForEach-Object { $_.Trim() }
        $isSeparator = $true
        foreach ($cell in $cells) {
            if ($cell -notmatch '^:?-{3,}:?$') {
                $isSeparator = $false
                break
            }
        }

        if ($isSeparator) {
            continue
        }

        $row = [ordered]@{}
        for ($cellIndex = 0; $cellIndex -lt $headers.Count; $cellIndex++) {
            $value = if ($cellIndex -lt $cells.Count) { $cells[$cellIndex] } else { '' }
            $row[$headers[$cellIndex]] = $value
        }

        $rows.Add([pscustomobject]$row)
    }

    return $rows.ToArray()
}

function Get-MarkdownMetadataValue {
    param(
        [string[]]$Lines,
        [string]$Label
    )

    $pattern = '^\*\*' + [regex]::Escape($Label) + '\*\*:\s*(.+?)\s*$'
    foreach ($line in $Lines) {
        if ($line -match $pattern) {
            return $Matches[1].Trim()
        }
    }

    return $null
}

function Normalize-MarkdownCell {
    param([AllowNull()][string]$Value)

    if ($null -eq $Value) {
        return ''
    }

    return $Value.Trim().Trim('`')
}

function Get-DefaultRequirementRefsForGate {
    param(
        [Parameter(Mandatory = $true)]
        [string]$GateId
    )

    switch ($GateId) {
        'dead-field' { return @('FR-011', 'FR-027', 'FR-030') }
        'anti-pattern' { return @('FR-011', 'FR-028', 'FR-030') }
        'test-integrity' { return @('FR-011', 'FR-029', 'FR-030') }
        'stack-tooling-evidence' { return @('FR-011') }
        'quality-lens-review' { return @('FR-011', 'FR-012') }
        'concurrency-correctness-review' { return @('FR-011', 'FR-012', 'FR-015') }
        'resiliency-semantics-review' { return @('FR-011', 'FR-012', 'FR-015') }
        'retry-idempotency-review' { return @('FR-011', 'FR-012', 'FR-015') }
        default { return @('FR-011') }
    }
}

function Resolve-QualityEvidenceSource {
    param(
        [AllowNull()][string]$Value,
        [Parameter(Mandatory = $true)]
        [string]$FeatureId,
        [Parameter(Mandatory = $true)]
        [string]$IterationNumber,
        [Parameter(Mandatory = $true)]
        [string]$FindingsRef,
        [Parameter(Mandatory = $true)]
        [string]$EvidenceRef
    )

    $normalized = Normalize-MarkdownCell $Value
    if ([string]::IsNullOrWhiteSpace($normalized)) {
        return $EvidenceRef
    }

    $resolved = $normalized.Replace('specs/<feature>/iterations/<NNN>/quality/mechanical-findings.json', $FindingsRef)
    $resolved = $resolved.Replace('specs/<feature>/iterations/<NNN>/quality/quality-evidence.md', $EvidenceRef)
    $resolved = $resolved.Replace('<feature>', $FeatureId)
    $resolved = $resolved.Replace('<NNN>', $IterationNumber)
    return $resolved
}

function Get-DefaultQualityGateRows {
    return @(
        [pscustomobject]@{ 'Required Quality Gate' = 'dead-field'; Category = 'mechanical'; 'Evidence Source' = 'specs/<feature>/iterations/<NNN>/quality/mechanical-findings.json' }
        [pscustomobject]@{ 'Required Quality Gate' = 'anti-pattern'; Category = 'mechanical'; 'Evidence Source' = 'specs/<feature>/iterations/<NNN>/quality/mechanical-findings.json' }
        [pscustomobject]@{ 'Required Quality Gate' = 'test-integrity'; Category = 'mechanical'; 'Evidence Source' = 'specs/<feature>/iterations/<NNN>/quality/mechanical-findings.json' }
        [pscustomobject]@{ 'Required Quality Gate' = 'stack-tooling-evidence'; Category = 'tooling'; 'Evidence Source' = 'specs/<feature>/iterations/<NNN>/quality/quality-evidence.md' }
        [pscustomobject]@{ 'Required Quality Gate' = 'quality-lens-review'; Category = 'manual-evidence'; 'Evidence Source' = 'specs/<feature>/iterations/<NNN>/quality/quality-evidence.md' }
    )
}

function Get-ExistingQualityEvidenceState {
    param([string]$QualityEvidencePath)

    $rowsByGate = @{}
    $reviewedBy = $null
    $reviewedAt = $null

    if (Test-Path -LiteralPath $QualityEvidencePath -PathType Leaf) {
        $evidenceLines = @(Get-Content -LiteralPath $QualityEvidencePath -Encoding UTF8)
        $reviewedBy = Get-MarkdownMetadataValue -Lines $evidenceLines -Label 'Reviewed By'
        $reviewedAt = Get-MarkdownMetadataValue -Lines $evidenceLines -Label 'Reviewed At'

        foreach ($row in @(Get-MarkdownSectionTable -Lines $evidenceLines -Heading 'Gate Matrix')) {
            $gateId = Normalize-MarkdownCell ([string]$row.Gate)
            if ([string]::IsNullOrWhiteSpace($gateId)) {
                continue
            }

            $rowsByGate[$gateId] = [pscustomobject]@{
                Requirement   = Normalize-MarkdownCell ([string]$row.Requirement)
                EvidenceSource = Normalize-MarkdownCell ([string]$row.'Evidence Source')
                Status        = Normalize-MarkdownCell ([string]$row.Status)
                Exception     = Normalize-MarkdownCell ([string]$row.Exception)
            }
        }
    }

    return [pscustomobject]@{
        RowsByGate = $rowsByGate
        ReviewedBy = $reviewedBy
        ReviewedAt = $reviewedAt
    }
}

function Get-MechanicalGateOverrides {
    param(
        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [object[]]$Findings,
        [Parameter(Mandatory = $true)]
        [string]$FindingsRef
    )

    $overrides = @{}
    foreach ($gateId in @('dead-field', 'anti-pattern', 'test-integrity')) {
        $gateFindings = @($Findings | Where-Object { [string]$_.gateId -eq $gateId })
        $status = 'passed'
        $exception = '—'

        if ($gateFindings.Count -gt 0) {
            $demotedRefs = @(
                $gateFindings |
                    Where-Object { $_.demoted -and -not [string]::IsNullOrWhiteSpace([string]$_.dispositionRef) } |
                    ForEach-Object { [string]$_.dispositionRef } |
                    Select-Object -Unique
            )

            if ($gateFindings.Count -eq $demotedRefs.Count -and $demotedRefs.Count -gt 0) {
                $status = 'excepted'
                $exception = $demotedRefs -join ', '
            }
            else {
                $status = 'failed'
            }
        }

        $overrides[$gateId] = [pscustomobject]@{
            Requirement   = (Get-DefaultRequirementRefsForGate -GateId $gateId) -join ', '
            EvidenceSource = $FindingsRef
            Status        = $status
            Exception     = $exception
        }
    }

    return $overrides
}

function Get-QualityEvidenceContent {
    param(
        [AllowEmptyString()]
        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [string[]]$PlanLines,
        [Parameter(Mandatory = $true)]
        [string]$FeatureId,
        [Parameter(Mandatory = $true)]
        [string]$IterationNumber,
        [Parameter(Mandatory = $true)]
        [string]$FindingsRef,
        [Parameter(Mandatory = $true)]
        [string]$EvidenceRef,
        [Parameter(Mandatory = $true)]
        [hashtable]$ExistingRows,
        [Parameter(Mandatory = $true)]
        [hashtable]$Overrides,
        [Parameter(Mandatory = $true)]
        [string]$ReviewedBy,
        [Parameter(Mandatory = $true)]
        [string]$ReviewedAt
    )

    $profileRef = Normalize-MarkdownCell (Get-MarkdownMetadataValue -Lines $PlanLines -Label 'Inferred Quality Profile')
    if ([string]::IsNullOrWhiteSpace($profileRef)) {
        $profileRef = 'quality-profile.pending'
    }

    $presetRefs = Normalize-MarkdownCell (Get-MarkdownMetadataValue -Lines $PlanLines -Label 'Selected preset ref or explicit custom composition')
    if ([string]::IsNullOrWhiteSpace($presetRefs)) {
        $presetRefs = '(pending preset selection)'
    }

    $gateRows = @(Get-MarkdownSectionTable -Lines $PlanLines -Heading 'Required Quality Gates')
    if ($gateRows.Count -eq 0) {
        $gateRows = @(Get-DefaultQualityGateRows)
    }
    $lines = [System.Collections.Generic.List[string]]::new()
    $null = $lines.Add("# Quality Evidence: Iteration $IterationNumber")
    $null = $lines.Add('')
    $null = $lines.Add(('**Profile Ref**: `' + $profileRef + '`'))
    $null = $lines.Add(('**Preset Refs**: ' + $presetRefs))
    $null = $lines.Add(('**Findings Ref**: `' + $FindingsRef + '`'))
    $null = $lines.Add(('**Reviewed By**: ' + $ReviewedBy))
    $null = $lines.Add(('**Reviewed At**: ' + $ReviewedAt))
    $null = $lines.Add('')
    $null = $lines.Add('## Gate Matrix')
    $null = $lines.Add('')
    $null = $lines.Add('| Gate | Requirement | Evidence Source | Status | Exception |')
    $null = $lines.Add('| --- | --- | --- | --- | --- |')

    foreach ($gateRow in $gateRows) {
        $gateId = Normalize-MarkdownCell ([string]$gateRow.'Required Quality Gate')
        if ([string]::IsNullOrWhiteSpace($gateId)) {
            continue
        }

        $requirement = (Get-DefaultRequirementRefsForGate -GateId $gateId) -join ', '
        $evidenceSource = Resolve-QualityEvidenceSource `
            -Value ([string]$gateRow.'Evidence Source') `
            -FeatureId $FeatureId `
            -IterationNumber $IterationNumber `
            -FindingsRef $FindingsRef `
            -EvidenceRef $EvidenceRef
        $status = 'planned'
        $exception = '—'

        if ($ExistingRows.ContainsKey($gateId)) {
            $existingRow = $ExistingRows[$gateId]
            if (-not [string]::IsNullOrWhiteSpace($existingRow.Requirement)) {
                $requirement = $existingRow.Requirement
            }
            if (-not [string]::IsNullOrWhiteSpace($existingRow.EvidenceSource)) {
                $evidenceSource = $existingRow.EvidenceSource
            }
            if (-not [string]::IsNullOrWhiteSpace($existingRow.Status)) {
                $status = $existingRow.Status
            }
            if (-not [string]::IsNullOrWhiteSpace($existingRow.Exception)) {
                $exception = $existingRow.Exception
            }
        }

        if ($Overrides.ContainsKey($gateId)) {
            $override = $Overrides[$gateId]
            if ($override.Requirement) {
                $requirement = $override.Requirement
            }
            if ($override.EvidenceSource) {
                $evidenceSource = $override.EvidenceSource
            }
            if ($override.Status) {
                $status = $override.Status
            }
            if ($override.Exception) {
                $exception = $override.Exception
            }
        }

        $null = $lines.Add(('| `{0}` | {1} | `{2}` | `{3}` | `{4}` |' -f $gateId, $requirement, $evidenceSource, $status, $exception))
    }

    return ($lines -join [Environment]::NewLine) + [Environment]::NewLine
}

function Get-ExtensionVersion {
    $extensionPath = Join-Path $PSScriptRoot '..\extension.yml'
    $extensionPath = [System.IO.Path]::GetFullPath($extensionPath)
    if (-not (Test-Path -LiteralPath $extensionPath -PathType Leaf)) {
        return '0.0.0'
    }

    foreach ($line in Get-Content -LiteralPath $extensionPath -Encoding UTF8) {
        if ($line -match '^\s+version:\s*"?(?<version>[^"#]+?)"?\s*$') {
            return $Matches.version.Trim()
        }
    }

    return '0.0.0'
}

function Get-DependencyNames {
    param(
        [Parameter(Mandatory = $true)]
        [string]$PackageJsonPath
    )

    if (-not (Test-Path -LiteralPath $PackageJsonPath -PathType Leaf)) {
        return @()
    }

    try {
        $packageJson = Get-Content -LiteralPath $PackageJsonPath -Raw -Encoding UTF8 | ConvertFrom-Json -AsHashtable
    }
    catch {
        return @()
    }

    $dependencies = [System.Collections.Generic.List[string]]::new()
    foreach ($propertyName in @('dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies')) {
        if (-not $packageJson.ContainsKey($propertyName)) {
            continue
        }

        $propertyValue = $packageJson[$propertyName]
        if ($propertyValue -isnot [System.Collections.IDictionary]) {
            continue
        }

        foreach ($dependencyName in $propertyValue.Keys) {
            $dependency = [string]$dependencyName
            if (-not [string]::IsNullOrWhiteSpace($dependency) -and -not $dependencies.Contains($dependency.ToLowerInvariant())) {
                $null = $dependencies.Add($dependency.ToLowerInvariant())
            }
        }
    }

    return $dependencies.ToArray()
}

function Resolve-MechanicalContext {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectRoot,
        [string]$FeaturePath,
        [string]$IterationPath,
        [string]$SpecPath
    )

    $resolvedFeaturePath = $null
    if (-not [string]::IsNullOrWhiteSpace($FeaturePath)) {
        $resolvedFeaturePath = Resolve-ProjectPath -Path $FeaturePath
    }
    elseif (-not [string]::IsNullOrWhiteSpace($SpecPath)) {
        $resolvedFeaturePath = Split-Path -Parent (Resolve-ProjectPath -Path $SpecPath)
    }

    $resolvedIterationPath = $null
    if (-not [string]::IsNullOrWhiteSpace($IterationPath)) {
        $resolvedIterationPath = Resolve-ProjectPath -Path $IterationPath
        if ([string]::IsNullOrWhiteSpace($resolvedFeaturePath)) {
            $resolvedFeaturePath = Split-Path -Parent (Split-Path -Parent $resolvedIterationPath)
        }
    }

    if ([string]::IsNullOrWhiteSpace($resolvedFeaturePath)) {
        $specsRoot = Join-Path $ProjectRoot 'specs'
        if (Test-Path -LiteralPath $specsRoot -PathType Container) {
            $featureCandidates = @(Get-ChildItem -LiteralPath $specsRoot -Directory | Where-Object {
                    Test-Path -LiteralPath (Join-Path $_.FullName 'spec.md') -PathType Leaf
                } | Sort-Object Name)

            if ($featureCandidates.Count -eq 1) {
                $resolvedFeaturePath = $featureCandidates[0].FullName
            }
            elseif ($featureCandidates.Count -gt 1) {
                $iterationCandidates = foreach ($featureCandidate in $featureCandidates) {
                    $iterationsRoot = Join-Path $featureCandidate.FullName 'iterations'
                    if (-not (Test-Path -LiteralPath $iterationsRoot -PathType Container)) {
                        continue
                    }

                    foreach ($directory in Get-ChildItem -LiteralPath $iterationsRoot -Directory | Sort-Object Name) {
                        $numericValue = 0
                        if ([int]::TryParse($directory.Name, [ref]$numericValue)) {
                            [pscustomobject]@{
                                FeaturePath = $featureCandidate.FullName
                                IterationPath = $directory.FullName
                                IterationNumber = $numericValue
                            }
                        }
                    }
                }

                $selectedCandidate = @($iterationCandidates | Sort-Object IterationNumber -Descending | Select-Object -First 1)[0]
                if ($null -ne $selectedCandidate) {
                    $resolvedFeaturePath = $selectedCandidate.FeaturePath
                    if ([string]::IsNullOrWhiteSpace($resolvedIterationPath)) {
                        $resolvedIterationPath = $selectedCandidate.IterationPath
                    }
                }
            }
        }
    }

    if ([string]::IsNullOrWhiteSpace($resolvedFeaturePath)) {
        throw 'Unable to resolve a feature path for mechanical checks. Provide -FeaturePath, -SpecPath, or -IterationPath.'
    }

    if ([string]::IsNullOrWhiteSpace($resolvedIterationPath)) {
        $iterationsRoot = Join-Path $resolvedFeaturePath 'iterations'
        if (Test-Path -LiteralPath $iterationsRoot -PathType Container) {
            $iterationDirectories = foreach ($directory in Get-ChildItem -LiteralPath $iterationsRoot -Directory | Sort-Object Name) {
                $numericValue = 0
                if ([int]::TryParse($directory.Name, [ref]$numericValue)) {
                    [pscustomobject]@{
                        FullName = $directory.FullName
                        IterationNumber = $numericValue
                    }
                }
            }

            $resolvedIterationPath = @($iterationDirectories | Sort-Object IterationNumber -Descending | Select-Object -First 1 | ForEach-Object { $_.FullName })[0]
        }
    }

    if ([string]::IsNullOrWhiteSpace($resolvedIterationPath)) {
        throw "Unable to resolve an iteration path under '$resolvedFeaturePath'."
    }

    $resolvedSpecPath = if (-not [string]::IsNullOrWhiteSpace($SpecPath)) {
        Resolve-ProjectPath -Path $SpecPath
    }
    else {
        Join-Path $resolvedFeaturePath 'spec.md'
    }

    $schemaPath = Join-Path $resolvedFeaturePath 'contracts\mechanical-findings.schema.json'
    if (-not (Test-Path -LiteralPath $schemaPath -PathType Leaf)) {
        throw "Mechanical findings schema not found at '$schemaPath'."
    }

    $packageJsonPath = Join-Path $ProjectRoot 'package.json'
    $dependencies = @(Get-DependencyNames -PackageJsonPath $packageJsonPath)
    $surfaceId = 'project-default-surface'
    if (($dependencies -contains 'ws' -or $dependencies -contains 'socket.io') -and ($dependencies -contains 'express' -or $dependencies -contains 'fastify')) {
        $surfaceId = 'node-public-ws-service'
    }
    elseif (($dependencies -contains 'express' -or $dependencies -contains '@nestjs/core') -and $dependencies -contains 'pg') {
        $surfaceId = 'node-rest-with-postgres'
    }
    elseif ($dependencies -contains 'react') {
        $surfaceId = 'react-spa-public'
    }

    return [pscustomobject]@{
        ProjectRoot = $ProjectRoot
        FeaturePath = $resolvedFeaturePath
        IterationPath = $resolvedIterationPath
        SpecPath = $resolvedSpecPath
        SchemaPath = $schemaPath
        FeatureRef = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $resolvedSpecPath
        IterationRef = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $resolvedIterationPath
        SurfaceId = $surfaceId
    }
}

function Get-CandidateCodeFiles {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectRoot
    )

    $allowedExtensions = @('.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.ps1', '.py', '.cs', '.go', '.java', '.kt')
    $excludedPattern = '(^|[\\/])(\.git|\.specify|\.squad|\.scratch|node_modules|dist|build|coverage|docs|evaluation|specs)([\\/]|$)'
    $preferredRoots = @('src', 'server', 'app', 'lib', 'client', 'tests', 'test')
    $searchRoots = [System.Collections.Generic.List[string]]::new()

    foreach ($preferredRoot in $preferredRoots) {
        $candidatePath = Join-Path $ProjectRoot $preferredRoot
        if (Test-Path -LiteralPath $candidatePath -PathType Container) {
            $null = $searchRoots.Add($candidatePath)
        }
    }

    if ($searchRoots.Count -eq 0) {
        $null = $searchRoots.Add($ProjectRoot)
    }

    $results = [System.Collections.Generic.List[System.IO.FileInfo]]::new()
    foreach ($searchRoot in $searchRoots) {
        foreach ($file in Get-ChildItem -LiteralPath $searchRoot -Recurse -File) {
            if (($allowedExtensions -contains $file.Extension.ToLowerInvariant()) -and ($file.FullName -notmatch $excludedPattern)) {
                $null = $results.Add($file)
            }
        }
    }

    return $results.ToArray()
}

function Get-CandidateTestFiles {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectRoot
    )

    $testPattern = '(^|[\\/])(test|tests|__tests__)([\\/]|$)|\.(spec|test)\.[^.]+$'
    return @(Get-CandidateCodeFiles -ProjectRoot $ProjectRoot | Where-Object { $_.FullName -match $testPattern })
}

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

    return @(Get-Content -LiteralPath $Path -Encoding UTF8)
}

function Get-RuleDispositions {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectRoot,

        [Parameter(Mandatory = $true)]
        [string]$IterationPath,

        [string]$DispositionPath
    )

    $dispositions = @{}
    $candidatePaths = [System.Collections.Generic.List[string]]::new()

    if (-not [string]::IsNullOrWhiteSpace($DispositionPath)) {
        $null = $candidatePaths.Add((Resolve-ProjectPath -Path $DispositionPath))
    }

    $dispositionDirectory = Join-Path $IterationPath 'quality\dispositions'
    if (Test-Path -LiteralPath $dispositionDirectory -PathType Container) {
        foreach ($file in Get-ChildItem -LiteralPath $dispositionDirectory -File) {
            $null = $candidatePaths.Add($file.FullName)
        }
    }

    foreach ($candidatePath in $candidatePaths) {
        if (-not (Test-Path -LiteralPath $candidatePath -PathType Leaf)) {
            continue
        }

        if ($candidatePath -match '\.json$') {
            try {
                $payload = Get-Content -LiteralPath $candidatePath -Raw -Encoding UTF8 | ConvertFrom-Json -Depth 32
            }
            catch {
                continue
            }

            $hasRulesProperty = $false
            if ($payload -isnot [System.Collections.IEnumerable] -or $payload -is [string]) {
                $hasRulesProperty = ($null -ne ($payload.PSObject.Properties['rules']))
            }

            $records = if ($payload -is [System.Collections.IEnumerable] -and $payload -isnot [string]) {
                @($payload)
            }
            elseif ($hasRulesProperty -and $null -ne $payload.rules) {
                @($payload.rules)
            }
            else {
                @($payload)
            }

            foreach ($record in $records) {
                $ruleId = [string]$record.ruleId
                if ([string]::IsNullOrWhiteSpace($ruleId)) {
                    continue
                }

                $severity = [string]$record.severity
                $dispositionRef = [string]$record.dispositionRef
                if ([string]::IsNullOrWhiteSpace($dispositionRef)) {
                    $dispositionRef = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $candidatePath
                }

                $dispositions[$ruleId.ToLowerInvariant()] = [pscustomobject]@{
                    ruleId = $ruleId
                    severity = $severity
                    dispositionRef = $dispositionRef
                }
            }

            continue
        }

        $content = Get-Content -LiteralPath $candidatePath -Raw -Encoding UTF8
        $ruleId = $null
        $severity = $null

        foreach ($pattern in @(
                '(?im)^\s*rule[_-]?id\s*:\s*["'']?(?<value>[^"'']+)["'']?\s*$',
                '(?im)^\s*\*\*Rule ID\*\*\s*:\s*`?(?<value>[^`\r\n]+)`?\s*$'
            )) {
            $match = [regex]::Match($content, $pattern)
            if ($match.Success) {
                $ruleId = $match.Groups['value'].Value.Trim()
                break
            }
        }

        foreach ($pattern in @(
                '(?im)^\s*new[_-]?behavior\s*:\s*["'']?(?<value>[^"'']+)["'']?\s*$',
                '(?im)^\s*\*\*New Behavior\*\*\s*:\s*`?(?<value>[^`\r\n]+)`?\s*$'
            )) {
            $match = [regex]::Match($content, $pattern)
            if ($match.Success) {
                $severity = switch -Regex ($match.Groups['value'].Value.Trim().ToLowerInvariant()) {
                    'advisory|warning' { 'warning' }
                    'info|informational' { 'info' }
                    default { '' }
                }
                break
            }
        }

        if (-not [string]::IsNullOrWhiteSpace($ruleId)) {
            $dispositions[$ruleId.ToLowerInvariant()] = [pscustomobject]@{
                ruleId = $ruleId
                severity = $severity
                dispositionRef = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $candidatePath
            }
        }
    }

    return $dispositions
}

function New-MechanicalFinding {
    param(
        [Parameter(Mandatory = $true)]
        [string]$GateId,

        [Parameter(Mandatory = $true)]
        [string]$RuleId,

        [Parameter(Mandatory = $true)]
        [string]$SurfaceId,

        [Parameter(Mandatory = $true)]
        [string]$Severity,

        [Parameter(Mandatory = $true)]
        [string]$Message,

        [Parameter(Mandatory = $true)]
        [string]$Remediation,

        [Parameter(Mandatory = $true)]
        [string]$SourcePath,

        [Parameter(Mandatory = $true)]
        [int]$SourceLine,

        [int]$SourceColumn,

        [Parameter(Mandatory = $true)]
        [string[]]$RequirementRefs,

        [Parameter(Mandatory = $true)]
        [hashtable]$RuleDispositions
    )

    $effectiveRequirementRefs = [System.Collections.Generic.List[string]]::new()
    foreach ($requirementRef in $RequirementRefs) {
        if (-not [string]::IsNullOrWhiteSpace($requirementRef) -and -not $effectiveRequirementRefs.Contains($requirementRef)) {
            $null = $effectiveRequirementRefs.Add($requirementRef)
        }
    }

    $effectiveSeverity = $Severity
    $demoted = $false
    $dispositionRef = $null
    $disposition = $RuleDispositions[$RuleId.ToLowerInvariant()]
    if ($null -ne $disposition) {
        $demoted = $true
        $dispositionRef = [string]$disposition.dispositionRef
        if (-not [string]::IsNullOrWhiteSpace([string]$disposition.severity)) {
            $effectiveSeverity = [string]$disposition.severity
        }
        elseif ($effectiveSeverity -eq 'error') {
            $effectiveSeverity = 'warning'
        }

        if (-not $effectiveRequirementRefs.Contains('FR-030a')) {
            $null = $effectiveRequirementRefs.Add('FR-030a')
        }
    }

    $source = [ordered]@{
        path = $SourcePath
        line = $SourceLine
    }

    if ($PSBoundParameters.ContainsKey('SourceColumn') -and $SourceColumn -gt 0) {
        $source.column = $SourceColumn
    }

    $finding = [ordered]@{
        gateId = $GateId
        ruleId = $RuleId
        surfaceId = $SurfaceId
        severity = $effectiveSeverity
        message = $Message
        remediation = $Remediation
        source = [pscustomobject]$source
        requirementRefs = $effectiveRequirementRefs.ToArray()
        demoted = $demoted
    }

    if ($demoted) {
        $finding.dispositionRef = $dispositionRef
    }

    return [pscustomobject]$finding
}

function Get-DeadFieldFindings {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectRoot,

        [Parameter(Mandatory = $true)]
        [System.IO.FileInfo[]]$SourceFiles,

        [Parameter(Mandatory = $true)]
        [string]$SurfaceId,

        [Parameter(Mandatory = $true)]
        [hashtable]$RuleDispositions
    )

    $findings = [System.Collections.Generic.List[object]]::new()
    $seen = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($file in $SourceFiles) {
        $relativePath = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $file.FullName
        if ($relativePath -notmatch '(payload|message|dto|context|subscription|socket|websocket)') {
            continue
        }

        $content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
        $lines = @(Read-TextLines -Path $file.FullName)
        if ([string]::IsNullOrWhiteSpace($content) -or $lines.Count -eq 0) {
            continue
        }

        for ($index = 0; $index -lt $lines.Count; $index++) {
            $line = $lines[$index]
            $match = [regex]::Match($line, '^\s*(?:readonly\s+)?(?<name>[A-Za-z_][A-Za-z0-9_]*)\??\s*:\s*[^=][^;]*[;,]?\s*(?://.*)?$')
            if (-not $match.Success) {
                continue
            }

            $fieldName = $match.Groups['name'].Value
            if ([string]::IsNullOrWhiteSpace($fieldName)) {
                continue
            }

            $occurrenceCount = ([regex]::Matches($content, ('(?<![A-Za-z0-9_]){0}(?![A-Za-z0-9_])' -f [regex]::Escape($fieldName)))).Count
            if ($occurrenceCount -gt 1) {
                continue
            }

            $findingKey = '{0}|{1}|{2}' -f $relativePath, $fieldName, 'dead-field.websocket-payload-unused'
            if (-not $seen.Add($findingKey)) {
                continue
            }

            $column = [int]$match.Groups['name'].Index + 1
            $null = $findings.Add((New-MechanicalFinding `
                        -GateId 'dead-field' `
                        -RuleId 'dead-field.websocket-payload-unused' `
                        -SurfaceId $SurfaceId `
                        -Severity 'error' `
                        -Message ("Websocket payload field '{0}' is declared but never read." -f $fieldName) `
                        -Remediation "Remove the dead payload field or document a reviewed rationale before it drifts further." `
                        -SourcePath $relativePath `
                        -SourceLine ($index + 1) `
                        -SourceColumn $column `
                        -RequirementRefs @('FR-027', 'FR-030') `
                        -RuleDispositions $RuleDispositions))
        }
    }

    return $findings.ToArray()
}

function Get-AntiPatternFindings {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectRoot,

        [Parameter(Mandatory = $true)]
        [System.IO.FileInfo[]]$SourceFiles,

        [Parameter(Mandatory = $true)]
        [string]$SurfaceId,

        [Parameter(Mandatory = $true)]
        [hashtable]$RuleDispositions
    )

    $findings = [System.Collections.Generic.List[object]]::new()
    $seen = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($file in $SourceFiles) {
        $relativePath = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $file.FullName
        if ($relativePath -notmatch '(broadcast|handler|socket|websocket|message)') {
            continue
        }

        $lines = @(Read-TextLines -Path $file.FullName)
        for ($index = 0; $index -lt $lines.Count; $index++) {
            $line = $lines[$index]
            $match = [regex]::Match($line, '^(?!\s*(?:await|return)\b)\s*(?:void\s+)?(?<call>[A-Za-z_][A-Za-z0-9_\.]*)\s*\(')
            if (-not $match.Success) {
                continue
            }

            if ($match.Groups['call'].Value -notmatch 'broadcast') {
                continue
            }

            $findingKey = '{0}|{1}|{2}' -f $relativePath, ($index + 1), 'anti-pattern.fire-and-forget-broadcast'
            if (-not $seen.Add($findingKey)) {
                continue
            }

            $column = [int]$match.Groups['call'].Index + 1
            $null = $findings.Add((New-MechanicalFinding `
                        -GateId 'anti-pattern' `
                        -RuleId 'anti-pattern.fire-and-forget-broadcast' `
                        -SurfaceId $SurfaceId `
                        -Severity 'error' `
                        -Message 'Broadcast handler starts fire-and-forget work that can hide failures from the caller.' `
                        -Remediation 'Await the broadcast path or capture failures explicitly so the handler keeps observable failure semantics.' `
                        -SourcePath $relativePath `
                        -SourceLine ($index + 1) `
                        -SourceColumn $column `
                        -RequirementRefs @('FR-028', 'FR-030') `
                        -RuleDispositions $RuleDispositions))
        }
    }

    return $findings.ToArray()
}

function Get-TestIntegrityFindings {
    param(
        [Parameter(Mandatory = $true)]
        [string]$ProjectRoot,

        [Parameter(Mandatory = $true)]
        [System.IO.FileInfo[]]$TestFiles,

        [Parameter(Mandatory = $true)]
        [string]$SurfaceId,

        [Parameter(Mandatory = $true)]
        [hashtable]$RuleDispositions
    )

    $findings = [System.Collections.Generic.List[object]]::new()
    $seen = New-Object System.Collections.Generic.HashSet[string] ([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($file in $TestFiles) {
        $relativePath = Convert-ToRepoRelativePath -BasePath $ProjectRoot -TargetPath $file.FullName
        $content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8
        if ($relativePath -notmatch '(handshake|socket|websocket)' -and $content -notmatch '(handshake|socket|websocket)') {
            continue
        }

        $lines = @(Read-TextLines -Path $file.FullName)
        $hasNegativePathCoverage = $content -match '(reject|unauthori[sz]ed|denied|disconnect|cleanup|close|teardown|forbidden)'

        for ($index = 0; $index -lt $lines.Count; $index++) {
            $line = $lines[$index]
            $match = [regex]::Match($line, 'expect\s*\(.+?\)\s*\.\s*(?<assertion>toBeTruthy|toBeDefined|toBe\s*\(\s*true\s*\)|toEqual\s*\(\s*true\s*\))\s*\(')
            if (-not $match.Success) {
                continue
            }

            if ($hasNegativePathCoverage) {
                continue
            }

            $findingKey = '{0}|{1}|{2}' -f $relativePath, ($index + 1), 'test-integrity.smoke-only-handshake'
            if (-not $seen.Add($findingKey)) {
                continue
            }

            $column = [int]$match.Index + 1
            $null = $findings.Add((New-MechanicalFinding `
                        -GateId 'test-integrity' `
                        -RuleId 'test-integrity.smoke-only-handshake' `
                        -SurfaceId $SurfaceId `
                        -Severity 'warning' `
                        -Message 'Handshake test opens a socket but does not assert the rejected-auth path or disconnect cleanup.' `
                        -Remediation 'Add positive and negative assertions so the websocket suite proves meaningful lifecycle behavior.' `
                        -SourcePath $relativePath `
                        -SourceLine ($index + 1) `
                        -SourceColumn $column `
                        -RequirementRefs @('FR-029', 'FR-030') `
                        -RuleDispositions $RuleDispositions))
        }
    }

    return $findings.ToArray()
}

function Convert-ToValidatedPayload {
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject]$Context,

        [Parameter(Mandatory = $true)]
        [string]$GeneratorVersion,

        [AllowEmptyCollection()]
        [Parameter(Mandatory = $true)]
        [object[]]$Findings
    )

    $gateOrder = @{
        'dead-field' = 1
        'anti-pattern' = 2
        'test-integrity' = 3
    }

    $sortedFindings = @($Findings | Sort-Object `
            @{ Expression = { $gateOrder[[string]$_.gateId] } }, `
            @{ Expression = { [string]$_.source.path } }, `
            @{ Expression = { [int]$_.source.line } }, `
            @{ Expression = { [string]$_.ruleId } })

    $materializedFindings = [System.Collections.Generic.List[object]]::new()
    for ($index = 0; $index -lt $sortedFindings.Count; $index++) {
        $finding = $sortedFindings[$index]
        $source = [ordered]@{
            path = [string]$finding.source.path
            line = [int]$finding.source.line
        }

        if ($null -ne $finding.source.column) {
            $source.column = [int]$finding.source.column
        }

        $serializedFinding = [ordered]@{
            findingId = ('mf-{0:D3}' -f ($index + 1))
            gateId = [string]$finding.gateId
            ruleId = [string]$finding.ruleId
            surfaceId = [string]$finding.surfaceId
            severity = [string]$finding.severity
            message = [string]$finding.message
            remediation = [string]$finding.remediation
            source = [pscustomobject]$source
            requirementRefs = @([string[]]$finding.requirementRefs)
            demoted = [bool]$finding.demoted
        }

        if ($finding.demoted) {
            $serializedFinding.dispositionRef = [string]$finding.dispositionRef
        }

        $null = $materializedFindings.Add([pscustomobject]$serializedFinding)
    }

    $payload = [pscustomobject][ordered]@{
        schemaVersion = 'v1'
        featureRef = [string]$Context.FeatureRef
        iterationRef = [string]$Context.IterationRef
        generatedAt = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
        generator = [pscustomobject][ordered]@{
            name = 'specrew-mechanical-checks'
            version = $GeneratorVersion
        }
        findings = $materializedFindings.ToArray()
    }

    $json = $payload | ConvertTo-Json -Depth 16
    if (-not (Test-Json -Json $json -SchemaFile $Context.SchemaPath -WarningAction SilentlyContinue)) {
        throw 'Generated mechanical findings payload does not satisfy the v1 schema.'
    }

    return $payload
}

$resolvedProjectPath = Resolve-ProjectPath -Path $ProjectPath
if (-not (Test-Path -LiteralPath $resolvedProjectPath -PathType Container)) {
    throw "Project path '$resolvedProjectPath' does not exist."
}

$context = Resolve-MechanicalContext -ProjectRoot $resolvedProjectPath -FeaturePath $FeaturePath -IterationPath $IterationPath -SpecPath $SpecPath
$generatorVersion = Get-ExtensionVersion
$sourceFiles = @(Get-CandidateCodeFiles -ProjectRoot $resolvedProjectPath)
$testFiles = @(Get-CandidateTestFiles -ProjectRoot $resolvedProjectPath)
$ruleDispositions = Get-RuleDispositions -ProjectRoot $resolvedProjectPath -IterationPath $context.IterationPath -DispositionPath $DispositionPath

$findings = [System.Collections.Generic.List[object]]::new()
foreach ($finding in @(Get-DeadFieldFindings -ProjectRoot $resolvedProjectPath -SourceFiles $sourceFiles -SurfaceId $context.SurfaceId -RuleDispositions $ruleDispositions)) {
    $null = $findings.Add($finding)
}
foreach ($finding in @(Get-AntiPatternFindings -ProjectRoot $resolvedProjectPath -SourceFiles $sourceFiles -SurfaceId $context.SurfaceId -RuleDispositions $ruleDispositions)) {
    $null = $findings.Add($finding)
}
foreach ($finding in @(Get-TestIntegrityFindings -ProjectRoot $resolvedProjectPath -TestFiles $testFiles -SurfaceId $context.SurfaceId -RuleDispositions $ruleDispositions)) {
    $null = $findings.Add($finding)
}

$payload = Convert-ToValidatedPayload -Context $context -GeneratorVersion $generatorVersion -Findings $findings.ToArray()
$planPath = Join-Path $context.IterationPath 'plan.md'
$planLines = if (Test-Path -LiteralPath $planPath -PathType Leaf) {
    @(Get-Content -LiteralPath $planPath -Encoding UTF8)
}
else {
    @()
}

$qualityDirectory = Join-Path $context.IterationPath 'quality'
$mechanicalFindingsPath = Join-Path $qualityDirectory 'mechanical-findings.json'
$qualityEvidencePath = Join-Path $qualityDirectory 'quality-evidence.md'

if (-not (Test-Path -LiteralPath $qualityDirectory -PathType Container)) {
    $null = New-Item -ItemType Directory -Path $qualityDirectory -Force
}

$mechanicalFindingsJson = $payload | ConvertTo-Json -Depth 16
[System.IO.File]::WriteAllText($mechanicalFindingsPath, $mechanicalFindingsJson, [System.Text.UTF8Encoding]::new($false))

$qualityGateRows = @(Get-MarkdownSectionTable -Lines $planLines -Heading 'Required Quality Gates')
$qualityContractPath = Join-Path $context.FeaturePath 'contracts\quality-governance-artifacts.md'
if ($qualityGateRows.Count -eq 0 -and (Test-Path -LiteralPath $qualityContractPath -PathType Leaf)) {
    $qualityGateRows = @(Get-DefaultQualityGateRows)
}
if ($qualityGateRows.Count -gt 0) {
    $featureId = Split-Path -Leaf $context.FeaturePath
    $iterationNumber = Split-Path -Leaf $context.IterationPath
    $findingsRef = Convert-ToRepoRelativePath -BasePath $resolvedProjectPath -TargetPath $mechanicalFindingsPath
    $evidenceRef = Convert-ToRepoRelativePath -BasePath $resolvedProjectPath -TargetPath $qualityEvidencePath
    $existingEvidenceState = Get-ExistingQualityEvidenceState -QualityEvidencePath $qualityEvidencePath
    $qualityEvidenceOverrides = Get-MechanicalGateOverrides -Findings $payload.findings -FindingsRef $findingsRef
    $reviewedBy = if ([string]::IsNullOrWhiteSpace($existingEvidenceState.ReviewedBy)) { 'Mechanical checks (automated)' } else { $existingEvidenceState.ReviewedBy }
    $reviewedAt = if ([string]::IsNullOrWhiteSpace($existingEvidenceState.ReviewedAt)) { (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $existingEvidenceState.ReviewedAt }
    $qualityEvidenceContent = Get-QualityEvidenceContent `
        -PlanLines $planLines `
        -FeatureId $featureId `
        -IterationNumber $iterationNumber `
        -FindingsRef $findingsRef `
        -EvidenceRef $evidenceRef `
        -ExistingRows $existingEvidenceState.RowsByGate `
        -Overrides $qualityEvidenceOverrides `
        -ReviewedBy $reviewedBy `
        -ReviewedAt $reviewedAt
    [System.IO.File]::WriteAllText($qualityEvidencePath, $qualityEvidenceContent, [System.Text.UTF8Encoding]::new($false))
}

switch ($OutputFormat) {
    'Json' {
        $mechanicalFindingsJson
    }
    default {
        $payload
    }
}