Private/Assert-HDTRuleDocument.ps1
|
function Assert-HDTRuleDocument { <# .SYNOPSIS Validates a parsed rules.yaml against the authoring rules. .DESCRIPTION The engine's own validator, and the one that actually runs in WinPE: Test-Json does not exist under Windows PowerShell 5.1, so schemas/rules.schema.json is a gate for the console, editors and CI while this is the gate for a deployment. It throws on the first violation and returns nothing otherwise. Every failure is a terminating error built by New-HDTErrorRecord, so it names the file, carries the file as its TargetObject and reports HDTConfigurationError - "fail fast and point at the file". The authoring rules, in the order they are checked: document not empty; a mapping; only schemaVersion and rules; schemaVersion present, an integer, and not newer than this engine (delegated to Test-HDTSchemaVersion); rules present, a list, and not empty rule a mapping; a non-empty name; a name no other rule uses; no key outside name/when/set/setFrom; exactly one of set and setFrom when at least one condition; every value a scalar - a nested mapping is not a condition language, and HDT deliberately does not have one set at least one variable; every name matching ^HDT[A-Za-z0-9_]*$; no _HDT* name, which is engine-owned and cannot be assigned; every value a scalar or a list setFrom a non-empty script path Two of those deserve their reason stated. Duplicate rule NAMES are rejected because provenance records which rule set a variable, and two rules called Fallback make that answer ambiguous. An unknown key is rejected rather than ignored because the INI dialect HDT replaces let 'Priority' and friends look meaningful while doing nothing - a typo that silently does nothing is worse than a refusal. .PARAMETER Document The parsed document, as returned by ConvertFrom-HDTYaml. $null is accepted and reported as an empty file rather than crashing. .PARAMETER Path The file the document came from. Used for the message and the TargetObject; this function reads nothing. .OUTPUTS None. It throws or it returns nothing. .EXAMPLE Assert-HDTRuleDocument -Document (ConvertFrom-HDTYaml -Yaml $text -Path $path) -Path $path .NOTES The locator in a message is the RULE, not a line number: "rule 2 ('Latitude naming'): ...". The YAML parser does not carry line information onto the object graph it returns, so after parsing there is no honest line to report. Only ConvertFrom-HDTYaml, which still holds the parser's own exception, can name a line. #> [CmdletBinding()] [OutputType([void])] param( [Parameter(Mandatory = $true, Position = 0)] [AllowNull()] [object] $Document, [Parameter(Mandatory = $true, Position = 1)] [ValidateNotNullOrEmpty()] [string] $Path ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $supportedSchemaVersion = 1 $allowedRuleKey = @('name', 'when', 'set', 'setFrom') # -- the document --------------------------------------------------------- if ($null -eq $Document) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message 'the file is empty. A rules document must declare schemaVersion and at least one rule.')) } if (-not ($Document -is [System.Collections.IDictionary])) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("the document must be a mapping with schemaVersion and rules keys, but it is a {0}." -f $Document.GetType().Name))) } foreach ($key in @($Document.Keys)) { if (@('schemaVersion', 'rules') -notcontains [string] $key) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("'{0}' is not a key a rules document may declare. The allowed keys are schemaVersion and rules." -f $key))) } } if (-not $Document.Contains('schemaVersion')) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message 'schemaVersion is missing. Every HDT document declares one; this engine understands schemaVersion 1.')) } $schemaVersion = $Document['schemaVersion'] if (-not (($schemaVersion -is [int]) -or ($schemaVersion -is [long]))) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("schemaVersion must be an integer, but it is '{0}'." -f $schemaVersion))) } $supported = $false try { $supported = Test-HDTSchemaVersion -SchemaVersion ([int] $schemaVersion) -Supported $supportedSchemaVersion } catch { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("schemaVersion {0} is not a valid schema version. It must be 1 or greater." -f $schemaVersion))) } if (-not $supported) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("schemaVersion {0} is newer than this engine understands (schemaVersion {1}). Upgrade the engine rather than the workspace." -f $schemaVersion, $supportedSchemaVersion))) } if (-not $Document.Contains('rules')) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message 'the rules key is missing. A rules document declares a rules list, even if it holds a single fallback.')) } $rule = $Document['rules'] if (-not ($rule -is [System.Collections.IList])) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message 'the rules key must be a list of rules.')) } if (@($rule).Count -eq 0) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message 'the rules list is empty. A rules document must declare at least one rule.')) } # -- each rule ------------------------------------------------------------ $seenName = New-Object -TypeName System.Collections.ArrayList $index = 0 foreach ($current in @($rule)) { $index++ $locator = 'rule {0}' -f $index if (-not ($current -is [System.Collections.IDictionary])) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: a rule must be a mapping with a name and either set or setFrom." -f $locator))) } $name = '' if ($current.Contains('name')) { $name = [string] $current['name'] } if ([string]::IsNullOrWhiteSpace($name)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: every rule needs a non-empty name. The name is what provenance reports when this rule sets a variable." -f $locator))) } $locator = "rule {0} ('{1}')" -f $index, $name if ($seenName -contains $name) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: two rules share this name, which makes provenance ambiguous. Rule names must be unique." -f $locator))) } [void] $seenName.Add($name) foreach ($key in @($current.Keys)) { if ($allowedRuleKey -notcontains [string] $key) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: '{1}' is not a key a rule may declare. The allowed keys are {2}." -f $locator, $key, ($allowedRuleKey -join ', ')))) } } $hasSet = $current.Contains('set') $hasSetFrom = $current.Contains('setFrom') if (-not $hasSet -and -not $hasSetFrom) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: a rule must declare either set or setFrom. A rule that assigns nothing has no effect." -f $locator))) } if ($hasSet -and $hasSetFrom) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: a rule declares either set or setFrom, never both." -f $locator))) } # -- when ------------------------------------------------------------- if ($current.Contains('when')) { $when = $current['when'] if (-not ($when -is [System.Collections.IDictionary])) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: when must be a mapping of variable name to expected value." -f $locator))) } if (@($when.Keys).Count -eq 0) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: when must hold at least one condition. Omit it entirely for a rule that always applies." -f $locator))) } foreach ($key in @($when.Keys)) { $value = $when[$key] $isScalar = ($value -is [string]) -or ($value -is [bool]) -or ($value -is [int]) -or ($value -is [long]) -or ($value -is [double]) -or ($value -is [decimal]) if (-not $isScalar) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: the condition '{1}' must be a string, a boolean or a number. A rule condition is a comparison, not an expression - use setFrom for real logic." -f $locator, $key))) } } } # -- set -------------------------------------------------------------- if ($hasSet) { $set = $current['set'] if (-not ($set -is [System.Collections.IDictionary])) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: set must be a mapping of variable name to value." -f $locator))) } if (@($set.Keys).Count -eq 0) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: set must assign at least one variable." -f $locator))) } foreach ($key in @($set.Keys)) { $variable = [string] $key if ($variable.StartsWith('_')) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: '{1}' is engine-owned and cannot be assigned. A variable named _HDT* is set by the engine and is read-only." -f $locator, $variable))) } if ($variable -cnotmatch '^HDT[A-Za-z0-9_]*$') { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: '{1}' is not an HDT variable name. Every deployment variable is prefixed HDT; run Get-HDTVariableMap for the MDT translation." -f $locator, $variable))) } $value = $set[$key] $isScalar = ($value -is [string]) -or ($value -is [bool]) -or ($value -is [int]) -or ($value -is [long]) -or ($value -is [double]) -or ($value -is [decimal]) if (-not $isScalar -and -not ($value -is [System.Collections.IList])) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: the value of '{1}' must be a string, a boolean, a number or a list." -f $locator, $variable))) } } } # -- setFrom ---------------------------------------------------------- if ($hasSetFrom) { $setFrom = $current['setFrom'] if (-not ($setFrom -is [string]) -or [string]::IsNullOrWhiteSpace([string] $setFrom)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $Path ` -Message ("{0}: setFrom must be the path of a script, relative to the workspace root." -f $locator))) } } } } |