param( [string]$RepoRoot = "", [string]$ConfigPath = "", [string]$ReportPath = "" ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Resolve-AbsolutePath { param( [string]$Path, [string]$BasePath ) if ([System.IO.Path]::IsPathRooted($Path)) { return [System.IO.Path]::GetFullPath($Path) } return [System.IO.Path]::GetFullPath((Join-Path $BasePath $Path)) } function Get-RelativePath { param( [string]$Root, [string]$Path ) $rootPrefix = [System.IO.Path]::GetFullPath($Root).TrimEnd('\', '/') + [System.IO.Path]::DirectorySeparatorChar $fullPath = [System.IO.Path]::GetFullPath($Path) if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Path is outside repository root: $fullPath" } return $fullPath.Substring($rootPrefix.Length).Replace('\', '/') } function Get-TextFiles { param( [string]$Root, [object[]]$Paths ) $allowedExtensions = @(".h", ".hpp", ".cpp", ".cxx", ".md", ".txt", ".json", ".cmake", ".ps1") $files = New-Object System.Collections.Generic.List[System.IO.FileInfo] foreach ($relativePath in $Paths) { $absolutePath = Resolve-AbsolutePath -Path ([string]$relativePath) -BasePath $Root if (-not (Test-Path -LiteralPath $absolutePath)) { continue } $item = Get-Item -LiteralPath $absolutePath if (-not $item.PSIsContainer) { $files.Add($item) continue } foreach ($file in Get-ChildItem -LiteralPath $absolutePath -Recurse -File) { if ($allowedExtensions -contains $file.Extension.ToLowerInvariant()) { $files.Add($file) } } } return @($files | Sort-Object FullName -Unique) } function Invoke-GitDiffCheck { param([string]$Root) $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = "git" $escapedRoot = $Root.Replace('"', '\"') $startInfo.Arguments = "-C `"$escapedRoot`" diff --check" $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $process = New-Object System.Diagnostics.Process $process.StartInfo = $startInfo if (-not $process.Start()) { throw "Unable to start git for diff validation." } $standardOutput = $process.StandardOutput.ReadToEnd() $standardError = $process.StandardError.ReadToEnd() $process.WaitForExit() return [pscustomobject]@{ ExitCode = $process.ExitCode Output = ($standardOutput + $standardError).Trim() } } $defaultRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot "..\..\..")) $resolvedRoot = if ([string]::IsNullOrWhiteSpace($RepoRoot)) { $defaultRoot } else { Resolve-AbsolutePath -Path $RepoRoot -BasePath (Get-Location) } $resolvedConfig = if ([string]::IsNullOrWhiteSpace($ConfigPath)) { Join-Path $resolvedRoot "internal/qa/config/static-checks.json" } else { Resolve-AbsolutePath -Path $ConfigPath -BasePath $resolvedRoot } if (-not (Test-Path -LiteralPath $resolvedConfig -PathType Leaf)) { throw "Static-check configuration was not found: $resolvedConfig" } $config = Get-Content -LiteralPath $resolvedConfig -Raw -Encoding UTF8 | ConvertFrom-Json if ($config.schemaVersion -ne 1) { throw "Unsupported static-check configuration version: $($config.schemaVersion)" } $results = New-Object System.Collections.Generic.List[object] function Add-Result { param( [string]$Name, [bool]$Passed, [string]$Details ) $results.Add([pscustomobject]@{ Name = $Name Passed = $Passed Details = $Details }) } $cmakePath = Resolve-AbsolutePath -Path ([string]$config.productionCmakeFile) -BasePath $resolvedRoot $cmakeText = Get-Content -LiteralPath $cmakePath -Raw -Encoding UTF8 $productionFiles = New-Object System.Collections.Generic.List[System.IO.FileInfo] foreach ($sourceRoot in $config.productionSourceRoots) { $sourceRootPath = Resolve-AbsolutePath -Path ([string]$sourceRoot) -BasePath $resolvedRoot foreach ($file in Get-ChildItem -LiteralPath $sourceRootPath -Recurse -File) { if ($config.productionExtensions -contains $file.Extension.ToLowerInvariant()) { $productionFiles.Add($file) } } } $missingFromCmake = New-Object System.Collections.Generic.List[string] foreach ($file in $productionFiles) { $relativePath = Get-RelativePath -Root $resolvedRoot -Path $file.FullName if (-not $cmakeText.Contains($relativePath)) { $missingFromCmake.Add($relativePath) } } Add-Result -Name "Production sources are registered in CMake" ` -Passed ($missingFromCmake.Count -eq 0) ` -Details ($(if ($missingFromCmake.Count -eq 0) { "$($productionFiles.Count) source/header files registered" } else { "Missing: " + ($missingFromCmake -join ", ") })) $missingCmakeReferences = New-Object System.Collections.Generic.List[string] $cmakeReferencePattern = 'src[\\/][A-Za-z0-9_.\\/-]+\.(?:h|cpp)' foreach ($match in [regex]::Matches($cmakeText, $cmakeReferencePattern)) { $relativePath = $match.Value.Replace('\', '/') if (-not (Test-Path -LiteralPath (Join-Path $resolvedRoot $relativePath) -PathType Leaf)) { $missingCmakeReferences.Add($relativePath) } } Add-Result -Name "CMake does not reference missing production sources" ` -Passed ($missingCmakeReferences.Count -eq 0) ` -Details ($(if ($missingCmakeReferences.Count -eq 0) { "No missing source references" } else { "Missing: " + (($missingCmakeReferences | Sort-Object -Unique) -join ", ") })) $qaCmakePath = Resolve-AbsolutePath -Path ([string]$config.qaCmakeFile) -BasePath $resolvedRoot $qaRoot = Split-Path $qaCmakePath -Parent $qaCmakeText = Get-Content -LiteralPath $qaCmakePath -Raw -Encoding UTF8 $missingQaTests = New-Object System.Collections.Generic.List[string] $qaTestCount = 0 foreach ($testRoot in $config.qaTestRoots) { $testRootPath = Resolve-AbsolutePath -Path ([string]$testRoot) -BasePath $resolvedRoot foreach ($testFile in Get-ChildItem -LiteralPath $testRootPath -Recurse -Filter "*.cpp" -File) { ++$qaTestCount $relativeTestPath = Get-RelativePath -Root $qaRoot -Path $testFile.FullName if (-not $qaCmakeText.Contains($relativeTestPath)) { $missingQaTests.Add($relativeTestPath) } } } Add-Result -Name "Internal QA test sources are registered in the QA CMake project" ` -Passed ($missingQaTests.Count -eq 0) ` -Details ($(if ($missingQaTests.Count -eq 0) { "$qaTestCount test translation units registered" } else { "Missing: " + ($missingQaTests -join ", ") })) $activeFiles = Get-TextFiles -Root $resolvedRoot -Paths $config.activeScanPaths foreach ($rule in $config.forbiddenReferences) { $matches = New-Object System.Collections.Generic.List[string] foreach ($file in $activeFiles) { $content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 if ($content -match $rule.pattern) { $matches.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName)) } } Add-Result -Name "No $($rule.name)" ` -Passed ($matches.Count -eq 0) ` -Details ($(if ($matches.Count -eq 0) { "No active references" } else { "Found in: " + (($matches | Sort-Object -Unique) -join ", ") })) } if ($config.forbidQObjectMacro) { $qObjectFiles = New-Object System.Collections.Generic.List[string] foreach ($file in $productionFiles) { $content = Get-Content -LiteralPath $file.FullName -Raw -Encoding UTF8 if ($content -match '\bQ_OBJECT\b') { $qObjectFiles.Add((Get-RelativePath -Root $resolvedRoot -Path $file.FullName)) } } Add-Result -Name "Production code remains compatible with CMAKE_AUTOMOC OFF" ` -Passed ($qObjectFiles.Count -eq 0) ` -Details ($(if ($qObjectFiles.Count -eq 0) { "No Q_OBJECT macro found" } else { "Found in: " + ($qObjectFiles -join ", ") })) } $missingRequiredPaths = @($config.requiredPaths | Where-Object { -not (Test-Path -LiteralPath (Resolve-AbsolutePath -Path ([string]$_) -BasePath $resolvedRoot)) }) Add-Result -Name "Required release inputs exist" ` -Passed ($missingRequiredPaths.Count -eq 0) ` -Details ($(if ($missingRequiredPaths.Count -eq 0) { "$($config.requiredPaths.Count) required paths found" } else { "Missing: " + ($missingRequiredPaths -join ", ") })) $characterManifestPath = Join-Path $resolvedRoot "resources/characters/shiroko/character.json" $characterProblems = New-Object System.Collections.Generic.List[string] try { $character = Get-Content -LiteralPath $characterManifestPath -Raw -Encoding UTF8 | ConvertFrom-Json if ([string]::IsNullOrWhiteSpace([string]$character.defaultState)) { $characterProblems.Add("defaultState is empty") } foreach ($stateProperty in $character.states.PSObject.Properties) { $statePath = Join-Path (Split-Path $characterManifestPath -Parent) ([string]$stateProperty.Value.path) if (-not (Test-Path -LiteralPath $statePath -PathType Container)) { $characterProblems.Add("state directory missing: $($stateProperty.Name)") continue } if (@(Get-ChildItem -LiteralPath $statePath -Filter "*.png" -File).Count -eq 0) { $characterProblems.Add("state has no PNG frames: $($stateProperty.Name)") } } } catch { $characterProblems.Add("manifest parse failed: $($_.Exception.Message)") } Add-Result -Name "Default character manifest and frames are complete" ` -Passed ($characterProblems.Count -eq 0) ` -Details ($(if ($characterProblems.Count -eq 0) { "All declared states contain PNG frames" } else { $characterProblems -join "; " })) $exportScriptPath = Join-Path $resolvedRoot "tools/prepare_github_export.ps1" $exportScriptText = Get-Content -LiteralPath $exportScriptPath -Raw -Encoding UTF8 $missingExportExclusions = @($config.publicExportExcludedRoots | Where-Object { $quotedName = '"' + [regex]::Escape([string]$_) + '"' $exportScriptText -notmatch $quotedName }) Add-Result -Name "Public GitHub export excludes internal development roots" ` -Passed ($missingExportExclusions.Count -eq 0) ` -Details ($(if ($missingExportExclusions.Count -eq 0) { "Excluded: " + ($config.publicExportExcludedRoots -join ", ") } else { "Not excluded: " + ($missingExportExclusions -join ", ") })) $releaseIsolationMatches = New-Object System.Collections.Generic.List[string] foreach ($relativeFile in $config.releaseIsolationFiles) { $absoluteFile = Resolve-AbsolutePath -Path ([string]$relativeFile) -BasePath $resolvedRoot $content = Get-Content -LiteralPath $absoluteFile -Raw -Encoding UTF8 foreach ($pattern in $config.releaseForbiddenPatterns) { if ($content -match $pattern) { $releaseIsolationMatches.Add("$relativeFile -> $pattern") } } } Add-Result -Name "Production build and packaging do not depend on internal QA" ` -Passed ($releaseIsolationMatches.Count -eq 0) ` -Details ($(if ($releaseIsolationMatches.Count -eq 0) { "No internal QA dependency" } else { $releaseIsolationMatches -join "; " })) $infrastructureProblems = New-Object System.Collections.Generic.List[string] foreach ($scriptFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/scripts") -Filter "*.ps1" -File) { $tokens = $null $parseErrors = $null [System.Management.Automation.Language.Parser]::ParseFile( $scriptFile.FullName, [ref]$tokens, [ref]$parseErrors) | Out-Null foreach ($parseError in $parseErrors) { $infrastructureProblems.Add("$($scriptFile.Name):$($parseError.Extent.StartLineNumber): $($parseError.Message)") } } foreach ($jsonFile in Get-ChildItem -LiteralPath (Join-Path $resolvedRoot "internal/qa/config") -Filter "*.json" -File) { try { Get-Content -LiteralPath $jsonFile.FullName -Raw -Encoding UTF8 | ConvertFrom-Json | Out-Null } catch { $infrastructureProblems.Add("$($jsonFile.Name): $($_.Exception.Message)") } } Add-Result -Name "Internal QA scripts and configuration parse successfully" ` -Passed ($infrastructureProblems.Count -eq 0) ` -Details ($(if ($infrastructureProblems.Count -eq 0) { "All PowerShell and JSON files parsed" } else { $infrastructureProblems -join "; " })) $gitResult = Invoke-GitDiffCheck -Root $resolvedRoot Add-Result -Name "Git diff whitespace check" ` -Passed ($gitResult.ExitCode -eq 0) ` -Details ($(if ($gitResult.ExitCode -eq 0) { "git diff --check passed" } else { $gitResult.Output })) $passedCount = @($results | Where-Object Passed).Count $failedResults = @($results | Where-Object { -not $_.Passed }) foreach ($result in $results) { $prefix = if ($result.Passed) { "PASS" } else { "FAIL" } Write-Host "[$prefix] $($result.Name) - $($result.Details)" } Write-Host "Static checks: $passedCount passed, $($failedResults.Count) failed." if (-not [string]::IsNullOrWhiteSpace($ReportPath)) { $resolvedReportPath = Resolve-AbsolutePath -Path $ReportPath -BasePath $resolvedRoot $reportDirectory = Split-Path $resolvedReportPath -Parent if (-not (Test-Path -LiteralPath $reportDirectory)) { New-Item -ItemType Directory -Path $reportDirectory -Force | Out-Null } $lines = New-Object System.Collections.Generic.List[string] $lines.Add("# Static Check Report") $lines.Add("") $lines.Add("Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss zzz')") $lines.Add("") $lines.Add("| Check | Result | Details |") $lines.Add("| --- | --- | --- |") foreach ($result in $results) { $status = if ($result.Passed) { "PASS" } else { "FAIL" } $details = ([string]$result.Details).Replace("|", "\|").Replace("`r", " ").Replace("`n", " ") $lines.Add("| $($result.Name) | $status | $details |") } Set-Content -LiteralPath $resolvedReportPath -Value $lines -Encoding UTF8 Write-Host "Report: $resolvedReportPath" } if ($failedResults.Count -gt 0) { exit 1 }