完成40%

This commit is contained in:
rain
2026-07-23 17:21:27 +08:00
parent f1edc6b533
commit bb6431b319
114 changed files with 10931 additions and 877 deletions
+874
View File
@@ -0,0 +1,874 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$jsonPath = Join-Path $root 'APP.openapi.json'
$document = Get-Content -Raw -Encoding UTF8 -LiteralPath $jsonPath | ConvertFrom-Json
$issues = New-Object System.Collections.Generic.List[string]
$settingsPath = '/genealogy/app/genealogies/{genealogyId}'
$overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview'
$identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$'
$genealogyNamePattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\n\u0000-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$'
$genealogyIntroPattern = '^[^\s\u0000-\u001F\u007F-\u009F](?:[^\r\u0000-\u0009\u000B-\u001F\u007F-\u009F\u2028\u2029]*[^\s\u0000-\u001F\u007F-\u009F])?$'
function Add-Issue {
param([string]$Message)
$script:issues.Add($Message)
}
function Test-IsJsonArray {
param([object]$Value)
return $null -ne $Value -and $Value.GetType().IsArray
}
function Test-IsJsonBoolean {
param([object]$Value, [bool]$Expected)
return $Value -is [System.Boolean] -and $Value -eq $Expected
}
function Test-IsNonNullable {
param([object]$Schema)
if (-not $Schema) { return $false }
$nullable = $Schema.PSObject.Properties['nullable']
return -not $nullable -or (Test-IsJsonBoolean $nullable.Value $false)
}
function Assert-NoConflictingSchemaKeywords {
param(
[object]$Schema,
[string]$Label,
[string[]]$Allowed = @()
)
if (-not $Schema) { return }
foreach ($keyword in @('not', 'allOf', 'anyOf', 'oneOf', 'const', 'enum')) {
if ($keyword -notin $Allowed -and $Schema.PSObject.Properties[$keyword]) {
Add-Issue "JSON $Label must not define conflicting schema keyword: $keyword"
}
}
}
function Assert-AllowedSchemaKeywords {
param([object]$Schema, [string]$Label, [string[]]$Allowed)
if (-not $Schema) { return }
$annotations = @('title', 'description', 'example', 'examples', 'deprecated')
foreach ($property in @($Schema.PSObject.Properties)) {
if ($property.Name -like 'x-*' -or $property.Name -in $annotations -or $property.Name -in $Allowed) { continue }
Add-Issue "JSON $Label contains an unowned schema keyword: $($property.Name)"
}
}
function Test-IsPureSchemaRef {
param([object]$Schema, [string]$ExpectedRef, [string]$Label)
if (-not $Schema) { return $false }
$properties = @($Schema.PSObject.Properties.Name)
$actualRef = [string]$Schema.'$ref'
if ($properties.Count -ne 1 -or $properties[0] -cne '$ref' -or $actualRef -cne $ExpectedRef) {
Add-Issue "JSON $Label must be the sole exact local schema ref $ExpectedRef; actual: $actualRef"
return $false
}
[void](Get-LocalComponentName $actualRef 'schemas' $Label)
return $true
}
function Get-LocalComponentName {
param([string]$Ref, [string]$Section, [string]$Label)
$pattern = '^#/components/' + [regex]::Escape($Section) + '/(?<name>[^/]+)$'
$match = [regex]::Match($Ref, $pattern)
if (-not $match.Success) {
Add-Issue "JSON $Label must use an exact local #/components/$Section/... ref; actual: $Ref"
return ''
}
return $match.Groups['name'].Value
}
function Get-Schema {
param([string]$Name)
$property = $document.components.schemas.PSObject.Properties[$Name]
if (-not $property) {
Add-Issue "JSON missing schema owner: $Name"
return $null
}
return $property.Value
}
function Get-Operation {
param([string]$Path, [string]$Method)
$pathProperty = $document.paths.PSObject.Properties[$Path]
if (-not $pathProperty) {
Add-Issue "JSON missing path: $Path"
return $null
}
$operationProperty = $pathProperty.Value.PSObject.Properties[$Method]
if (-not $operationProperty) {
Add-Issue "JSON missing operation: $($Method.ToUpper()) $Path"
return $null
}
return $operationProperty.Value
}
function Resolve-Parameter {
param([object]$Parameter, [string]$Label)
if (-not $Parameter) { return $null }
if (-not $Parameter.'$ref') { return $Parameter }
$parameterRefSiblings = @($Parameter.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($parameterRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label parameter ref contains semantic sibling keywords: $($parameterRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Parameter.'$ref') 'parameters' $Label
if (-not $name) { return $null }
$owner = $document.components.parameters.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing parameter owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Response {
param([object]$Response, [string]$Label)
if (-not $Response) { return $null }
if (-not $Response.'$ref') { return $Response }
$responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($responseRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label response ref contains semantic sibling keywords: $($responseRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Response.'$ref') 'responses' $Label
if (-not $name) { return $null }
$owner = $document.components.responses.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing response owner: $name"
return $null
}
return $owner.Value
}
function Resolve-Header {
param([object]$Header, [string]$Label)
if (-not $Header) { return $null }
if (-not $Header.'$ref') { return $Header }
$headerRefSiblings = @($Header.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') })
if ($headerRefSiblings.Count -gt 0) {
Add-Issue "JSON $Label header ref contains semantic sibling keywords: $($headerRefSiblings -join ',')"
}
$name = Get-LocalComponentName ([string]$Header.'$ref') 'headers' $Label
if (-not $name) { return $null }
$owner = $document.components.headers.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing header owner: $name"
return $null
}
return $owner.Value
}
function Get-Response {
param([object]$Operation, [string]$Label, [string]$Status)
if (-not $Operation) { return $null }
$property = $Operation.responses.PSObject.Properties[$Status]
if (-not $property) {
Add-Issue "JSON $Label missing response: $Status"
return $null
}
return Resolve-Response $property.Value "$Label $Status"
}
function Get-JsonResponseRef {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return '' }
$media = @($Response.content.PSObject.Properties)
if ($media.Count -ne 1 -or $media[0].Name -ne 'application/json') {
Add-Issue "JSON $Label $Status must expose only application/json"
return ''
}
$schema = $media[0].Value.schema
$ref = [string]$schema.'$ref'
if (-not $ref) {
Add-Issue "JSON $Label $Status must use a component schema ref"
} else {
[void](Get-LocalComponentName $ref 'schemas' "$Label $Status response schema")
$schemaKeywords = @($schema.PSObject.Properties.Name)
if ($schemaKeywords.Count -ne 1 -or $schemaKeywords[0] -cne '$ref') {
Add-Issue "JSON $Label $Status response schema must contain only its exact local schema ref"
}
}
return $ref
}
function Assert-ExactResponseSet {
param([object]$Operation, [string]$Label, [string[]]$Expected)
if (-not $Operation) { return }
$actual = @($Operation.responses.PSObject.Properties.Name | Sort-Object)
$wanted = @($Expected | Sort-Object)
if (($actual -join ',') -ne ($wanted -join ',')) {
Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')"
}
}
function Assert-PrivateNoStore {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Cache-Control'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label $Status must document Cache-Control: private, no-store"
return
}
$header = Resolve-Header $property.Value "$Label $Status Cache-Control"
if (-not $header) { return }
$values = @($header.schema.enum)
if ($header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema) -or
-not (Test-IsJsonArray $header.schema.enum) -or $values.Count -ne 1 -or
[string]$values[0] -cne 'private, no-store') {
Add-Issue "JSON $Label $Status Cache-Control must be fixed by a single enum value: private, no-store"
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('enum')
Assert-AllowedSchemaKeywords $header.schema "$Label $Status Cache-Control schema" @('type', 'enum', 'nullable')
}
function Assert-RequiredResponseHeaders {
param([object]$Response, [string]$Label, [string]$Status)
if (-not $Response) { return }
$headerNames = if ($Response.headers) { @($Response.headers.PSObject.Properties.Name) } else { @() }
if ('Cache-Control' -notin $headerNames) {
Add-Issue "JSON $Label $Status response headers must include Cache-Control"
}
if ($Status -eq '429' -and 'Retry-After' -notin $headerNames) {
Add-Issue "JSON $Label $Status response headers must include Retry-After"
}
if (@($headerNames | Where-Object { $_ -ieq 'ETag' }).Count -gt 0) {
Add-Issue "JSON $Label $Status must not publish ETag; settings concurrency has one If-Match/settingsVersion owner"
}
$allowedHeaders = @('Cache-Control', 'traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')
if ($Status -eq '429') { $allowedHeaders += 'Retry-After' }
$unexpectedHeaders = @($headerNames | Where-Object { $_ -notin $allowedHeaders })
if ($unexpectedHeaders.Count -gt 0) {
Add-Issue "JSON $Label $Status response headers may add only traceparent/tracestate/x-request-id/x-correlation-id tracing headers; unexpected: $($unexpectedHeaders -join ',')"
}
foreach ($traceName in @('traceparent', 'tracestate', 'x-request-id', 'x-correlation-id')) {
$property = if ($Response.headers) { $Response.headers.PSObject.Properties[$traceName] } else { $null }
if (-not $property) { continue }
$header = Resolve-Header $property.Value "$Label $Status $traceName"
if (-not $header) { continue }
foreach ($headerProperty in @($header.PSObject.Properties)) {
if ($headerProperty.Name -like 'x-*' -or $headerProperty.Name -in @('description', 'deprecated', 'schema')) { continue }
Add-Issue "JSON $Label $Status $traceName contains an unowned Header Object keyword: $($headerProperty.Name)"
}
if (-not $header.schema -or $header.schema.type -ne 'string' -or -not (Test-IsNonNullable $header.schema)) {
Add-Issue "JSON $Label $Status $traceName must resolve to a Header Object with a non-null string schema"
continue
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label $Status $traceName schema"
Assert-AllowedSchemaKeywords $header.schema "$Label $Status $traceName schema" @('type', 'nullable')
}
}
function Assert-RetryAfter {
param([object]$Response, [string]$Label)
if (-not $Response) { return }
$property = if ($Response.headers) { $Response.headers.PSObject.Properties['Retry-After'] } else { $null }
if (-not $property) {
Add-Issue "JSON $Label must document Retry-After"
return
}
$header = Resolve-Header $property.Value "$Label Retry-After"
if (-not $header) { return }
if ($header.schema.type -ne 'integer' -or -not (Test-IsNonNullable $header.schema) -or
[int]$header.schema.minimum -ne 1 -or
[int]$header.schema.maximum -lt 1 -or [int]$header.schema.maximum -gt 300) {
Add-Issue "JSON $Label Retry-After must be an integer in a bounded 1..300 second range"
}
Assert-NoConflictingSchemaKeywords $header.schema "$Label Retry-After schema"
Assert-AllowedSchemaKeywords $header.schema "$Label Retry-After schema" @('type', 'minimum', 'maximum', 'nullable')
}
function Assert-SaToken {
param([object]$Operation, [string]$Label)
if (-not $Operation) { return }
$requirements = @($Operation.security)
if ($requirements.Count -ne 1) {
Add-Issue "JSON $Label must have exactly one SaToken security requirement"
return
}
$names = @($requirements[0].PSObject.Properties.Name)
if ($names.Count -ne 1 -or $names[0] -ne 'SaToken') {
Add-Issue "JSON $Label must require only SaToken"
}
}
function Get-OperationParameters {
param([string]$Path, [object]$Operation, [string]$Label)
$parameters = @()
$pathProperty = $document.paths.PSObject.Properties[$Path]
if ($pathProperty -and $pathProperty.Value.parameters) {
foreach ($parameter in @($pathProperty.Value.parameters)) {
$resolved = Resolve-Parameter $parameter "$Label path parameter"
if ($resolved) { $parameters += $resolved }
}
}
if ($Operation -and $Operation.parameters) {
foreach ($parameter in @($Operation.parameters)) {
$resolved = Resolve-Parameter $parameter "$Label operation parameter"
if ($resolved) { $parameters += $resolved }
}
}
return $parameters
}
function Get-Parameter {
param([object[]]$Parameters, [string]$Name, [string]$In, [string]$Label)
$matches = @($Parameters | Where-Object { $_.name -eq $Name -and $_.in -eq $In })
if ($matches.Count -ne 1) {
Add-Issue "JSON $Label must declare exactly one $In parameter: $Name"
return $null
}
return $matches[0]
}
function Assert-ExactParameters {
param([object[]]$Parameters, [string]$Label, [string[]]$Expected)
$actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object)
$wanted = @($Expected | Sort-Object)
if (($actual -join ',') -ne ($wanted -join ',')) {
Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')"
}
}
function Assert-ExactObject {
param(
[object]$Schema,
[string]$Name,
[string[]]$Properties,
[string[]]$Required,
[int]$MinProperties = -1,
[int]$MaxProperties = -1
)
if (-not $Schema) { return }
$actualProperties = @($Schema.properties.PSObject.Properties.Name | Sort-Object)
$expectedProperties = @($Properties | Sort-Object)
$actualRequired = @($Schema.required | Sort-Object)
$expectedRequired = @($Required | Sort-Object)
if ($Schema.type -ne 'object' -or -not (Test-IsNonNullable $Schema) -or
-not (Test-IsJsonBoolean $Schema.additionalProperties $false) -or
($actualProperties -join ',') -ne ($expectedProperties -join ',') -or
($actualRequired -join ',') -ne ($expectedRequired -join ',')) {
Add-Issue "JSON $Name must be a closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]"
}
Assert-NoConflictingSchemaKeywords $Schema $Name
$allowedObjectKeywords = @('type', 'properties', 'required', 'additionalProperties', 'nullable')
if ($MinProperties -ge 0) { $allowedObjectKeywords += 'minProperties' }
if ($MaxProperties -ge 0) { $allowedObjectKeywords += 'maxProperties' }
Assert-AllowedSchemaKeywords $Schema $Name $allowedObjectKeywords
if ($MinProperties -ge 0 -and [int]$Schema.minProperties -ne $MinProperties) {
Add-Issue "JSON $Name.minProperties must be $MinProperties"
}
if ($MaxProperties -ge 0 -and [int]$Schema.maxProperties -ne $MaxProperties) {
Add-Issue "JSON $Name.maxProperties must be $MaxProperties"
}
}
function Assert-PropertyRef {
param([object]$Schema, [string]$SchemaName, [string]$Field, [string]$ExpectedRef)
if (-not $Schema) { return }
$property = $Schema.properties.PSObject.Properties[$Field]
if (-not $property) {
Add-Issue "JSON $SchemaName missing property: $Field"
return
}
[void](Test-IsPureSchemaRef $property.Value $ExpectedRef "$SchemaName.$Field")
}
function Assert-FixedError {
param(
[object]$Schema,
[string]$Name,
[int]$Status,
[string]$BusinessCode,
[switch]$Current
)
$properties = @('code', 'businessCode')
if ($Current) { $properties += 'current' }
Assert-ExactObject $Schema $Name $properties $properties
if (-not $Schema) { return }
if ($Schema.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $Schema.properties.code) -or
-not (Test-IsJsonArray $Schema.properties.code.enum) -or
@($Schema.properties.code.enum).Count -ne 1 -or
@($Schema.properties.code.enum)[0] -ne $Status -or
$Schema.properties.businessCode.type -ne 'string' -or
-not (Test-IsNonNullable $Schema.properties.businessCode) -or
-not (Test-IsJsonArray $Schema.properties.businessCode.enum) -or
@($Schema.properties.businessCode.enum).Count -ne 1 -or
@($Schema.properties.businessCode.enum)[0] -ne $BusinessCode) {
Add-Issue "JSON $Name must fix code=$Status and businessCode=$BusinessCode"
}
Assert-NoConflictingSchemaKeywords $Schema.properties.code "$Name.code" @('enum')
Assert-NoConflictingSchemaKeywords $Schema.properties.businessCode "$Name.businessCode" @('enum')
Assert-AllowedSchemaKeywords $Schema.properties.code "$Name.code" @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $Schema.properties.businessCode "$Name.businessCode" @('type', 'enum', 'nullable')
if ($Current) { [void](Test-IsPureSchemaRef $Schema.properties.current '#/components/schemas/AppGenealogyVo' "$Name.current") }
}
function Get-RequestBodySchemas {
param([object]$Operation, [string]$Label)
if (-not $Operation -or -not $Operation.requestBody) { return @() }
$requestBody = $Operation.requestBody
if ($requestBody.'$ref') {
$name = Get-LocalComponentName ([string]$requestBody.'$ref') 'requestBodies' "$Label request body"
if (-not $name) { return @() }
$owner = $document.components.requestBodies.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing request body owner: $name"
return @()
}
$requestBody = $owner.Value
}
if (-not $requestBody.content) { return @() }
return @($requestBody.content.PSObject.Properties | ForEach-Object { $_.Value.schema } | Where-Object { $_ })
}
function Test-SchemaContainsSettingsContract {
param(
[object]$Schema,
[hashtable]$Seen,
[string]$Label,
[bool]$AllowInlineIntro,
[bool]$AtRequestRoot = $true
)
if (-not $Schema) { return $false }
$ref = [string]$Schema.'$ref'
if ($ref) {
$leaf = @($ref -split '/')[-1]
if ($leaf -ceq 'AppGenealogySettingsUpdateBody') {
if ($ref -cne '#/components/schemas/AppGenealogySettingsUpdateBody') {
[void](Get-LocalComponentName $ref 'schemas' $Label)
}
return $true
}
$name = Get-LocalComponentName $ref 'schemas' $Label
if (-not $name) { return $false }
if ($Seen.ContainsKey($name)) { return $false }
$Seen[$name] = $true
$owner = $document.components.schemas.PSObject.Properties[$name]
if (-not $owner) {
Add-Issue "JSON $Label references missing schema owner: $name"
return $false
}
return Test-SchemaContainsSettingsContract $owner.Value $Seen "$Label -> $name" $AllowInlineIntro $AtRequestRoot
}
if ($Schema.properties) {
$sharedOwners = @{
genealogyName = '#/components/schemas/GenealogyName'
intro = '#/components/schemas/GenealogyIntro'
accessPreset = '#/components/schemas/GenealogyAccessPreset'
}
foreach ($field in $sharedOwners.Keys) {
$property = $Schema.properties.PSObject.Properties[$field]
if (-not $property) { continue }
$propertyKeywords = @($property.Value.PSObject.Properties.Name)
if ($propertyKeywords.Count -eq 1 -and $propertyKeywords[0] -ceq '$ref' -and
[string]$property.Value.'$ref' -ceq $sharedOwners[$field]) {
return $true
}
if ($AtRequestRoot -and ($field -ne 'intro' -or $AllowInlineIntro)) { return $true }
}
}
foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) {
foreach ($branch in @($Schema.$keyword)) {
if (Test-SchemaContainsSettingsContract $branch $Seen "$Label $keyword" $AllowInlineIntro $AtRequestRoot) { return $true }
}
}
if ($Schema.items -and (Test-SchemaContainsSettingsContract $Schema.items $Seen "$Label items" $AllowInlineIntro $false)) { return $true }
if ($Schema.properties) {
foreach ($property in @($Schema.properties.PSObject.Properties)) {
if (Test-SchemaContainsSettingsContract $property.Value $Seen "$Label.$($property.Name)" $AllowInlineIntro $false) { return $true }
}
}
return $false
}
function Assert-GlobalSettingsWriteOwner {
$methods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace')
$writeMethods = @('post', 'put', 'patch', 'delete')
$settingsOwners = New-Object System.Collections.Generic.List[string]
$operationIdOwners = New-Object System.Collections.Generic.List[string]
foreach ($pathProperty in @($document.paths.PSObject.Properties)) {
foreach ($methodProperty in @($pathProperty.Value.PSObject.Properties | Where-Object { $_.Name -in $methods })) {
$method = [string]$methodProperty.Name
$operation = $methodProperty.Value
$label = "$($method.ToUpperInvariant()) $($pathProperty.Name)"
if ([string]$operation.operationId -ceq 'appUpdateGenealogySettings') {
$operationIdOwners.Add($label)
}
if ($method -notin $writeMethods) { continue }
$isSettingsOwner = $method -eq 'put' -and $pathProperty.Name -ceq $settingsPath
$settingsSemanticPath = $pathProperty.Name -match '(?i)/genealogies(?:/\{[^}]+\})?/settings(?:[-_/]|$)'
if ($settingsSemanticPath -or
[string]$operation.operationId -ceq 'appUpdateGenealogySettings') {
$isSettingsOwner = $true
}
$isGenealogyCreate = $method -eq 'post' -and
$pathProperty.Name -ceq '/genealogy/app/genealogies'
if (-not $isGenealogyCreate) {
if ($operation.requestBody -and $operation.requestBody.'$ref') {
$requestBodyLeaf = @(([string]$operation.requestBody.'$ref') -split '/')[-1]
if ($requestBodyLeaf -ceq 'AppGenealogySettingsUpdateBody') {
$isSettingsOwner = $true
}
}
$allowInlineIntro = $settingsSemanticPath -or
$pathProperty.Name -match '^/genealogy/app/genealogies(?:/|$)'
foreach ($schema in @(Get-RequestBodySchemas $operation $label)) {
if (Test-SchemaContainsSettingsContract $schema @{} "$label request schema" $allowInlineIntro $true) {
$isSettingsOwner = $true
break
}
}
}
if ($isSettingsOwner) { $settingsOwners.Add("$method $($pathProperty.Name)") }
}
}
$uniqueSettingsOwners = @($settingsOwners | Sort-Object -Unique)
$expected = "put $settingsPath"
if ($uniqueSettingsOwners.Count -ne 1 -or $uniqueSettingsOwners[0] -cne $expected) {
Add-Issue "JSON settings write contract must have exactly one global owner ($expected); actual: $($uniqueSettingsOwners -join ',')"
}
if ($operationIdOwners.Count -ne 1 -or $operationIdOwners[0] -cne "PUT $settingsPath") {
Add-Issue "JSON operationId appUpdateGenealogySettings must be globally unique on PUT $settingsPath; actual: $($operationIdOwners -join ',')"
}
}
$parityScript = Join-Path $PSScriptRoot 'openapi-yaml-json-parity-runtime-smoke.js'
$parityOutput = @(& node $parityScript 2>&1)
if ($LASTEXITCODE -ne 0 -or 'OPENAPI-YAML-JSON-PARITY PASS' -notin $parityOutput) {
Add-Issue "protected JSON/YAML semantic parity failed: $($parityOutput -join ' | ')"
}
$workspaceScript = Join-Path $PSScriptRoot 'genealogy-workspace-openapi-contract.ps1'
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$workspaceOutput = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $workspaceScript 2>&1)
$workspaceExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
$workspaceLines = @($workspaceOutput | ForEach-Object { [string]$_ })
if ($workspaceExitCode -ne 0 -or 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS' -notin $workspaceLines) {
Add-Issue 'canonical /mine and /overview workspace prerequisite must pass its sole owner gate before G11 can pass'
}
Assert-GlobalSettingsWriteOwner
$settingsPathProperty = $document.paths.PSObject.Properties[$settingsPath]
$settingsPathItem = if ($settingsPathProperty) { $settingsPathProperty.Value } else { $null }
$settingsOperation = Get-Operation $settingsPath 'put'
if ($settingsPathItem -and $settingsPathItem.patch) {
Add-Issue "JSON $settingsPath must not publish a second PATCH settings owner"
}
if ($settingsOperation) {
if ($settingsOperation.operationId -ne 'appUpdateGenealogySettings') {
Add-Issue "JSON PUT $settingsPath operationId must be appUpdateGenealogySettings"
}
Assert-SaToken $settingsOperation "PUT $settingsPath"
$settingsParameters = @(Get-OperationParameters $settingsPath $settingsOperation "PUT $settingsPath")
Assert-ExactParameters $settingsParameters "PUT $settingsPath" @(
'path:genealogyId', 'header:clientid', 'header:If-Match'
)
$genealogyId = Get-Parameter $settingsParameters 'genealogyId' 'path' "PUT $settingsPath"
if ($genealogyId) {
if (-not (Test-IsJsonBoolean $genealogyId.required $true)) {
Add-Issue "JSON PUT $settingsPath genealogyId must be required"
}
[void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "PUT $settingsPath genealogyId")
}
$clientid = Get-Parameter $settingsParameters 'clientid' 'header' "PUT $settingsPath"
if ($clientid -and (-not (Test-IsJsonBoolean $clientid.required $true) -or
$clientid.schema.type -ne 'string' -or -not (Test-IsNonNullable $clientid.schema) -or
[int]$clientid.schema.minLength -ne 1 -or
[int]$clientid.schema.maxLength -ne 128)) {
Add-Issue "JSON PUT $settingsPath clientid must be a required non-null bounded non-empty string"
}
if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "PUT $settingsPath clientid" }
if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "PUT $settingsPath clientid" @('type', 'minLength', 'maxLength', 'nullable') }
$ifMatch = Get-Parameter $settingsParameters 'If-Match' 'header' "PUT $settingsPath"
if ($ifMatch) {
if (-not (Test-IsJsonBoolean $ifMatch.required $true)) {
Add-Issue "JSON PUT $settingsPath If-Match must be required"
}
[void](Test-IsPureSchemaRef $ifMatch.schema '#/components/schemas/GenealogySettingsVersion' "PUT $settingsPath If-Match")
}
$requestContent = if ($settingsOperation.requestBody) { @($settingsOperation.requestBody.content.PSObject.Properties) } else { @() }
if (-not $settingsOperation.requestBody -or -not (Test-IsJsonBoolean $settingsOperation.requestBody.required $true) -or
$requestContent.Count -ne 1 -or $requestContent[0].Name -ne 'application/json') {
Add-Issue "JSON PUT $settingsPath must require only application/json AppGenealogySettingsUpdateBody"
} elseif ($requestContent.Count -eq 1) {
[void](Test-IsPureSchemaRef $requestContent[0].Value.schema '#/components/schemas/AppGenealogySettingsUpdateBody' "PUT $settingsPath request schema")
}
if ([string]$settingsOperation.'x-update-semantics' -ne 'ATOMIC_DIRTY_ONLY_MERGE' -or
[string]$settingsOperation.'x-omitted-fields' -ne 'UNCHANGED' -or
[string]$settingsOperation.'x-version-precondition' -ne 'IF_MATCH_SETTINGS_VERSION_CAS' -or
[string]$settingsOperation.'x-canonical-noop-policy' -ne 'RETURN_200_KEEP_VERSION_NO_DOMAIN_SIDE_EFFECTS') {
Add-Issue "JSON PUT $settingsPath must machine-bind dirty-only merge, omission, version CAS, and canonical no-op semantics"
}
$precedence = @($settingsOperation.'x-conflict-precedence')
$expectedPrecedence = @(
'GENEALOGY_SETTINGS_VERSION_CHANGED',
'GENEALOGY_NOT_READY',
'ACTIVE_PENDING_APPLICATIONS'
)
if (-not (Test-IsJsonArray $settingsOperation.'x-conflict-precedence') -or
($precedence -join ',') -ne ($expectedPrecedence -join ',')) {
Add-Issue "JSON PUT $settingsPath must order version, READY, then active-pending conflicts"
}
if ([string]$settingsOperation.'x-active-pending-policy' -ne 'BLOCK_ONLY_PUBLIC_APPLY_TO_MEMBER_ONLY' -or
[string]$settingsOperation.'x-public-apply-coordination' -ne 'ATOMIC_SINGLE_WINNER' -or
-not (Test-IsJsonBoolean $settingsOperation.'x-authorization-revalidated-in-transaction' $true) -or
-not (Test-IsJsonBoolean $settingsOperation.'x-conflict-disclosure-requires-current-permission' $true)) {
Add-Issue "JSON PUT $settingsPath must bind permission revalidation and atomic single-winner PUBLIC_APPLY-to-MEMBER_ONLY pending protection"
}
}
$statuses = @('200', '400', '401', '403', '404', '409', '422', '429', '500')
Assert-ExactResponseSet $settingsOperation "PUT $settingsPath" $statuses
$responseRefs = @{
'200' = '#/components/schemas/RAppGenealogyVo'
'400' = '#/components/schemas/RGenealogySettingsBadRequest'
'401' = '#/components/schemas/RGenealogySettingsUnauthorized'
'403' = '#/components/schemas/RGenealogySettingsForbidden'
'404' = '#/components/schemas/RGenealogySettingsNotFound'
'409' = '#/components/schemas/RGenealogySettingsConflict'
'422' = '#/components/schemas/RGenealogySettingsValidationError'
'429' = '#/components/schemas/RGenealogySettingsRateLimited'
'500' = '#/components/schemas/RGenealogySettingsOutcomeUnknown'
}
$settingsResponses = @{}
foreach ($status in $statuses) {
$response = Get-Response $settingsOperation "PUT $settingsPath" $status
$settingsResponses[$status] = $response
$actualRef = Get-JsonResponseRef $response "PUT $settingsPath" $status
if ($actualRef -ne $responseRefs[$status]) {
Add-Issue "JSON PUT $settingsPath $status must return $($responseRefs[$status]); actual: $actualRef"
}
Assert-RequiredResponseHeaders $response "PUT $settingsPath" $status
Assert-PrivateNoStore $response "PUT $settingsPath" $status
}
Assert-RetryAfter $settingsResponses['429'] "PUT $settingsPath 429"
$nameOwner = Get-Schema 'GenealogyName'
if ($nameOwner) {
if ($nameOwner.type -ne 'string' -or [int]$nameOwner.minLength -ne 1 -or
[int]$nameOwner.maxLength -ne 24 -or [string]$nameOwner.pattern -cne $genealogyNamePattern -or
-not (Test-IsNonNullable $nameOwner) -or
[string]$nameOwner.'x-unicode-normalization' -ne 'NFC' -or
[string]$nameOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT') {
Add-Issue 'JSON GenealogyName must be non-null NFC, 1..24 Unicode code points, without boundary whitespace, line breaks, or control characters'
}
Assert-NoConflictingSchemaKeywords $nameOwner 'GenealogyName'
Assert-AllowedSchemaKeywords $nameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
}
$introOwner = Get-Schema 'GenealogyIntro'
if ($introOwner) {
$branches = @($introOwner.oneOf)
$clear = @($branches | Where-Object {
$_.type -eq 'string' -and (Test-IsNonNullable $_) -and (Test-IsJsonArray $_.enum) -and
@($_.enum).Count -eq 1 -and [string]$_.enum[0] -eq ''
})
$value = @($branches | Where-Object {
$_.type -eq 'string' -and (Test-IsNonNullable $_) -and [int]$_.minLength -eq 1
})
if (-not (Test-IsJsonArray $introOwner.oneOf) -or $branches.Count -ne 2 -or
$clear.Count -ne 1 -or $value.Count -ne 1 -or
-not (Test-IsNonNullable $introOwner) -or
[int]$value[0].maxLength -ne 80 -or [string]$value[0].pattern -cne $genealogyIntroPattern -or
[string]$introOwner.'x-unicode-normalization' -ne 'NFC' -or
[string]$introOwner.'x-length-unit' -ne 'UNICODE_CODE_POINT' -or
[string]$introOwner.'x-line-ending-normalization' -ne 'LF') {
Add-Issue 'JSON GenealogyIntro must be non-null, allow exact empty clear or boundary-trimmed NFC 1..80 code points, permit internal LF only, and reject other controls'
}
Assert-NoConflictingSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf')
Assert-AllowedSchemaKeywords $introOwner 'GenealogyIntro' @('oneOf', 'nullable')
foreach ($branch in $clear) {
Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro empty branch' @('enum')
Assert-AllowedSchemaKeywords $branch 'GenealogyIntro empty branch' @('type', 'enum', 'nullable')
}
foreach ($branch in $value) {
Assert-NoConflictingSchemaKeywords $branch 'GenealogyIntro value branch'
Assert-AllowedSchemaKeywords $branch 'GenealogyIntro value branch' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
}
}
$versionOwner = Get-Schema 'GenealogySettingsVersion'
if ($versionOwner) {
if ($versionOwner.type -ne 'string' -or [int]$versionOwner.minLength -ne 1 -or
[int]$versionOwner.maxLength -ne 128 -or [string]$versionOwner.pattern -ne $identifierPattern -or
-not (Test-IsNonNullable $versionOwner) -or
[string]$versionOwner.'x-semantics' -ne 'OPAQUE_SETTINGS_CAS_VERSION' -or
-not (Test-IsJsonArray $versionOwner.'x-version-scope-fields') -or
(@($versionOwner.'x-version-scope-fields') -join ',') -cne 'genealogyName,intro,accessPreset' -or
[string]$versionOwner.'x-version-change-policy' -ne 'CANONICAL_SETTINGS_CHANGE_ONLY') {
Add-Issue 'JSON GenealogySettingsVersion must be a non-null 1..128 URL-safe opaque CAS token that changes only when canonical genealogyName/intro/accessPreset changes'
}
Assert-NoConflictingSchemaKeywords $versionOwner 'GenealogySettingsVersion'
Assert-AllowedSchemaKeywords $versionOwner 'GenealogySettingsVersion' @('type', 'minLength', 'maxLength', 'pattern', 'nullable')
}
$settingsBody = Get-Schema 'AppGenealogySettingsUpdateBody'
Assert-ExactObject $settingsBody 'AppGenealogySettingsUpdateBody' @(
'genealogyName', 'intro', 'accessPreset'
) @() 1 3
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'genealogyName' '#/components/schemas/GenealogyName'
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'intro' '#/components/schemas/GenealogyIntro'
Assert-PropertyRef $settingsBody 'AppGenealogySettingsUpdateBody' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
$appGenealogy = Get-Schema 'AppGenealogyVo'
if ($appGenealogy) {
if (-not (Test-IsNonNullable $appGenealogy)) {
Add-Issue 'JSON AppGenealogyVo must reject nullable=true'
}
foreach ($field in @('genealogyId', 'genealogyName', 'intro', 'accessPreset', 'settingsVersion', 'canManage')) {
if ($field -notin @($appGenealogy.required)) {
Add-Issue "JSON AppGenealogyVo.required missing settings baseline field: $field"
}
}
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId'
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName'
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'intro' '#/components/schemas/GenealogyIntro'
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'accessPreset' '#/components/schemas/GenealogyAccessPreset'
Assert-PropertyRef $appGenealogy 'AppGenealogyVo' 'settingsVersion' '#/components/schemas/GenealogySettingsVersion'
if ($appGenealogy.properties.canManage.type -ne 'boolean' -or
-not (Test-IsNonNullable $appGenealogy.properties.canManage)) {
Add-Issue 'JSON AppGenealogyVo.canManage must be a non-null boolean and is only an entry capability, not write authorization proof'
}
Assert-NoConflictingSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage'
Assert-AllowedSchemaKeywords $appGenealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable')
Assert-AllowedSchemaKeywords $appGenealogy.properties.canManage 'AppGenealogyVo.canManage' @('type', 'nullable')
}
$successEnvelope = Get-Schema 'RAppGenealogyVo'
if ($successEnvelope) {
Assert-ExactObject $successEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data')
if ($successEnvelope.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $successEnvelope.properties.code) -or
-not (Test-IsJsonArray $successEnvelope.properties.code.enum) -or
@($successEnvelope.properties.code.enum).Count -ne 1 -or
@($successEnvelope.properties.code.enum)[0] -ne 200) {
Add-Issue 'JSON RAppGenealogyVo must expose fixed code=200 and canonical AppGenealogyVo data'
}
Assert-NoConflictingSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('enum')
Assert-AllowedSchemaKeywords $successEnvelope.properties.code 'RAppGenealogyVo.code' @('type', 'enum', 'nullable')
[void](Test-IsPureSchemaRef $successEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data')
}
$badRequest = Get-Schema 'RGenealogySettingsBadRequest'
$unauthorized = Get-Schema 'RGenealogySettingsUnauthorized'
$forbidden = Get-Schema 'RGenealogySettingsForbidden'
$notFound = Get-Schema 'RGenealogySettingsNotFound'
$versionChanged = Get-Schema 'RGenealogySettingsVersionChanged'
$notReady = Get-Schema 'RGenealogySettingsNotReady'
$pendingApplications = Get-Schema 'RGenealogySettingsPendingApplications'
$conflict = Get-Schema 'RGenealogySettingsConflict'
$validation = Get-Schema 'RGenealogySettingsValidationError'
$rateLimited = Get-Schema 'RGenealogySettingsRateLimited'
$outcomeUnknown = Get-Schema 'RGenealogySettingsOutcomeUnknown'
Assert-FixedError $badRequest 'RGenealogySettingsBadRequest' 400 'GENEALOGY_SETTINGS_REQUEST_INVALID'
Assert-FixedError $unauthorized 'RGenealogySettingsUnauthorized' 401 'AUTH_REQUIRED'
Assert-FixedError $forbidden 'RGenealogySettingsForbidden' 403 'GENEALOGY_SETTINGS_FORBIDDEN'
Assert-FixedError $notFound 'RGenealogySettingsNotFound' 404 'GENEALOGY_NOT_AVAILABLE'
Assert-FixedError $versionChanged 'RGenealogySettingsVersionChanged' 409 'GENEALOGY_SETTINGS_VERSION_CHANGED' -Current
Assert-FixedError $notReady 'RGenealogySettingsNotReady' 409 'GENEALOGY_NOT_READY'
Assert-FixedError $pendingApplications 'RGenealogySettingsPendingApplications' 409 'ACTIVE_PENDING_APPLICATIONS'
Assert-FixedError $rateLimited 'RGenealogySettingsRateLimited' 429 'RATE_LIMITED'
Assert-FixedError $outcomeUnknown 'RGenealogySettingsOutcomeUnknown' 500 'GENEALOGY_SETTINGS_OUTCOME_UNKNOWN'
if ($conflict) {
$actualRefs = @($conflict.oneOf | ForEach-Object { [string]$_.'$ref' } | Sort-Object)
$expectedRefs = @(
'#/components/schemas/RGenealogySettingsVersionChanged',
'#/components/schemas/RGenealogySettingsNotReady',
'#/components/schemas/RGenealogySettingsPendingApplications'
) | Sort-Object
$mapping = $conflict.discriminator.mapping
$actualMapping = if ($mapping) {
@($mapping.PSObject.Properties | ForEach-Object { "$($_.Name)=$($_.Value)" } | Sort-Object)
} else { @() }
$expectedMapping = @(
'GENEALOGY_SETTINGS_VERSION_CHANGED=#/components/schemas/RGenealogySettingsVersionChanged',
'GENEALOGY_NOT_READY=#/components/schemas/RGenealogySettingsNotReady',
'ACTIVE_PENDING_APPLICATIONS=#/components/schemas/RGenealogySettingsPendingApplications'
) | Sort-Object
$conflictKeywords = @($conflict.PSObject.Properties.Name | Sort-Object)
$discriminatorKeywords = if ($conflict.discriminator) {
@($conflict.discriminator.PSObject.Properties.Name | Sort-Object)
} else { @() }
$pureBranches = @($conflict.oneOf | Where-Object {
(@($_.PSObject.Properties.Name) -join ',') -ceq '$ref' -and
[string]$_.'$ref' -match '^#/components/schemas/[^/]+$'
})
if (($conflictKeywords -join ',') -cne 'discriminator,oneOf' -or
($discriminatorKeywords -join ',') -cne 'mapping,propertyName' -or
-not (Test-IsJsonArray $conflict.oneOf) -or $pureBranches.Count -ne 3 -or
($actualRefs -join ',') -ne ($expectedRefs -join ',') -or
$conflict.discriminator.propertyName -ne 'businessCode' -or
($actualMapping -join ',') -ne ($expectedMapping -join ',')) {
Add-Issue 'JSON RGenealogySettingsConflict must contain only an exact businessCode discriminator and three local-ref oneOf branches for version, READY, and active-pending conflicts'
}
}
Assert-ExactObject $validation 'RGenealogySettingsValidationError' @(
'code', 'businessCode', 'fieldErrors'
) @('code', 'businessCode', 'fieldErrors')
if ($validation) {
if ($validation.properties.code.type -ne 'integer' -or
-not (Test-IsNonNullable $validation.properties.code) -or
-not (Test-IsJsonArray $validation.properties.code.enum) -or
@($validation.properties.code.enum).Count -ne 1 -or @($validation.properties.code.enum)[0] -ne 422 -or
$validation.properties.businessCode.type -ne 'string' -or
-not (Test-IsNonNullable $validation.properties.businessCode) -or
-not (Test-IsJsonArray $validation.properties.businessCode.enum) -or
@($validation.properties.businessCode.enum).Count -ne 1 -or
@($validation.properties.businessCode.enum)[0] -ne 'GENEALOGY_SETTINGS_INVALID') {
Add-Issue 'JSON RGenealogySettingsValidationError must fix code=422 and businessCode=GENEALOGY_SETTINGS_INVALID'
}
Assert-NoConflictingSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('enum')
Assert-NoConflictingSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('enum')
Assert-AllowedSchemaKeywords $validation.properties.code 'RGenealogySettingsValidationError.code' @('type', 'enum', 'nullable')
Assert-AllowedSchemaKeywords $validation.properties.businessCode 'RGenealogySettingsValidationError.businessCode' @('type', 'enum', 'nullable')
$fieldErrors = $validation.properties.fieldErrors
Assert-ExactObject $fieldErrors 'RGenealogySettingsValidationError.fieldErrors' @(
'genealogyName', 'intro', 'accessPreset'
) @() 1 3
if ($fieldErrors) {
foreach ($field in @('genealogyName', 'intro', 'accessPreset')) {
$property = $fieldErrors.properties.PSObject.Properties[$field]
if (-not $property -or $property.Value.type -ne 'string' -or
-not (Test-IsNonNullable $property.Value) -or
[int]$property.Value.minLength -ne 1 -or [int]$property.Value.maxLength -ne 200) {
Add-Issue "JSON RGenealogySettingsValidationError.fieldErrors.$field must be an optional non-null bounded non-empty string"
}
if ($property) {
Assert-NoConflictingSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field"
Assert-AllowedSchemaKeywords $property.Value "RGenealogySettingsValidationError.fieldErrors.$field" @('type', 'minLength', 'maxLength', 'nullable')
}
}
}
}
if ($issues.Count -gt 0) {
Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT BLOCKED'
Write-Output "Issues: $($issues.Count)"
$issues | ForEach-Object { Write-Output "- $_" }
Write-Output '- Keep PUT as the only settings owner and /overview as the only single-genealogy read owner; do not add PATCH or revive the generic GET.'
Write-Output '- Use one atomic dirty-only merge with GenealogySettingsVersion in AppGenealogyVo, required If-Match, typed 409 conflicts, and no version field in the body.'
Write-Output '- Block only PUBLIC_APPLY-to-MEMBER_ONLY while active PENDING applications exist, and serialize that transition with new application admission.'
Write-Output '- Timeout, cancellation, 5xx, or malformed success is outcome-unknown; reconcile with a fresh /overview and never auto-repeat PUT or claim this request succeeded.'
Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.'
exit 1
}
Write-Output 'G11-SETTINGS-OPENAPI-CONTRACT PASS'