From d8658b191e012ec197f81c3af9fc163360ae8267 Mon Sep 17 00:00:00 2001 From: Hasso Date: Tue, 31 Mar 2026 16:03:04 -0500 Subject: [PATCH 1/3] Fix whitespace in PowerShell scripts --- Build/Agent/FwBuildEnvironment.psm1 | 492 ++++---- Build/Agent/FwBuildHelpers.psm1 | 552 ++++----- Build/Agent/Preprocess-WixIncludes.ps1 | 146 +-- Build/Agent/Rebuild-TestProjects.ps1 | 116 +- Build/Agent/Remove-StaleDlls.ps1 | 304 ++--- Build/Agent/Run-VsTests.ps1 | 256 ++-- Build/Agent/Setup-DefenderExclusions.ps1 | 554 ++++----- Build/Agent/Setup-FwBuildEnv.ps1 | 250 ++-- Build/Agent/Setup-InstallerBuild.ps1 | 528 ++++----- Build/Agent/Setup-Serena.ps1 | 196 ++-- Build/Agent/Summarize-NativeTestResults.ps1 | 84 +- Build/Agent/Verify-FwDependencies.ps1 | 426 +++---- Build/Agent/validate-test-exclusions.ps1 | 76 +- Build/scripts/Invoke-CppTest.ps1 | 30 +- scripts/Agent/Copy-LocalLcm.ps1 | 188 +-- scripts/Agent/Git-Search.ps1 | 352 +++--- scripts/Agent/Read-FileContent.ps1 | 154 +-- scripts/Agent/Setup-Local-Localization.ps1 | 86 +- .../reflect_attributes.ps1 | 124 +- .../verify-performance-and-tests.ps1 | 26 +- scripts/Installer/Invoke-InstallerWithLog.ps1 | 60 +- scripts/Rename-WorktreeToBranch.ps1 | 10 +- scripts/Setup-WorktreeColor.ps1 | 12 +- scripts/Worktree-CreateFromBranch.ps1 | 12 +- scripts/git-utilities.ps1 | 138 +-- scripts/openspec/Propose-RefFixes.ps1 | 1026 ++++++++--------- scripts/openspec/Report-RefCoverage.ps1 | 210 ++-- scripts/openspec/Sync-AgentsAnchors.ps1 | 370 +++--- scripts/openspec/Validate-OpenSpecRefs.ps1 | 722 ++++++------ scripts/regfree/run-in-vm.ps1 | 128 +- scripts/test_exclusions/assembly_guard.ps1 | 82 +- scripts/toolshims/py.ps1 | 78 +- test.ps1 | 970 ++++++++-------- 33 files changed, 4379 insertions(+), 4379 deletions(-) diff --git a/Build/Agent/FwBuildEnvironment.psm1 b/Build/Agent/FwBuildEnvironment.psm1 index b7486e6be1..5b73699100 100644 --- a/Build/Agent/FwBuildEnvironment.psm1 +++ b/Build/Agent/FwBuildEnvironment.psm1 @@ -1,13 +1,13 @@ <# .SYNOPSIS - Visual Studio and build tool environment helpers for FieldWorks. + Visual Studio and build tool environment helpers for FieldWorks. .DESCRIPTION - Provides VS environment initialization, MSBuild execution, and - VSTest path discovery. + Provides VS environment initialization, MSBuild execution, and + VSTest path discovery. .NOTES - Used by FwBuildHelpers.psm1 - do not import directly. + Used by FwBuildHelpers.psm1 - do not import directly. #> # ============================================================================= @@ -15,134 +15,134 @@ # ============================================================================= function Initialize-VsDevEnvironment { - <# - .SYNOPSIS - Initializes the Visual Studio Developer environment. - .DESCRIPTION - Sets up environment variables for native C++ compilation (x64 only). - Safe to call multiple times - will skip if already initialized. - #> - if ($env:OS -ne 'Windows_NT') { - return - } - - if ($env:VCINSTALLDIR) { - Write-Host '[OK] Visual Studio environment already initialized' -ForegroundColor Green - return - } - - Write-Host 'Initializing Visual Studio Developer environment...' -ForegroundColor Yellow - - $vswhereCandidates = @() - if ($env:ProgramFiles) { - $pfVswhere = Join-Path -Path $env:ProgramFiles -ChildPath 'Microsoft Visual Studio\Installer\vswhere.exe' - if (Test-Path $pfVswhere) { $vswhereCandidates += $pfVswhere } - } - $programFilesX86 = ${env:ProgramFiles(x86)} - if ($programFilesX86) { - $pf86Vswhere = Join-Path -Path $programFilesX86 -ChildPath 'Microsoft Visual Studio\Installer\vswhere.exe' - if (Test-Path $pf86Vswhere) { $vswhereCandidates += $pf86Vswhere } - } - - if (-not $vswhereCandidates) { - Write-Host '' - Write-Host '[ERROR] Visual Studio 2017+ not found' -ForegroundColor Red - Write-Host ' Install from: https://visualstudio.microsoft.com/downloads/' -ForegroundColor Yellow - throw 'Visual Studio not found' - } - - $vsInstallPath = & $vswhereCandidates[0] -latest -requires Microsoft.Component.MSBuild Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -products * -property installationPath - if (-not $vsInstallPath) { - Write-Host '' - Write-Host '[ERROR] Visual Studio found but missing required C++ tools' -ForegroundColor Red - Write-Host ' Please install the "Desktop development with C++" workload' -ForegroundColor Yellow - throw 'Visual Studio C++ tools not found' - } - - $vsDevCmd = Join-Path -Path $vsInstallPath -ChildPath 'Common7\Tools\VsDevCmd.bat' - if (-not (Test-Path $vsDevCmd)) { - throw "Unable to locate VsDevCmd.bat under '$vsInstallPath'." - } - - # x64-only build - $arch = 'amd64' - $vsVersion = Split-Path (Split-Path (Split-Path (Split-Path $vsInstallPath))) -Leaf - Write-Host " Found Visual Studio $vsVersion at: $vsInstallPath" -ForegroundColor Gray - Write-Host " Setting up environment for $arch..." -ForegroundColor Gray - - $cmdArgs = "`"$vsDevCmd`" -no_logo -arch=$arch -host_arch=$arch && set" - $envOutput = & cmd.exe /c $cmdArgs 2>&1 - if ($LASTEXITCODE -ne 0) { - throw 'Failed to initialize Visual Studio environment' - } - - foreach ($line in $envOutput) { - $parts = $line -split '=', 2 - if ($parts.Length -eq 2 -and $parts[0]) { - Set-Item -Path "Env:$($parts[0])" -Value $parts[1] - } - } - - if (-not $env:VCINSTALLDIR) { - throw 'Visual Studio C++ environment not configured' - } - - Write-Host '[OK] Visual Studio environment initialized successfully' -ForegroundColor Green - Write-Host " VCINSTALLDIR: $env:VCINSTALLDIR" -ForegroundColor Gray + <# + .SYNOPSIS + Initializes the Visual Studio Developer environment. + .DESCRIPTION + Sets up environment variables for native C++ compilation (x64 only). + Safe to call multiple times - will skip if already initialized. + #> + if ($env:OS -ne 'Windows_NT') { + return + } + + if ($env:VCINSTALLDIR) { + Write-Host '[OK] Visual Studio environment already initialized' -ForegroundColor Green + return + } + + Write-Host 'Initializing Visual Studio Developer environment...' -ForegroundColor Yellow + + $vswhereCandidates = @() + if ($env:ProgramFiles) { + $pfVswhere = Join-Path -Path $env:ProgramFiles -ChildPath 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path $pfVswhere) { $vswhereCandidates += $pfVswhere } + } + $programFilesX86 = ${env:ProgramFiles(x86)} + if ($programFilesX86) { + $pf86Vswhere = Join-Path -Path $programFilesX86 -ChildPath 'Microsoft Visual Studio\Installer\vswhere.exe' + if (Test-Path $pf86Vswhere) { $vswhereCandidates += $pf86Vswhere } + } + + if (-not $vswhereCandidates) { + Write-Host '' + Write-Host '[ERROR] Visual Studio 2017+ not found' -ForegroundColor Red + Write-Host ' Install from: https://visualstudio.microsoft.com/downloads/' -ForegroundColor Yellow + throw 'Visual Studio not found' + } + + $vsInstallPath = & $vswhereCandidates[0] -latest -requires Microsoft.Component.MSBuild Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -products * -property installationPath + if (-not $vsInstallPath) { + Write-Host '' + Write-Host '[ERROR] Visual Studio found but missing required C++ tools' -ForegroundColor Red + Write-Host ' Please install the "Desktop development with C++" workload' -ForegroundColor Yellow + throw 'Visual Studio C++ tools not found' + } + + $vsDevCmd = Join-Path -Path $vsInstallPath -ChildPath 'Common7\Tools\VsDevCmd.bat' + if (-not (Test-Path $vsDevCmd)) { + throw "Unable to locate VsDevCmd.bat under '$vsInstallPath'." + } + + # x64-only build + $arch = 'amd64' + $vsVersion = Split-Path (Split-Path (Split-Path (Split-Path $vsInstallPath))) -Leaf + Write-Host " Found Visual Studio $vsVersion at: $vsInstallPath" -ForegroundColor Gray + Write-Host " Setting up environment for $arch..." -ForegroundColor Gray + + $cmdArgs = "`"$vsDevCmd`" -no_logo -arch=$arch -host_arch=$arch && set" + $envOutput = & cmd.exe /c $cmdArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + throw 'Failed to initialize Visual Studio environment' + } + + foreach ($line in $envOutput) { + $parts = $line -split '=', 2 + if ($parts.Length -eq 2 -and $parts[0]) { + Set-Item -Path "Env:$($parts[0])" -Value $parts[1] + } + } + + if (-not $env:VCINSTALLDIR) { + throw 'Visual Studio C++ environment not configured' + } + + Write-Host '[OK] Visual Studio environment initialized successfully' -ForegroundColor Green + Write-Host " VCINSTALLDIR: $env:VCINSTALLDIR" -ForegroundColor Gray } function Get-CvtresDiagnostics { - <# - .SYNOPSIS - Returns details about the cvtres.exe resolved in the current session. - #> - $result = [ordered]@{ - Path = $null - IsVcToolset = $false - IsDotNetFramework = $false - } - - $cmd = Get-Command "cvtres.exe" -ErrorAction SilentlyContinue - if ($cmd) { - $result.Path = $cmd.Source - $lower = $result.Path.ToLowerInvariant() - $result.IsVcToolset = $lower -match "[\\/]vc[\\/]tools[\\/]msvc[\\/][^\\/]+[\\/]bin[\\/]hostx64[\\/]x64[\\/]cvtres\.exe$" - $result.IsDotNetFramework = $lower -match "windows[\\/]microsoft\.net[\\/]framework" - return $result - } - - if ($env:VCINSTALLDIR) { - $candidates = Get-ChildItem -Path (Join-Path $env:VCINSTALLDIR "Tools\MSVC\*") -Filter cvtres.exe -Recurse -ErrorAction SilentlyContinue | - Sort-Object FullName -Descending - if ($candidates -and $candidates.Count -gt 0) { - $result.Path = $candidates[0].FullName - $lower = $result.Path.ToLowerInvariant() - $result.IsVcToolset = $lower -match "[\\/]vc[\\/]tools[\\/]msvc[\\/][^\\/]+[\\/]bin[\\/]hostx64[\\/]x64[\\/]cvtres\.exe$" - $result.IsDotNetFramework = $lower -match "windows[\\/]microsoft\.net[\\/]framework" - } - } - - return $result + <# + .SYNOPSIS + Returns details about the cvtres.exe resolved in the current session. + #> + $result = [ordered]@{ + Path = $null + IsVcToolset = $false + IsDotNetFramework = $false + } + + $cmd = Get-Command "cvtres.exe" -ErrorAction SilentlyContinue + if ($cmd) { + $result.Path = $cmd.Source + $lower = $result.Path.ToLowerInvariant() + $result.IsVcToolset = $lower -match "[\\/]vc[\\/]tools[\\/]msvc[\\/][^\\/]+[\\/]bin[\\/]hostx64[\\/]x64[\\/]cvtres\.exe$" + $result.IsDotNetFramework = $lower -match "windows[\\/]microsoft\.net[\\/]framework" + return $result + } + + if ($env:VCINSTALLDIR) { + $candidates = Get-ChildItem -Path (Join-Path $env:VCINSTALLDIR "Tools\MSVC\*") -Filter cvtres.exe -Recurse -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending + if ($candidates -and $candidates.Count -gt 0) { + $result.Path = $candidates[0].FullName + $lower = $result.Path.ToLowerInvariant() + $result.IsVcToolset = $lower -match "[\\/]vc[\\/]tools[\\/]msvc[\\/][^\\/]+[\\/]bin[\\/]hostx64[\\/]x64[\\/]cvtres\.exe$" + $result.IsDotNetFramework = $lower -match "windows[\\/]microsoft\.net[\\/]framework" + } + } + + return $result } function Test-CvtresCompatibility { - <# - .SYNOPSIS - Emits warnings if cvtres.exe resolves to a non-VC toolset binary. - #> - $diag = Get-CvtresDiagnostics - - if (-not $diag.Path) { - Write-Host "[WARN] cvtres.exe not found after VS environment setup. Toolchain may be incomplete." -ForegroundColor Yellow - return - } - - if ($diag.IsDotNetFramework) { - Write-Host "[WARN] cvtres.exe resolves to a .NET Framework path. Prefer the VC toolset version (Hostx64\\x64). $($diag.Path)" -ForegroundColor Yellow - } - elseif (-not $diag.IsVcToolset) { - Write-Host "[WARN] cvtres.exe is not from the VC toolset Hostx64\\x64 folder. Confirm PATH ordering. $($diag.Path)" -ForegroundColor Yellow - } + <# + .SYNOPSIS + Emits warnings if cvtres.exe resolves to a non-VC toolset binary. + #> + $diag = Get-CvtresDiagnostics + + if (-not $diag.Path) { + Write-Host "[WARN] cvtres.exe not found after VS environment setup. Toolchain may be incomplete." -ForegroundColor Yellow + return + } + + if ($diag.IsDotNetFramework) { + Write-Host "[WARN] cvtres.exe resolves to a .NET Framework path. Prefer the VC toolset version (Hostx64\\x64). $($diag.Path)" -ForegroundColor Yellow + } + elseif (-not $diag.IsVcToolset) { + Write-Host "[WARN] cvtres.exe is not from the VC toolset Hostx64\\x64 folder. Confirm PATH ordering. $($diag.Path)" -ForegroundColor Yellow + } } # ============================================================================= @@ -150,89 +150,89 @@ function Test-CvtresCompatibility { # ============================================================================= function Get-MSBuildPath { - <# - .SYNOPSIS - Gets the path to MSBuild.exe. - .DESCRIPTION - Returns the MSBuild command, either from PATH or 'msbuild' as fallback. - #> - $msbuildCmd = Get-Command msbuild -ErrorAction SilentlyContinue - if ($msbuildCmd) { - return $msbuildCmd.Source - } - return 'msbuild' + <# + .SYNOPSIS + Gets the path to MSBuild.exe. + .DESCRIPTION + Returns the MSBuild command, either from PATH or 'msbuild' as fallback. + #> + $msbuildCmd = Get-Command msbuild -ErrorAction SilentlyContinue + if ($msbuildCmd) { + return $msbuildCmd.Source + } + return 'msbuild' } function Invoke-MSBuild { - <# - .SYNOPSIS - Executes MSBuild with proper error handling. - .DESCRIPTION - Runs MSBuild with the specified arguments and handles errors appropriately. - .PARAMETER Arguments - Array of arguments to pass to MSBuild. - .PARAMETER Description - Human-readable description of the build step. - .PARAMETER LogPath - Optional path to write build output to a log file. - .PARAMETER TailLines - If specified, only displays the last N lines of output. - #> - param( - [Parameter(Mandatory)] - [string[]]$Arguments, - [Parameter(Mandatory)] - [string]$Description, - [string]$LogPath = '', - [int]$TailLines = 0 - ) - - $msbuildCmd = Get-MSBuildPath - Write-Host "Running $Description..." -ForegroundColor Cyan - - if ($TailLines -gt 0) { - # Capture all output, optionally log to file, then display tail - $output = & $msbuildCmd $Arguments 2>&1 | ForEach-Object { $_.ToString() } - $exitCode = $LASTEXITCODE - - if ($LogPath) { - $logDir = Split-Path -Parent $LogPath - if ($logDir -and -not (Test-Path $logDir)) { - New-Item -Path $logDir -ItemType Directory -Force | Out-Null - } - $output | Out-File -FilePath $LogPath -Encoding utf8 - } - - # Display last N lines - $totalLines = $output.Count - if ($totalLines -gt $TailLines) { - Write-Host "... ($($totalLines - $TailLines) lines omitted, showing last $TailLines) ..." -ForegroundColor DarkGray - $output | Select-Object -Last $TailLines | ForEach-Object { Write-Host $_ } - } - else { - $output | ForEach-Object { Write-Host $_ } - } - - $LASTEXITCODE = $exitCode - } - elseif ($LogPath) { - $logDir = Split-Path -Parent $LogPath - if ($logDir -and -not (Test-Path $logDir)) { - New-Item -Path $logDir -ItemType Directory -Force | Out-Null - } - & $msbuildCmd $Arguments | Tee-Object -FilePath $LogPath - } - else { - & $msbuildCmd $Arguments - } - - if ($LASTEXITCODE -ne 0) { - $errorMsg = "MSBuild failed during $Description with exit code $LASTEXITCODE" - if ($LASTEXITCODE -eq -1073741819) { - $errorMsg += " (0xC0000005 - Access Violation). This indicates a crash in native code during build." - } - throw $errorMsg - } + <# + .SYNOPSIS + Executes MSBuild with proper error handling. + .DESCRIPTION + Runs MSBuild with the specified arguments and handles errors appropriately. + .PARAMETER Arguments + Array of arguments to pass to MSBuild. + .PARAMETER Description + Human-readable description of the build step. + .PARAMETER LogPath + Optional path to write build output to a log file. + .PARAMETER TailLines + If specified, only displays the last N lines of output. + #> + param( + [Parameter(Mandatory)] + [string[]]$Arguments, + [Parameter(Mandatory)] + [string]$Description, + [string]$LogPath = '', + [int]$TailLines = 0 + ) + + $msbuildCmd = Get-MSBuildPath + Write-Host "Running $Description..." -ForegroundColor Cyan + + if ($TailLines -gt 0) { + # Capture all output, optionally log to file, then display tail + $output = & $msbuildCmd $Arguments 2>&1 | ForEach-Object { $_.ToString() } + $exitCode = $LASTEXITCODE + + if ($LogPath) { + $logDir = Split-Path -Parent $LogPath + if ($logDir -and -not (Test-Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null + } + $output | Out-File -FilePath $LogPath -Encoding utf8 + } + + # Display last N lines + $totalLines = $output.Count + if ($totalLines -gt $TailLines) { + Write-Host "... ($($totalLines - $TailLines) lines omitted, showing last $TailLines) ..." -ForegroundColor DarkGray + $output | Select-Object -Last $TailLines | ForEach-Object { Write-Host $_ } + } + else { + $output | ForEach-Object { Write-Host $_ } + } + + $LASTEXITCODE = $exitCode + } + elseif ($LogPath) { + $logDir = Split-Path -Parent $LogPath + if ($logDir -and -not (Test-Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null + } + & $msbuildCmd $Arguments | Tee-Object -FilePath $LogPath + } + else { + & $msbuildCmd $Arguments + } + + if ($LASTEXITCODE -ne 0) { + $errorMsg = "MSBuild failed during $Description with exit code $LASTEXITCODE" + if ($LASTEXITCODE -eq -1073741819) { + $errorMsg += " (0xC0000005 - Access Violation). This indicates a crash in native code during build." + } + throw $errorMsg + } } # ============================================================================= @@ -240,42 +240,42 @@ function Invoke-MSBuild { # ============================================================================= function Get-VSTestPath { - <# - .SYNOPSIS - Finds vstest.console.exe in PATH or known locations. - .DESCRIPTION - First checks PATH, then falls back to known VS installation paths. - #> - - # Try PATH first (setup scripts add vstest to PATH) - $vstestFromPath = Get-Command "vstest.console.exe" -ErrorAction SilentlyContinue - if ($vstestFromPath) { - return $vstestFromPath.Source - } - - # Fall back to known installation paths - $programFilesX86 = ${env:ProgramFiles(x86)} - if (-not $programFilesX86) { $programFilesX86 = "C:\Program Files (x86)" } - - $vstestCandidates = @( - # BuildTools - "$programFilesX86\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", - "C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", - # TestAgent (sometimes installed separately) - "$programFilesX86\Microsoft Visual Studio\2022\TestAgent\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", - # Full VS installations - "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", - "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", - "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" - ) - - foreach ($candidate in $vstestCandidates) { - if (Test-Path $candidate) { - return $candidate - } - } - - return $null + <# + .SYNOPSIS + Finds vstest.console.exe in PATH or known locations. + .DESCRIPTION + First checks PATH, then falls back to known VS installation paths. + #> + + # Try PATH first (setup scripts add vstest to PATH) + $vstestFromPath = Get-Command "vstest.console.exe" -ErrorAction SilentlyContinue + if ($vstestFromPath) { + return $vstestFromPath.Source + } + + # Fall back to known installation paths + $programFilesX86 = ${env:ProgramFiles(x86)} + if (-not $programFilesX86) { $programFilesX86 = "C:\Program Files (x86)" } + + $vstestCandidates = @( + # BuildTools + "$programFilesX86\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", + "C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", + # TestAgent (sometimes installed separately) + "$programFilesX86\Microsoft Visual Studio\2022\TestAgent\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", + # Full VS installations + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" + ) + + foreach ($candidate in $vstestCandidates) { + if (Test-Path $candidate) { + return $candidate + } + } + + return $null } # ============================================================================= @@ -283,9 +283,9 @@ function Get-VSTestPath { # ============================================================================= Export-ModuleMember -Function @( - 'Initialize-VsDevEnvironment', + 'Initialize-VsDevEnvironment', 'Test-CvtresCompatibility', - 'Get-MSBuildPath', - 'Invoke-MSBuild', - 'Get-VSTestPath' + 'Get-MSBuildPath', + 'Invoke-MSBuild', + 'Get-VSTestPath' ) diff --git a/Build/Agent/FwBuildHelpers.psm1 b/Build/Agent/FwBuildHelpers.psm1 index b436b8fcca..3cc639ff85 100644 --- a/Build/Agent/FwBuildHelpers.psm1 +++ b/Build/Agent/FwBuildHelpers.psm1 @@ -1,20 +1,20 @@ <# .SYNOPSIS - Shared helper functions for FieldWorks build and test scripts. + Shared helper functions for FieldWorks build and test scripts. .DESCRIPTION - This module provides common functionality for build.ps1 and test.ps1: - - Worktree path detection - - VS environment initialization - - Conflicting process cleanup - - Stale obj folder cleanup - - MSBuild execution helpers + This module provides common functionality for build.ps1 and test.ps1: + - Worktree path detection + - VS environment initialization + - Conflicting process cleanup + - Stale obj folder cleanup + - MSBuild execution helpers - This is the main entry point that imports specialized sub-modules. + This is the main entry point that imports specialized sub-modules. .NOTES - Import this module at the start of build.ps1 and test.ps1: - Import-Module "$PSScriptRoot/Build/Agent/FwBuildHelpers.psm1" -Force + Import this module at the start of build.ps1 and test.ps1: + Import-Module "$PSScriptRoot/Build/Agent/FwBuildHelpers.psm1" -Force #> # ============================================================================= @@ -31,161 +31,161 @@ Import-Module (Join-Path $moduleRoot "FwBuildEnvironment.psm1") -Force # ============================================================================= function Stop-ConflictingProcesses { - <# - .SYNOPSIS - Stops processes that could interfere with builds/tests. - .DESCRIPTION - Kills build- and test-related processes that can hold locks on artifacts - such as FwBuildTasks.dll. Defaults to the current session. - - Implements "Smart Kill" strategy: - 1. Identifies processes by name (msbuild, dotnet, etc.) - 2. Filters by current session ID - 3. If RepoRoot is provided, filters by: - - Command line containing RepoRoot path - - Loaded modules (DLLs) within RepoRoot path - - This allows concurrent builds in different worktrees to coexist without - killing each other's MSBuild nodes. - #> - param( - [string[]]$AdditionalProcessNames = @(), - [switch]$IncludeOmniSharp, - [string]$RepoRoot - ) - - $conflicts = @( - # Managed build/test hosts (Persistent lockers) - "dotnet", "msbuild", "VBCSCompiler", "vstest.console", "testhost", "FieldWorks" - ) - - if ($IncludeOmniSharp) { - $conflicts += @("OmniSharp", "OmniSharp.Http", "OmniSharp.Stdio") - } - - if ($AdditionalProcessNames) { - $conflicts += $AdditionalProcessNames - } - - $conflicts = $conflicts | Where-Object { $_ } | Select-Object -Unique - - $currentSessionId = (Get-Process -Id $PID).SessionId - - $processes = foreach ($name in $conflicts) { - Get-Process -Name $name -ErrorAction SilentlyContinue - } - - # Always filter by current session - $processes = $processes | Where-Object { $_.SessionId -eq $currentSessionId } - - # Filter by RepoRoot (Smart Kill) - only kill processes locking files in this repo - if ($RepoRoot) { - $processesToKill = @() - $RepoRoot = $RepoRoot.TrimEnd('\').TrimEnd('/') - - foreach ($p in $processes) { - if ($p.Id -eq $PID) { continue } # Don't kill self - - $isRelated = $false - - # 1. Check Command Line (fast) - try { - $cim = Get-CimInstance Win32_Process -Filter "ProcessId = $($p.Id)" -ErrorAction SilentlyContinue - if ($cim.CommandLine -and $cim.CommandLine.IndexOf($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { - $isRelated = $true - } - } catch {} - - # 2. Check Modules (slower, but catches MSBuild nodes holding DLLs) - if (-not $isRelated) { - try { - # Check if any loaded module is within the RepoRoot - if ($p.Modules | Where-Object { $_.FileName -and $_.FileName.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase) }) { - $isRelated = $true - } - } catch {} - } - - if ($isRelated) { - $processesToKill += $p - } - } - $processes = $processesToKill - } - - if ($processes) { - $byName = $processes | Group-Object -Property ProcessName - foreach ($group in $byName) { - $count = @($group.Group).Count - Write-Host "Closing $count stale $($group.Name) process(es)..." -ForegroundColor Yellow - $group.Group | Stop-Process -Force -ErrorAction SilentlyContinue - } - - Start-Sleep -Milliseconds 500 - } + <# + .SYNOPSIS + Stops processes that could interfere with builds/tests. + .DESCRIPTION + Kills build- and test-related processes that can hold locks on artifacts + such as FwBuildTasks.dll. Defaults to the current session. + + Implements "Smart Kill" strategy: + 1. Identifies processes by name (msbuild, dotnet, etc.) + 2. Filters by current session ID + 3. If RepoRoot is provided, filters by: + - Command line containing RepoRoot path + - Loaded modules (DLLs) within RepoRoot path + + This allows concurrent builds in different worktrees to coexist without + killing each other's MSBuild nodes. + #> + param( + [string[]]$AdditionalProcessNames = @(), + [switch]$IncludeOmniSharp, + [string]$RepoRoot + ) + + $conflicts = @( + # Managed build/test hosts (Persistent lockers) + "dotnet", "msbuild", "VBCSCompiler", "vstest.console", "testhost", "FieldWorks" + ) + + if ($IncludeOmniSharp) { + $conflicts += @("OmniSharp", "OmniSharp.Http", "OmniSharp.Stdio") + } + + if ($AdditionalProcessNames) { + $conflicts += $AdditionalProcessNames + } + + $conflicts = $conflicts | Where-Object { $_ } | Select-Object -Unique + + $currentSessionId = (Get-Process -Id $PID).SessionId + + $processes = foreach ($name in $conflicts) { + Get-Process -Name $name -ErrorAction SilentlyContinue + } + + # Always filter by current session + $processes = $processes | Where-Object { $_.SessionId -eq $currentSessionId } + + # Filter by RepoRoot (Smart Kill) - only kill processes locking files in this repo + if ($RepoRoot) { + $processesToKill = @() + $RepoRoot = $RepoRoot.TrimEnd('\').TrimEnd('/') + + foreach ($p in $processes) { + if ($p.Id -eq $PID) { continue } # Don't kill self + + $isRelated = $false + + # 1. Check Command Line (fast) + try { + $cim = Get-CimInstance Win32_Process -Filter "ProcessId = $($p.Id)" -ErrorAction SilentlyContinue + if ($cim.CommandLine -and $cim.CommandLine.IndexOf($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) { + $isRelated = $true + } + } catch {} + + # 2. Check Modules (slower, but catches MSBuild nodes holding DLLs) + if (-not $isRelated) { + try { + # Check if any loaded module is within the RepoRoot + if ($p.Modules | Where-Object { $_.FileName -and $_.FileName.StartsWith($RepoRoot, [System.StringComparison]::OrdinalIgnoreCase) }) { + $isRelated = $true + } + } catch {} + } + + if ($isRelated) { + $processesToKill += $p + } + } + $processes = $processesToKill + } + + if ($processes) { + $byName = $processes | Group-Object -Property ProcessName + foreach ($group in $byName) { + $count = @($group.Group).Count + Write-Host "Closing $count stale $($group.Name) process(es)..." -ForegroundColor Yellow + $group.Group | Stop-Process -Force -ErrorAction SilentlyContinue + } + + Start-Sleep -Milliseconds 500 + } } function Test-IsFileLockError { - param([Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord) - - $messages = @() - $messages += $ErrorRecord.ToString() - if ($ErrorRecord.Exception) { - $messages += $ErrorRecord.Exception.Message - if ($ErrorRecord.Exception.InnerException) { - $messages += $ErrorRecord.Exception.InnerException.Message - } - } - - $lockPatterns = @( - 'used by another process', - 'being used by another process', - 'cannot access the file', - 'file is locked', - 'Access to the path .* denied', - 'sharing violation' - ) - - foreach ($pattern in $lockPatterns) { - if ($messages -match $pattern) { - return $true - } - } - - return $false + param([Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord) + + $messages = @() + $messages += $ErrorRecord.ToString() + if ($ErrorRecord.Exception) { + $messages += $ErrorRecord.Exception.Message + if ($ErrorRecord.Exception.InnerException) { + $messages += $ErrorRecord.Exception.InnerException.Message + } + } + + $lockPatterns = @( + 'used by another process', + 'being used by another process', + 'cannot access the file', + 'file is locked', + 'Access to the path .* denied', + 'sharing violation' + ) + + foreach ($pattern in $lockPatterns) { + if ($messages -match $pattern) { + return $true + } + } + + return $false } function Invoke-WithFileLockRetry { - param( - [Parameter(Mandatory)][ScriptBlock]$Action, - [Parameter(Mandatory)][string]$Context, - [switch]$IncludeOmniSharp, - [int]$MaxAttempts = 2 - ) - - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - $retry = $false - try { - & $Action - return - } - catch { - if ($attempt -lt $MaxAttempts -and (Test-IsFileLockError -ErrorRecord $_)) { - $nextAttempt = $attempt + 1 - Write-Host "[WARN] $Context hit a file lock. Cleaning and retrying (attempt $nextAttempt of $MaxAttempts)..." -ForegroundColor Yellow - Stop-ConflictingProcesses -IncludeOmniSharp:$IncludeOmniSharp - Start-Sleep -Seconds 2 - $retry = $true - } - else { - throw - } - } - - if (-not $retry) { - throw - } - } + param( + [Parameter(Mandatory)][ScriptBlock]$Action, + [Parameter(Mandatory)][string]$Context, + [switch]$IncludeOmniSharp, + [int]$MaxAttempts = 2 + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + $retry = $false + try { + & $Action + return + } + catch { + if ($attempt -lt $MaxAttempts -and (Test-IsFileLockError -ErrorRecord $_)) { + $nextAttempt = $attempt + 1 + Write-Host "[WARN] $Context hit a file lock. Cleaning and retrying (attempt $nextAttempt of $MaxAttempts)..." -ForegroundColor Yellow + Stop-ConflictingProcesses -IncludeOmniSharp:$IncludeOmniSharp + Start-Sleep -Seconds 2 + $retry = $true + } + else { + throw + } + } + + if (-not $retry) { + throw + } + } } # ============================================================================= @@ -193,117 +193,117 @@ function Invoke-WithFileLockRetry { # ============================================================================= function Remove-StaleObjFolders { - <# - .SYNOPSIS - Removes stale per-project obj/ folders from source trees. - .DESCRIPTION - Since SDK migration, intermediate output uses centralized Obj/ folder. - Old per-project obj/ folders cause CS0579 duplicate attribute errors. - #> - param([Parameter(Mandatory)][string]$RepoRoot) - - $scanRoots = @( - (Join-Path $RepoRoot "Src"), - (Join-Path $RepoRoot "Lib"), - (Join-Path $RepoRoot "FLExInstaller") - ) - - foreach ($root in $scanRoots) { - if (-not (Test-Path $root -PathType Container)) { - continue - } - - try { - # Use .NET enumeration for performance (faster than Get-ChildItem -Recurse) - $staleObjFolders = [System.IO.Directory]::GetDirectories($root, "obj", [System.IO.SearchOption]::AllDirectories) - if ($staleObjFolders.Length -gt 0) { - Write-Host "Removing stale per-project obj/ folders under '$root' ($($staleObjFolders.Length) found)..." -ForegroundColor Yellow - foreach ($folder in $staleObjFolders) { - Remove-Item -Path $folder -Recurse -Force -ErrorAction SilentlyContinue - } - Write-Host "[OK] Stale obj/ folders cleaned under '$root'" -ForegroundColor Green - } - } - catch { - # Ignore enumeration errors (access denied, etc.) - } - } - - # Check for stale Output/Common/ (pre-configuration-aware build artifacts) - # After migration, COM artifacts are in Output/$(Configuration)/Common/ instead of Output/Common/ - $staleCommonDir = Join-Path (Join-Path $RepoRoot "Output") "Common" - if (Test-Path $staleCommonDir) { - Write-Host "Removing stale Output/Common/ folder (migrated to Output//Common/)..." -ForegroundColor Yellow - Remove-Item -Path $staleCommonDir -Recurse -Force -ErrorAction SilentlyContinue - Write-Host "[OK] Stale Output/Common/ cleaned" -ForegroundColor Green - } + <# + .SYNOPSIS + Removes stale per-project obj/ folders from source trees. + .DESCRIPTION + Since SDK migration, intermediate output uses centralized Obj/ folder. + Old per-project obj/ folders cause CS0579 duplicate attribute errors. + #> + param([Parameter(Mandatory)][string]$RepoRoot) + + $scanRoots = @( + (Join-Path $RepoRoot "Src"), + (Join-Path $RepoRoot "Lib"), + (Join-Path $RepoRoot "FLExInstaller") + ) + + foreach ($root in $scanRoots) { + if (-not (Test-Path $root -PathType Container)) { + continue + } + + try { + # Use .NET enumeration for performance (faster than Get-ChildItem -Recurse) + $staleObjFolders = [System.IO.Directory]::GetDirectories($root, "obj", [System.IO.SearchOption]::AllDirectories) + if ($staleObjFolders.Length -gt 0) { + Write-Host "Removing stale per-project obj/ folders under '$root' ($($staleObjFolders.Length) found)..." -ForegroundColor Yellow + foreach ($folder in $staleObjFolders) { + Remove-Item -Path $folder -Recurse -Force -ErrorAction SilentlyContinue + } + Write-Host "[OK] Stale obj/ folders cleaned under '$root'" -ForegroundColor Green + } + } + catch { + # Ignore enumeration errors (access denied, etc.) + } + } + + # Check for stale Output/Common/ (pre-configuration-aware build artifacts) + # After migration, COM artifacts are in Output/$(Configuration)/Common/ instead of Output/Common/ + $staleCommonDir = Join-Path (Join-Path $RepoRoot "Output") "Common" + if (Test-Path $staleCommonDir) { + Write-Host "Removing stale Output/Common/ folder (migrated to Output//Common/)..." -ForegroundColor Yellow + Remove-Item -Path $staleCommonDir -Recurse -Force -ErrorAction SilentlyContinue + Write-Host "[OK] Stale Output/Common/ cleaned" -ForegroundColor Green + } } function Test-CoffArchiveHeader { - <# - .SYNOPSIS - Validates the COFF archive magic at the start of a .lib file. - .DESCRIPTION - Reads the first 8 bytes and checks for the standard "!\n" header. - Returns $true when the header matches, $false when it is readable but - does not match, and $null when the file cannot be opened (skip delete). - #> - param([Parameter(Mandatory)][string]$Path) - - if (-not (Test-Path $Path -PathType Leaf)) { return $null } - - $expected = "!\n" - $buffer = New-Object byte[] ($expected.Length) - $bytesRead = 0 - - try { - $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) - try { - $bytesRead = $stream.Read($buffer, 0, $buffer.Length) - } - finally { - $stream.Dispose() - } - } - catch { - return $null - } - - if ($bytesRead -lt $expected.Length) { return $false } - - $actual = [System.Text.Encoding]::ASCII.GetString($buffer) - return $actual -eq $expected + <# + .SYNOPSIS + Validates the COFF archive magic at the start of a .lib file. + .DESCRIPTION + Reads the first 8 bytes and checks for the standard "!\n" header. + Returns $true when the header matches, $false when it is readable but + does not match, and $null when the file cannot be opened (skip delete). + #> + param([Parameter(Mandatory)][string]$Path) + + if (-not (Test-Path $Path -PathType Leaf)) { return $null } + + $expected = "!\n" + $buffer = New-Object byte[] ($expected.Length) + $bytesRead = 0 + + try { + $stream = [System.IO.File]::Open($Path, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) + try { + $bytesRead = $stream.Read($buffer, 0, $buffer.Length) + } + finally { + $stream.Dispose() + } + } + catch { + return $null + } + + if ($bytesRead -lt $expected.Length) { return $false } + + $actual = [System.Text.Encoding]::ASCII.GetString($buffer) + return $actual -eq $expected } function Test-GitTrackedFile { - <# - .SYNOPSIS - Returns $true if the path is tracked by git, $false if untracked, $null on error. - #> - param( - [Parameter(Mandatory)][string]$RepoRoot, - [Parameter(Mandatory)][string]$Path - ) - - if (-not (Test-Path $RepoRoot)) { return $null } - - $relPath = $Path - try { - $uriRoot = New-Object System.Uri($RepoRoot + [System.IO.Path]::DirectorySeparatorChar) - $uriPath = New-Object System.Uri($Path) - $relPath = $uriRoot.MakeRelativeUri($uriPath).ToString().Replace('/', '\') - } - catch { } - - $gitExe = "git" - $arguments = @('-C', $RepoRoot, 'ls-files', '--error-unmatch', $relPath) - try { - $p = Start-Process -FilePath $gitExe -ArgumentList $arguments -NoNewWindow -Wait -PassThru -ErrorAction Stop - return $p.ExitCode -eq 0 - } - catch { - return $null - } + <# + .SYNOPSIS + Returns $true if the path is tracked by git, $false if untracked, $null on error. + #> + param( + [Parameter(Mandatory)][string]$RepoRoot, + [Parameter(Mandatory)][string]$Path + ) + + if (-not (Test-Path $RepoRoot)) { return $null } + + $relPath = $Path + try { + $uriRoot = New-Object System.Uri($RepoRoot + [System.IO.Path]::DirectorySeparatorChar) + $uriPath = New-Object System.Uri($Path) + $relPath = $uriRoot.MakeRelativeUri($uriPath).ToString().Replace('/', '\') + } + catch { } + + $gitExe = "git" + $arguments = @('-C', $RepoRoot, 'ls-files', '--error-unmatch', $relPath) + try { + $p = Start-Process -FilePath $gitExe -ArgumentList $arguments -NoNewWindow -Wait -PassThru -ErrorAction Stop + return $p.ExitCode -eq 0 + } + catch { + return $null + } } # ============================================================================= @@ -312,16 +312,16 @@ function Test-GitTrackedFile { # Re-export functions from sub-modules plus local functions Export-ModuleMember -Function @( - # From FwBuildEnvironment.psm1 - 'Initialize-VsDevEnvironment', - 'Get-MSBuildPath', - 'Invoke-MSBuild', - 'Get-VSTestPath', - 'Test-CvtresCompatibility', - 'Get-CvtresDiagnostics', - # Local functions - 'Stop-ConflictingProcesses', - 'Remove-StaleObjFolders', - 'Test-IsFileLockError', - 'Invoke-WithFileLockRetry' + # From FwBuildEnvironment.psm1 + 'Initialize-VsDevEnvironment', + 'Get-MSBuildPath', + 'Invoke-MSBuild', + 'Get-VSTestPath', + 'Test-CvtresCompatibility', + 'Get-CvtresDiagnostics', + # Local functions + 'Stop-ConflictingProcesses', + 'Remove-StaleObjFolders', + 'Test-IsFileLockError', + 'Invoke-WithFileLockRetry' ) diff --git a/Build/Agent/Preprocess-WixIncludes.ps1 b/Build/Agent/Preprocess-WixIncludes.ps1 index e2d2c07592..f6a4e65710 100644 --- a/Build/Agent/Preprocess-WixIncludes.ps1 +++ b/Build/Agent/Preprocess-WixIncludes.ps1 @@ -1,99 +1,99 @@ [CmdletBinding()] param( - [Parameter(Mandatory = $true)] - [string]$InputPath, + [Parameter(Mandatory = $true)] + [string]$InputPath, - [Parameter(Mandatory = $true)] - [string]$OutputPath, + [Parameter(Mandatory = $true)] + [string]$OutputPath, - [string]$BaseDirectory + [string]$BaseDirectory ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Resolve-IncludePath { - param( - [Parameter(Mandatory = $true)] - [string]$Path, + param( + [Parameter(Mandatory = $true)] + [string]$Path, - [Parameter(Mandatory = $true)] - [string]$CurrentDirectory - ) + [Parameter(Mandatory = $true)] + [string]$CurrentDirectory + ) - if ([System.IO.Path]::IsPathRooted($Path)) { - return $Path - } + if ([System.IO.Path]::IsPathRooted($Path)) { + return $Path + } - return [System.IO.Path]::Combine($CurrentDirectory, $Path) + return [System.IO.Path]::Combine($CurrentDirectory, $Path) } function Expand-File { - param( - [Parameter(Mandatory = $true)] - [string]$Path, - - [Parameter(Mandatory = $true)] - [hashtable]$Vars - ) - - $currentDir = Split-Path -Parent $Path - if (-not (Test-Path -LiteralPath $Path)) { - throw "Input/include file not found: $Path" - } - - $outLines = New-Object System.Collections.Generic.List[string] - - foreach ($line in (Get-Content -LiteralPath $Path)) { - $defineMatch = [regex]::Match($line, '^\s*<\?define\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*\?>\s*$') - if ($defineMatch.Success) { - $name = $defineMatch.Groups[1].Value - $value = $defineMatch.Groups[2].Value - $Vars[$name] = $value - continue - } - - $includeMatch = [regex]::Match($line, '^\s*<\?include\s+(.+?)\s*\?>\s*$') - if ($includeMatch.Success) { - $raw = $includeMatch.Groups[1].Value.Trim() - $inc = $raw.Trim('"', "'") - $resolved = Resolve-IncludePath -Path $inc -CurrentDirectory $currentDir - $expanded = Expand-File -Path $resolved -Vars $Vars - $outLines.AddRange([string[]]$expanded) - continue - } - - $expandedLine = [regex]::Replace( - $line, - '\$\(var\.([A-Za-z_][A-Za-z0-9_]*)\)', - { - param($m) - $key = $m.Groups[1].Value - if ($Vars.ContainsKey($key)) { return $Vars[$key] } - return $m.Value - } - ) - - $outLines.Add($expandedLine) - } - - return $outLines + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [hashtable]$Vars + ) + + $currentDir = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $Path)) { + throw "Input/include file not found: $Path" + } + + $outLines = New-Object System.Collections.Generic.List[string] + + foreach ($line in (Get-Content -LiteralPath $Path)) { + $defineMatch = [regex]::Match($line, '^\s*<\?define\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*\?>\s*$') + if ($defineMatch.Success) { + $name = $defineMatch.Groups[1].Value + $value = $defineMatch.Groups[2].Value + $Vars[$name] = $value + continue + } + + $includeMatch = [regex]::Match($line, '^\s*<\?include\s+(.+?)\s*\?>\s*$') + if ($includeMatch.Success) { + $raw = $includeMatch.Groups[1].Value.Trim() + $inc = $raw.Trim('"', "'") + $resolved = Resolve-IncludePath -Path $inc -CurrentDirectory $currentDir + $expanded = Expand-File -Path $resolved -Vars $Vars + $outLines.AddRange([string[]]$expanded) + continue + } + + $expandedLine = [regex]::Replace( + $line, + '\$\(var\.([A-Za-z_][A-Za-z0-9_]*)\)', + { + param($m) + $key = $m.Groups[1].Value + if ($Vars.ContainsKey($key)) { return $Vars[$key] } + return $m.Value + } + ) + + $outLines.Add($expandedLine) + } + + return $outLines } $inputFullPath = $InputPath if (-not [System.IO.Path]::IsPathRooted($inputFullPath)) { - if ([string]::IsNullOrWhiteSpace($BaseDirectory)) { - $BaseDirectory = (Get-Location).Path - } - $inputFullPath = [System.IO.Path]::Combine($BaseDirectory, $InputPath) + if ([string]::IsNullOrWhiteSpace($BaseDirectory)) { + $BaseDirectory = (Get-Location).Path + } + $inputFullPath = [System.IO.Path]::Combine($BaseDirectory, $InputPath) } $outputFullPath = $OutputPath if (-not [System.IO.Path]::IsPathRooted($outputFullPath)) { - if ([string]::IsNullOrWhiteSpace($BaseDirectory)) { - $BaseDirectory = (Get-Location).Path - } - $outputFullPath = [System.IO.Path]::Combine($BaseDirectory, $OutputPath) + if ([string]::IsNullOrWhiteSpace($BaseDirectory)) { + $BaseDirectory = (Get-Location).Path + } + $outputFullPath = [System.IO.Path]::Combine($BaseDirectory, $OutputPath) } $vars = @{} @@ -101,7 +101,7 @@ $expandedLines = Expand-File -Path $inputFullPath -Vars $vars $outDir = Split-Path -Parent $outputFullPath if (-not (Test-Path -LiteralPath $outDir)) { - [void](New-Item -ItemType Directory -Path $outDir -Force) + [void](New-Item -ItemType Directory -Path $outDir -Force) } $utf8NoBom = New-Object System.Text.UTF8Encoding($false) diff --git a/Build/Agent/Rebuild-TestProjects.ps1 b/Build/Agent/Rebuild-TestProjects.ps1 index bc94c0bc08..f653d9ecc0 100644 --- a/Build/Agent/Rebuild-TestProjects.ps1 +++ b/Build/Agent/Rebuild-TestProjects.ps1 @@ -1,34 +1,34 @@ <# .SYNOPSIS - Rebuild test projects to ensure binding redirects are generated. + Rebuild test projects to ensure binding redirects are generated. .DESCRIPTION - After changes to Directory.Build.props (like adding DependencyModel reference), - test projects need to be rebuilt to regenerate their .dll.config binding redirects. - This script identifies test projects without the required redirects and rebuilds them. + After changes to Directory.Build.props (like adding DependencyModel reference), + test projects need to be rebuilt to regenerate their .dll.config binding redirects. + This script identifies test projects without the required redirects and rebuilds them. .PARAMETER Force - Rebuild all test projects, not just those missing binding redirects. + Rebuild all test projects, not just those missing binding redirects. .PARAMETER DryRun - Show which projects would be rebuilt without actually rebuilding. + Show which projects would be rebuilt without actually rebuilding. .EXAMPLE - .\Rebuild-TestProjects.ps1 - Rebuilds only test projects missing binding redirects. + .\Rebuild-TestProjects.ps1 + Rebuilds only test projects missing binding redirects. .EXAMPLE - .\Rebuild-TestProjects.ps1 -Force - Rebuilds all test projects. + .\Rebuild-TestProjects.ps1 -Force + Rebuilds all test projects. .EXAMPLE - .\Rebuild-TestProjects.ps1 -DryRun - Shows which projects need rebuilding without doing it. + .\Rebuild-TestProjects.ps1 -DryRun + Shows which projects need rebuilding without doing it. #> [CmdletBinding()] param( - [switch]$Force, - [switch]$DryRun + [switch]$Force, + [switch]$DryRun ) $ErrorActionPreference = 'Stop' @@ -36,11 +36,11 @@ $ErrorActionPreference = 'Stop' # Find repo root $repoRoot = $PSScriptRoot while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot "FieldWorks.sln"))) { - $repoRoot = Split-Path $repoRoot -Parent + $repoRoot = Split-Path $repoRoot -Parent } if (-not $repoRoot) { - Write-Error "Could not find repository root (FieldWorks.sln)" - exit 1 + Write-Error "Could not find repository root (FieldWorks.sln)" + exit 1 } $outputDir = Join-Path $repoRoot "Output\Debug" @@ -52,32 +52,32 @@ Write-Host "Checking test assemblies for binding redirects..." -ForegroundColor $needsRebuild = @() if ($Force) { - # Rebuild all - $testConfigs = Get-ChildItem $outputDir -Filter "*Tests.dll.config" -ErrorAction SilentlyContinue - $needsRebuild = $testConfigs | ForEach-Object { $_.Name -replace '\.dll\.config$', '' } + # Rebuild all + $testConfigs = Get-ChildItem $outputDir -Filter "*Tests.dll.config" -ErrorAction SilentlyContinue + $needsRebuild = $testConfigs | ForEach-Object { $_.Name -replace '\.dll\.config$', '' } } else { - # Check which ones are missing DependencyModel redirect - $testConfigs = Get-ChildItem $outputDir -Filter "*Tests.dll.config" -ErrorAction SilentlyContinue - foreach ($config in $testConfigs) { - $hasRedirect = (Get-Content $config.FullName | Select-String "DependencyModel").Count -gt 0 - if (-not $hasRedirect) { - $needsRebuild += $config.Name -replace '\.dll\.config$', '' - } - } + # Check which ones are missing DependencyModel redirect + $testConfigs = Get-ChildItem $outputDir -Filter "*Tests.dll.config" -ErrorAction SilentlyContinue + foreach ($config in $testConfigs) { + $hasRedirect = (Get-Content $config.FullName | Select-String "DependencyModel").Count -gt 0 + if (-not $hasRedirect) { + $needsRebuild += $config.Name -replace '\.dll\.config$', '' + } + } } if ($needsRebuild.Count -eq 0) { - Write-Host "All test assemblies have proper binding redirects." -ForegroundColor Green - exit 0 + Write-Host "All test assemblies have proper binding redirects." -ForegroundColor Green + exit 0 } Write-Host "Found $($needsRebuild.Count) test project(s) needing rebuild:" -ForegroundColor Yellow $needsRebuild | ForEach-Object { Write-Host " $_" -ForegroundColor Gray } if ($DryRun) { - Write-Host "" - Write-Host "Dry run - no changes made." -ForegroundColor Cyan - exit 0 + Write-Host "" + Write-Host "Dry run - no changes made." -ForegroundColor Cyan + exit 0 } Write-Host "" @@ -87,25 +87,25 @@ $succeeded = 0 $failed = 0 foreach ($projectName in $needsRebuild) { - $csproj = Get-ChildItem -Path $srcDir -Recurse -Filter "$projectName.csproj" | Select-Object -First 1 + $csproj = Get-ChildItem -Path $srcDir -Recurse -Filter "$projectName.csproj" | Select-Object -First 1 - if (-not $csproj) { - Write-Warning "Could not find $projectName.csproj" - $failed++ - continue - } + if (-not $csproj) { + Write-Warning "Could not find $projectName.csproj" + $failed++ + continue + } - Write-Host " Building $($csproj.Name)..." -ForegroundColor Gray -NoNewline + Write-Host " Building $($csproj.Name)..." -ForegroundColor Gray -NoNewline - $buildOutput = dotnet build $csproj.FullName -c Debug --no-incremental -v q 2>&1 + $buildOutput = dotnet build $csproj.FullName -c Debug --no-incremental -v q 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host " OK" -ForegroundColor Green - $succeeded++ - } else { - Write-Host " FAILED" -ForegroundColor Red - $failed++ - } + if ($LASTEXITCODE -eq 0) { + Write-Host " OK" -ForegroundColor Green + $succeeded++ + } else { + Write-Host " FAILED" -ForegroundColor Red + $failed++ + } } Write-Host "" @@ -117,20 +117,20 @@ Write-Host "Verifying binding redirects..." -ForegroundColor Cyan $stillMissing = @() foreach ($projectName in $needsRebuild) { - $configPath = Join-Path $outputDir "$projectName.dll.config" - if (Test-Path $configPath) { - $hasRedirect = (Get-Content $configPath | Select-String "DependencyModel").Count -gt 0 - if (-not $hasRedirect) { - $stillMissing += $projectName - } - } + $configPath = Join-Path $outputDir "$projectName.dll.config" + if (Test-Path $configPath) { + $hasRedirect = (Get-Content $configPath | Select-String "DependencyModel").Count -gt 0 + if (-not $hasRedirect) { + $stillMissing += $projectName + } + } } if ($stillMissing.Count -gt 0) { - Write-Warning "Still missing binding redirects: $($stillMissing -join ', ')" - exit 1 + Write-Warning "Still missing binding redirects: $($stillMissing -join ', ')" + exit 1 } else { - Write-Host "All rebuilt projects now have proper binding redirects." -ForegroundColor Green + Write-Host "All rebuilt projects now have proper binding redirects." -ForegroundColor Green } exit 0 diff --git a/Build/Agent/Remove-StaleDlls.ps1 b/Build/Agent/Remove-StaleDlls.ps1 index 6889e64494..7091f1f66f 100644 --- a/Build/Agent/Remove-StaleDlls.ps1 +++ b/Build/Agent/Remove-StaleDlls.ps1 @@ -1,65 +1,65 @@ <# .SYNOPSIS - Single-pass detection and removal of stale DLLs from a FieldWorks output directory. + Single-pass detection and removal of stale DLLs from a FieldWorks output directory. .DESCRIPTION - Performs up to two checks in one pass over every DLL in the target directory: + Performs up to two checks in one pass over every DLL in the target directory: - 1. Product major-version check (first-party whitelist) - Builds a positive list of first-party assembly names from Src/**/*.csproj project names - and overrides. For every DLL whose basename is in this whitelist, its - AssemblyVersion major component must match FWMAJOR from Src/MasterVersionInfo.txt. - This catches stale first-party DLLs left behind from a different FW version - (e.g. 6.0.0.0 in a 9.x tree). + 1. Product major-version check (first-party whitelist) + Builds a positive list of first-party assembly names from Src/**/*.csproj project names + and overrides. For every DLL whose basename is in this whitelist, its + AssemblyVersion major component must match FWMAJOR from Src/MasterVersionInfo.txt. + This catches stale first-party DLLs left behind from a different FW version + (e.g. 6.0.0.0 in a 9.x tree). - 2. Staged-vs-reference comparison (when -ReferenceDir is provided) - Every DLL present in both OutputDir and ReferenceDir is compared by - AssemblyName.FullName and FileVersion. This is the most reliable check and catches - NuGet version drift (LT-22382, e.g. Newtonsoft.Json 13.0.3 vs 13.0.4) as well as - any configuration-switch staleness. + 2. Staged-vs-reference comparison (when -ReferenceDir is provided) + Every DLL present in both OutputDir and ReferenceDir is compared by + AssemblyName.FullName and FileVersion. This is the most reliable check and catches + NuGet version drift (LT-22382, e.g. Newtonsoft.Json 13.0.3 vs 13.0.4) as well as + any configuration-switch staleness. - Any DLL that fails either check is removed so MSBuild re-copies the correct version. + Any DLL that fails either check is removed so MSBuild re-copies the correct version. - Use -ValidateOnly to report mismatches as errors without deleting (used by installer staging - validation targets). + Use -ValidateOnly to report mismatches as errors without deleting (used by installer staging + validation targets). .PARAMETER OutputDir - The directory to scan (e.g., Output\Debug, or a staged installer bin dir). + The directory to scan (e.g., Output\Debug, or a staged installer bin dir). .PARAMETER RepoRoot - Repository root. Defaults to two levels above this script. + Repository root. Defaults to two levels above this script. .PARAMETER ValidateOnly - Report mismatches as errors (exit 1) instead of deleting files. Suitable for MSBuild - post-staging validation. + Report mismatches as errors (exit 1) instead of deleting files. Suitable for MSBuild + post-staging validation. .PARAMETER ReferenceDir - Optional second directory. When provided, every DLL present in both OutputDir and - ReferenceDir is compared by AssemblyName.FullName and FileVersion. Mismatches are - reported (or cause deletion from OutputDir when -ValidateOnly is not set). + Optional second directory. When provided, every DLL present in both OutputDir and + ReferenceDir is compared by AssemblyName.FullName and FileVersion. Mismatches are + reported (or cause deletion from OutputDir when -ValidateOnly is not set). .EXAMPLE - .\Remove-StaleDlls.ps1 -OutputDir "Output\Debug" - Pre-build clean pass: removes stale first-party DLLs from the output directory. + .\Remove-StaleDlls.ps1 -OutputDir "Output\Debug" + Pre-build clean pass: removes stale first-party DLLs from the output directory. .EXAMPLE - .\Remove-StaleDlls.ps1 -OutputDir "Output\Release" -WhatIf - Shows what would be removed without deleting. + .\Remove-StaleDlls.ps1 -OutputDir "Output\Release" -WhatIf + Shows what would be removed without deleting. .EXAMPLE - .\Remove-StaleDlls.ps1 -OutputDir "BuildDir\...\objects\FieldWorks" -ReferenceDir "Output\Release" -ValidateOnly - Installer post-staging validation: fails the build if any staged DLL doesn't match the build output. + .\Remove-StaleDlls.ps1 -OutputDir "BuildDir\...\objects\FieldWorks" -ReferenceDir "Output\Release" -ValidateOnly + Installer post-staging validation: fails the build if any staged DLL doesn't match the build output. #> [CmdletBinding(SupportsShouldProcess)] param( - [Parameter(Mandatory)] - [string]$OutputDir, + [Parameter(Mandatory)] + [string]$OutputDir, - [string]$RepoRoot = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent), + [string]$RepoRoot = (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent), - [switch]$ValidateOnly, + [switch]$ValidateOnly, - [string]$ReferenceDir + [string]$ReferenceDir ) $ErrorActionPreference = 'Stop' @@ -69,23 +69,23 @@ $ErrorActionPreference = 'Stop' # --------------------------------------------------------------------------- function Resolve-DirPath ([string]$dir) { - if ([System.IO.Path]::IsPathRooted($dir)) { return $dir } - return Join-Path $RepoRoot $dir + if ([System.IO.Path]::IsPathRooted($dir)) { return $dir } + return Join-Path $RepoRoot $dir } $outputPath = Resolve-DirPath $OutputDir if (-not (Test-Path $outputPath)) { - Write-Verbose "Output directory does not exist: $outputPath" - return + Write-Verbose "Output directory does not exist: $outputPath" + return } $referencePath = $null if ($ReferenceDir) { - $referencePath = Resolve-DirPath $ReferenceDir - if (-not (Test-Path $referencePath)) { - Write-Verbose "Reference directory does not exist: $referencePath" - $referencePath = $null - } + $referencePath = Resolve-DirPath $ReferenceDir + if (-not (Test-Path $referencePath)) { + Write-Verbose "Reference directory does not exist: $referencePath" + $referencePath = $null + } } # ============================================================================= @@ -98,23 +98,23 @@ $firstPartyNames = [System.Collections.Generic.HashSet[string]]::new([System.Str $srcDir = Join-Path $RepoRoot "Src" if (Test-Path $srcDir) { - Get-ChildItem $srcDir -Filter "*.csproj" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { - # Default assembly name = project filename without extension - $projName = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) - [void]$firstPartyNames.Add($projName) - - try { - [xml]$csproj = Get-Content $_.FullName -Raw - # Check for override - $asmNameNode = $csproj.SelectSingleNode("//AssemblyName") - if ($asmNameNode -and $asmNameNode.InnerText) { - [void]$firstPartyNames.Add($asmNameNode.InnerText) - } - } - catch { - Write-Verbose "Could not parse $($_.FullName): $_" - } - } + Get-ChildItem $srcDir -Filter "*.csproj" -Recurse -ErrorAction SilentlyContinue | ForEach-Object { + # Default assembly name = project filename without extension + $projName = [System.IO.Path]::GetFileNameWithoutExtension($_.Name) + [void]$firstPartyNames.Add($projName) + + try { + [xml]$csproj = Get-Content $_.FullName -Raw + # Check for override + $asmNameNode = $csproj.SelectSingleNode("//AssemblyName") + if ($asmNameNode -and $asmNameNode.InnerText) { + [void]$firstPartyNames.Add($asmNameNode.InnerText) + } + } + catch { + Write-Verbose "Could not parse $($_.FullName): $_" + } + } } Write-Verbose "Found $($firstPartyNames.Count) first-party assembly names" @@ -123,17 +123,17 @@ Write-Verbose "Found $($firstPartyNames.Count) first-party assembly names" $expectedMajor = $null $versionInfoPath = Join-Path $RepoRoot "Src\MasterVersionInfo.txt" if (Test-Path $versionInfoPath) { - Get-Content $versionInfoPath | ForEach-Object { - if ($_ -match '^FWMAJOR=(\d+)') { - $expectedMajor = [int]$Matches[1] - } - } + Get-Content $versionInfoPath | ForEach-Object { + if ($_ -match '^FWMAJOR=(\d+)') { + $expectedMajor = [int]$Matches[1] + } + } } if ($null -eq $expectedMajor) { - Write-Warning "Could not determine FWMAJOR from MasterVersionInfo.txt. Product-version check disabled." + Write-Warning "Could not determine FWMAJOR from MasterVersionInfo.txt. Product-version check disabled." } else { - Write-Verbose "Expected product major version: $expectedMajor" + Write-Verbose "Expected product major version: $expectedMajor" } # ============================================================================= @@ -145,83 +145,83 @@ $removedCount = 0 $checkedProduct = 0 Get-ChildItem "$outputPath\*.dll" -ErrorAction SilentlyContinue | ForEach-Object { - $dll = $_ - $baseName = [System.IO.Path]::GetFileNameWithoutExtension($dll.Name) - - # --- Check A: Product major-version match (first-party DLLs only) --- - if (($null -ne $expectedMajor) -and $firstPartyNames.Contains($baseName)) { - try { - $asmName = [System.Reflection.AssemblyName]::GetAssemblyName($dll.FullName) - $asmVersion = $asmName.Version - } - catch { - # Not a managed assembly (native DLL) — skip - Write-Verbose "Skipping non-managed: $($dll.Name)" - return - } - - # Skip assemblies with version 0.0.0.0 (auto-generated or unversioned) - if ($asmVersion.Major -eq 0 -and $asmVersion.Minor -eq 0) { - return - } - - $checkedProduct++ - - if ($asmVersion.Major -ne $expectedMajor) { - $msg = "$($dll.Name): AssemblyVersion=$asmVersion, expected major=$expectedMajor (product)" - $problems.Add($msg) - if (-not $ValidateOnly) { - if ($PSCmdlet.ShouldProcess($dll.Name, "Remove (wrong product version: AssemblyVersion=$asmVersion, expected major=$expectedMajor)")) { - Remove-Item $dll.FullName -Force - $removedCount++ - Write-Host " Removed $msg" -ForegroundColor Yellow - } - } - return - } - } - - # --- Check B: Staged-vs-reference comparison (when -ReferenceDir provided) --- - if ($referencePath) { - $refDll = Join-Path $referencePath $dll.Name - if (Test-Path $refDll) { - # Compare AssemblyName.FullName (catches strong-name/version mismatches) - try { - $stagedAsm = [System.Reflection.AssemblyName]::GetAssemblyName($dll.FullName) - $refAsm = [System.Reflection.AssemblyName]::GetAssemblyName($refDll) - if ($stagedAsm.FullName -ne $refAsm.FullName) { - $msg = "$($dll.Name): staged=$($stagedAsm.FullName), build=$($refAsm.FullName) (assembly mismatch)" - $problems.Add($msg) - if (-not $ValidateOnly) { - if ($PSCmdlet.ShouldProcess($dll.Name, "Remove (staged/build assembly mismatch)")) { - Remove-Item $dll.FullName -Force - $removedCount++ - Write-Host " Removed $msg" -ForegroundColor Yellow - } - } - return - } - } - catch { - # One or both are native — fall through to FileVersion check - } - - # Compare FileVersion (catches same-AssemblyVersion NuGet bumps like Newtonsoft.Json) - $stagedFV = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll.FullName).FileVersion - $refFV = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($refDll).FileVersion - if ($stagedFV -ne $refFV) { - $msg = "$($dll.Name): staged FileVersion=$stagedFV, build FileVersion=$refFV (drift)" - $problems.Add($msg) - if (-not $ValidateOnly) { - if ($PSCmdlet.ShouldProcess($dll.Name, "Remove (staged/build FileVersion drift)")) { - Remove-Item $dll.FullName -Force - $removedCount++ - Write-Host " Removed $msg" -ForegroundColor Yellow - } - } - } - } - } + $dll = $_ + $baseName = [System.IO.Path]::GetFileNameWithoutExtension($dll.Name) + + # --- Check A: Product major-version match (first-party DLLs only) --- + if (($null -ne $expectedMajor) -and $firstPartyNames.Contains($baseName)) { + try { + $asmName = [System.Reflection.AssemblyName]::GetAssemblyName($dll.FullName) + $asmVersion = $asmName.Version + } + catch { + # Not a managed assembly (native DLL) — skip + Write-Verbose "Skipping non-managed: $($dll.Name)" + return + } + + # Skip assemblies with version 0.0.0.0 (auto-generated or unversioned) + if ($asmVersion.Major -eq 0 -and $asmVersion.Minor -eq 0) { + return + } + + $checkedProduct++ + + if ($asmVersion.Major -ne $expectedMajor) { + $msg = "$($dll.Name): AssemblyVersion=$asmVersion, expected major=$expectedMajor (product)" + $problems.Add($msg) + if (-not $ValidateOnly) { + if ($PSCmdlet.ShouldProcess($dll.Name, "Remove (wrong product version: AssemblyVersion=$asmVersion, expected major=$expectedMajor)")) { + Remove-Item $dll.FullName -Force + $removedCount++ + Write-Host " Removed $msg" -ForegroundColor Yellow + } + } + return + } + } + + # --- Check B: Staged-vs-reference comparison (when -ReferenceDir provided) --- + if ($referencePath) { + $refDll = Join-Path $referencePath $dll.Name + if (Test-Path $refDll) { + # Compare AssemblyName.FullName (catches strong-name/version mismatches) + try { + $stagedAsm = [System.Reflection.AssemblyName]::GetAssemblyName($dll.FullName) + $refAsm = [System.Reflection.AssemblyName]::GetAssemblyName($refDll) + if ($stagedAsm.FullName -ne $refAsm.FullName) { + $msg = "$($dll.Name): staged=$($stagedAsm.FullName), build=$($refAsm.FullName) (assembly mismatch)" + $problems.Add($msg) + if (-not $ValidateOnly) { + if ($PSCmdlet.ShouldProcess($dll.Name, "Remove (staged/build assembly mismatch)")) { + Remove-Item $dll.FullName -Force + $removedCount++ + Write-Host " Removed $msg" -ForegroundColor Yellow + } + } + return + } + } + catch { + # One or both are native — fall through to FileVersion check + } + + # Compare FileVersion (catches same-AssemblyVersion NuGet bumps like Newtonsoft.Json) + $stagedFV = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll.FullName).FileVersion + $refFV = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($refDll).FileVersion + if ($stagedFV -ne $refFV) { + $msg = "$($dll.Name): staged FileVersion=$stagedFV, build FileVersion=$refFV (drift)" + $problems.Add($msg) + if (-not $ValidateOnly) { + if ($PSCmdlet.ShouldProcess($dll.Name, "Remove (staged/build FileVersion drift)")) { + Remove-Item $dll.FullName -Force + $removedCount++ + Write-Host " Removed $msg" -ForegroundColor Yellow + } + } + } + } + } } # ============================================================================= @@ -230,16 +230,16 @@ Get-ChildItem "$outputPath\*.dll" -ErrorAction SilentlyContinue | ForEach-Object $totalChecked = $checkedProduct if ($problems.Count -eq 0) { - Write-Verbose ("No stale DLLs found (first-party checked={0})" -f $checkedProduct) + Write-Verbose ("No stale DLLs found (first-party checked={0})" -f $checkedProduct) } elseif ($ValidateOnly) { - Write-Host "" - Write-Host ("Stale DLL validation failed - {0} problem(s):" -f $problems.Count) -ForegroundColor Red - foreach ($p in $problems) { - Write-Host " $p" -ForegroundColor Red - } - exit 1 + Write-Host "" + Write-Host ("Stale DLL validation failed - {0} problem(s):" -f $problems.Count) -ForegroundColor Red + foreach ($p in $problems) { + Write-Host " $p" -ForegroundColor Red + } + exit 1 } else { - Write-Host ("Removed {0} stale DLL(s) (first-party checked={1})" -f $removedCount, $checkedProduct) -ForegroundColor Yellow + Write-Host ("Removed {0} stale DLL(s) (first-party checked={1})" -f $removedCount, $checkedProduct) -ForegroundColor Yellow } diff --git a/Build/Agent/Run-VsTests.ps1 b/Build/Agent/Run-VsTests.ps1 index 26e11873f8..64ed1c1540 100644 --- a/Build/Agent/Run-VsTests.ps1 +++ b/Build/Agent/Run-VsTests.ps1 @@ -1,56 +1,56 @@ <# .SYNOPSIS - Run VSTest for FieldWorks test assemblies with proper result parsing. + Run VSTest for FieldWorks test assemblies with proper result parsing. .DESCRIPTION - This script runs vstest.console.exe on specified test DLLs and parses the results - to provide clear pass/fail/skip counts. It handles the InIsolation mode configured - in Test.runsettings and properly interprets exit codes. + This script runs vstest.console.exe on specified test DLLs and parses the results + to provide clear pass/fail/skip counts. It handles the InIsolation mode configured + in Test.runsettings and properly interprets exit codes. .PARAMETER TestDlls - Array of test DLL names (e.g., "FwUtilsTests.dll") or paths. - If just names are provided, looks in Output\Debug by default. + Array of test DLL names (e.g., "FwUtilsTests.dll") or paths. + If just names are provided, looks in Output\Debug by default. .PARAMETER OutputDir - Directory containing test DLLs. Defaults to Output\Debug. + Directory containing test DLLs. Defaults to Output\Debug. .PARAMETER Filter - Optional VSTest filter expression (e.g., "TestCategory!=Slow"). + Optional VSTest filter expression (e.g., "TestCategory!=Slow"). .PARAMETER Rebuild - If specified, rebuilds the test projects before running tests. + If specified, rebuilds the test projects before running tests. .PARAMETER All - If specified, runs all *Tests.dll files found in OutputDir. + If specified, runs all *Tests.dll files found in OutputDir. .EXAMPLE - .\Run-VsTests.ps1 -TestDlls FwUtilsTests.dll - Runs FwUtilsTests.dll and shows results. + .\Run-VsTests.ps1 -TestDlls FwUtilsTests.dll + Runs FwUtilsTests.dll and shows results. .EXAMPLE - .\Run-VsTests.ps1 -TestDlls FwUtilsTests.dll,xCoreTests.dll - Runs multiple test DLLs and shows aggregate results. + .\Run-VsTests.ps1 -TestDlls FwUtilsTests.dll,xCoreTests.dll + Runs multiple test DLLs and shows aggregate results. .EXAMPLE - .\Run-VsTests.ps1 -All - Runs all test DLLs in Output\Debug. + .\Run-VsTests.ps1 -All + Runs all test DLLs in Output\Debug. .EXAMPLE - .\Run-VsTests.ps1 -TestDlls FwUtilsTests.dll -Rebuild - Rebuilds the test project first, then runs tests. + .\Run-VsTests.ps1 -TestDlls FwUtilsTests.dll -Rebuild + Rebuilds the test project first, then runs tests. #> [CmdletBinding()] param( - [Parameter(Position = 0)] - [string[]]$TestDlls, + [Parameter(Position = 0)] + [string[]]$TestDlls, - [string]$OutputDir, + [string]$OutputDir, - [string]$Filter, + [string]$Filter, - [switch]$Rebuild, + [switch]$Rebuild, - [switch]$All + [switch]$All ) $ErrorActionPreference = 'Continue' # Don't stop on stderr output from vstest @@ -58,68 +58,68 @@ $ErrorActionPreference = 'Continue' # Don't stop on stderr output from vstest # Find repo root (where FieldWorks.sln is) $repoRoot = $PSScriptRoot while ($repoRoot -and -not (Test-Path (Join-Path $repoRoot "FieldWorks.sln"))) { - $repoRoot = Split-Path $repoRoot -Parent + $repoRoot = Split-Path $repoRoot -Parent } if (-not $repoRoot) { - Write-Error "Could not find repository root (FieldWorks.sln)" - exit 1 + Write-Error "Could not find repository root (FieldWorks.sln)" + exit 1 } # Set defaults if (-not $OutputDir) { - $OutputDir = Join-Path $repoRoot "Output\Debug" + $OutputDir = Join-Path $repoRoot "Output\Debug" } $runSettings = Join-Path $repoRoot "Test.runsettings" $vsTestPath = "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" if (-not (Test-Path $vsTestPath)) { - # Try BuildTools path - $vsTestPath = "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" + # Try BuildTools path + $vsTestPath = "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow\vstest.console.exe" } if (-not (Test-Path $vsTestPath)) { - Write-Error "vstest.console.exe not found. Install Visual Studio 2022 or Build Tools." - exit 1 + Write-Error "vstest.console.exe not found. Install Visual Studio 2022 or Build Tools." + exit 1 } # Collect test DLLs if ($All) { - $TestDlls = Get-ChildItem $OutputDir -Filter "*Tests.dll" | - Where-Object { $_.Name -notmatch "\.resources\." } | - Select-Object -ExpandProperty Name - Write-Host "Found $($TestDlls.Count) test assemblies" -ForegroundColor Cyan + $TestDlls = Get-ChildItem $OutputDir -Filter "*Tests.dll" | + Where-Object { $_.Name -notmatch "\.resources\." } | + Select-Object -ExpandProperty Name + Write-Host "Found $($TestDlls.Count) test assemblies" -ForegroundColor Cyan } if (-not $TestDlls -or $TestDlls.Count -eq 0) { - Write-Host "Usage: Run-VsTests.ps1 [-TestDlls] [-All] [-Rebuild] [-Filter ]" -ForegroundColor Yellow - Write-Host "" - Write-Host "Examples:" - Write-Host " Run-VsTests.ps1 FwUtilsTests.dll" - Write-Host " Run-VsTests.ps1 FwUtilsTests.dll,xCoreTests.dll" - Write-Host " Run-VsTests.ps1 -All" - Write-Host " Run-VsTests.ps1 FwUtilsTests.dll -Rebuild" - exit 0 + Write-Host "Usage: Run-VsTests.ps1 [-TestDlls] [-All] [-Rebuild] [-Filter ]" -ForegroundColor Yellow + Write-Host "" + Write-Host "Examples:" + Write-Host " Run-VsTests.ps1 FwUtilsTests.dll" + Write-Host " Run-VsTests.ps1 FwUtilsTests.dll,xCoreTests.dll" + Write-Host " Run-VsTests.ps1 -All" + Write-Host " Run-VsTests.ps1 FwUtilsTests.dll -Rebuild" + exit 0 } # Rebuild if requested if ($Rebuild) { - Write-Host "Rebuilding test projects..." -ForegroundColor Cyan - foreach ($dll in $TestDlls) { - $dllName = [System.IO.Path]::GetFileNameWithoutExtension($dll) - $csprojPattern = Join-Path $repoRoot "Src\**\$dllName.csproj" - $csproj = Get-ChildItem -Path (Join-Path $repoRoot "Src") -Recurse -Filter "$dllName.csproj" | Select-Object -First 1 - - if ($csproj) { - Write-Host " Building $($csproj.Name)..." -ForegroundColor Gray - $buildOutput = dotnet build $csproj.FullName -c Debug --no-incremental -v q 2>&1 - if ($LASTEXITCODE -ne 0) { - Write-Warning "Build failed for $($csproj.Name)" - $buildOutput | Write-Host - } - } - } - Write-Host "" + Write-Host "Rebuilding test projects..." -ForegroundColor Cyan + foreach ($dll in $TestDlls) { + $dllName = [System.IO.Path]::GetFileNameWithoutExtension($dll) + $csprojPattern = Join-Path $repoRoot "Src\**\$dllName.csproj" + $csproj = Get-ChildItem -Path (Join-Path $repoRoot "Src") -Recurse -Filter "$dllName.csproj" | Select-Object -First 1 + + if ($csproj) { + Write-Host " Building $($csproj.Name)..." -ForegroundColor Gray + $buildOutput = dotnet build $csproj.FullName -c Debug --no-incremental -v q 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Warning "Build failed for $($csproj.Name)" + $buildOutput | Write-Host + } + } + } + Write-Host "" } # Run tests @@ -132,63 +132,63 @@ Write-Host "Running tests..." -ForegroundColor Cyan Write-Host "" foreach ($dll in $TestDlls) { - # Resolve full path - if (-not [System.IO.Path]::IsPathRooted($dll)) { - $dllPath = Join-Path $OutputDir $dll - } else { - $dllPath = $dll - } - - if (-not (Test-Path $dllPath)) { - Write-Warning "Not found: $dll" - continue - } - - $dllName = [System.IO.Path]::GetFileName($dllPath) - - # Build arguments - $args = @($dllPath, "/Settings:$runSettings") - if ($Filter) { - $args += "/TestCaseFilter:$Filter" - } - - # Run vstest - $output = & $vsTestPath @args 2>&1 - - # Parse results - $passed = ($output | Select-String "^\s+Passed").Count - $failed = ($output | Select-String "^\s+Failed").Count - $skipped = ($output | Select-String "^\s+Skipped").Count - $exitCode = $LASTEXITCODE - - $totalPassed += $passed - $totalFailed += $failed - $totalSkipped += $skipped - - # Determine status - if ($failed -gt 0) { - $status = "FAIL" - $color = "Red" - } elseif ($passed -eq 0 -and $skipped -eq 0) { - $status = "NONE" - $color = "Yellow" - } else { - $status = "PASS" - $color = "Green" - } - - # Output result - $resultLine = "{0,-40} {1,6} passed, {2,4} failed, {3,4} skipped [{4}]" -f $dllName, $passed, $failed, $skipped, $status - Write-Host $resultLine -ForegroundColor $color - - $results += [PSCustomObject]@{ - DLL = $dllName - Passed = $passed - Failed = $failed - Skipped = $skipped - Status = $status - Output = $output - } + # Resolve full path + if (-not [System.IO.Path]::IsPathRooted($dll)) { + $dllPath = Join-Path $OutputDir $dll + } else { + $dllPath = $dll + } + + if (-not (Test-Path $dllPath)) { + Write-Warning "Not found: $dll" + continue + } + + $dllName = [System.IO.Path]::GetFileName($dllPath) + + # Build arguments + $args = @($dllPath, "/Settings:$runSettings") + if ($Filter) { + $args += "/TestCaseFilter:$Filter" + } + + # Run vstest + $output = & $vsTestPath @args 2>&1 + + # Parse results + $passed = ($output | Select-String "^\s+Passed").Count + $failed = ($output | Select-String "^\s+Failed").Count + $skipped = ($output | Select-String "^\s+Skipped").Count + $exitCode = $LASTEXITCODE + + $totalPassed += $passed + $totalFailed += $failed + $totalSkipped += $skipped + + # Determine status + if ($failed -gt 0) { + $status = "FAIL" + $color = "Red" + } elseif ($passed -eq 0 -and $skipped -eq 0) { + $status = "NONE" + $color = "Yellow" + } else { + $status = "PASS" + $color = "Green" + } + + # Output result + $resultLine = "{0,-40} {1,6} passed, {2,4} failed, {3,4} skipped [{4}]" -f $dllName, $passed, $failed, $skipped, $status + Write-Host $resultLine -ForegroundColor $color + + $results += [PSCustomObject]@{ + DLL = $dllName + Passed = $passed + Failed = $failed + Skipped = $skipped + Status = $status + Output = $output + } } # Summary @@ -196,22 +196,22 @@ Write-Host "" Write-Host ("=" * 70) -ForegroundColor Cyan $summaryLine = "TOTAL: {0} passed, {1} failed, {2} skipped" -f $totalPassed, $totalFailed, $totalSkipped if ($totalFailed -gt 0) { - Write-Host $summaryLine -ForegroundColor Red - $exitCode = 1 + Write-Host $summaryLine -ForegroundColor Red + $exitCode = 1 } else { - Write-Host $summaryLine -ForegroundColor Green - $exitCode = 0 + Write-Host $summaryLine -ForegroundColor Green + $exitCode = 0 } # Show failures if any if ($totalFailed -gt 0) { - Write-Host "" - Write-Host "Failed tests:" -ForegroundColor Red - foreach ($r in $results | Where-Object { $_.Failed -gt 0 }) { - Write-Host "" - Write-Host "=== $($r.DLL) ===" -ForegroundColor Yellow - $r.Output | Select-String "^\s+Failed" -Context 0,5 | ForEach-Object { Write-Host $_ } - } + Write-Host "" + Write-Host "Failed tests:" -ForegroundColor Red + foreach ($r in $results | Where-Object { $_.Failed -gt 0 }) { + Write-Host "" + Write-Host "=== $($r.DLL) ===" -ForegroundColor Yellow + $r.Output | Select-String "^\s+Failed" -Context 0,5 | ForEach-Object { Write-Host $_ } + } } exit $exitCode diff --git a/Build/Agent/Setup-DefenderExclusions.ps1 b/Build/Agent/Setup-DefenderExclusions.ps1 index 9fe2e985b4..7f04dbfbbc 100644 --- a/Build/Agent/Setup-DefenderExclusions.ps1 +++ b/Build/Agent/Setup-DefenderExclusions.ps1 @@ -1,56 +1,56 @@ <# .SYNOPSIS - Configures Windows Defender exclusions for FieldWorks development. + Configures Windows Defender exclusions for FieldWorks development. .DESCRIPTION - This script adds Windows Defender exclusions for paths and processes used - during FieldWorks development. Without these exclusions, real-time scanning - can cause significant slowdowns during builds, NuGet restores, and IDE usage. + This script adds Windows Defender exclusions for paths and processes used + during FieldWorks development. Without these exclusions, real-time scanning + can cause significant slowdowns during builds, NuGet restores, and IDE usage. - MUST BE RUN AS ADMINISTRATOR. + MUST BE RUN AS ADMINISTRATOR. - Exclusions added: - - Repository and worktree paths (source code, build outputs) - - NuGet package caches (global, per-repo, per-agent) - - Build tool processes (MSBuild, cl.exe, dotnet.exe, etc.) - - IDE processes (VS Code, Visual Studio, language servers) - - Docker paths and processes - - Temp folders used during package extraction + Exclusions added: + - Repository and worktree paths (source code, build outputs) + - NuGet package caches (global, per-repo, per-agent) + - Build tool processes (MSBuild, cl.exe, dotnet.exe, etc.) + - IDE processes (VS Code, Visual Studio, language servers) + - Docker paths and processes + - Temp folders used during package extraction .PARAMETER RepoRoot - Path to the FieldWorks repository. Default: auto-detected from script location. + Path to the FieldWorks repository. Default: auto-detected from script location. .PARAMETER DryRun - Show what would be added without making changes. + Show what would be added without making changes. .PARAMETER Remove - Remove the exclusions instead of adding them. + Remove the exclusions instead of adding them. .EXAMPLE - # Run from Admin PowerShell - .\Build\Agent\Setup-DefenderExclusions.ps1 + # Run from Admin PowerShell + .\Build\Agent\Setup-DefenderExclusions.ps1 .EXAMPLE - # Preview changes without applying - .\Build\Agent\Setup-DefenderExclusions.ps1 -DryRun + # Preview changes without applying + .\Build\Agent\Setup-DefenderExclusions.ps1 -DryRun .EXAMPLE - # Remove all FieldWorks exclusions - .\Build\Agent\Setup-DefenderExclusions.ps1 -Remove + # Remove all FieldWorks exclusions + .\Build\Agent\Setup-DefenderExclusions.ps1 -Remove .NOTES - Requires Administrator privileges. - Exclusions take effect immediately - no restart required. + Requires Administrator privileges. + Exclusions take effect immediately - no restart required. - Security note: These exclusions reduce protection for development folders. - This is standard practice for developer workstations but should not be - applied to production or shared systems. + Security note: These exclusions reduce protection for development folders. + This is standard practice for developer workstations but should not be + applied to production or shared systems. #> [CmdletBinding(SupportsShouldProcess)] param( - [string]$RepoRoot, - [switch]$DryRun, - [switch]$Remove + [string]$RepoRoot, + [switch]$DryRun, + [switch]$Remove ) $ErrorActionPreference = 'Stop' @@ -62,17 +62,17 @@ $ErrorActionPreference = 'Stop' $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $isAdmin -and -not $DryRun) { - Write-Host "" - Write-Host "ERROR: This script must be run as Administrator." -ForegroundColor Red - Write-Host "" - Write-Host "Please:" -ForegroundColor Yellow - Write-Host " 1. Right-click PowerShell or Windows Terminal" - Write-Host " 2. Select 'Run as Administrator'" - Write-Host " 3. Navigate to this repo and run the script again" - Write-Host "" - Write-Host "Or use -DryRun to preview changes without Administrator." -ForegroundColor Gray - Write-Host "" - exit 1 + Write-Host "" + Write-Host "ERROR: This script must be run as Administrator." -ForegroundColor Red + Write-Host "" + Write-Host "Please:" -ForegroundColor Yellow + Write-Host " 1. Right-click PowerShell or Windows Terminal" + Write-Host " 2. Select 'Run as Administrator'" + Write-Host " 3. Navigate to this repo and run the script again" + Write-Host "" + Write-Host "Or use -DryRun to preview changes without Administrator." -ForegroundColor Gray + Write-Host "" + exit 1 } # ============================================================================= @@ -80,11 +80,11 @@ if (-not $isAdmin -and -not $DryRun) { # ============================================================================= if (-not $RepoRoot) { - $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) } if (-not (Test-Path (Join-Path $RepoRoot "FieldWorks.sln"))) { - throw "Could not find FieldWorks.sln in '$RepoRoot'. Specify -RepoRoot explicitly." + throw "Could not find FieldWorks.sln in '$RepoRoot'. Specify -RepoRoot explicitly." } $RepoRoot = (Resolve-Path $RepoRoot).Path @@ -103,168 +103,168 @@ Write-Host "" # ============================================================================= $pathExclusions = @( - # ------------------------------------------------------------------------- - # Repository paths - # ------------------------------------------------------------------------- - $RepoRoot, # Main repo (covers Src/, Output/, Obj/, packages/) - (Join-Path $RepoRoot ".nuget"), # Per-agent NuGet caches - (Join-Path $RepoRoot "Output"), # Build outputs - (Join-Path $RepoRoot "Obj"), # Intermediate files - (Join-Path $RepoRoot "packages"), # NuGet packages (host builds) - - # ------------------------------------------------------------------------- - # Worktrees (if using multi-agent setup) - # ------------------------------------------------------------------------- - $WorktreesRoot, # All worktrees - - # ------------------------------------------------------------------------- - # NuGet caches - # ------------------------------------------------------------------------- - (Join-Path $UserProfile ".nuget"), # Global NuGet cache - (Join-Path $UserProfile ".nuget\packages"), # Global packages folder - (Join-Path $UserProfile "AppData\Local\NuGet"), # NuGet local data (http-cache, plugins-cache) - (Join-Path $UserProfile "AppData\Local\NuGet\v3-cache"), # HTTP cache - (Join-Path $UserProfile "AppData\Local\NuGet\plugins-cache"), # Plugins cache - (Join-Path $UserProfile "AppData\Local\Temp\NuGetScratch"), # NuGet temp/scratch - - # ------------------------------------------------------------------------- - # VS Code - # ------------------------------------------------------------------------- - (Join-Path $UserProfile "AppData\Local\Programs\Microsoft VS Code"), # VS Code installation - (Join-Path $UserProfile ".vscode"), # VS Code extensions - (Join-Path $UserProfile ".vscode-server"), # VS Code Server (remote) - (Join-Path $UserProfile "AppData\Roaming\Code"), # VS Code user data - - # ------------------------------------------------------------------------- - # Serena language servers - # ------------------------------------------------------------------------- - (Join-Path $UserProfile ".serena"), # Serena MCP (OmniSharp, clangd) - - # ------------------------------------------------------------------------- - # Visual Studio - # ------------------------------------------------------------------------- - "C:\Program Files\Microsoft Visual Studio", # VS installation - "C:\Program Files (x86)\Microsoft Visual Studio", # VS x86 components - - # ------------------------------------------------------------------------- - # .NET / dotnet - # ------------------------------------------------------------------------- - "C:\Program Files\dotnet", # .NET SDK - - # ------------------------------------------------------------------------- - # FieldWorks app data (runtime data) - # ------------------------------------------------------------------------- - (Join-Path $UserProfile "AppData\Local\SIL"), # FieldWorks user data - - # ------------------------------------------------------------------------- - # Developer tools (Setup-Developer-Machine.ps1 location) - # ------------------------------------------------------------------------- - (Join-Path $UserProfile "AppData\Local\FieldWorksTools"), # WiX, etc. on dev machines - - # ------------------------------------------------------------------------- - # Temp folders (package extraction) - # ------------------------------------------------------------------------- - (Join-Path $UserProfile "AppData\Local\Temp"), # User temp folder - "C:\Windows\Temp", # System temp folder - - # ------------------------------------------------------------------------- - # Symbol cache and debug symbols - # ------------------------------------------------------------------------- - (Join-Path $UserProfile "AppData\Local\Microsoft\VisualStudio"), # VS local data, symbol cache - (Join-Path $UserProfile "AppData\Local\Microsoft\VSCommon"), # VS common data - (Join-Path $UserProfile "AppData\Roaming\Microsoft\VisualStudio"), # VS roaming data - - # ------------------------------------------------------------------------- - # Windows SDK and build tools - # ------------------------------------------------------------------------- - "C:\Program Files (x86)\Windows Kits", # Windows SDK - "C:\Program Files (x86)\Microsoft SDKs", # Microsoft SDKs - "C:\Program Files\Microsoft SDKs" # Microsoft SDKs (x64) + # ------------------------------------------------------------------------- + # Repository paths + # ------------------------------------------------------------------------- + $RepoRoot, # Main repo (covers Src/, Output/, Obj/, packages/) + (Join-Path $RepoRoot ".nuget"), # Per-agent NuGet caches + (Join-Path $RepoRoot "Output"), # Build outputs + (Join-Path $RepoRoot "Obj"), # Intermediate files + (Join-Path $RepoRoot "packages"), # NuGet packages (host builds) + + # ------------------------------------------------------------------------- + # Worktrees (if using multi-agent setup) + # ------------------------------------------------------------------------- + $WorktreesRoot, # All worktrees + + # ------------------------------------------------------------------------- + # NuGet caches + # ------------------------------------------------------------------------- + (Join-Path $UserProfile ".nuget"), # Global NuGet cache + (Join-Path $UserProfile ".nuget\packages"), # Global packages folder + (Join-Path $UserProfile "AppData\Local\NuGet"), # NuGet local data (http-cache, plugins-cache) + (Join-Path $UserProfile "AppData\Local\NuGet\v3-cache"), # HTTP cache + (Join-Path $UserProfile "AppData\Local\NuGet\plugins-cache"), # Plugins cache + (Join-Path $UserProfile "AppData\Local\Temp\NuGetScratch"), # NuGet temp/scratch + + # ------------------------------------------------------------------------- + # VS Code + # ------------------------------------------------------------------------- + (Join-Path $UserProfile "AppData\Local\Programs\Microsoft VS Code"), # VS Code installation + (Join-Path $UserProfile ".vscode"), # VS Code extensions + (Join-Path $UserProfile ".vscode-server"), # VS Code Server (remote) + (Join-Path $UserProfile "AppData\Roaming\Code"), # VS Code user data + + # ------------------------------------------------------------------------- + # Serena language servers + # ------------------------------------------------------------------------- + (Join-Path $UserProfile ".serena"), # Serena MCP (OmniSharp, clangd) + + # ------------------------------------------------------------------------- + # Visual Studio + # ------------------------------------------------------------------------- + "C:\Program Files\Microsoft Visual Studio", # VS installation + "C:\Program Files (x86)\Microsoft Visual Studio", # VS x86 components + + # ------------------------------------------------------------------------- + # .NET / dotnet + # ------------------------------------------------------------------------- + "C:\Program Files\dotnet", # .NET SDK + + # ------------------------------------------------------------------------- + # FieldWorks app data (runtime data) + # ------------------------------------------------------------------------- + (Join-Path $UserProfile "AppData\Local\SIL"), # FieldWorks user data + + # ------------------------------------------------------------------------- + # Developer tools (Setup-Developer-Machine.ps1 location) + # ------------------------------------------------------------------------- + (Join-Path $UserProfile "AppData\Local\FieldWorksTools"), # WiX, etc. on dev machines + + # ------------------------------------------------------------------------- + # Temp folders (package extraction) + # ------------------------------------------------------------------------- + (Join-Path $UserProfile "AppData\Local\Temp"), # User temp folder + "C:\Windows\Temp", # System temp folder + + # ------------------------------------------------------------------------- + # Symbol cache and debug symbols + # ------------------------------------------------------------------------- + (Join-Path $UserProfile "AppData\Local\Microsoft\VisualStudio"), # VS local data, symbol cache + (Join-Path $UserProfile "AppData\Local\Microsoft\VSCommon"), # VS common data + (Join-Path $UserProfile "AppData\Roaming\Microsoft\VisualStudio"), # VS roaming data + + # ------------------------------------------------------------------------- + # Windows SDK and build tools + # ------------------------------------------------------------------------- + "C:\Program Files (x86)\Windows Kits", # Windows SDK + "C:\Program Files (x86)\Microsoft SDKs", # Microsoft SDKs + "C:\Program Files\Microsoft SDKs" # Microsoft SDKs (x64) ) $processExclusions = @( - # ------------------------------------------------------------------------- - # Build tools (C++/C#) - # ------------------------------------------------------------------------- - "MSBuild.exe", - "dotnet.exe", - "cl.exe", # C++ compiler - "link.exe", # Linker - "lib.exe", # Library manager - "ml64.exe", # MASM assembler - "nmake.exe", - "csc.exe", # C# compiler - "csc.dll", - "VBCSCompiler.exe", # Roslyn compiler server - "nuget.exe", - "midl.exe", # IDL compiler (COM interfaces) - - # ------------------------------------------------------------------------- - # WiX Toolset (installer builds) - # ------------------------------------------------------------------------- - "wix.exe", # WiX v6 toolset driver (SDK build) - "heat.exe", # WixToolset.Heat (v6) harvester - - # ------------------------------------------------------------------------- - # Test runners - # ------------------------------------------------------------------------- - "vstest.console.exe", # VS Test runner - "testhost.exe", # .NET test host - "nunit3-console.exe", # NUnit (if used) - - # ------------------------------------------------------------------------- - # VS Code - # ------------------------------------------------------------------------- - "Code.exe", - "cpptools.exe", - "cpptools-srv.exe", - "Microsoft.VisualStudio.Code.ServiceHost.exe", - - # ------------------------------------------------------------------------- - # Language servers - # ------------------------------------------------------------------------- - "OmniSharp.exe", - "clangd.exe", - "Microsoft.CodeAnalysis.LanguageServer.exe", # Roslyn C# server (Serena) - - # ------------------------------------------------------------------------- - # Visual Studio - # ------------------------------------------------------------------------- - "devenv.exe", - "PerfWatson2.exe", - "ServiceHub.Host.CLR.x64.exe", # VS service hub - "ServiceHub.Host.dotnet.x64.exe", # VS dotnet service hub - "ServiceHub.IdentityHost.exe", - "ServiceHub.VSDetouredHost.exe", - "ServiceHub.RoslynCodeAnalysisService.exe", # Roslyn analysis - "ServiceHub.ThreadedWaitDialog.exe", - "Microsoft.ServiceHub.Controller.exe", - - # ------------------------------------------------------------------------- - # Other common dev tools - # ------------------------------------------------------------------------- - "git.exe", - "node.exe", - "npm.exe", - "java.exe", - "javac.exe", - "python.exe", - "python3.exe", - "msedgewebview2.exe", - "powershell.exe", - "pwsh.exe", - "conhost.exe", - "OpenConsole.exe", # Windows Terminal - "WindowsTerminal.exe", - "explorer.exe", - "cmd.exe", - - # ------------------------------------------------------------------------- - # Remote development - # ------------------------------------------------------------------------- - "remoting_host.exe", # Chrome Remote Desktop - "chrome.exe", - "msedge.exe" + # ------------------------------------------------------------------------- + # Build tools (C++/C#) + # ------------------------------------------------------------------------- + "MSBuild.exe", + "dotnet.exe", + "cl.exe", # C++ compiler + "link.exe", # Linker + "lib.exe", # Library manager + "ml64.exe", # MASM assembler + "nmake.exe", + "csc.exe", # C# compiler + "csc.dll", + "VBCSCompiler.exe", # Roslyn compiler server + "nuget.exe", + "midl.exe", # IDL compiler (COM interfaces) + + # ------------------------------------------------------------------------- + # WiX Toolset (installer builds) + # ------------------------------------------------------------------------- + "wix.exe", # WiX v6 toolset driver (SDK build) + "heat.exe", # WixToolset.Heat (v6) harvester + + # ------------------------------------------------------------------------- + # Test runners + # ------------------------------------------------------------------------- + "vstest.console.exe", # VS Test runner + "testhost.exe", # .NET test host + "nunit3-console.exe", # NUnit (if used) + + # ------------------------------------------------------------------------- + # VS Code + # ------------------------------------------------------------------------- + "Code.exe", + "cpptools.exe", + "cpptools-srv.exe", + "Microsoft.VisualStudio.Code.ServiceHost.exe", + + # ------------------------------------------------------------------------- + # Language servers + # ------------------------------------------------------------------------- + "OmniSharp.exe", + "clangd.exe", + "Microsoft.CodeAnalysis.LanguageServer.exe", # Roslyn C# server (Serena) + + # ------------------------------------------------------------------------- + # Visual Studio + # ------------------------------------------------------------------------- + "devenv.exe", + "PerfWatson2.exe", + "ServiceHub.Host.CLR.x64.exe", # VS service hub + "ServiceHub.Host.dotnet.x64.exe", # VS dotnet service hub + "ServiceHub.IdentityHost.exe", + "ServiceHub.VSDetouredHost.exe", + "ServiceHub.RoslynCodeAnalysisService.exe", # Roslyn analysis + "ServiceHub.ThreadedWaitDialog.exe", + "Microsoft.ServiceHub.Controller.exe", + + # ------------------------------------------------------------------------- + # Other common dev tools + # ------------------------------------------------------------------------- + "git.exe", + "node.exe", + "npm.exe", + "java.exe", + "javac.exe", + "python.exe", + "python3.exe", + "msedgewebview2.exe", + "powershell.exe", + "pwsh.exe", + "conhost.exe", + "OpenConsole.exe", # Windows Terminal + "WindowsTerminal.exe", + "explorer.exe", + "cmd.exe", + + # ------------------------------------------------------------------------- + # Remote development + # ------------------------------------------------------------------------- + "remoting_host.exe", # Chrome Remote Desktop + "chrome.exe", + "msedge.exe" ) # ============================================================================= @@ -275,14 +275,14 @@ $currentPathExclusions = @() $currentProcessExclusions = @() if ($isAdmin) { - try { - $prefs = Get-MpPreference -ErrorAction SilentlyContinue - if ($prefs.ExclusionPath) { $currentPathExclusions = $prefs.ExclusionPath } - if ($prefs.ExclusionProcess) { $currentProcessExclusions = $prefs.ExclusionProcess } - } - catch { - Write-Host " (Could not read current exclusions)" -ForegroundColor DarkGray - } + try { + $prefs = Get-MpPreference -ErrorAction SilentlyContinue + if ($prefs.ExclusionPath) { $currentPathExclusions = $prefs.ExclusionPath } + if ($prefs.ExclusionProcess) { $currentProcessExclusions = $prefs.ExclusionProcess } + } + catch { + Write-Host " (Could not read current exclusions)" -ForegroundColor DarkGray + } } # ============================================================================= @@ -296,35 +296,35 @@ Write-Host "$action Path Exclusions:" -ForegroundColor Yellow Write-Host "-" * 50 foreach ($path in $pathExclusions) { - $alreadyExists = $currentPathExclusions -contains $path - - if ($DryRun) { - if ($alreadyExists -and -not $Remove) { - Write-Host " [EXISTS] $path" -ForegroundColor DarkGreen - } - else { - Write-Host " [DryRun] Would $verb`: $path" -ForegroundColor Gray - } - } - else { - try { - if ($Remove) { - Remove-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue - } - else { - Add-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue - } - if ($alreadyExists -and -not $Remove) { - Write-Host " [EXISTS] $path" -ForegroundColor DarkGreen - } - else { - Write-Host " [OK] $path" -ForegroundColor Green - } - } - catch { - Write-Host " [SKIP] $path - $($_.Exception.Message)" -ForegroundColor DarkGray - } - } + $alreadyExists = $currentPathExclusions -contains $path + + if ($DryRun) { + if ($alreadyExists -and -not $Remove) { + Write-Host " [EXISTS] $path" -ForegroundColor DarkGreen + } + else { + Write-Host " [DryRun] Would $verb`: $path" -ForegroundColor Gray + } + } + else { + try { + if ($Remove) { + Remove-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue + } + else { + Add-MpPreference -ExclusionPath $path -ErrorAction SilentlyContinue + } + if ($alreadyExists -and -not $Remove) { + Write-Host " [EXISTS] $path" -ForegroundColor DarkGreen + } + else { + Write-Host " [OK] $path" -ForegroundColor Green + } + } + catch { + Write-Host " [SKIP] $path - $($_.Exception.Message)" -ForegroundColor DarkGray + } + } } Write-Host "" @@ -332,35 +332,35 @@ Write-Host "$action Process Exclusions:" -ForegroundColor Yellow Write-Host "-" * 50 foreach ($proc in $processExclusions) { - $alreadyExists = $currentProcessExclusions -contains $proc - - if ($DryRun) { - if ($alreadyExists -and -not $Remove) { - Write-Host " [EXISTS] $proc" -ForegroundColor DarkGreen - } - else { - Write-Host " [DryRun] Would $verb`: $proc" -ForegroundColor Gray - } - } - else { - try { - if ($Remove) { - Remove-MpPreference -ExclusionProcess $proc -ErrorAction SilentlyContinue - } - else { - Add-MpPreference -ExclusionProcess $proc -ErrorAction SilentlyContinue - } - if ($alreadyExists -and -not $Remove) { - Write-Host " [EXISTS] $proc" -ForegroundColor DarkGreen - } - else { - Write-Host " [OK] $proc" -ForegroundColor Green - } - } - catch { - Write-Host " [SKIP] $proc - $($_.Exception.Message)" -ForegroundColor DarkGray - } - } + $alreadyExists = $currentProcessExclusions -contains $proc + + if ($DryRun) { + if ($alreadyExists -and -not $Remove) { + Write-Host " [EXISTS] $proc" -ForegroundColor DarkGreen + } + else { + Write-Host " [DryRun] Would $verb`: $proc" -ForegroundColor Gray + } + } + else { + try { + if ($Remove) { + Remove-MpPreference -ExclusionProcess $proc -ErrorAction SilentlyContinue + } + else { + Add-MpPreference -ExclusionProcess $proc -ErrorAction SilentlyContinue + } + if ($alreadyExists -and -not $Remove) { + Write-Host " [EXISTS] $proc" -ForegroundColor DarkGreen + } + else { + Write-Host " [OK] $proc" -ForegroundColor Green + } + } + catch { + Write-Host " [SKIP] $proc - $($_.Exception.Message)" -ForegroundColor DarkGray + } + } } # ============================================================================= @@ -371,20 +371,20 @@ Write-Host "" Write-Host "=" * 50 -ForegroundColor Cyan if ($DryRun) { - Write-Host "DRY RUN COMPLETE - No changes were made." -ForegroundColor Yellow - if (-not $isAdmin) { - Write-Host "[EXISTS] markers require Administrator to check current exclusions." -ForegroundColor DarkGray - } - Write-Host "Run without -DryRun (as Admin) to apply exclusions." + Write-Host "DRY RUN COMPLETE - No changes were made." -ForegroundColor Yellow + if (-not $isAdmin) { + Write-Host "[EXISTS] markers require Administrator to check current exclusions." -ForegroundColor DarkGray + } + Write-Host "Run without -DryRun (as Admin) to apply exclusions." } elseif ($Remove) { - Write-Host "EXCLUSIONS REMOVED" -ForegroundColor Green - Write-Host "Windows Defender will now scan these locations." + Write-Host "EXCLUSIONS REMOVED" -ForegroundColor Green + Write-Host "Windows Defender will now scan these locations." } else { - Write-Host "EXCLUSIONS APPLIED" -ForegroundColor Green - Write-Host "[EXISTS] = already configured, [OK] = newly added" - Write-Host "Changes take effect immediately - no restart required." + Write-Host "EXCLUSIONS APPLIED" -ForegroundColor Green + Write-Host "[EXISTS] = already configured, [OK] = newly added" + Write-Host "Changes take effect immediately - no restart required." } Write-Host "" diff --git a/Build/Agent/Setup-FwBuildEnv.ps1 b/Build/Agent/Setup-FwBuildEnv.ps1 index 31d47ff37d..a95fe4c6f5 100644 --- a/Build/Agent/Setup-FwBuildEnv.ps1 +++ b/Build/Agent/Setup-FwBuildEnv.ps1 @@ -1,91 +1,91 @@ <# .SYNOPSIS - Configures the FieldWorks build environment on Windows. + Configures the FieldWorks build environment on Windows. .DESCRIPTION - Sets up environment variables and PATH entries needed for FieldWorks builds. - Can be run locally for testing or called from GitHub Actions workflows. + Sets up environment variables and PATH entries needed for FieldWorks builds. + Can be run locally for testing or called from GitHub Actions workflows. - This script is idempotent - safe to run multiple times. + This script is idempotent - safe to run multiple times. .PARAMETER OutputGitHubEnv - If specified, outputs environment variables to GITHUB_ENV and GITHUB_PATH - for use in GitHub Actions. Otherwise, sets them in the current process. + If specified, outputs environment variables to GITHUB_ENV and GITHUB_PATH + for use in GitHub Actions. Otherwise, sets them in the current process. .PARAMETER Verify - If specified, runs verification checks and exits with non-zero on failure. + If specified, runs verification checks and exits with non-zero on failure. .EXAMPLE - # Local testing - just configure current session - .\Build\Agent\Setup-FwBuildEnv.ps1 + # Local testing - just configure current session + .\Build\Agent\Setup-FwBuildEnv.ps1 .EXAMPLE - # GitHub Actions - output to GITHUB_ENV - .\Build\Agent\Setup-FwBuildEnv.ps1 -OutputGitHubEnv + # GitHub Actions - output to GITHUB_ENV + .\Build\Agent\Setup-FwBuildEnv.ps1 -OutputGitHubEnv .EXAMPLE - # Verify all dependencies are available - .\Build\Agent\Setup-FwBuildEnv.ps1 -Verify + # Verify all dependencies are available + .\Build\Agent\Setup-FwBuildEnv.ps1 -Verify #> [CmdletBinding()] param( - [switch]$OutputGitHubEnv, - [switch]$Verify + [switch]$OutputGitHubEnv, + [switch]$Verify ) $ErrorActionPreference = 'Stop' function Write-Status { - param([string]$Message, [string]$Status = "INFO", [string]$Color = "White") - $prefix = switch ($Status) { - "OK" { "[OK] "; $Color = "Green" } - "FAIL" { "[FAIL] "; $Color = "Red" } - "WARN" { "[WARN] "; $Color = "Yellow" } - "SKIP" { "[SKIP] "; $Color = "DarkGray" } - default { "[INFO] " } - } - Write-Host "$prefix$Message" -ForegroundColor $Color + param([string]$Message, [string]$Status = "INFO", [string]$Color = "White") + $prefix = switch ($Status) { + "OK" { "[OK] "; $Color = "Green" } + "FAIL" { "[FAIL] "; $Color = "Red" } + "WARN" { "[WARN] "; $Color = "Yellow" } + "SKIP" { "[SKIP] "; $Color = "DarkGray" } + default { "[INFO] " } + } + Write-Host "$prefix$Message" -ForegroundColor $Color } function Set-EnvVar { - param([string]$Name, [string]$Value) - - if ($OutputGitHubEnv -and $env:GITHUB_ENV) { - # GitHub Actions format - "$Name=$Value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - Write-Status "Set $Name (GITHUB_ENV)" - } - else { - # Local session - [Environment]::SetEnvironmentVariable($Name, $Value, 'Process') - Write-Status "Set $Name = $Value" - } + param([string]$Name, [string]$Value) + + if ($OutputGitHubEnv -and $env:GITHUB_ENV) { + # GitHub Actions format + "$Name=$Value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + Write-Status "Set $Name (GITHUB_ENV)" + } + else { + # Local session + [Environment]::SetEnvironmentVariable($Name, $Value, 'Process') + Write-Status "Set $Name = $Value" + } } function Add-ToPath { - param([string]$Path) - - if (-not (Test-Path $Path)) { - Write-Status "Path does not exist: $Path" -Status "WARN" - return $false - } - - if ($OutputGitHubEnv -and $env:GITHUB_PATH) { - $Path | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - Write-Status "Added to PATH (GITHUB_PATH): $Path" - } - else { - $currentPath = [Environment]::GetEnvironmentVariable('PATH', 'Process') - if ($currentPath -notlike "*$Path*") { - [Environment]::SetEnvironmentVariable('PATH', "$Path;$currentPath", 'Process') - Write-Status "Added to PATH: $Path" - } - else { - Write-Status "Already in PATH: $Path" -Status "SKIP" - } - } - return $true + param([string]$Path) + + if (-not (Test-Path $Path)) { + Write-Status "Path does not exist: $Path" -Status "WARN" + return $false + } + + if ($OutputGitHubEnv -and $env:GITHUB_PATH) { + $Path | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + Write-Status "Added to PATH (GITHUB_PATH): $Path" + } + else { + $currentPath = [Environment]::GetEnvironmentVariable('PATH', 'Process') + if ($currentPath -notlike "*$Path*") { + [Environment]::SetEnvironmentVariable('PATH', "$Path;$currentPath", 'Process') + Write-Status "Added to PATH: $Path" + } + else { + Write-Status "Already in PATH: $Path" -Status "SKIP" + } + } + return $true } # ============================================================================ @@ -107,9 +107,9 @@ Set-EnvVar -Name "FW_ROOT_CODE_DIR" -Value $distFiles Set-EnvVar -Name "FW_ROOT_DATA_DIR" -Value $distFiles $results = @{ - VSPath = $null - MSBuildPath = $null - Errors = @() + VSPath = $null + MSBuildPath = $null + Errors = @() } # ---------------------------------------------------------------------------- @@ -119,29 +119,29 @@ Write-Host "--- Locating Visual Studio ---" -ForegroundColor Cyan $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" if (Test-Path $vsWhere) { - $vsPath = & $vsWhere -latest -requires Microsoft.Component.MSBuild -products * -property installationPath - if ($vsPath) { - Write-Status "Visual Studio: $vsPath" -Status "OK" - $results.VSPath = $vsPath - - # Set VS environment variables - Set-EnvVar -Name "VSINSTALLDIR" -Value "$vsPath\" - Set-EnvVar -Name "VCINSTALLDIR" -Value "$vsPath\VC\" - - # VCTargetsPath for C++ builds - $vcTargets = Join-Path $vsPath 'MSBuild\Microsoft\VC\v170' - if (Test-Path $vcTargets) { - Set-EnvVar -Name "VCTargetsPath" -Value $vcTargets - } - } - else { - Write-Status "Visual Studio not found via vswhere" -Status "FAIL" - $results.Errors += "Visual Studio not found" - } + $vsPath = & $vsWhere -latest -requires Microsoft.Component.MSBuild -products * -property installationPath + if ($vsPath) { + Write-Status "Visual Studio: $vsPath" -Status "OK" + $results.VSPath = $vsPath + + # Set VS environment variables + Set-EnvVar -Name "VSINSTALLDIR" -Value "$vsPath\" + Set-EnvVar -Name "VCINSTALLDIR" -Value "$vsPath\VC\" + + # VCTargetsPath for C++ builds + $vcTargets = Join-Path $vsPath 'MSBuild\Microsoft\VC\v170' + if (Test-Path $vcTargets) { + Set-EnvVar -Name "VCTargetsPath" -Value $vcTargets + } + } + else { + Write-Status "Visual Studio not found via vswhere" -Status "FAIL" + $results.Errors += "Visual Studio not found" + } } else { - Write-Status "vswhere.exe not found at: $vsWhere" -Status "FAIL" - $results.Errors += "vswhere.exe not found" + Write-Status "vswhere.exe not found at: $vsWhere" -Status "FAIL" + $results.Errors += "vswhere.exe not found" } # ---------------------------------------------------------------------------- @@ -152,8 +152,8 @@ Write-Host "--- Locating MSBuild ---" -ForegroundColor Cyan $msbuildCandidates = @() if ($results.VSPath) { - $msbuildCandidates += Join-Path $results.VSPath 'MSBuild\Current\Bin\MSBuild.exe' - $msbuildCandidates += Join-Path $results.VSPath 'MSBuild\Current\Bin\amd64\MSBuild.exe' + $msbuildCandidates += Join-Path $results.VSPath 'MSBuild\Current\Bin\MSBuild.exe' + $msbuildCandidates += Join-Path $results.VSPath 'MSBuild\Current\Bin\amd64\MSBuild.exe' } $msbuildCandidates += "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Enterprise\MSBuild\Current\Bin\MSBuild.exe" $msbuildCandidates += "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" @@ -161,16 +161,16 @@ $msbuildCandidates += "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Pro $msbuildCandidates += "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" foreach ($candidate in $msbuildCandidates) { - if (Test-Path $candidate) { - $results.MSBuildPath = $candidate - Write-Status "MSBuild: $candidate" -Status "OK" - break - } + if (Test-Path $candidate) { + $results.MSBuildPath = $candidate + Write-Status "MSBuild: $candidate" -Status "OK" + break + } } if (-not $results.MSBuildPath) { - Write-Status "MSBuild not found" -Status "FAIL" - $results.Errors += "MSBuild not found" + Write-Status "MSBuild not found" -Status "FAIL" + $results.Errors += "MSBuild not found" } # ---------------------------------------------------------------------------- @@ -180,21 +180,21 @@ Write-Host "" Write-Host "--- Configuring PATH ---" -ForegroundColor Cyan $netfxPaths = @( - "${env:ProgramFiles(x86)}\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8.1 Tools", - "${env:ProgramFiles(x86)}\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools", - "${env:ProgramFiles(x86)}\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6.1 Tools" + "${env:ProgramFiles(x86)}\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8.1 Tools", + "${env:ProgramFiles(x86)}\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools", + "${env:ProgramFiles(x86)}\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.6.1 Tools" ) $foundNetfx = $false foreach ($p in $netfxPaths) { - if (Test-Path $p) { - Add-ToPath -Path $p | Out-Null - $foundNetfx = $true - break - } + if (Test-Path $p) { + Add-ToPath -Path $p | Out-Null + $foundNetfx = $true + break + } } if (-not $foundNetfx) { - Write-Status "NETFX tools not found (sn.exe may not work)" -Status "WARN" + Write-Status "NETFX tools not found (sn.exe may not work)" -Status "WARN" } # ---------------------------------------------------------------------------- @@ -208,32 +208,32 @@ $vstestCandidates = @() # Check VS installation paths first if ($results.VSPath) { - $vstestCandidates += Join-Path $results.VSPath 'Common7\IDE\CommonExtensions\Microsoft\TestWindow' + $vstestCandidates += Join-Path $results.VSPath 'Common7\IDE\CommonExtensions\Microsoft\TestWindow' } # Add known installation paths (BuildTools, TestAgent, etc.) $vstestCandidates += @( - 'C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow', - "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow", - "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\TestAgent\Common7\IDE\CommonExtensions\Microsoft\TestWindow", - "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow", - "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional\Common7\IDE\CommonExtensions\Microsoft\TestWindow", - "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow" + 'C:\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow', + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\BuildTools\Common7\IDE\CommonExtensions\Microsoft\TestWindow", + "${env:ProgramFiles(x86)}\Microsoft Visual Studio\2022\TestAgent\Common7\IDE\CommonExtensions\Microsoft\TestWindow", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\TestWindow", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Professional\Common7\IDE\CommonExtensions\Microsoft\TestWindow", + "${env:ProgramFiles}\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\TestWindow" ) foreach ($candidate in $vstestCandidates) { - if ($candidate -and (Test-Path (Join-Path $candidate 'vstest.console.exe'))) { - $vstestPath = $candidate - Add-ToPath -Path $vstestPath | Out-Null - break - } + if ($candidate -and (Test-Path (Join-Path $candidate 'vstest.console.exe'))) { + $vstestPath = $candidate + Add-ToPath -Path $vstestPath | Out-Null + break + } } if (-not $vstestPath) { - Write-Status "vstest.console.exe not found" -Status "WARN" + Write-Status "vstest.console.exe not found" -Status "WARN" } else { - Write-Status "VSTest: $vstestPath" -Status "OK" + Write-Status "VSTest: $vstestPath" -Status "OK" } # ---------------------------------------------------------------------------- @@ -244,23 +244,23 @@ Write-Host "=== Setup Complete ===" -ForegroundColor Cyan # Output key paths for GitHub Actions if ($OutputGitHubEnv -and $env:GITHUB_OUTPUT) { - "msbuild-path=$($results.MSBuildPath)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append - "vs-install-path=$($results.VSPath)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "msbuild-path=$($results.MSBuildPath)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + "vs-install-path=$($results.VSPath)" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append } # Return results object for programmatic use if ($results.Errors.Count -gt 0) { - Write-Host "" - Write-Status "Setup completed with errors:" -Status "FAIL" - foreach ($err in $results.Errors) { - Write-Host " - $err" -ForegroundColor Red - } - if ($Verify) { - exit 1 - } + Write-Host "" + Write-Status "Setup completed with errors:" -Status "FAIL" + foreach ($err in $results.Errors) { + Write-Host " - $err" -ForegroundColor Red + } + if ($Verify) { + exit 1 + } } else { - Write-Status "All environment configuration successful" -Status "OK" + Write-Status "All environment configuration successful" -Status "OK" } return $results diff --git a/Build/Agent/Setup-InstallerBuild.ps1 b/Build/Agent/Setup-InstallerBuild.ps1 index bfec87848f..25c70ab010 100644 --- a/Build/Agent/Setup-InstallerBuild.ps1 +++ b/Build/Agent/Setup-InstallerBuild.ps1 @@ -1,47 +1,47 @@ <# .SYNOPSIS - Sets up the environment and validates prerequisites for building FieldWorks installers. + Sets up the environment and validates prerequisites for building FieldWorks installers. .DESCRIPTION - This script prepares a development machine for building FieldWorks base and patch installers. - It validates WiX Toolset installation, clones required helper repositories, downloads base - build artifacts for patch builds, and sets necessary registry keys. + This script prepares a development machine for building FieldWorks base and patch installers. + It validates WiX Toolset installation, clones required helper repositories, downloads base + build artifacts for patch builds, and sets necessary registry keys. .PARAMETER ValidateOnly - Only validate the environment without making changes. + Only validate the environment without making changes. .PARAMETER SetupPatch - Download and extract base build artifacts needed for patch installer builds. + Download and extract base build artifacts needed for patch installer builds. .PARAMETER BaseRelease - GitHub release tag for base build artifacts (default: build-1188). + GitHub release tag for base build artifacts (default: build-1188). .PARAMETER Force - Force re-download of base build artifacts even if they exist. + Force re-download of base build artifacts even if they exist. .EXAMPLE - .\Setup-InstallerBuild.ps1 - # Validates environment and sets up for base installer builds + .\Setup-InstallerBuild.ps1 + # Validates environment and sets up for base installer builds .EXAMPLE - .\Setup-InstallerBuild.ps1 -SetupPatch - # Also downloads base build artifacts for patch installer builds + .\Setup-InstallerBuild.ps1 -SetupPatch + # Also downloads base build artifacts for patch installer builds .EXAMPLE - .\Setup-InstallerBuild.ps1 -ValidateOnly - # Only checks prerequisites without making changes + .\Setup-InstallerBuild.ps1 -ValidateOnly + # Only checks prerequisites without making changes .NOTES - For full developer machine setup, run Setup-Developer-Machine.ps1 first. - Installers are built with WiX Toolset v6 (SDK-style .wixproj) restored via NuGet. + For full developer machine setup, run Setup-Developer-Machine.ps1 first. + Installers are built with WiX Toolset v6 (SDK-style .wixproj) restored via NuGet. #> [CmdletBinding()] param( - [switch]$ValidateOnly, - [switch]$SetupPatch, - [string]$BaseRelease = "build-1188", - [switch]$Force + [switch]$ValidateOnly, + [switch]$SetupPatch, + [string]$BaseRelease = "build-1188", + [switch]$Force ) $ErrorActionPreference = 'Stop' @@ -64,24 +64,24 @@ $installerProject = Join-Path $repoRoot "FLExInstaller\wix6\FieldWorks.Installer $bundleProject = Join-Path $repoRoot "FLExInstaller\wix6\FieldWorks.Bundle.wixproj" if (Test-Path $installerProject) { - Write-Host "[OK] Installer project: $installerProject" -ForegroundColor Green + Write-Host "[OK] Installer project: $installerProject" -ForegroundColor Green } else { - $issues += "Missing installer project: $installerProject" + $issues += "Missing installer project: $installerProject" } if (Test-Path $bundleProject) { - Write-Host "[OK] Bundle project: $bundleProject" -ForegroundColor Green + Write-Host "[OK] Bundle project: $bundleProject" -ForegroundColor Green } else { - $issues += "Missing bundle project: $bundleProject" + $issues += "Missing bundle project: $bundleProject" } Write-Host "[INFO] WiX v6 tools are restored during build (no candle.exe/light.exe required)" -ForegroundColor Gray $heatFromRepoPackages = Join-Path $repoRoot "packages\wixtoolset.heat\6.0.2\tools\net472\x64\heat.exe" if (Test-Path $heatFromRepoPackages) { - Write-Host "[OK] Heat.exe (WixToolset.Heat v6) found: $heatFromRepoPackages" -ForegroundColor Green + Write-Host "[OK] Heat.exe (WixToolset.Heat v6) found: $heatFromRepoPackages" -ForegroundColor Green } else { - Write-Host "[INFO] Heat.exe (WixToolset.Heat) not found yet; it will be restored on first installer build" -ForegroundColor Gray + Write-Host "[INFO] Heat.exe (WixToolset.Heat) not found yet; it will be restored on first installer build" -ForegroundColor Gray } #endregion @@ -95,52 +95,52 @@ $vsInstall = $null $vsDevEnvActive = $false if (Test-Path $vsWhere) { - $vsInstall = & $vsWhere -latest -property installationPath 2>$null - if ($vsInstall) { - $vsVersion = & $vsWhere -latest -property catalog_productDisplayVersion 2>$null - Write-Host "[OK] Visual Studio 2022: $vsVersion" -ForegroundColor Green - - # Check for MSBuild - $msbuildPath = Join-Path $vsInstall "MSBuild\Current\Bin\MSBuild.exe" - if (Test-Path $msbuildPath) { - Write-Host "[OK] MSBuild found: $msbuildPath" -ForegroundColor Green - } else { - $issues += "MSBuild not found in VS installation" - } - - # Check for VsDevCmd - $vsDevCmd = Join-Path $vsInstall "Common7\Tools\VsDevCmd.bat" - $launchVsDevShell = Join-Path $vsInstall "Common7\Tools\Launch-VsDevShell.ps1" - if ((Test-Path $vsDevCmd) -or (Test-Path $launchVsDevShell)) { - Write-Host "[OK] VS Developer environment scripts available" -ForegroundColor Green - } - - # Check if VS Developer environment is active (nmake in PATH) - $nmake = Get-Command nmake.exe -ErrorAction SilentlyContinue - if ($nmake) { - Write-Host "[OK] VS Developer environment active (nmake in PATH)" -ForegroundColor Green - $vsDevEnvActive = $true - } else { - # Check if nmake exists in VS installation - $nmakePath = Join-Path $vsInstall "VC\Tools\MSVC\*\bin\Hostx64\x64\nmake.exe" - $nmakeExists = Get-ChildItem -Path $nmakePath -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($nmakeExists) { - Write-Host "[WARN] VS Developer environment NOT active" -ForegroundColor Yellow - Write-Host " nmake.exe exists but is not in PATH" -ForegroundColor Yellow - Write-Host " Run builds from VS Developer Command Prompt or use:" -ForegroundColor Yellow - Write-Host " cmd /c `"call `"$vsDevCmd`" -arch=amd64 && msbuild ...`"" -ForegroundColor Cyan - $warnings += "VS Developer environment not active (nmake not in PATH)" - } else { - Write-Host "[MISSING] C++ build tools (nmake.exe) not found" -ForegroundColor Red - Write-Host " Install 'Desktop development with C++' workload in VS Installer" -ForegroundColor Red - $issues += "C++ build tools not installed (nmake.exe missing)" - } - } - } else { - $issues += "Visual Studio 2022 not installed" - } + $vsInstall = & $vsWhere -latest -property installationPath 2>$null + if ($vsInstall) { + $vsVersion = & $vsWhere -latest -property catalog_productDisplayVersion 2>$null + Write-Host "[OK] Visual Studio 2022: $vsVersion" -ForegroundColor Green + + # Check for MSBuild + $msbuildPath = Join-Path $vsInstall "MSBuild\Current\Bin\MSBuild.exe" + if (Test-Path $msbuildPath) { + Write-Host "[OK] MSBuild found: $msbuildPath" -ForegroundColor Green + } else { + $issues += "MSBuild not found in VS installation" + } + + # Check for VsDevCmd + $vsDevCmd = Join-Path $vsInstall "Common7\Tools\VsDevCmd.bat" + $launchVsDevShell = Join-Path $vsInstall "Common7\Tools\Launch-VsDevShell.ps1" + if ((Test-Path $vsDevCmd) -or (Test-Path $launchVsDevShell)) { + Write-Host "[OK] VS Developer environment scripts available" -ForegroundColor Green + } + + # Check if VS Developer environment is active (nmake in PATH) + $nmake = Get-Command nmake.exe -ErrorAction SilentlyContinue + if ($nmake) { + Write-Host "[OK] VS Developer environment active (nmake in PATH)" -ForegroundColor Green + $vsDevEnvActive = $true + } else { + # Check if nmake exists in VS installation + $nmakePath = Join-Path $vsInstall "VC\Tools\MSVC\*\bin\Hostx64\x64\nmake.exe" + $nmakeExists = Get-ChildItem -Path $nmakePath -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($nmakeExists) { + Write-Host "[WARN] VS Developer environment NOT active" -ForegroundColor Yellow + Write-Host " nmake.exe exists but is not in PATH" -ForegroundColor Yellow + Write-Host " Run builds from VS Developer Command Prompt or use:" -ForegroundColor Yellow + Write-Host " cmd /c `"call `"$vsDevCmd`" -arch=amd64 && msbuild ...`"" -ForegroundColor Cyan + $warnings += "VS Developer environment not active (nmake not in PATH)" + } else { + Write-Host "[MISSING] C++ build tools (nmake.exe) not found" -ForegroundColor Red + Write-Host " Install 'Desktop development with C++' workload in VS Installer" -ForegroundColor Red + $issues += "C++ build tools not installed (nmake.exe missing)" + } + } + } else { + $issues += "Visual Studio 2022 not installed" + } } else { - $issues += "Visual Studio Installer not found" + $issues += "Visual Studio Installer not found" } #endregion @@ -150,32 +150,32 @@ if (Test-Path $vsWhere) { Write-Host "`n--- Checking Helper Repositories ---" -ForegroundColor Yellow $helperRepos = @( - @{ Name = "FwHelps"; Path = "DistFiles/Helps"; Required = $true }, - @{ Name = "FwLocalizations"; Path = "Localizations"; Required = $true }, - @{ Name = "liblcm"; Path = "Localizations/LCM"; Required = $true } + @{ Name = "FwHelps"; Path = "DistFiles/Helps"; Required = $true }, + @{ Name = "FwLocalizations"; Path = "Localizations"; Required = $true }, + @{ Name = "liblcm"; Path = "Localizations/LCM"; Required = $true } ) $missingRepos = @() foreach ($repo in $helperRepos) { - $fullPath = Join-Path $repoRoot $repo.Path - $gitPath = Join-Path $fullPath ".git" - $isJunction = (Test-Path $fullPath) -and ((Get-Item $fullPath -Force -ErrorAction SilentlyContinue).Attributes -band [IO.FileAttributes]::ReparsePoint) - - if ((Test-Path $gitPath) -or $isJunction) { - $status = if ($isJunction) { "junction" } else { "git repo" } - Write-Host "[OK] $($repo.Name): $($repo.Path) ($status)" -ForegroundColor Green - } else { - Write-Host "[MISSING] $($repo.Name): $($repo.Path)" -ForegroundColor Red - $missingRepos += $repo - if ($repo.Required) { - $issues += "Missing helper repository: $($repo.Name)" - } - } + $fullPath = Join-Path $repoRoot $repo.Path + $gitPath = Join-Path $fullPath ".git" + $isJunction = (Test-Path $fullPath) -and ((Get-Item $fullPath -Force -ErrorAction SilentlyContinue).Attributes -band [IO.FileAttributes]::ReparsePoint) + + if ((Test-Path $gitPath) -or $isJunction) { + $status = if ($isJunction) { "junction" } else { "git repo" } + Write-Host "[OK] $($repo.Name): $($repo.Path) ($status)" -ForegroundColor Green + } else { + Write-Host "[MISSING] $($repo.Name): $($repo.Path)" -ForegroundColor Red + $missingRepos += $repo + if ($repo.Required) { + $issues += "Missing helper repository: $($repo.Name)" + } + } } if ($missingRepos.Count -gt 0 -and -not $ValidateOnly) { - Write-Host "`n[INFO] Missing repositories can be cloned with:" -ForegroundColor Cyan - Write-Host " .\Setup-Developer-Machine.ps1 -InstallerDeps" -ForegroundColor Cyan + Write-Host "`n[INFO] Missing repositories can be cloned with:" -ForegroundColor Cyan + Write-Host " .\Setup-Developer-Machine.ps1 -InstallerDeps" -ForegroundColor Cyan } #endregion @@ -185,48 +185,48 @@ if ($missingRepos.Count -gt 0 -and -not $ValidateOnly) { Write-Host "`n--- Checking WiX Registry Configuration ---" -ForegroundColor Yellow $regPaths = @( - "HKLM:\SOFTWARE\Microsoft\.NETFramework\AppContext", - "HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\AppContext" + "HKLM:\SOFTWARE\Microsoft\.NETFramework\AppContext", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\AppContext" ) $valueName = "Switch.System.DisableTempFileCollectionDirectoryFeature" $regKeySet = $true foreach ($path in $regPaths) { - if (Test-Path $path) { - $value = Get-ItemProperty -Path $path -Name $valueName -ErrorAction SilentlyContinue - if ($value -and $value.$valueName -eq "true") { - Write-Host "[OK] Registry key set: $path" -ForegroundColor Green - } else { - $regKeySet = $false - } - } else { - $regKeySet = $false - } + if (Test-Path $path) { + $value = Get-ItemProperty -Path $path -Name $valueName -ErrorAction SilentlyContinue + if ($value -and $value.$valueName -eq "true") { + Write-Host "[OK] Registry key set: $path" -ForegroundColor Green + } else { + $regKeySet = $false + } + } else { + $regKeySet = $false + } } if (-not $regKeySet) { - if ($ValidateOnly) { - $warnings += "WiX temp file registry key not set (may cause build errors)" - Write-Host "[WARN] WiX temp file registry key not set" -ForegroundColor Yellow - Write-Host " This may cause 'DisableTempFileCollectionDirectoryFeature' errors" -ForegroundColor Yellow - Write-Host " Run this command in an elevated (Admin) PowerShell to fix:" -ForegroundColor Yellow - Write-Host ' $paths = @("HKLM:\SOFTWARE\Microsoft\.NETFramework\AppContext", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\AppContext"); foreach ($path in $paths) { if (-not (Test-Path $path)) { New-Item -Path $path -Force | Out-Null }; New-ItemProperty -Path $path -Name "Switch.System.DisableTempFileCollectionDirectoryFeature" -Value "true" -Type String -Force | Out-Null }; Write-Host "Registry keys set successfully"' -ForegroundColor Cyan - } else { - Write-Host "[INFO] Setting WiX temp file registry key (requires admin)..." -ForegroundColor Cyan - $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) - if ($isAdmin) { - foreach ($path in $regPaths) { - if (-not (Test-Path $path)) { - New-Item -Path $path -Force | Out-Null - } - New-ItemProperty -Path $path -Name $valueName -Value "true" -Type String -Force | Out-Null - } - Write-Host "[OK] Registry keys set successfully" -ForegroundColor Green - } else { - $warnings += "Run as Administrator to set WiX registry keys" - Write-Host "[WARN] Cannot set registry keys without Administrator privileges" -ForegroundColor Yellow - } - } + if ($ValidateOnly) { + $warnings += "WiX temp file registry key not set (may cause build errors)" + Write-Host "[WARN] WiX temp file registry key not set" -ForegroundColor Yellow + Write-Host " This may cause 'DisableTempFileCollectionDirectoryFeature' errors" -ForegroundColor Yellow + Write-Host " Run this command in an elevated (Admin) PowerShell to fix:" -ForegroundColor Yellow + Write-Host ' $paths = @("HKLM:\SOFTWARE\Microsoft\.NETFramework\AppContext", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\AppContext"); foreach ($path in $paths) { if (-not (Test-Path $path)) { New-Item -Path $path -Force | Out-Null }; New-ItemProperty -Path $path -Name "Switch.System.DisableTempFileCollectionDirectoryFeature" -Value "true" -Type String -Force | Out-Null }; Write-Host "Registry keys set successfully"' -ForegroundColor Cyan + } else { + Write-Host "[INFO] Setting WiX temp file registry key (requires admin)..." -ForegroundColor Cyan + $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if ($isAdmin) { + foreach ($path in $regPaths) { + if (-not (Test-Path $path)) { + New-Item -Path $path -Force | Out-Null + } + New-ItemProperty -Path $path -Name $valueName -Value "true" -Type String -Force | Out-Null + } + Write-Host "[OK] Registry keys set successfully" -ForegroundColor Green + } else { + $warnings += "Run as Administrator to set WiX registry keys" + Write-Host "[WARN] Cannot set registry keys without Administrator privileges" -ForegroundColor Yellow + } + } } #endregion @@ -234,73 +234,73 @@ if (-not $regKeySet) { #region Patch Build Setup if ($SetupPatch) { - Write-Host "`n--- Setting Up Patch Build Prerequisites ---" -ForegroundColor Yellow - - $buildDir = Join-Path $repoRoot "BuildDir" - $procRunnerDir = Join-Path $repoRoot "FLExInstaller\Shared\ProcRunner\ProcRunner\bin\Release\net48" - $artifactsDir = Join-Path $repoRoot "base-artifacts" - - # Check if artifacts already exist - $buildDirExists = (Test-Path $buildDir) -and (Test-Path "$buildDir\version") - $procRunnerExists = Test-Path "$procRunnerDir\ProcRunner.exe" - - if ($buildDirExists -and $procRunnerExists -and -not $Force) { - Write-Host "[OK] Base build artifacts already present" -ForegroundColor Green - Write-Host " BuildDir: $buildDir" -ForegroundColor Gray - Write-Host " ProcRunner: $procRunnerDir" -ForegroundColor Gray - } else { - if ($ValidateOnly) { - $warnings += "Base build artifacts not found (needed for patch builds)" - Write-Host "[WARN] Base build artifacts not found" -ForegroundColor Yellow - } else { - Write-Host "[INFO] Downloading base build artifacts from $BaseRelease..." -ForegroundColor Cyan - - # Check for gh CLI - $gh = Get-Command gh -ErrorAction SilentlyContinue - if (-not $gh) { - Write-Host "[ERROR] GitHub CLI (gh) not found. Install from https://cli.github.com/" -ForegroundColor Red - $issues += "GitHub CLI not installed (required for downloading artifacts)" - } else { - # Create temp directory for downloads - if (-not (Test-Path $artifactsDir)) { - New-Item -ItemType Directory -Path $artifactsDir -Force | Out-Null - } - - try { - # Download BuildDir.zip - Write-Host " Downloading BuildDir.zip..." -ForegroundColor Gray - gh release download $BaseRelease --repo sillsdev/FieldWorks --pattern "BuildDir.zip" --dir $artifactsDir --clobber - - # Download ProcRunner.zip - Write-Host " Downloading ProcRunner.zip..." -ForegroundColor Gray - gh release download $BaseRelease --repo sillsdev/FieldWorks --pattern "ProcRunner.zip" --dir $artifactsDir --clobber - - # Extract BuildDir.zip - $buildDirZip = Join-Path $artifactsDir "BuildDir.zip" - if (Test-Path $buildDirZip) { - Write-Host " Extracting BuildDir.zip..." -ForegroundColor Gray - if (Test-Path $buildDir) { Remove-Item $buildDir -Recurse -Force } - Expand-Archive -Path $buildDirZip -DestinationPath $buildDir -Force - Write-Host "[OK] BuildDir extracted to $buildDir" -ForegroundColor Green - } - - # Extract ProcRunner.zip - $procRunnerZip = Join-Path $artifactsDir "ProcRunner.zip" - if (Test-Path $procRunnerZip) { - Write-Host " Extracting ProcRunner.zip..." -ForegroundColor Gray - if (-not (Test-Path $procRunnerDir)) { - New-Item -ItemType Directory -Path $procRunnerDir -Force | Out-Null - } - Expand-Archive -Path $procRunnerZip -DestinationPath $procRunnerDir -Force - Write-Host "[OK] ProcRunner extracted to $procRunnerDir" -ForegroundColor Green - } - } catch { - Write-Host "[ERROR] Failed to download/extract artifacts: $_" -ForegroundColor Red - $issues += "Failed to download base build artifacts" - } - } - } - } + Write-Host "`n--- Setting Up Patch Build Prerequisites ---" -ForegroundColor Yellow + + $buildDir = Join-Path $repoRoot "BuildDir" + $procRunnerDir = Join-Path $repoRoot "FLExInstaller\Shared\ProcRunner\ProcRunner\bin\Release\net48" + $artifactsDir = Join-Path $repoRoot "base-artifacts" + + # Check if artifacts already exist + $buildDirExists = (Test-Path $buildDir) -and (Test-Path "$buildDir\version") + $procRunnerExists = Test-Path "$procRunnerDir\ProcRunner.exe" + + if ($buildDirExists -and $procRunnerExists -and -not $Force) { + Write-Host "[OK] Base build artifacts already present" -ForegroundColor Green + Write-Host " BuildDir: $buildDir" -ForegroundColor Gray + Write-Host " ProcRunner: $procRunnerDir" -ForegroundColor Gray + } else { + if ($ValidateOnly) { + $warnings += "Base build artifacts not found (needed for patch builds)" + Write-Host "[WARN] Base build artifacts not found" -ForegroundColor Yellow + } else { + Write-Host "[INFO] Downloading base build artifacts from $BaseRelease..." -ForegroundColor Cyan + + # Check for gh CLI + $gh = Get-Command gh -ErrorAction SilentlyContinue + if (-not $gh) { + Write-Host "[ERROR] GitHub CLI (gh) not found. Install from https://cli.github.com/" -ForegroundColor Red + $issues += "GitHub CLI not installed (required for downloading artifacts)" + } else { + # Create temp directory for downloads + if (-not (Test-Path $artifactsDir)) { + New-Item -ItemType Directory -Path $artifactsDir -Force | Out-Null + } + + try { + # Download BuildDir.zip + Write-Host " Downloading BuildDir.zip..." -ForegroundColor Gray + gh release download $BaseRelease --repo sillsdev/FieldWorks --pattern "BuildDir.zip" --dir $artifactsDir --clobber + + # Download ProcRunner.zip + Write-Host " Downloading ProcRunner.zip..." -ForegroundColor Gray + gh release download $BaseRelease --repo sillsdev/FieldWorks --pattern "ProcRunner.zip" --dir $artifactsDir --clobber + + # Extract BuildDir.zip + $buildDirZip = Join-Path $artifactsDir "BuildDir.zip" + if (Test-Path $buildDirZip) { + Write-Host " Extracting BuildDir.zip..." -ForegroundColor Gray + if (Test-Path $buildDir) { Remove-Item $buildDir -Recurse -Force } + Expand-Archive -Path $buildDirZip -DestinationPath $buildDir -Force + Write-Host "[OK] BuildDir extracted to $buildDir" -ForegroundColor Green + } + + # Extract ProcRunner.zip + $procRunnerZip = Join-Path $artifactsDir "ProcRunner.zip" + if (Test-Path $procRunnerZip) { + Write-Host " Extracting ProcRunner.zip..." -ForegroundColor Gray + if (-not (Test-Path $procRunnerDir)) { + New-Item -ItemType Directory -Path $procRunnerDir -Force | Out-Null + } + Expand-Archive -Path $procRunnerZip -DestinationPath $procRunnerDir -Force + Write-Host "[OK] ProcRunner extracted to $procRunnerDir" -ForegroundColor Green + } + } catch { + Write-Host "[ERROR] Failed to download/extract artifacts: $_" -ForegroundColor Red + $issues += "Failed to download base build artifacts" + } + } + } + } } #endregion @@ -310,80 +310,80 @@ if ($SetupPatch) { Write-Host "`n========================================" -ForegroundColor Cyan if ($issues.Count -eq 0) { - Write-Host " Environment Ready!" -ForegroundColor Green - Write-Host "========================================" -ForegroundColor Cyan - - if ($warnings.Count -gt 0) { - Write-Host "`nWarnings:" -ForegroundColor Yellow - foreach ($w in $warnings) { - Write-Host " - $w" -ForegroundColor Yellow - } - } - - Write-Host "`nTo build installers:" -ForegroundColor White - - if ($vsDevEnvActive) { - # VS Developer environment is active, show simple commands - Write-Host "" - Write-Host " # Restore packages" -ForegroundColor Gray - Write-Host " msbuild Build/InstallerBuild.proj /t:RestorePackages /p:Configuration=Debug /p:Platform=x64" -ForegroundColor Cyan - Write-Host "" - Write-Host " # Build base installer" -ForegroundColor Gray - Write-Host " msbuild Build/InstallerBuild.proj /t:BuildInstaller /p:Configuration=Debug /p:Platform=x64 /p:config=release /m /v:n" -ForegroundColor Cyan - Write-Host "" - - if ($SetupPatch) { - Write-Host " # Build patch installer" -ForegroundColor Gray - Write-Host " msbuild Build/InstallerBuild.proj /t:BuildPatchInstaller /p:Configuration=Debug /p:Platform=x64 /p:config=release /m /v:n" -ForegroundColor Cyan - Write-Host "" - } - } else { - # Need to wrap commands with VsDevCmd - Write-Host "" - Write-Host " # Option 1: Open VS Developer Command Prompt and run commands there" -ForegroundColor Gray - Write-Host " # Option 2: Use these one-liner commands from any PowerShell:" -ForegroundColor Gray - Write-Host "" - Write-Host " # Restore packages" -ForegroundColor Gray - Write-Host ' cmd /c "call ""C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"" -arch=amd64 >nul && msbuild Build/InstallerBuild.proj /t:RestorePackages /p:Configuration=Debug /p:Platform=x64"' -ForegroundColor Cyan - Write-Host "" - Write-Host " # Build base installer" -ForegroundColor Gray - Write-Host ' cmd /c "call ""C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"" -arch=amd64 >nul && msbuild Build/InstallerBuild.proj /t:BuildInstaller /p:Configuration=Debug /p:Platform=x64 /p:config=release /m /v:n"' -ForegroundColor Cyan - Write-Host "" - - if ($SetupPatch) { - Write-Host " # Build patch installer" -ForegroundColor Gray - Write-Host ' cmd /c "call ""C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"" -arch=amd64 >nul && msbuild Build/InstallerBuild.proj /t:BuildPatchInstaller /p:Configuration=Debug /p:Platform=x64 /p:config=release /m /v:n"' -ForegroundColor Cyan - Write-Host "" - } - } - - if (-not $SetupPatch) { - Write-Host " # For patch builds, run: .\Build\Agent\Setup-InstallerBuild.ps1 -SetupPatch" -ForegroundColor Gray - Write-Host "" - } - - Write-Host "Output location: BuildDir/" -ForegroundColor Gray - exit 0 + Write-Host " Environment Ready!" -ForegroundColor Green + Write-Host "========================================" -ForegroundColor Cyan + + if ($warnings.Count -gt 0) { + Write-Host "`nWarnings:" -ForegroundColor Yellow + foreach ($w in $warnings) { + Write-Host " - $w" -ForegroundColor Yellow + } + } + + Write-Host "`nTo build installers:" -ForegroundColor White + + if ($vsDevEnvActive) { + # VS Developer environment is active, show simple commands + Write-Host "" + Write-Host " # Restore packages" -ForegroundColor Gray + Write-Host " msbuild Build/InstallerBuild.proj /t:RestorePackages /p:Configuration=Debug /p:Platform=x64" -ForegroundColor Cyan + Write-Host "" + Write-Host " # Build base installer" -ForegroundColor Gray + Write-Host " msbuild Build/InstallerBuild.proj /t:BuildInstaller /p:Configuration=Debug /p:Platform=x64 /p:config=release /m /v:n" -ForegroundColor Cyan + Write-Host "" + + if ($SetupPatch) { + Write-Host " # Build patch installer" -ForegroundColor Gray + Write-Host " msbuild Build/InstallerBuild.proj /t:BuildPatchInstaller /p:Configuration=Debug /p:Platform=x64 /p:config=release /m /v:n" -ForegroundColor Cyan + Write-Host "" + } + } else { + # Need to wrap commands with VsDevCmd + Write-Host "" + Write-Host " # Option 1: Open VS Developer Command Prompt and run commands there" -ForegroundColor Gray + Write-Host " # Option 2: Use these one-liner commands from any PowerShell:" -ForegroundColor Gray + Write-Host "" + Write-Host " # Restore packages" -ForegroundColor Gray + Write-Host ' cmd /c "call ""C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"" -arch=amd64 >nul && msbuild Build/InstallerBuild.proj /t:RestorePackages /p:Configuration=Debug /p:Platform=x64"' -ForegroundColor Cyan + Write-Host "" + Write-Host " # Build base installer" -ForegroundColor Gray + Write-Host ' cmd /c "call ""C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"" -arch=amd64 >nul && msbuild Build/InstallerBuild.proj /t:BuildInstaller /p:Configuration=Debug /p:Platform=x64 /p:config=release /m /v:n"' -ForegroundColor Cyan + Write-Host "" + + if ($SetupPatch) { + Write-Host " # Build patch installer" -ForegroundColor Gray + Write-Host ' cmd /c "call ""C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\VsDevCmd.bat"" -arch=amd64 >nul && msbuild Build/InstallerBuild.proj /t:BuildPatchInstaller /p:Configuration=Debug /p:Platform=x64 /p:config=release /m /v:n"' -ForegroundColor Cyan + Write-Host "" + } + } + + if (-not $SetupPatch) { + Write-Host " # For patch builds, run: .\Build\Agent\Setup-InstallerBuild.ps1 -SetupPatch" -ForegroundColor Gray + Write-Host "" + } + + Write-Host "Output location: BuildDir/" -ForegroundColor Gray + exit 0 } else { - Write-Host " Setup Incomplete" -ForegroundColor Red - Write-Host "========================================" -ForegroundColor Cyan - - Write-Host "`nIssues found:" -ForegroundColor Red - foreach ($issue in $issues) { - Write-Host " - $issue" -ForegroundColor Red - } - - if ($warnings.Count -gt 0) { - Write-Host "`nWarnings:" -ForegroundColor Yellow - foreach ($w in $warnings) { - Write-Host " - $w" -ForegroundColor Yellow - } - } - - Write-Host "`nTo fix:" -ForegroundColor White - Write-Host " 1. Run .\Setup-Developer-Machine.ps1 -InstallerDeps" -ForegroundColor Cyan - Write-Host " 2. Re-run this script" -ForegroundColor Cyan - exit 1 + Write-Host " Setup Incomplete" -ForegroundColor Red + Write-Host "========================================" -ForegroundColor Cyan + + Write-Host "`nIssues found:" -ForegroundColor Red + foreach ($issue in $issues) { + Write-Host " - $issue" -ForegroundColor Red + } + + if ($warnings.Count -gt 0) { + Write-Host "`nWarnings:" -ForegroundColor Yellow + foreach ($w in $warnings) { + Write-Host " - $w" -ForegroundColor Yellow + } + } + + Write-Host "`nTo fix:" -ForegroundColor White + Write-Host " 1. Run .\Setup-Developer-Machine.ps1 -InstallerDeps" -ForegroundColor Cyan + Write-Host " 2. Re-run this script" -ForegroundColor Cyan + exit 1 } #endregion diff --git a/Build/Agent/Setup-Serena.ps1 b/Build/Agent/Setup-Serena.ps1 index b0cbd81dbf..3fa4054c7d 100644 --- a/Build/Agent/Setup-Serena.ps1 +++ b/Build/Agent/Setup-Serena.ps1 @@ -1,73 +1,73 @@ <# .SYNOPSIS - Sets up and verifies Serena MCP for FieldWorks development. + Sets up and verifies Serena MCP for FieldWorks development. .DESCRIPTION - Ensures the Serena Model Context Protocol server is properly configured - for FieldWorks. This enables AI-assisted code navigation and analysis - for both C# (via OmniSharp) and C++ (via clangd). + Ensures the Serena Model Context Protocol server is properly configured + for FieldWorks. This enables AI-assisted code navigation and analysis + for both C# (via OmniSharp) and C++ (via clangd). - Steps performed: - 1. Verifies uv/uvx is installed - 2. Verifies Serena project configuration exists - 3. Initializes Serena and triggers language server downloads if needed - 4. Validates that language servers respond to basic queries + Steps performed: + 1. Verifies uv/uvx is installed + 2. Verifies Serena project configuration exists + 3. Initializes Serena and triggers language server downloads if needed + 4. Validates that language servers respond to basic queries .PARAMETER SkipLanguageServerCheck - Skip the language server connectivity check (useful for CI where we just - want to ensure config exists). + Skip the language server connectivity check (useful for CI where we just + want to ensure config exists). .PARAMETER CacheDir - Directory for Serena language server cache. Defaults to ~/.cache/serena. + Directory for Serena language server cache. Defaults to ~/.cache/serena. .PARAMETER OutputGitHubEnv - If specified, writes environment variables to $GITHUB_ENV for Actions. + If specified, writes environment variables to $GITHUB_ENV for Actions. .EXAMPLE - # Quick setup/check - .\Build\Agent\Setup-Serena.ps1 + # Quick setup/check + .\Build\Agent\Setup-Serena.ps1 .EXAMPLE - # Skip slow language server check - .\Build\Agent\Setup-Serena.ps1 -SkipLanguageServerCheck + # Skip slow language server check + .\Build\Agent\Setup-Serena.ps1 -SkipLanguageServerCheck .EXAMPLE - # CI mode with GitHub Actions output - .\Build\Agent\Setup-Serena.ps1 -OutputGitHubEnv + # CI mode with GitHub Actions output + .\Build\Agent\Setup-Serena.ps1 -OutputGitHubEnv #> [CmdletBinding()] param( - [switch]$SkipLanguageServerCheck, - [string]$CacheDir = "$env:USERPROFILE\.cache\serena", - [switch]$OutputGitHubEnv + [switch]$SkipLanguageServerCheck, + [string]$CacheDir = "$env:USERPROFILE\.cache\serena", + [switch]$OutputGitHubEnv ) $ErrorActionPreference = 'Stop' function Write-Status { - param([string]$Message, [string]$Status = "INFO") - $color = switch ($Status) { - "OK" { "Green" } - "WARN" { "Yellow" } - "ERROR" { "Red" } - default { "Cyan" } - } - $prefix = switch ($Status) { - "OK" { "[OK] " } - "WARN" { "[WARN] " } - "ERROR" { "[FAIL] " } - default { " " } - } - Write-Host "$prefix$Message" -ForegroundColor $color + param([string]$Message, [string]$Status = "INFO") + $color = switch ($Status) { + "OK" { "Green" } + "WARN" { "Yellow" } + "ERROR" { "Red" } + default { "Cyan" } + } + $prefix = switch ($Status) { + "OK" { "[OK] " } + "WARN" { "[WARN] " } + "ERROR" { "[FAIL] " } + default { " " } + } + Write-Host "$prefix$Message" -ForegroundColor $color } function Set-EnvVar { - param([string]$Name, [string]$Value) - [Environment]::SetEnvironmentVariable($Name, $Value, 'Process') - if ($OutputGitHubEnv -and $env:GITHUB_ENV) { - Add-Content -Path $env:GITHUB_ENV -Value "$Name=$Value" - } + param([string]$Name, [string]$Value) + [Environment]::SetEnvironmentVariable($Name, $Value, 'Process') + if ($OutputGitHubEnv -and $env:GITHUB_ENV) { + Add-Content -Path $env:GITHUB_ENV -Value "$Name=$Value" + } } # ============================================================================ @@ -88,42 +88,42 @@ Write-Host "--- Step 1: Python Package Manager ---" -ForegroundColor Cyan $uvInstalled = $false $uv = Get-Command uv -ErrorAction SilentlyContinue if ($uv) { - $uvVersion = (& uv --version 2>&1) - Write-Status "uv installed: $uvVersion" -Status "OK" - $uvInstalled = $true + $uvVersion = (& uv --version 2>&1) + Write-Status "uv installed: $uvVersion" -Status "OK" + $uvInstalled = $true } else { - Write-Status "uv not found - attempting install via pip" -Status "WARN" - try { - $python = Get-Command python -ErrorAction SilentlyContinue - if (-not $python) { $python = Get-Command python3 -ErrorAction SilentlyContinue } - if ($python) { - & $python.Source -m pip install --quiet uv - $uv = Get-Command uv -ErrorAction SilentlyContinue - if ($uv) { - Write-Status "uv installed successfully via pip" -Status "OK" - $uvInstalled = $true - } - } - } - catch { - Write-Status "Failed to install uv: $_" -Status "ERROR" - } + Write-Status "uv not found - attempting install via pip" -Status "WARN" + try { + $python = Get-Command python -ErrorAction SilentlyContinue + if (-not $python) { $python = Get-Command python3 -ErrorAction SilentlyContinue } + if ($python) { + & $python.Source -m pip install --quiet uv + $uv = Get-Command uv -ErrorAction SilentlyContinue + if ($uv) { + Write-Status "uv installed successfully via pip" -Status "OK" + $uvInstalled = $true + } + } + } + catch { + Write-Status "Failed to install uv: $_" -Status "ERROR" + } } if (-not $uvInstalled) { - Write-Status "Cannot proceed without uv. Install with: winget install astral-sh.uv" -Status "ERROR" - exit 1 + Write-Status "Cannot proceed without uv. Install with: winget install astral-sh.uv" -Status "ERROR" + exit 1 } # Verify uvx is available $uvx = Get-Command uvx -ErrorAction SilentlyContinue if ($uvx) { - Write-Status "uvx available" -Status "OK" + Write-Status "uvx available" -Status "OK" } else { - # uvx should be bundled with uv - Write-Status "uvx not found (should be bundled with uv)" -Status "WARN" + # uvx should be bundled with uv + Write-Status "uvx not found (should be bundled with uv)" -Status "WARN" } # ---------------------------------------------------------------------------- @@ -133,25 +133,25 @@ Write-Host "" Write-Host "--- Step 2: Serena Configuration ---" -ForegroundColor Cyan if (Test-Path $serenaConfig) { - Write-Status "Found .serena/project.yml" -Status "OK" - - # Show configured languages - $configContent = Get-Content $serenaConfig -Raw - if ($configContent -match 'programming_languages:\s*\[([^\]]+)\]') { - $languages = $matches[1] - Write-Status "Configured languages: $languages" - } + Write-Status "Found .serena/project.yml" -Status "OK" + + # Show configured languages + $configContent = Get-Content $serenaConfig -Raw + if ($configContent -match 'programming_languages:\s*\[([^\]]+)\]') { + $languages = $matches[1] + Write-Status "Configured languages: $languages" + } } else { - Write-Status "Missing .serena/project.yml - Serena not configured" -Status "ERROR" - Write-Host "" - Write-Host "Create .serena/project.yml with:" -ForegroundColor Yellow - Write-Host @" + Write-Status "Missing .serena/project.yml - Serena not configured" -Status "ERROR" + Write-Host "" + Write-Host "Create .serena/project.yml with:" -ForegroundColor Yellow + Write-Host @" name: FieldWorks project_root: . programming_languages: [csharp_omnisharp, cpp] "@ - exit 1 + exit 1 } # ---------------------------------------------------------------------------- @@ -161,13 +161,13 @@ Write-Host "" Write-Host "--- Step 3: Cache Directory ---" -ForegroundColor Cyan if (-not (Test-Path $CacheDir)) { - New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null - Write-Status "Created cache directory: $CacheDir" -Status "OK" + New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null + Write-Status "Created cache directory: $CacheDir" -Status "OK" } else { - $cacheSize = (Get-ChildItem $CacheDir -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum - $cacheSizeMB = [math]::Round($cacheSize / 1MB, 2) - Write-Status "Cache directory exists ($cacheSizeMB MB): $CacheDir" -Status "OK" + $cacheSize = (Get-ChildItem $CacheDir -Recurse -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum + $cacheSizeMB = [math]::Round($cacheSize / 1MB, 2) + Write-Status "Cache directory exists ($cacheSizeMB MB): $CacheDir" -Status "OK" } # Set environment variable for Serena cache @@ -177,21 +177,21 @@ Set-EnvVar -Name "SERENA_CACHE_DIR" -Value $CacheDir # Step 4: Check language servers (optional) # ---------------------------------------------------------------------------- if (-not $SkipLanguageServerCheck) { - Write-Host "" - Write-Host "--- Step 4: Language Server Check ---" -ForegroundColor Cyan - - # Check for clangd (C++) - $clangd = Get-Command clangd -ErrorAction SilentlyContinue - if ($clangd) { - $clangdVersion = (& clangd --version 2>&1 | Select-Object -First 1) - Write-Status "clangd: $clangdVersion" -Status "OK" - } - else { - Write-Status "clangd not in PATH - Serena will download on first use" -Status "WARN" - } - - # OmniSharp is downloaded by Serena automatically - Write-Status "OmniSharp: Will be downloaded by Serena on first use" + Write-Host "" + Write-Host "--- Step 4: Language Server Check ---" -ForegroundColor Cyan + + # Check for clangd (C++) + $clangd = Get-Command clangd -ErrorAction SilentlyContinue + if ($clangd) { + $clangdVersion = (& clangd --version 2>&1 | Select-Object -First 1) + Write-Status "clangd: $clangdVersion" -Status "OK" + } + else { + Write-Status "clangd not in PATH - Serena will download on first use" -Status "WARN" + } + + # OmniSharp is downloaded by Serena automatically + Write-Status "OmniSharp: Will be downloaded by Serena on first use" } # ---------------------------------------------------------------------------- diff --git a/Build/Agent/Summarize-NativeTestResults.ps1 b/Build/Agent/Summarize-NativeTestResults.ps1 index 9e1600d6a0..62fbe73c6a 100644 --- a/Build/Agent/Summarize-NativeTestResults.ps1 +++ b/Build/Agent/Summarize-NativeTestResults.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param( - [string]$Configuration = 'Debug', - [string]$StepSummaryPath = $env:GITHUB_STEP_SUMMARY + [string]$Configuration = 'Debug', + [string]$StepSummaryPath = $env:GITHUB_STEP_SUMMARY ) Set-StrictMode -Version Latest @@ -9,51 +9,51 @@ $ErrorActionPreference = 'Stop' $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $nativeLogs = @( - (Join-Path $repoRoot "Output/$Configuration/testGenericLib.exe.log"), - (Join-Path $repoRoot "Output/$Configuration/TestViews.exe.log") + (Join-Path $repoRoot "Output/$Configuration/testGenericLib.exe.log"), + (Join-Path $repoRoot "Output/$Configuration/TestViews.exe.log") ) $tableRows = @() foreach ($logPath in $nativeLogs) { - $displayPath = Resolve-Path -LiteralPath $logPath -ErrorAction SilentlyContinue - if (-not $displayPath) { - Write-Host "::warning title=Native log missing::$logPath not found" - $tableRows += "| $logPath | - | - | - | MISSING |" - continue - } - - $summaryLine = Get-Content -Path $logPath -ErrorAction SilentlyContinue | - Select-String -Pattern 'Tests \[Ok-Fail-Error\]: \[(\d+)-(\d+)-(\d+)\]' | - Select-Object -Last 1 - - if (-not $summaryLine) { - Write-Host "::warning title=Native summary missing::$logPath does not contain Unit++ summary line" - $tableRows += "| $logPath | - | - | - | UNKNOWN |" - continue - } - - $match = [regex]::Match($summaryLine.Line, 'Tests \[Ok-Fail-Error\]: \[(\d+)-(\d+)-(\d+)\]') - $okCount = [int]$match.Groups[1].Value - $failCount = [int]$match.Groups[2].Value - $errorCount = [int]$match.Groups[3].Value - $status = if ($failCount -gt 0 -or $errorCount -gt 0) { 'FAIL' } else { 'PASS' } - - if ($status -eq 'FAIL') { - Write-Host "::error title=Native test failure::$logPath => Ok=$okCount Fail=$failCount Error=$errorCount" - } - else { - Write-Host "::notice title=Native test pass::$logPath => Ok=$okCount Fail=$failCount Error=$errorCount" - } - - $tableRows += "| $logPath | $okCount | $failCount | $errorCount | $status |" + $displayPath = Resolve-Path -LiteralPath $logPath -ErrorAction SilentlyContinue + if (-not $displayPath) { + Write-Host "::warning title=Native log missing::$logPath not found" + $tableRows += "| $logPath | - | - | - | MISSING |" + continue + } + + $summaryLine = Get-Content -Path $logPath -ErrorAction SilentlyContinue | + Select-String -Pattern 'Tests \[Ok-Fail-Error\]: \[(\d+)-(\d+)-(\d+)\]' | + Select-Object -Last 1 + + if (-not $summaryLine) { + Write-Host "::warning title=Native summary missing::$logPath does not contain Unit++ summary line" + $tableRows += "| $logPath | - | - | - | UNKNOWN |" + continue + } + + $match = [regex]::Match($summaryLine.Line, 'Tests \[Ok-Fail-Error\]: \[(\d+)-(\d+)-(\d+)\]') + $okCount = [int]$match.Groups[1].Value + $failCount = [int]$match.Groups[2].Value + $errorCount = [int]$match.Groups[3].Value + $status = if ($failCount -gt 0 -or $errorCount -gt 0) { 'FAIL' } else { 'PASS' } + + if ($status -eq 'FAIL') { + Write-Host "::error title=Native test failure::$logPath => Ok=$okCount Fail=$failCount Error=$errorCount" + } + else { + Write-Host "::notice title=Native test pass::$logPath => Ok=$okCount Fail=$failCount Error=$errorCount" + } + + $tableRows += "| $logPath | $okCount | $failCount | $errorCount | $status |" } if (-not [string]::IsNullOrWhiteSpace($StepSummaryPath)) { - Add-Content -Path $StepSummaryPath -Value '### Native test summary' - Add-Content -Path $StepSummaryPath -Value '' - Add-Content -Path $StepSummaryPath -Value '| Log | Ok | Fail | Error | Status |' - Add-Content -Path $StepSummaryPath -Value '|---|---:|---:|---:|---|' - foreach ($row in $tableRows) { - Add-Content -Path $StepSummaryPath -Value $row - } + Add-Content -Path $StepSummaryPath -Value '### Native test summary' + Add-Content -Path $StepSummaryPath -Value '' + Add-Content -Path $StepSummaryPath -Value '| Log | Ok | Fail | Error | Status |' + Add-Content -Path $StepSummaryPath -Value '|---|---:|---:|---:|---|' + foreach ($row in $tableRows) { + Add-Content -Path $StepSummaryPath -Value $row + } } diff --git a/Build/Agent/Verify-FwDependencies.ps1 b/Build/Agent/Verify-FwDependencies.ps1 index 5d1195dac5..0aef01efef 100644 --- a/Build/Agent/Verify-FwDependencies.ps1 +++ b/Build/Agent/Verify-FwDependencies.ps1 @@ -1,118 +1,118 @@ <# .SYNOPSIS - Verifies that all FieldWorks build dependencies are available. + Verifies that all FieldWorks build dependencies are available. .DESCRIPTION - Checks for required tools and SDKs needed to build FieldWorks. - Can be run locally for testing or called from GitHub Actions workflows. + Checks for required tools and SDKs needed to build FieldWorks. + Can be run locally for testing or called from GitHub Actions workflows. - Expected dependencies (typically pre-installed on windows-latest): - - Visual Studio 2022 with Desktop & C++ workloads - - MSBuild - - .NET Framework 4.8.1 SDK & Targeting Pack - - Windows SDK - - WiX Toolset v6 (installer builds restore via NuGet) - - .NET SDK 8.x+ + Expected dependencies (typically pre-installed on windows-latest): + - Visual Studio 2022 with Desktop & C++ workloads + - MSBuild + - .NET Framework 4.8.1 SDK & Targeting Pack + - Windows SDK + - WiX Toolset v6 (installer builds restore via NuGet) + - .NET SDK 8.x+ .PARAMETER FailOnMissing - If specified, exits with non-zero code if any required dependency is missing. + If specified, exits with non-zero code if any required dependency is missing. .PARAMETER IncludeOptional - If specified, also checks optional dependencies like clangd for Serena. + If specified, also checks optional dependencies like clangd for Serena. .PARAMETER Detailed - If specified, prints the full per-dependency section headers and success details instead of the compact summary-only output. + If specified, prints the full per-dependency section headers and success details instead of the compact summary-only output. .PARAMETER PassThru - If specified, returns the dependency result objects for scripting callers instead of writing them implicitly. + If specified, returns the dependency result objects for scripting callers instead of writing them implicitly. .EXAMPLE - # Quick check - .\Build\Agent\Verify-FwDependencies.ps1 + # Quick check + .\Build\Agent\Verify-FwDependencies.ps1 .EXAMPLE - # Strict check for CI - .\Build\Agent\Verify-FwDependencies.ps1 -FailOnMissing + # Strict check for CI + .\Build\Agent\Verify-FwDependencies.ps1 -FailOnMissing .EXAMPLE - # Include Serena dependencies - .\Build\Agent\Verify-FwDependencies.ps1 -IncludeOptional + # Include Serena dependencies + .\Build\Agent\Verify-FwDependencies.ps1 -IncludeOptional .EXAMPLE - # Show full dependency-by-dependency output - .\Build\Agent\Verify-FwDependencies.ps1 -IncludeOptional -Detailed + # Show full dependency-by-dependency output + .\Build\Agent\Verify-FwDependencies.ps1 -IncludeOptional -Detailed .EXAMPLE - # Capture structured results for automation - $results = .\Build\Agent\Verify-FwDependencies.ps1 -IncludeOptional -PassThru + # Capture structured results for automation + $results = .\Build\Agent\Verify-FwDependencies.ps1 -IncludeOptional -PassThru #> [CmdletBinding()] param( - [switch]$FailOnMissing, - [switch]$IncludeOptional, - [switch]$Detailed, - [switch]$PassThru + [switch]$FailOnMissing, + [switch]$IncludeOptional, + [switch]$Detailed, + [switch]$PassThru ) $ErrorActionPreference = 'Stop' function Test-Dependency { - param( - [string]$Name, - [scriptblock]$Check, - [string]$Required = "Required" - ) - - try { - $result = & $Check - if ($result) { - if ($Detailed) { - Write-Host "[OK] $Name" -ForegroundColor Green - if ($result -is [string] -and $result.Length -gt 0 -and $result.Length -lt 100) { - Write-Host " $result" -ForegroundColor DarkGray - } - } - return @{ Name = $Name; Found = $true; IsRequired = ($Required -eq "Required"); Info = $result } - } - else { - throw "Check returned null/false" - } - } - catch { - $color = if ($Required -eq "Required") { "Red" } else { "Yellow" } - $status = if ($Required -eq "Required") { "[FAIL]" } else { "[WARN]" } - Write-Host "$status $Name" -ForegroundColor $color - Write-Host " $_" -ForegroundColor DarkGray - return @{ Name = $Name; Found = $false; IsRequired = ($Required -eq "Required"); Error = $_.ToString() } - } + param( + [string]$Name, + [scriptblock]$Check, + [string]$Required = "Required" + ) + + try { + $result = & $Check + if ($result) { + if ($Detailed) { + Write-Host "[OK] $Name" -ForegroundColor Green + if ($result -is [string] -and $result.Length -gt 0 -and $result.Length -lt 100) { + Write-Host " $result" -ForegroundColor DarkGray + } + } + return @{ Name = $Name; Found = $true; IsRequired = ($Required -eq "Required"); Info = $result } + } + else { + throw "Check returned null/false" + } + } + catch { + $color = if ($Required -eq "Required") { "Red" } else { "Yellow" } + $status = if ($Required -eq "Required") { "[FAIL]" } else { "[WARN]" } + Write-Host "$status $Name" -ForegroundColor $color + Write-Host " $_" -ForegroundColor DarkGray + return @{ Name = $Name; Found = $false; IsRequired = ($Required -eq "Required"); Error = $_.ToString() } + } } function Find-DotNetFrameworkSdkTool { - param([Parameter(Mandatory)][string]$ToolName) + param([Parameter(Mandatory)][string]$ToolName) - $programFilesX86 = ${env:ProgramFiles(x86)} - if (-not $programFilesX86) { return $null } + $programFilesX86 = ${env:ProgramFiles(x86)} + if (-not $programFilesX86) { return $null } - $sdkBase = Join-Path $programFilesX86 'Microsoft SDKs\Windows\v10.0A\bin' - if (-not (Test-Path $sdkBase)) { return $null } + $sdkBase = Join-Path $programFilesX86 'Microsoft SDKs\Windows\v10.0A\bin' + if (-not (Test-Path $sdkBase)) { return $null } - $toolCandidates = @() - $netfxDirs = Get-ChildItem -Path $sdkBase -Directory -Filter 'NETFX*' -ErrorAction SilentlyContinue | - Sort-Object Name -Descending + $toolCandidates = @() + $netfxDirs = Get-ChildItem -Path $sdkBase -Directory -Filter 'NETFX*' -ErrorAction SilentlyContinue | + Sort-Object Name -Descending - foreach ($dir in $netfxDirs) { - $toolCandidates += (Join-Path $dir.FullName (Join-Path 'x64' $ToolName)) - $toolCandidates += (Join-Path $dir.FullName $ToolName) - } + foreach ($dir in $netfxDirs) { + $toolCandidates += (Join-Path $dir.FullName (Join-Path 'x64' $ToolName)) + $toolCandidates += (Join-Path $dir.FullName $ToolName) + } - foreach ($candidate in $toolCandidates) { - if (Test-Path $candidate) { - return $candidate - } - } + foreach ($candidate in $toolCandidates) { + if (Test-Path $candidate) { + return $candidate + } + } - return $null + return $null } # ============================================================================ @@ -120,8 +120,8 @@ function Find-DotNetFrameworkSdkTool { # ============================================================================ if ($Detailed) { - Write-Host "=== FieldWorks Dependency Verification ===" -ForegroundColor Cyan - Write-Host "" + Write-Host "=== FieldWorks Dependency Verification ===" -ForegroundColor Cyan + Write-Host "" } $results = @() @@ -130,171 +130,171 @@ $results = @() # Required Dependencies # ---------------------------------------------------------------------------- if ($Detailed) { - Write-Host "--- Required Dependencies ---" -ForegroundColor Cyan + Write-Host "--- Required Dependencies ---" -ForegroundColor Cyan } # .NET Framework targeting pack (4.8+) $results += Test-Dependency -Name ".NET Framework Targeting Pack (4.8+)" -Check { - $base = "${env:ProgramFiles(x86)}\Reference Assemblies\Microsoft\Framework\.NETFramework" - $candidates = @('v4.8.1', 'v4.8') - foreach ($version in $candidates) { - $path = Join-Path $base $version - if (Test-Path $path) { - return "$version at $path" - } - } - throw "Neither v4.8.1 nor v4.8 targeting pack was found under $base" + $base = "${env:ProgramFiles(x86)}\Reference Assemblies\Microsoft\Framework\.NETFramework" + $candidates = @('v4.8.1', 'v4.8') + foreach ($version in $candidates) { + $path = Join-Path $base $version + if (Test-Path $path) { + return "$version at $path" + } + } + throw "Neither v4.8.1 nor v4.8 targeting pack was found under $base" } # Windows SDK $results += Test-Dependency -Name "Windows SDK" -Check { - $path = "${env:ProgramFiles(x86)}\Windows Kits\10\Include" - if (Test-Path $path) { - $versions = (Get-ChildItem $path -Directory | Sort-Object Name -Descending | Select-Object -First 3).Name -join ', ' - return "Versions: $versions" - } - throw "Not found at $path" + $path = "${env:ProgramFiles(x86)}\Windows Kits\10\Include" + if (Test-Path $path) { + $versions = (Get-ChildItem $path -Directory | Sort-Object Name -Descending | Select-Object -First 3).Name -join ', ' + return "Versions: $versions" + } + throw "Not found at $path" } # Visual Studio / MSBuild $results += Test-Dependency -Name "Visual Studio 2022" -Check { - $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - if (-not (Test-Path $vsWhere)) { throw "vswhere.exe not found" } - $vsPath = & $vsWhere -latest -requires Microsoft.Component.MSBuild -products * -property installationPath - if (-not $vsPath) { throw "No VS installation with MSBuild found" } - $version = & $vsWhere -latest -property catalog_productDisplayVersion - return "Version $version at $vsPath" + $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vsWhere)) { throw "vswhere.exe not found" } + $vsPath = & $vsWhere -latest -requires Microsoft.Component.MSBuild -products * -property installationPath + if (-not $vsPath) { throw "No VS installation with MSBuild found" } + $version = & $vsWhere -latest -property catalog_productDisplayVersion + return "Version $version at $vsPath" } # MSBuild $results += Test-Dependency -Name "MSBuild" -Check { - $msbuild = Get-Command msbuild.exe -ErrorAction SilentlyContinue - if ($msbuild) { - $version = (& msbuild.exe -version -nologo 2>$null | Select-Object -Last 1) - return "Version $version" - } - # Try via vswhere - $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" - $vsPath = & $vsWhere -latest -requires Microsoft.Component.MSBuild -products * -property installationPath 2>$null - if ($vsPath) { - $msbuildPath = Join-Path $vsPath 'MSBuild\Current\Bin\MSBuild.exe' - if (Test-Path $msbuildPath) { - return "Found at $msbuildPath (not in PATH)" - } - } - throw "MSBuild not found in PATH or VS installation" + $msbuild = Get-Command msbuild.exe -ErrorAction SilentlyContinue + if ($msbuild) { + $version = (& msbuild.exe -version -nologo 2>$null | Select-Object -Last 1) + return "Version $version" + } + # Try via vswhere + $vsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vsPath = & $vsWhere -latest -requires Microsoft.Component.MSBuild -products * -property installationPath 2>$null + if ($vsPath) { + $msbuildPath = Join-Path $vsPath 'MSBuild\Current\Bin\MSBuild.exe' + if (Test-Path $msbuildPath) { + return "Found at $msbuildPath (not in PATH)" + } + } + throw "MSBuild not found in PATH or VS installation" } # .NET Framework SDK tools used by localization tasks $results += Test-Dependency -Name "ResGen.exe (.NET Framework SDK)" -Check { - $resgen = Find-DotNetFrameworkSdkTool -ToolName 'ResGen.exe' - if ($resgen) { return $resgen } - throw "ResGen.exe not found in Windows SDK NETFX tool folders" + $resgen = Find-DotNetFrameworkSdkTool -ToolName 'ResGen.exe' + if ($resgen) { return $resgen } + throw "ResGen.exe not found in Windows SDK NETFX tool folders" } $results += Test-Dependency -Name "al.exe (.NET Framework SDK)" -Check { - $al = Find-DotNetFrameworkSdkTool -ToolName 'al.exe' - if ($al) { return $al } - throw "al.exe not found in Windows SDK NETFX tool folders" + $al = Find-DotNetFrameworkSdkTool -ToolName 'al.exe' + if ($al) { return $al } + throw "al.exe not found in Windows SDK NETFX tool folders" } # .NET SDK $results += Test-Dependency -Name ".NET SDK" -Check { - $dotnet = Get-Command dotnet.exe -ErrorAction SilentlyContinue - if ($dotnet) { - $version = (& dotnet.exe --version 2>&1) - return "Version $version" - } - throw "dotnet.exe not found in PATH" + $dotnet = Get-Command dotnet.exe -ErrorAction SilentlyContinue + if ($dotnet) { + $version = (& dotnet.exe --version 2>&1) + return "Version $version" + } + throw "dotnet.exe not found in PATH" } # WiX Toolset (v6) # Installer projects use WixToolset.Sdk via NuGet restore; no global WiX 3.x install is required. $results += Test-Dependency -Name "WiX Toolset (v6 via NuGet)" -Required "Optional" -Check { - $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) - $wixProj = Join-Path $repoRoot "FLExInstaller\wix6\FieldWorks.Installer.wixproj" - if (-not (Test-Path $wixProj)) { - throw "Installer project not found: $wixProj" - } - - [xml]$wixProjXml = Get-Content -LiteralPath $wixProj - $projectNode = $wixProjXml.Project - $hasWixSdk = $false - - if ($projectNode -and $projectNode.Sdk -match 'WixToolset\.Sdk') { - $hasWixSdk = $true - } - - if (-not $hasWixSdk) { - $wixSdkReference = $wixProjXml.SelectSingleNode("//*[local-name()='PackageReference' and @Include='WixToolset.Sdk']") - if ($wixSdkReference) { - $hasWixSdk = $true - } - } - - if ($hasWixSdk) { - return "Configured in $wixProj (restored during build)" - } - - throw "WixToolset.Sdk not referenced in $wixProj" + $repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $wixProj = Join-Path $repoRoot "FLExInstaller\wix6\FieldWorks.Installer.wixproj" + if (-not (Test-Path $wixProj)) { + throw "Installer project not found: $wixProj" + } + + [xml]$wixProjXml = Get-Content -LiteralPath $wixProj + $projectNode = $wixProjXml.Project + $hasWixSdk = $false + + if ($projectNode -and $projectNode.Sdk -match 'WixToolset\.Sdk') { + $hasWixSdk = $true + } + + if (-not $hasWixSdk) { + $wixSdkReference = $wixProjXml.SelectSingleNode("//*[local-name()='PackageReference' and @Include='WixToolset.Sdk']") + if ($wixSdkReference) { + $hasWixSdk = $true + } + } + + if ($hasWixSdk) { + return "Configured in $wixProj (restored during build)" + } + + throw "WixToolset.Sdk not referenced in $wixProj" } # ---------------------------------------------------------------------------- # Optional Dependencies (for Serena MCP) # ---------------------------------------------------------------------------- if ($IncludeOptional) { - if ($Detailed) { - Write-Host "" - Write-Host "--- Optional Dependencies (Serena MCP) ---" -ForegroundColor Cyan - } - - # Python - $results += Test-Dependency -Name "Python" -Required "Optional" -Check { - $python = Get-Command python.exe -ErrorAction SilentlyContinue - if (-not $python) { $python = Get-Command python3.exe -ErrorAction SilentlyContinue } - if ($python) { - $version = (& $python.Source --version 2>&1) - return $version - } - throw "python.exe not found in PATH" - } - - # uv (Python package manager) - $results += Test-Dependency -Name "uv (Python package manager)" -Required "Optional" -Check { - $uv = Get-Command uv -ErrorAction SilentlyContinue - if ($uv) { - $version = (& uv --version 2>&1) - return $version - } - throw "uv not found - install with: winget install astral-sh.uv" - } - - # clangd (C++ language server) - $results += Test-Dependency -Name "clangd (C++ language server)" -Required "Optional" -Check { - $clangd = Get-Command clangd -ErrorAction SilentlyContinue - if ($clangd) { - $version = (& clangd --version 2>&1 | Select-Object -First 1) - return $version - } - throw "clangd not found - Serena will auto-download if needed" - } - - # Serena project config - $results += Test-Dependency -Name "Serena project config" -Required "Optional" -Check { - $configPath = ".serena/project.yml" - if (Test-Path $configPath) { - return "Found at $configPath" - } - throw "No .serena/project.yml - Serena not configured for this repo" - } + if ($Detailed) { + Write-Host "" + Write-Host "--- Optional Dependencies (Serena MCP) ---" -ForegroundColor Cyan + } + + # Python + $results += Test-Dependency -Name "Python" -Required "Optional" -Check { + $python = Get-Command python.exe -ErrorAction SilentlyContinue + if (-not $python) { $python = Get-Command python3.exe -ErrorAction SilentlyContinue } + if ($python) { + $version = (& $python.Source --version 2>&1) + return $version + } + throw "python.exe not found in PATH" + } + + # uv (Python package manager) + $results += Test-Dependency -Name "uv (Python package manager)" -Required "Optional" -Check { + $uv = Get-Command uv -ErrorAction SilentlyContinue + if ($uv) { + $version = (& uv --version 2>&1) + return $version + } + throw "uv not found - install with: winget install astral-sh.uv" + } + + # clangd (C++ language server) + $results += Test-Dependency -Name "clangd (C++ language server)" -Required "Optional" -Check { + $clangd = Get-Command clangd -ErrorAction SilentlyContinue + if ($clangd) { + $version = (& clangd --version 2>&1 | Select-Object -First 1) + return $version + } + throw "clangd not found - Serena will auto-download if needed" + } + + # Serena project config + $results += Test-Dependency -Name "Serena project config" -Required "Optional" -Check { + $configPath = ".serena/project.yml" + if (Test-Path $configPath) { + return "Found at $configPath" + } + throw "No .serena/project.yml - Serena not configured for this repo" + } } # ---------------------------------------------------------------------------- # Summary # ---------------------------------------------------------------------------- if ($Detailed) { - Write-Host "" - Write-Host "=== Summary ===" -ForegroundColor Cyan + Write-Host "" + Write-Host "=== Summary ===" -ForegroundColor Cyan } $required = $results | Where-Object { $_.IsRequired -ne $false } @@ -307,28 +307,28 @@ $foundRequired = ($required | Where-Object { $_.Found } | Measure-Object).Count Write-Host "Dependency preflight: required $foundRequired/$totalRequired found" if ($IncludeOptional) { - $totalOptional = ($optional | Measure-Object).Count - $foundOptional = ($optional | Where-Object { $_.Found } | Measure-Object).Count - Write-Host "Dependency preflight: optional $foundOptional/$totalOptional found" + $totalOptional = ($optional | Measure-Object).Count + $foundOptional = ($optional | Where-Object { $_.Found } | Measure-Object).Count + Write-Host "Dependency preflight: optional $foundOptional/$totalOptional found" } if ($missing.Count -gt 0) { - Write-Host "" - Write-Host "Missing required dependencies:" -ForegroundColor Red - foreach ($m in $missing) { - Write-Host " - $($m.Name)" -ForegroundColor Red - } - - if ($FailOnMissing) { - Write-Host "" - Write-Host "Exiting with error (FailOnMissing specified)" -ForegroundColor Red - exit 1 - } + Write-Host "" + Write-Host "Missing required dependencies:" -ForegroundColor Red + foreach ($m in $missing) { + Write-Host " - $($m.Name)" -ForegroundColor Red + } + + if ($FailOnMissing) { + Write-Host "" + Write-Host "Exiting with error (FailOnMissing specified)" -ForegroundColor Red + exit 1 + } } else { - Write-Host "Dependency preflight: all required dependencies are available" -ForegroundColor Green + Write-Host "Dependency preflight: all required dependencies are available" -ForegroundColor Green } if ($PassThru) { - return $results + return $results } diff --git a/Build/Agent/validate-test-exclusions.ps1 b/Build/Agent/validate-test-exclusions.ps1 index 3513eeb557..4c9eb97620 100644 --- a/Build/Agent/validate-test-exclusions.ps1 +++ b/Build/Agent/validate-test-exclusions.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param ( - [switch]$FailOnWarning + [switch]$FailOnWarning ) $ErrorActionPreference = "Stop" @@ -13,42 +13,42 @@ Write-Host "Repo Root: $RepoRoot" Push-Location $RepoRoot try { - $PyArgs = @("-m", "scripts.validate_test_exclusions", "--json-report", $ReportPath) - if ($FailOnWarning) { - $PyArgs += "--fail-on-warning" - } - - & python $PyArgs - if ($LASTEXITCODE -ne 0) { - throw "Validation failed with exit code $LASTEXITCODE. See report at $ReportPath" - } - - # Run MSBuild and check for CS0436 - $BuildLog = "$RepoRoot/Output/test-exclusions/build.log" - Write-Host "Running MSBuild..." - $BuildArgs = @("FieldWorks.proj", "/m", "/p:Configuration=Debug", "/p:Platform=x64", "/v:minimal", "/nologo", "/fl", "/flp:LogFile=$BuildLog;Verbosity=Normal") - - & msbuild $BuildArgs - if ($LASTEXITCODE -ne 0) { - throw "MSBuild failed with exit code $LASTEXITCODE." - } - - # Analyze log - Write-Host "Analyzing build log for CS0436..." - $PyArgs = @("-m", "scripts.validate_test_exclusions", "--analyze-log", $BuildLog) - & python $PyArgs - if ($LASTEXITCODE -ne 0) { - throw "CS0436 analysis failed." - } - - # Assembly Guard - $AssemblyPattern = "$RepoRoot/Output/Debug/**/*.dll" - if (Test-Path "$RepoRoot/Output/Debug") { - Write-Host "Running Assembly Guard..." - & "$RepoRoot/scripts/test_exclusions/assembly_guard.ps1" -Assemblies $AssemblyPattern - } else { - Write-Warning "Output/Debug not found. Skipping Assembly Guard." - } + $PyArgs = @("-m", "scripts.validate_test_exclusions", "--json-report", $ReportPath) + if ($FailOnWarning) { + $PyArgs += "--fail-on-warning" + } + + & python $PyArgs + if ($LASTEXITCODE -ne 0) { + throw "Validation failed with exit code $LASTEXITCODE. See report at $ReportPath" + } + + # Run MSBuild and check for CS0436 + $BuildLog = "$RepoRoot/Output/test-exclusions/build.log" + Write-Host "Running MSBuild..." + $BuildArgs = @("FieldWorks.proj", "/m", "/p:Configuration=Debug", "/p:Platform=x64", "/v:minimal", "/nologo", "/fl", "/flp:LogFile=$BuildLog;Verbosity=Normal") + + & msbuild $BuildArgs + if ($LASTEXITCODE -ne 0) { + throw "MSBuild failed with exit code $LASTEXITCODE." + } + + # Analyze log + Write-Host "Analyzing build log for CS0436..." + $PyArgs = @("-m", "scripts.validate_test_exclusions", "--analyze-log", $BuildLog) + & python $PyArgs + if ($LASTEXITCODE -ne 0) { + throw "CS0436 analysis failed." + } + + # Assembly Guard + $AssemblyPattern = "$RepoRoot/Output/Debug/**/*.dll" + if (Test-Path "$RepoRoot/Output/Debug") { + Write-Host "Running Assembly Guard..." + & "$RepoRoot/scripts/test_exclusions/assembly_guard.ps1" -Assemblies $AssemblyPattern + } else { + Write-Warning "Output/Debug not found. Skipping Assembly Guard." + } } finally { - Pop-Location + Pop-Location } diff --git a/Build/scripts/Invoke-CppTest.ps1 b/Build/scripts/Invoke-CppTest.ps1 index 2c349085e2..87f90598ac 100644 --- a/Build/scripts/Invoke-CppTest.ps1 +++ b/Build/scripts/Invoke-CppTest.ps1 @@ -1,38 +1,38 @@ <# .SYNOPSIS - Build and/or run native C++ test executables. + Build and/or run native C++ test executables. .DESCRIPTION - Script for building and running native C++ tests. - Supports both MSBuild (new vcxproj) and nmake (legacy makefile) builds. - Auto-approvable by Copilot agents. + Script for building and running native C++ tests. + Supports both MSBuild (new vcxproj) and nmake (legacy makefile) builds. + Auto-approvable by Copilot agents. .PARAMETER Action - What to do: Build, Run, or BuildAndRun (default). + What to do: Build, Run, or BuildAndRun (default). .PARAMETER TestProject - Which test: TestGeneric (default) or TestViews. + Which test: TestGeneric (default) or TestViews. .PARAMETER Configuration - Build configuration: Debug (default) or Release. + Build configuration: Debug (default) or Release. .PARAMETER BuildSystem - Build system to use: MSBuild (default, uses vcxproj) or NMake (legacy). + Build system to use: MSBuild (default, uses vcxproj) or NMake (legacy). .PARAMETER WorktreePath - Path to the worktree root. Defaults to current directory. + Path to the worktree root. Defaults to current directory. .EXAMPLE - .\Invoke-CppTest.ps1 -TestProject TestGeneric - Build and run TestGeneric using MSBuild. + .\Invoke-CppTest.ps1 -TestProject TestGeneric + Build and run TestGeneric using MSBuild. .EXAMPLE - .\Invoke-CppTest.ps1 -Action Run -TestProject TestViews - Run TestViews without rebuilding. + .\Invoke-CppTest.ps1 -Action Run -TestProject TestViews + Run TestViews without rebuilding. .EXAMPLE - .\Invoke-CppTest.ps1 -BuildSystem NMake -TestProject TestGeneric - Build TestGeneric using legacy nmake (requires VsDevCmd). + .\Invoke-CppTest.ps1 -BuildSystem NMake -TestProject TestGeneric + Build TestGeneric using legacy nmake (requires VsDevCmd). #> [CmdletBinding()] param( diff --git a/scripts/Agent/Copy-LocalLcm.ps1 b/scripts/Agent/Copy-LocalLcm.ps1 index a7ed2fd826..f621f3d8cc 100644 --- a/scripts/Agent/Copy-LocalLcm.ps1 +++ b/scripts/Agent/Copy-LocalLcm.ps1 @@ -1,45 +1,45 @@ <# .SYNOPSIS - Copies locally-built LCM assemblies from an adjacent liblcm folder into the FieldWorks output directory. + Copies locally-built LCM assemblies from an adjacent liblcm folder into the FieldWorks output directory. .DESCRIPTION - This script enables developers to test local liblcm fixes without publishing a NuGet package. - It builds liblcm from a local checkout (by default ../liblcm) and copies the resulting - assemblies into FieldWorks' Output/ folder, overwriting the NuGet versions. + This script enables developers to test local liblcm fixes without publishing a NuGet package. + It builds liblcm from a local checkout (by default ../liblcm) and copies the resulting + assemblies into FieldWorks' Output/ folder, overwriting the NuGet versions. .PARAMETER LcmRoot - Path to the liblcm repository root. Defaults to ../liblcm (relative to FieldWorks repo). + Path to the liblcm repository root. Defaults to ../liblcm (relative to FieldWorks repo). .PARAMETER FwOutputDir - Path to FieldWorks output directory. Defaults to Output/. + Path to FieldWorks output directory. Defaults to Output/. .PARAMETER Configuration - Build configuration (Debug or Release). Default is Debug. + Build configuration (Debug or Release). Default is Debug. .PARAMETER BuildLcm - If set, builds liblcm before copying. If not set, just copies existing DLLs. + If set, builds liblcm before copying. If not set, just copies existing DLLs. .PARAMETER SkipConfirm - If set, skips the confirmation prompt. + If set, skips the confirmation prompt. .EXAMPLE - .\Copy-LocalLcm.ps1 -BuildLcm - Builds liblcm from ../liblcm and copies DLLs to Output/Debug. + .\Copy-LocalLcm.ps1 -BuildLcm + Builds liblcm from ../liblcm and copies DLLs to Output/Debug. .EXAMPLE - .\Copy-LocalLcm.ps1 -Configuration Release -BuildLcm - Builds liblcm for Release and copies to Output/Release. + .\Copy-LocalLcm.ps1 -Configuration Release -BuildLcm + Builds liblcm for Release and copies to Output/Release. .NOTES - Use this for local debugging of liblcm issues. Never commit code that depends on this. + Use this for local debugging of liblcm issues. Never commit code that depends on this. #> [CmdletBinding()] param( - [string]$LcmRoot, - [string]$FwOutputDir, - [string]$Configuration = "Debug", - [switch]$BuildLcm, - [switch]$SkipConfirm + [string]$LcmRoot, + [string]$FwOutputDir, + [string]$Configuration = "Debug", + [switch]$BuildLcm, + [switch]$SkipConfirm ) $ErrorActionPreference = "Stop" @@ -47,22 +47,22 @@ $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "../..") if (-not $LcmRoot) { - $LcmRoot = Join-Path (Split-Path $repoRoot -Parent) "liblcm" + $LcmRoot = Join-Path (Split-Path $repoRoot -Parent) "liblcm" } if (-not (Test-Path $LcmRoot)) { - Write-Error "liblcm not found at '$LcmRoot'. Clone it there or specify -LcmRoot." - exit 1 + Write-Error "liblcm not found at '$LcmRoot'. Clone it there or specify -LcmRoot." + exit 1 } $lcmSolution = Join-Path $LcmRoot "LCM.sln" if (-not (Test-Path $lcmSolution)) { - Write-Error "LCM.sln not found at '$lcmSolution'. Is '$LcmRoot' a valid liblcm checkout?" - exit 1 + Write-Error "LCM.sln not found at '$lcmSolution'. Is '$LcmRoot' a valid liblcm checkout?" + exit 1 } if (-not $FwOutputDir) { - $FwOutputDir = Join-Path $repoRoot "Output\$Configuration" + $FwOutputDir = Join-Path $repoRoot "Output\$Configuration" } Write-Host "" @@ -71,60 +71,60 @@ Write-Host " Local LCM Copy Utility" -ForegroundColor Cyan Write-Host "===============================================" -ForegroundColor Cyan Write-Host " LCM Source: $LcmRoot" -ForegroundColor White Write-Host " FW Output: $FwOutputDir" -ForegroundColor White -Write-Host " Config: $Configuration" -ForegroundColor White +Write-Host " Config: $Configuration" -ForegroundColor White Write-Host " Build LCM: $($BuildLcm.IsPresent)" -ForegroundColor White Write-Host "" if (-not $SkipConfirm) { - Write-Host "This will OVERWRITE NuGet LCM DLLs in the FW output directory." -ForegroundColor Yellow - Write-Host "Only use for local debugging - never commit changes that depend on this." -ForegroundColor Yellow - Write-Host "" - $confirm = Read-Host "Continue? [y/N]" - if ($confirm -notin @('y', 'Y', 'yes', 'Yes')) { - Write-Host "Aborted." -ForegroundColor Red - exit 0 - } + Write-Host "This will OVERWRITE NuGet LCM DLLs in the FW output directory." -ForegroundColor Yellow + Write-Host "Only use for local debugging - never commit changes that depend on this." -ForegroundColor Yellow + Write-Host "" + $confirm = Read-Host "Continue? [y/N]" + if ($confirm -notin @('y', 'Y', 'yes', 'Yes')) { + Write-Host "Aborted." -ForegroundColor Red + exit 0 + } } # Build liblcm if requested if ($BuildLcm) { - Write-Host "" - Write-Host "Building liblcm ($Configuration)..." -ForegroundColor Cyan - - Push-Location $LcmRoot - try { - # liblcm uses build.cmd for building - $buildScript = Join-Path $LcmRoot "build.cmd" - if (-not (Test-Path $buildScript)) { - throw "build.cmd not found at '$buildScript'. Is '$LcmRoot' a valid liblcm checkout?" - } - - Write-Host "Running: $buildScript" -ForegroundColor Gray - & cmd /c $buildScript - if ($LASTEXITCODE -ne 0) { - throw "liblcm build failed with exit code $LASTEXITCODE" - } - Write-Host "[OK] liblcm build complete." -ForegroundColor Green - } - finally { - Pop-Location - } + Write-Host "" + Write-Host "Building liblcm ($Configuration)..." -ForegroundColor Cyan + + Push-Location $LcmRoot + try { + # liblcm uses build.cmd for building + $buildScript = Join-Path $LcmRoot "build.cmd" + if (-not (Test-Path $buildScript)) { + throw "build.cmd not found at '$buildScript'. Is '$LcmRoot' a valid liblcm checkout?" + } + + Write-Host "Running: $buildScript" -ForegroundColor Gray + & cmd /c $buildScript + if ($LASTEXITCODE -ne 0) { + throw "liblcm build failed with exit code $LASTEXITCODE" + } + Write-Host "[OK] liblcm build complete." -ForegroundColor Green + } + finally { + Pop-Location + } } # Find the LCM output directory # liblcm builds to: artifacts//net462/ $lcmBinDir = Join-Path $LcmRoot "artifacts\$Configuration\net462" if (-not (Test-Path $lcmBinDir)) { - # Try net472 (alternative target) - $lcmBinDir = Join-Path $LcmRoot "artifacts\$Configuration\net472" + # Try net472 (alternative target) + $lcmBinDir = Join-Path $LcmRoot "artifacts\$Configuration\net472" } if (-not (Test-Path $lcmBinDir)) { - # Try net48 (newer versions) - $lcmBinDir = Join-Path $LcmRoot "artifacts\$Configuration\net48" + # Try net48 (newer versions) + $lcmBinDir = Join-Path $LcmRoot "artifacts\$Configuration\net48" } if (-not (Test-Path $lcmBinDir)) { - Write-Error "LCM bin directory not found. Build liblcm first with -BuildLcm or manually run 'build.cmd' in '$LcmRoot'." - exit 1 + Write-Error "LCM bin directory not found. Build liblcm first with -BuildLcm or manually run 'build.cmd' in '$LcmRoot'." + exit 1 } Write-Host "" @@ -133,49 +133,49 @@ Write-Host " From: $lcmBinDir" -ForegroundColor Gray # List of LCM assemblies to copy $lcmAssemblies = @( - "SIL.LCModel.dll", - "SIL.LCModel.pdb", - "SIL.LCModel.Core.dll", - "SIL.LCModel.Core.pdb", - "SIL.LCModel.Utils.dll", - "SIL.LCModel.Utils.pdb" + "SIL.LCModel.dll", + "SIL.LCModel.pdb", + "SIL.LCModel.Core.dll", + "SIL.LCModel.Core.pdb", + "SIL.LCModel.Utils.dll", + "SIL.LCModel.Utils.pdb" ) $copied = 0 $missing = @() foreach ($asm in $lcmAssemblies) { - $sourcePath = Join-Path $lcmBinDir $asm - $destPath = Join-Path $FwOutputDir $asm - - if (Test-Path $sourcePath) { - if (-not (Test-Path $FwOutputDir)) { - New-Item -Path $FwOutputDir -ItemType Directory -Force | Out-Null - } - Copy-Item -Path $sourcePath -Destination $destPath -Force - Write-Host " [COPIED] $asm" -ForegroundColor Green - $copied++ - } - else { - # PDB files are optional - if ($asm -match '\.pdb$') { - Write-Host " [SKIP] $asm (not found)" -ForegroundColor Gray - } - else { - $missing += $asm - Write-Host " [WARN] $asm not found!" -ForegroundColor Yellow - } - } + $sourcePath = Join-Path $lcmBinDir $asm + $destPath = Join-Path $FwOutputDir $asm + + if (Test-Path $sourcePath) { + if (-not (Test-Path $FwOutputDir)) { + New-Item -Path $FwOutputDir -ItemType Directory -Force | Out-Null + } + Copy-Item -Path $sourcePath -Destination $destPath -Force + Write-Host " [COPIED] $asm" -ForegroundColor Green + $copied++ + } + else { + # PDB files are optional + if ($asm -match '\.pdb$') { + Write-Host " [SKIP] $asm (not found)" -ForegroundColor Gray + } + else { + $missing += $asm + Write-Host " [WARN] $asm not found!" -ForegroundColor Yellow + } + } } Write-Host "" if ($missing.Count -gt 0) { - Write-Host "WARNING: Some assemblies were not found:" -ForegroundColor Yellow - $missing | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } - Write-Host "" - Write-Host "This may indicate liblcm wasn't built, or has a different output structure." -ForegroundColor Yellow - Write-Host "Try running: dotnet build LCM.sln -c $Configuration in '$LcmRoot'" -ForegroundColor Yellow - exit 1 + Write-Host "WARNING: Some assemblies were not found:" -ForegroundColor Yellow + $missing | ForEach-Object { Write-Host " - $_" -ForegroundColor Yellow } + Write-Host "" + Write-Host "This may indicate liblcm wasn't built, or has a different output structure." -ForegroundColor Yellow + Write-Host "Try running: dotnet build LCM.sln -c $Configuration in '$LcmRoot'" -ForegroundColor Yellow + exit 1 } Write-Host "[OK] Copied $copied LCM assembly file(s) to FW output." -ForegroundColor Green diff --git a/scripts/Agent/Git-Search.ps1 b/scripts/Agent/Git-Search.ps1 index fe56f5021b..e85ca93cd3 100644 --- a/scripts/Agent/Git-Search.ps1 +++ b/scripts/Agent/Git-Search.ps1 @@ -1,237 +1,237 @@ <# .SYNOPSIS - Git helper for cross-worktree and cross-branch operations. + Git helper for cross-worktree and cross-branch operations. .DESCRIPTION - Encapsulates common git operations that would otherwise require pipes or - complex commands that don't auto-approve. Designed for use by Copilot agents. + Encapsulates common git operations that would otherwise require pipes or + complex commands that don't auto-approve. Designed for use by Copilot agents. .PARAMETER Action - The git operation to perform: - - show: Show file contents from a specific ref - - diff: Compare files between refs or worktrees - - log: Show commit history - - blame: Show line-by-line authorship - - search: Search for text in files (git grep) - - branches: List branches matching pattern - - files: List files in a tree + The git operation to perform: + - show: Show file contents from a specific ref + - diff: Compare files between refs or worktrees + - log: Show commit history + - blame: Show line-by-line authorship + - search: Search for text in files (git grep) + - branches: List branches matching pattern + - files: List files in a tree .PARAMETER Ref - Git ref (branch, tag, commit) to operate on. Default: HEAD + Git ref (branch, tag, commit) to operate on. Default: HEAD .PARAMETER Path - File path within the repository. + File path within the repository. .PARAMETER Pattern - Search pattern for 'search' action or branch pattern for 'branches' action. + Search pattern for 'search' action or branch pattern for 'branches' action. .PARAMETER RepoPath - Path to git repository. Default: current directory or main FieldWorks repo. + Path to git repository. Default: current directory or main FieldWorks repo. .PARAMETER HeadLines - Number of lines to show from the beginning. Default: 0 (all) + Number of lines to show from the beginning. Default: 0 (all) .PARAMETER TailLines - Number of lines to show from the end. Default: 0 (all) + Number of lines to show from the end. Default: 0 (all) .PARAMETER Context - Lines of context for search results. Default: 3 + Lines of context for search results. Default: 3 .EXAMPLE - .\Git-Search.ps1 -Action show -Ref "release/9.3" -Path "Output/Common/ViewsTlb.h" -HeadLines 5 + .\Git-Search.ps1 -Action show -Ref "release/9.3" -Path "Output/Common/ViewsTlb.h" -HeadLines 5 .EXAMPLE - .\Git-Search.ps1 -Action search -Pattern "IVwGraphics" -Ref "HEAD" -Path "Src/" + .\Git-Search.ps1 -Action search -Pattern "IVwGraphics" -Ref "HEAD" -Path "Src/" .EXAMPLE - .\Git-Search.ps1 -Action diff -Ref "release/9.3..HEAD" -Path "Src/Common" + .\Git-Search.ps1 -Action diff -Ref "release/9.3..HEAD" -Path "Src/Common" .EXAMPLE - .\Git-Search.ps1 -Action branches -Pattern "feature/*" + .\Git-Search.ps1 -Action branches -Pattern "feature/*" #> [CmdletBinding()] param( - [Parameter(Mandatory = $true)] - [ValidateSet('show', 'diff', 'log', 'blame', 'search', 'branches', 'files')] - [string]$Action, + [Parameter(Mandatory = $true)] + [ValidateSet('show', 'diff', 'log', 'blame', 'search', 'branches', 'files')] + [string]$Action, - [string]$Ref = 'HEAD', + [string]$Ref = 'HEAD', - [string]$Path, + [string]$Path, - [string]$Pattern, + [string]$Pattern, - [string]$RepoPath, + [string]$RepoPath, - [int]$HeadLines = 0, + [int]$HeadLines = 0, - [int]$TailLines = 0, + [int]$TailLines = 0, - [int]$Context = 3 + [int]$Context = 3 - , + , - [ValidateSet('oneline', 'fuller')] - [string]$LogStyle = 'oneline' + [ValidateSet('oneline', 'fuller')] + [string]$LogStyle = 'oneline' - , + , - [int]$MaxCount = 20 + [int]$MaxCount = 20 ) $ErrorActionPreference = 'Stop' # Determine repository path if (-not $RepoPath) { - # Try to find FieldWorks main repo - $candidates = @( - 'C:\Users\johnm\Documents\repos\FieldWorks', - 'C:\Users\johnm\Documents\repos\fw-worktrees\main', - (Get-Location).Path - ) - foreach ($candidate in $candidates) { - if (Test-Path (Join-Path $candidate '.git')) { - $RepoPath = $candidate - break - } - } + # Try to find FieldWorks main repo + $candidates = @( + 'C:\Users\johnm\Documents\repos\FieldWorks', + 'C:\Users\johnm\Documents\repos\fw-worktrees\main', + (Get-Location).Path + ) + foreach ($candidate in $candidates) { + if (Test-Path (Join-Path $candidate '.git')) { + $RepoPath = $candidate + break + } + } } if (-not $RepoPath -or -not (Test-Path $RepoPath)) { - throw "Repository path not found. Specify -RepoPath explicitly." + throw "Repository path not found. Specify -RepoPath explicitly." } function Invoke-GitCommand { - param([string[]]$Arguments) - - $result = & git -C $RepoPath @Arguments 2>&1 - $exitCode = $LASTEXITCODE - - if ($exitCode -ne 0) { - $errorMsg = $result | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() } - if ($errorMsg) { - Write-Error "Git command failed: $errorMsg" - } - return $null - } - - # Convert to string array - $lines = @($result | ForEach-Object { $_.ToString() }) - return $lines + param([string[]]$Arguments) + + $result = & git -C $RepoPath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + + if ($exitCode -ne 0) { + $errorMsg = $result | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] } | ForEach-Object { $_.ToString() } + if ($errorMsg) { + Write-Error "Git command failed: $errorMsg" + } + return $null + } + + # Convert to string array + $lines = @($result | ForEach-Object { $_.ToString() }) + return $lines } function Format-Output { - param([string[]]$Lines) - - if (-not $Lines -or $Lines.Count -eq 0) { - return - } - - $total = $Lines.Count - - if ($HeadLines -gt 0 -and $TailLines -gt 0) { - # Show head and tail - if ($total -le ($HeadLines + $TailLines)) { - $Lines | ForEach-Object { Write-Output $_ } - } else { - $Lines | Select-Object -First $HeadLines | ForEach-Object { Write-Output $_ } - Write-Output "... ($($total - $HeadLines - $TailLines) lines omitted) ..." - $Lines | Select-Object -Last $TailLines | ForEach-Object { Write-Output $_ } - } - } - elseif ($HeadLines -gt 0) { - if ($total -gt $HeadLines) { - $Lines | Select-Object -First $HeadLines | ForEach-Object { Write-Output $_ } - Write-Output "... ($($total - $HeadLines) more lines) ..." - } else { - $Lines | ForEach-Object { Write-Output $_ } - } - } - elseif ($TailLines -gt 0) { - if ($total -gt $TailLines) { - Write-Output "... ($($total - $TailLines) lines omitted) ..." - $Lines | Select-Object -Last $TailLines | ForEach-Object { Write-Output $_ } - } else { - $Lines | ForEach-Object { Write-Output $_ } - } - } - else { - $Lines | ForEach-Object { Write-Output $_ } - } + param([string[]]$Lines) + + if (-not $Lines -or $Lines.Count -eq 0) { + return + } + + $total = $Lines.Count + + if ($HeadLines -gt 0 -and $TailLines -gt 0) { + # Show head and tail + if ($total -le ($HeadLines + $TailLines)) { + $Lines | ForEach-Object { Write-Output $_ } + } else { + $Lines | Select-Object -First $HeadLines | ForEach-Object { Write-Output $_ } + Write-Output "... ($($total - $HeadLines - $TailLines) lines omitted) ..." + $Lines | Select-Object -Last $TailLines | ForEach-Object { Write-Output $_ } + } + } + elseif ($HeadLines -gt 0) { + if ($total -gt $HeadLines) { + $Lines | Select-Object -First $HeadLines | ForEach-Object { Write-Output $_ } + Write-Output "... ($($total - $HeadLines) more lines) ..." + } else { + $Lines | ForEach-Object { Write-Output $_ } + } + } + elseif ($TailLines -gt 0) { + if ($total -gt $TailLines) { + Write-Output "... ($($total - $TailLines) lines omitted) ..." + $Lines | Select-Object -Last $TailLines | ForEach-Object { Write-Output $_ } + } else { + $Lines | ForEach-Object { Write-Output $_ } + } + } + else { + $Lines | ForEach-Object { Write-Output $_ } + } } switch ($Action) { - 'show' { - if (-not $Path) { - throw "Path is required for 'show' action" - } - $refPath = "${Ref}:${Path}" - $lines = Invoke-GitCommand @('show', $refPath) - Format-Output $lines - } - - 'diff' { - $args = @('diff', '--stat') - if ($Ref) { $args += $Ref } - if ($Path) { $args += '--'; $args += $Path } - $lines = Invoke-GitCommand $args - Format-Output $lines - } - - 'log' { - if ($LogStyle -eq 'fuller') { - $args = @('log', '--pretty=fuller') - } else { - $args = @('log', '--oneline') - } - - if ($MaxCount -gt 0) { - $args += @('-n', $MaxCount) - } - if ($Ref -and $Ref -ne 'HEAD') { $args += $Ref } - if ($Path) { $args += '--'; $args += $Path } - $lines = Invoke-GitCommand $args - Format-Output $lines - } - - 'blame' { - if (-not $Path) { - throw "Path is required for 'blame' action" - } - $args = @('blame', '--line-porcelain') - if ($Ref -and $Ref -ne 'HEAD') { $args += $Ref } - $args += '--' - $args += $Path - $lines = Invoke-GitCommand $args - Format-Output $lines - } - - 'search' { - if (-not $Pattern) { - throw "Pattern is required for 'search' action" - } - $args = @('grep', '-n', "-C$Context", $Pattern) - if ($Ref -and $Ref -ne 'HEAD') { $args += $Ref } - if ($Path) { $args += '--'; $args += $Path } - $lines = Invoke-GitCommand $args - Format-Output $lines - } - - 'branches' { - $args = @('branch', '-a') - if ($Pattern) { - $args += '--list' - $args += $Pattern - } - $lines = Invoke-GitCommand $args - Format-Output $lines - } - - 'files' { - $args = @('ls-tree', '--name-only', '-r') - if ($Ref) { $args += $Ref } else { $args += 'HEAD' } - if ($Path) { $args += '--'; $args += $Path } - $lines = Invoke-GitCommand $args - Format-Output $lines - } + 'show' { + if (-not $Path) { + throw "Path is required for 'show' action" + } + $refPath = "${Ref}:${Path}" + $lines = Invoke-GitCommand @('show', $refPath) + Format-Output $lines + } + + 'diff' { + $args = @('diff', '--stat') + if ($Ref) { $args += $Ref } + if ($Path) { $args += '--'; $args += $Path } + $lines = Invoke-GitCommand $args + Format-Output $lines + } + + 'log' { + if ($LogStyle -eq 'fuller') { + $args = @('log', '--pretty=fuller') + } else { + $args = @('log', '--oneline') + } + + if ($MaxCount -gt 0) { + $args += @('-n', $MaxCount) + } + if ($Ref -and $Ref -ne 'HEAD') { $args += $Ref } + if ($Path) { $args += '--'; $args += $Path } + $lines = Invoke-GitCommand $args + Format-Output $lines + } + + 'blame' { + if (-not $Path) { + throw "Path is required for 'blame' action" + } + $args = @('blame', '--line-porcelain') + if ($Ref -and $Ref -ne 'HEAD') { $args += $Ref } + $args += '--' + $args += $Path + $lines = Invoke-GitCommand $args + Format-Output $lines + } + + 'search' { + if (-not $Pattern) { + throw "Pattern is required for 'search' action" + } + $args = @('grep', '-n', "-C$Context", $Pattern) + if ($Ref -and $Ref -ne 'HEAD') { $args += $Ref } + if ($Path) { $args += '--'; $args += $Path } + $lines = Invoke-GitCommand $args + Format-Output $lines + } + + 'branches' { + $args = @('branch', '-a') + if ($Pattern) { + $args += '--list' + $args += $Pattern + } + $lines = Invoke-GitCommand $args + Format-Output $lines + } + + 'files' { + $args = @('ls-tree', '--name-only', '-r') + if ($Ref) { $args += $Ref } else { $args += 'HEAD' } + if ($Path) { $args += '--'; $args += $Path } + $lines = Invoke-GitCommand $args + Format-Output $lines + } } diff --git a/scripts/Agent/Read-FileContent.ps1 b/scripts/Agent/Read-FileContent.ps1 index f2a498ca3c..a9ed28c013 100644 --- a/scripts/Agent/Read-FileContent.ps1 +++ b/scripts/Agent/Read-FileContent.ps1 @@ -1,56 +1,56 @@ <# .SYNOPSIS - Reads file content with built-in head/tail limiting. + Reads file content with built-in head/tail limiting. .DESCRIPTION - Alternative to "Get-Content file | Select-Object -First N" that auto-approves. - Supports reading from beginning, end, or both, with optional line numbers. + Alternative to "Get-Content file | Select-Object -First N" that auto-approves. + Supports reading from beginning, end, or both, with optional line numbers. .PARAMETER Path - Path to the file to read. + Path to the file to read. .PARAMETER HeadLines - Number of lines to show from the beginning. Default: 0 (all) + Number of lines to show from the beginning. Default: 0 (all) .PARAMETER TailLines - Number of lines to show from the end. Default: 0 (all) + Number of lines to show from the end. Default: 0 (all) .PARAMETER LineNumbers - If specified, prefix each line with its line number. + If specified, prefix each line with its line number. .PARAMETER Pattern - Optional regex pattern to filter lines (like Select-String but simpler output). + Optional regex pattern to filter lines (like Select-String but simpler output). .EXAMPLE - .\Read-FileContent.ps1 -Path "src/file.cs" -HeadLines 50 + .\Read-FileContent.ps1 -Path "src/file.cs" -HeadLines 50 .EXAMPLE - .\Read-FileContent.ps1 -Path "build.log" -TailLines 100 + .\Read-FileContent.ps1 -Path "build.log" -TailLines 100 .EXAMPLE - .\Read-FileContent.ps1 -Path "src/file.cs" -HeadLines 20 -TailLines 20 + .\Read-FileContent.ps1 -Path "src/file.cs" -HeadLines 20 -TailLines 20 .EXAMPLE - .\Read-FileContent.ps1 -Path "src/file.cs" -Pattern "class\s+\w+" -LineNumbers + .\Read-FileContent.ps1 -Path "src/file.cs" -Pattern "class\s+\w+" -LineNumbers #> [CmdletBinding()] param( - [Parameter(Mandatory = $true)] - [string]$Path, + [Parameter(Mandatory = $true)] + [string]$Path, - [int]$HeadLines = 0, + [int]$HeadLines = 0, - [int]$TailLines = 0, + [int]$TailLines = 0, - [switch]$LineNumbers, + [switch]$LineNumbers, - [string]$Pattern + [string]$Pattern ) $ErrorActionPreference = 'Stop' if (-not (Test-Path $Path)) { - throw "File not found: $Path" + throw "File not found: $Path" } $lines = Get-Content -Path $Path -Encoding UTF8 @@ -58,79 +58,79 @@ $total = $lines.Count # Apply pattern filter if specified if ($Pattern) { - $filtered = @() - $lineNums = @() - for ($i = 0; $i -lt $lines.Count; $i++) { - if ($lines[$i] -match $Pattern) { - $filtered += $lines[$i] - $lineNums += ($i + 1) - } - } - $lines = $filtered - $originalLineNums = $lineNums + $filtered = @() + $lineNums = @() + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -match $Pattern) { + $filtered += $lines[$i] + $lineNums += ($i + 1) + } + } + $lines = $filtered + $originalLineNums = $lineNums } function Format-Line { - param([string]$Line, [int]$Number) - if ($LineNumbers) { - return "{0,5}: {1}" -f $Number, $Line - } - return $Line + param([string]$Line, [int]$Number) + if ($LineNumbers) { + return "{0,5}: {1}" -f $Number, $Line + } + return $Line } # Determine what to output if ($HeadLines -gt 0 -and $TailLines -gt 0) { - # Show head and tail - if ($lines.Count -le ($HeadLines + $TailLines)) { - for ($i = 0; $i -lt $lines.Count; $i++) { - $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } - Write-Output (Format-Line -Line $lines[$i] -Number $num) - } - } else { - # Head - for ($i = 0; $i -lt $HeadLines; $i++) { - $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } - Write-Output (Format-Line -Line $lines[$i] -Number $num) - } - Write-Output "... ($($lines.Count - $HeadLines - $TailLines) lines omitted) ..." - # Tail - $startIdx = $lines.Count - $TailLines - for ($i = $startIdx; $i -lt $lines.Count; $i++) { - $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } - Write-Output (Format-Line -Line $lines[$i] -Number $num) - } - } + # Show head and tail + if ($lines.Count -le ($HeadLines + $TailLines)) { + for ($i = 0; $i -lt $lines.Count; $i++) { + $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } + Write-Output (Format-Line -Line $lines[$i] -Number $num) + } + } else { + # Head + for ($i = 0; $i -lt $HeadLines; $i++) { + $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } + Write-Output (Format-Line -Line $lines[$i] -Number $num) + } + Write-Output "... ($($lines.Count - $HeadLines - $TailLines) lines omitted) ..." + # Tail + $startIdx = $lines.Count - $TailLines + for ($i = $startIdx; $i -lt $lines.Count; $i++) { + $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } + Write-Output (Format-Line -Line $lines[$i] -Number $num) + } + } } elseif ($HeadLines -gt 0) { - $showCount = [Math]::Min($HeadLines, $lines.Count) - for ($i = 0; $i -lt $showCount; $i++) { - $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } - Write-Output (Format-Line -Line $lines[$i] -Number $num) - } - if ($lines.Count -gt $HeadLines) { - Write-Output "... ($($lines.Count - $HeadLines) more lines) ..." - } + $showCount = [Math]::Min($HeadLines, $lines.Count) + for ($i = 0; $i -lt $showCount; $i++) { + $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } + Write-Output (Format-Line -Line $lines[$i] -Number $num) + } + if ($lines.Count -gt $HeadLines) { + Write-Output "... ($($lines.Count - $HeadLines) more lines) ..." + } } elseif ($TailLines -gt 0) { - if ($lines.Count -gt $TailLines) { - Write-Output "... ($($lines.Count - $TailLines) lines omitted) ..." - } - $startIdx = [Math]::Max(0, $lines.Count - $TailLines) - for ($i = $startIdx; $i -lt $lines.Count; $i++) { - $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } - Write-Output (Format-Line -Line $lines[$i] -Number $num) - } + if ($lines.Count -gt $TailLines) { + Write-Output "... ($($lines.Count - $TailLines) lines omitted) ..." + } + $startIdx = [Math]::Max(0, $lines.Count - $TailLines) + for ($i = $startIdx; $i -lt $lines.Count; $i++) { + $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } + Write-Output (Format-Line -Line $lines[$i] -Number $num) + } } else { - # Show all - for ($i = 0; $i -lt $lines.Count; $i++) { - $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } - Write-Output (Format-Line -Line $lines[$i] -Number $num) - } + # Show all + for ($i = 0; $i -lt $lines.Count; $i++) { + $num = if ($Pattern) { $originalLineNums[$i] } else { $i + 1 } + Write-Output (Format-Line -Line $lines[$i] -Number $num) + } } # Summary if ($Pattern) { - Write-Host "" - Write-Host "Found $($lines.Count) matching lines out of $total total" -ForegroundColor Gray + Write-Host "" + Write-Host "Found $($lines.Count) matching lines out of $total total" -ForegroundColor Gray } diff --git a/scripts/Agent/Setup-Local-Localization.ps1 b/scripts/Agent/Setup-Local-Localization.ps1 index 36e9868954..d0c8588aae 100644 --- a/scripts/Agent/Setup-Local-Localization.ps1 +++ b/scripts/Agent/Setup-Local-Localization.ps1 @@ -1,5 +1,5 @@ param( - [string]$RepoRoot = $PWD + [string]$RepoRoot = $PWD ) $ErrorActionPreference = "Stop" @@ -13,9 +13,9 @@ $FwBuildTasksDll = Join-Path $BuildToolsDir "FwBuildTasks\Debug\FwBuildTasks.dll # Ensure FwBuildTasks is built if (-not (Test-Path $FwBuildTasksDll)) { - Write-Host "FwBuildTasks.dll not found. Building it..." - & msbuild "$RepoRoot\Build\Tools.proj" /t:FwBuildTasks /p:Configuration=Debug - if ($LASTEXITCODE -ne 0) { throw "Failed to build FwBuildTasks" } + Write-Host "FwBuildTasks.dll not found. Building it..." + & msbuild "$RepoRoot\Build\Tools.proj" /t:FwBuildTasks /p:Configuration=Debug + if ($LASTEXITCODE -ne 0) { throw "Failed to build FwBuildTasks" } } # Load assembly for PoToXml @@ -23,23 +23,23 @@ Add-Type -Path $FwBuildTasksDll # Helper function to run PoToXml function Run-PoToXml { - param($PoFile, $StringsXml) - # We can't easily instantiate a Task class in PS without loading MSBuild assemblies properly. - # So we'll use a temporary MSBuild project. - - $projContent = @" + param($PoFile, $StringsXml) + # We can't easily instantiate a Task class in PS without loading MSBuild assemblies properly. + # So we'll use a temporary MSBuild project. + + $projContent = @" - + "@ - $projFile = Join-Path $env:TEMP "RunPoToXml.proj" - Set-Content -Path $projFile -Value $projContent - & msbuild $projFile /nologo /v:q - if ($LASTEXITCODE -ne 0) { Write-Error "PoToXml failed for $PoFile" } - Remove-Item $projFile -Force + $projFile = Join-Path $env:TEMP "RunPoToXml.proj" + Set-Content -Path $projFile -Value $projContent + & msbuild $projFile /nologo /v:q + if ($LASTEXITCODE -ne 0) { Write-Error "PoToXml failed for $PoFile" } + Remove-Item $projFile -Force } # Create l10ns dir if missing @@ -52,34 +52,34 @@ if (-not (Test-Path $stringsEn)) { throw "strings-en.xml not found at $stringsEn # Iterate PO files $poFiles = Get-ChildItem -Path $LocalizationsDir -Filter "messages.*.po" foreach ($po in $poFiles) { - # Name is messages.fr.po -> fr - $parts = $po.Name -split '\.' - if ($parts.Count -lt 3) { continue } - $locale = $parts[1] - - Write-Host "Processing $locale..." - - $localeDir = Join-Path $L10nsDir $locale - if (-not (Test-Path $localeDir)) { New-Item -ItemType Directory -Path $localeDir | Out-Null } - - # 1. Copy PO - Copy-Item $po.FullName -Destination (Join-Path $localeDir $po.Name) -Force - - # 2. Setup strings-LOCALE.xml - $targetStrings = Join-Path $localeDir "strings-$locale.xml" - Copy-Item $stringsEn -Destination $targetStrings -Force - - # 3. Update strings-LOCALE.xml - Run-PoToXml -PoFile $po.FullName -StringsXml $targetStrings - - # 4. Handle LocalizedLists (copy directly to DistFiles/Templates instead of via l10ns) - $listName = "LocalizedLists-$locale.xml" - $sourceList = Join-Path $LocalizationsDir $listName - if (Test-Path $sourceList) { - $destList = Join-Path $DistFilesDir "Templates\$listName" - Copy-Item $sourceList -Destination $destList -Force - Write-Host " Copied $listName to DistFiles/Templates" - } + # Name is messages.fr.po -> fr + $parts = $po.Name -split '\.' + if ($parts.Count -lt 3) { continue } + $locale = $parts[1] + + Write-Host "Processing $locale..." + + $localeDir = Join-Path $L10nsDir $locale + if (-not (Test-Path $localeDir)) { New-Item -ItemType Directory -Path $localeDir | Out-Null } + + # 1. Copy PO + Copy-Item $po.FullName -Destination (Join-Path $localeDir $po.Name) -Force + + # 2. Setup strings-LOCALE.xml + $targetStrings = Join-Path $localeDir "strings-$locale.xml" + Copy-Item $stringsEn -Destination $targetStrings -Force + + # 3. Update strings-LOCALE.xml + Run-PoToXml -PoFile $po.FullName -StringsXml $targetStrings + + # 4. Handle LocalizedLists (copy directly to DistFiles/Templates instead of via l10ns) + $listName = "LocalizedLists-$locale.xml" + $sourceList = Join-Path $LocalizationsDir $listName + if (Test-Path $sourceList) { + $destList = Join-Path $DistFilesDir "Templates\$listName" + Copy-Item $sourceList -Destination $destList -Force + Write-Host " Copied $listName to DistFiles/Templates" + } } Write-Host "Localization setup complete. You can now run the app and select languages." diff --git a/scripts/GenerateAssemblyInfo/reflect_attributes.ps1 b/scripts/GenerateAssemblyInfo/reflect_attributes.ps1 index d91ad43f23..b17fbde37c 100644 --- a/scripts/GenerateAssemblyInfo/reflect_attributes.ps1 +++ b/scripts/GenerateAssemblyInfo/reflect_attributes.ps1 @@ -1,25 +1,25 @@ <# .SYNOPSIS - Verifies that assemblies contain the expected CommonAssemblyInfo attributes. + Verifies that assemblies contain the expected CommonAssemblyInfo attributes. .DESCRIPTION - Loads assemblies via Reflection and checks for: - - AssemblyCompany ("SIL International") - - AssemblyProduct ("FieldWorks") - - AssemblyCopyright ("Copyright © 2002-2025 SIL International") + Loads assemblies via Reflection and checks for: + - AssemblyCompany ("SIL International") + - AssemblyProduct ("FieldWorks") + - AssemblyCopyright ("Copyright © 2002-2025 SIL International") .PARAMETER Assemblies - List of assembly paths to inspect. + List of assembly paths to inspect. .PARAMETER Output - Path to write the validation log. + Path to write the validation log. #> param( - [Parameter(Mandatory=$true)] - [string[]]$Assemblies, + [Parameter(Mandatory=$true)] + [string[]]$Assemblies, - [Parameter(Mandatory=$false)] - [string]$Output + [Parameter(Mandatory=$false)] + [string]$Output ) $ErrorActionPreference = "Stop" @@ -27,67 +27,67 @@ $failures = 0 $results = @() foreach ($asmPath in $Assemblies) { - if (-not (Test-Path $asmPath)) { - $msg = "MISSING: $asmPath" - Write-Error $msg -ErrorAction Continue - $results += $msg - $failures++ - continue - } + if (-not (Test-Path $asmPath)) { + $msg = "MISSING: $asmPath" + Write-Error $msg -ErrorAction Continue + $results += $msg + $failures++ + continue + } - try { - $absPath = Resolve-Path $asmPath - $asm = [System.Reflection.Assembly]::LoadFile($absPath) - $results += "Assembly: $($asm.FullName) ($($asmPath))" + try { + $absPath = Resolve-Path $asmPath + $asm = [System.Reflection.Assembly]::LoadFile($absPath) + $results += "Assembly: $($asm.FullName) ($($asmPath))" - # Check Company - $companyAttr = $asm.GetCustomAttributes([System.Reflection.AssemblyCompanyAttribute], $false) - if ($companyAttr) { - $company = $companyAttr[0].Company - $results += " Company: $company" - if ($company -notmatch "SIL International") { - $results += " ERROR: Unexpected Company '$company'" - $failures++ - } - } else { - $results += " ERROR: Missing AssemblyCompanyAttribute" - $failures++ - } + # Check Company + $companyAttr = $asm.GetCustomAttributes([System.Reflection.AssemblyCompanyAttribute], $false) + if ($companyAttr) { + $company = $companyAttr[0].Company + $results += " Company: $company" + if ($company -notmatch "SIL International") { + $results += " ERROR: Unexpected Company '$company'" + $failures++ + } + } else { + $results += " ERROR: Missing AssemblyCompanyAttribute" + $failures++ + } - # Check Product - $productAttr = $asm.GetCustomAttributes([System.Reflection.AssemblyProductAttribute], $false) - if ($productAttr) { - $product = $productAttr[0].Product - $results += " Product: $product" - if ($product -notmatch "FieldWorks") { - $results += " ERROR: Unexpected Product '$product'" - $failures++ - } - } else { - $results += " ERROR: Missing AssemblyProductAttribute" - $failures++ - } + # Check Product + $productAttr = $asm.GetCustomAttributes([System.Reflection.AssemblyProductAttribute], $false) + if ($productAttr) { + $product = $productAttr[0].Product + $results += " Product: $product" + if ($product -notmatch "FieldWorks") { + $results += " ERROR: Unexpected Product '$product'" + $failures++ + } + } else { + $results += " ERROR: Missing AssemblyProductAttribute" + $failures++ + } - if (-not $failures) { - $results += "PASS: $($asmPath)" - } + if (-not $failures) { + $results += "PASS: $($asmPath)" + } - } catch { - $msg = "EXCEPTION: $($asmPath) - $_" - Write-Error $msg -ErrorAction Continue - $results += $msg - $failures++ - } + } catch { + $msg = "EXCEPTION: $($asmPath) - $_" + Write-Error $msg -ErrorAction Continue + $results += $msg + $failures++ + } } if ($Output) { - $parent = Split-Path $Output - if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } - $results | Out-File -FilePath $Output -Encoding utf8 + $parent = Split-Path $Output + if (-not (Test-Path $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } + $results | Out-File -FilePath $Output -Encoding utf8 } if ($failures -gt 0) { - exit 1 + exit 1 } else { - exit 0 + exit 0 } diff --git a/scripts/GenerateAssemblyInfo/verify-performance-and-tests.ps1 b/scripts/GenerateAssemblyInfo/verify-performance-and-tests.ps1 index 38233128e3..7c8d31b047 100644 --- a/scripts/GenerateAssemblyInfo/verify-performance-and-tests.ps1 +++ b/scripts/GenerateAssemblyInfo/verify-performance-and-tests.ps1 @@ -1,17 +1,17 @@ <# .SYNOPSIS - Runs performance metrics and regression tests for GenerateAssemblyInfo validation. + Runs performance metrics and regression tests for GenerateAssemblyInfo validation. .DESCRIPTION - Executes: - 1. Release build timing (T021) - 2. Regression tests (T020) + Executes: + 1. Release build timing (T021) + 2. Regression tests (T020) - Outputs artifacts to Output/GenerateAssemblyInfo/ + Outputs artifacts to Output/GenerateAssemblyInfo/ #> param( - [string]$RepoRoot = $PSScriptRoot\..\.., - [string]$Output = "$PSScriptRoot\..\..\Output\GenerateAssemblyInfo" + [string]$RepoRoot = $PSScriptRoot\..\.., + [string]$Output = "$PSScriptRoot\..\..\Output\GenerateAssemblyInfo" ) $ErrorActionPreference = "Stop" @@ -28,7 +28,7 @@ $timer = [System.Diagnostics.Stopwatch]::StartNew() # Let's do a standard build. & msbuild "$RepoRoot\FieldWorks.sln" /m /p:Configuration=Release /v:m if ($LASTEXITCODE -ne 0) { - Write-Error "Build failed!" + Write-Error "Build failed!" } $timer.Stop() @@ -36,9 +36,9 @@ $buildTime = $timer.Elapsed.TotalSeconds Write-Host "Build completed in $buildTime seconds." -ForegroundColor Green $metrics = @{ - timestamp = (Get-Date).ToString("u") - build_duration_seconds = $buildTime - configuration = "Release" + timestamp = (Get-Date).ToString("u") + build_duration_seconds = $buildTime + configuration = "Release" } $metrics | ConvertTo-Json | Out-File "$OutputDir\build-metrics.json" -Encoding utf8 @@ -50,9 +50,9 @@ $testDir = New-Item -ItemType Directory -Path "$OutputDir\tests" -Force # Note: This might take a long time. & msbuild "$RepoRoot\FieldWorks.sln" /t:Test /p:Configuration=Debug /p:ContinueOnError=true /p:TestResultsDir="$testDir" if ($LASTEXITCODE -ne 0) { - Write-Warning "Some tests failed. Check $testDir" + Write-Warning "Some tests failed. Check $testDir" } else { - Write-Host "All tests passed." -ForegroundColor Green + Write-Host "All tests passed." -ForegroundColor Green } Write-Host "Verification complete. Artifacts in $OutputDir" -ForegroundColor Cyan diff --git a/scripts/Installer/Invoke-InstallerWithLog.ps1 b/scripts/Installer/Invoke-InstallerWithLog.ps1 index 4eab2ff431..538344d2be 100644 --- a/scripts/Installer/Invoke-InstallerWithLog.ps1 +++ b/scripts/Installer/Invoke-InstallerWithLog.ps1 @@ -1,42 +1,42 @@ [CmdletBinding()] param( - [ValidateSet('Bundle', 'Msi')] - [string]$InstallerType = 'Bundle', - [string]$InstallerPath + [ValidateSet('Bundle', 'Msi')] + [string]$InstallerType = 'Bundle', + [string]$InstallerPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Get-InstallerPath { - param( - [string]$SearchRoot, - [string]$Type - ) - - if ($Type -eq 'Bundle') { - $candidate = Get-ChildItem -Path $SearchRoot -Filter '*Bundle*.exe' -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if ($null -ne $candidate) { - return $candidate.FullName - } - return $null - } - - $candidate = Get-ChildItem -Path $SearchRoot -Filter '*.msi' -File -Recurse | Sort-Object LastWriteTime -Descending | Select-Object -First 1 - if ($null -ne $candidate) { - return $candidate.FullName - } - - return $null + param( + [string]$SearchRoot, + [string]$Type + ) + + if ($Type -eq 'Bundle') { + $candidate = Get-ChildItem -Path $SearchRoot -Filter '*Bundle*.exe' -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($null -ne $candidate) { + return $candidate.FullName + } + return $null + } + + $candidate = Get-ChildItem -Path $SearchRoot -Filter '*.msi' -File -Recurse | Sort-Object LastWriteTime -Descending | Select-Object -First 1 + if ($null -ne $candidate) { + return $candidate.FullName + } + + return $null } $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path if ([string]::IsNullOrWhiteSpace($InstallerPath)) { - $InstallerPath = Get-InstallerPath -SearchRoot $scriptDir -Type $InstallerType + $InstallerPath = Get-InstallerPath -SearchRoot $scriptDir -Type $InstallerType } if ([string]::IsNullOrWhiteSpace($InstallerPath) -or -not (Test-Path -LiteralPath $InstallerPath)) { - throw "Installer not found. Provide -InstallerPath or place the installer next to this script." + throw "Installer not found. Provide -InstallerPath or place the installer next to this script." } $installerDir = Split-Path -Parent $InstallerPath @@ -47,17 +47,17 @@ Write-Output "Installer: $InstallerPath" Write-Output "Log: $logPath" if ($InstallerType -eq 'Bundle') { - & $InstallerPath '/log' $logPath - $exitCode = $LASTEXITCODE + & $InstallerPath '/log' $logPath + $exitCode = $LASTEXITCODE } else { - $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $InstallerPath, '/l*v', $logPath) -Wait -PassThru - $exitCode = $process.ExitCode + $process = Start-Process -FilePath 'msiexec.exe' -ArgumentList @('/i', $InstallerPath, '/l*v', $logPath) -Wait -PassThru + $exitCode = $process.ExitCode } if ($exitCode -ne 0) { - Write-Error "Installer returned exit code $exitCode. See log: $logPath" - exit $exitCode + Write-Error "Installer returned exit code $exitCode. See log: $logPath" + exit $exitCode } Write-Output "[OK] Installer completed. Log saved to $logPath" diff --git a/scripts/Rename-WorktreeToBranch.ps1 b/scripts/Rename-WorktreeToBranch.ps1 index e767f380a5..763c2be48a 100644 --- a/scripts/Rename-WorktreeToBranch.ps1 +++ b/scripts/Rename-WorktreeToBranch.ps1 @@ -1,13 +1,13 @@ <# .SYNOPSIS - Rename (move) the current git worktree folder to match the current branch name. + Rename (move) the current git worktree folder to match the current branch name. .DESCRIPTION - Uses 'git worktree move' so git keeps tracking the worktree. + Uses 'git worktree move' so git keeps tracking the worktree. - The target path is: - ../.worktrees/ + The target path is: + ../.worktrees/ - If run from inside a worktree, the main repo root is used for base paths. + If run from inside a worktree, the main repo root is used for base paths. #> [CmdletBinding()] diff --git a/scripts/Setup-WorktreeColor.ps1 b/scripts/Setup-WorktreeColor.ps1 index 7be118b529..0ec48c4acf 100644 --- a/scripts/Setup-WorktreeColor.ps1 +++ b/scripts/Setup-WorktreeColor.ps1 @@ -1,16 +1,16 @@ <# .SYNOPSIS - Sets a unique window color for the current VS Code workspace/worktree. + Sets a unique window color for the current VS Code workspace/worktree. .DESCRIPTION Chooses a color from a fixed deterministic palette using the lowest free slot. - Uses the VS Code workspace (.code-workspace) paradigm for worktree overrides: - - Base workspace configuration is embedded in this script + Uses the VS Code workspace (.code-workspace) paradigm for worktree overrides: + - Base workspace configuration is embedded in this script - Writes a worktree-local workspace file: .code-workspace (git-ignored) - - If in a Git Worktree: Applies colors to Title Bar, Status Bar, and Activity Bar. - - If in Main Repo: Removes these color customizations. - Intended to be run as a "folderOpen" task in VS Code. + - If in a Git Worktree: Applies colors to Title Bar, Status Bar, and Activity Bar. + - If in Main Repo: Removes these color customizations. + Intended to be run as a "folderOpen" task in VS Code. #> param( diff --git a/scripts/Worktree-CreateFromBranch.ps1 b/scripts/Worktree-CreateFromBranch.ps1 index 185ab047af..8fda8eee7c 100644 --- a/scripts/Worktree-CreateFromBranch.ps1 +++ b/scripts/Worktree-CreateFromBranch.ps1 @@ -1,13 +1,13 @@ <# .SYNOPSIS - Create (or open) a git worktree for a branch and open it in a new VS Code window. + Create (or open) a git worktree for a branch and open it in a new VS Code window. .DESCRIPTION - - Worktrees are placed under ../.worktrees/ - - If invoked from within an existing worktree, path names are based on the main repo root. - - If the branch already has a worktree (or the folder already exists as a worktree), it opens that. - - If a VS Code window already appears to be open for that worktree, it attempts to bring it to the foreground. + - Worktrees are placed under ../.worktrees/ + - If invoked from within an existing worktree, path names are based on the main repo root. + - If the branch already has a worktree (or the folder already exists as a worktree), it opens that. + - If a VS Code window already appears to be open for that worktree, it attempts to bring it to the foreground. - Colorization and workspace generation is delegated to scripts/Setup-WorktreeColor.ps1. + Colorization and workspace generation is delegated to scripts/Setup-WorktreeColor.ps1. #> [CmdletBinding()] diff --git a/scripts/git-utilities.ps1 b/scripts/git-utilities.ps1 index 8fe410c504..04da0cb0ca 100644 --- a/scripts/git-utilities.ps1 +++ b/scripts/git-utilities.ps1 @@ -9,35 +9,35 @@ get out of sync so callers stay tidy. function Invoke-GitSafe { [CmdletBinding()] param( - [Parameter(Mandatory=$true)][string[]]$Arguments, - [switch]$Quiet, - [switch]$CaptureOutput + [Parameter(Mandatory=$true)][string[]]$Arguments, + [switch]$Quiet, + [switch]$CaptureOutput ) $previousEap = $ErrorActionPreference $output = @() try { - $ErrorActionPreference = 'Continue' - $output = @( & git @Arguments 2>&1 ) - $exitCode = $LASTEXITCODE + $ErrorActionPreference = 'Continue' + $output = @( & git @Arguments 2>&1 ) + $exitCode = $LASTEXITCODE } finally { - $ErrorActionPreference = $previousEap + $ErrorActionPreference = $previousEap } if ($exitCode -ne 0) { - $message = "git $($Arguments -join ' ') failed with exit code $exitCode" - if ($output) { - $message += "`n$output" - } - throw $message + $message = "git $($Arguments -join ' ') failed with exit code $exitCode" + if ($output) { + $message += "`n$output" + } + throw $message } if ($CaptureOutput) { - return $output + return $output } if (-not $Quiet -and $output) { - return $output + return $output } } @@ -50,35 +50,35 @@ function Get-GitWorktrees { $current = @{} foreach ($line in $lines) { - if ([string]::IsNullOrWhiteSpace($line)) { continue } - $parts = $line.Split(' ',2) - $key = $parts[0] - $value = $parts[1] - - switch ($key) { - 'worktree' { - if ($current.Keys.Count -gt 0) { - $result += [PSCustomObject]$current - $current = @{} - } - $rawPath = $value.Trim() - $fullPath = [System.IO.Path]::GetFullPath($rawPath) - $current = @{ RawPath = $rawPath; FullPath = $fullPath; Flags = @() } - } - 'HEAD' { $current.Head = $value.Trim() } - 'branch' { $current.Branch = $value.Trim() } - 'detached' { $current.Detached = $true } - 'locked' { $current.Flags += 'locked' } - 'prunable' { $current.Flags += 'prunable' } - default { - # Preserve unknown keys for troubleshooting - $current[$key] = $value.Trim() - } - } + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $parts = $line.Split(' ',2) + $key = $parts[0] + $value = $parts[1] + + switch ($key) { + 'worktree' { + if ($current.Keys.Count -gt 0) { + $result += [PSCustomObject]$current + $current = @{} + } + $rawPath = $value.Trim() + $fullPath = [System.IO.Path]::GetFullPath($rawPath) + $current = @{ RawPath = $rawPath; FullPath = $fullPath; Flags = @() } + } + 'HEAD' { $current.Head = $value.Trim() } + 'branch' { $current.Branch = $value.Trim() } + 'detached' { $current.Detached = $true } + 'locked' { $current.Flags += 'locked' } + 'prunable' { $current.Flags += 'prunable' } + default { + # Preserve unknown keys for troubleshooting + $current[$key] = $value.Trim() + } + } } if ($current.Keys.Count -gt 0) { - $result += [PSCustomObject]$current + $result += [PSCustomObject]$current } return $result @@ -87,7 +87,7 @@ function Get-GitWorktrees { function Get-GitWorktreeForBranch { [CmdletBinding()] param( - [Parameter(Mandatory=$true)][string]$Branch + [Parameter(Mandatory=$true)][string]$Branch ) $branchRef = if ($Branch.StartsWith('refs/')) { $Branch } else { "refs/heads/$Branch" } @@ -97,60 +97,60 @@ function Get-GitWorktreeForBranch { function Remove-GitWorktreePath { [CmdletBinding()] param( - [Parameter(Mandatory=$true)][string]$WorktreePath + [Parameter(Mandatory=$true)][string]$WorktreePath ) try { - Invoke-GitSafe @('worktree','remove','--force','--',$WorktreePath) -Quiet - return + Invoke-GitSafe @('worktree','remove','--force','--',$WorktreePath) -Quiet + return } catch { - $message = $_ - if (Detach-GitWorktreeMetadata -WorktreePath $WorktreePath -VerboseMode ($PSBoundParameters['Verbose'] -or $VerbosePreference -ne 'SilentlyContinue')) { - try { - Invoke-GitSafe @('worktree','prune','--expire=now') -Quiet - } catch {} - Write-Warning "git worktree remove failed for $WorktreePath; completed metadata-only detach instead. Original error: $message" - return - } - - try { - Invoke-GitSafe @('worktree','prune','--expire=now') -Quiet - } catch {} - throw + $message = $_ + if (Detach-GitWorktreeMetadata -WorktreePath $WorktreePath -VerboseMode ($PSBoundParameters['Verbose'] -or $VerbosePreference -ne 'SilentlyContinue')) { + try { + Invoke-GitSafe @('worktree','prune','--expire=now') -Quiet + } catch {} + Write-Warning "git worktree remove failed for $WorktreePath; completed metadata-only detach instead. Original error: $message" + return + } + + try { + Invoke-GitSafe @('worktree','prune','--expire=now') -Quiet + } catch {} + throw } } function Detach-GitWorktreeMetadata { param( - [Parameter(Mandatory=$true)][string]$WorktreePath, - [bool]$VerboseMode = $false + [Parameter(Mandatory=$true)][string]$WorktreePath, + [bool]$VerboseMode = $false ) $gitPointer = Join-Path $WorktreePath '.git' if (-not (Test-Path -LiteralPath $gitPointer)) { return $false } try { - $content = Get-Content -LiteralPath $gitPointer -Raw -ErrorAction Stop + $content = Get-Content -LiteralPath $gitPointer -Raw -ErrorAction Stop } catch { - return $false + return $false } if ($content -notmatch 'gitdir:\s*(.+)') { return $false } $dirPath = $matches[1].Trim() $resolvedDir = $null try { - if (Test-Path -LiteralPath $dirPath) { - $resolvedDir = (Resolve-Path -LiteralPath $dirPath).Path - } else { - $resolvedDir = (Resolve-Path -LiteralPath ([System.IO.Path]::Combine($WorktreePath,$dirPath)) -ErrorAction SilentlyContinue).Path - } + if (Test-Path -LiteralPath $dirPath) { + $resolvedDir = (Resolve-Path -LiteralPath $dirPath).Path + } else { + $resolvedDir = (Resolve-Path -LiteralPath ([System.IO.Path]::Combine($WorktreePath,$dirPath)) -ErrorAction SilentlyContinue).Path + } } catch { - $resolvedDir = $null + $resolvedDir = $null } if ($resolvedDir -and (Test-Path -LiteralPath $resolvedDir)) { - if ($VerboseMode) { Write-Warning "Falling back to metadata-only detach for $WorktreePath (removing $resolvedDir)" } - Remove-Item -Recurse -Force $resolvedDir -ErrorAction SilentlyContinue + if ($VerboseMode) { Write-Warning "Falling back to metadata-only detach for $WorktreePath (removing $resolvedDir)" } + Remove-Item -Recurse -Force $resolvedDir -ErrorAction SilentlyContinue } Remove-Item -LiteralPath $gitPointer -Force -ErrorAction SilentlyContinue diff --git a/scripts/openspec/Propose-RefFixes.ps1 b/scripts/openspec/Propose-RefFixes.ps1 index 625be0ff9e..becf5f30ea 100644 --- a/scripts/openspec/Propose-RefFixes.ps1 +++ b/scripts/openspec/Propose-RefFixes.ps1 @@ -1,560 +1,560 @@ <# .SYNOPSIS - Propose fixes for broken or missing OpenSpec references. + Propose fixes for broken or missing OpenSpec references. .DESCRIPTION - Scans specs and AGENTS.md for cross-references, reports issues, and can - apply safe fixes (anchor corrections, missing ref blocks) with --Apply. + Scans specs and AGENTS.md for cross-references, reports issues, and can + apply safe fixes (anchor corrections, missing ref blocks) with --Apply. #> [CmdletBinding()] param( - [string]$RepoRoot, - [switch]$Apply + [string]$RepoRoot, + [switch]$Apply ) $ErrorActionPreference = 'Stop' if (-not $RepoRoot) { - $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') + $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') } function Get-RepoRelativePath { - param([string]$Path) + param([string]$Path) - $full = (Resolve-Path $Path).Path - $root = (Resolve-Path $RepoRoot).Path + $full = (Resolve-Path $Path).Path + $root = (Resolve-Path $RepoRoot).Path - if ($full.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { - $rel = $full.Substring($root.Length).TrimStart('\', '/') - return $rel.Replace('\\', '/') - } + if ($full.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($root.Length).TrimStart('\', '/') + return $rel.Replace('\\', '/') + } - return $full.Replace('\\', '/') + return $full.Replace('\\', '/') } function Get-AnchorSlug { - param([string]$Heading) + param([string]$Heading) - $clean = $Heading.Trim() - $clean = $clean -replace '[^A-Za-z0-9 \-]', '' - $clean = $clean.ToLowerInvariant() - return ($clean -replace '\s', '-') + $clean = $Heading.Trim() + $clean = $clean -replace '[^A-Za-z0-9 \-]', '' + $clean = $clean.ToLowerInvariant() + return ($clean -replace '\s', '-') } function Get-FrontMatterAnchors { - param([string[]]$Lines) - - $anchors = @() - if ($Lines.Count -lt 2) { - return $anchors - } - - if ($Lines[0].Trim() -ne '---') { - return $anchors - } - - $endIndex = -1 - for ($i = 1; $i -lt $Lines.Count; $i++) { - if ($Lines[$i].Trim() -eq '---') { - $endIndex = $i - break - } - } - - if ($endIndex -lt 0) { - return $anchors - } - - $inAnchors = $false - for ($i = 1; $i -lt $endIndex; $i++) { - $line = $Lines[$i] - if ($line -match '^\s*anchors\s*:\s*$') { - $inAnchors = $true - continue - } - - if ($inAnchors) { - if ($line -match '^\s*-\s*(\S+)\s*$') { - $anchors += $matches[1] - continue - } - - if ($line -match '^\s*\S') { - $inAnchors = $false - } - } - } - - return $anchors + param([string[]]$Lines) + + $anchors = @() + if ($Lines.Count -lt 2) { + return $anchors + } + + if ($Lines[0].Trim() -ne '---') { + return $anchors + } + + $endIndex = -1 + for ($i = 1; $i -lt $Lines.Count; $i++) { + if ($Lines[$i].Trim() -eq '---') { + $endIndex = $i + break + } + } + + if ($endIndex -lt 0) { + return $anchors + } + + $inAnchors = $false + for ($i = 1; $i -lt $endIndex; $i++) { + $line = $Lines[$i] + if ($line -match '^\s*anchors\s*:\s*$') { + $inAnchors = $true + continue + } + + if ($inAnchors) { + if ($line -match '^\s*-\s*(\S+)\s*$') { + $anchors += $matches[1] + continue + } + + if ($line -match '^\s*\S') { + $inAnchors = $false + } + } + } + + return $anchors } function Get-FileAnchors { - param([string]$Path) - - $lines = Get-Content -Path $Path -Encoding UTF8 - $anchors = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - - foreach ($anchor in (Get-FrontMatterAnchors -Lines $lines)) { - $null = $anchors.Add($anchor) - } - - $inFence = $false - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - if ($line -match '^(#+)\s+(.+)$') { - $heading = $matches[2] - $slug = Get-AnchorSlug -Heading $heading - if ($slug) { - $null = $anchors.Add($slug) - } - } - } - - return $anchors + param([string]$Path) + + $lines = Get-Content -Path $Path -Encoding UTF8 + $anchors = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($anchor in (Get-FrontMatterAnchors -Lines $lines)) { + $null = $anchors.Add($anchor) + } + + $inFence = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + if ($line -match '^(#+)\s+(.+)$') { + $heading = $matches[2] + $slug = Get-AnchorSlug -Heading $heading + if ($slug) { + $null = $anchors.Add($slug) + } + } + } + + return $anchors } function Resolve-LinkTarget { - param( - [string]$SourcePath, - [string]$Href - ) + param( + [string]$SourcePath, + [string]$Href + ) - if ($Href -match '^\w+://') { - return $null - } + if ($Href -match '^\w+://') { + return $null + } - if ($Href -match '^#') { - return $null - } + if ($Href -match '^#') { + return $null + } - $decoded = [System.Uri]::UnescapeDataString($Href) - $parts = $decoded.Split('#', 2) + $decoded = [System.Uri]::UnescapeDataString($Href) + $parts = $decoded.Split('#', 2) - if ($parts.Count -lt 2) { - return $null - } + if ($parts.Count -lt 2) { + return $null + } - $pathPart = $parts[0].Trim() - $anchor = $parts[1].Trim() + $pathPart = $parts[0].Trim() + $anchor = $parts[1].Trim() - if (-not $pathPart -or -not $anchor) { - return $null - } + if (-not $pathPart -or -not $anchor) { + return $null + } - $fullPath = [System.IO.Path]::GetFullPath((Join-Path (Split-Path $SourcePath) $pathPart)) + $fullPath = [System.IO.Path]::GetFullPath((Join-Path (Split-Path $SourcePath) $pathPart)) - return [pscustomobject]@{ - FullPath = $fullPath - RelPath = Get-RepoRelativePath -Path $fullPath - Anchor = $anchor - } + return [pscustomobject]@{ + FullPath = $fullPath + RelPath = Get-RepoRelativePath -Path $fullPath + Anchor = $anchor + } } function Get-LinkMatches { - param([string]$Line) + param([string]$Line) - $regex = '(?[^\]]+)\]\((?[^)]+)\)' - return [regex]::Matches($Line, $regex) + $regex = '(?[^\]]+)\]\((?[^)]+)\)' + return [regex]::Matches($Line, $regex) } function Get-SectionRanges { - param([string[]]$Lines) - - $sections = @() - $inFence = $false - for ($i = 0; $i -lt $Lines.Count; $i++) { - $line = $Lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - if ($line -match '^(#{2,3})\s+(.+)$') { - $level = $matches[1].Length - $title = $matches[2].Trim() - $slug = Get-AnchorSlug -Heading $title - $sections += [pscustomobject]@{ - Index = $i - Level = $level - Title = $title - Anchor = $slug - } - } - } - - return $sections + param([string[]]$Lines) + + $sections = @() + $inFence = $false + for ($i = 0; $i -lt $Lines.Count; $i++) { + $line = $Lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + if ($line -match '^(#{2,3})\s+(.+)$') { + $level = $matches[1].Length + $title = $matches[2].Trim() + $slug = Get-AnchorSlug -Heading $title + $sections += [pscustomobject]@{ + Index = $i + Level = $level + Title = $title + Anchor = $slug + } + } + } + + return $sections } function Find-SectionRange { - param( - [string[]]$Lines, - [string]$Anchor - ) - - $sections = Get-SectionRanges -Lines $Lines - $match = $sections | Where-Object { $_.Anchor -eq $Anchor } | Select-Object -First 1 - if (-not $match) { - return $null - } - - $endIndex = $Lines.Count - foreach ($section in $sections) { - if ($section.Index -le $match.Index) { - continue - } - if ($section.Level -le $match.Level) { - $endIndex = $section.Index - break - } - } - - return [pscustomobject]@{ - Start = $match.Index - End = $endIndex - Title = $match.Title - } + param( + [string[]]$Lines, + [string]$Anchor + ) + + $sections = Get-SectionRanges -Lines $Lines + $match = $sections | Where-Object { $_.Anchor -eq $Anchor } | Select-Object -First 1 + if (-not $match) { + return $null + } + + $endIndex = $Lines.Count + foreach ($section in $sections) { + if ($section.Index -le $match.Index) { + continue + } + if ($section.Level -le $match.Level) { + $endIndex = $section.Index + break + } + } + + return [pscustomobject]@{ + Start = $match.Index + End = $endIndex + Title = $match.Title + } } function Get-ClosestAnchor { - param( - [string]$Anchor, - [System.Collections.Generic.HashSet[string]]$Anchors - ) - - if ($Anchors.Contains($Anchor)) { - return $Anchor - } - - $normalized = ($Anchor -replace '-', '').ToLowerInvariant() - foreach ($candidate in $Anchors) { - if (($candidate -replace '-', '').ToLowerInvariant() -eq $normalized) { - return $candidate - } - } - - return $null + param( + [string]$Anchor, + [System.Collections.Generic.HashSet[string]]$Anchors + ) + + if ($Anchors.Contains($Anchor)) { + return $Anchor + } + + $normalized = ($Anchor -replace '-', '').ToLowerInvariant() + foreach ($candidate in $Anchors) { + if (($candidate -replace '-', '').ToLowerInvariant() -eq $normalized) { + return $candidate + } + } + + return $null } function Get-RelativeLink { - param( - [string]$FromFile, - [string]$ToFile - ) - - $fromDir = Split-Path -Path $FromFile - $fromUri = New-Object System.Uri(($fromDir + [System.IO.Path]::DirectorySeparatorChar)) - $toUri = New-Object System.Uri($ToFile) - $relative = $fromUri.MakeRelativeUri($toUri).ToString() - return [System.Uri]::UnescapeDataString($relative).Replace('\\', '/') + param( + [string]$FromFile, + [string]$ToFile + ) + + $fromDir = Split-Path -Path $FromFile + $fromUri = New-Object System.Uri(($fromDir + [System.IO.Path]::DirectorySeparatorChar)) + $toUri = New-Object System.Uri($ToFile) + $relative = $fromUri.MakeRelativeUri($toUri).ToString() + return [System.Uri]::UnescapeDataString($relative).Replace('\\', '/') } function Insert-ReferenceBlock { - param( - [string]$Path, - [string]$SectionAnchor, - [string]$HeadingText, - [string]$LinkText, - [string]$LinkHref, - [string]$BlockHeading - ) - - $lines = Get-Content -Path $Path -Encoding UTF8 - $range = Find-SectionRange -Lines $lines -Anchor $SectionAnchor - if (-not $range) { - return $false - } - - $blockIndex = -1 - $insertIndex = $range.End - for ($i = $range.Start + 1; $i -lt $range.End; $i++) { - if ($lines[$i] -match ('^#{3,4}\s+' + [regex]::Escape($BlockHeading) + '\s*$')) { - $blockIndex = $i - $insertIndex = $i + 1 - for ($j = $i + 1; $j -lt $range.End; $j++) { - if ($lines[$j] -match '^\s*-\s+') { - $insertIndex = $j + 1 - continue - } - if ($lines[$j] -match '^#{2,4}\s+') { - break - } - if ($lines[$j].Trim() -eq '') { - $insertIndex = $j + 1 - break - } - } - break - } - } - - $bullet = "- [$LinkText]($LinkHref) - $HeadingText" - $existing = $lines | Where-Object { $_ -eq $bullet } - if ($existing) { - return $false - } - - $newLines = New-Object System.Collections.Generic.List[string] - for ($i = 0; $i -lt $lines.Count; $i++) { - if ($i -eq $insertIndex) { - if ($blockIndex -lt 0) { - $newLines.Add('') - $newLines.Add("### $BlockHeading") - $newLines.Add('') - } - $newLines.Add($bullet) - } - $newLines.Add($lines[$i]) - } - - Set-Content -Path $Path -Value $newLines -Encoding UTF8 - return $true + param( + [string]$Path, + [string]$SectionAnchor, + [string]$HeadingText, + [string]$LinkText, + [string]$LinkHref, + [string]$BlockHeading + ) + + $lines = Get-Content -Path $Path -Encoding UTF8 + $range = Find-SectionRange -Lines $lines -Anchor $SectionAnchor + if (-not $range) { + return $false + } + + $blockIndex = -1 + $insertIndex = $range.End + for ($i = $range.Start + 1; $i -lt $range.End; $i++) { + if ($lines[$i] -match ('^#{3,4}\s+' + [regex]::Escape($BlockHeading) + '\s*$')) { + $blockIndex = $i + $insertIndex = $i + 1 + for ($j = $i + 1; $j -lt $range.End; $j++) { + if ($lines[$j] -match '^\s*-\s+') { + $insertIndex = $j + 1 + continue + } + if ($lines[$j] -match '^#{2,4}\s+') { + break + } + if ($lines[$j].Trim() -eq '') { + $insertIndex = $j + 1 + break + } + } + break + } + } + + $bullet = "- [$LinkText]($LinkHref) - $HeadingText" + $existing = $lines | Where-Object { $_ -eq $bullet } + if ($existing) { + return $false + } + + $newLines = New-Object System.Collections.Generic.List[string] + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($i -eq $insertIndex) { + if ($blockIndex -lt 0) { + $newLines.Add('') + $newLines.Add("### $BlockHeading") + $newLines.Add('') + } + $newLines.Add($bullet) + } + $newLines.Add($lines[$i]) + } + + Set-Content -Path $Path -Value $newLines -Encoding UTF8 + return $true } function Get-SpecForwardRefs { - param([string]$SpecPath) - - $lines = Get-Content -Path $SpecPath -Encoding UTF8 - $currentSectionAnchor = '' - $currentSectionTitle = '' - $refs = @() - - $inFence = $false - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - if ($line -match '^(#+)\s+(.+)$') { - $headingText = $matches[2].Trim() - if ($headingText -notin @('References', 'Referenced By')) { - $currentSectionTitle = $headingText - $currentSectionAnchor = Get-AnchorSlug -Heading $headingText - } - } - - foreach ($match in (Get-LinkMatches -Line $line)) { - $href = $match.Groups['href'].Value - if ($href -notmatch 'AGENTS\.md#') { - continue - } - - $target = Resolve-LinkTarget -SourcePath $SpecPath -Href $href - if (-not $target) { - continue - } - - $refs += [pscustomobject]@{ - SpecPath = $SpecPath - SpecRel = Get-RepoRelativePath -Path $SpecPath - SpecAnchor = $currentSectionAnchor - SpecTitle = $currentSectionTitle - LineNumber = $i + 1 - AgentRel = $target.RelPath - AgentFull = $target.FullPath - AgentAnchor = $target.Anchor - } - } - } - - return $refs + param([string]$SpecPath) + + $lines = Get-Content -Path $SpecPath -Encoding UTF8 + $currentSectionAnchor = '' + $currentSectionTitle = '' + $refs = @() + + $inFence = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + if ($line -match '^(#+)\s+(.+)$') { + $headingText = $matches[2].Trim() + if ($headingText -notin @('References', 'Referenced By')) { + $currentSectionTitle = $headingText + $currentSectionAnchor = Get-AnchorSlug -Heading $headingText + } + } + + foreach ($match in (Get-LinkMatches -Line $line)) { + $href = $match.Groups['href'].Value + if ($href -notmatch 'AGENTS\.md#') { + continue + } + + $target = Resolve-LinkTarget -SourcePath $SpecPath -Href $href + if (-not $target) { + continue + } + + $refs += [pscustomobject]@{ + SpecPath = $SpecPath + SpecRel = Get-RepoRelativePath -Path $SpecPath + SpecAnchor = $currentSectionAnchor + SpecTitle = $currentSectionTitle + LineNumber = $i + 1 + AgentRel = $target.RelPath + AgentFull = $target.FullPath + AgentAnchor = $target.Anchor + } + } + } + + return $refs } function Get-AgentsBackRefs { - param([string]$AgentPath) - - $lines = Get-Content -Path $AgentPath -Encoding UTF8 - $currentSectionAnchor = '' - $currentSectionTitle = '' - $inReferencedBy = $false - $refs = @() - - $inFence = $false - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - - if ($line -match '^(#+)\s+(.+)$') { - $level = $matches[1].Length - $headingText = $matches[2].Trim() - - $inReferencedBy = $headingText -eq 'Referenced By' - - if ($headingText -notin @('Referenced By', 'References')) { - if ($level -le 2) { - $currentSectionTitle = $headingText - $currentSectionAnchor = Get-AnchorSlug -Heading $headingText - } - } - } - - if (-not $inReferencedBy) { - continue - } - - foreach ($match in (Get-LinkMatches -Line $line)) { - $href = $match.Groups['href'].Value - if ($href -notmatch 'openspec/specs/.+\.md#') { - continue - } - - $target = Resolve-LinkTarget -SourcePath $AgentPath -Href $href - if (-not $target) { - continue - } - - $refs += [pscustomobject]@{ - AgentPath = $AgentPath - AgentRel = Get-RepoRelativePath -Path $AgentPath - AgentAnchor = $currentSectionAnchor - AgentTitle = $currentSectionTitle - LineNumber = $i + 1 - SpecRel = $target.RelPath - SpecFull = $target.FullPath - SpecAnchor = $target.Anchor - } - } - } - - return $refs + param([string]$AgentPath) + + $lines = Get-Content -Path $AgentPath -Encoding UTF8 + $currentSectionAnchor = '' + $currentSectionTitle = '' + $inReferencedBy = $false + $refs = @() + + $inFence = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + + if ($line -match '^(#+)\s+(.+)$') { + $level = $matches[1].Length + $headingText = $matches[2].Trim() + + $inReferencedBy = $headingText -eq 'Referenced By' + + if ($headingText -notin @('Referenced By', 'References')) { + if ($level -le 2) { + $currentSectionTitle = $headingText + $currentSectionAnchor = Get-AnchorSlug -Heading $headingText + } + } + } + + if (-not $inReferencedBy) { + continue + } + + foreach ($match in (Get-LinkMatches -Line $line)) { + $href = $match.Groups['href'].Value + if ($href -notmatch 'openspec/specs/.+\.md#') { + continue + } + + $target = Resolve-LinkTarget -SourcePath $AgentPath -Href $href + if (-not $target) { + continue + } + + $refs += [pscustomobject]@{ + AgentPath = $AgentPath + AgentRel = Get-RepoRelativePath -Path $AgentPath + AgentAnchor = $currentSectionAnchor + AgentTitle = $currentSectionTitle + LineNumber = $i + 1 + SpecRel = $target.RelPath + SpecFull = $target.FullPath + SpecAnchor = $target.Anchor + } + } + } + + return $refs } $specRoot = Join-Path $RepoRoot 'openspec\specs' $specFiles = @() if (Test-Path $specRoot) { - $specFiles = Get-ChildItem -Path $specRoot -Recurse -Filter '*.md' -File + $specFiles = Get-ChildItem -Path $specRoot -Recurse -Filter '*.md' -File } $agentFiles = Get-ChildItem -Path $RepoRoot -Recurse -Filter 'AGENTS.md' -File $forwardRefs = @() foreach ($spec in $specFiles) { - $forwardRefs += Get-SpecForwardRefs -SpecPath $spec.FullName + $forwardRefs += Get-SpecForwardRefs -SpecPath $spec.FullName } $backRefs = @() foreach ($agent in $agentFiles) { - $backRefs += Get-AgentsBackRefs -AgentPath $agent.FullName + $backRefs += Get-AgentsBackRefs -AgentPath $agent.FullName } $forwardMap = @{} foreach ($ref in $forwardRefs) { - $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor - $forwardMap[$key] = $ref + $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor + $forwardMap[$key] = $ref } $backMap = @{} foreach ($ref in $backRefs) { - $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor - $backMap[$key] = $ref + $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor + $backMap[$key] = $ref } $fixes = New-Object System.Collections.Generic.List[object] foreach ($ref in $forwardRefs) { - if (-not (Test-Path $ref.AgentFull)) { - $fixes.Add([pscustomobject]@{ - Type = 'MissingTarget' - Message = "Target file not found: {0}" -f $ref.AgentRel - }) - continue - } - - $agentAnchors = Get-FileAnchors -Path $ref.AgentFull - if (-not $agentAnchors.Contains($ref.AgentAnchor)) { - $suggested = Get-ClosestAnchor -Anchor $ref.AgentAnchor -Anchors $agentAnchors - if ($suggested) { - $fixes.Add([pscustomobject]@{ - Type = 'AnchorFix' - Path = $ref.SpecPath - Rel = $ref.SpecRel - Line = $ref.LineNumber - Old = $ref.AgentAnchor - New = $suggested - Message = ("Anchor fix in {0}:{1} -> #" -f $ref.SpecRel, $ref.LineNumber) + $suggested - }) - } - continue - } - - $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor - if (-not $backMap.ContainsKey($key)) { - $fixes.Add([pscustomobject]@{ - Type = 'MissingBackRef' - SpecRel = $ref.SpecRel - SpecFull = $ref.SpecPath - SpecAnchor = $ref.SpecAnchor - SpecTitle = $ref.SpecTitle - AgentRel = $ref.AgentRel - AgentFull = $ref.AgentFull - AgentAnchor = $ref.AgentAnchor - Message = "Missing back-ref in {0}" -f $ref.AgentRel - }) - } + if (-not (Test-Path $ref.AgentFull)) { + $fixes.Add([pscustomobject]@{ + Type = 'MissingTarget' + Message = "Target file not found: {0}" -f $ref.AgentRel + }) + continue + } + + $agentAnchors = Get-FileAnchors -Path $ref.AgentFull + if (-not $agentAnchors.Contains($ref.AgentAnchor)) { + $suggested = Get-ClosestAnchor -Anchor $ref.AgentAnchor -Anchors $agentAnchors + if ($suggested) { + $fixes.Add([pscustomobject]@{ + Type = 'AnchorFix' + Path = $ref.SpecPath + Rel = $ref.SpecRel + Line = $ref.LineNumber + Old = $ref.AgentAnchor + New = $suggested + Message = ("Anchor fix in {0}:{1} -> #" -f $ref.SpecRel, $ref.LineNumber) + $suggested + }) + } + continue + } + + $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor + if (-not $backMap.ContainsKey($key)) { + $fixes.Add([pscustomobject]@{ + Type = 'MissingBackRef' + SpecRel = $ref.SpecRel + SpecFull = $ref.SpecPath + SpecAnchor = $ref.SpecAnchor + SpecTitle = $ref.SpecTitle + AgentRel = $ref.AgentRel + AgentFull = $ref.AgentFull + AgentAnchor = $ref.AgentAnchor + Message = "Missing back-ref in {0}" -f $ref.AgentRel + }) + } } foreach ($ref in $backRefs) { - if (-not (Test-Path $ref.SpecFull)) { - $fixes.Add([pscustomobject]@{ - Type = 'MissingTarget' - Message = "Target file not found: {0}" -f $ref.SpecRel - }) - continue - } - - $specAnchors = Get-FileAnchors -Path $ref.SpecFull - if (-not $specAnchors.Contains($ref.SpecAnchor)) { - $suggested = Get-ClosestAnchor -Anchor $ref.SpecAnchor -Anchors $specAnchors - if ($suggested) { - $fixes.Add([pscustomobject]@{ - Type = 'AnchorFix' - Path = $ref.AgentPath - Rel = $ref.AgentRel - Line = $ref.LineNumber - Old = $ref.SpecAnchor - New = $suggested - Message = ("Anchor fix in {0}:{1} -> #" -f $ref.AgentRel, $ref.LineNumber) + $suggested - }) - } - continue - } - - $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor - if (-not $forwardMap.ContainsKey($key)) { - $fixes.Add([pscustomobject]@{ - Type = 'MissingForwardRef' - SpecRel = $ref.SpecRel - SpecFull = $ref.SpecFull - SpecAnchor = $ref.SpecAnchor - AgentRel = $ref.AgentRel - AgentFull = $ref.AgentPath - AgentAnchor = $ref.AgentAnchor - AgentTitle = $ref.AgentTitle - Message = "Missing forward-ref in {0}" -f $ref.SpecRel - }) - } + if (-not (Test-Path $ref.SpecFull)) { + $fixes.Add([pscustomobject]@{ + Type = 'MissingTarget' + Message = "Target file not found: {0}" -f $ref.SpecRel + }) + continue + } + + $specAnchors = Get-FileAnchors -Path $ref.SpecFull + if (-not $specAnchors.Contains($ref.SpecAnchor)) { + $suggested = Get-ClosestAnchor -Anchor $ref.SpecAnchor -Anchors $specAnchors + if ($suggested) { + $fixes.Add([pscustomobject]@{ + Type = 'AnchorFix' + Path = $ref.AgentPath + Rel = $ref.AgentRel + Line = $ref.LineNumber + Old = $ref.SpecAnchor + New = $suggested + Message = ("Anchor fix in {0}:{1} -> #" -f $ref.AgentRel, $ref.LineNumber) + $suggested + }) + } + continue + } + + $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor + if (-not $forwardMap.ContainsKey($key)) { + $fixes.Add([pscustomobject]@{ + Type = 'MissingForwardRef' + SpecRel = $ref.SpecRel + SpecFull = $ref.SpecFull + SpecAnchor = $ref.SpecAnchor + AgentRel = $ref.AgentRel + AgentFull = $ref.AgentPath + AgentAnchor = $ref.AgentAnchor + AgentTitle = $ref.AgentTitle + Message = "Missing forward-ref in {0}" -f $ref.SpecRel + }) + } } if ($fixes.Count -eq 0) { - Write-Host 'No fixes proposed.' - exit 0 + Write-Host 'No fixes proposed.' + exit 0 } Write-Host ("Proposed fixes for {0} issues:" -f $fixes.Count) @@ -562,66 +562,66 @@ Write-Host '' $index = 1 foreach ($fix in $fixes) { - Write-Host ("FIX {0}: {1}" -f $index, $fix.Type) - Write-Host (" {0}" -f $fix.Message) - if ($fix.Type -eq 'AnchorFix') { - Write-Host (" File: {0}:{1}" -f $fix.Rel, $fix.Line) - Write-Host (" Old: #{0}" -f $fix.Old) - Write-Host (" New: #{0}" -f $fix.New) - } - $index++ - Write-Host '' + Write-Host ("FIX {0}: {1}" -f $index, $fix.Type) + Write-Host (" {0}" -f $fix.Message) + if ($fix.Type -eq 'AnchorFix') { + Write-Host (" File: {0}:{1}" -f $fix.Rel, $fix.Line) + Write-Host (" Old: #{0}" -f $fix.Old) + Write-Host (" New: #{0}" -f $fix.New) + } + $index++ + Write-Host '' } if (-not $Apply) { - exit 0 + exit 0 } $applied = 0 foreach ($fix in $fixes) { - if ($fix.Type -eq 'AnchorFix') { - $lines = Get-Content -Path $fix.Path -Encoding UTF8 - $lineIndex = $fix.Line - 1 - if ($lineIndex -ge 0 -and $lineIndex -lt $lines.Count) { - $oldToken = "#{0}" -f $fix.Old - $newToken = "#{0}" -f $fix.New - if ($lines[$lineIndex] -match [regex]::Escape($oldToken)) { - $lines[$lineIndex] = $lines[$lineIndex] -replace [regex]::Escape($oldToken), $newToken - Set-Content -Path $fix.Path -Value $lines -Encoding UTF8 - $applied++ - } - } - continue - } - - if ($fix.Type -eq 'MissingBackRef') { - $specFull = $fix.SpecFull - $agentFull = $fix.AgentFull - $specPath = Join-Path $RepoRoot $fix.SpecRel - $linkPath = Get-RelativeLink -FromFile $agentFull -ToFile $specPath - $linkHref = "{0}#{1}" -f $linkPath, $fix.SpecAnchor - $heading = $fix.SpecTitle - if (-not $heading) { - $heading = $fix.SpecAnchor - } - if (Insert-ReferenceBlock -Path $agentFull -SectionAnchor $fix.AgentAnchor -HeadingText $heading -LinkText $heading -LinkHref $linkHref -BlockHeading 'Referenced By') { - $applied++ - } - continue - } - - if ($fix.Type -eq 'MissingForwardRef') { - $agentPath = Join-Path $RepoRoot $fix.AgentRel - $linkPath = Get-RelativeLink -FromFile $fix.SpecFull -ToFile $agentPath - $linkHref = "{0}#{1}" -f $linkPath, $fix.AgentAnchor - $heading = $fix.AgentTitle - if (-not $heading) { - $heading = $fix.AgentAnchor - } - if (Insert-ReferenceBlock -Path $fix.SpecFull -SectionAnchor $fix.SpecAnchor -HeadingText $heading -LinkText $heading -LinkHref $linkHref -BlockHeading 'References') { - $applied++ - } - } + if ($fix.Type -eq 'AnchorFix') { + $lines = Get-Content -Path $fix.Path -Encoding UTF8 + $lineIndex = $fix.Line - 1 + if ($lineIndex -ge 0 -and $lineIndex -lt $lines.Count) { + $oldToken = "#{0}" -f $fix.Old + $newToken = "#{0}" -f $fix.New + if ($lines[$lineIndex] -match [regex]::Escape($oldToken)) { + $lines[$lineIndex] = $lines[$lineIndex] -replace [regex]::Escape($oldToken), $newToken + Set-Content -Path $fix.Path -Value $lines -Encoding UTF8 + $applied++ + } + } + continue + } + + if ($fix.Type -eq 'MissingBackRef') { + $specFull = $fix.SpecFull + $agentFull = $fix.AgentFull + $specPath = Join-Path $RepoRoot $fix.SpecRel + $linkPath = Get-RelativeLink -FromFile $agentFull -ToFile $specPath + $linkHref = "{0}#{1}" -f $linkPath, $fix.SpecAnchor + $heading = $fix.SpecTitle + if (-not $heading) { + $heading = $fix.SpecAnchor + } + if (Insert-ReferenceBlock -Path $agentFull -SectionAnchor $fix.AgentAnchor -HeadingText $heading -LinkText $heading -LinkHref $linkHref -BlockHeading 'Referenced By') { + $applied++ + } + continue + } + + if ($fix.Type -eq 'MissingForwardRef') { + $agentPath = Join-Path $RepoRoot $fix.AgentRel + $linkPath = Get-RelativeLink -FromFile $fix.SpecFull -ToFile $agentPath + $linkHref = "{0}#{1}" -f $linkPath, $fix.AgentAnchor + $heading = $fix.AgentTitle + if (-not $heading) { + $heading = $fix.AgentAnchor + } + if (Insert-ReferenceBlock -Path $fix.SpecFull -SectionAnchor $fix.SpecAnchor -HeadingText $heading -LinkText $heading -LinkHref $linkHref -BlockHeading 'References') { + $applied++ + } + } } Write-Host ("Applied {0} fixes." -f $applied) diff --git a/scripts/openspec/Report-RefCoverage.ps1 b/scripts/openspec/Report-RefCoverage.ps1 index b4e24e1070..dc381bd9c0 100644 --- a/scripts/openspec/Report-RefCoverage.ps1 +++ b/scripts/openspec/Report-RefCoverage.ps1 @@ -1,113 +1,113 @@ <# .SYNOPSIS - Report OpenSpec coverage for AGENTS.md files. + Report OpenSpec coverage for AGENTS.md files. .DESCRIPTION - Counts how many top-level sections in AGENTS.md are referenced by specs and - prints a summary report. + Counts how many top-level sections in AGENTS.md are referenced by specs and + prints a summary report. #> [CmdletBinding()] param( - [string]$RepoRoot + [string]$RepoRoot ) $ErrorActionPreference = 'Stop' if (-not $RepoRoot) { - $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') + $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') } function Get-RepoRelativePath { - param([string]$Path) + param([string]$Path) - $full = (Resolve-Path $Path).Path - $root = (Resolve-Path $RepoRoot).Path + $full = (Resolve-Path $Path).Path + $root = (Resolve-Path $RepoRoot).Path - if ($full.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { - $rel = $full.Substring($root.Length).TrimStart('\', '/') - return $rel.Replace('\', '/') - } + if ($full.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($root.Length).TrimStart('\', '/') + return $rel.Replace('\', '/') + } - return $full.Replace('\', '/') + return $full.Replace('\', '/') } function Get-AnchorSlug { - param([string]$Heading) + param([string]$Heading) - $clean = $Heading.Trim() - $clean = $clean -replace '[^A-Za-z0-9 \-]', '' - $clean = $clean.ToLowerInvariant() - return ($clean -replace '\s', '-') + $clean = $Heading.Trim() + $clean = $clean -replace '[^A-Za-z0-9 \-]', '' + $clean = $clean.ToLowerInvariant() + return ($clean -replace '\s', '-') } function Get-Sections { - param([string[]]$Lines) - - $sections = @() - $inFence = $false - for ($i = 0; $i -lt $Lines.Count; $i++) { - $line = $Lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - if ($line -match '^(#{2})\s+(.+)$') { - $title = $matches[2].Trim() - if ($title -eq 'Change Log (auto)') { - continue - } - $sections += [pscustomobject]@{ - Index = $i - Title = $title - Anchor = Get-AnchorSlug -Heading $title - } - } - } - - return $sections + param([string[]]$Lines) + + $sections = @() + $inFence = $false + for ($i = 0; $i -lt $Lines.Count; $i++) { + $line = $Lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + if ($line -match '^(#{2})\s+(.+)$') { + $title = $matches[2].Trim() + if ($title -eq 'Change Log (auto)') { + continue + } + $sections += [pscustomobject]@{ + Index = $i + Title = $title + Anchor = Get-AnchorSlug -Heading $title + } + } + } + + return $sections } function Get-ReferencedSections { - param([string[]]$Lines) - - $sections = Get-Sections -Lines $Lines - $referenced = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - - $currentAnchor = '' - $inReferencedBy = $false - $inFence = $false - for ($i = 0; $i -lt $Lines.Count; $i++) { - $line = $Lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - if ($line -match '^(#{2})\s+(.+)$') { - $title = $matches[2].Trim() - $currentAnchor = Get-AnchorSlug -Heading $title - } - if ($line -match '^#{3,4}\s+Referenced By\s*$') { - $inReferencedBy = $true - continue - } - if ($line -match '^#{2,4}\s+' -and $line -notmatch '^#{3,4}\s+Referenced By\s*$') { - $inReferencedBy = $false - } - if ($inReferencedBy -and $currentAnchor) { - $null = $referenced.Add($currentAnchor) - } - } - - return [pscustomobject]@{ - Sections = $sections - Referenced = $referenced - } + param([string[]]$Lines) + + $sections = Get-Sections -Lines $Lines + $referenced = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + $currentAnchor = '' + $inReferencedBy = $false + $inFence = $false + for ($i = 0; $i -lt $Lines.Count; $i++) { + $line = $Lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + if ($line -match '^(#{2})\s+(.+)$') { + $title = $matches[2].Trim() + $currentAnchor = Get-AnchorSlug -Heading $title + } + if ($line -match '^#{3,4}\s+Referenced By\s*$') { + $inReferencedBy = $true + continue + } + if ($line -match '^#{2,4}\s+' -and $line -notmatch '^#{3,4}\s+Referenced By\s*$') { + $inReferencedBy = $false + } + if ($inReferencedBy -and $currentAnchor) { + $null = $referenced.Add($currentAnchor) + } + } + + return [pscustomobject]@{ + Sections = $sections + Referenced = $referenced + } } $agentFiles = Get-ChildItem -Path $RepoRoot -Recurse -Filter 'AGENTS.md' -File @@ -117,23 +117,23 @@ $partial = @() $none = @() foreach ($agent in $agentFiles) { - $lines = Get-Content -Path $agent.FullName -Encoding UTF8 - $data = Get-ReferencedSections -Lines $lines - $total = $data.Sections.Count - $count = $data.Referenced.Count - - $rel = Get-RepoRelativePath -Path $agent.FullName - - if ($count -eq 0) { - $none += $rel - continue - } - - if ($count -ge $total -and $total -gt 0) { - $fully += [pscustomobject]@{ Path = $rel; Count = $count } - } else { - $partial += [pscustomobject]@{ Path = $rel; Count = $count; Total = $total } - } + $lines = Get-Content -Path $agent.FullName -Encoding UTF8 + $data = Get-ReferencedSections -Lines $lines + $total = $data.Sections.Count + $count = $data.Referenced.Count + + $rel = Get-RepoRelativePath -Path $agent.FullName + + if ($count -eq 0) { + $none += $rel + continue + } + + if ($count -ge $total -and $total -gt 0) { + $fully += [pscustomobject]@{ Path = $rel; Count = $count } + } else { + $partial += [pscustomobject]@{ Path = $rel; Count = $count; Total = $total } + } } Write-Host 'OpenSpec Reference Coverage Report' @@ -142,28 +142,28 @@ Write-Host '' Write-Host 'Fully covered (has refs + back-refs):' foreach ($item in $fully) { - Write-Host (" OK {0} ({1} sections referenced)" -f $item.Path, $item.Count) + Write-Host (" OK {0} ({1} sections referenced)" -f $item.Path, $item.Count) } if ($fully.Count -eq 0) { - Write-Host ' (none)' + Write-Host ' (none)' } Write-Host '' Write-Host 'Partially covered:' foreach ($item in $partial) { - Write-Host (" ~ {0} ({1} of {2} sections referenced)" -f $item.Path, $item.Count, $item.Total) + Write-Host (" ~ {0} ({1} of {2} sections referenced)" -f $item.Path, $item.Count, $item.Total) } if ($partial.Count -eq 0) { - Write-Host ' (none)' + Write-Host ' (none)' } Write-Host '' Write-Host 'Not covered:' foreach ($item in $none) { - Write-Host (" o {0}" -f $item) + Write-Host (" o {0}" -f $item) } if ($none.Count -eq 0) { - Write-Host ' (none)' + Write-Host ' (none)' } Write-Host '' diff --git a/scripts/openspec/Sync-AgentsAnchors.ps1 b/scripts/openspec/Sync-AgentsAnchors.ps1 index 61a10bcfe7..97e864325b 100644 --- a/scripts/openspec/Sync-AgentsAnchors.ps1 +++ b/scripts/openspec/Sync-AgentsAnchors.ps1 @@ -1,236 +1,236 @@ <# .SYNOPSIS - Sync anchors frontmatter for AGENTS.md files. + Sync anchors frontmatter for AGENTS.md files. .DESCRIPTION - Parses AGENTS.md headings (## and ###), generates GitHub-style anchor slugs, - and compares/updates the anchors: block in YAML frontmatter. + Parses AGENTS.md headings (## and ###), generates GitHub-style anchor slugs, + and compares/updates the anchors: block in YAML frontmatter. .PARAMETER RepoRoot - Repository root path. Defaults to script root/../.. + Repository root path. Defaults to script root/../.. .PARAMETER Check - Report mismatches only (exit 1 if any mismatch). + Report mismatches only (exit 1 if any mismatch). .PARAMETER Fix - Update anchors in place when mismatched. + Update anchors in place when mismatched. .PARAMETER Init - Add anchors block to files that lack it. + Add anchors block to files that lack it. #> [CmdletBinding()] param( - [string]$RepoRoot, - [switch]$Check, - [switch]$Fix, - [switch]$Init + [string]$RepoRoot, + [switch]$Check, + [switch]$Fix, + [switch]$Init ) $ErrorActionPreference = 'Stop' if (-not $RepoRoot) { - $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') + $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') } function Get-AnchorSlug { - param([string]$Heading) + param([string]$Heading) - $clean = $Heading.Trim() - $clean = $clean -replace '[^A-Za-z0-9 \-]', '' - $clean = $clean.ToLowerInvariant() - return ($clean -replace '\s', '-') + $clean = $Heading.Trim() + $clean = $clean -replace '[^A-Za-z0-9 \-]', '' + $clean = $clean.ToLowerInvariant() + return ($clean -replace '\s', '-') } function Get-FrontMatterRange { - param([string[]]$Lines) - - if ($Lines.Count -lt 2) { - return $null - } - - if ($Lines[0].Trim() -ne '---') { - return $null - } - - for ($i = 1; $i -lt $Lines.Count; $i++) { - if ($Lines[$i].Trim() -eq '---') { - return [pscustomobject]@{ - Start = 0 - End = $i - } - } - } - - return $null + param([string[]]$Lines) + + if ($Lines.Count -lt 2) { + return $null + } + + if ($Lines[0].Trim() -ne '---') { + return $null + } + + for ($i = 1; $i -lt $Lines.Count; $i++) { + if ($Lines[$i].Trim() -eq '---') { + return [pscustomobject]@{ + Start = 0 + End = $i + } + } + } + + return $null } function Get-AnchorBlockRange { - param( - [string[]]$Lines, - [int]$StartIndex - ) - - $anchorStart = -1 - $anchorEnd = -1 - $inAnchors = $false - - for ($i = $StartIndex; $i -lt $Lines.Count; $i++) { - $line = $Lines[$i] - if ($line -match '^\s*anchors\s*:\s*$') { - $anchorStart = $i - $inAnchors = $true - continue - } - - if ($inAnchors) { - if ($line -match '^\s*-\s*\S+') { - $anchorEnd = $i - continue - } - - if ($line -match '^\s*\S') { - break - } - } - } - - if ($anchorStart -ge 0) { - if ($anchorEnd -lt $anchorStart) { - $anchorEnd = $anchorStart - } - return [pscustomobject]@{ Start = $anchorStart; End = $anchorEnd } - } - - return $null + param( + [string[]]$Lines, + [int]$StartIndex + ) + + $anchorStart = -1 + $anchorEnd = -1 + $inAnchors = $false + + for ($i = $StartIndex; $i -lt $Lines.Count; $i++) { + $line = $Lines[$i] + if ($line -match '^\s*anchors\s*:\s*$') { + $anchorStart = $i + $inAnchors = $true + continue + } + + if ($inAnchors) { + if ($line -match '^\s*-\s*\S+') { + $anchorEnd = $i + continue + } + + if ($line -match '^\s*\S') { + break + } + } + } + + if ($anchorStart -ge 0) { + if ($anchorEnd -lt $anchorStart) { + $anchorEnd = $anchorStart + } + return [pscustomobject]@{ Start = $anchorStart; End = $anchorEnd } + } + + return $null } function Get-ExistingAnchors { - param( - [string[]]$Lines, - [int]$StartIndex - ) - - $anchorBlock = Get-AnchorBlockRange -Lines $Lines -StartIndex $StartIndex - if (-not $anchorBlock) { - return @() - } - - $anchors = @() - for ($i = $anchorBlock.Start + 1; $i -le $anchorBlock.End; $i++) { - $line = $Lines[$i] - if ($line -match '^\s*-\s*(\S+)\s*$') { - $anchors += $matches[1] - } - } - - return $anchors + param( + [string[]]$Lines, + [int]$StartIndex + ) + + $anchorBlock = Get-AnchorBlockRange -Lines $Lines -StartIndex $StartIndex + if (-not $anchorBlock) { + return @() + } + + $anchors = @() + for ($i = $anchorBlock.Start + 1; $i -le $anchorBlock.End; $i++) { + $line = $Lines[$i] + if ($line -match '^\s*-\s*(\S+)\s*$') { + $anchors += $matches[1] + } + } + + return $anchors } function Get-HeadingAnchors { - param([string[]]$Lines) - - $anchors = New-Object System.Collections.Generic.List[string] - $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - - foreach ($line in $Lines) { - if ($line -match '^(#{2,3})\s+(.+)$') { - $heading = $matches[2] - $slug = Get-AnchorSlug -Heading $heading - if ($slug -and -not $seen.Contains($slug)) { - $anchors.Add($slug) - $null = $seen.Add($slug) - } - } - } - - return $anchors + param([string[]]$Lines) + + $anchors = New-Object System.Collections.Generic.List[string] + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($line in $Lines) { + if ($line -match '^(#{2,3})\s+(.+)$') { + $heading = $matches[2] + $slug = Get-AnchorSlug -Heading $heading + if ($slug -and -not $seen.Contains($slug)) { + $anchors.Add($slug) + $null = $seen.Add($slug) + } + } + } + + return $anchors } function Update-FileAnchors { - param( - [string]$Path, - [switch]$Check, - [switch]$Fix, - [switch]$Init - ) - - $lines = Get-Content -Path $Path -Encoding UTF8 - $front = Get-FrontMatterRange -Lines $lines - - if (-not $front) { - if ($Init) { - $anchors = Get-HeadingAnchors -Lines $lines - $frontMatter = @('---', '---', '') - $anchorLines = @('anchors:') + ($anchors | ForEach-Object { " - $_" }) + @('') - $newLines = $frontMatter + $anchorLines + $lines - Set-Content -Path $Path -Value $newLines -Encoding UTF8 - return $true - } - return $false - } - - $searchStart = $front.End + 1 - $headingAnchors = Get-HeadingAnchors -Lines $lines - $existingAnchors = Get-ExistingAnchors -Lines $lines -StartIndex $searchStart - $anchorBlock = Get-AnchorBlockRange -Lines $lines -StartIndex $searchStart - - $matches = ($existingAnchors.Count -eq $headingAnchors.Count) - if ($matches) { - for ($i = 0; $i -lt $existingAnchors.Count; $i++) { - if ($existingAnchors[$i] -ne $headingAnchors[$i]) { - $matches = $false - break - } - } - } - - if ($matches) { - return $false - } - - if ($Check -and -not $Fix) { - return $true - } - - if (-not $Fix -and -not $Init) { - return $false - } - - $anchorLines = @('anchors:') + ($headingAnchors | ForEach-Object { " - $_" }) - - if ($anchorBlock) { - $before = $lines[0..($anchorBlock.Start - 1)] - $after = $lines[($anchorBlock.End + 1)..($lines.Count - 1)] - $newLines = @() - $newLines += $before - $newLines += $anchorLines - $newLines += $after - } else { - $before = $lines[0..$front.End] - $after = $lines[($front.End + 1)..($lines.Count - 1)] - $newLines = @() - $newLines += $before - $newLines += $anchorLines - $newLines += $after - } - - Set-Content -Path $Path -Value $newLines -Encoding UTF8 - return $true + param( + [string]$Path, + [switch]$Check, + [switch]$Fix, + [switch]$Init + ) + + $lines = Get-Content -Path $Path -Encoding UTF8 + $front = Get-FrontMatterRange -Lines $lines + + if (-not $front) { + if ($Init) { + $anchors = Get-HeadingAnchors -Lines $lines + $frontMatter = @('---', '---', '') + $anchorLines = @('anchors:') + ($anchors | ForEach-Object { " - $_" }) + @('') + $newLines = $frontMatter + $anchorLines + $lines + Set-Content -Path $Path -Value $newLines -Encoding UTF8 + return $true + } + return $false + } + + $searchStart = $front.End + 1 + $headingAnchors = Get-HeadingAnchors -Lines $lines + $existingAnchors = Get-ExistingAnchors -Lines $lines -StartIndex $searchStart + $anchorBlock = Get-AnchorBlockRange -Lines $lines -StartIndex $searchStart + + $matches = ($existingAnchors.Count -eq $headingAnchors.Count) + if ($matches) { + for ($i = 0; $i -lt $existingAnchors.Count; $i++) { + if ($existingAnchors[$i] -ne $headingAnchors[$i]) { + $matches = $false + break + } + } + } + + if ($matches) { + return $false + } + + if ($Check -and -not $Fix) { + return $true + } + + if (-not $Fix -and -not $Init) { + return $false + } + + $anchorLines = @('anchors:') + ($headingAnchors | ForEach-Object { " - $_" }) + + if ($anchorBlock) { + $before = $lines[0..($anchorBlock.Start - 1)] + $after = $lines[($anchorBlock.End + 1)..($lines.Count - 1)] + $newLines = @() + $newLines += $before + $newLines += $anchorLines + $newLines += $after + } else { + $before = $lines[0..$front.End] + $after = $lines[($front.End + 1)..($lines.Count - 1)] + $newLines = @() + $newLines += $before + $newLines += $anchorLines + $newLines += $after + } + + Set-Content -Path $Path -Value $newLines -Encoding UTF8 + return $true } $agentFiles = Get-ChildItem -Path $RepoRoot -Recurse -Filter 'AGENTS.md' -File $mismatches = 0 foreach ($agent in $agentFiles) { - $changed = Update-FileAnchors -Path $agent.FullName -Check:$Check -Fix:$Fix -Init:$Init - if ($changed) { - $mismatches++ - Write-Host ("Anchors out of sync: {0}" -f $agent.FullName) - } + $changed = Update-FileAnchors -Path $agent.FullName -Check:$Check -Fix:$Fix -Init:$Init + if ($changed) { + $mismatches++ + Write-Host ("Anchors out of sync: {0}" -f $agent.FullName) + } } if ($Check -and $mismatches -gt 0) { - exit 1 + exit 1 } exit 0 diff --git a/scripts/openspec/Validate-OpenSpecRefs.ps1 b/scripts/openspec/Validate-OpenSpecRefs.ps1 index 9c9189edb5..2d376dc235 100644 --- a/scripts/openspec/Validate-OpenSpecRefs.ps1 +++ b/scripts/openspec/Validate-OpenSpecRefs.ps1 @@ -1,304 +1,304 @@ <# .SYNOPSIS - Validate OpenSpec cross-references between spec files and AGENTS.md. + Validate OpenSpec cross-references between spec files and AGENTS.md. .DESCRIPTION - Scans OpenSpec specs for AGENTS.md forward refs and verifies: - - Target files exist - - Anchors exist - - Back-refs are present in AGENTS.md "Referenced By" sections - Scans AGENTS.md back-refs and verifies: - - Target specs exist - - Anchors exist - - Forward refs are present in specs + Scans OpenSpec specs for AGENTS.md forward refs and verifies: + - Target files exist + - Anchors exist + - Back-refs are present in AGENTS.md "Referenced By" sections + Scans AGENTS.md back-refs and verifies: + - Target specs exist + - Anchors exist + - Forward refs are present in specs .EXITCODES - 0: All refs valid - 1: Broken refs found - 2: Orphaned refs found (missing bidirectional pair) + 0: All refs valid + 1: Broken refs found + 2: Orphaned refs found (missing bidirectional pair) #> [CmdletBinding()] param( - [string]$RepoRoot + [string]$RepoRoot ) $ErrorActionPreference = 'Stop' if (-not $RepoRoot) { - $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') + $RepoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..') } function Get-RepoRelativePath { - param([string]$Path) + param([string]$Path) - $full = (Resolve-Path $Path).Path - $root = (Resolve-Path $RepoRoot).Path + $full = (Resolve-Path $Path).Path + $root = (Resolve-Path $RepoRoot).Path - if ($full.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { - $rel = $full.Substring($root.Length).TrimStart('\', '/') - return $rel.Replace('\', '/') - } + if ($full.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($root.Length).TrimStart('\', '/') + return $rel.Replace('\', '/') + } - return $full.Replace('\', '/') + return $full.Replace('\', '/') } function Get-AnchorSlug { - param([string]$Heading) - - $clean = $Heading.Trim() - # Remove punctuation but keep spaces to preserve GitHub-style double hyphens. - $clean = $clean -replace '[^A-Za-z0-9 \-]', '' - $clean = $clean.ToLowerInvariant() - $slug = $clean -replace '\s', '-' - return $slug + param([string]$Heading) + + $clean = $Heading.Trim() + # Remove punctuation but keep spaces to preserve GitHub-style double hyphens. + $clean = $clean -replace '[^A-Za-z0-9 \-]', '' + $clean = $clean.ToLowerInvariant() + $slug = $clean -replace '\s', '-' + return $slug } function Get-FrontMatterAnchors { - param([string[]]$Lines) - - $anchors = @() - if ($Lines.Count -lt 2) { - return $anchors - } - - if ($Lines[0].Trim() -ne '---') { - return $anchors - } - - $endIndex = -1 - for ($i = 1; $i -lt $Lines.Count; $i++) { - if ($Lines[$i].Trim() -eq '---') { - $endIndex = $i - break - } - } - - if ($endIndex -lt 0) { - return $anchors - } - - $inAnchors = $false - for ($i = 1; $i -lt $endIndex; $i++) { - $line = $Lines[$i] - if ($line -match '^\s*anchors\s*:\s*$') { - $inAnchors = $true - continue - } - - if ($inAnchors) { - if ($line -match '^\s*-\s*(\S+)\s*$') { - $anchors += $matches[1] - continue - } - - if ($line -match '^\s*\S') { - $inAnchors = $false - } - } - } - - return $anchors + param([string[]]$Lines) + + $anchors = @() + if ($Lines.Count -lt 2) { + return $anchors + } + + if ($Lines[0].Trim() -ne '---') { + return $anchors + } + + $endIndex = -1 + for ($i = 1; $i -lt $Lines.Count; $i++) { + if ($Lines[$i].Trim() -eq '---') { + $endIndex = $i + break + } + } + + if ($endIndex -lt 0) { + return $anchors + } + + $inAnchors = $false + for ($i = 1; $i -lt $endIndex; $i++) { + $line = $Lines[$i] + if ($line -match '^\s*anchors\s*:\s*$') { + $inAnchors = $true + continue + } + + if ($inAnchors) { + if ($line -match '^\s*-\s*(\S+)\s*$') { + $anchors += $matches[1] + continue + } + + if ($line -match '^\s*\S') { + $inAnchors = $false + } + } + } + + return $anchors } $anchorCache = @{} function Get-FileAnchors { - param([string]$Path) - - if ($anchorCache.ContainsKey($Path)) { - return $anchorCache[$Path] - } - - $lines = Get-Content -Path $Path -Encoding UTF8 - $anchors = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) - - foreach ($anchor in (Get-FrontMatterAnchors -Lines $lines)) { - $null = $anchors.Add($anchor) - } - - $inFence = $false - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - if ($line -match '^(#+)\s+(.+)$') { - $heading = $matches[2] - $slug = Get-AnchorSlug -Heading $heading - if ($slug) { - $null = $anchors.Add($slug) - } - } - } - - $anchorCache[$Path] = $anchors - return $anchors + param([string]$Path) + + if ($anchorCache.ContainsKey($Path)) { + return $anchorCache[$Path] + } + + $lines = Get-Content -Path $Path -Encoding UTF8 + $anchors = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($anchor in (Get-FrontMatterAnchors -Lines $lines)) { + $null = $anchors.Add($anchor) + } + + $inFence = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + if ($line -match '^(#+)\s+(.+)$') { + $heading = $matches[2] + $slug = Get-AnchorSlug -Heading $heading + if ($slug) { + $null = $anchors.Add($slug) + } + } + } + + $anchorCache[$Path] = $anchors + return $anchors } function Resolve-LinkTarget { - param( - [string]$SourcePath, - [string]$Href - ) + param( + [string]$SourcePath, + [string]$Href + ) - if ($Href -match '^\w+://') { - return $null - } + if ($Href -match '^\w+://') { + return $null + } - if ($Href -match '^#') { - return $null - } + if ($Href -match '^#') { + return $null + } - $decoded = [System.Uri]::UnescapeDataString($Href) - $parts = $decoded.Split('#', 2) + $decoded = [System.Uri]::UnescapeDataString($Href) + $parts = $decoded.Split('#', 2) - if ($parts.Count -lt 2) { - return $null - } + if ($parts.Count -lt 2) { + return $null + } - $pathPart = $parts[0].Trim() - $anchor = $parts[1].Trim() + $pathPart = $parts[0].Trim() + $anchor = $parts[1].Trim() - if (-not $pathPart -or -not $anchor) { - return $null - } + if (-not $pathPart -or -not $anchor) { + return $null + } - $fullPath = [System.IO.Path]::GetFullPath((Join-Path (Split-Path $SourcePath) $pathPart)) + $fullPath = [System.IO.Path]::GetFullPath((Join-Path (Split-Path $SourcePath) $pathPart)) - return [pscustomobject]@{ - FullPath = $fullPath - RelPath = Get-RepoRelativePath -Path $fullPath - Anchor = $anchor - } + return [pscustomobject]@{ + FullPath = $fullPath + RelPath = Get-RepoRelativePath -Path $fullPath + Anchor = $anchor + } } function Get-LinkMatches { - param([string]$Line) + param([string]$Line) - $regex = '(?[^\]]+)\]\((?[^)]+)\)' - return [regex]::Matches($Line, $regex) + $regex = '(?[^\]]+)\]\((?[^)]+)\)' + return [regex]::Matches($Line, $regex) } function Get-SpecForwardRefs { - param([string]$SpecPath) - - $lines = Get-Content -Path $SpecPath -Encoding UTF8 - $currentSectionAnchor = '' - $refs = @() - - $inFence = $false - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - if ($line -match '^(#+)\s+(.+)$') { - $headingText = $matches[2].Trim() - if ($headingText -notin @('References', 'Referenced By')) { - $currentSectionAnchor = Get-AnchorSlug -Heading $headingText - } - } - - foreach ($match in (Get-LinkMatches -Line $line)) { - $href = $match.Groups['href'].Value - if ($href -notmatch 'AGENTS\.md#') { - continue - } - - $target = Resolve-LinkTarget -SourcePath $SpecPath -Href $href - if (-not $target) { - continue - } - - $refs += [pscustomobject]@{ - SpecPath = $SpecPath - SpecRel = Get-RepoRelativePath -Path $SpecPath - SpecAnchor = $currentSectionAnchor - LineNumber = $i + 1 - AgentRel = $target.RelPath - AgentFull = $target.FullPath - AgentAnchor = $target.Anchor - } - } - } - - return $refs + param([string]$SpecPath) + + $lines = Get-Content -Path $SpecPath -Encoding UTF8 + $currentSectionAnchor = '' + $refs = @() + + $inFence = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + if ($line -match '^(#+)\s+(.+)$') { + $headingText = $matches[2].Trim() + if ($headingText -notin @('References', 'Referenced By')) { + $currentSectionAnchor = Get-AnchorSlug -Heading $headingText + } + } + + foreach ($match in (Get-LinkMatches -Line $line)) { + $href = $match.Groups['href'].Value + if ($href -notmatch 'AGENTS\.md#') { + continue + } + + $target = Resolve-LinkTarget -SourcePath $SpecPath -Href $href + if (-not $target) { + continue + } + + $refs += [pscustomobject]@{ + SpecPath = $SpecPath + SpecRel = Get-RepoRelativePath -Path $SpecPath + SpecAnchor = $currentSectionAnchor + LineNumber = $i + 1 + AgentRel = $target.RelPath + AgentFull = $target.FullPath + AgentAnchor = $target.Anchor + } + } + } + + return $refs } function Get-AgentsBackRefs { - param([string]$AgentPath) - - $lines = Get-Content -Path $AgentPath -Encoding UTF8 - $currentSectionAnchor = '' - $inReferencedBy = $false - $refs = @() - - $inFence = $false - for ($i = 0; $i -lt $lines.Count; $i++) { - $line = $lines[$i] - if ($line -match '^```') { - $inFence = -not $inFence - continue - } - if ($inFence) { - continue - } - - if ($line -match '^(#+)\s+(.+)$') { - $level = $matches[1].Length - $headingText = $matches[2].Trim() - - $inReferencedBy = $headingText -eq 'Referenced By' - - if ($headingText -notin @('Referenced By', 'References')) { - if ($level -le 2) { - $currentSectionAnchor = Get-AnchorSlug -Heading $headingText - } - } - } - - if (-not $inReferencedBy) { - continue - } - - foreach ($match in (Get-LinkMatches -Line $line)) { - $href = $match.Groups['href'].Value - if ($href -notmatch 'openspec/specs/.+\.md#') { - continue - } - - $target = Resolve-LinkTarget -SourcePath $AgentPath -Href $href - if (-not $target) { - continue - } - - $refs += [pscustomobject]@{ - AgentPath = $AgentPath - AgentRel = Get-RepoRelativePath -Path $AgentPath - AgentAnchor = $currentSectionAnchor - LineNumber = $i + 1 - SpecRel = $target.RelPath - SpecFull = $target.FullPath - SpecAnchor = $target.Anchor - } - } - } - - return $refs + param([string]$AgentPath) + + $lines = Get-Content -Path $AgentPath -Encoding UTF8 + $currentSectionAnchor = '' + $inReferencedBy = $false + $refs = @() + + $inFence = $false + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + if ($line -match '^```') { + $inFence = -not $inFence + continue + } + if ($inFence) { + continue + } + + if ($line -match '^(#+)\s+(.+)$') { + $level = $matches[1].Length + $headingText = $matches[2].Trim() + + $inReferencedBy = $headingText -eq 'Referenced By' + + if ($headingText -notin @('Referenced By', 'References')) { + if ($level -le 2) { + $currentSectionAnchor = Get-AnchorSlug -Heading $headingText + } + } + } + + if (-not $inReferencedBy) { + continue + } + + foreach ($match in (Get-LinkMatches -Line $line)) { + $href = $match.Groups['href'].Value + if ($href -notmatch 'openspec/specs/.+\.md#') { + continue + } + + $target = Resolve-LinkTarget -SourcePath $AgentPath -Href $href + if (-not $target) { + continue + } + + $refs += [pscustomobject]@{ + AgentPath = $AgentPath + AgentRel = Get-RepoRelativePath -Path $AgentPath + AgentAnchor = $currentSectionAnchor + LineNumber = $i + 1 + SpecRel = $target.RelPath + SpecFull = $target.FullPath + SpecAnchor = $target.Anchor + } + } + } + + return $refs } $specRoot = Join-Path $RepoRoot 'openspec\specs' $specFiles = @() if (Test-Path $specRoot) { - $specFiles = Get-ChildItem -Path $specRoot -Recurse -Filter '*.md' -File + $specFiles = Get-ChildItem -Path $specRoot -Recurse -Filter '*.md' -File } $agentFiles = Get-ChildItem -Path $RepoRoot -Recurse -Filter 'AGENTS.md' -File @@ -310,24 +310,24 @@ Write-Host '' $forwardRefs = @() foreach ($spec in $specFiles) { - $forwardRefs += Get-SpecForwardRefs -SpecPath $spec.FullName + $forwardRefs += Get-SpecForwardRefs -SpecPath $spec.FullName } $backRefs = @() foreach ($agent in $agentFiles) { - $backRefs += Get-AgentsBackRefs -AgentPath $agent.FullName + $backRefs += Get-AgentsBackRefs -AgentPath $agent.FullName } $forwardMap = @{} foreach ($ref in $forwardRefs) { - $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor - $forwardMap[$key] = $ref + $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor + $forwardMap[$key] = $ref } $backMap = @{} foreach ($ref in $backRefs) { - $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor - $backMap[$key] = $ref + $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor + $backMap[$key] = $ref } $broken = @() @@ -336,88 +336,88 @@ $brokenCount = 0 $orphanCount = 0 foreach ($ref in $forwardRefs) { - if (-not (Test-Path $ref.AgentFull)) { - $brokenCount++ - $broken += @( - "[BROKEN] {0}:{1}" -f $ref.SpecRel, $ref.LineNumber, - " -> {0}#{1}" -f $ref.AgentRel, $ref.AgentAnchor, - ' Target file not found.' - ) - continue - } - - $agentAnchors = Get-FileAnchors -Path $ref.AgentFull - if (-not $agentAnchors.Contains($ref.AgentAnchor)) { - $brokenCount++ - $broken += @( - "[BROKEN] {0}:{1}" -f $ref.SpecRel, $ref.LineNumber, - " -> {0}#{1}" -f $ref.AgentRel, $ref.AgentAnchor, - ' Anchor not found.' - ) - continue - } - - $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor - if (-not $backMap.ContainsKey($key)) { - $orphanCount++ - $orphans += @( - "[ORPHAN] {0}:{1} #{2}" -f $ref.SpecRel, $ref.LineNumber, $ref.SpecAnchor, - " Missing back-ref in {0}." -f $ref.AgentRel - ) - } + if (-not (Test-Path $ref.AgentFull)) { + $brokenCount++ + $broken += @( + "[BROKEN] {0}:{1}" -f $ref.SpecRel, $ref.LineNumber, + " -> {0}#{1}" -f $ref.AgentRel, $ref.AgentAnchor, + ' Target file not found.' + ) + continue + } + + $agentAnchors = Get-FileAnchors -Path $ref.AgentFull + if (-not $agentAnchors.Contains($ref.AgentAnchor)) { + $brokenCount++ + $broken += @( + "[BROKEN] {0}:{1}" -f $ref.SpecRel, $ref.LineNumber, + " -> {0}#{1}" -f $ref.AgentRel, $ref.AgentAnchor, + ' Anchor not found.' + ) + continue + } + + $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor + if (-not $backMap.ContainsKey($key)) { + $orphanCount++ + $orphans += @( + "[ORPHAN] {0}:{1} #{2}" -f $ref.SpecRel, $ref.LineNumber, $ref.SpecAnchor, + " Missing back-ref in {0}." -f $ref.AgentRel + ) + } } foreach ($ref in $backRefs) { - if (-not (Test-Path $ref.SpecFull)) { - $brokenCount++ - $broken += @( - "[BROKEN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, - " -> {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor, - ' Target spec not found.' - ) - continue - } - - $specAnchors = Get-FileAnchors -Path $ref.SpecFull - if (-not $specAnchors.Contains($ref.SpecAnchor)) { - $brokenCount++ - $broken += @( - "[BROKEN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, - " -> {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor, - ' Anchor not found.' - ) - continue - } - - $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor - if (-not $forwardMap.ContainsKey($key)) { - $orphanCount++ - $orphans += @( - "[ORPHAN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, - " Spec missing forward ref: {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor - ) - } + if (-not (Test-Path $ref.SpecFull)) { + $brokenCount++ + $broken += @( + "[BROKEN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, + " -> {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor, + ' Target spec not found.' + ) + continue + } + + $specAnchors = Get-FileAnchors -Path $ref.SpecFull + if (-not $specAnchors.Contains($ref.SpecAnchor)) { + $brokenCount++ + $broken += @( + "[BROKEN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, + " -> {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor, + ' Anchor not found.' + ) + continue + } + + $key = "{0}|{1}|{2}|{3}" -f $ref.SpecRel, $ref.SpecAnchor, $ref.AgentRel, $ref.AgentAnchor + if (-not $forwardMap.ContainsKey($key)) { + $orphanCount++ + $orphans += @( + "[ORPHAN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, + " Spec missing forward ref: {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor + ) + } } $graph = @{} function Add-Edge { - param([string]$From, [string]$To) + param([string]$From, [string]$To) - if (-not $graph.ContainsKey($From)) { - $graph[$From] = New-Object System.Collections.Generic.List[string] - } + if (-not $graph.ContainsKey($From)) { + $graph[$From] = New-Object System.Collections.Generic.List[string] + } - if (-not $graph[$From].Contains($To)) { - $graph[$From].Add($To) - } + if (-not $graph[$From].Contains($To)) { + $graph[$From].Add($To) + } } foreach ($ref in $forwardRefs) { - Add-Edge -From $ref.SpecRel -To $ref.AgentRel + Add-Edge -From $ref.SpecRel -To $ref.AgentRel } foreach ($ref in $backRefs) { - Add-Edge -From $ref.AgentRel -To $ref.SpecRel + Add-Edge -From $ref.AgentRel -To $ref.SpecRel } $cycleSet = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) @@ -426,71 +426,71 @@ $onStack = @{} $stack = New-Object System.Collections.Generic.List[string] function Visit-Node { - param([string]$Node) - - $visited[$Node] = $true - $onStack[$Node] = $true - $stack.Add($Node) - - if ($graph.ContainsKey($Node)) { - foreach ($neighbor in $graph[$Node]) { - if (-not $visited.ContainsKey($neighbor)) { - Visit-Node -Node $neighbor - } elseif ($onStack.ContainsKey($neighbor)) { - $startIndex = $stack.IndexOf($neighbor) - if ($startIndex -ge 0) { - $cycle = $stack[$startIndex..($stack.Count - 1)] + $neighbor - if ($cycle.Count -ge 4) { - $cycleSet.Add(($cycle -join ' -> ')) | Out-Null - } - } - } - } - } - - $stack.RemoveAt($stack.Count - 1) - $onStack.Remove($Node) + param([string]$Node) + + $visited[$Node] = $true + $onStack[$Node] = $true + $stack.Add($Node) + + if ($graph.ContainsKey($Node)) { + foreach ($neighbor in $graph[$Node]) { + if (-not $visited.ContainsKey($neighbor)) { + Visit-Node -Node $neighbor + } elseif ($onStack.ContainsKey($neighbor)) { + $startIndex = $stack.IndexOf($neighbor) + if ($startIndex -ge 0) { + $cycle = $stack[$startIndex..($stack.Count - 1)] + $neighbor + if ($cycle.Count -ge 4) { + $cycleSet.Add(($cycle -join ' -> ')) | Out-Null + } + } + } + } + } + + $stack.RemoveAt($stack.Count - 1) + $onStack.Remove($Node) } foreach ($node in $graph.Keys) { - if (-not $visited.ContainsKey($node)) { - Visit-Node -Node $node - } + if (-not $visited.ContainsKey($node)) { + Visit-Node -Node $node + } } if ($broken.Count -gt 0) { - Write-Host 'ERRORS:' - foreach ($line in $broken) { - Write-Host " $line" - } - Write-Host '' + Write-Host 'ERRORS:' + foreach ($line in $broken) { + Write-Host " $line" + } + Write-Host '' } if ($orphans.Count -gt 0) { - Write-Host 'ORPHANS:' - foreach ($line in $orphans) { - Write-Host " $line" - } - Write-Host '' + Write-Host 'ORPHANS:' + foreach ($line in $orphans) { + Write-Host " $line" + } + Write-Host '' } if ($cycleSet.Count -gt 0) { - Write-Host 'WARNINGS:' - foreach ($cycle in $cycleSet) { - Write-Host " [CYCLE] $cycle" - } - Write-Host '' + Write-Host 'WARNINGS:' + foreach ($cycle in $cycleSet) { + Write-Host " [CYCLE] $cycle" + } + Write-Host '' } $errorCount = $brokenCount + $orphanCount Write-Host ("Summary: {0} errors, {1} warnings" -f $errorCount, $cycleSet.Count) if ($brokenCount -gt 0) { - exit 1 + exit 1 } if ($orphanCount -gt 0) { - exit 2 + exit 2 } exit 0 diff --git a/scripts/regfree/run-in-vm.ps1 b/scripts/regfree/run-in-vm.ps1 index 204fad63db..ddd88f7843 100644 --- a/scripts/regfree/run-in-vm.ps1 +++ b/scripts/regfree/run-in-vm.ps1 @@ -1,43 +1,43 @@ [CmdletBinding()] param ( - [Parameter(Mandatory = $true)] - [string]$VmName, + [Parameter(Mandatory = $true)] + [string]$VmName, - [Parameter(Mandatory = $true)] - [string]$ExecutablePath, + [Parameter(Mandatory = $true)] + [string]$ExecutablePath, - [string[]]$ExtraPayload = @(), + [string[]]$ExtraPayload = @(), - [string[]]$Arguments = @(), + [string[]]$Arguments = @(), - [string]$CheckpointName = "regfree-clean", + [string]$CheckpointName = "regfree-clean", - [string]$GuestWorkingDirectory = "C:\\RegFreePayload", + [string]$GuestWorkingDirectory = "C:\\RegFreePayload", - [string]$OutputDirectory = "specs/003-convergence-regfree-com-coverage/artifacts/vm-output", + [string]$OutputDirectory = "specs/003-convergence-regfree-com-coverage/artifacts/vm-output", - [switch]$NoCheckpointRestore, + [switch]$NoCheckpointRestore, - [switch]$SkipStopVm + [switch]$SkipStopVm ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Write-Log { - param([string]$Message) - $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" - Write-Output "[$timestamp] $Message" + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Write-Output "[$timestamp] $Message" } function Resolve-PathStrict { - param([string]$Path) - $resolved = Resolve-Path -Path $Path -ErrorAction Stop - return $resolved.ProviderPath + param([string]$Path) + $resolved = Resolve-Path -Path $Path -ErrorAction Stop + return $resolved.ProviderPath } if (-not (Get-Command Get-VM -ErrorAction SilentlyContinue)) { - throw "Hyper-V PowerShell module is required. Install the Hyper-V feature and rerun this script." + throw "Hyper-V PowerShell module is required. Install the Hyper-V feature and rerun this script." } $exePath = Resolve-PathStrict -Path $ExecutablePath @@ -45,72 +45,72 @@ $payloadPaths = @($exePath) $manifestPath = "$exePath.manifest" if (Test-Path -Path $manifestPath) { - $payloadPaths += (Resolve-PathStrict -Path $manifestPath) + $payloadPaths += (Resolve-PathStrict -Path $manifestPath) } else { - Write-Log "Manifest not found next to executable ($manifestPath). Continuing without manifest copy." + Write-Log "Manifest not found next to executable ($manifestPath). Continuing without manifest copy." } foreach ($item in $ExtraPayload) { - $payloadPaths += (Resolve-PathStrict -Path $item) + $payloadPaths += (Resolve-PathStrict -Path $item) } $vm = Get-VM -Name $VmName -ErrorAction Stop if (-not $NoCheckpointRestore) { - $checkpoint = Get-VMCheckpoint -VMName $VmName -Name $CheckpointName -ErrorAction SilentlyContinue - if ($null -eq $checkpoint) { - Write-Log "Checkpoint '$CheckpointName' not found; continuing without restore." - } else { - Write-Log "Restoring checkpoint '$CheckpointName'." - Restore-VMCheckpoint -VMCheckpoint $checkpoint -Confirm:$false | Out-Null - } + $checkpoint = Get-VMCheckpoint -VMName $VmName -Name $CheckpointName -ErrorAction SilentlyContinue + if ($null -eq $checkpoint) { + Write-Log "Checkpoint '$CheckpointName' not found; continuing without restore." + } else { + Write-Log "Restoring checkpoint '$CheckpointName'." + Restore-VMCheckpoint -VMCheckpoint $checkpoint -Confirm:$false | Out-Null + } } if ($vm.State -ne 'Running') { - Write-Log "Starting VM '$VmName'." - Start-VM -VM $vm | Out-Null + Write-Log "Starting VM '$VmName'." + Start-VM -VM $vm | Out-Null } $payloadRoot = Join-Path -Path $env:TEMP -ChildPath ("regfree-" + [Guid]::NewGuid()) New-Item -ItemType Directory -Path $payloadRoot | Out-Null try { - foreach ($path in $payloadPaths) { - Copy-Item -Path $path -Destination $payloadRoot -Force - } - - $hostOutputDirectory = Resolve-Path -Path $OutputDirectory -ErrorAction SilentlyContinue - if (-not $hostOutputDirectory) { - $hostOutputDirectory = New-Item -ItemType Directory -Path $OutputDirectory -Force - } - - $outputFile = Join-Path -Path $hostOutputDirectory -ChildPath ("${VmName}-" + (Split-Path -Leaf $exePath) + "-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".log") - - Write-Log "Copying payload files to VM '$VmName'." - foreach ($file in Get-ChildItem -Path $payloadRoot) { - Copy-VMFile -VMName $VmName -SourcePath $file.FullName -DestinationPath (Join-Path $GuestWorkingDirectory $file.Name) -CreateFullPath -FileSource Host -ErrorAction Stop - } - - $guestExePath = Join-Path $GuestWorkingDirectory (Split-Path -Leaf $exePath) - $scriptBlock = @" - param( - [string]`$CommandPath, - [string[]]`$Args, - [string]`$WorkingDir - ) - Set-Location -Path `$WorkingDir - & `$CommandPath @Args + foreach ($path in $payloadPaths) { + Copy-Item -Path $path -Destination $payloadRoot -Force + } + + $hostOutputDirectory = Resolve-Path -Path $OutputDirectory -ErrorAction SilentlyContinue + if (-not $hostOutputDirectory) { + $hostOutputDirectory = New-Item -ItemType Directory -Path $OutputDirectory -Force + } + + $outputFile = Join-Path -Path $hostOutputDirectory -ChildPath ("${VmName}-" + (Split-Path -Leaf $exePath) + "-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".log") + + Write-Log "Copying payload files to VM '$VmName'." + foreach ($file in Get-ChildItem -Path $payloadRoot) { + Copy-VMFile -VMName $VmName -SourcePath $file.FullName -DestinationPath (Join-Path $GuestWorkingDirectory $file.Name) -CreateFullPath -FileSource Host -ErrorAction Stop + } + + $guestExePath = Join-Path $GuestWorkingDirectory (Split-Path -Leaf $exePath) + $scriptBlock = @" + param( + [string]`$CommandPath, + [string[]]`$Args, + [string]`$WorkingDir + ) + Set-Location -Path `$WorkingDir + & `$CommandPath @Args "@ - Write-Log "Launching executable inside VM via PowerShell Direct." - $invokeResult = Invoke-Command -VMName $VmName -ScriptBlock ([ScriptBlock]::Create($scriptBlock)) -ArgumentList $guestExePath, $Arguments, $GuestWorkingDirectory -ErrorAction Stop - $invokeResult | Out-File -FilePath $outputFile -Encoding utf8 - Write-Log "VM execution complete. Log saved to $outputFile" + Write-Log "Launching executable inside VM via PowerShell Direct." + $invokeResult = Invoke-Command -VMName $VmName -ScriptBlock ([ScriptBlock]::Create($scriptBlock)) -ArgumentList $guestExePath, $Arguments, $GuestWorkingDirectory -ErrorAction Stop + $invokeResult | Out-File -FilePath $outputFile -Encoding utf8 + Write-Log "VM execution complete. Log saved to $outputFile" } finally { - Remove-Item -Path $payloadRoot -Recurse -Force -ErrorAction SilentlyContinue | Out-Null - if (-not $SkipStopVm) { - Write-Log "Stopping VM '$VmName'." - Stop-VM -VM $vm -Force -TurnOff:$false | Out-Null - } + Remove-Item -Path $payloadRoot -Recurse -Force -ErrorAction SilentlyContinue | Out-Null + if (-not $SkipStopVm) { + Write-Log "Stopping VM '$VmName'." + Stop-VM -VM $vm -Force -TurnOff:$false | Out-Null + } } diff --git a/scripts/test_exclusions/assembly_guard.ps1 b/scripts/test_exclusions/assembly_guard.ps1 index cf03f99064..248b4355e8 100644 --- a/scripts/test_exclusions/assembly_guard.ps1 +++ b/scripts/test_exclusions/assembly_guard.ps1 @@ -1,7 +1,7 @@ [CmdletBinding()] param ( - [Parameter(Mandatory=$true)] - [string]$Assemblies + [Parameter(Mandatory=$true)] + [string]$Assemblies ) $ErrorActionPreference = "Stop" @@ -16,28 +16,28 @@ $ErrorActionPreference = "Stop" $Files = @() if ($Assemblies -match "\*") { - # It's a glob. - # If it contains **, we might need to split. - # But Get-ChildItem -Path "Output/Debug" -Recurse -Filter "*.dll" is safer. - # Let's assume the user passes a path to a folder or a specific file pattern. - # If the user passes "Output/Debug/**/*.dll", PowerShell might expand it if passed from shell. - # But if passed as string... - # Let's just use Resolve-Path if possible, or Get-ChildItem. + # It's a glob. + # If it contains **, we might need to split. + # But Get-ChildItem -Path "Output/Debug" -Recurse -Filter "*.dll" is safer. + # Let's assume the user passes a path to a folder or a specific file pattern. + # If the user passes "Output/Debug/**/*.dll", PowerShell might expand it if passed from shell. + # But if passed as string... + # Let's just use Resolve-Path if possible, or Get-ChildItem. - # Simple approach: If it looks like a recursive glob, try to find the root. - # Actually, let's just trust Get-ChildItem to handle what it can, or iterate. - $Files = Get-ChildItem -Path $Assemblies -ErrorAction SilentlyContinue + # Simple approach: If it looks like a recursive glob, try to find the root. + # Actually, let's just trust Get-ChildItem to handle what it can, or iterate. + $Files = Get-ChildItem -Path $Assemblies -ErrorAction SilentlyContinue } else { - if (Test-Path $Assemblies -PathType Container) { - $Files = Get-ChildItem -Path $Assemblies -Recurse -Filter "*.dll" - } else { - $Files = Get-ChildItem -Path $Assemblies - } + if (Test-Path $Assemblies -PathType Container) { + $Files = Get-ChildItem -Path $Assemblies -Recurse -Filter "*.dll" + } else { + $Files = Get-ChildItem -Path $Assemblies + } } if ($Files.Count -eq 0) { - Write-Warning "No assemblies found matching: $Assemblies" - exit 0 + Write-Warning "No assemblies found matching: $Assemblies" + exit 0 } Write-Host "Scanning $($Files.Count) assemblies..." @@ -45,33 +45,33 @@ Write-Host "Scanning $($Files.Count) assemblies..." $Failed = $false foreach ($File in $Files) { - try { - # LoadFile vs LoadFrom. LoadFrom is usually better for dependencies. - $Assembly = [System.Reflection.Assembly]::LoadFrom($File.FullName) - try { - $Types = $Assembly.GetTypes() - } catch [System.Reflection.ReflectionTypeLoadException] { - $Types = $_.Types | Where-Object { $_ -ne $null } - } + try { + # LoadFile vs LoadFrom. LoadFrom is usually better for dependencies. + $Assembly = [System.Reflection.Assembly]::LoadFrom($File.FullName) + try { + $Types = $Assembly.GetTypes() + } catch [System.Reflection.ReflectionTypeLoadException] { + $Types = $_.Types | Where-Object { $_ -ne $null } + } - $TestTypes = $Types | Where-Object { - $_.IsPublic -and ($_.Name -match "Tests?$") -and -not ($_.Name -match "^Test") # Exclude "Test" prefix if needed? No, suffix "Test" or "Tests". - } + $TestTypes = $Types | Where-Object { + $_.IsPublic -and ($_.Name -match "Tests?$") -and -not ($_.Name -match "^Test") # Exclude "Test" prefix if needed? No, suffix "Test" or "Tests". + } - if ($TestTypes) { - Write-Error "Assembly '$($File.Name)' contains test types:" - foreach ($Type in $TestTypes) { - Write-Error " - $($Type.FullName)" - } - $Failed = $true - } - } catch { - Write-Warning "Failed to inspect assembly '$($File.Name)': $_" - } + if ($TestTypes) { + Write-Error "Assembly '$($File.Name)' contains test types:" + foreach ($Type in $TestTypes) { + Write-Error " - $($Type.FullName)" + } + $Failed = $true + } + } catch { + Write-Warning "Failed to inspect assembly '$($File.Name)': $_" + } } if ($Failed) { - throw "Assembly guard failed: Test types detected in production assemblies." + throw "Assembly guard failed: Test types detected in production assemblies." } Write-Host "Assembly guard passed." diff --git a/scripts/toolshims/py.ps1 b/scripts/toolshims/py.ps1 index ae2765eaaf..4f63720eda 100644 --- a/scripts/toolshims/py.ps1 +++ b/scripts/toolshims/py.ps1 @@ -6,65 +6,65 @@ Also falls back to `python` or `py` executables when available. #> param( - [Parameter(ValueFromRemainingArguments=$true)] - [string[]]$RemainingArgs + [Parameter(ValueFromRemainingArguments=$true)] + [string[]]$RemainingArgs ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Resolve-Python { - $py = Get-Command python -ErrorAction SilentlyContinue - if ($py) { return $py.Source } - $py = Get-Command py -ErrorAction SilentlyContinue - if ($py) { return $py.Source } - return $null + $py = Get-Command python -ErrorAction SilentlyContinue + if ($py) { return $py.Source } + $py = Get-Command py -ErrorAction SilentlyContinue + if ($py) { return $py.Source } + return $null } # Detect heredoc token in args: an arg like '< [CmdletBinding()] param( - [string]$Configuration = "Debug", - [string]$TestFilter = "", - [string]$TestProject = "", - [switch]$NoBuild, - [switch]$ListTests, - [ValidateSet('quiet', 'minimal', 'normal', 'detailed', 'q', 'm', 'n', 'd')] - [string]$Verbosity = "normal", - [switch]$Native, - [switch]$SkipDependencyCheck + [string]$Configuration = "Debug", + [string]$TestFilter = "", + [string]$TestProject = "", + [switch]$NoBuild, + [switch]$ListTests, + [ValidateSet('quiet', 'minimal', 'normal', 'detailed', 'q', 'm', 'n', 'd')] + [string]$Verbosity = "normal", + [switch]$Native, + [switch]$SkipDependencyCheck ) $ErrorActionPreference = 'Stop' @@ -66,8 +66,8 @@ $ErrorActionPreference = 'Stop' $helpersPath = Join-Path $PSScriptRoot "Build/Agent/FwBuildHelpers.psm1" if (-not (Test-Path $helpersPath)) { - Write-Host "[ERROR] FwBuildHelpers.psm1 not found at $helpersPath" -ForegroundColor Red - exit 1 + Write-Host "[ERROR] FwBuildHelpers.psm1 not found at $helpersPath" -ForegroundColor Red + exit 1 } Import-Module $helpersPath -Force @@ -78,423 +78,423 @@ Stop-ConflictingProcesses -IncludeOmniSharp # ============================================================================= $cleanupArgs = @{ - IncludeOmniSharp = $true - RepoRoot = $PSScriptRoot + IncludeOmniSharp = $true + RepoRoot = $PSScriptRoot } $testExitCode = 0 try { - Invoke-WithFileLockRetry -Context "FieldWorks test run" -IncludeOmniSharp -Action { - # Initialize VS environment - Initialize-VsDevEnvironment - Test-CvtresCompatibility - - if (-not $SkipDependencyCheck) { - $verifyScript = Join-Path $PSScriptRoot "Build/Agent/Verify-FwDependencies.ps1" - if (Test-Path $verifyScript) { - Write-Host "Running dependency preflight..." -ForegroundColor Cyan - & $verifyScript -FailOnMissing - if ($LASTEXITCODE -ne 0) { - throw "Dependency preflight failed. Re-run with -SkipDependencyCheck only if you are actively debugging environment setup." - } - } - } - - # Set architecture (x64-only) - $env:arch = 'x64' - - # Stop conflicting processes - Stop-ConflictingProcesses @cleanupArgs - - # Clean stale obj folders (only if not building, as build.ps1 does it too) - if ($NoBuild) { - Remove-StaleObjFolders -RepoRoot $PSScriptRoot - } - - # ============================================================================= - # Native Tests Dispatch - # ============================================================================= - - if ($Native) { - $cppScript = Join-Path $PSScriptRoot "Build/scripts/Invoke-CppTest.ps1" - if (-not (Test-Path $cppScript)) { - Write-Host "[ERROR] Native test script not found at $cppScript" -ForegroundColor Red - $script:testExitCode = 1 - return - } - - $action = if ($NoBuild) { 'Run' } else { 'BuildAndRun' } - - # Map TestProject to Invoke-CppTest expectations - $projectsToRun = @() - if ($TestProject) { - if ($TestProject -match 'TestViews') { $projectsToRun += 'TestViews' } - elseif ($TestProject -match 'TestGeneric') { $projectsToRun += 'TestGeneric' } - else { - Write-Host "[WARN] Unknown native project '$TestProject'. Defaulting to TestGeneric." -ForegroundColor Yellow - $projectsToRun += 'TestGeneric' - } - } - else { - $projectsToRun += 'TestGeneric', 'TestViews' - } - - $overallExitCode = 0 - foreach ($proj in $projectsToRun) { - Write-Host "Dispatching $proj to Invoke-CppTest.ps1..." -ForegroundColor Cyan - & $cppScript -Action $action -TestProject $proj -Configuration $Configuration - if ($LASTEXITCODE -ne 0) { - $overallExitCode = $LASTEXITCODE - Write-Host "[ERROR] $proj failed with exit code $LASTEXITCODE" -ForegroundColor Red - } - } - $script:testExitCode = $overallExitCode - return - } - - # ============================================================================= - # Build (unless -NoBuild) - # ============================================================================= - - if (-not $NoBuild) { - $normalizedTestProjectForBuild = $TestProject.Replace('\\', '/').TrimEnd('/') - - if ($TestProject -and ($normalizedTestProjectForBuild -match '^Build/Src/FwBuildTasks($|/)' -or $normalizedTestProjectForBuild -match '/FwBuildTasksTests$' -or $normalizedTestProjectForBuild -match '^FwBuildTasksTests$')) { - Write-Host "Building FwBuildTasks before running tests..." -ForegroundColor Cyan - - $fwBuildTasksOutputDir = Join-Path $PSScriptRoot "BuildTools/FwBuildTasks/$Configuration/" - $fwBuildTasksIntermediateDir = Join-Path $PSScriptRoot "Obj/Build/Src/FwBuildTasks/$Configuration/" - $fwBuildTasksIntermediateDirX64 = Join-Path $PSScriptRoot "Obj/Build/Src/FwBuildTasks/x64/$Configuration/" - - foreach ($dirToClean in @($fwBuildTasksIntermediateDir, $fwBuildTasksIntermediateDirX64, $fwBuildTasksOutputDir)) { - if (Test-Path $dirToClean) { - try { - Remove-Item -LiteralPath $dirToClean -Recurse -Force -ErrorAction Stop - } - catch { - Write-Host "[ERROR] Failed to clean $dirToClean before rebuilding FwBuildTasks." -ForegroundColor Red - throw - } - } - } - New-Item -Path $fwBuildTasksOutputDir -ItemType Directory -Force | Out-Null - - Invoke-MSBuild ` - -Arguments @( - 'Build/Src/FwBuildTasks/FwBuildTasks.csproj', - '/t:Restore;Clean;Build', - "/p:Configuration=$Configuration", - '/p:Platform=AnyCPU', - "/p:FwBuildTasksOutputPath=$fwBuildTasksOutputDir", - '/p:SkipFwBuildTasksAssemblyCheck=true', - '/p:SkipFwBuildTasksUsingTask=true', - '/p:SkipGenerateFwTargets=true', - '/p:SkipSetupTargets=true', - '/nr:false', - '/v:minimal', - '/nologo' - ) ` - -Description 'FwBuildTasks (Tests)' - - Write-Host "" - } - else { - Write-Host "Building before running tests..." -ForegroundColor Cyan - & "$PSScriptRoot\build.ps1" -Configuration $Configuration -BuildTests - if ($LASTEXITCODE -ne 0) { - Write-Host "[ERROR] Build failed. Fix build errors before running tests." -ForegroundColor Red - $script:testExitCode = $LASTEXITCODE - return - } - Write-Host "" - } - } - - # ============================================================================= - # Find Test Assemblies - # ============================================================================= - - # ============================================================================= - # Prevent modal dialogs during tests - # ============================================================================= - - # FieldWorks native + managed assertion infrastructure may show modal UI unless - # explicitly disabled. Ensure the test host inherits these settings even when - # invoked outside the .runsettings flow. - $env:AssertUiEnabled = 'false' - $env:AssertExceptionEnabled = 'true' - - $outputDir = Join-Path $PSScriptRoot "Output/$Configuration" - - if ($TestProject) { - $normalizedTestProject = $TestProject.Replace('\\', '/').TrimEnd('/') - - # Specific project/DLL requested - if ($normalizedTestProject -match '^Build/Src/FwBuildTasks($|/)' -or $normalizedTestProject -match '/FwBuildTasksTests$' -or $normalizedTestProject -match '^FwBuildTasksTests$') { - # Build tasks tests live in the FwBuildTasks project (not a separate *Tests project). - # build.ps1 bootstraps this into BuildTools/FwBuildTasks//FwBuildTasks.dll. - $testDlls = @(Join-Path $PSScriptRoot "BuildTools/FwBuildTasks/$Configuration/FwBuildTasks.dll") - } - elseif ($TestProject -match '\.dll$') { - $testDlls = @(Join-Path $outputDir (Split-Path $TestProject -Leaf)) - } - else { - # Assume it's a project path, find the DLL - $projectName = Split-Path $TestProject -Leaf - if ($projectName -notmatch 'Tests?$') { - $projectName = "${projectName}Tests" - } - $testDlls = @(Join-Path $outputDir "$projectName.dll") - } - } - else { - # Find all test DLLs, excluding: - # - Test framework DLLs (nunit, Microsoft.*, xunit) - # - External NuGet package tests (SIL.LCModel.*.Tests) - these test liblcm, not FieldWorks - # - SIL.WritingSystems.Tests - NuGet-delivered libpalaso test DLL compiled against - # NUnit 3.13.3; loading it causes binding-redirect failures (not a FieldWorks test) - $testDlls = Get-ChildItem -Path $outputDir -Filter "*Tests.dll" -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notmatch '^nunit|^Microsoft|^xunit|^SIL\.LCModel|^SIL\.WritingSystems\.Tests' } | - Select-Object -ExpandProperty FullName - } - - $missingTestDlls = @($testDlls | Where-Object { -not (Test-Path $_) }) - if ($missingTestDlls.Count -gt 0) { - Write-Host "[ERROR] One or more requested test assemblies were not found:" -ForegroundColor Red - foreach ($missing in $missingTestDlls) { - Write-Host " - $missing" -ForegroundColor Red - } - Write-Host " If this is a build tasks test, run: .\\build.ps1 -Configuration $Configuration" -ForegroundColor Yellow - $script:testExitCode = 1 - return - } - - if (-not $testDlls -or $testDlls.Count -eq 0) { - Write-Host "[ERROR] No test assemblies found in $outputDir" -ForegroundColor Red - Write-Host " Run with -BuildTests first: .\build.ps1 -BuildTests" -ForegroundColor Yellow - $script:testExitCode = 1 - return - } - - Write-Host "Found $($testDlls.Count) test assembly(ies)" -ForegroundColor Cyan - - # ============================================================================= - # Ensure activation context manifests are present - # ============================================================================= - - # Many tests rely on ActivationContextHelper("FieldWorks.Tests.manifest") (and related manifests) - # being present in the working directory. When a test assembly lives outside Output/ - # (e.g., Lib/src/*/bin), copy the manifests so reg-free COM activation works. - $manifestFiles = Get-ChildItem -Path $outputDir -Filter "*.manifest" -ErrorAction SilentlyContinue - if ($manifestFiles -and $manifestFiles.Count -gt 0) { - foreach ($testDll in $testDlls) { - $testDir = Split-Path $testDll -Parent - if ($testDir -and ($testDir.TrimEnd('\\') -ne $outputDir.TrimEnd('\\'))) { - foreach ($manifest in $manifestFiles) { - $dest = Join-Path $testDir $manifest.Name - if (-not (Test-Path -LiteralPath $dest -PathType Leaf)) { - Copy-Item -LiteralPath $manifest.FullName -Destination $dest -Force - } - } - } - } - } - - # ============================================================================= - # Find VSTest - # ============================================================================= - - $vstestPath = Get-VSTestPath - - if (-not $vstestPath) { - Write-Host "[ERROR] vstest.console.exe not found" -ForegroundColor Red - Write-Host " Install Visual Studio Build Tools with test components or add vstest to PATH" -ForegroundColor Yellow - $script:testExitCode = 1 - return - } - - Write-Host "Found vstest.console.exe: $vstestPath" -ForegroundColor Gray - - # ============================================================================= - # Build VSTest Arguments - # ============================================================================= - - $resultsDir = Join-Path $outputDir "TestResults" - if (-not (Test-Path $resultsDir)) { - New-Item -Path $resultsDir -ItemType Directory -Force | Out-Null - } - - # ============================================================================= - # ICU_DATA setup (dev/test convenience) - # ============================================================================= - - function Test-IcuDataDir([string]$dir) { - if ([string]::IsNullOrWhiteSpace($dir)) { return $false } - - # Some machines may have ICU_DATA set to a list. Prefer the first entry. - $firstDir = $dir.Split(';') | Select-Object -First 1 - if (-not (Test-Path -LiteralPath $firstDir -PathType Container)) { return $false } - - return (Test-Path -LiteralPath (Join-Path $firstDir 'nfc_fw.nrm') -PathType Leaf) -and - (Test-Path -LiteralPath (Join-Path $firstDir 'nfkc_fw.nrm') -PathType Leaf) - } - - $icuDataNeedsConfig = -not (Test-IcuDataDir $env:ICU_DATA) - if ($icuDataNeedsConfig) { - try { - $distFiles = Join-Path $PSScriptRoot 'DistFiles' - if (Test-Path $distFiles) { - $icuDataDir = $null - - $icuRoots = Get-ChildItem -Path $distFiles -Directory -Filter 'Icu*' -ErrorAction SilentlyContinue - foreach ($icuRoot in $icuRoots) { - $candidate = Get-ChildItem -Path $icuRoot.FullName -Directory -Filter 'icudt*l' -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($candidate) { - $icuDataDir = $candidate.FullName - break - } - } - - if (-not $icuDataDir) { - $candidate = Get-ChildItem -Path $distFiles -Directory -Filter 'icudt*l' -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($candidate) { - $icuDataDir = $candidate.FullName - } - } - - if ($icuDataDir) { - $env:FW_ICU_DATA_DIR = $icuDataDir - $env:ICU_DATA = $icuDataDir - Write-Host "Configured ICU_DATA=$icuDataDir" -ForegroundColor Gray - } - elseif ($env:ICU_DATA) { - Write-Host "ICU_DATA is set but invalid (missing nfc_fw.nrm/nfkc_fw.nrm): $($env:ICU_DATA)" -ForegroundColor Yellow - } - } - } - catch { - # Best-effort: tests may still run on machines where ICU_DATA is already configured. - } - } - - $runSettingsPath = Join-Path $PSScriptRoot "Test.runsettings" - - $vstestArgs = @() - $vstestArgs += $testDlls - $vstestArgs += "/Platform:x64" - $vstestArgs += "/Settings:$runSettingsPath" - $vstestArgs += "/ResultsDirectory:$resultsDir" - - # Logger configuration - verbosity goes with the console logger - $verbosityMap = @{ - 'quiet' = 'quiet'; 'q' = 'quiet' - 'minimal' = 'minimal'; 'm' = 'minimal' - 'normal' = 'normal'; 'n' = 'normal' - 'detailed' = 'detailed'; 'd' = 'detailed' - } - $vstestVerbosity = $verbosityMap[$Verbosity] - $vstestArgs += "/Logger:trx" - $vstestArgs += "/Logger:console;verbosity=$vstestVerbosity" - - if ($TestFilter) { - $vstestArgs += "/TestCaseFilter:$TestFilter" - } - - if ($ListTests) { - $vstestArgs += "/ListTests" - } - - # ============================================================================= - # Run Tests - # ============================================================================= - - Write-Host "" - Write-Host "Running tests..." -ForegroundColor Cyan - Write-Host " vstest.console.exe $($vstestArgs -join ' ')" -ForegroundColor DarkGray - Write-Host "" - - $previousEap = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - $vstestOutput = & $vstestPath $vstestArgs 2>&1 | Tee-Object -Variable testOutput - $script:testExitCode = $LASTEXITCODE - } - finally { - $ErrorActionPreference = $previousEap - } - - $vstestLogPath = Join-Path $resultsDir "vstest.console.log" - try { - $testOutput | Out-File -FilePath $vstestLogPath -Encoding UTF8 - Write-Host "VSTest output log: $vstestLogPath" -ForegroundColor Gray - } - catch { - Write-Host "[WARN] Failed to write VSTest output log to $vstestLogPath" -ForegroundColor Yellow - } - - if ($script:testExitCode -ne 0) { - $outputText = ($testOutput | Out-String) - if ($outputText -match 'used by another process|file is locked|cannot access the file') { - throw "Detected possible file is locked during vstest execution." - } - } - - # ============================================================================= - # Workaround: multi-assembly VSTest may fail with exit code -1 and minimal output - # ============================================================================= - - if (-not $ListTests -and $testDlls.Count -gt 1 -and $script:testExitCode -eq -1) { - Write-Host "[WARN] vstest.console.exe returned exit code -1 with multiple test assemblies. Retrying per-assembly to isolate failures." -ForegroundColor Yellow - - $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' - $overallExitCode = 0 - - foreach ($testDll in $testDlls) { - $dllName = [System.IO.Path]::GetFileNameWithoutExtension($testDll) - Write-Host "" - Write-Host "Running tests in $dllName..." -ForegroundColor Cyan - - $singleArgs = @() - $singleArgs += $testDll - $singleArgs += "/Platform:x64" - $singleArgs += "/Settings:$runSettingsPath" - $singleArgs += "/ResultsDirectory:$resultsDir" - $singleArgs += "/Logger:trx;LogFileName=${dllName}_${timestamp}.trx" - $singleArgs += "/Logger:console;verbosity=$vstestVerbosity" - - if ($TestFilter) { - $singleArgs += "/TestCaseFilter:$TestFilter" - } - - $singleOutput = & $vstestPath $singleArgs 2>&1 | Tee-Object -Variable singleTestOutput - $singleExitCode = $LASTEXITCODE - if ($singleExitCode -ne 0 -and $overallExitCode -eq 0) { - $overallExitCode = $singleExitCode - } - - $singleLogPath = Join-Path $resultsDir "vstest.${dllName}.console.log" - try { - $singleTestOutput | Out-File -FilePath $singleLogPath -Encoding UTF8 - } - catch { - Write-Host "[WARN] Failed to write VSTest output log to $singleLogPath" -ForegroundColor Yellow - } - - if ($singleExitCode -ne 0) { - $singleOutputText = ($singleTestOutput | Out-String) - if ($singleOutputText -match 'used by another process|file is locked|cannot access the file') { - throw "Detected possible file is locked during vstest execution." - } - } - } - - $script:testExitCode = $overallExitCode - } - } + Invoke-WithFileLockRetry -Context "FieldWorks test run" -IncludeOmniSharp -Action { + # Initialize VS environment + Initialize-VsDevEnvironment + Test-CvtresCompatibility + + if (-not $SkipDependencyCheck) { + $verifyScript = Join-Path $PSScriptRoot "Build/Agent/Verify-FwDependencies.ps1" + if (Test-Path $verifyScript) { + Write-Host "Running dependency preflight..." -ForegroundColor Cyan + & $verifyScript -FailOnMissing + if ($LASTEXITCODE -ne 0) { + throw "Dependency preflight failed. Re-run with -SkipDependencyCheck only if you are actively debugging environment setup." + } + } + } + + # Set architecture (x64-only) + $env:arch = 'x64' + + # Stop conflicting processes + Stop-ConflictingProcesses @cleanupArgs + + # Clean stale obj folders (only if not building, as build.ps1 does it too) + if ($NoBuild) { + Remove-StaleObjFolders -RepoRoot $PSScriptRoot + } + + # ============================================================================= + # Native Tests Dispatch + # ============================================================================= + + if ($Native) { + $cppScript = Join-Path $PSScriptRoot "Build/scripts/Invoke-CppTest.ps1" + if (-not (Test-Path $cppScript)) { + Write-Host "[ERROR] Native test script not found at $cppScript" -ForegroundColor Red + $script:testExitCode = 1 + return + } + + $action = if ($NoBuild) { 'Run' } else { 'BuildAndRun' } + + # Map TestProject to Invoke-CppTest expectations + $projectsToRun = @() + if ($TestProject) { + if ($TestProject -match 'TestViews') { $projectsToRun += 'TestViews' } + elseif ($TestProject -match 'TestGeneric') { $projectsToRun += 'TestGeneric' } + else { + Write-Host "[WARN] Unknown native project '$TestProject'. Defaulting to TestGeneric." -ForegroundColor Yellow + $projectsToRun += 'TestGeneric' + } + } + else { + $projectsToRun += 'TestGeneric', 'TestViews' + } + + $overallExitCode = 0 + foreach ($proj in $projectsToRun) { + Write-Host "Dispatching $proj to Invoke-CppTest.ps1..." -ForegroundColor Cyan + & $cppScript -Action $action -TestProject $proj -Configuration $Configuration + if ($LASTEXITCODE -ne 0) { + $overallExitCode = $LASTEXITCODE + Write-Host "[ERROR] $proj failed with exit code $LASTEXITCODE" -ForegroundColor Red + } + } + $script:testExitCode = $overallExitCode + return + } + + # ============================================================================= + # Build (unless -NoBuild) + # ============================================================================= + + if (-not $NoBuild) { + $normalizedTestProjectForBuild = $TestProject.Replace('\\', '/').TrimEnd('/') + + if ($TestProject -and ($normalizedTestProjectForBuild -match '^Build/Src/FwBuildTasks($|/)' -or $normalizedTestProjectForBuild -match '/FwBuildTasksTests$' -or $normalizedTestProjectForBuild -match '^FwBuildTasksTests$')) { + Write-Host "Building FwBuildTasks before running tests..." -ForegroundColor Cyan + + $fwBuildTasksOutputDir = Join-Path $PSScriptRoot "BuildTools/FwBuildTasks/$Configuration/" + $fwBuildTasksIntermediateDir = Join-Path $PSScriptRoot "Obj/Build/Src/FwBuildTasks/$Configuration/" + $fwBuildTasksIntermediateDirX64 = Join-Path $PSScriptRoot "Obj/Build/Src/FwBuildTasks/x64/$Configuration/" + + foreach ($dirToClean in @($fwBuildTasksIntermediateDir, $fwBuildTasksIntermediateDirX64, $fwBuildTasksOutputDir)) { + if (Test-Path $dirToClean) { + try { + Remove-Item -LiteralPath $dirToClean -Recurse -Force -ErrorAction Stop + } + catch { + Write-Host "[ERROR] Failed to clean $dirToClean before rebuilding FwBuildTasks." -ForegroundColor Red + throw + } + } + } + New-Item -Path $fwBuildTasksOutputDir -ItemType Directory -Force | Out-Null + + Invoke-MSBuild ` + -Arguments @( + 'Build/Src/FwBuildTasks/FwBuildTasks.csproj', + '/t:Restore;Clean;Build', + "/p:Configuration=$Configuration", + '/p:Platform=AnyCPU', + "/p:FwBuildTasksOutputPath=$fwBuildTasksOutputDir", + '/p:SkipFwBuildTasksAssemblyCheck=true', + '/p:SkipFwBuildTasksUsingTask=true', + '/p:SkipGenerateFwTargets=true', + '/p:SkipSetupTargets=true', + '/nr:false', + '/v:minimal', + '/nologo' + ) ` + -Description 'FwBuildTasks (Tests)' + + Write-Host "" + } + else { + Write-Host "Building before running tests..." -ForegroundColor Cyan + & "$PSScriptRoot\build.ps1" -Configuration $Configuration -BuildTests + if ($LASTEXITCODE -ne 0) { + Write-Host "[ERROR] Build failed. Fix build errors before running tests." -ForegroundColor Red + $script:testExitCode = $LASTEXITCODE + return + } + Write-Host "" + } + } + + # ============================================================================= + # Find Test Assemblies + # ============================================================================= + + # ============================================================================= + # Prevent modal dialogs during tests + # ============================================================================= + + # FieldWorks native + managed assertion infrastructure may show modal UI unless + # explicitly disabled. Ensure the test host inherits these settings even when + # invoked outside the .runsettings flow. + $env:AssertUiEnabled = 'false' + $env:AssertExceptionEnabled = 'true' + + $outputDir = Join-Path $PSScriptRoot "Output/$Configuration" + + if ($TestProject) { + $normalizedTestProject = $TestProject.Replace('\\', '/').TrimEnd('/') + + # Specific project/DLL requested + if ($normalizedTestProject -match '^Build/Src/FwBuildTasks($|/)' -or $normalizedTestProject -match '/FwBuildTasksTests$' -or $normalizedTestProject -match '^FwBuildTasksTests$') { + # Build tasks tests live in the FwBuildTasks project (not a separate *Tests project). + # build.ps1 bootstraps this into BuildTools/FwBuildTasks//FwBuildTasks.dll. + $testDlls = @(Join-Path $PSScriptRoot "BuildTools/FwBuildTasks/$Configuration/FwBuildTasks.dll") + } + elseif ($TestProject -match '\.dll$') { + $testDlls = @(Join-Path $outputDir (Split-Path $TestProject -Leaf)) + } + else { + # Assume it's a project path, find the DLL + $projectName = Split-Path $TestProject -Leaf + if ($projectName -notmatch 'Tests?$') { + $projectName = "${projectName}Tests" + } + $testDlls = @(Join-Path $outputDir "$projectName.dll") + } + } + else { + # Find all test DLLs, excluding: + # - Test framework DLLs (nunit, Microsoft.*, xunit) + # - External NuGet package tests (SIL.LCModel.*.Tests) - these test liblcm, not FieldWorks + # - SIL.WritingSystems.Tests - NuGet-delivered libpalaso test DLL compiled against + # NUnit 3.13.3; loading it causes binding-redirect failures (not a FieldWorks test) + $testDlls = Get-ChildItem -Path $outputDir -Filter "*Tests.dll" -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notmatch '^nunit|^Microsoft|^xunit|^SIL\.LCModel|^SIL\.WritingSystems\.Tests' } | + Select-Object -ExpandProperty FullName + } + + $missingTestDlls = @($testDlls | Where-Object { -not (Test-Path $_) }) + if ($missingTestDlls.Count -gt 0) { + Write-Host "[ERROR] One or more requested test assemblies were not found:" -ForegroundColor Red + foreach ($missing in $missingTestDlls) { + Write-Host " - $missing" -ForegroundColor Red + } + Write-Host " If this is a build tasks test, run: .\\build.ps1 -Configuration $Configuration" -ForegroundColor Yellow + $script:testExitCode = 1 + return + } + + if (-not $testDlls -or $testDlls.Count -eq 0) { + Write-Host "[ERROR] No test assemblies found in $outputDir" -ForegroundColor Red + Write-Host " Run with -BuildTests first: .\build.ps1 -BuildTests" -ForegroundColor Yellow + $script:testExitCode = 1 + return + } + + Write-Host "Found $($testDlls.Count) test assembly(ies)" -ForegroundColor Cyan + + # ============================================================================= + # Ensure activation context manifests are present + # ============================================================================= + + # Many tests rely on ActivationContextHelper("FieldWorks.Tests.manifest") (and related manifests) + # being present in the working directory. When a test assembly lives outside Output/ + # (e.g., Lib/src/*/bin), copy the manifests so reg-free COM activation works. + $manifestFiles = Get-ChildItem -Path $outputDir -Filter "*.manifest" -ErrorAction SilentlyContinue + if ($manifestFiles -and $manifestFiles.Count -gt 0) { + foreach ($testDll in $testDlls) { + $testDir = Split-Path $testDll -Parent + if ($testDir -and ($testDir.TrimEnd('\\') -ne $outputDir.TrimEnd('\\'))) { + foreach ($manifest in $manifestFiles) { + $dest = Join-Path $testDir $manifest.Name + if (-not (Test-Path -LiteralPath $dest -PathType Leaf)) { + Copy-Item -LiteralPath $manifest.FullName -Destination $dest -Force + } + } + } + } + } + + # ============================================================================= + # Find VSTest + # ============================================================================= + + $vstestPath = Get-VSTestPath + + if (-not $vstestPath) { + Write-Host "[ERROR] vstest.console.exe not found" -ForegroundColor Red + Write-Host " Install Visual Studio Build Tools with test components or add vstest to PATH" -ForegroundColor Yellow + $script:testExitCode = 1 + return + } + + Write-Host "Found vstest.console.exe: $vstestPath" -ForegroundColor Gray + + # ============================================================================= + # Build VSTest Arguments + # ============================================================================= + + $resultsDir = Join-Path $outputDir "TestResults" + if (-not (Test-Path $resultsDir)) { + New-Item -Path $resultsDir -ItemType Directory -Force | Out-Null + } + + # ============================================================================= + # ICU_DATA setup (dev/test convenience) + # ============================================================================= + + function Test-IcuDataDir([string]$dir) { + if ([string]::IsNullOrWhiteSpace($dir)) { return $false } + + # Some machines may have ICU_DATA set to a list. Prefer the first entry. + $firstDir = $dir.Split(';') | Select-Object -First 1 + if (-not (Test-Path -LiteralPath $firstDir -PathType Container)) { return $false } + + return (Test-Path -LiteralPath (Join-Path $firstDir 'nfc_fw.nrm') -PathType Leaf) -and + (Test-Path -LiteralPath (Join-Path $firstDir 'nfkc_fw.nrm') -PathType Leaf) + } + + $icuDataNeedsConfig = -not (Test-IcuDataDir $env:ICU_DATA) + if ($icuDataNeedsConfig) { + try { + $distFiles = Join-Path $PSScriptRoot 'DistFiles' + if (Test-Path $distFiles) { + $icuDataDir = $null + + $icuRoots = Get-ChildItem -Path $distFiles -Directory -Filter 'Icu*' -ErrorAction SilentlyContinue + foreach ($icuRoot in $icuRoots) { + $candidate = Get-ChildItem -Path $icuRoot.FullName -Directory -Filter 'icudt*l' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($candidate) { + $icuDataDir = $candidate.FullName + break + } + } + + if (-not $icuDataDir) { + $candidate = Get-ChildItem -Path $distFiles -Directory -Filter 'icudt*l' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($candidate) { + $icuDataDir = $candidate.FullName + } + } + + if ($icuDataDir) { + $env:FW_ICU_DATA_DIR = $icuDataDir + $env:ICU_DATA = $icuDataDir + Write-Host "Configured ICU_DATA=$icuDataDir" -ForegroundColor Gray + } + elseif ($env:ICU_DATA) { + Write-Host "ICU_DATA is set but invalid (missing nfc_fw.nrm/nfkc_fw.nrm): $($env:ICU_DATA)" -ForegroundColor Yellow + } + } + } + catch { + # Best-effort: tests may still run on machines where ICU_DATA is already configured. + } + } + + $runSettingsPath = Join-Path $PSScriptRoot "Test.runsettings" + + $vstestArgs = @() + $vstestArgs += $testDlls + $vstestArgs += "/Platform:x64" + $vstestArgs += "/Settings:$runSettingsPath" + $vstestArgs += "/ResultsDirectory:$resultsDir" + + # Logger configuration - verbosity goes with the console logger + $verbosityMap = @{ + 'quiet' = 'quiet'; 'q' = 'quiet' + 'minimal' = 'minimal'; 'm' = 'minimal' + 'normal' = 'normal'; 'n' = 'normal' + 'detailed' = 'detailed'; 'd' = 'detailed' + } + $vstestVerbosity = $verbosityMap[$Verbosity] + $vstestArgs += "/Logger:trx" + $vstestArgs += "/Logger:console;verbosity=$vstestVerbosity" + + if ($TestFilter) { + $vstestArgs += "/TestCaseFilter:$TestFilter" + } + + if ($ListTests) { + $vstestArgs += "/ListTests" + } + + # ============================================================================= + # Run Tests + # ============================================================================= + + Write-Host "" + Write-Host "Running tests..." -ForegroundColor Cyan + Write-Host " vstest.console.exe $($vstestArgs -join ' ')" -ForegroundColor DarkGray + Write-Host "" + + $previousEap = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + $vstestOutput = & $vstestPath $vstestArgs 2>&1 | Tee-Object -Variable testOutput + $script:testExitCode = $LASTEXITCODE + } + finally { + $ErrorActionPreference = $previousEap + } + + $vstestLogPath = Join-Path $resultsDir "vstest.console.log" + try { + $testOutput | Out-File -FilePath $vstestLogPath -Encoding UTF8 + Write-Host "VSTest output log: $vstestLogPath" -ForegroundColor Gray + } + catch { + Write-Host "[WARN] Failed to write VSTest output log to $vstestLogPath" -ForegroundColor Yellow + } + + if ($script:testExitCode -ne 0) { + $outputText = ($testOutput | Out-String) + if ($outputText -match 'used by another process|file is locked|cannot access the file') { + throw "Detected possible file is locked during vstest execution." + } + } + + # ============================================================================= + # Workaround: multi-assembly VSTest may fail with exit code -1 and minimal output + # ============================================================================= + + if (-not $ListTests -and $testDlls.Count -gt 1 -and $script:testExitCode -eq -1) { + Write-Host "[WARN] vstest.console.exe returned exit code -1 with multiple test assemblies. Retrying per-assembly to isolate failures." -ForegroundColor Yellow + + $timestamp = Get-Date -Format 'yyyyMMdd_HHmmss' + $overallExitCode = 0 + + foreach ($testDll in $testDlls) { + $dllName = [System.IO.Path]::GetFileNameWithoutExtension($testDll) + Write-Host "" + Write-Host "Running tests in $dllName..." -ForegroundColor Cyan + + $singleArgs = @() + $singleArgs += $testDll + $singleArgs += "/Platform:x64" + $singleArgs += "/Settings:$runSettingsPath" + $singleArgs += "/ResultsDirectory:$resultsDir" + $singleArgs += "/Logger:trx;LogFileName=${dllName}_${timestamp}.trx" + $singleArgs += "/Logger:console;verbosity=$vstestVerbosity" + + if ($TestFilter) { + $singleArgs += "/TestCaseFilter:$TestFilter" + } + + $singleOutput = & $vstestPath $singleArgs 2>&1 | Tee-Object -Variable singleTestOutput + $singleExitCode = $LASTEXITCODE + if ($singleExitCode -ne 0 -and $overallExitCode -eq 0) { + $overallExitCode = $singleExitCode + } + + $singleLogPath = Join-Path $resultsDir "vstest.${dllName}.console.log" + try { + $singleTestOutput | Out-File -FilePath $singleLogPath -Encoding UTF8 + } + catch { + Write-Host "[WARN] Failed to write VSTest output log to $singleLogPath" -ForegroundColor Yellow + } + + if ($singleExitCode -ne 0) { + $singleOutputText = ($singleTestOutput | Out-String) + if ($singleOutputText -match 'used by another process|file is locked|cannot access the file') { + throw "Detected possible file is locked during vstest execution." + } + } + } + + $script:testExitCode = $overallExitCode + } + } } finally { - Stop-ConflictingProcesses @cleanupArgs + Stop-ConflictingProcesses @cleanupArgs } # ============================================================================= @@ -503,55 +503,55 @@ finally { $vstestLogPath = Join-Path $PSScriptRoot "Output/$Configuration/TestResults/vstest.console.log" if ($testExitCode -ne 0 -and (Test-Path $vstestLogPath)) { - Write-Host "" - Write-Host "========== FAILURE SUMMARY ==========" -ForegroundColor Red - - $logLines = Get-Content $vstestLogPath - $failedTests = @() - for ($i = 0; $i -lt $logLines.Count; $i++) { - if ($logLines[$i] -match '^\s+Failed\s+(\S.*)') { - $testName = $Matches[1].Trim() - $errorMsg = "" - # Look ahead for "Error Message:" line - if ($i + 2 -lt $logLines.Count -and $logLines[$i + 1] -match '^\s+Error Message:') { - $errorMsg = $logLines[$i + 2].Trim() - } - $failedTests += [PSCustomObject]@{ Test = $testName; Error = $errorMsg } - } - } - - if ($failedTests.Count -gt 0) { - # Group by error message for a compact summary - $groups = $failedTests | Group-Object Error | Sort-Object Count -Descending - foreach ($grp in $groups) { - Write-Host "" - Write-Host " [$($grp.Count) failure(s)] $($grp.Name)" -ForegroundColor Yellow - # Show up to 5 test names per group - $shown = 0 - foreach ($item in $grp.Group) { - if ($shown -ge 5) { - Write-Host " ... and $($grp.Count - 5) more" -ForegroundColor DarkGray - break - } - Write-Host " - $($item.Test)" -ForegroundColor Gray - $shown++ - } - } - Write-Host "" - Write-Host " Total: $($failedTests.Count) failed test(s)" -ForegroundColor Red - } - - Write-Host "=====================================" -ForegroundColor Red - Write-Host " Full log: $vstestLogPath" -ForegroundColor Gray + Write-Host "" + Write-Host "========== FAILURE SUMMARY ==========" -ForegroundColor Red + + $logLines = Get-Content $vstestLogPath + $failedTests = @() + for ($i = 0; $i -lt $logLines.Count; $i++) { + if ($logLines[$i] -match '^\s+Failed\s+(\S.*)') { + $testName = $Matches[1].Trim() + $errorMsg = "" + # Look ahead for "Error Message:" line + if ($i + 2 -lt $logLines.Count -and $logLines[$i + 1] -match '^\s+Error Message:') { + $errorMsg = $logLines[$i + 2].Trim() + } + $failedTests += [PSCustomObject]@{ Test = $testName; Error = $errorMsg } + } + } + + if ($failedTests.Count -gt 0) { + # Group by error message for a compact summary + $groups = $failedTests | Group-Object Error | Sort-Object Count -Descending + foreach ($grp in $groups) { + Write-Host "" + Write-Host " [$($grp.Count) failure(s)] $($grp.Name)" -ForegroundColor Yellow + # Show up to 5 test names per group + $shown = 0 + foreach ($item in $grp.Group) { + if ($shown -ge 5) { + Write-Host " ... and $($grp.Count - 5) more" -ForegroundColor DarkGray + break + } + Write-Host " - $($item.Test)" -ForegroundColor Gray + $shown++ + } + } + Write-Host "" + Write-Host " Total: $($failedTests.Count) failed test(s)" -ForegroundColor Red + } + + Write-Host "=====================================" -ForegroundColor Red + Write-Host " Full log: $vstestLogPath" -ForegroundColor Gray } if ($testExitCode -eq 0) { - Write-Host "" - Write-Host "[PASS] All tests passed" -ForegroundColor Green + Write-Host "" + Write-Host "[PASS] All tests passed" -ForegroundColor Green } else { - Write-Host "" - Write-Host "[FAIL] Some tests failed (exit code: $testExitCode)" -ForegroundColor Red + Write-Host "" + Write-Host "[FAIL] Some tests failed (exit code: $testExitCode)" -ForegroundColor Red } exit $testExitCode From d7a501092bd97c32b43985d1f518c387fe11e2e8 Mon Sep 17 00:00:00 2001 From: Hasso Date: Tue, 31 Mar 2026 16:13:55 -0500 Subject: [PATCH 2/3] Oops, I did it --- Build/Agent/Setup-InstallerBuild.ps1 | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Build/Agent/Setup-InstallerBuild.ps1 b/Build/Agent/Setup-InstallerBuild.ps1 index 25c70ab010..bb4b203c76 100644 --- a/Build/Agent/Setup-InstallerBuild.ps1 +++ b/Build/Agent/Setup-InstallerBuild.ps1 @@ -126,13 +126,13 @@ if (Test-Path $vsWhere) { $nmakeExists = Get-ChildItem -Path $nmakePath -ErrorAction SilentlyContinue | Select-Object -First 1 if ($nmakeExists) { Write-Host "[WARN] VS Developer environment NOT active" -ForegroundColor Yellow - Write-Host " nmake.exe exists but is not in PATH" -ForegroundColor Yellow - Write-Host " Run builds from VS Developer Command Prompt or use:" -ForegroundColor Yellow - Write-Host " cmd /c `"call `"$vsDevCmd`" -arch=amd64 && msbuild ...`"" -ForegroundColor Cyan + Write-Host " nmake.exe exists but is not in PATH" -ForegroundColor Yellow + Write-Host " Run builds from VS Developer Command Prompt or use:" -ForegroundColor Yellow + Write-Host " cmd /c `"call `"$vsDevCmd`" -arch=amd64 && msbuild ...`"" -ForegroundColor Cyan $warnings += "VS Developer environment not active (nmake not in PATH)" } else { Write-Host "[MISSING] C++ build tools (nmake.exe) not found" -ForegroundColor Red - Write-Host " Install 'Desktop development with C++' workload in VS Installer" -ForegroundColor Red + Write-Host " Install 'Desktop development with C++' workload in VS Installer" -ForegroundColor Red $issues += "C++ build tools not installed (nmake.exe missing)" } } @@ -175,7 +175,7 @@ foreach ($repo in $helperRepos) { if ($missingRepos.Count -gt 0 -and -not $ValidateOnly) { Write-Host "`n[INFO] Missing repositories can be cloned with:" -ForegroundColor Cyan - Write-Host " .\Setup-Developer-Machine.ps1 -InstallerDeps" -ForegroundColor Cyan + Write-Host " .\Setup-Developer-Machine.ps1 -InstallerDeps" -ForegroundColor Cyan } #endregion @@ -208,9 +208,9 @@ if (-not $regKeySet) { if ($ValidateOnly) { $warnings += "WiX temp file registry key not set (may cause build errors)" Write-Host "[WARN] WiX temp file registry key not set" -ForegroundColor Yellow - Write-Host " This may cause 'DisableTempFileCollectionDirectoryFeature' errors" -ForegroundColor Yellow - Write-Host " Run this command in an elevated (Admin) PowerShell to fix:" -ForegroundColor Yellow - Write-Host ' $paths = @("HKLM:\SOFTWARE\Microsoft\.NETFramework\AppContext", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\AppContext"); foreach ($path in $paths) { if (-not (Test-Path $path)) { New-Item -Path $path -Force | Out-Null }; New-ItemProperty -Path $path -Name "Switch.System.DisableTempFileCollectionDirectoryFeature" -Value "true" -Type String -Force | Out-Null }; Write-Host "Registry keys set successfully"' -ForegroundColor Cyan + Write-Host " This may cause 'DisableTempFileCollectionDirectoryFeature' errors" -ForegroundColor Yellow + Write-Host " Run this command in an elevated (Admin) PowerShell to fix:" -ForegroundColor Yellow + Write-Host ' $paths = @("HKLM:\SOFTWARE\Microsoft\.NETFramework\AppContext", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\.NETFramework\AppContext"); foreach ($path in $paths) { if (-not (Test-Path $path)) { New-Item -Path $path -Force | Out-Null }; New-ItemProperty -Path $path -Name "Switch.System.DisableTempFileCollectionDirectoryFeature" -Value "true" -Type String -Force | Out-Null }; Write-Host "Registry keys set successfully"' -ForegroundColor Cyan } else { Write-Host "[INFO] Setting WiX temp file registry key (requires admin)..." -ForegroundColor Cyan $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) @@ -246,8 +246,8 @@ if ($SetupPatch) { if ($buildDirExists -and $procRunnerExists -and -not $Force) { Write-Host "[OK] Base build artifacts already present" -ForegroundColor Green - Write-Host " BuildDir: $buildDir" -ForegroundColor Gray - Write-Host " ProcRunner: $procRunnerDir" -ForegroundColor Gray + Write-Host " BuildDir: $buildDir" -ForegroundColor Gray + Write-Host " ProcRunner: $procRunnerDir" -ForegroundColor Gray } else { if ($ValidateOnly) { $warnings += "Base build artifacts not found (needed for patch builds)" From fcc7e13757cca8a6787e0122b83901acac366917 Mon Sep 17 00:00:00 2001 From: Hasso Date: Tue, 31 Mar 2026 16:19:25 -0500 Subject: [PATCH 3/3] Oops, I did it again --- Build/Agent/Remove-StaleDlls.ps1 | 4 ++-- Build/Agent/Verify-FwDependencies.ps1 | 4 ++-- scripts/Agent/Copy-LocalLcm.ps1 | 2 +- scripts/openspec/Validate-OpenSpecRefs.ps1 | 20 ++++++++++---------- test.ps1 | 4 ++-- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Build/Agent/Remove-StaleDlls.ps1 b/Build/Agent/Remove-StaleDlls.ps1 index 7091f1f66f..3a64c74a21 100644 --- a/Build/Agent/Remove-StaleDlls.ps1 +++ b/Build/Agent/Remove-StaleDlls.ps1 @@ -188,7 +188,7 @@ Get-ChildItem "$outputPath\*.dll" -ErrorAction SilentlyContinue | ForEach-Object # Compare AssemblyName.FullName (catches strong-name/version mismatches) try { $stagedAsm = [System.Reflection.AssemblyName]::GetAssemblyName($dll.FullName) - $refAsm = [System.Reflection.AssemblyName]::GetAssemblyName($refDll) + $refAsm = [System.Reflection.AssemblyName]::GetAssemblyName($refDll) if ($stagedAsm.FullName -ne $refAsm.FullName) { $msg = "$($dll.Name): staged=$($stagedAsm.FullName), build=$($refAsm.FullName) (assembly mismatch)" $problems.Add($msg) @@ -208,7 +208,7 @@ Get-ChildItem "$outputPath\*.dll" -ErrorAction SilentlyContinue | ForEach-Object # Compare FileVersion (catches same-AssemblyVersion NuGet bumps like Newtonsoft.Json) $stagedFV = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($dll.FullName).FileVersion - $refFV = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($refDll).FileVersion + $refFV = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($refDll).FileVersion if ($stagedFV -ne $refFV) { $msg = "$($dll.Name): staged FileVersion=$stagedFV, build FileVersion=$refFV (drift)" $problems.Add($msg) diff --git a/Build/Agent/Verify-FwDependencies.ps1 b/Build/Agent/Verify-FwDependencies.ps1 index 0aef01efef..10c943dc16 100644 --- a/Build/Agent/Verify-FwDependencies.ps1 +++ b/Build/Agent/Verify-FwDependencies.ps1 @@ -70,7 +70,7 @@ function Test-Dependency { if ($Detailed) { Write-Host "[OK] $Name" -ForegroundColor Green if ($result -is [string] -and $result.Length -gt 0 -and $result.Length -lt 100) { - Write-Host " $result" -ForegroundColor DarkGray + Write-Host " $result" -ForegroundColor DarkGray } } return @{ Name = $Name; Found = $true; IsRequired = ($Required -eq "Required"); Info = $result } @@ -83,7 +83,7 @@ function Test-Dependency { $color = if ($Required -eq "Required") { "Red" } else { "Yellow" } $status = if ($Required -eq "Required") { "[FAIL]" } else { "[WARN]" } Write-Host "$status $Name" -ForegroundColor $color - Write-Host " $_" -ForegroundColor DarkGray + Write-Host " $_" -ForegroundColor DarkGray return @{ Name = $Name; Found = $false; IsRequired = ($Required -eq "Required"); Error = $_.ToString() } } } diff --git a/scripts/Agent/Copy-LocalLcm.ps1 b/scripts/Agent/Copy-LocalLcm.ps1 index f621f3d8cc..81e116a5d0 100644 --- a/scripts/Agent/Copy-LocalLcm.ps1 +++ b/scripts/Agent/Copy-LocalLcm.ps1 @@ -71,7 +71,7 @@ Write-Host " Local LCM Copy Utility" -ForegroundColor Cyan Write-Host "===============================================" -ForegroundColor Cyan Write-Host " LCM Source: $LcmRoot" -ForegroundColor White Write-Host " FW Output: $FwOutputDir" -ForegroundColor White -Write-Host " Config: $Configuration" -ForegroundColor White +Write-Host " Config: $Configuration" -ForegroundColor White Write-Host " Build LCM: $($BuildLcm.IsPresent)" -ForegroundColor White Write-Host "" diff --git a/scripts/openspec/Validate-OpenSpecRefs.ps1 b/scripts/openspec/Validate-OpenSpecRefs.ps1 index 2d376dc235..9440182f03 100644 --- a/scripts/openspec/Validate-OpenSpecRefs.ps1 +++ b/scripts/openspec/Validate-OpenSpecRefs.ps1 @@ -340,8 +340,8 @@ foreach ($ref in $forwardRefs) { $brokenCount++ $broken += @( "[BROKEN] {0}:{1}" -f $ref.SpecRel, $ref.LineNumber, - " -> {0}#{1}" -f $ref.AgentRel, $ref.AgentAnchor, - ' Target file not found.' + " -> {0}#{1}" -f $ref.AgentRel, $ref.AgentAnchor, + ' Target file not found.' ) continue } @@ -351,8 +351,8 @@ foreach ($ref in $forwardRefs) { $brokenCount++ $broken += @( "[BROKEN] {0}:{1}" -f $ref.SpecRel, $ref.LineNumber, - " -> {0}#{1}" -f $ref.AgentRel, $ref.AgentAnchor, - ' Anchor not found.' + " -> {0}#{1}" -f $ref.AgentRel, $ref.AgentAnchor, + ' Anchor not found.' ) continue } @@ -362,7 +362,7 @@ foreach ($ref in $forwardRefs) { $orphanCount++ $orphans += @( "[ORPHAN] {0}:{1} #{2}" -f $ref.SpecRel, $ref.LineNumber, $ref.SpecAnchor, - " Missing back-ref in {0}." -f $ref.AgentRel + " Missing back-ref in {0}." -f $ref.AgentRel ) } } @@ -372,8 +372,8 @@ foreach ($ref in $backRefs) { $brokenCount++ $broken += @( "[BROKEN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, - " -> {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor, - ' Target spec not found.' + " -> {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor, + ' Target spec not found.' ) continue } @@ -383,8 +383,8 @@ foreach ($ref in $backRefs) { $brokenCount++ $broken += @( "[BROKEN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, - " -> {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor, - ' Anchor not found.' + " -> {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor, + ' Anchor not found.' ) continue } @@ -394,7 +394,7 @@ foreach ($ref in $backRefs) { $orphanCount++ $orphans += @( "[ORPHAN] {0}:{1} #{2}" -f $ref.AgentRel, $ref.LineNumber, $ref.AgentAnchor, - " Spec missing forward ref: {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor + " Spec missing forward ref: {0}#{1}" -f $ref.SpecRel, $ref.SpecAnchor ) } } diff --git a/test.ps1 b/test.ps1 index 378005ff1a..4b9eaa2e73 100644 --- a/test.ps1 +++ b/test.ps1 @@ -530,10 +530,10 @@ if ($testExitCode -ne 0 -and (Test-Path $vstestLogPath)) { $shown = 0 foreach ($item in $grp.Group) { if ($shown -ge 5) { - Write-Host " ... and $($grp.Count - 5) more" -ForegroundColor DarkGray + Write-Host " ... and $($grp.Count - 5) more" -ForegroundColor DarkGray break } - Write-Host " - $($item.Test)" -ForegroundColor Gray + Write-Host " - $($item.Test)" -ForegroundColor Gray $shown++ } }