function Test-PersonaConfiguration { <# .SYNOPSIS Runs all four validation layers over a configuration file (VR-001, FR-002). .DESCRIPTION Layers run in order and fail fast between them: 1. Syntax ConvertFrom-Json PE-SYN-nnn 2. Schema Test-Json -SchemaFile PE-SCH-nnn 3. Semantic Test-PersonaConfigurationSemantic PE-SEM-nnn 4. Safety Test-PersonaConfigurationSafety PE-SAF-nnn A layer that produces Error findings stops the sequence. Running semantic checks over a structurally invalid document produces noise, not signal: every missing field yields a cascade of consequent errors, and the author has to guess which one is the actual cause. Layer 2 error handling is driven by the V-5a observation (specs/001-persona-engine/verification/V-5a.md), which matters more than it looks. On PowerShell 7.6.5, Test-Json returns $true when the SCHEMA ITSELF is unparseable, writing the failure to the error stream instead. A wrapper that trusted the return value would report a configuration as schema-valid when the schema never ran. So this function treats a non-empty error variable as failure regardless of what was returned, and separates the "schema is broken" case from the "configuration is invalid" case, because they need different exit codes. .PARAMETER Path Configuration file to validate. .PARAMETER SchemaPath Schema override. Defaults to the shipped config/persona-engine.schema.json. .PARAMETER PreviousConfigPath A previously deployed configuration, enabling the VR-003 comparison checks (version downgrade, undeclared rule deletion or reorder). .PARAMETER EnforcementEnabled Whether this configuration will be used for an enforcing run. Raises the severity of several safety findings. .PARAMETER SkipSafety Runs layers 1-3 only. Used by the editor while a document is mid-edit. .OUTPUTS PersonaEngine.ValidationResult #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [string] $SchemaPath, [string] $PreviousConfigPath, [switch] $EnforcementEnabled, [switch] $SkipSafety ) $findings = [System.Collections.Generic.List[object]]::new() $document = $null $stoppedAtLayer = $null $schemaUnusable = $false # ---------------------------------------------------------------- Layer 1 if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-001' ` -Location $Path ` -Description "Configuration file not found or is not a file: '$Path'." ` -SuggestedResolution 'Check the path. In a pipeline, confirm the file was checked out and the working directory is what you expect.')) return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false } $raw = $null try { $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop } catch { $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-002' ` -Location $Path ` -Description "Configuration file could not be read: $($_.Exception.Message)" ` -SuggestedResolution 'Check file permissions and that no other process holds an exclusive lock.')) return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false } try { $document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop } catch { $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-003' ` -Location $Path ` -Description "Configuration is not valid JSON: $($_.Exception.Message)" ` -SuggestedResolution 'Fix the JSON syntax. A trailing comma or an unquoted key is the usual cause.')) return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false } # ---------------------------------------------------------------- Layer 2 if (-not $SchemaPath) { $moduleRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent $SchemaPath = Join-Path $moduleRoot 'config/persona-engine.schema.json' } if (-not (Test-Path -LiteralPath $SchemaPath -PathType Leaf)) { $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-002' ` -Location $SchemaPath ` -Description "Schema file not found: '$SchemaPath'." ` -SuggestedResolution 'Supply -SchemaPath, or restore config/persona-engine.schema.json.')) return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $true } $schemaErrors = $null $schemaOk = $raw | Test-Json -SchemaFile $SchemaPath -ErrorAction SilentlyContinue -ErrorVariable schemaErrors foreach ($schemaError in @($schemaErrors)) { $message = [string]$schemaError.Exception.Message # V-5a: this message arrives with a $true return value. Treating it as a # pass would validate every configuration against a schema that never ran. if ($message -match 'Cannot parse the JSON schema') { $schemaUnusable = $true $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-003' ` -Location $SchemaPath ` -Description "The schema file itself is not valid JSON Schema and could not be used: $message" ` -SuggestedResolution 'Repair the schema file. Until it parses, no configuration can be schema-validated, and a passing result would be meaningless.')) continue } $findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-001' ` -Location (Get-PersonaSchemaErrorLocation -Message $message) ` -Description $message ` -SuggestedResolution 'Correct the document to match config/persona-engine.schema.json. Test-Json reports only the first violation per run, so re-validate after each fix.')) } if ($schemaUnusable -or -not $schemaOk -or @($schemaErrors).Count -gt 0) { return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $schemaUnusable } # ---------------------------------------------------------------- Layer 3 foreach ($finding in (Test-PersonaConfigurationSemantic -Document $document)) { $findings.Add($finding) } if (@($findings | Where-Object Severity -EQ 'Error').Count -gt 0) { return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Semantic' -SchemaUnusable $false } # ---------------------------------------------------------------- Layer 4 if (-not $SkipSafety) { $safetyParams = @{ Document = $document; EnforcementEnabled = $EnforcementEnabled } if ($PreviousConfigPath) { $safetyParams['PreviousConfigPath'] = $PreviousConfigPath } foreach ($finding in (Test-PersonaConfigurationSafety @safetyParams)) { $findings.Add($finding) } } New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer $null -SchemaUnusable $false } function New-PersonaValidationResult { <# .SYNOPSIS Wraps a finding collection into the shape every caller consumes. .DESCRIPTION IsValid is computed here rather than by each caller, so console, pipeline, and editor cannot disagree about what "valid" means. Warnings never affect IsValid; escalation under -TreatWarningsAsErrors is the caller's decision (VR-005) and belongs where the exit code is chosen. #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object] $Findings, [AllowNull()] [object] $Document, [AllowNull()] [string] $StoppedAtLayer, [bool] $SchemaUnusable ) $all = @($Findings) [pscustomobject]@{ PSTypeName = 'PersonaEngine.ValidationResult' IsValid = (@($all | Where-Object Severity -EQ 'Error').Count -eq 0) Findings = $all ErrorCount = @($all | Where-Object Severity -EQ 'Error').Count WarningCount = @($all | Where-Object Severity -EQ 'Warning').Count Document = $Document StoppedAtLayer = $StoppedAtLayer SchemaUnusable = $SchemaUnusable } } function Get-PersonaSchemaErrorLocation { <# .SYNOPSIS Extracts the JSON pointer from a Test-Json error message. .DESCRIPTION Test-Json embeds the failing location in prose - "... at '/rules/3/priority'". VR-004 requires a location on every finding, so it is lifted out here rather than leaving the caller to read it out of the description. When no pointer is present the document root is reported, which is honest: the violation is somewhere in the document and the message says where in words. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Message) if ($Message -match "at '([^']*)'") { $pointer = $Matches[1] return $pointer ? $pointer : '/' } '/' } function Write-PersonaValidationFinding { <# .SYNOPSIS Renders validation findings for a human reader. .DESCRIPTION Grouped by severity, most serious first, with the suggested resolution on its own line. A finding without a visible resolution just tells someone they are wrong, which is why VR-004 makes the field mandatory and why it is printed rather than hidden behind a verbose switch. #> [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Findings ) if (@($Findings).Count -eq 0) { Write-Host 'Validation passed with no findings.' -ForegroundColor Green return } foreach ($severity in @('Error', 'Warning', 'Information')) { $set = @($Findings | Where-Object Severity -EQ $severity) if ($set.Count -eq 0) { continue } $colour = switch ($severity) { 'Error' { 'Red' } 'Warning' { 'Yellow' } default { 'Gray' } } foreach ($finding in $set) { Write-Host ("[{0}] {1} {2}" -f $finding.Severity.ToUpperInvariant(), $finding.Code, $finding.Location) -ForegroundColor $colour Write-Host (" {0}" -f $finding.Description) Write-Host (" -> {0}" -f $finding.SuggestedResolution) -ForegroundColor DarkGray } } }