#!/usr/bin/env pwsh [CmdletBinding()] param( [string]$DiscordWebhookUrl = $env:DISCORD_WEBHOOK_URL, [string]$Repository = $env:GITHUB_REPOSITORY, [string]$RefName = $env:GITHUB_REF_NAME, [string]$Sha = $env:GITHUB_SHA, [string]$RunUrl = $env:GITHUB_SERVER_URL ) $ErrorActionPreference = 'Stop' $exitCode = 0 $smellFailures = New-Object System.Collections.Generic.List[string] $smellWarnings = New-Object System.Collections.Generic.List[string] function Add-SmellFailure { param([string]$Message) $script:smellFailures.Add($Message) } function Add-SmellWarning { param([string]$Message) $script:smellWarnings.Add($Message) } function Get-ProjectFiles { $excludeDirs = @('.git', 'node_modules', 'dist', 'build', 'coverage') Get-ChildItem -Path . -File -Recurse -Force | Where-Object { $pathParts = $_.FullName.Substring((Get-Location).Path.Length).Split([IO.Path]::DirectorySeparatorChar, [StringSplitOptions]::RemoveEmptyEntries) -not ($pathParts | Where-Object { $excludeDirs -contains $_ }) } } function Test-ManifestSchema { Write-Host 'Validating data/manifest.json...' $manifestPath = Join-Path (Get-Location) 'data/manifest.json' if (-not (Test-Path -LiteralPath $manifestPath)) { Add-SmellFailure 'data/manifest.json is missing.' return $false } try { $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json } catch { Add-SmellFailure "data/manifest.json is not valid JSON: $($_.Exception.Message)" return $false } $requiredRoots = @('dashboard', 'links', 'builds', 'status') foreach ($key in $requiredRoots) { if (-not ($manifest.PSObject.Properties.Name -contains $key)) { Add-SmellFailure "manifest missing required key '$key'." return $false } } $linkIds = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($link in @($manifest.links)) { if ([string]::IsNullOrWhiteSpace($link.id)) { Add-SmellFailure 'manifest link missing id.' return $false } if (-not $linkIds.Add([string]$link.id)) { Add-SmellFailure "duplicate manifest link id '$($link.id)'." return $false } } $buildIds = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($build in @($manifest.builds)) { if ([string]::IsNullOrWhiteSpace($build.id)) { Add-SmellFailure 'manifest build missing id.' return $false } if (-not $buildIds.Add([string]$build.id)) { Add-SmellFailure "duplicate manifest build id '$($build.id)'." return $false } foreach ($pathKey in @('start', 'status', 'logs')) { $section = $build.$pathKey if ($null -eq $section -or [string]::IsNullOrWhiteSpace($section.path)) { Add-SmellFailure "manifest build '$($build.id)' missing $pathKey.path." return $false } } } Write-Host 'Manifest schema validation passed.' return $true } function Test-CodeSmells { Write-Host 'Running gated smell checks...' $files = @(Get-ProjectFiles) $debtMarkerPattern = '\b(T' + 'ODO|F' + 'IXME|H' + 'ACK|X' + 'XX|W' + 'IP)\b' foreach ($file in $files) { $relativePath = Resolve-Path -Relative $file.FullName if ($file.Length -gt 1MB) { Add-SmellWarning "$relativePath is larger than 1 MB." } $content = Get-Content -Raw -LiteralPath $file.FullName if ($content -match '(?m)^(<<<<<<<|=======|>>>>>>>)') { Add-SmellFailure "$relativePath contains merge conflict markers." } if ($content -match '(?i)(discord(?:_webhook)?_url|password|api[_-]?key|secret)\s*[:=]\s*["''][^"'']{8,}["'']') { Add-SmellFailure "$relativePath may contain a committed secret." } $todoMatches = [regex]::Matches($content, "(?i)$debtMarkerPattern") if ($todoMatches.Count -gt 0) { Add-SmellWarning "$relativePath contains $($todoMatches.Count) debt marker(s)." } } if ($smellFailures.Count -gt 0) { Write-Host 'Smell checks failed:' $smellFailures | ForEach-Object { Write-Host " - $_" } return $false } if ($smellWarnings.Count -gt 0) { Write-Host 'Smell check warnings:' $smellWarnings | ForEach-Object { Write-Host " - $_" } } else { Write-Host 'No smell check warnings found.' } return $true } function Invoke-TestSuite { Write-Host 'Running test suite...' $tempRoot = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } $outputFile = Join-Path $tempRoot 'zone-test-output.txt' & npm test 2>&1 | Tee-Object -FilePath $outputFile $testExitCode = $LASTEXITCODE $output = Get-Content -Raw -LiteralPath $outputFile return [pscustomobject]@{ Passed = ($testExitCode -eq 0) ExitCode = $testExitCode Output = $output.Trim() } } function Get-ShortText { param( [string]$Text, [int]$MaxLength = 1400 ) if ([string]::IsNullOrWhiteSpace($Text)) { return 'No output captured.' } if ($Text.Length -le $MaxLength) { return $Text } return $Text.Substring($Text.Length - $MaxLength) } function Send-DiscordReport { param( [bool]$SmellsPassed, [object]$Tests ) if ([string]::IsNullOrWhiteSpace($DiscordWebhookUrl)) { Write-Host 'DISCORD_WEBHOOK_URL is not set; skipping Discord report.' return } $status = if ($SmellsPassed -and $Tests.Passed -and $manifestPassed) { 'passed' } else { 'failed' } $color = if ($status -eq 'passed') { 5763719 } else { 15548997 } $shaShort = if ($Sha -and $Sha.Length -ge 7) { $Sha.Substring(0, 7) } else { $Sha } $repoText = if ($Repository) { $Repository } else { 'zone' } $refText = if ($RefName) { $RefName } else { 'unknown ref' } $smellStatus = if ($SmellsPassed) { 'passed' } else { 'failed' } $testStatus = if ($Tests.Passed) { 'passed' } else { "failed (exit $($Tests.ExitCode))" } $warningText = if ($smellWarnings.Count -gt 0) { ($smellWarnings -join "`n") } else { 'None' } $failureText = if ($smellFailures.Count -gt 0) { ($smellFailures -join "`n") } else { 'None' } $description = @" Repository: $repoText Ref: $refText Commit: $shaShort Smell checks: $smellStatus Tests: $testStatus "@ $fields = @( @{ name = 'Smell failures' value = Get-ShortText -Text $failureText -MaxLength 900 inline = $false }, @{ name = 'Smell warnings' value = Get-ShortText -Text $warningText -MaxLength 900 inline = $false }, @{ name = 'Test output' value = "``````text`n$(Get-ShortText -Text $Tests.Output -MaxLength 900)`n``````" inline = $false } ) $payload = @{ username = 'Zone CI' embeds = @( @{ title = "Gated analysis $status" description = $description.Trim() color = $color fields = $fields timestamp = (Get-Date).ToUniversalTime().ToString('o') } ) } if ($RunUrl -and $env:GITHUB_RUN_ID) { $payload.embeds[0].url = "$RunUrl/$repoText/actions/runs/$($env:GITHUB_RUN_ID)" } Invoke-RestMethod -Method Post -Uri $DiscordWebhookUrl -ContentType 'application/json' -Body ($payload | ConvertTo-Json -Depth 8) | Out-Null Write-Host 'Discord report published.' } function Test-ManifestV2Validator { Write-Host 'Running scripts/validate-manifest.mjs...' $validator = Join-Path (Get-Location) 'scripts/validate-manifest.mjs' if (-not (Test-Path -LiteralPath $validator)) { Add-SmellFailure 'scripts/validate-manifest.mjs is missing.' return $false } $output = & node $validator 2>&1 | Out-String if ($LASTEXITCODE -ne 0) { Add-SmellFailure "validate-manifest.mjs failed: $output" return $false } Write-Host $output.Trim() return $true } $manifestPassed = (Test-ManifestSchema) -and (Test-ManifestV2Validator) if (-not $manifestPassed) { $exitCode = 1 } $smellsPassed = Test-CodeSmells if (-not $smellsPassed) { $exitCode = 1 } $tests = Invoke-TestSuite if (-not $tests.Passed) { $exitCode = $tests.ExitCode } Send-DiscordReport -SmellsPassed ($smellsPassed -and $manifestPassed) -Tests $tests exit $exitCode