$ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot $jsonPath = Join-Path $root 'APP.openapi.json' $yamlPath = Join-Path $root 'APP.openapi.yaml' $document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json $yaml = Get-Content -Raw -Encoding UTF8 -LiteralPath $yamlPath $yamlLines = @($yaml -split "`r?`n") $openApi = [string]$document.openapi $isOpenApi30 = $openApi -match '^3\.0\.' $isOpenApi31 = $openApi -match '^3\.1\.' if (-not $isOpenApi30 -and -not $isOpenApi31) { throw "Unsupported OpenAPI version: $openApi" } if ($isOpenApi31 -and $null -ne $document.PSObject.Properties['jsonSchemaDialect']) { $dialect = [string]$document.PSObject.Properties['jsonSchemaDialect'].Value if ($dialect -ne 'https://spec.openapis.org/oas/3.1/dialect/base') { throw "Unsupported OpenAPI 3.1 jsonSchemaDialect override: $dialect" } } $componentSections = @('schemas', 'responses', 'parameters', 'examples', 'requestBodies', 'headers', 'securitySchemes', 'links', 'callbacks') if ($isOpenApi31) { $componentSections += 'pathItems' } $operations = @( @{ Method = 'get'; Path = '/genealogy/app/v2/genealogies/{genealogyId}/lineage/tree'; RootSchema = 'LineageGraphWindow' }, @{ Method = 'get'; Path = '/genealogy/app/v2/genealogies/{genealogyId}/lineage/tree/overview'; RootSchema = 'LineageOverview' }, @{ Method = 'get'; Path = '/genealogy/app/v2/genealogies/{genealogyId}/lineage/persons/{personId}/locator'; RootSchema = 'LineageLocator' }, @{ Method = 'patch'; Path = '/genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relationshipId}'; RootSchema = $null } ) $rootSchemaNames = @('LineageGraphWindow', 'LineageOverview', 'LineageLocator') function Get-PropertyValue { param([object]$Object, [string]$Name) if ($null -eq $Object) { return $null } $property = $Object.PSObject.Properties[$Name] if ($null -eq $property) { return $null } return $property.Value } function Test-HasProperty { param([object]$Object, [string]$Name) return $null -ne $Object -and $null -ne $Object.PSObject.Properties[$Name] } function Assert-Contract { param([bool]$Condition, [string]$Message) if (-not $Condition) { throw $Message } } function Assert-ExactSet { param([object[]]$Actual, [object[]]$Expected, [string]$Label) $actualValues = @($Actual | ForEach-Object { [string]$_ } | Sort-Object -Unique) $expectedValues = @($Expected | ForEach-Object { [string]$_ } | Sort-Object -Unique) if (($actualValues -join ',') -ne ($expectedValues -join ',')) { throw "$Label drifted: actual=[$($actualValues -join ',')] expected=[$($expectedValues -join ',')]" } } function Get-YamlRange { param([string]$Header, [string]$EndPattern) $start = [Array]::IndexOf($yamlLines, $Header) if ($start -lt 0) { return $null } $end = $yamlLines.Count for ($index = $start + 1; $index -lt $yamlLines.Count; $index += 1) { if ($yamlLines[$index] -match $EndPattern) { $end = $index break } } return ($yamlLines[$start..($end - 1)] -join "`n") } function Get-YamlPathBlock { param([string]$Path) foreach ($header in @(" $Path`:", " '$Path`':", " `"$Path`":")) { $block = Get-YamlRange -Header $header -EndPattern '^ [''"]?/.*[''"]?:$|^[A-Za-z][A-Za-z0-9_-]*:$' if ($null -ne $block) { return $block } } return $null } function Get-YamlOperationBlock { param([string]$Path, [string]$Method) $pathBlock = Get-YamlPathBlock -Path $Path if ($null -eq $pathBlock) { return $null } $lines = @($pathBlock -split "`n") $header = " $Method`:" $start = [Array]::IndexOf($lines, $header) if ($start -lt 0) { return $null } $end = $lines.Count for ($index = $start + 1; $index -lt $lines.Count; $index += 1) { if ($lines[$index] -match '^ (get|put|post|delete|patch|options|head|trace):$') { $end = $index break } } return ($lines[$start..($end - 1)] -join "`n") } function Get-YamlComponentSection { param([string]$Section) return Get-YamlRange -Header " $Section`:" -EndPattern '^ [A-Za-z][A-Za-z0-9_-]*:$|^[A-Za-z][A-Za-z0-9_-]*:$' } function Get-YamlComponentNames { param([string]$Section) $sectionBlock = Get-YamlComponentSection -Section $Section if ($null -eq $sectionBlock) { return @() } return @( [regex]::Matches($sectionBlock, '(?m)^ [''"]?(?[A-Za-z0-9_.-]+)[''"]?:$') | ForEach-Object { $_.Groups['name'].Value } ) } function Get-YamlComponentBlock { param([string]$Section, [string]$Name) $sectionBlock = Get-YamlComponentSection -Section $Section if ($null -eq $sectionBlock) { return $null } $lines = @($sectionBlock -split "`n") $start = -1 foreach ($header in @(" $Name`:", " '$Name`':", " `"$Name`":")) { $start = [Array]::IndexOf($lines, $header) if ($start -ge 0) { break } } if ($start -lt 0) { return $null } $end = $lines.Count for ($index = $start + 1; $index -lt $lines.Count; $index += 1) { if ($lines[$index] -match '^ [''"]?[A-Za-z0-9_.-]+[''"]?:$') { $end = $index break } } return ($lines[$start..($end - 1)] -join "`n") } function Get-ComponentRefSet { param([object]$Value) if ($null -eq $Value) { return @() } $refs = New-Object 'System.Collections.Generic.HashSet[string]' if ($Value -is [string]) { foreach ($refLine in [regex]::Matches($Value, '(?m)^\s+\$ref:\s*[''"]?(?[^\s''"]+)[''"]?\s*$')) { $match = [regex]::Match($refLine.Groups['ref'].Value, '^#/components/(?
[A-Za-z][A-Za-z0-9]*)/(?[A-Za-z0-9_.-]+)$') if (-not $match.Success -or $match.Groups['section'].Value -notin $componentSections) { throw "Unsupported component reference: $($refLine.Groups['ref'].Value)" } [void]$refs.Add("$($match.Groups['section'].Value)/$($match.Groups['name'].Value)") } return @($refs | Sort-Object) } function Visit-ComponentRefs { param([object]$Current, [System.Collections.Generic.HashSet[string]]$Result) if ($null -eq $Current -or $Current -is [string] -or $Current -is [ValueType]) { return } if ($Current -is [System.Collections.IEnumerable] -and -not ($Current -is [pscustomobject])) { foreach ($item in $Current) { Visit-ComponentRefs -Current $item -Result $Result } return } foreach ($property in $Current.PSObject.Properties) { if ($property.Name -eq '$ref') { $match = [regex]::Match([string]$property.Value, '^#/components/(?
[A-Za-z][A-Za-z0-9]*)/(?[A-Za-z0-9_.-]+)$') if (-not $match.Success -or $match.Groups['section'].Value -notin $componentSections) { throw "Unsupported component reference: $($property.Value)" } [void]$Result.Add("$($match.Groups['section'].Value)/$($match.Groups['name'].Value)") } else { Visit-ComponentRefs -Current $property.Value -Result $Result } } } Visit-ComponentRefs -Current $Value -Result $refs return @($refs | Sort-Object) } function Resolve-LocalRef { param([object]$Value) $current = $Value $visited = New-Object 'System.Collections.Generic.HashSet[string]' while (Test-HasProperty -Object $current -Name '$ref') { $ref = [string](Get-PropertyValue -Object $current -Name '$ref') if (-not $visited.Add($ref)) { throw "Circular component reference: $ref" } $match = [regex]::Match($ref, '^#/components/(?
schemas|responses|parameters|requestBodies)/(?[^/]+)$') if (-not $match.Success) { throw "Unsupported component reference: $ref" } $section = Get-PropertyValue -Object $document.components -Name $match.Groups['section'].Value $property = if ($null -eq $section) { $null } else { $section.PSObject.Properties[$match.Groups['name'].Value] } if ($null -eq $property) { throw "Component reference target missing: $ref" } $current = $property.Value } return $current } function Resolve-Schema { param([object]$Schema) if (Test-HasProperty -Object $Schema -Name '$ref') { # 本门禁只接受独立引用以及不改变校验语义的注解旁项。OpenAPI 3.1 虽允许 # Schema `$ref` 旁项,但若在这里静默丢弃 type/allOf/not 等约束会造成假绿。 $annotationKeywords = @('$ref', 'title', 'summary', 'description', 'deprecated', 'readOnly', 'writeOnly', 'examples', 'externalDocs') $validationSiblings = @($Schema.PSObject.Properties.Name | Where-Object { $_ -notin $annotationKeywords }) if ($validationSiblings.Count -gt 0) { throw "Schema reference contains unsupported validation siblings: $($validationSiblings -join ',')" } } $resolved = Resolve-LocalRef -Value $Schema return $resolved } function Get-SchemaFragments { param([object]$Schema, [string[]]$RefStack = @(), [int]$Depth = 0) if ($Depth -gt 50) { throw 'Schema allOf nesting exceeds 50 levels' } $nextStack = @($RefStack) if (Test-HasProperty -Object $Schema -Name '$ref') { $ref = [string](Get-PropertyValue -Object $Schema -Name '$ref') if ($ref -in $RefStack) { throw "Circular allOf schema reference: $ref" } $nextStack += $ref } $resolved = Resolve-Schema -Schema $Schema $result = @($resolved) foreach ($child in @(Get-PropertyValue -Object $resolved -Name 'allOf')) { $result += @(Get-SchemaFragments -Schema $child -RefStack $nextStack -Depth ($Depth + 1)) } return $result } function Get-SchemaPropertyNames { param([object]$Schema) $names = @() foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) { $properties = Get-PropertyValue -Object $fragment -Name 'properties' if ($null -ne $properties) { $names += @($properties.PSObject.Properties.Name) } } return @($names | Sort-Object -Unique) } function Get-SchemaRequiredNames { param([object]$Schema) $names = @() foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) { $names += @(Get-PropertyValue -Object $fragment -Name 'required') } return @($names | Sort-Object -Unique) } function Get-SchemaProperty { param([object]$Schema, [string]$Name) $matches = @() foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) { $properties = Get-PropertyValue -Object $fragment -Name 'properties' if ($null -ne $properties -and $null -ne $properties.PSObject.Properties[$Name]) { $matches += ,$properties.PSObject.Properties[$Name].Value } } if ($matches.Count -gt 1) { throw "Schema property is defined more than once across allOf: $Name" } if ($matches.Count -eq 1) { return $matches[0] } return $null } function Test-SchemaIsClosed { param([object]$Schema) $resolved = Resolve-Schema -Schema $Schema if ($isOpenApi31 -and (Test-HasProperty -Object $resolved -Name 'unevaluatedProperties') -and (Get-PropertyValue -Object $resolved -Name 'unevaluatedProperties') -eq $false) { return $true } $allFields = @(Get-SchemaPropertyNames -Schema $Schema) foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) { if ((Test-HasProperty -Object $fragment -Name 'additionalProperties') -and (Get-PropertyValue -Object $fragment -Name 'additionalProperties') -eq $false) { $fragmentFields = @() $properties = Get-PropertyValue -Object $fragment -Name 'properties' if ($null -ne $properties) { $fragmentFields = @($properties.PSObject.Properties.Name | Sort-Object -Unique) } if (($fragmentFields -join ',') -eq (@($allFields | Sort-Object) -join ',')) { return $true } } } return $false } function Assert-ObjectTypedSchema { param([object]$Schema, [string]$Label, [bool]$AllowNullable = $false) $objectTypes = @() foreach ($fragment in @(Get-SchemaFragments -Schema $Schema)) { $objectTypes += @(Get-TypeNames -Schema $fragment) } Assert-ExactSet -Actual $objectTypes -Expected @('object') -Label "$Label type" if (-not $AllowNullable) { Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $Schema)) -Message "$Label must not allow null" } } function Assert-ExactObjectSchema { param([object]$Schema, [string[]]$Fields, [string[]]$Required, [string]$Label, [bool]$AllowNullable = $false) Assert-ObjectTypedSchema -Schema $Schema -Label $Label -AllowNullable $AllowNullable Assert-ExactSet -Actual @(Get-SchemaPropertyNames -Schema $Schema) -Expected $Fields -Label "$Label properties" Assert-ExactSet -Actual @(Get-SchemaRequiredNames -Schema $Schema) -Expected $Required -Label "$Label required" Assert-Contract -Condition (Test-SchemaIsClosed -Schema $Schema) -Message "$Label must reject additional properties" } function Get-TypeNames { param([object]$Schema) $resolved = Resolve-Schema -Schema $Schema return @((Get-PropertyValue -Object $resolved -Name 'type') | ForEach-Object { [string]$_ }) } function Get-NonNullTypeNames { param([object]$Schema) return @((Get-TypeNames -Schema $Schema) | Where-Object { $_ -ne 'null' } | Sort-Object -Unique) } function Assert-ExactNonNullType { param([object]$Schema, [string]$Type, [string]$Label, [bool]$AllowNullable = $false) $resolved = Resolve-Schema -Schema $Schema foreach ($keyword in @('allOf', 'oneOf', 'anyOf', 'not', 'if', 'then', 'else')) { if (Test-HasProperty -Object $resolved -Name $keyword) { throw "$Label scalar schema must not hide validation semantics in $keyword" } } Assert-ExactSet -Actual @(Get-TypeNames -Schema $resolved) -Expected @($Type) -Label "$Label type" if (-not $AllowNullable) { Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $Schema)) -Message "$Label must not allow null" } } function Test-IsNullOnlySchema { param([object]$Schema) $resolved = Resolve-Schema -Schema $Schema $types = @(Get-TypeNames -Schema $resolved) if ($types.Count -eq 1 -and $types[0] -eq 'null') { return $true } if ($isOpenApi31 -and (Test-HasProperty -Object $resolved -Name 'const') -and $null -eq (Get-PropertyValue -Object $resolved -Name 'const')) { return $true } if (Test-HasProperty -Object $resolved -Name 'enum') { $enumValue = $resolved.PSObject.Properties['enum'].Value $isSingleNullEnum = $enumValue -is [System.Array] -and $enumValue.Count -eq 1 -and $null -eq $enumValue[0] if ($isSingleNullEnum -and ($isOpenApi31 -or ((Get-PropertyValue -Object $resolved -Name 'nullable') -eq $true))) { return $true } } return $false } function Test-IsNullableSchema { param([object]$Schema) $resolved = Resolve-Schema -Schema $Schema if ($isOpenApi30) { foreach ($fragment in @(Get-SchemaFragments -Schema $resolved)) { if ((Test-HasProperty -Object $fragment -Name 'nullable') -and (Get-PropertyValue -Object $fragment -Name 'nullable') -eq $true) { return $true } } } if ($isOpenApi31 -and 'null' -in @(Get-TypeNames -Schema $resolved)) { return $true } foreach ($keyword in @('oneOf', 'anyOf')) { foreach ($branch in @(Get-PropertyValue -Object $resolved -Name $keyword)) { if ((Test-IsNullOnlySchema -Schema $branch) -or (Test-IsNullableSchema -Schema $branch)) { return $true } } } return $false } function Get-NonNullSchema { param([object]$Schema) $resolved = Resolve-Schema -Schema $Schema foreach ($keyword in @('oneOf', 'anyOf')) { $branches = @(Get-PropertyValue -Object $resolved -Name $keyword) if ($branches.Count -gt 0) { $nonNull = @($branches | Where-Object { -not (Test-IsNullOnlySchema -Schema $_) }) if ($nonNull.Count -ne 1) { throw "Nullable union must expose exactly one non-null branch" } return $nonNull[0] } } return $Schema } function Get-NonNullEnumValues { param([object]$Schema) $resolved = Resolve-Schema -Schema $Schema return @((Get-PropertyValue -Object $resolved -Name 'enum') | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) } function Assert-ExactEnum { param([object]$Schema, [string[]]$Values, [string]$Label, [bool]$AllowNullable = $false) Assert-ExactNonNullType -Schema $Schema -Type 'string' -Label $Label -AllowNullable $AllowNullable Assert-ExactSet -Actual @(Get-NonNullEnumValues -Schema $Schema) -Expected $Values -Label "$Label enum" } function Get-SingleLiteral { param([object]$Schema, [string]$Label) $resolved = Resolve-Schema -Schema $Schema Assert-ExactNonNullType -Schema $resolved -Type 'string' -Label $Label if ($isOpenApi31 -and (Test-HasProperty -Object $resolved -Name 'const')) { return [string](Get-PropertyValue -Object $resolved -Name 'const') } $values = @(Get-NonNullEnumValues -Schema $resolved) if ($values.Count -ne 1) { throw "$Label must use const or a single-value enum" } return $values[0] } function Assert-StringSchema { param([object]$Schema, [string]$Label, [bool]$Nullable = $false) $candidate = Get-NonNullSchema -Schema $Schema Assert-ExactNonNullType -Schema $candidate -Type 'string' -Label $Label -AllowNullable $Nullable $resolved = Resolve-Schema -Schema $candidate Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'minLength') -ge 1) -Message "$Label must set minLength >= 1" if ($Nullable) { Assert-Contract -Condition (Test-IsNullableSchema -Schema $Schema) -Message "$Label must allow null" } else { Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $Schema)) -Message "$Label must not allow null" } } function Assert-StablePersonIdSchema { param([object]$Schema, [string]$Label, [bool]$Nullable = $false) Assert-StringSchema -Schema $Schema -Label $Label -Nullable $Nullable $candidate = Resolve-Schema -Schema (Get-NonNullSchema -Schema $Schema) $pattern = [string](Get-PropertyValue -Object $candidate -Name 'pattern') Assert-Contract -Condition (-not [string]::IsNullOrWhiteSpace($pattern)) -Message "$Label must reject redacted opaque IDs with a pattern" try { $regex = [regex]::new($pattern) } catch { throw "$Label has an invalid pattern: $pattern" } foreach ($accepted in @('p103', '900719925474099312345', 'person:alpha-1')) { $match = $regex.Match($accepted) Assert-Contract -Condition ($match.Success -and $match.Index -eq 0 -and $match.Length -eq $accepted.Length) -Message "$Label pattern rejects stable ID: $accepted" } foreach ($rejected in @('redacted:t-1:17', 'redacted:t-20260722-1:opaque-token', 'redacted:x:y')) { Assert-Contract -Condition (-not $regex.IsMatch($rejected)) -Message "$Label pattern accepts redacted opaque ID: $rejected" } } function Assert-IntegerSchema { param([object]$Schema, [string]$Label, [Nullable[int]]$Minimum = $null, [Nullable[int]]$Maximum = $null, [Nullable[int]]$Default = $null, [bool]$AllowNullable = $false) $resolved = Resolve-Schema -Schema (Get-NonNullSchema -Schema $Schema) Assert-ExactNonNullType -Schema $resolved -Type 'integer' -Label $Label -AllowNullable $AllowNullable if ($null -ne $Minimum) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'minimum') -eq $Minimum) -Message "$Label minimum must be $Minimum" } if ($null -ne $Maximum) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'maximum') -eq $Maximum) -Message "$Label maximum must be $Maximum" } if ($null -ne $Default) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'default') -eq $Default) -Message "$Label default must be $Default" } } function Assert-ArraySchema { param([object]$Schema, [string]$Label, [Nullable[int]]$MinimumItems = $null, [Nullable[int]]$MaximumItems = $null) $resolved = Resolve-Schema -Schema $Schema Assert-ExactNonNullType -Schema $resolved -Type 'array' -Label $Label Assert-Contract -Condition (Test-HasProperty -Object $resolved -Name 'items') -Message "$Label must define items" if ($null -ne $MinimumItems) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'minItems') -eq $MinimumItems) -Message "$Label minItems must be $MinimumItems" } if ($null -ne $MaximumItems) { Assert-Contract -Condition ((Get-PropertyValue -Object $resolved -Name 'maxItems') -eq $MaximumItems) -Message "$Label maxItems must be $MaximumItems" } return Get-PropertyValue -Object $resolved -Name 'items' } function Assert-GenerationRangeSchema { param([object]$Schema, [string]$Label) Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $Schema)) -Message "$Label must be non-null" Assert-ExactObjectSchema -Schema $Schema -Fields @('minGeneration', 'maxGeneration') -Required @('minGeneration', 'maxGeneration') -Label $Label Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $Schema -Name 'minGeneration') -Label "$Label.minGeneration" -Minimum 1 Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $Schema -Name 'maxGeneration') -Label "$Label.maxGeneration" -Minimum 1 } function Assert-PureOneOfOwner { param([object]$Schema, [string]$Label, [bool]$AllowDiscriminator = $false) $resolved = Resolve-Schema -Schema $Schema $allowed = @('oneOf', 'title', 'summary', 'description', 'deprecated', 'readOnly', 'writeOnly', 'examples', 'externalDocs') if ($AllowDiscriminator) { $allowed += 'discriminator' } $conflicts = @($resolved.PSObject.Properties.Name | Where-Object { $_ -notin $allowed }) if ($conflicts.Count -gt 0) { throw "$Label oneOf owner contains conflicting validation keywords: $($conflicts -join ',')" } } function Get-DiscriminatedBranches { param([object]$Schema, [string]$PropertyName, [string[]]$Values, [string]$Label) $resolved = Resolve-Schema -Schema $Schema Assert-PureOneOfOwner -Schema $resolved -Label $Label -AllowDiscriminator $true $branches = @(Get-PropertyValue -Object $resolved -Name 'oneOf') Assert-Contract -Condition ($branches.Count -eq $Values.Count) -Message "$Label must use oneOf with $($Values.Count) branches" $discriminator = Get-PropertyValue -Object $resolved -Name 'discriminator' Assert-Contract -Condition ($null -ne $discriminator -and (Get-PropertyValue -Object $discriminator -Name 'propertyName') -eq $PropertyName) -Message "$Label discriminator must use $PropertyName" $mapping = Get-PropertyValue -Object $discriminator -Name 'mapping' if ($null -ne $mapping) { Assert-ExactSet -Actual @($mapping.PSObject.Properties.Name) -Expected $Values -Label "$Label discriminator mapping" } $result = @{} foreach ($branch in $branches) { $ref = [string](Get-PropertyValue -Object $branch -Name '$ref') $candidate = Resolve-Schema -Schema $branch $value = Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $candidate -Name $PropertyName) -Label "$Label.$PropertyName" Assert-Contract -Condition ($value -in $Values) -Message "$Label contains unexpected discriminator value: $value" Assert-Contract -Condition (-not $result.ContainsKey($value)) -Message "$Label repeats discriminator value: $value" if ($null -ne $mapping) { Assert-Contract -Condition (-not [string]::IsNullOrWhiteSpace($ref) -and [string](Get-PropertyValue -Object $mapping -Name $value) -eq $ref) -Message "$Label mapping for $value does not point to its branch" } $result[$value] = $candidate } Assert-ExactSet -Actual @($result.Keys) -Expected $Values -Label "$Label branch values" return $result } function Get-OperationParameters { param([object]$PathItem, [object]$Operation) $parameters = @() foreach ($candidate in @(@(Get-PropertyValue -Object $PathItem -Name 'parameters') + @(Get-PropertyValue -Object $Operation -Name 'parameters'))) { if ($null -ne $candidate) { $parameters += ,(Resolve-LocalRef -Value $candidate) } } return $parameters } function Get-Parameter { param([object[]]$Parameters, [string]$Name, [string]$In) $matches = @($Parameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'name') -eq $Name -and (Get-PropertyValue -Object $_ -Name 'in') -eq $In }) if ($matches.Count -ne 1) { throw "Parameter must exist exactly once: $In $Name" } return $matches[0] } function Assert-PathIdParameter { param([object[]]$Parameters, [string]$Name, [string]$Label) $parameter = Get-Parameter -Parameters $Parameters -Name $Name -In 'path' Assert-Contract -Condition ((Get-PropertyValue -Object $parameter -Name 'required') -eq $true) -Message "$Label must be required" Assert-StringSchema -Schema (Get-PropertyValue -Object $parameter -Name 'schema') -Label $Label } function Assert-ExactParameters { param([object[]]$Parameters, [string[]]$Expected, [string]$Label) $actual = @($Parameters | ForEach-Object { "$(Get-PropertyValue -Object $_ -Name 'in'):$(Get-PropertyValue -Object $_ -Name 'name')" }) Assert-Contract -Condition ($actual.Count -eq $Expected.Count) -Message "$Label count drifted: actual=$($actual.Count) expected=$($Expected.Count)" Assert-ExactSet -Actual $actual -Expected $Expected -Label $Label } function Get-ResponseObject { param([object]$Operation, [string]$Status) $responses = Get-PropertyValue -Object $Operation -Name 'responses' $property = if ($null -eq $responses) { $null } else { $responses.PSObject.Properties[$Status] } if ($null -eq $property) { throw "Response missing: HTTP $Status" } return Resolve-LocalRef -Value $property.Value } function Get-ResponseSchema { param([object]$Operation, [string]$Status) $response = Get-ResponseObject -Operation $Operation -Status $Status $content = Get-PropertyValue -Object $response -Name 'content' $jsonContent = if ($null -eq $content) { $null } else { $content.PSObject.Properties['application/json'] } if ($null -eq $jsonContent) { throw "HTTP $Status must define application/json content" } $schema = Get-PropertyValue -Object $jsonContent.Value -Name 'schema' if ($null -eq $schema) { throw "HTTP $Status application/json schema missing" } return $schema } function Assert-ResponseStatusMatrix { param([object]$Operation, [string]$Label, [bool]$RequiresConflict) $responses = Get-PropertyValue -Object $Operation -Name 'responses' $names = @($responses.PSObject.Properties.Name) foreach ($status in @('200', '400', '401', '403', '404', '422', '429')) { Assert-Contract -Condition ($status -in $names) -Message "$Label response missing HTTP $status" } if ($RequiresConflict) { Assert-Contract -Condition ('409' -in $names) -Message "$Label response missing HTTP 409" } Assert-Contract -Condition ('5XX' -in $names) -Message "$Label must declare the 5XX wildcard response" } function Assert-ResponseDataRoot { param([object]$Operation, [string]$RootSchemaName, [string]$Label) $schema = Get-ResponseSchema -Operation $Operation -Status '200' $wrapper = Resolve-Schema -Schema $schema Assert-ObjectTypedSchema -Schema $wrapper -Label "$Label response wrapper" $data = Get-SchemaProperty -Schema $wrapper -Name 'data' Assert-Contract -Condition ($null -ne $data) -Message "$Label response wrapper must define data" Assert-Contract -Condition ('data' -in @(Get-SchemaRequiredNames -Schema $wrapper)) -Message "$Label response wrapper data must be required" Assert-Contract -Condition ([string](Get-PropertyValue -Object $data -Name '$ref') -eq "#/components/schemas/$RootSchemaName") -Message "$Label response data must reference $RootSchemaName" return Resolve-Schema -Schema $data } function Assert-ResponseBusinessCode { param([object]$Operation, [string]$Status, [string]$Code, [string]$Label) $schema = Resolve-Schema -Schema (Get-ResponseSchema -Operation $Operation -Status $Status) Assert-PureOneOfOwner -Schema $schema -Label "$Label HTTP $Status error" $variants = @(Get-PropertyValue -Object $schema -Name 'oneOf') Assert-Contract -Condition ($variants.Count -gt 0) -Message "$Label HTTP $Status must use oneOf error variants" $codes = @() foreach ($variant in $variants) { $resolvedVariant = Resolve-Schema -Schema $variant Assert-ObjectTypedSchema -Schema $resolvedVariant -Label "$Label HTTP $Status error variant" $businessCode = Get-SchemaProperty -Schema $resolvedVariant -Name 'businessCode' if ($null -eq $businessCode -or 'businessCode' -notin @(Get-SchemaRequiredNames -Schema $resolvedVariant)) { continue } Assert-ExactNonNullType -Schema $businessCode -Type 'string' -Label "$Label HTTP $Status businessCode" Assert-Contract -Condition (-not (Test-IsNullableSchema -Schema $businessCode)) -Message "$Label HTTP $Status businessCode must not be nullable" try { $codes += Get-SingleLiteral -Schema $businessCode -Label "$Label HTTP $Status businessCode" } catch { continue } } Assert-Contract -Condition ($Code -in $codes) -Message "$Label HTTP $Status must expose required root businessCode=$Code as a typed oneOf variant" } function Add-ReachableSchemaNames { param([object]$Value, [System.Collections.Generic.HashSet[string]]$Names) if ($null -eq $Value) { return } $serialized = $Value | ConvertTo-Json -Depth 100 -Compress foreach ($match in [regex]::Matches($serialized, '#/components/schemas/(?[A-Za-z0-9_.-]+)')) { $name = $match.Groups['name'].Value if ($Names.Add($name)) { $property = $document.components.schemas.PSObject.Properties[$name] if ($null -eq $property) { throw "Referenced schema missing: $name" } Add-ReachableSchemaNames -Value $property.Value -Names $Names } } } function Add-ReachableComponentKeys { param([object]$Value, [System.Collections.Generic.HashSet[string]]$Keys) foreach ($key in @(Get-ComponentRefSet -Value $Value)) { if ($Keys.Add($key)) { $parts = $key -split '/', 2 $section = Get-PropertyValue -Object $document.components -Name $parts[0] $property = if ($null -eq $section) { $null } else { $section.PSObject.Properties[$parts[1]] } if ($null -eq $property) { throw "Referenced component missing: $key" } Add-ReachableComponentKeys -Value $property.Value -Keys $Keys } } } function Test-SchemaGraphContainsFields { param([object]$Schema, [string[]]$Fields, [System.Collections.Generic.HashSet[string]]$VisitedRefs) if ($null -eq $Schema) { return $false } if (Test-HasProperty -Object $Schema -Name '$ref') { $ref = [string](Get-PropertyValue -Object $Schema -Name '$ref') if (-not $VisitedRefs.Add($ref)) { return $false } } $resolved = Resolve-Schema -Schema $Schema $propertyNames = @(Get-SchemaPropertyNames -Schema $resolved) if (@($Fields | Where-Object { $_ -notin $propertyNames }).Count -eq 0) { return $true } foreach ($keyword in @('allOf', 'oneOf', 'anyOf')) { foreach ($branch in @(Get-PropertyValue -Object $resolved -Name $keyword)) { if (Test-SchemaGraphContainsFields -Schema $branch -Fields $Fields -VisitedRefs $VisitedRefs) { return $true } } } $properties = Get-PropertyValue -Object $resolved -Name 'properties' if ($null -ne $properties) { foreach ($property in $properties.PSObject.Properties) { if (Test-SchemaGraphContainsFields -Schema $property.Value -Fields $Fields -VisitedRefs $VisitedRefs) { return $true } } } $items = Get-PropertyValue -Object $resolved -Name 'items' if ($null -ne $items -and (Test-SchemaGraphContainsFields -Schema $items -Fields $Fields -VisitedRefs $VisitedRefs)) { return $true } return $false } function Assert-SchemaDialect { param([object]$Schema, [string]$Label) if ($null -eq $Schema) { return } if (Test-HasProperty -Object $Schema -Name '$schema') { throw "$Label overrides the document JSON Schema dialect" } if (Test-HasProperty -Object $Schema -Name '$ref') { $annotationKeywords = @('$ref', 'title', 'summary', 'description', 'deprecated', 'readOnly', 'writeOnly', 'examples', 'externalDocs') $validationSiblings = @($Schema.PSObject.Properties.Name | Where-Object { $_ -notin $annotationKeywords }) if ($validationSiblings.Count -gt 0) { throw "$Label uses validation siblings beside a schema reference: $($validationSiblings -join ',')" } } if ($isOpenApi30) { if (Test-HasProperty -Object $Schema -Name 'const') { throw "$Label uses const, which is not valid in OpenAPI 3.0" } if (Test-HasProperty -Object $Schema -Name 'unevaluatedProperties') { throw "$Label uses unevaluatedProperties, which is not valid in OpenAPI 3.0" } $typeValue = Get-PropertyValue -Object $Schema -Name 'type' if ($typeValue -is [System.Array] -or $typeValue -eq 'null') { throw "$Label uses a JSON Schema type form that is not valid in OpenAPI 3.0" } } else { if (Test-HasProperty -Object $Schema -Name 'nullable') { throw "$Label uses the retired nullable keyword in OpenAPI 3.1" } } foreach ($keyword in @('allOf', 'oneOf', 'anyOf', 'prefixItems')) { foreach ($branch in @(Get-PropertyValue -Object $Schema -Name $keyword)) { Assert-SchemaDialect -Schema $branch -Label "$Label.$keyword" } } foreach ($keyword in @('not', 'if', 'then', 'else', 'contains', 'propertyNames', 'contentSchema')) { $child = Get-PropertyValue -Object $Schema -Name $keyword if ($null -ne $child) { Assert-SchemaDialect -Schema $child -Label "$Label.$keyword" } } $properties = Get-PropertyValue -Object $Schema -Name 'properties' if ($null -ne $properties) { foreach ($property in $properties.PSObject.Properties) { Assert-SchemaDialect -Schema $property.Value -Label "$Label.$($property.Name)" } } $items = Get-PropertyValue -Object $Schema -Name 'items' if ($null -ne $items) { Assert-SchemaDialect -Schema $items -Label "$Label.items" } $additional = Get-PropertyValue -Object $Schema -Name 'additionalProperties' if ($null -ne $additional -and -not ($additional -is [bool])) { Assert-SchemaDialect -Schema $additional -Label "$Label.additionalProperties" } $unevaluated = Get-PropertyValue -Object $Schema -Name 'unevaluatedProperties' if ($null -ne $unevaluated -and -not ($unevaluated -is [bool])) { Assert-SchemaDialect -Schema $unevaluated -Label "$Label.unevaluatedProperties" } foreach ($mapKeyword in @('patternProperties', 'dependentSchemas', '$defs', 'definitions')) { $map = Get-PropertyValue -Object $Schema -Name $mapKeyword if ($null -ne $map) { foreach ($property in $map.PSObject.Properties) { Assert-SchemaDialect -Schema $property.Value -Label "$Label.$mapKeyword.$($property.Name)" } } } } function Assert-OpenApiValueDialect { param([object]$Value, [string]$Label) if ($null -eq $Value -or $Value -is [string] -or $Value -is [ValueType]) { return } if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [pscustomobject])) { $index = 0 foreach ($item in $Value) { Assert-OpenApiValueDialect -Value $item -Label "$Label[$index]" $index += 1 } return } foreach ($property in $Value.PSObject.Properties) { if ($property.Name -eq 'schema') { Assert-SchemaDialect -Schema $property.Value -Label "$Label.schema" } else { Assert-OpenApiValueDialect -Value $property.Value -Label "$Label.$($property.Name)" } } } # 门禁自检:可空类型、allOf 字段冲突、伪引用和遗漏的组件类别都不能绕过合同。 if ($isOpenApi30) { $nullableStringProbe = '{"type":"string","nullable":true}' | ConvertFrom-Json $nullOnlyProbe = '{"type":"string","nullable":true,"enum":[null]}' | ConvertFrom-Json } else { $nullableStringProbe = '{"type":["string","null"]}' | ConvertFrom-Json $nullOnlyProbe = '{"type":"null"}' | ConvertFrom-Json } Assert-Contract -Condition (-not (Test-IsNullOnlySchema -Schema $nullableStringProbe)) -Message 'Contract self-check failed: nullable string was treated as null-only' Assert-Contract -Condition (Test-IsNullOnlySchema -Schema $nullOnlyProbe) -Message 'Contract self-check failed: null-only schema was rejected' $nullableIntegerProbe = if ($isOpenApi30) { '{"type":"integer","nullable":true}' } else { '{"type":["integer","null"]}' } $nullableArrayProbe = if ($isOpenApi30) { '{"type":"array","nullable":true,"items":{"type":"string"}}' } else { '{"type":["array","null"],"items":{"type":"string"}}' } $nullableObjectProbe = if ($isOpenApi30) { '{"type":"object","nullable":true,"additionalProperties":false,"properties":{}}' } else { '{"type":["object","null"],"additionalProperties":false,"properties":{}}' } $nullableEnumProbe = if ($isOpenApi30) { '{"type":"string","nullable":true,"enum":["A"]}' } else { '{"type":["string","null"],"enum":["A",null]}' } foreach ($probe in @( @{ Label = 'integer'; Run = { Assert-IntegerSchema -Schema ($nullableIntegerProbe | ConvertFrom-Json) -Label 'probe' } }, @{ Label = 'array'; Run = { [void](Assert-ArraySchema -Schema ($nullableArrayProbe | ConvertFrom-Json) -Label 'probe') } }, @{ Label = 'object'; Run = { Assert-ExactObjectSchema -Schema ($nullableObjectProbe | ConvertFrom-Json) -Fields @() -Required @() -Label 'probe' } }, @{ Label = 'enum'; Run = { Assert-ExactEnum -Schema ($nullableEnumProbe | ConvertFrom-Json) -Values @('A') -Label 'probe' } } )) { $rejected = $false try { & $probe.Run } catch { $rejected = $true } Assert-Contract -Condition $rejected -Message "Contract self-check failed: nullable $($probe.Label) was accepted" } $duplicatePropertyProbe = '{"allOf":[{"type":"object","properties":{"value":{"type":"integer"}}},{"type":"object","properties":{"value":{"type":"string"}}}]}' | ConvertFrom-Json $duplicateRejected = $false try { [void](Get-SchemaProperty -Schema $duplicatePropertyProbe -Name 'value') } catch { $duplicateRejected = $true } Assert-Contract -Condition $duplicateRejected -Message 'Contract self-check failed: conflicting allOf property was accepted' $refSiblingRejected = $false try { Assert-StringSchema -Schema ('{"$ref":"#/components/schemas/NoSuchProbe","type":"null"}' | ConvertFrom-Json) -Label 'probe' } catch { $refSiblingRejected = $true } Assert-Contract -Condition $refSiblingRejected -Message 'Contract self-check failed: schema reference validation sibling was accepted' $scalarCombinationRejected = $false try { Assert-IntegerSchema -Schema ('{"type":"integer","allOf":[{"type":"string"}]}' | ConvertFrom-Json) -Label 'probe' } catch { $scalarCombinationRejected = $true } Assert-Contract -Condition $scalarCombinationRejected -Message 'Contract self-check failed: contradictory scalar allOf was accepted' $inlineDialectRejected = $false try { Assert-OpenApiValueDialect -Value ('{"parameters":[{"schema":{"type":"string","not":{"const":"forbidden"}}}]}' | ConvertFrom-Json) -Label 'probe' } catch { $inlineDialectRejected = $true } if ($isOpenApi30) { Assert-Contract -Condition $inlineDialectRejected -Message 'Contract self-check failed: illegal inline OpenAPI 3.0 const was accepted' } $unionOwnerRejected = $false try { Assert-PureOneOfOwner -Schema ('{"type":"string","oneOf":[{"type":"object"}]}' | ConvertFrom-Json) -Label 'probe' } catch { $unionOwnerRejected = $true } Assert-Contract -Condition $unionOwnerRejected -Message 'Contract self-check failed: conflicting oneOf owner validation was accepted' $refProbe = '{"description":"#/components/schemas/Fake","schema":{"$ref":"#/components/schemas/Real"},"header":{"$ref":"#/components/headers/TraceId"}}' | ConvertFrom-Json Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $refProbe) -Expected @('headers/TraceId', 'schemas/Real') -Label 'structured component ref self-check' # 第一阶段只聚合前置缺口,避免在后端尚未发布 v2 时产生级联空引用错误。 $jsonMissingOperations = @() $yamlMissingOperations = @() foreach ($target in $operations) { $pathProperty = $document.paths.PSObject.Properties[$target.Path] if ($null -eq $pathProperty -or $null -eq $pathProperty.Value.PSObject.Properties[$target.Method]) { $jsonMissingOperations += "$($target.Method.ToUpperInvariant()) $($target.Path)" } if ($null -eq (Get-YamlOperationBlock -Path $target.Path -Method $target.Method)) { $yamlMissingOperations += "$($target.Method.ToUpperInvariant()) $($target.Path)" } } $yamlSchemaNames = @(Get-YamlComponentNames -Section 'schemas') $jsonMissingSchemas = @($rootSchemaNames | Where-Object { $null -eq $document.components.schemas.PSObject.Properties[$_] }) $yamlMissingSchemas = @($rootSchemaNames | Where-Object { $_ -notin $yamlSchemaNames }) if ($jsonMissingOperations.Count -gt 0 -or $yamlMissingOperations.Count -gt 0 -or $jsonMissingSchemas.Count -gt 0 -or $yamlMissingSchemas.Count -gt 0) { $lines = @('LINEAGE-OPENAPI-CONTRACT BLOCKED') $lines += 'JSON missing operations:' $lines += @($jsonMissingOperations | Sort-Object | ForEach-Object { "- $_" }) $lines += 'YAML missing operations:' $lines += @($yamlMissingOperations | Sort-Object | ForEach-Object { "- $_" }) $lines += 'JSON missing schema owners:' $lines += @($jsonMissingSchemas | Sort-Object | ForEach-Object { "- $_" }) $lines += 'YAML missing schema owners:' $lines += @($yamlMissingSchemas | Sort-Object | ForEach-Object { "- $_" }) $lines += 'Protected-local legacy evidence:' $lines += '- GET /genealogy/app/genealogies/{genealogyId}/lineage/tree -> LineagePersonTreeResult -> RLineagePersonTreeList -> LineagePersonTreeView[]' throw ($lines -join [Environment]::NewLine) } $resolvedOperations = @{} $operationIds = @() $allOperationIds = @() foreach ($pathProperty in $document.paths.PSObject.Properties) { foreach ($method in @('get', 'put', 'post', 'delete', 'patch', 'options', 'head', 'trace')) { $methodProperty = $pathProperty.Value.PSObject.Properties[$method] if ($null -ne $methodProperty) { $candidateId = [string](Get-PropertyValue -Object $methodProperty.Value -Name 'operationId') if (-not [string]::IsNullOrWhiteSpace($candidateId)) { $allOperationIds += $candidateId } } } } foreach ($target in $operations) { $pathItem = $document.paths.PSObject.Properties[$target.Path].Value $operation = $pathItem.PSObject.Properties[$target.Method].Value $key = "$($target.Method.ToUpperInvariant()) $($target.Path)" $resolvedOperations[$key] = @{ PathItem = $pathItem; Operation = $operation } $operationId = [string](Get-PropertyValue -Object $operation -Name 'operationId') Assert-Contract -Condition (-not [string]::IsNullOrWhiteSpace($operationId)) -Message "$key operationId must be non-empty" Assert-Contract -Condition (@($allOperationIds | Where-Object { $_ -eq $operationId }).Count -eq 1) -Message "$key operationId collides with another operation: $operationId" $operationIds += $operationId Assert-OpenApiValueDialect -Value $operation -Label $key } Assert-Contract -Condition (@($operationIds | Sort-Object -Unique).Count -eq $operationIds.Count) -Message 'Lineage v2 operationIds must be unique' $treeKey = 'GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/tree' $overviewKey = 'GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/tree/overview' $locatorKey = 'GET /genealogy/app/v2/genealogies/{genealogyId}/lineage/persons/{personId}/locator' $patchKey = 'PATCH /genealogy/app/v2/genealogies/{genealogyId}/lineage/relationships/{relationshipId}' $tree = $resolvedOperations[$treeKey] $overview = $resolvedOperations[$overviewKey] $locator = $resolvedOperations[$locatorKey] $patch = $resolvedOperations[$patchKey] $treeParameters = @(Get-OperationParameters -PathItem $tree.PathItem -Operation $tree.Operation) Assert-ExactParameters -Parameters $treeParameters -Expected @( 'path:genealogyId', 'query:mode', 'query:focusPersonId', 'query:ancestorDepth', 'query:descendantDepth', 'query:boundaryId', 'query:cursor', 'query:limit', 'query:treeVersion' ) -Label 'tree parameters' Assert-PathIdParameter -Parameters $treeParameters -Name 'genealogyId' -Label 'tree genealogyId' $treeQueryNames = @($treeParameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'in') -eq 'query' } | ForEach-Object { Get-PropertyValue -Object $_ -Name 'name' }) $treeQueryExpected = @('mode', 'focusPersonId', 'ancestorDepth', 'descendantDepth', 'boundaryId', 'cursor', 'limit', 'treeVersion') Assert-ExactSet -Actual $treeQueryNames -Expected $treeQueryExpected -Label 'tree query parameters' $mode = Get-Parameter -Parameters $treeParameters -Name 'mode' -In 'query' Assert-Contract -Condition ((Get-PropertyValue -Object $mode -Name 'required') -eq $true) -Message 'tree mode must be required' Assert-ExactEnum -Schema (Get-PropertyValue -Object $mode -Name 'schema') -Values @('FOCUS', 'BOUNDARY') -Label 'tree mode' foreach ($name in @('focusPersonId', 'ancestorDepth', 'descendantDepth', 'boundaryId', 'cursor', 'limit', 'treeVersion')) { $parameter = Get-Parameter -Parameters $treeParameters -Name $name -In 'query' Assert-Contract -Condition ((Get-PropertyValue -Object $parameter -Name 'required') -ne $true) -Message "tree $name must not be globally required; mode-specific requirements need deployment tests" } Assert-StablePersonIdSchema -Schema (Get-PropertyValue -Object (Get-Parameter -Parameters $treeParameters -Name 'focusPersonId' -In 'query') -Name 'schema') -Label 'tree focusPersonId' foreach ($name in @('ancestorDepth', 'descendantDepth')) { $parameter = Get-Parameter -Parameters $treeParameters -Name $name -In 'query' Assert-IntegerSchema -Schema (Get-PropertyValue -Object $parameter -Name 'schema') -Label "tree $name" -Minimum 0 -Maximum 20 -Default 2 } foreach ($name in @('boundaryId', 'cursor', 'treeVersion')) { $parameter = Get-Parameter -Parameters $treeParameters -Name $name -In 'query' Assert-StringSchema -Schema (Get-PropertyValue -Object $parameter -Name 'schema') -Label "tree $name" } $limit = Get-Parameter -Parameters $treeParameters -Name 'limit' -In 'query' Assert-IntegerSchema -Schema (Get-PropertyValue -Object $limit -Name 'schema') -Label 'tree limit' -Minimum 1 -Maximum 500 -Default 200 Assert-ResponseStatusMatrix -Operation $tree.Operation -Label $treeKey -RequiresConflict $true Assert-ResponseBusinessCode -Operation $tree.Operation -Status '404' -Code 'LINEAGE_FOCUS_NOT_AVAILABLE' -Label $treeKey Assert-ResponseBusinessCode -Operation $tree.Operation -Status '409' -Code 'TREE_VERSION_CHANGED' -Label $treeKey Assert-ResponseBusinessCode -Operation $tree.Operation -Status '422' -Code 'LINEAGE_QUERY_INVALID' -Label $treeKey $overviewParameters = @(Get-OperationParameters -PathItem $overview.PathItem -Operation $overview.Operation) Assert-ExactParameters -Parameters $overviewParameters -Expected @('path:genealogyId', 'query:treeVersion') -Label 'overview parameters' Assert-PathIdParameter -Parameters $overviewParameters -Name 'genealogyId' -Label 'overview genealogyId' $overviewQueryNames = @($overviewParameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'in') -eq 'query' } | ForEach-Object { Get-PropertyValue -Object $_ -Name 'name' }) Assert-ExactSet -Actual $overviewQueryNames -Expected @('treeVersion') -Label 'overview query parameters' $overviewVersion = Get-Parameter -Parameters $overviewParameters -Name 'treeVersion' -In 'query' Assert-Contract -Condition ((Get-PropertyValue -Object $overviewVersion -Name 'required') -eq $true) -Message 'overview treeVersion must be required' Assert-StringSchema -Schema (Get-PropertyValue -Object $overviewVersion -Name 'schema') -Label 'overview treeVersion' Assert-ResponseStatusMatrix -Operation $overview.Operation -Label $overviewKey -RequiresConflict $true Assert-ResponseBusinessCode -Operation $overview.Operation -Status '409' -Code 'TREE_VERSION_CHANGED' -Label $overviewKey $locatorParameters = @(Get-OperationParameters -PathItem $locator.PathItem -Operation $locator.Operation) Assert-ExactParameters -Parameters $locatorParameters -Expected @('path:genealogyId', 'path:personId') -Label 'locator parameters' Assert-PathIdParameter -Parameters $locatorParameters -Name 'genealogyId' -Label 'locator genealogyId' Assert-PathIdParameter -Parameters $locatorParameters -Name 'personId' -Label 'locator personId' $locatorPersonParameter = Get-Parameter -Parameters $locatorParameters -Name 'personId' -In 'path' Assert-StablePersonIdSchema -Schema (Get-PropertyValue -Object $locatorPersonParameter -Name 'schema') -Label 'locator personId' $locatorQueryNames = @($locatorParameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'in') -eq 'query' } | ForEach-Object { Get-PropertyValue -Object $_ -Name 'name' }) Assert-ExactSet -Actual $locatorQueryNames -Expected @() -Label 'locator query parameters' Assert-ResponseStatusMatrix -Operation $locator.Operation -Label $locatorKey -RequiresConflict $false $patchParameters = @(Get-OperationParameters -PathItem $patch.PathItem -Operation $patch.Operation) Assert-ExactParameters -Parameters $patchParameters -Expected @('path:genealogyId', 'path:relationshipId', 'header:If-Match') -Label 'relationship patch parameters' Assert-PathIdParameter -Parameters $patchParameters -Name 'genealogyId' -Label 'relationship patch genealogyId' Assert-PathIdParameter -Parameters $patchParameters -Name 'relationshipId' -Label 'relationship patch relationshipId' $patchQueryNames = @($patchParameters | Where-Object { (Get-PropertyValue -Object $_ -Name 'in') -eq 'query' } | ForEach-Object { Get-PropertyValue -Object $_ -Name 'name' }) Assert-ExactSet -Actual $patchQueryNames -Expected @() -Label 'relationship patch query parameters' $ifMatch = Get-Parameter -Parameters $patchParameters -Name 'If-Match' -In 'header' Assert-Contract -Condition ((Get-PropertyValue -Object $ifMatch -Name 'required') -eq $true) -Message 'relationship patch If-Match must be required' Assert-StringSchema -Schema (Get-PropertyValue -Object $ifMatch -Name 'schema') -Label 'relationship patch If-Match' Assert-ResponseStatusMatrix -Operation $patch.Operation -Label $patchKey -RequiresConflict $true Assert-ResponseBusinessCode -Operation $patch.Operation -Status '409' -Code 'TREE_VERSION_CHANGED' -Label $patchKey Assert-ResponseBusinessCode -Operation $patch.Operation -Status '422' -Code 'RELATIONSHIP_PATCH_EMPTY' -Label $patchKey $graphRoot = Assert-ResponseDataRoot -Operation $tree.Operation -RootSchemaName 'LineageGraphWindow' -Label $treeKey $graphBranches = Get-DiscriminatedBranches -Schema $graphRoot -PropertyName 'state' -Values @('EMPTY', 'POPULATED') -Label 'LineageGraphWindow' $graphFields = @('version', 'state', 'genealogyId', 'nodes', 'familyUnits', 'edges', 'window') foreach ($state in @('EMPTY', 'POPULATED')) { Assert-ExactObjectSchema -Schema $graphBranches[$state] -Fields $graphFields -Required $graphFields -Label "LineageGraphWindow.$state" Assert-StringSchema -Schema (Get-SchemaProperty -Schema $graphBranches[$state] -Name 'genealogyId') -Label "LineageGraphWindow.$state.genealogyId" } $versionFields = @('schemaVersion', 'treeVersion', 'generatedAt') foreach ($state in @('EMPTY', 'POPULATED')) { $branchVersion = Get-SchemaProperty -Schema $graphBranches[$state] -Name 'version' Assert-ExactObjectSchema -Schema $branchVersion -Fields $versionFields -Required $versionFields -Label "LineageVersion.$state" Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $branchVersion -Name 'schemaVersion') -Label "LineageVersion.$state.schemaVersion") -eq '2.0') -Message "LineageVersion.$state.schemaVersion must be 2.0" Assert-StringSchema -Schema (Get-SchemaProperty -Schema $branchVersion -Name 'treeVersion') -Label "LineageVersion.$state.treeVersion" $branchGeneratedAt = Resolve-Schema -Schema (Get-SchemaProperty -Schema $branchVersion -Name 'generatedAt') Assert-StringSchema -Schema $branchGeneratedAt -Label "LineageVersion.$state.generatedAt" Assert-Contract -Condition ((Get-PropertyValue -Object $branchGeneratedAt -Name 'format') -eq 'date-time') -Message "LineageVersion.$state.generatedAt must use date-time format" } $emptyGraph = $graphBranches['EMPTY'] foreach ($name in @('nodes', 'familyUnits', 'edges')) { [void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyGraph -Name $name) -Label "LineageGraphWindow.EMPTY.$name" -MaximumItems 0) } $emptyWindow = Get-SchemaProperty -Schema $emptyGraph -Name 'window' $windowFields = @('focusPersonId', 'entryPersonIds', 'scope', 'generationRange', 'returnedNodeCount', 'boundaries') Assert-ExactObjectSchema -Schema $emptyWindow -Fields $windowFields -Required $windowFields -Label 'LineageWindow.EMPTY' Assert-Contract -Condition (Test-IsNullOnlySchema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'focusPersonId')) -Message 'EMPTY focusPersonId must be null-only' [void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'entryPersonIds') -Label 'EMPTY entryPersonIds' -MaximumItems 0) Assert-Contract -Condition (Test-IsNullOnlySchema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'generationRange')) -Message 'EMPTY generationRange must be null-only' $emptyCount = Resolve-Schema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'returnedNodeCount') Assert-IntegerSchema -Schema $emptyCount -Label 'EMPTY returnedNodeCount' -Minimum 0 -Maximum 0 [void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyWindow -Name 'boundaries') -Label 'EMPTY boundaries' -MaximumItems 0) $emptyScope = Get-SchemaProperty -Schema $emptyWindow -Name 'scope' Assert-ExactObjectSchema -Schema $emptyScope -Fields @('ancestorDepth', 'descendantDepth') -Required @('ancestorDepth', 'descendantDepth') -Label 'LineageWindow.EMPTY.scope' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $emptyScope -Name 'ancestorDepth') -Label 'EMPTY scope.ancestorDepth' -Minimum 0 -Maximum 20 Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $emptyScope -Name 'descendantDepth') -Label 'EMPTY scope.descendantDepth' -Minimum 0 -Maximum 20 $populatedGraph = $graphBranches['POPULATED'] $nodeSchema = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'nodes') -Label 'POPULATED nodes' -MinimumItems 1 [void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'familyUnits') -Label 'POPULATED familyUnits') [void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'edges') -Label 'POPULATED edges') $populatedWindow = Get-SchemaProperty -Schema $populatedGraph -Name 'window' Assert-ExactObjectSchema -Schema $populatedWindow -Fields $windowFields -Required $windowFields -Label 'LineageWindow.POPULATED' Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'focusPersonId') -Label 'POPULATED focusPersonId' $entryId = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'entryPersonIds') -Label 'POPULATED entryPersonIds' -MinimumItems 1 Assert-StringSchema -Schema $entryId -Label 'POPULATED entryPersonIds item' Assert-GenerationRangeSchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'generationRange') -Label 'POPULATED generationRange' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'returnedNodeCount') -Label 'POPULATED returnedNodeCount' -Minimum 1 $scope = Get-SchemaProperty -Schema $populatedWindow -Name 'scope' Assert-ExactObjectSchema -Schema $scope -Fields @('ancestorDepth', 'descendantDepth') -Required @('ancestorDepth', 'descendantDepth') -Label 'LineageWindow.scope' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $scope -Name 'ancestorDepth') -Label 'scope.ancestorDepth' -Minimum 0 -Maximum 20 Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $scope -Name 'descendantDepth') -Label 'scope.descendantDepth' -Minimum 0 -Maximum 20 $nodeBranches = Get-DiscriminatedBranches -Schema $nodeSchema -PropertyName 'visibility' -Values @('VISIBLE', 'REDACTED') -Label 'LineagePerson' $visibleFields = @('id', 'generation', 'displayName', 'sex', 'avatarOssId', 'branchId', 'branchPath', 'order', 'visibility', 'entryReason') $redactedFields = @('id', 'generation', 'displayName', 'order', 'visibility', 'entryReason') Assert-ExactObjectSchema -Schema $nodeBranches['VISIBLE'] -Fields $visibleFields -Required $visibleFields -Label 'VisibleLineagePerson' Assert-ExactObjectSchema -Schema $nodeBranches['REDACTED'] -Fields $redactedFields -Required $redactedFields -Label 'RedactedLineagePerson' Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'id') -Label 'VisibleLineagePerson.id' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'displayName') -Label 'VisibleLineagePerson.displayName' $redactedId = Resolve-Schema -Schema (Get-SchemaProperty -Schema $nodeBranches['REDACTED'] -Name 'id') Assert-StringSchema -Schema $redactedId -Label 'RedactedLineagePerson.id' $redactedPattern = [string](Get-PropertyValue -Object $redactedId -Name 'pattern') try { $redactedRegex = [regex]::new($redactedPattern) } catch { throw "RedactedLineagePerson.id has an invalid pattern: $redactedPattern" } foreach ($accepted in @('redacted:t-1:17', 'redacted:t-20260722-1:opaque-token')) { $match = $redactedRegex.Match($accepted) Assert-Contract -Condition ($match.Success -and $match.Index -eq 0 -and $match.Length -eq $accepted.Length) -Message "RedactedLineagePerson.id rejects opaque ID: $accepted" } foreach ($rejected in @('p103', 'xredacted:t-1:17', 'redacted:t-1:17:extra', 'redacted::17', 'redacted:t-1:')) { Assert-Contract -Condition (-not $redactedRegex.IsMatch($rejected)) -Message "RedactedLineagePerson.id accepts invalid opaque ID: $rejected" } foreach ($branchName in @('VISIBLE', 'REDACTED')) { $branch = $nodeBranches[$branchName] Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'generation') -Label "$branchName generation" -Minimum 1 Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'order') -Label "$branchName order" -Minimum 0 $entryReason = Get-SchemaProperty -Schema $branch -Name 'entryReason' Assert-Contract -Condition (Test-IsNullableSchema -Schema $entryReason) -Message "$branchName entryReason must be nullable" Assert-ExactEnum -Schema (Get-NonNullSchema -Schema $entryReason) -Values @('GENEALOGY_ROOT', 'WINDOW_CUT', 'DISCONNECTED_COMPONENT') -Label "$branchName entryReason" -AllowNullable $true } Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'sex') -Values @('MALE', 'FEMALE', 'UNKNOWN') -Label 'VisibleLineagePerson.sex' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'avatarOssId') -Label 'VisibleLineagePerson.avatarOssId' -Nullable $true Assert-StringSchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'branchId') -Label 'VisibleLineagePerson.branchId' $branchPathItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $nodeBranches['VISIBLE'] -Name 'branchPath') -Label 'VisibleLineagePerson.branchPath' -MinimumItems 1 Assert-StringSchema -Schema $branchPathItem -Label 'VisibleLineagePerson.branchPath item' $redactedName = Get-SchemaProperty -Schema $nodeBranches['REDACTED'] -Name 'displayName' Assert-Contract -Condition ((Get-SingleLiteral -Schema $redactedName -Label 'RedactedLineagePerson.displayName') -eq '隐私成员') -Message 'RedactedLineagePerson.displayName must be 隐私成员' $familyItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'familyUnits') -Label 'familyUnits' $familyFields = @('id', 'anchorPersonId', 'partnerRelationship', 'partners', 'order') Assert-ExactObjectSchema -Schema $familyItem -Fields $familyFields -Required $familyFields -Label 'FamilyUnit' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $familyItem -Name 'id') -Label 'FamilyUnit.id' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $familyItem -Name 'anchorPersonId') -Label 'FamilyUnit.anchorPersonId' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $familyItem -Name 'order') -Label 'FamilyUnit.order' -Minimum 0 $partnerRelationshipProperty = Get-SchemaProperty -Schema $familyItem -Name 'partnerRelationship' Assert-Contract -Condition (Test-IsNullableSchema -Schema $partnerRelationshipProperty) -Message 'FamilyUnit.partnerRelationship must allow null for single-parent families' $partnerRelationship = Get-NonNullSchema -Schema $partnerRelationshipProperty $partnerRelationFields = @('relationshipId', 'relationshipKind', 'relationType', 'status') Assert-ExactObjectSchema -Schema $partnerRelationship -Fields $partnerRelationFields -Required $partnerRelationFields -Label 'PartnerRelationship' -AllowNullable $true Assert-StringSchema -Schema (Get-SchemaProperty -Schema $partnerRelationship -Name 'relationshipId') -Label 'PartnerRelationship.relationshipId' Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $partnerRelationship -Name 'relationshipKind') -Label 'PartnerRelationship.relationshipKind') -eq 'PARTNER') -Message 'PartnerRelationship.relationshipKind must be PARTNER' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $partnerRelationship -Name 'relationType') -Values @('MARRIAGE', 'PARTNERSHIP', 'UNKNOWN') -Label 'PartnerRelationship.relationType' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $partnerRelationship -Name 'status') -Values @('ACTIVE', 'ENDED', 'UNKNOWN') -Label 'PartnerRelationship.status' $partnerItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $familyItem -Name 'partners') -Label 'FamilyUnit.partners' -MinimumItems 1 -MaximumItems 2 $partnerFields = @('personId', 'partnerRole', 'order') Assert-ExactObjectSchema -Schema $partnerItem -Fields $partnerFields -Required $partnerFields -Label 'FamilyUnitPartner' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $partnerItem -Name 'personId') -Label 'FamilyUnitPartner.personId' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $partnerItem -Name 'partnerRole') -Values @('ANCHOR', 'PARTNER') -Label 'FamilyUnitPartner.partnerRole' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $partnerItem -Name 'order') -Label 'FamilyUnitPartner.order' -Minimum 0 $edgeItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedGraph -Name 'edges') -Label 'edges' $edgeFields = @('id', 'familyUnitId', 'childId', 'lineageParentId', 'parentRelations', 'primary', 'order') Assert-ExactObjectSchema -Schema $edgeItem -Fields $edgeFields -Required $edgeFields -Label 'ParentChildEdge' foreach ($idField in @('id', 'familyUnitId', 'childId', 'lineageParentId')) { Assert-StringSchema -Schema (Get-SchemaProperty -Schema $edgeItem -Name $idField) -Label "ParentChildEdge.$idField" } Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $edgeItem -Name 'order') -Label 'ParentChildEdge.order' -Minimum 0 Assert-ExactNonNullType -Schema (Get-SchemaProperty -Schema $edgeItem -Name 'primary') -Type 'boolean' -Label 'ParentChildEdge.primary' $parentRelationItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $edgeItem -Name 'parentRelations') -Label 'ParentChildEdge.parentRelations' -MinimumItems 1 $parentRelationFields = @('relationshipId', 'relationshipKind', 'personId', 'parentRole', 'relationType') Assert-ExactObjectSchema -Schema $parentRelationItem -Fields $parentRelationFields -Required $parentRelationFields -Label 'ParentRelation' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'relationshipId') -Label 'ParentRelation.relationshipId' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'personId') -Label 'ParentRelation.personId' Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'relationshipKind') -Label 'ParentRelation.relationshipKind') -eq 'PARENT_CHILD') -Message 'ParentRelation.relationshipKind must be PARENT_CHILD' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'parentRole') -Values @('FATHER', 'MOTHER', 'PARENT', 'GUARDIAN', 'UNKNOWN') -Label 'ParentRelation.parentRole' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $parentRelationItem -Name 'relationType') -Values @('BIOLOGICAL', 'ADOPTIVE', 'STEP', 'GUARDIAN', 'UNKNOWN') -Label 'ParentRelation.relationType' $boundaryItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedWindow -Name 'boundaries') -Label 'LineageWindow.boundaries' $boundaryFields = @('id', 'anchorType', 'anchorId', 'direction', 'reason', 'hiddenCount', 'cursor') Assert-ExactObjectSchema -Schema $boundaryItem -Fields $boundaryFields -Required $boundaryFields -Label 'LineageBoundary' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'id') -Label 'LineageBoundary.id' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'anchorType') -Values @('PERSON', 'FAMILY_UNIT', 'WINDOW') -Label 'LineageBoundary.anchorType' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'direction') -Values @('ANCESTORS', 'DESCENDANTS', 'LATERAL') -Label 'LineageBoundary.direction' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'reason') -Values @('MISSING', 'REDACTED', 'UNLOADED') -Label 'LineageBoundary.reason' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'anchorId') -Label 'LineageBoundary.anchorId' -Nullable $true Assert-Contract -Condition (Test-IsNullableSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'hiddenCount')) -Message 'LineageBoundary.hiddenCount must allow null' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'hiddenCount') -Label 'LineageBoundary.hiddenCount' -Minimum 0 -AllowNullable $true Assert-StringSchema -Schema (Get-SchemaProperty -Schema $boundaryItem -Name 'cursor') -Label 'LineageBoundary.cursor' -Nullable $true $overviewRoot = Assert-ResponseDataRoot -Operation $overview.Operation -RootSchemaName 'LineageOverview' -Label $overviewKey $overviewBranches = Get-DiscriminatedBranches -Schema $overviewRoot -PropertyName 'state' -Values @('EMPTY', 'POPULATED') -Label 'LineageOverview' $overviewFields = @('schemaVersion', 'treeVersion', 'generatedAt', 'state', 'genealogyId', 'genealogyPersonCount', 'genealogyRootPersonIds', 'redactedGenealogyRootCount', 'generationRange', 'buckets') foreach ($state in @('EMPTY', 'POPULATED')) { $branch = $overviewBranches[$state] Assert-ExactObjectSchema -Schema $branch -Fields $overviewFields -Required $overviewFields -Label "LineageOverview.$state" Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $branch -Name 'schemaVersion') -Label "LineageOverview.$state.schemaVersion") -eq '2.0') -Message "LineageOverview.$state.schemaVersion must be 2.0" Assert-StringSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'treeVersion') -Label "LineageOverview.$state.treeVersion" Assert-StringSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'genealogyId') -Label "LineageOverview.$state.genealogyId" $overviewGeneratedAt = Resolve-Schema -Schema (Get-SchemaProperty -Schema $branch -Name 'generatedAt') Assert-StringSchema -Schema $overviewGeneratedAt -Label "LineageOverview.$state.generatedAt" Assert-Contract -Condition ((Get-PropertyValue -Object $overviewGeneratedAt -Name 'format') -eq 'date-time') -Message "LineageOverview.$state.generatedAt must use date-time format" } $emptyOverview = $overviewBranches['EMPTY'] Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'genealogyPersonCount') -Label 'EMPTY genealogyPersonCount' -Minimum 0 -Maximum 0 [void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'genealogyRootPersonIds') -Label 'EMPTY genealogyRootPersonIds' -MaximumItems 0) Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'redactedGenealogyRootCount') -Label 'EMPTY redactedGenealogyRootCount' -Minimum 0 -Maximum 0 Assert-Contract -Condition (Test-IsNullOnlySchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'generationRange')) -Message 'EMPTY overview generationRange must be null-only' [void](Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $emptyOverview -Name 'buckets') -Label 'EMPTY overview buckets' -MaximumItems 0) $populatedOverview = $overviewBranches['POPULATED'] Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'genealogyPersonCount') -Label 'POPULATED genealogyPersonCount' -Minimum 1 $rootId = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'genealogyRootPersonIds') -Label 'POPULATED genealogyRootPersonIds' Assert-StablePersonIdSchema -Schema $rootId -Label 'genealogyRootPersonIds item' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'redactedGenealogyRootCount') -Label 'POPULATED redactedGenealogyRootCount' -Minimum 0 Assert-GenerationRangeSchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'generationRange') -Label 'POPULATED overview generationRange' $bucket = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $populatedOverview -Name 'buckets') -Label 'POPULATED overview buckets' -MinimumItems 1 $bucketFields = @('id', 'branchId', 'generation', 'visibleCount', 'redactedCount', 'unloadedCount', 'focusPersonId') Assert-ExactObjectSchema -Schema $bucket -Fields $bucketFields -Required $bucketFields -Label 'LineageOverviewBucket' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $bucket -Name 'id') -Label 'LineageOverviewBucket.id' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $bucket -Name 'branchId') -Label 'LineageOverviewBucket.branchId' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $bucket -Name 'generation') -Label 'LineageOverviewBucket.generation' -Minimum 1 foreach ($name in @('visibleCount', 'redactedCount', 'unloadedCount')) { Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $bucket -Name $name) -Label "LineageOverviewBucket.$name" -Minimum 0 } Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $bucket -Name 'focusPersonId') -Label 'LineageOverviewBucket.focusPersonId' -Nullable $true $locatorRoot = Assert-ResponseDataRoot -Operation $locator.Operation -RootSchemaName 'LineageLocator' -Label $locatorKey $locatorBranches = Get-DiscriminatedBranches -Schema $locatorRoot -PropertyName 'rootVisibility' -Values @('VISIBLE', 'REDACTED') -Label 'LineageLocator' $locatorFields = @('treeVersion', 'genealogyId', 'personId', 'rootVisibility', 'rootPersonId', 'pathCompleteness', 'ancestorPathSegments', 'generation', 'branchId') foreach ($visibility in @('VISIBLE', 'REDACTED')) { $branch = $locatorBranches[$visibility] Assert-ExactObjectSchema -Schema $branch -Fields $locatorFields -Required $locatorFields -Label "LineageLocator.$visibility" foreach ($name in @('treeVersion', 'genealogyId', 'branchId')) { Assert-StringSchema -Schema (Get-SchemaProperty -Schema $branch -Name $name) -Label "LineageLocator.$visibility.$name" } Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'personId') -Label "LineageLocator.$visibility.personId" Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $branch -Name 'generation') -Label "LineageLocator.$visibility.generation" -Minimum 1 } Assert-StablePersonIdSchema -Schema (Get-SchemaProperty -Schema $locatorBranches['VISIBLE'] -Name 'rootPersonId') -Label 'LineageLocator.VISIBLE.rootPersonId' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $locatorBranches['VISIBLE'] -Name 'pathCompleteness') -Values @('COMPLETE', 'REDACTED_GAPS') -Label 'LineageLocator.VISIBLE.pathCompleteness' Assert-Contract -Condition (Test-IsNullOnlySchema -Schema (Get-SchemaProperty -Schema $locatorBranches['REDACTED'] -Name 'rootPersonId')) -Message 'LineageLocator.REDACTED.rootPersonId must be null-only' Assert-Contract -Condition ((Get-SingleLiteral -Schema (Get-SchemaProperty -Schema $locatorBranches['REDACTED'] -Name 'pathCompleteness') -Label 'LineageLocator.REDACTED.pathCompleteness') -eq 'REDACTED_GAPS') -Message 'REDACTED root cannot claim a COMPLETE path' $segmentUnion = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $locatorBranches['VISIBLE'] -Name 'ancestorPathSegments') -Label 'LineageLocator.ancestorPathSegments' -MinimumItems 1 $redactedSegmentUnion = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $locatorBranches['REDACTED'] -Name 'ancestorPathSegments') -Label 'LineageLocator.REDACTED.ancestorPathSegments' -MinimumItems 1 $segmentBranches = Get-DiscriminatedBranches -Schema $segmentUnion -PropertyName 'kind' -Values @('VISIBLE', 'REDACTED') -Label 'LineageLocatorSegment' Assert-ExactObjectSchema -Schema $segmentBranches['VISIBLE'] -Fields @('kind', 'personIds') -Required @('kind', 'personIds') -Label 'VisibleLocatorSegment' $segmentPersonId = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $segmentBranches['VISIBLE'] -Name 'personIds') -Label 'VisibleLocatorSegment.personIds' -MinimumItems 1 Assert-StablePersonIdSchema -Schema $segmentPersonId -Label 'VisibleLocatorSegment.personIds item' Assert-ExactObjectSchema -Schema $segmentBranches['REDACTED'] -Fields @('kind', 'hiddenCount') -Required @('kind', 'hiddenCount') -Label 'RedactedLocatorSegment' Assert-Contract -Condition (Test-IsNullableSchema -Schema (Get-SchemaProperty -Schema $segmentBranches['REDACTED'] -Name 'hiddenCount')) -Message 'RedactedLocatorSegment.hiddenCount must allow null' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $segmentBranches['REDACTED'] -Name 'hiddenCount') -Label 'RedactedLocatorSegment.hiddenCount' -Minimum 1 -AllowNullable $true $redactedLocatorSegmentBranches = Get-DiscriminatedBranches -Schema $redactedSegmentUnion -PropertyName 'kind' -Values @('VISIBLE', 'REDACTED') -Label 'LineageLocator.REDACTED segment' Assert-ExactObjectSchema -Schema $redactedLocatorSegmentBranches['VISIBLE'] -Fields @('kind', 'personIds') -Required @('kind', 'personIds') -Label 'REDACTED locator visible segment' $redactedLocatorPersonId = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $redactedLocatorSegmentBranches['VISIBLE'] -Name 'personIds') -Label 'REDACTED locator visible segment personIds' -MinimumItems 1 Assert-StablePersonIdSchema -Schema $redactedLocatorPersonId -Label 'REDACTED locator visible segment personIds item' Assert-ExactObjectSchema -Schema $redactedLocatorSegmentBranches['REDACTED'] -Fields @('kind', 'hiddenCount') -Required @('kind', 'hiddenCount') -Label 'REDACTED locator redacted segment' Assert-Contract -Condition (Test-IsNullableSchema -Schema (Get-SchemaProperty -Schema $redactedLocatorSegmentBranches['REDACTED'] -Name 'hiddenCount')) -Message 'REDACTED locator hiddenCount must allow null' Assert-IntegerSchema -Schema (Get-SchemaProperty -Schema $redactedLocatorSegmentBranches['REDACTED'] -Name 'hiddenCount') -Label 'REDACTED locator hiddenCount' -Minimum 1 -AllowNullable $true $requestBody = Get-PropertyValue -Object $patch.Operation -Name 'requestBody' Assert-Contract -Condition ($null -ne $requestBody -and (Get-PropertyValue -Object $requestBody -Name 'required') -eq $true) -Message 'relationship patch requestBody must be required' $requestContent = Get-PropertyValue -Object $requestBody -Name 'content' $requestJson = if ($null -eq $requestContent) { $null } else { $requestContent.PSObject.Properties['application/json'] } Assert-Contract -Condition ($null -ne $requestJson) -Message 'relationship patch must consume application/json' $patchRequestSchema = Get-PropertyValue -Object $requestJson.Value -Name 'schema' $patchBranches = Get-DiscriminatedBranches -Schema $patchRequestSchema -PropertyName 'relationshipKind' -Values @('PARTNER', 'PARENT_CHILD') -Label 'RelationshipPatchBody' $patchDefinitions = @{ PARTNER = @{ Fields = @('relationshipKind', 'relationType', 'status'); Mutable = @('relationType', 'status'); RelationTypes = @('MARRIAGE', 'PARTNERSHIP', 'UNKNOWN') } PARENT_CHILD = @{ Fields = @('relationshipKind', 'relationType', 'parentRole'); Mutable = @('relationType', 'parentRole'); RelationTypes = @('BIOLOGICAL', 'ADOPTIVE', 'STEP', 'GUARDIAN', 'UNKNOWN') } } foreach ($kind in @('PARTNER', 'PARENT_CHILD')) { $branch = $patchBranches[$kind] Assert-ExactObjectSchema -Schema $branch -Fields $patchDefinitions[$kind].Fields -Required @('relationshipKind') -Label "RelationshipPatchBody.$kind" $resolvedBranch = Resolve-Schema -Schema $branch $hasMinProperties = (Get-PropertyValue -Object $resolvedBranch -Name 'minProperties') -eq 2 $anyOf = @(Get-PropertyValue -Object $resolvedBranch -Name 'anyOf') $anyOfRequired = @($anyOf | ForEach-Object { @(Get-PropertyValue -Object $_ -Name 'required') } | Sort-Object -Unique) $hasRequiredAlternatives = $anyOf.Count -eq $patchDefinitions[$kind].Mutable.Count -and (@($anyOf | Where-Object { @(Get-PropertyValue -Object $_ -Name 'required').Count -ne 1 }).Count -eq 0) -and (($anyOfRequired -join ',') -eq (@($patchDefinitions[$kind].Mutable | Sort-Object) -join ',')) Assert-Contract -Condition ($hasMinProperties -or $hasRequiredAlternatives) -Message "RelationshipPatchBody.$kind must reject a discriminator-only empty patch" Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $branch -Name 'relationType') -Values $patchDefinitions[$kind].RelationTypes -Label "RelationshipPatchBody.$kind.relationType" } Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $patchBranches['PARTNER'] -Name 'status') -Values @('ACTIVE', 'ENDED', 'UNKNOWN') -Label 'RelationshipPatchBody.PARTNER.status' Assert-ExactEnum -Schema (Get-SchemaProperty -Schema $patchBranches['PARENT_CHILD'] -Name 'parentRole') -Values @('FATHER', 'MOTHER', 'PARENT', 'GUARDIAN', 'UNKNOWN') -Label 'RelationshipPatchBody.PARENT_CHILD.parentRole' $mutationSchema = Get-ResponseSchema -Operation $patch.Operation -Status '200' $mutationWrapper = Resolve-Schema -Schema $mutationSchema Assert-ObjectTypedSchema -Schema $mutationWrapper -Label 'relationship patch response wrapper' $mutationData = Get-SchemaProperty -Schema $mutationWrapper -Name 'data' if ($null -eq $mutationData) { throw 'relationship patch HTTP 200 response wrapper must define required data' } Assert-Contract -Condition ('data' -in @(Get-SchemaRequiredNames -Schema $mutationWrapper)) -Message 'relationship patch HTTP 200 response data must be required' $mutationFields = @('treeVersion', 'affectedPersonIds', 'affectedFamilyUnitIds', 'affectedRelationshipIds') Assert-ExactObjectSchema -Schema $mutationData -Fields $mutationFields -Required $mutationFields -Label 'LineageMutationResult' Assert-StringSchema -Schema (Get-SchemaProperty -Schema $mutationData -Name 'treeVersion') -Label 'LineageMutationResult.treeVersion' foreach ($name in @('affectedPersonIds', 'affectedFamilyUnitIds', 'affectedRelationshipIds')) { $item = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $mutationData -Name $name) -Label "LineageMutationResult.$name" if ($name -eq 'affectedPersonIds') { Assert-StablePersonIdSchema -Schema $item -Label "LineageMutationResult.$name item" } else { Assert-StringSchema -Schema $item -Label "LineageMutationResult.$name item" } } # 新 v2 操作的全部 component 闭包必须存在、符合当前 OpenAPI 方言,并与 YAML 的直接引用逐组件一致。 $newComponentKeys = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($target in $operations) { $operation = $resolvedOperations["$($target.Method.ToUpperInvariant()) $($target.Path)"].Operation Add-ReachableComponentKeys -Value $operation -Keys $newComponentKeys } foreach ($name in $rootSchemaNames) { [void]$newComponentKeys.Add("schemas/$name") Add-ReachableComponentKeys -Value $document.components.schemas.PSObject.Properties[$name].Value -Keys $newComponentKeys } Add-ReachableComponentKeys -Value $patchRequestSchema -Keys $newComponentKeys Add-ReachableComponentKeys -Value $mutationData -Keys $newComponentKeys $newSchemaNames = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($key in $newComponentKeys) { $parts = $key -split '/', 2 if ($parts[0] -eq 'schemas') { [void]$newSchemaNames.Add($parts[1]) Assert-SchemaDialect -Schema $document.components.schemas.PSObject.Properties[$parts[1]].Value -Label $parts[1] } else { $componentSection = Get-PropertyValue -Object $document.components -Name $parts[0] Assert-OpenApiValueDialect -Value $componentSection.PSObject.Properties[$parts[1]].Value -Label $key } } # 旧 v1 树响应必须继续是递归数组,且不得以内联、改名或引用方式混入任何新图根结构。 $legacyPath = '/genealogy/app/genealogies/{genealogyId}/lineage/tree' $legacyPathProperty = $document.paths.PSObject.Properties[$legacyPath] Assert-Contract -Condition ($null -ne $legacyPathProperty -and $null -ne $legacyPathProperty.Value.PSObject.Properties['get']) -Message 'Legacy v1 lineage tree contract unexpectedly disappeared' $legacyOperation = $legacyPathProperty.Value.get $legacySchema = Get-ResponseSchema -Operation $legacyOperation -Status '200' $legacyWrapper = Resolve-Schema -Schema $legacySchema $legacyData = Get-SchemaProperty -Schema $legacyWrapper -Name 'data' Assert-Contract -Condition ($null -ne $legacyData) -Message 'Legacy v1 lineage response wrapper must retain data' $legacyItem = Assert-ArraySchema -Schema $legacyData -Label 'Legacy v1 lineage data' $legacyItemRef = [string](Get-PropertyValue -Object $legacyItem -Name '$ref') Assert-Contract -Condition (-not [string]::IsNullOrWhiteSpace($legacyItemRef)) -Message 'Legacy v1 lineage array item must use a stable recursive schema reference' $legacyItemFields = @(Get-SchemaPropertyNames -Schema $legacyItem) foreach ($recursiveField in @('children', 'spouses')) { Assert-Contract -Condition ($recursiveField -in $legacyItemFields) -Message "Legacy v1 lineage item lost recursive field: $recursiveField" $recursiveItem = Assert-ArraySchema -Schema (Get-SchemaProperty -Schema $legacyItem -Name $recursiveField) -Label "Legacy v1 lineage $recursiveField" Assert-Contract -Condition ([string](Get-PropertyValue -Object $recursiveItem -Name '$ref') -eq $legacyItemRef) -Message "Legacy v1 lineage $recursiveField items must recurse to $legacyItemRef" } $legacyNames = New-Object 'System.Collections.Generic.HashSet[string]' Add-ReachableSchemaNames -Value $legacySchema -Names $legacyNames $legacyLeak = @($legacyNames | Where-Object { $newSchemaNames.Contains($_) } | Sort-Object) Assert-Contract -Condition ($legacyLeak.Count -eq 0) -Message "Legacy v1 lineage tree reaches new schemas: $($legacyLeak -join ',')" $newRootSignatures = @( @('version', 'state', 'nodes', 'familyUnits', 'edges', 'window'), @('schemaVersion', 'treeVersion', 'genealogyPersonCount', 'buckets'), @('rootVisibility', 'pathCompleteness', 'ancestorPathSegments'), @('treeVersion', 'affectedPersonIds', 'affectedFamilyUnitIds', 'affectedRelationshipIds') ) foreach ($signature in $newRootSignatures) { $visitedLegacyRefs = New-Object 'System.Collections.Generic.HashSet[string]' if (Test-SchemaGraphContainsFields -Schema $legacySchema -Fields $signature -VisitedRefs $visitedLegacyRefs) { throw "Legacy v1 lineage response contains a copied v2 root signature: $($signature -join ',')" } } # YAML 不是用全文关键词冒充绿灯:在限定的 paths/components 块内对齐操作和完整 component 引用闭包。 foreach ($target in $operations) { $jsonOperation = $resolvedOperations["$($target.Method.ToUpperInvariant()) $($target.Path)"].Operation $yamlOperation = Get-YamlOperationBlock -Path $target.Path -Method $target.Method $yamlOperationId = [regex]::Match($yamlOperation, '(?m)^ operationId:\s*["'']?(?[^\s"'']+)["'']?\s*$') Assert-Contract -Condition ($yamlOperationId.Success -and $yamlOperationId.Groups['id'].Value -eq [string](Get-PropertyValue -Object $jsonOperation -Name 'operationId')) -Message "YAML operationId drifted: $($target.Method.ToUpperInvariant()) $($target.Path)" Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $yamlOperation) -Expected @(Get-ComponentRefSet -Value $jsonOperation) -Label "YAML operation refs $($target.Method.ToUpperInvariant()) $($target.Path)" } foreach ($key in $newComponentKeys) { $parts = $key -split '/', 2 $section = Get-PropertyValue -Object $document.components -Name $parts[0] $jsonComponent = $section.PSObject.Properties[$parts[1]] Assert-Contract -Condition ($null -ne $jsonComponent) -Message "JSON component missing from v2 closure: $key" $yamlBlock = Get-YamlComponentBlock -Section $parts[0] -Name $parts[1] Assert-Contract -Condition ($null -ne $yamlBlock) -Message "YAML component missing from v2 closure: $key" Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $yamlBlock) -Expected @(Get-ComponentRefSet -Value $jsonComponent.Value) -Label "YAML component refs $key" } $yamlLegacyOperation = Get-YamlOperationBlock -Path $legacyPath -Method 'get' Assert-Contract -Condition ($null -ne $yamlLegacyOperation) -Message 'YAML legacy v1 lineage GET disappeared' Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $yamlLegacyOperation) -Expected @(Get-ComponentRefSet -Value $legacyOperation) -Label 'YAML legacy v1 operation refs' $legacyComponentKeys = New-Object 'System.Collections.Generic.HashSet[string]' Add-ReachableComponentKeys -Value $legacyOperation -Keys $legacyComponentKeys foreach ($key in $legacyComponentKeys) { $parts = $key -split '/', 2 $section = Get-PropertyValue -Object $document.components -Name $parts[0] $jsonComponent = $section.PSObject.Properties[$parts[1]] $yamlBlock = Get-YamlComponentBlock -Section $parts[0] -Name $parts[1] Assert-Contract -Condition ($null -ne $yamlBlock) -Message "YAML component missing from legacy v1 closure: $key" Assert-ExactSet -Actual @(Get-ComponentRefSet -Value $yamlBlock) -Expected @(Get-ComponentRefSet -Value $jsonComponent.Value) -Label "YAML legacy component refs $key" } Write-Output 'LINEAGE-OPENAPI-CONTRACT PASS'