From 54a470ba00d0468d0a466faf5616fe272324b372 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:14:47 +0700 Subject: [PATCH 01/10] build(p3a): request reproducible ArdIrec bridge linking --- scripts/build-ardirec-bridge.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/build-ardirec-bridge.ps1 b/scripts/build-ardirec-bridge.ps1 index a5f502735..7fcf4d287 100644 --- a/scripts/build-ardirec-bridge.ps1 +++ b/scripts/build-ardirec-bridge.ps1 @@ -84,7 +84,8 @@ Write-Host "==> Configuring pinned ArdIrec native bridge (desktop/Qt disabled)" -DARDIREC_BUILD_DESKTOP=OFF ` -DARDIREC_BUILD_BRIDGE=ON ` -DARDIREC_BUILD_TESTS=ON ` - -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded + -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded ` + -DCMAKE_SHARED_LINKER_FLAGS_RELEASE=/Brepro if ($LASTEXITCODE -ne 0) { throw "ArdIrec bridge CMake configure failed with exit code $LASTEXITCODE." } From af457c09764bc9f53e18c65bff630fdde5746f10 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:14:51 +0700 Subject: [PATCH 02/10] build(p3a): emit deterministic portable build identity --- scripts/publish-windows-portable.ps1 | 107 +++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1 index aaa0fefc6..2fff2f28b 100644 --- a/scripts/publish-windows-portable.ps1 +++ b/scripts/publish-windows-portable.ps1 @@ -116,6 +116,8 @@ $publishArguments = @( "-p:UseAppHost=true", "-p:DebugType=None", "-p:DebugSymbols=false", + "-p:Deterministic=true", + "-p:ContinuousIntegrationBuild=true", "-p:Version=$normalizedVersion", "-p:AssemblyVersion=$numericVersion", "-p:FileVersion=$numericVersion", @@ -161,7 +163,112 @@ if ($SingleFile) { throw "Versioned portable single EXE was not produced: $singleExePath" } + function Get-GitIdentity([string]$path) { + try { + $repoRoot = (& git -C $path rev-parse --show-toplevel 2>$null).Trim() + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { return $null } + $head = (& git -C $repoRoot rev-parse HEAD 2>$null).Trim().ToLowerInvariant() + if ($LASTEXITCODE -ne 0 -or $head -notmatch '^[0-9a-f]{40} + +$requiredInstallerFiles = @( + "AR.Iec61850.Transports.Npcap.dll", + "SharpPcap.dll", + "PacketDotNet.dll", + "README.txt", + "LICENSE", + "COMMERCIAL-LICENSE.md", + "TRADEMARK.md", + "COPYRIGHT.md", + "THIRD_PARTY_NOTICES.md", + "NOTICE", + "LICENSING.md", + "engines\ARIEC61850.lock.json", + "engines\ARDIREC.lock.json", + "Tools\ArdIrec\ardirec_bridge.dll" +) +foreach ($runtimeFile in $requiredInstallerFiles) { + $runtimePath = Join-Path $publishDir $runtimeFile + if (-not (Test-Path $runtimePath -PathType Leaf)) { + throw "Installer-source dependency was not published: $runtimePath" + } +} + +Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $folderZipPath -CompressionLevel Optimal +Write-Host "==> Installer source executable: $exe" +Write-Host "==> Legacy folder ZIP for diagnostics: $folderZipPath" +Write-Output $publishDir +) { return $null } + return $head + } + catch { + return $null + } + } + + $engineProjectDirectory = Split-Path -Parent ([System.IO.Path]::GetFullPath($EngineProject)) + $sourceCommit = Get-GitIdentity $root + $engineCommit = Get-GitIdentity $engineProjectDirectory + $ardirecLockPath = Join-Path $root "engines\ARDIREC.lock.json" + $ardirecLockCommit = $null + if (Test-Path $ardirecLockPath -PathType Leaf) { + $ardirecLock = Get-Content $ardirecLockPath -Raw | ConvertFrom-Json + if ($ardirecLock.commit -match '^[0-9a-f]{40} + +$requiredInstallerFiles = @( + "AR.Iec61850.Transports.Npcap.dll", + "SharpPcap.dll", + "PacketDotNet.dll", + "README.txt", + "LICENSE", + "COMMERCIAL-LICENSE.md", + "TRADEMARK.md", + "COPYRIGHT.md", + "THIRD_PARTY_NOTICES.md", + "NOTICE", + "LICENSING.md", + "engines\ARIEC61850.lock.json", + "engines\ARDIREC.lock.json", + "Tools\ArdIrec\ardirec_bridge.dll" +) +foreach ($runtimeFile in $requiredInstallerFiles) { + $runtimePath = Join-Path $publishDir $runtimeFile + if (-not (Test-Path $runtimePath -PathType Leaf)) { + throw "Installer-source dependency was not published: $runtimePath" + } +} + +Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $folderZipPath -CompressionLevel Optimal +Write-Host "==> Installer source executable: $exe" +Write-Host "==> Legacy folder ZIP for diagnostics: $folderZipPath" +Write-Output $publishDir +) { + $ardirecLockCommit = [string]$ardirecLock.commit + } + } + + $bridgeHash = (Get-FileHash $ArdIrecBridgePath -Algorithm SHA256).Hash.ToLowerInvariant() + $portableHash = (Get-FileHash $singleExePath -Algorithm SHA256).Hash.ToLowerInvariant() + $identityPath = Join-Path $outputRoot "ARSAS-$normalizedVersion-$Runtime-portable-build-identity.json" + [ordered]@{ + schemaVersion = 1 + kind = "arsas-portable-build-identity" + version = $normalizedVersion + runtime = $Runtime + sourceCommit = $sourceCommit + engineCommit = $engineCommit + ardIrecLockCommit = $ardirecLockCommit + ardIrecBridgeSha256 = $bridgeHash + ardIrecBridgeSizeBytes = (Get-Item $ArdIrecBridgePath).Length + portableSha256 = $portableHash + portableSizeBytes = (Get-Item $singleExePath).Length + deterministicManagedBuild = $true + reproducibleNativeLinkRequested = $true + } | ConvertTo-Json -Depth 4 | Set-Content $identityPath -Encoding utf8 + Write-Host "==> Real portable single EXE with embedded ArdIrec bridge: $singleExePath" + Write-Host "==> Portable build identity: $identityPath" + Write-Host "==> Portable SHA256: $portableHash" + Write-Host "==> ArdIrec bridge SHA256: $bridgeHash" Write-Output $singleExePath exit 0 } From 947481d186fcb73d239b972e53e08c5e8ac61f7f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:15:17 +0700 Subject: [PATCH 03/10] ci(p3a): publish canonical portable build identity evidence --- .github/workflows/build.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f56abc80b..c1b613f61 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -415,5 +415,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: ARSAS-win-x64-portable-single-exe - path: ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + path: | + ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json if-no-files-found: error From a77f206d19795bef73324f010884ba95c14131b6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:15:21 +0700 Subject: [PATCH 04/10] ci(p3a): publish Field Capture build identity evidence --- .github/workflows/smart-discovery-capture-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/smart-discovery-capture-build.yml b/.github/workflows/smart-discovery-capture-build.yml index 4901ff0bd..4227a8b8d 100644 --- a/.github/workflows/smart-discovery-capture-build.yml +++ b/.github/workflows/smart-discovery-capture-build.yml @@ -383,6 +383,7 @@ jobs: name: ARSAS-smart-discovery-pr134-win-x64 path: | ArIED61850Tester\dist\ARSAS-*-win-x64-portable.exe + ArIED61850Tester\dist\ARSAS-*-win-x64-portable-build-identity.json ArIED61850Tester\dist\SMART-CAPTURE-BUILD.txt ArIED61850Tester\scripts\verify-smart-discovery-pcap.ps1 ArIED61850Tester\scripts\new-smart-discovery-golden-lock.ps1 From 5667a34667a4186a5556145fda623a6285e69a6e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:15:24 +0700 Subject: [PATCH 05/10] test(p3a): lock reproducible portable packaging contract --- ...wsPackagingReproducibilityContractTests.cs | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs diff --git a/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs new file mode 100644 index 000000000..ec154884e --- /dev/null +++ b/tests/ARSAS.Tests/WindowsPackagingReproducibilityContractTests.cs @@ -0,0 +1,75 @@ +namespace ARSAS.Tests; + +public sealed class WindowsPackagingReproducibilityContractTests +{ + [Fact] + public void NativeBridge_RequestsReproducibleMsvcLinkOutput() + { + var bridge = File.ReadAllText( + FindRepositoryFile("scripts/build-ardirec-bridge.ps1")); + + Assert.Contains( + "-DCMAKE_SHARED_LINKER_FLAGS_RELEASE=/Brepro", + bridge, + StringComparison.Ordinal); + Assert.Contains( + "-DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreaded", + bridge, + StringComparison.Ordinal); + Assert.Contains( + "ctest --test-dir", + bridge, + StringComparison.Ordinal); + } + + [Fact] + public void PortablePublish_IsDeterministicAndEmitsHashBoundBuildIdentity() + { + var publish = File.ReadAllText( + FindRepositoryFile("scripts/publish-windows-portable.ps1")); + + Assert.Contains("-p:Deterministic=true", publish, StringComparison.Ordinal); + Assert.Contains("-p:ContinuousIntegrationBuild=true", publish, StringComparison.Ordinal); + Assert.Contains("arsas-portable-build-identity", publish, StringComparison.Ordinal); + Assert.Contains("ardIrecBridgeSha256", publish, StringComparison.Ordinal); + Assert.Contains("portableSha256", publish, StringComparison.Ordinal); + Assert.Contains("sourceCommit", publish, StringComparison.Ordinal); + Assert.Contains("engineCommit", publish, StringComparison.Ordinal); + Assert.Contains("ardIrecLockCommit", publish, StringComparison.Ordinal); + Assert.Contains("reproducibleNativeLinkRequested = $true", publish, StringComparison.Ordinal); + } + + [Fact] + public void CanonicalAndFieldCaptureArtifacts_BothCarryBuildIdentity() + { + var canonical = File.ReadAllText( + FindRepositoryFile(".github/workflows/build.yml")); + var capture = File.ReadAllText( + FindRepositoryFile(".github/workflows/smart-discovery-capture-build.yml")); + + const string identity = + "ArIED61850Tester\\dist\\ARSAS-*-win-x64-portable-build-identity.json"; + + Assert.Contains(identity, canonical, StringComparison.Ordinal); + Assert.Contains(identity, capture, StringComparison.Ordinal); + Assert.Contains( + "Packaging authority: independent Field Capture publish/smoke", + capture, + StringComparison.Ordinal); + } + + private static string FindRepositoryFile(string relativePath) + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory != null) + { + var candidate = Path.Combine(directory.FullName, relativePath); + if (File.Exists(candidate)) + return candidate; + directory = directory.Parent; + } + + throw new FileNotFoundException( + $"Repository file not found: {relativePath}"); + } +} From a0158d3f6b38b40ab80a7e190a94e5252a8bf7e9 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:15:50 +0700 Subject: [PATCH 06/10] evidence(p3a): record reproducible packaging acceptance gate --- evidence/ci-workflow-budget.json | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/evidence/ci-workflow-budget.json b/evidence/ci-workflow-budget.json index c86d1bae6..73812cbc1 100644 --- a/evidence/ci-workflow-budget.json +++ b/evidence/ci-workflow-budget.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, "baselineSourceCommit": "71e8d7e864b22c2f2146e5f8576cef0126c5f6e7", - "baselineName": "CI-P2F: canonical regression reused by independent Field Capture packaging", - "policy": "P0 historical workflow-sprawl baseline remains immutable. P2A-P2E consolidate exact-SHA regression evidence; P2F keeps Field Capture source/wire checks and independent publish/smoke, but reuses canonical application regression because source and integration engine are identical. Binary artifact reuse remains forbidden because observed canonical and Field Capture EXEs were not byte-identical.", + "baselineName": "CI-P3A: reproducible portable build identity", + "policy": "P0-P2 reduced duplicate regression work. P3A does not add a heavy workflow or change static CI counts; it establishes deterministic Windows packaging inputs and hash-bound build identity so later artifact promotion can reuse only proven identical bits.", "preP0Observed": { "workflowFiles": 28, "actionsCheckout": 31, @@ -291,5 +291,23 @@ "canonicalBuildRemainsRegressionAuthorityOnPullRequest": true, "manualReusableRegressionRetained": true, "rollback": "Restore direct Field Capture restore/build/dotnet test steps; do not substitute canonical EXE unless future reproducible-build evidence proves byte identity." + }, + "ciP3ReproduciblePortableBuild": { + "issueNumber": 447, + "lane": "canonical-and-field-capture-windows-packaging", + "approach": "Request reproducible MSVC bridge linking, explicit deterministic .NET publish, and emit source/engine/ArdIrec/bridge/portable hash identity from both existing independent builders. No binary promotion until cross-run parity is proven.", + "staticLimitsChanged": false, + "nativeLinkerFlag": "/Brepro", + "managedDeterminism": [ + "Deterministic=true", + "ContinuousIntegrationBuild=true" + ], + "crossRunBuilders": [ + "Build ARSAS", + "Smart Discovery Field Capture Build" + ], + "binaryPromotionAllowed": false, + "acceptance": "Matching source, ARIEC61850 and ArdIrec inputs must yield identical bridge and portable SHA-256 across independent workflow runs.", + "rollback": "Remove P3A deterministic flags and identity artifact additions only if toolchain incompatibility is proven; retain P2F binary-reuse prohibition." } } From 0861e29d0415b5cf297739b98b038808106f2bf7 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:15:54 +0700 Subject: [PATCH 07/10] docs(p3a): define reproducible build acceptance boundary --- .../CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md diff --git a/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md b/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md new file mode 100644 index 000000000..8a6e6c45e --- /dev/null +++ b/docs/audits/CI_P3A_REPRODUCIBLE_PORTABLE_BUILD.md @@ -0,0 +1,48 @@ +# CI-P3A — Reproducible Windows portable build identity + +Issue #447. + +## Purpose + +P2F proved that matching ARSAS and ARIEC61850 revisions could still produce +different portable single-file executables in independent workflows. Therefore +binary artifact reuse remains prohibited until reproducibility is proven. + +## Root-cause hypothesis and controlled change + +The Windows package embeds a pinned ArdIrec native bridge. That bridge is built +independently with MSVC/CMake in each workflow. A PE linker timestamp or other +non-reproducible native metadata can change the embedded bridge and, because the +single-file payload is compressed, propagate into a large executable diff. + +P3A does not treat that hypothesis as accepted evidence. It makes the native +shared-library linker request reproducible output with /Brepro and makes the +.NET publish determinism intent explicit with Deterministic=true and +ContinuousIntegrationBuild=true. Existing ArdIrec native regression tests remain. + +## Build identity + +Each single-file publish writes a JSON identity next to the executable containing: + +- ARSAS source commit when the source is a Git checkout; +- ARIEC61850 engine commit when the project is a Git checkout; +- pinned ArdIrec lock commit; +- ArdIrec bridge SHA-256 and byte size; +- portable executable SHA-256 and byte size; +- version/runtime and deterministic-build flags. + +Both Build ARSAS and Smart Discovery Field Capture upload that identity. + +## Acceptance boundary + +P3A itself does not authorize installer/release artifact reuse. After CI, the two +independent builders must report identical source, engine and ArdIrec inputs and +identical bridge plus portable SHA-256. Only that measured result can unlock P3B. + +Installer/release/physical authority and runtime behavior are unchanged. + +## Efficiency + +No new heavy workflow is introduced. Existing canonical and Field Capture lanes +serve as the independent builders, so reproducibility evidence is obtained from +work already required by the repository. From dfee2c502dd374125a5ffaaaa3c57c9a0457aaa6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:19:12 +0700 Subject: [PATCH 08/10] fix(p3a): repair portable identity PowerShell block --- scripts/publish-windows-portable.ps1 | 62 +--------------------------- 1 file changed, 2 insertions(+), 60 deletions(-) diff --git a/scripts/publish-windows-portable.ps1 b/scripts/publish-windows-portable.ps1 index 2fff2f28b..e3aa95190 100644 --- a/scripts/publish-windows-portable.ps1 +++ b/scripts/publish-windows-portable.ps1 @@ -168,36 +168,7 @@ if ($SingleFile) { $repoRoot = (& git -C $path rev-parse --show-toplevel 2>$null).Trim() if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { return $null } $head = (& git -C $repoRoot rev-parse HEAD 2>$null).Trim().ToLowerInvariant() - if ($LASTEXITCODE -ne 0 -or $head -notmatch '^[0-9a-f]{40} - -$requiredInstallerFiles = @( - "AR.Iec61850.Transports.Npcap.dll", - "SharpPcap.dll", - "PacketDotNet.dll", - "README.txt", - "LICENSE", - "COMMERCIAL-LICENSE.md", - "TRADEMARK.md", - "COPYRIGHT.md", - "THIRD_PARTY_NOTICES.md", - "NOTICE", - "LICENSING.md", - "engines\ARIEC61850.lock.json", - "engines\ARDIREC.lock.json", - "Tools\ArdIrec\ardirec_bridge.dll" -) -foreach ($runtimeFile in $requiredInstallerFiles) { - $runtimePath = Join-Path $publishDir $runtimeFile - if (-not (Test-Path $runtimePath -PathType Leaf)) { - throw "Installer-source dependency was not published: $runtimePath" - } -} - -Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $folderZipPath -CompressionLevel Optimal -Write-Host "==> Installer source executable: $exe" -Write-Host "==> Legacy folder ZIP for diagnostics: $folderZipPath" -Write-Output $publishDir -) { return $null } + if ($LASTEXITCODE -ne 0 -or $head -notmatch '^[0-9a-f]{40}$') { return $null } return $head } catch { @@ -212,36 +183,7 @@ Write-Output $publishDir $ardirecLockCommit = $null if (Test-Path $ardirecLockPath -PathType Leaf) { $ardirecLock = Get-Content $ardirecLockPath -Raw | ConvertFrom-Json - if ($ardirecLock.commit -match '^[0-9a-f]{40} - -$requiredInstallerFiles = @( - "AR.Iec61850.Transports.Npcap.dll", - "SharpPcap.dll", - "PacketDotNet.dll", - "README.txt", - "LICENSE", - "COMMERCIAL-LICENSE.md", - "TRADEMARK.md", - "COPYRIGHT.md", - "THIRD_PARTY_NOTICES.md", - "NOTICE", - "LICENSING.md", - "engines\ARIEC61850.lock.json", - "engines\ARDIREC.lock.json", - "Tools\ArdIrec\ardirec_bridge.dll" -) -foreach ($runtimeFile in $requiredInstallerFiles) { - $runtimePath = Join-Path $publishDir $runtimeFile - if (-not (Test-Path $runtimePath -PathType Leaf)) { - throw "Installer-source dependency was not published: $runtimePath" - } -} - -Compress-Archive -Path (Join-Path $publishDir "*") -DestinationPath $folderZipPath -CompressionLevel Optimal -Write-Host "==> Installer source executable: $exe" -Write-Host "==> Legacy folder ZIP for diagnostics: $folderZipPath" -Write-Output $publishDir -) { + if ($ardirecLock.commit -match '^[0-9a-f]{40}$') { $ardirecLockCommit = [string]$ardirecLock.commit } } From a5f02b8ab35d686aebaef57f9cfdb96f457376ea Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:19:25 +0700 Subject: [PATCH 09/10] ci(p3a): fail early on packaging PowerShell syntax errors --- .github/workflows/build.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c1b613f61..6783c502e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,6 +55,25 @@ jobs: shell: powershell run: .\ArIED61850Tester\scripts\test-source-clean-guard.ps1 + - name: Validate Windows packaging PowerShell syntax + shell: powershell + run: | + $scripts = @( + '.\ArIED61850Tester\scripts\build-ardirec-bridge.ps1', + '.\ArIED61850Tester\scripts\publish-windows-portable.ps1', + '.\ArIED61850Tester\scripts\build-windows-installer.ps1' + ) + foreach ($script in $scripts) { + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + $script, [ref]$tokens, [ref]$errors) | Out-Null + if ($errors.Count -ne 0) { + $messages = ($errors | ForEach-Object Message) -join '; ' + throw "PowerShell syntax validation failed for $script: $messages" + } + } + - name: Set up Python for read-only maintenance inventory uses: actions/setup-python@v6 with: From 7b2a0a9d98d45e34689bf587c0c23ee1c76b40ca Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 7 Oct 2026 15:21:09 +0700 Subject: [PATCH 10/10] fix(p3a): delimit packaging parser diagnostic variable --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6783c502e..b3336e71e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,7 +70,7 @@ jobs: $script, [ref]$tokens, [ref]$errors) | Out-Null if ($errors.Count -ne 0) { $messages = ($errors | ForEach-Object Message) -join '; ' - throw "PowerShell syntax validation failed for $script: $messages" + throw "PowerShell syntax validation failed for ${script}: $messages" } }