diff --git a/docs/plans/evidence/2026-11-13-g1-compliance/RESULTS.md b/docs/plans/evidence/2026-11-13-g1-compliance/RESULTS.md index dfee9e717..d5c2e3c91 100644 --- a/docs/plans/evidence/2026-11-13-g1-compliance/RESULTS.md +++ b/docs/plans/evidence/2026-11-13-g1-compliance/RESULTS.md @@ -41,7 +41,9 @@ Today's equivalents, where one exists: - **LocalDb cache:** `ZB.MOM.WW.LocalDb`. - **Invariants:** pinned by tests that G2 runs. -## Owner decision needed +## Owner decision (2026-11-13): retired + +The owner retired G1. The `phase-6-*.ps1` scripts were deleted (they remain in git history). The options considered were: The 61 passing checks still hold; the 56 failing ones describe architecture that no longer exists. Options: 1. **Retire G1** as superseded by G2 plus the per-area test suites, as was done for G8. diff --git a/docs/plans/phase-6-3-redundancy-interop-plan.md b/docs/plans/phase-6-3-redundancy-interop-plan.md index 3a6b270a1..45a7225c4 100644 --- a/docs/plans/phase-6-3-redundancy-interop-plan.md +++ b/docs/plans/phase-6-3-redundancy-interop-plan.md @@ -11,8 +11,8 @@ > > **Source of truth**: `docs/Redundancy.md` (election, tiers, downing, manual failover, `ServerArray`), > `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Redundancy/ServiceLevelCalculator.cs` and the tests named below. -> `docs/v2/redundancy-interop-playbook.md` and `scripts/compliance/phase-6-3-compliance.ps1` are -> **historical** (they still name the deleted types and old bands) and must not be used as pass criteria. +> `docs/v2/redundancy-interop-playbook.md` is **historical** (it still names the deleted types and old bands) and must +> not be used as pass criteria. `scripts/compliance/phase-6-3-compliance.ps1` was deleted when G1 was retired (2026-11-13). ## Standard variables used by this plan diff --git a/docs/plans/v2-ga-lab-gates-plan.md b/docs/plans/v2-ga-lab-gates-plan.md index edd39dd1e..afb6203a7 100644 --- a/docs/plans/v2-ga-lab-gates-plan.md +++ b/docs/plans/v2-ga-lab-gates-plan.md @@ -27,6 +27,12 @@ From `docs/v2/v2-release-readiness.md` §"Release-readiness exit criteria": ## G1 — Phase 6 compliance scripts +> **RETIRED 2026-11-13 by the owner.** The scripts checked the v2 Phase 6 (2026-04) codebase. All 56 of their +> failures pointed at files a later commit deliberately deleted (the v1 `Server`/`Admin` projects in `76310b88`, #522, +> #524, the LiteDB cache, the v3 migration squash), so they could not pass. G2 (the full test suite) and the per-area +> test suites cover the invariants. The `phase-6-*.ps1` scripts were deleted. The last run and its classification: +> `docs/plans/evidence/2026-11-13-g1-compliance/RESULTS.md`. The section below is kept as history. + ### Command ```powershell @@ -305,7 +311,7 @@ Record: client name + version, OtOpcUa git SHA, test date. | Gate | Command / Procedure | Pass criterion | Owner | |------|---------------------|----------------|-------| -| G1 | `pwsh ./scripts/compliance/phase-6-all.ps1` | Exit 0, no `[FAIL]` | Dev | +| G1 | ~~`pwsh ./scripts/compliance/phase-6-all.ps1`~~ | **Retired 2026-11-13 (owner)**: superseded by G2 | — | | G2 | `dotnet test ZB.MOM.WW.OtOpcUa.slnx` | >= 1159 passing, <= 1 failure — **PASS 2026-11-13** (evidence `2026-11-13-g2-full-suite/`) | Dev | | G3 | Audit PR list in release-readiness.md | All blockers show CLOSED | Dev | | G4 | Audit driver table | All 8 drivers listed as shipped | Dev | diff --git a/docs/v2/v2-release-readiness.md b/docs/v2/v2-release-readiness.md index ccbc73b75..a03ffece6 100644 --- a/docs/v2/v2-release-readiness.md +++ b/docs/v2/v2-release-readiness.md @@ -10,7 +10,7 @@ > near the end of the file are still live — see `deferment.md` §5. > **Last updated**: 2026-04-24 (Phase 5 driver complement closed — AB CIP, AB Legacy, TwinCAT, FOCAS all shipped; FOCAS Tier-C retired for a pure-managed in-process client) -> **Status**: **RELEASE-READY (code-path)** for v2 GA. All three original code-path release blockers remain closed. Phase 5 is now complete. The #615 lab/operator gates are complete: G5 signed off by the Fleet Admin and G6/G9 passed (2026-11-12/13), and G8 (CTT) was waived by the owner (2026-11-13); hardening follow-ups remain; see exit-criteria checklist below. +> **Status**: **RELEASE-READY** for v2 GA. Every exit criterion is met, waived or retired as of 2026-11-13 (G1 retired, G8 waived; G2, G5, G6, G7, G9 done). All three original code-path release blockers remain closed. Phase 5 is now complete. The #615 lab/operator gates are complete: G5 signed off by the Fleet Admin and G6/G9 passed (2026-11-12/13), and G8 (CTT) was waived by the owner (2026-11-13); hardening follow-ups remain; see exit-criteria checklist below. This doc is the single view of where v2 stands against its release criteria. Update it whenever a deferred follow-up closes or a new release blocker is discovered. @@ -114,24 +114,22 @@ The code ships; these tasks remain open as lab/field verification: ## Running the release-readiness check +The Phase 6 compliance scripts (`scripts/compliance/phase-6-*.ps1`) were retired on 2026-11-13 (G1). They checked +code that has since been deliberately deleted. The automated check is now the full suite (G2): + ```bash -pwsh ./scripts/compliance/phase-6-all.ps1 +dotnet test ZB.MOM.WW.OtOpcUa.slnx --logger "console;verbosity=minimal" ``` -This meta-runner invokes each `phase-6-N-compliance.ps1` script in sequence and reports an aggregate PASS/FAIL: - -- `phase-6-1-compliance.ps1` — Resilience & Observability -- `phase-6-2-compliance.ps1` — Authorization runtime -- `phase-6-3-compliance.ps1` — Redundancy runtime -- `phase-6-4-compliance.ps1` — Admin UI completion - -Exit 0 = every phase passes its compliance checks + no test-count regression. +Run it with the shared fixtures on `10.100.0.35` up (`infra/README.md`). Restart `otopcua-pymodbus-standard` before +the run until #909 is fixed. The disposition rules and the 2026-11-13 result are in +`docs/plans/evidence/2026-11-13-g2-full-suite/RESULTS.md`. ## Release-readiness exit criteria v2 GA requires all of the following: -- [ ] All four Phase 6.N compliance scripts exit 0. +- [x] ~~All four Phase 6.N compliance scripts exit 0.~~ **G1 RETIRED 2026-11-13 by the owner.** The scripts checked deliberately deleted v2 Phase 6 code (56/56 failures were retired files) and were deleted; G2 and the per-area tests cover the invariants. Evidence: `docs/plans/evidence/2026-11-13-g1-compliance/RESULTS.md`. - [x] `dotnet test ZB.MOM.WW.OtOpcUa.slnx` passes with ≤ 1 known-flake failure. **G2 PASS 2026-11-13** on the product code: 2 full runs (20,589 / 20,588 passed). The one real defect found is fixed (`f416b883`); every other failure is a shared-fixture or load-timing issue (#909, #910, #911) whose test passes in isolation. Evidence: `docs/plans/evidence/2026-11-13-g2-full-suite/RESULTS.md`. - [x] Release blockers listed above all closed. - [x] Phase 5 driver complement shipped (Galaxy, Modbus, S7, OpcUaClient, AbCip, AbLegacy, TwinCAT, FOCAS). @@ -143,6 +141,7 @@ v2 GA requires all of the following: ## Change log +- **2026-11-13** — **G1 retired** by the owner and the `scripts/compliance/phase-6-*.ps1` scripts deleted: they checked deliberately deleted v2 Phase 6 code. Every v2 GA exit criterion is now met, waived (G8) or retired (G1). - **2026-11-13** — **G7 PASS:** a read-only FOCAS live-CNC smoke on vd03 against the Makino Z-34184 31i-B. The owner approved adding one axis tag (`Axes/X/AbsolutePosition`, deploy `e9fbe63e`, surgical PureAdd); it reads 280.1574 Good. The panel comparison was not done (it needs a person at the machine). G1 awaits the owner's decision. - **2026-11-13** — **G2 PASS.** Two full `dotnet test ZB.MOM.WW.OtOpcUa.slnx` runs (60 projects, ~21k tests). One real defect: a bare `"driver"` role literal from #717's `c569f806`, fixed in `f416b883`. Every other failure is environmental and filed: the Modbus fixture is swamped by load (#909), an intermittent opc-plc `BadRequestInterrupted` (#910), and a scripted-alarm run-guard timing test (#911). G1 (stale Phase 6 compliance scripts) and G7 (FOCAS live CNC) evidence is recorded and awaits the owner's decision. - **2026-11-13** — **G5 (#615) signed off** by the owner as Fleet Admin. With G6/G9 passed and G8 waived, every #615 gate is complete and #615 is closed. diff --git a/scripts/compliance/phase-6-1-compliance.ps1 b/scripts/compliance/phase-6-1-compliance.ps1 deleted file mode 100644 index eea2afcf4..000000000 --- a/scripts/compliance/phase-6-1-compliance.ps1 +++ /dev/null @@ -1,141 +0,0 @@ -<# -.SYNOPSIS - Phase 6.1 exit-gate compliance check. Each check either passes or records a - failure; non-zero exit = fail. - -.DESCRIPTION - Validates Phase 6.1 (Resilience & Observability runtime) completion. Checks - enumerated in `docs/v2/implementation/phase-6-1-resilience-and-observability.md` - §"Compliance Checks (run at exit gate)". - - Runs a mix of file-presence checks, text-pattern sweeps over the committed - codebase, and a full `dotnet test` pass to exercise the invariants each - class encodes. Meant to be invoked from repo root. - -.NOTES - Usage: pwsh ./scripts/compliance/phase-6-1-compliance.ps1 - Exit: 0 = all checks passed; non-zero = one or more FAILs -#> -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -$script:failures = 0 -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - -function Assert-Pass { - param([string]$Check) - Write-Host " [PASS] $Check" -ForegroundColor Green -} - -function Assert-Fail { - param([string]$Check, [string]$Reason) - Write-Host " [FAIL] $Check - $Reason" -ForegroundColor Red - $script:failures++ -} - -function Assert-Deferred { - param([string]$Check, [string]$FollowupPr) - Write-Host " [DEFERRED] $Check (follow-up: $FollowupPr)" -ForegroundColor Yellow -} - -function Assert-FileExists { - param([string]$Check, [string]$RelPath) - $full = Join-Path $repoRoot $RelPath - if (Test-Path $full) { Assert-Pass "$Check ($RelPath)" } - else { Assert-Fail $Check "missing file: $RelPath" } -} - -function Assert-TextFound { - param([string]$Check, [string]$Pattern, [string[]]$RelPaths) - foreach ($p in $RelPaths) { - $full = Join-Path $repoRoot $p - if (-not (Test-Path $full)) { continue } - if (Select-String -Path $full -Pattern $Pattern -Quiet) { - Assert-Pass "$Check (matched in $p)" - return - } - } - Assert-Fail $Check "pattern '$Pattern' not found in any of: $($RelPaths -join ', ')" -} - -Write-Host "" -Write-Host "=== Phase 6.1 compliance - Resilience & Observability runtime ===" -ForegroundColor Cyan -Write-Host "" - -Write-Host "Stream A - Resilience layer" -Assert-FileExists "Pipeline builder present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs" -Assert-FileExists "CapabilityInvoker present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/CapabilityInvoker.cs" -Assert-FileExists "WriteIdempotentAttribute present" "src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/WriteIdempotentAttribute.cs" -Assert-TextFound "Pipeline key includes HostName (per-device isolation)" "PipelineKey\(.+HostName" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResiliencePipelineBuilder.cs") -Assert-TextFound "OnReadValue routes through invoker" "DriverCapability\.Read," @("src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs") -Assert-TextFound "OnWriteValue routes through invoker" "ExecuteWriteAsync" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs") -Assert-TextFound "HistoryRead routes through invoker" "DriverCapability\.HistoryRead" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs") -# The pre-PR-7.2 Galaxy.Proxy supervisor (CircuitBreaker/Backoff) was retired with the legacy -# in-process Galaxy stack; circuit-breaker + backoff resilience is now the Core pipeline checked -# above (DriverResiliencePipelineBuilder, per-device-keyed). No Galaxy.Proxy assertions remain. - -Write-Host "" -Write-Host "Stream B - Tier A/B/C runtime" -Assert-FileExists "DriverTier enum present" "src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTier.cs" -Assert-TextFound "DriverTypeMetadata requires Tier" "DriverTier Tier" @("src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/DriverTypeRegistry.cs") -Assert-FileExists "MemoryTracking present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryTracking.cs" -Assert-FileExists "MemoryRecycle present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryRecycle.cs" -Assert-TextFound "MemoryRecycle is Tier C gated" "_tier == DriverTier\.C" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/MemoryRecycle.cs") -Assert-FileExists "ScheduledRecycleScheduler present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/ScheduledRecycleScheduler.cs" -Assert-TextFound "Scheduler ctor rejects Tier A/B" "tier != DriverTier\.C" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/ScheduledRecycleScheduler.cs") -Assert-FileExists "WedgeDetector present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/WedgeDetector.cs" -Assert-TextFound "WedgeDetector is demand-aware" "HasPendingWork" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Stability/WedgeDetector.cs") - -Write-Host "" -Write-Host "Stream C - Health + logging" -Assert-FileExists "DriverHealthReport present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Observability/DriverHealthReport.cs" -Assert-FileExists "HealthEndpointsHost present" "src/Server/ZB.MOM.WW.OtOpcUa.Server/Observability/HealthEndpointsHost.cs" -Assert-TextFound "State matrix: Healthy = 200" "ReadinessVerdict\.Healthy => 200" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Observability/DriverHealthReport.cs") -Assert-TextFound "State matrix: Faulted = 503" "ReadinessVerdict\.Faulted => 503" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Observability/DriverHealthReport.cs") -Assert-FileExists "LogContextEnricher present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Observability/LogContextEnricher.cs" -Assert-TextFound "Enricher pushes DriverInstanceId property" "DriverInstanceId" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Observability/LogContextEnricher.cs") -Assert-TextFound "JSON sink opt-in via Serilog:WriteJson" "Serilog:WriteJson" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Program.cs") - -Write-Host "" -Write-Host "Stream D - LiteDB generation-sealed cache" -Assert-FileExists "GenerationSealedCache present" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSealedCache.cs" -Assert-TextFound "Sealed files marked ReadOnly" "FileAttributes\.ReadOnly" @("src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSealedCache.cs") -Assert-TextFound "Corruption fails closed with GenerationCacheUnavailableException" "GenerationCacheUnavailableException" @("src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/GenerationSealedCache.cs") -Assert-FileExists "ResilientConfigReader present" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/ResilientConfigReader.cs" -Assert-FileExists "StaleConfigFlag present" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/LocalCache/StaleConfigFlag.cs" - -Write-Host "" -Write-Host "Stream E - Admin /hosts (data layer)" -Assert-FileExists "DriverInstanceResilienceStatus entity" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/DriverInstanceResilienceStatus.cs" -Assert-FileExists "DriverResilienceStatusTracker present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Resilience/DriverResilienceStatusTracker.cs" -# Gitea #542 (2026-08-06): the FleetStatusHub chain was DELETED (dead end-to-end), so the -# Stream E.2/E.3 "SignalR push + /hosts column refresh" follow-up is retired, not deferred. - -Write-Host "" -Write-Host "Cross-cutting" -Write-Host " Running full solution test suite..." -ForegroundColor DarkGray -$prevPref = $ErrorActionPreference -$ErrorActionPreference = 'Continue' -$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1 -$ErrorActionPreference = $prevPref -$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches -$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches -$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value } -$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value } -$baseline = 906 -if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline baseline)" } -else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" } - -# Pre-existing Client.CLI Subscribe flake tracked separately; exit gate tolerates a single -# known flake but flags any NEW failures. -if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" } -else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" } - -Write-Host "" -if ($script:failures -eq 0) { - Write-Host "Phase 6.1 compliance: PASS" -ForegroundColor Green - exit 0 -} -Write-Host "Phase 6.1 compliance: $script:failures FAIL(s)" -ForegroundColor Red -exit 1 diff --git a/scripts/compliance/phase-6-2-compliance.ps1 b/scripts/compliance/phase-6-2-compliance.ps1 deleted file mode 100644 index 16639cdb6..000000000 --- a/scripts/compliance/phase-6-2-compliance.ps1 +++ /dev/null @@ -1,147 +0,0 @@ -<# -.SYNOPSIS - Phase 6.2 exit-gate compliance check. Each check either passes or records a - failure; non-zero exit = fail. - -.DESCRIPTION - Validates Phase 6.2 (Authorization runtime) completion. Checks enumerated - in `docs/v2/implementation/phase-6-2-authorization-runtime.md` - §"Compliance Checks (run at exit gate)". - -.NOTES - Usage: pwsh ./scripts/compliance/phase-6-2-compliance.ps1 - Exit: 0 = all checks passed; non-zero = one or more FAILs -#> -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -$script:failures = 0 -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - -function Assert-Pass { - param([string]$Check) - Write-Host " [PASS] $Check" -ForegroundColor Green -} - -function Assert-Fail { - param([string]$Check, [string]$Reason) - Write-Host " [FAIL] $Check - $Reason" -ForegroundColor Red - $script:failures++ -} - -function Assert-Deferred { - param([string]$Check, [string]$FollowupPr) - Write-Host " [DEFERRED] $Check (follow-up: $FollowupPr)" -ForegroundColor Yellow -} - -function Assert-FileExists { - param([string]$Check, [string]$RelPath) - $full = Join-Path $repoRoot $RelPath - if (Test-Path $full) { Assert-Pass "$Check ($RelPath)" } - else { Assert-Fail $Check "missing file: $RelPath" } -} - -function Assert-TextFound { - param([string]$Check, [string]$Pattern, [string[]]$RelPaths) - foreach ($p in $RelPaths) { - $full = Join-Path $repoRoot $p - if (-not (Test-Path $full)) { continue } - if (Select-String -Path $full -Pattern $Pattern -Quiet) { - Assert-Pass "$Check (matched in $p)" - return - } - } - Assert-Fail $Check "pattern '$Pattern' not found in any of: $($RelPaths -join ', ')" -} - -function Assert-TextAbsent { - param([string]$Check, [string]$Pattern, [string[]]$RelPaths) - foreach ($p in $RelPaths) { - $full = Join-Path $repoRoot $p - if (-not (Test-Path $full)) { continue } - if (Select-String -Path $full -Pattern $Pattern -Quiet) { - Assert-Fail $Check "pattern '$Pattern' unexpectedly found in $p" - return - } - } - Assert-Pass "$Check (pattern '$Pattern' absent from: $($RelPaths -join ', '))" -} - -Write-Host "" -Write-Host "=== Phase 6.2 compliance - Authorization runtime ===" -ForegroundColor Cyan -Write-Host "" - -Write-Host "Stream A - LdapGroupRoleMapping (control plane)" -Assert-FileExists "LdapGroupRoleMapping entity present" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Entities/LdapGroupRoleMapping.cs" -Assert-FileExists "AdminRole enum present" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs" -Assert-FileExists "ILdapGroupRoleMappingService present" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/ILdapGroupRoleMappingService.cs" -Assert-FileExists "LdapGroupRoleMappingService impl present" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs" -Assert-TextFound "Write-time invariant: IsSystemWide XOR ClusterId" "IsSystemWide=true requires ClusterId" @("src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Services/LdapGroupRoleMappingService.cs") -Assert-FileExists "EF migration for LdapGroupRoleMapping" "src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Migrations/20260419131444_AddLdapGroupRoleMapping.cs" - -Write-Host "" -Write-Host "Stream B - Permission-trie evaluator (Core.Authorization)" -Assert-FileExists "OpcUaOperation enum present" "src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/OpcUaOperation.cs" -Assert-FileExists "NodeScope record present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/NodeScope.cs" -Assert-FileExists "AuthorizationDecision tri-state" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs" -Assert-TextFound "Verdict has Denied member (reserved for v2.1)" "Denied" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/AuthorizationDecision.cs") -Assert-FileExists "IPermissionEvaluator present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/IPermissionEvaluator.cs" -Assert-FileExists "PermissionTrie present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs" -Assert-FileExists "PermissionTrieBuilder present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs" -Assert-FileExists "PermissionTrieCache present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieCache.cs" -Assert-TextFound "Cache keyed on GenerationId" "GenerationId" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieCache.cs") -Assert-FileExists "UserAuthorizationState present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs" -Assert-TextFound "MembershipFreshnessInterval default 15 min" "FromMinutes\(15\)" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs") -Assert-TextFound "AuthCacheMaxStaleness default 5 min" "FromMinutes\(5\)" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/UserAuthorizationState.cs") -Assert-FileExists "TriePermissionEvaluator impl present" "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs" -Assert-TextFound "HistoryRead maps to NodePermissions.HistoryRead" "HistoryRead.+NodePermissions\.HistoryRead" @("src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs") - -Write-Host "" -Write-Host "Control/data-plane separation (decision #150)" -Assert-TextAbsent "Evaluator has zero references to LdapGroupRoleMapping" "LdapGroupRoleMapping" @( - "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/TriePermissionEvaluator.cs", - "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrie.cs", - "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieBuilder.cs", - "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/PermissionTrieCache.cs", - "src/Core/ZB.MOM.WW.OtOpcUa.Core/Authorization/IPermissionEvaluator.cs") - -Write-Host "" -Write-Host "Stream C foundation (dispatch-wiring gate)" -Assert-FileExists "ILdapGroupsBearer present" "src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/ILdapGroupsBearer.cs" -Assert-FileExists "AuthorizationGate present" "src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs" -Assert-TextFound "Gate has StrictMode knob" "StrictMode" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs") -Assert-Deferred "DriverNodeManager dispatch-path wiring (11 surfaces)" "Phase 6.2 Stream C follow-up task #143" - -Write-Host "" -Write-Host "Stream D data layer (ValidatedNodeAclAuthoringService)" -Assert-FileExists "ValidatedNodeAclAuthoringService present" "src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/ValidatedNodeAclAuthoringService.cs" -Assert-TextFound "InvalidNodeAclGrantException present" "class InvalidNodeAclGrantException" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/ValidatedNodeAclAuthoringService.cs") -Assert-TextFound "Rejects None permissions" "Permission set cannot be None" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/ValidatedNodeAclAuthoringService.cs") -Assert-Deferred "RoleGrantsTab + AclsTab Probe-this-permission + SignalR invalidation + draft diff section" "Phase 6.2 Stream D follow-up task #144" - -Write-Host "" -Write-Host "Cross-cutting" -Write-Host " Running full solution test suite..." -ForegroundColor DarkGray -$prevPref = $ErrorActionPreference -$ErrorActionPreference = 'Continue' -$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1 -$ErrorActionPreference = $prevPref -$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches -$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches -$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value } -$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value } -$baseline = 1042 -if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-6.2 baseline)" } -else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" } - -if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" } -else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" } - -Write-Host "" -if ($script:failures -eq 0) { - Write-Host "Phase 6.2 compliance: PASS" -ForegroundColor Green - exit 0 -} -Write-Host "Phase 6.2 compliance: $script:failures FAIL(s)" -ForegroundColor Red -exit 1 diff --git a/scripts/compliance/phase-6-3-compliance.ps1 b/scripts/compliance/phase-6-3-compliance.ps1 deleted file mode 100644 index 5aed1ed19..000000000 --- a/scripts/compliance/phase-6-3-compliance.ps1 +++ /dev/null @@ -1,110 +0,0 @@ -<# -.SYNOPSIS - Phase 6.3 exit-gate compliance check. Each check either passes or records a - failure; non-zero exit = fail. - -.DESCRIPTION - Validates Phase 6.3 (Redundancy runtime) completion. Checks enumerated in - `docs/v2/implementation/phase-6-3-redundancy-runtime.md` - §"Compliance Checks (run at exit gate)". - -.NOTES - Usage: pwsh ./scripts/compliance/phase-6-3-compliance.ps1 - Exit: 0 = all checks passed; non-zero = one or more FAILs -#> -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -$script:failures = 0 -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - -function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green } -function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ } -function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow } - -function Assert-FileExists { - param([string]$C, [string]$P) - if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" } - else { Assert-Fail $C "missing file: $P" } -} - -function Assert-TextFound { - param([string]$C, [string]$Pat, [string[]]$Paths) - foreach ($p in $Paths) { - $full = Join-Path $repoRoot $p - if (-not (Test-Path $full)) { continue } - if (Select-String -Path $full -Pattern $Pat -Quiet) { - Assert-Pass "$C (matched in $p)" - return - } - } - Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')" -} - -Write-Host "" -Write-Host "=== Phase 6.3 compliance - Redundancy runtime ===" -ForegroundColor Cyan -Write-Host "" - -Write-Host "Stream B - ServiceLevel 8-state matrix (decision #154)" -Assert-FileExists "ServiceLevelCalculator present" "src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs" -Assert-FileExists "ServiceLevelBand enum present" "src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs" -Assert-TextFound "Maintenance = 0 (reserved per OPC UA Part 5)" "Maintenance\s*=\s*0" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "NoData = 1 (reserved per OPC UA Part 5)" "NoData\s*=\s*1" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "InvalidTopology = 2 (detected-inconsistency band)" "InvalidTopology\s*=\s*2" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "AuthoritativePrimary = 255" "AuthoritativePrimary\s*=\s*255" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "IsolatedPrimary = 230 (retains authority)" "IsolatedPrimary\s*=\s*230" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "PrimaryMidApply = 200" "PrimaryMidApply\s*=\s*200" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "RecoveringPrimary = 180" "RecoveringPrimary\s*=\s*180" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "AuthoritativeBackup = 100" "AuthoritativeBackup\s*=\s*100" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "IsolatedBackup = 80 (does NOT auto-promote)" "IsolatedBackup\s*=\s*80" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "BackupMidApply = 50" "BackupMidApply\s*=\s*50" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") -Assert-TextFound "RecoveringBackup = 30" "RecoveringBackup\s*=\s*30" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ServiceLevelCalculator.cs") - -Write-Host "" -Write-Host "Stream B - RecoveryStateManager" -Assert-FileExists "RecoveryStateManager present" "src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/RecoveryStateManager.cs" -Assert-TextFound "Dwell + publish-witness gate" "_witnessed" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/RecoveryStateManager.cs") -Assert-TextFound "Default dwell 60 s" "FromSeconds\(60\)" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/RecoveryStateManager.cs") - -Write-Host "" -Write-Host "Stream D - Apply-lease registry (decision #162)" -Assert-FileExists "ApplyLeaseRegistry present" "src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ApplyLeaseRegistry.cs" -Assert-TextFound "BeginApplyLease returns IAsyncDisposable" "IAsyncDisposable" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ApplyLeaseRegistry.cs") -Assert-TextFound "Lease key includes PublishRequestId" "PublishRequestId" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ApplyLeaseRegistry.cs") -Assert-TextFound "Watchdog PruneStale present" "PruneStale" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ApplyLeaseRegistry.cs") -Assert-TextFound "Default ApplyMaxDuration 10 min" "FromMinutes\(10\)" @("src/Server/ZB.MOM.WW.OtOpcUa.Server/Redundancy/ApplyLeaseRegistry.cs") - -Write-Host "" -Write-Host "Deferred surfaces" -Assert-Deferred "Stream A - RedundancyCoordinator cluster-topology loader" "task #145" -Assert-Deferred "Stream C - OPC UA node wiring (ServiceLevel + ServerUriArray + RedundancySupport)" "task #147" -Assert-Deferred "Stream E - Admin RedundancyTab + OpenTelemetry metrics + SignalR" "task #149" -Assert-Deferred "Stream F - Client interop matrix + Galaxy MXAccess failover" "task #150" -Assert-Deferred "sp_PublishGeneration rejects Transparent mode pre-publish" "task #148 part 2 (SQL-side validator)" - -Write-Host "" -Write-Host "Cross-cutting" -Write-Host " Running full solution test suite..." -ForegroundColor DarkGray -$prevPref = $ErrorActionPreference -$ErrorActionPreference = 'Continue' -$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1 -$ErrorActionPreference = $prevPref -$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches -$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches -$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value } -$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value } -$baseline = 1097 -if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-6.3 baseline)" } -else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" } - -if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" } -else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" } - -Write-Host "" -if ($script:failures -eq 0) { - Write-Host "Phase 6.3 compliance: PASS" -ForegroundColor Green - exit 0 -} -Write-Host "Phase 6.3 compliance: $script:failures FAIL(s)" -ForegroundColor Red -exit 1 diff --git a/scripts/compliance/phase-6-4-compliance.ps1 b/scripts/compliance/phase-6-4-compliance.ps1 deleted file mode 100644 index 490fe650a..000000000 --- a/scripts/compliance/phase-6-4-compliance.ps1 +++ /dev/null @@ -1,96 +0,0 @@ -<# -.SYNOPSIS - Phase 6.4 exit-gate compliance check. Each check either passes or records a - failure; non-zero exit = fail. - -.DESCRIPTION - Validates Phase 6.4 (Admin UI completion) progress. Checks enumerated in - `docs/v2/implementation/phase-6-4-admin-ui-completion.md` - §"Compliance Checks (run at exit gate)". - -.NOTES - Usage: pwsh ./scripts/compliance/phase-6-4-compliance.ps1 - Exit: 0 = all checks passed; non-zero = one or more FAILs -#> -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Stop' -$script:failures = 0 -$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - -function Assert-Pass { param([string]$C) Write-Host " [PASS] $C" -ForegroundColor Green } -function Assert-Fail { param([string]$C, [string]$R) Write-Host " [FAIL] $C - $R" -ForegroundColor Red; $script:failures++ } -function Assert-Deferred { param([string]$C, [string]$P) Write-Host " [DEFERRED] $C (follow-up: $P)" -ForegroundColor Yellow } - -function Assert-FileExists { - param([string]$C, [string]$P) - if (Test-Path (Join-Path $repoRoot $P)) { Assert-Pass "$C ($P)" } - else { Assert-Fail $C "missing file: $P" } -} - -function Assert-TextFound { - param([string]$C, [string]$Pat, [string[]]$Paths) - foreach ($p in $Paths) { - $full = Join-Path $repoRoot $p - if (-not (Test-Path $full)) { continue } - if (Select-String -Path $full -Pattern $Pat -Quiet) { - Assert-Pass "$C (matched in $p)" - return - } - } - Assert-Fail $C "pattern '$Pat' not found in any of: $($Paths -join ', ')" -} - -Write-Host "" -Write-Host "=== Phase 6.4 compliance - Admin UI completion ===" -ForegroundColor Cyan -Write-Host "" - -Write-Host "Stream A data layer - UnsImpactAnalyzer" -Assert-FileExists "UnsImpactAnalyzer present" "src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs" -Assert-TextFound "DraftRevisionToken present" "record DraftRevisionToken" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs") -Assert-TextFound "Cross-cluster move rejected per decision #82" "CrossClusterMoveRejectedException" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs") -Assert-TextFound "LineMove + AreaRename + LineMerge covered" "UnsMoveKind\.LineMerge" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/UnsImpactAnalyzer.cs") - -Write-Host "" -Write-Host "Stream B data layer - EquipmentCsvImporter" -Assert-FileExists "EquipmentCsvImporter present" "src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs" -Assert-TextFound "CSV header version marker '# OtOpcUaCsv v1'" "OtOpcUaCsv v1" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs") -Assert-TextFound "Required columns match decision #117" "ZTag.+MachineCode.+SAPID.+EquipmentId.+EquipmentUuid" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs") -Assert-TextFound "Optional columns match decision #139 (Manufacturer)" "Manufacturer" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs") -Assert-TextFound "Optional columns include DeviceManualUri" "DeviceManualUri" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs") -Assert-TextFound "Rejects duplicate ZTag within file" "Duplicate ZTag" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs") -Assert-TextFound "Rejects unknown column" "unknown column" @("src/Server/ZB.MOM.WW.OtOpcUa.Admin/Services/EquipmentCsvImporter.cs") - -Write-Host "" -Write-Host "Deferred surfaces" -Assert-Deferred "Stream A UI - UnsTab MudBlazor drag/drop + 409 modal + Playwright" "task #153" -Assert-Deferred "Stream B follow-up - EquipmentImportBatch staging + FinaliseImportBatch + CSV import UI" "task #155" -Assert-Deferred "Stream C - DiffViewer refactor + 6 section plugins + 1000-row cap" "task #156" -Assert-Deferred "Stream D - IdentificationFields.razor + DriverNodeManager OPC 40010 sub-folder" "task #157" - -Write-Host "" -Write-Host "Cross-cutting" -Write-Host " Running full solution test suite..." -ForegroundColor DarkGray -$prevPref = $ErrorActionPreference -$ErrorActionPreference = 'Continue' -$testOutput = & dotnet test (Join-Path $repoRoot 'ZB.MOM.WW.OtOpcUa.slnx') --nologo 2>&1 -$ErrorActionPreference = $prevPref -$passLine = $testOutput | Select-String 'Passed:\s+(\d+)' -AllMatches -$failLine = $testOutput | Select-String 'Failed:\s+(\d+)' -AllMatches -$passCount = 0; foreach ($m in $passLine.Matches) { $passCount += [int]$m.Groups[1].Value } -$failCount = 0; foreach ($m in $failLine.Matches) { $failCount += [int]$m.Groups[1].Value } -$baseline = 1137 -if ($passCount -ge $baseline) { Assert-Pass "No test-count regression ($passCount >= $baseline pre-Phase-6.4 baseline)" } -else { Assert-Fail "Test-count regression" "passed $passCount < baseline $baseline" } - -if ($failCount -le 1) { Assert-Pass "No new failing tests (pre-existing CLI flake tolerated)" } -else { Assert-Fail "New failing tests" "$failCount failures > 1 tolerated" } - -Write-Host "" -if ($script:failures -eq 0) { - Write-Host "Phase 6.4 compliance: PASS" -ForegroundColor Green - exit 0 -} -Write-Host "Phase 6.4 compliance: $script:failures FAIL(s)" -ForegroundColor Red -exit 1 diff --git a/scripts/compliance/phase-6-all.ps1 b/scripts/compliance/phase-6-all.ps1 deleted file mode 100644 index 038c4f499..000000000 --- a/scripts/compliance/phase-6-all.ps1 +++ /dev/null @@ -1,77 +0,0 @@ -<# -.SYNOPSIS - Meta-runner that invokes every per-phase Phase 6.x compliance script and - reports an aggregate verdict. - -.DESCRIPTION - Runs phase-6-1-compliance.ps1, phase-6-2, phase-6-3, phase-6-4 in sequence. - Each sub-script returns its own exit code; this wrapper aggregates them. - Useful before a v2 release tag + as the `dotnet test` companion in CI. - -.NOTES - Usage: pwsh ./scripts/compliance/phase-6-all.ps1 - Exit: 0 = every phase passed; 1 = one or more phases failed -#> -[CmdletBinding()] -param() - -$ErrorActionPreference = 'Continue' - -$phases = @( - @{ Name = 'Phase 6.1 - Resilience & Observability'; Script = 'phase-6-1-compliance.ps1' }, - @{ Name = 'Phase 6.2 - Authorization runtime'; Script = 'phase-6-2-compliance.ps1' }, - @{ Name = 'Phase 6.3 - Redundancy runtime'; Script = 'phase-6-3-compliance.ps1' }, - @{ Name = 'Phase 6.4 - Admin UI completion'; Script = 'phase-6-4-compliance.ps1' } -) - -$results = @() -$startedAt = Get-Date - -foreach ($phase in $phases) { - Write-Host "" - Write-Host "" - Write-Host "=============================================================" -ForegroundColor DarkGray - Write-Host ("Running {0}" -f $phase.Name) -ForegroundColor Cyan - Write-Host "=============================================================" -ForegroundColor DarkGray - - $scriptPath = Join-Path $PSScriptRoot $phase.Script - if (-not (Test-Path $scriptPath)) { - Write-Host (" [MISSING] {0}" -f $phase.Script) -ForegroundColor Red - $results += @{ Name = $phase.Name; Exit = 2 } - continue - } - - # Invoke each sub-script in its own powershell.exe process so its local - # $ErrorActionPreference + exit-code semantics can't interfere with the meta-runner's - # state. Slower (one process spawn per phase) but makes aggregate PASS/FAIL match - # standalone runs exactly. - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $scriptPath - $exitCode = $LASTEXITCODE - $results += @{ Name = $phase.Name; Exit = $exitCode } -} - -$elapsed = (Get-Date) - $startedAt - -Write-Host "" -Write-Host "" -Write-Host "=============================================================" -ForegroundColor DarkGray -Write-Host "Phase 6 compliance aggregate" -ForegroundColor Cyan -Write-Host "=============================================================" -ForegroundColor DarkGray - -$totalFailures = 0 -foreach ($r in $results) { - $colour = if ($r.Exit -eq 0) { 'Green' } else { 'Red' } - $tag = if ($r.Exit -eq 0) { 'PASS' } else { "FAIL (exit=$($r.Exit))" } - Write-Host (" [{0}] {1}" -f $tag, $r.Name) -ForegroundColor $colour - if ($r.Exit -ne 0) { $totalFailures++ } -} - -Write-Host "" -Write-Host ("Elapsed: {0:N1} s" -f $elapsed.TotalSeconds) -ForegroundColor DarkGray - -if ($totalFailures -eq 0) { - Write-Host "Phase 6 aggregate: PASS" -ForegroundColor Green - exit 0 -} -Write-Host ("Phase 6 aggregate: {0} phase(s) FAILED" -f $totalFailures) -ForegroundColor Red -exit 1