$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] $minePath = '/genealogy/app/genealogies/mine' $overviewPath = '/genealogy/app/genealogies/{genealogyId}/overview' $identifierPattern = '^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$' $httpMethods = @('get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace') 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) + '/(?[^/]+)$' $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.ToUpperInvariant()) $Path" return $null } return $operationProperty.Value } function Assert-OnlyMethod { param([string]$Path, [string]$Method) $pathProperty = $document.paths.PSObject.Properties[$Path] if (-not $pathProperty) { return } $actual = @($pathProperty.Value.PSObject.Properties.Name | Where-Object { $_ -in $httpMethods } | Sort-Object) if ($actual.Count -ne 1 -or $actual[0] -ne $Method) { Add-Issue "JSON $Path must expose only $($Method.ToUpperInvariant()); actual: $($actual -join ',')" } } 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, [bool]$StrictRefObject = $true) if (-not $Response) { return $null } if (-not $Response.'$ref') { return $Response } $responseRefSiblings = @($Response.PSObject.Properties.Name | Where-Object { $_ -notin @('$ref', 'summary', 'description') }) if ($StrictRefObject -and $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-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 Assert-ExactParameters { param([object[]]$Parameters, [string]$Label, [string[]]$Expected) $actual = @($Parameters | ForEach-Object { "$($_.in):$($_.name)" } | Sort-Object) $wanted = @($Expected | Sort-Object) if (($actual -join ',') -cne ($wanted -join ',')) { Add-Issue "JSON $Label parameters must be exactly $($wanted -join ','); actual: $($actual -join ',')" } } 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-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 Assert-GlobalOperationId { param([string]$OperationId, [string]$ExpectedLabel) $owners = 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 $httpMethods })) { if ([string]$methodProperty.Value.operationId -ceq $OperationId) { $owners.Add("$($methodProperty.Name.ToUpperInvariant()) $($pathProperty.Name)") } } } if ($owners.Count -ne 1 -or $owners[0] -cne $ExpectedLabel) { Add-Issue "JSON operationId $OperationId must be globally unique on $ExpectedLabel; actual: $($owners -join ',')" } } 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 ',') -cne ($wanted -join ',')) { Add-Issue "JSON $Label responses must be exactly $($wanted -join ','); actual: $($actual -join ',')" } } 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-ResponseSchemaRef { 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" return '' } [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-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; workspace reads use the shared settingsVersion owner instead" } $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-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-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 a non-null 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-ExactObject { param([object]$Schema, [string]$Name, [string[]]$Properties, [string[]]$Required) 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 ',') -cne ($expectedProperties -join ',') -or ($actualRequired -join ',') -cne ($expectedRequired -join ',')) { Add-Issue "JSON $Name must be a non-null closed object with properties [$($expectedProperties -join ',')] and required [$($expectedRequired -join ',')]" } Assert-NoConflictingSchemaKeywords $Schema $Name Assert-AllowedSchemaKeywords $Schema $Name @('type', 'properties', 'required', 'additionalProperties', 'nullable') } 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([string]$Name, [int]$Status, [string]$BusinessCode) $schema = Get-Schema $Name Assert-ExactObject $schema $Name @('code', 'businessCode') @('code', 'businessCode') if (-not $schema) { return } $codes = @($schema.properties.code.enum) $businessCodes = @($schema.properties.businessCode.enum) if ($schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $schema.properties.code) -or -not (Test-IsJsonArray $schema.properties.code.enum) -or $codes.Count -ne 1 -or $codes[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 $businessCodes.Count -ne 1 -or [string]$businessCodes[0] -cne $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') } function Get-AllOfPropertyNames { param([object]$Schema, [hashtable]$Seen, [string]$Label) if (-not $Schema -or $Schema.type -eq 'array') { return @() } if ($Schema.'$ref') { $name = Get-LocalComponentName ([string]$Schema.'$ref') 'schemas' $Label if (-not $name -or $Seen.ContainsKey($name)) { return @() } $Seen[$name] = $true $owner = $document.components.schemas.PSObject.Properties[$name] if (-not $owner) { Add-Issue "JSON $Label references missing schema owner: $name" return @() } return @(Get-AllOfPropertyNames $owner.Value $Seen "$Label -> $name") } $names = New-Object System.Collections.Generic.List[string] if ($Schema.properties) { foreach ($name in @($Schema.properties.PSObject.Properties.Name)) { $names.Add($name) } } foreach ($branch in @($Schema.allOf)) { foreach ($name in @(Get-AllOfPropertyNames $branch $Seen "$Label allOf")) { $names.Add($name) } } return @($names | Sort-Object -Unique) } function Test-SchemaClosureExposesSingleGenealogy { param([object]$Schema, [hashtable]$Seen, [string]$Label) if (-not $Schema) { return $false } $ref = [string]$Schema.'$ref' if ($ref) { $leaf = @($ref -split '/')[-1] if ($leaf -in @('RAppGenealogyVo', 'AppGenealogyVo')) { $expectedRef = "#/components/schemas/$leaf" if ($ref -cne $expectedRef) { [void](Get-LocalComponentName $ref 'schemas' $Label) } return $true } $name = Get-LocalComponentName $ref 'schemas' $Label if (-not $name -or $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-SchemaClosureExposesSingleGenealogy $owner.Value $Seen "$Label -> $name" } if ($Schema.type -eq 'array') { return $false } $propertyNames = @(Get-AllOfPropertyNames $Schema @{} "$Label structural detail") $detailFields = @(@('genealogyId', 'genealogyName', 'canView') | Where-Object { $_ -in $propertyNames }) if ($detailFields.Count -eq 3) { return $true } foreach ($keyword in @('allOf', 'anyOf', 'oneOf')) { foreach ($branch in @($Schema.$keyword)) { if (Test-SchemaClosureExposesSingleGenealogy $branch $Seen "$Label $keyword") { return $true } } } if ($Schema.properties) { foreach ($property in @($Schema.properties.PSObject.Properties)) { if ($property.Value.type -eq 'array') { continue } if (Test-SchemaClosureExposesSingleGenealogy $property.Value $Seen "$Label.$($property.Name)") { return $true } } } return $false } function Test-OperationReturnsSingleGenealogy { param([object]$Operation, [string]$Label) if (-not $Operation -or -not $Operation.responses) { return $false } $responseProperty = $Operation.responses.PSObject.Properties['200'] if (-not $responseProperty) { return $false } $response = $responseProperty.Value if ($response.'$ref') { $leaf = @(([string]$response.'$ref') -split '/')[-1] if ($leaf -eq 'RAppGenealogyVo') { if ([string]$response.'$ref' -cne '#/components/responses/RAppGenealogyVo') { [void](Get-LocalComponentName ([string]$response.'$ref') 'responses' "$Label 200 response") } return $true } $response = Resolve-Response $response "$Label 200 response" $true if (-not $response) { return $false } } if (-not $response.content) { return $false } foreach ($media in @($response.content.PSObject.Properties)) { if (Test-SchemaClosureExposesSingleGenealogy $media.Value.schema @{} "$Label 200 $($media.Name)") { return $true } } return $false } function Assert-SoleSingleGenealogyReadOwner { $owners = New-Object System.Collections.Generic.List[string] foreach ($pathProperty in @($document.paths.PSObject.Properties | Where-Object { $_.Name -match '^/genealogy/app(?:/|$)' })) { $getProperty = $pathProperty.Value.PSObject.Properties['get'] if (-not $getProperty -or $pathProperty.Name -ceq $minePath) { continue } $label = "GET $($pathProperty.Name)" if (Test-OperationReturnsSingleGenealogy $getProperty.Value $label) { $owners.Add($label) } } $expected = "GET $overviewPath" $uniqueOwners = @($owners | Sort-Object -Unique) if ($uniqueOwners.Count -ne 1 -or $uniqueOwners[0] -cne $expected) { Add-Issue "JSON single-genealogy APP read must have exactly one global owner ($expected); actual: $($uniqueOwners -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 ' | ')" } $mine = Get-Operation $minePath 'get' $overview = Get-Operation $overviewPath 'get' Assert-OnlyMethod $minePath 'get' Assert-OnlyMethod $overviewPath 'get' Assert-SoleSingleGenealogyReadOwner $operationContracts = @( [pscustomobject]@{ Path = $minePath Operation = $mine Label = "GET $minePath" OperationId = 'appListMyGenealogies' Parameters = @('header:clientid') Responses = [ordered]@{ '200' = '#/components/schemas/RListAppGenealogyVo' '401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized' '429' = '#/components/schemas/RGenealogyWorkspaceRateLimited' '500' = '#/components/schemas/RGenealogyWorkspaceUnavailable' } }, [pscustomobject]@{ Path = $overviewPath Operation = $overview Label = "GET $overviewPath" OperationId = 'appGetGenealogyOverview' Parameters = @('header:clientid', 'path:genealogyId') Responses = [ordered]@{ '200' = '#/components/schemas/RAppGenealogyVo' '400' = '#/components/schemas/RGenealogyWorkspaceBadRequest' '401' = '#/components/schemas/RGenealogyWorkspaceUnauthorized' '404' = '#/components/schemas/RGenealogyWorkspaceNotFound' '429' = '#/components/schemas/RGenealogyWorkspaceRateLimited' '500' = '#/components/schemas/RGenealogyWorkspaceUnavailable' } } ) foreach ($contract in $operationContracts) { $operation = $contract.Operation if (-not $operation) { continue } if ([string]$operation.operationId -cne $contract.OperationId) { Add-Issue "JSON $($contract.Label) operationId must be $($contract.OperationId)" } Assert-GlobalOperationId $contract.OperationId $contract.Label Assert-SaToken $operation $contract.Label if ($operation.PSObject.Properties['requestBody']) { Add-Issue "JSON $($contract.Label) must not define a request body" } $parameters = @(Get-OperationParameters $contract.Path $operation $contract.Label) Assert-ExactParameters $parameters $contract.Label $contract.Parameters $clientid = Get-Parameter $parameters 'clientid' 'header' $contract.Label 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 $($contract.Label) clientid must be a required non-null string bounded to 1..128" } if ($clientid) { Assert-NoConflictingSchemaKeywords $clientid.schema "$($contract.Label) clientid" } if ($clientid) { Assert-AllowedSchemaKeywords $clientid.schema "$($contract.Label) clientid" @('type', 'minLength', 'maxLength', 'nullable') } if ($contract.Path -eq $overviewPath) { $genealogyId = Get-Parameter $parameters 'genealogyId' 'path' $contract.Label if ($genealogyId) { if (-not (Test-IsJsonBoolean $genealogyId.required $true)) { Add-Issue "JSON $($contract.Label) genealogyId must be required" } [void](Test-IsPureSchemaRef $genealogyId.schema '#/components/schemas/GenealogyId' "$($contract.Label) genealogyId") } } $statuses = @($contract.Responses.Keys) Assert-ExactResponseSet $operation $contract.Label $statuses foreach ($status in $statuses) { $response = Get-Response $operation $contract.Label $status $actualRef = Get-ResponseSchemaRef $response $contract.Label $status if ($actualRef -cne $contract.Responses[$status]) { Add-Issue "JSON $($contract.Label) $status must return $($contract.Responses[$status]); actual: $actualRef" } Assert-RequiredResponseHeaders $response $contract.Label $status Assert-PrivateNoStore $response $contract.Label $status if ($status -eq '429') { Assert-RetryAfter $response "$($contract.Label) 429" } } } if ($mine -and (-not (Test-IsJsonBoolean $mine.'x-current-account-viewable-only' $true) -or [string]$mine.'x-revocation-policy' -cne 'OMIT_ONLY_AFTER_CONFIRMED_ACCESS_LOSS')) { Add-Issue 'JSON GET /mine must machine-bind current-account viewable-only scope and confirmed-revocation omission' } if ($overview -and [string]$overview.'x-object-authorization-failure' -cne 'NON_DISCLOSING_GENEALOGY_NOT_AVAILABLE') { Add-Issue 'JSON GET /overview must machine-bind non-disclosing object authorization failure' } $genealogyIdOwner = Get-Schema 'GenealogyId' if ($genealogyIdOwner -and ($genealogyIdOwner.type -ne 'string' -or -not (Test-IsNonNullable $genealogyIdOwner) -or [int]$genealogyIdOwner.minLength -ne 1 -or [int]$genealogyIdOwner.maxLength -ne 128 -or [string]$genealogyIdOwner.pattern -cne $identifierPattern)) { Add-Issue 'JSON GenealogyId must be a non-null 1..128 URL-safe lexical identifier' } if ($genealogyIdOwner) { Assert-NoConflictingSchemaKeywords $genealogyIdOwner 'GenealogyId' } if ($genealogyIdOwner) { Assert-AllowedSchemaKeywords $genealogyIdOwner 'GenealogyId' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') } $genealogyNameOwner = Get-Schema 'GenealogyName' if ($genealogyNameOwner -and ($genealogyNameOwner.type -ne 'string' -or -not (Test-IsNonNullable $genealogyNameOwner))) { Add-Issue 'JSON GenealogyName shared owner must be a non-null string; G11 owns its normalization and length semantics' } if ($genealogyNameOwner) { Assert-NoConflictingSchemaKeywords $genealogyNameOwner 'GenealogyName' } if ($genealogyNameOwner) { Assert-AllowedSchemaKeywords $genealogyNameOwner 'GenealogyName' @('type', 'minLength', 'maxLength', 'pattern', 'nullable') } $listEnvelope = Get-Schema 'RListAppGenealogyVo' $objectEnvelope = Get-Schema 'RAppGenealogyVo' $genealogy = Get-Schema 'AppGenealogyVo' Assert-ExactObject $listEnvelope 'RListAppGenealogyVo' @('code', 'data') @('code', 'data') Assert-ExactObject $objectEnvelope 'RAppGenealogyVo' @('code', 'data') @('code', 'data') foreach ($envelope in @( [pscustomobject]@{ Name = 'RListAppGenealogyVo'; Schema = $listEnvelope }, [pscustomobject]@{ Name = 'RAppGenealogyVo'; Schema = $objectEnvelope } )) { if (-not $envelope.Schema) { continue } $codes = @($envelope.Schema.properties.code.enum) if ($envelope.Schema.properties.code.type -ne 'integer' -or -not (Test-IsNonNullable $envelope.Schema.properties.code) -or -not (Test-IsJsonArray $envelope.Schema.properties.code.enum) -or $codes.Count -ne 1 -or $codes[0] -ne 200) { Add-Issue "JSON $($envelope.Name).code must be fixed to 200" } Assert-NoConflictingSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('enum') Assert-AllowedSchemaKeywords $envelope.Schema.properties.code "$($envelope.Name).code" @('type', 'enum', 'nullable') } if ($listEnvelope) { $listData = $listEnvelope.properties.data if ($listData.type -ne 'array' -or -not (Test-IsNonNullable $listData)) { Add-Issue 'JSON RListAppGenealogyVo.data must be a non-null AppGenealogyVo array' } Assert-NoConflictingSchemaKeywords $listData 'RListAppGenealogyVo.data' Assert-AllowedSchemaKeywords $listData 'RListAppGenealogyVo.data' @('type', 'items', 'nullable') [void](Test-IsPureSchemaRef $listData.items '#/components/schemas/AppGenealogyVo' 'RListAppGenealogyVo.data.items') } if ($objectEnvelope) { [void](Test-IsPureSchemaRef $objectEnvelope.properties.data '#/components/schemas/AppGenealogyVo' 'RAppGenealogyVo.data') } $consumedFields = @('genealogyId', 'genealogyName', 'canView', 'canManage', 'canEditContent', 'roleType') if ($genealogy) { if ($genealogy.type -ne 'object' -or -not (Test-IsNonNullable $genealogy)) { Add-Issue 'JSON AppGenealogyVo must be a non-null object' } Assert-AllowedSchemaKeywords $genealogy 'AppGenealogyVo' @('type', 'properties', 'required', 'additionalProperties', 'nullable') foreach ($field in $consumedFields) { if ($field -notin @($genealogy.required)) { Add-Issue "JSON AppGenealogyVo.required missing workspace field: $field" } } Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyId' '#/components/schemas/GenealogyId' Assert-PropertyRef $genealogy 'AppGenealogyVo' 'genealogyName' '#/components/schemas/GenealogyName' foreach ($field in @('canView', 'canManage', 'canEditContent')) { $property = $genealogy.properties.PSObject.Properties[$field] if (-not $property -or $property.Value.type -ne 'boolean' -or -not (Test-IsNonNullable $property.Value)) { Add-Issue "JSON AppGenealogyVo.$field must be a non-null boolean" } if ($property) { Assert-NoConflictingSchemaKeywords $property.Value "AppGenealogyVo.$field" Assert-AllowedSchemaKeywords $property.Value "AppGenealogyVo.$field" @('type', 'nullable') } } $roleType = $genealogy.properties.PSObject.Properties['roleType'] $rawRoleValues = if ($roleType) { @($roleType.Value.enum) } else { @() } $roleValues = @($rawRoleValues | Where-Object { $_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_) }) $uniqueRoleValues = @($roleValues | Sort-Object -CaseSensitive -Unique) if (-not $roleType -or $roleType.Value.type -ne 'string' -or -not (Test-IsNonNullable $roleType.Value) -or -not (Test-IsJsonArray $roleType.Value.enum) -or $roleValues.Count -ne $rawRoleValues.Count -or $uniqueRoleValues.Count -ne $rawRoleValues.Count -or $rawRoleValues.Count -lt 2) { Add-Issue 'JSON AppGenealogyVo.roleType must be a non-null string with at least two stable non-empty enum values' } if ($roleType) { Assert-NoConflictingSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('enum') Assert-AllowedSchemaKeywords $roleType.Value 'AppGenealogyVo.roleType' @('type', 'enum', 'nullable') } } Assert-FixedError 'RGenealogyWorkspaceBadRequest' 400 'GENEALOGY_ID_INVALID' Assert-FixedError 'RGenealogyWorkspaceUnauthorized' 401 'AUTH_REQUIRED' Assert-FixedError 'RGenealogyWorkspaceNotFound' 404 'GENEALOGY_NOT_AVAILABLE' Assert-FixedError 'RGenealogyWorkspaceRateLimited' 429 'RATE_LIMITED' Assert-FixedError 'RGenealogyWorkspaceUnavailable' 500 'GENEALOGY_WORKSPACE_UNAVAILABLE' if ($issues.Count -gt 0) { Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT BLOCKED' Write-Output "Issues: $($issues.Count)" $issues | ForEach-Object { Write-Output "- $_" } Write-Output '- /mine and /overview are authenticated private reads; do not publish a generic duplicate detail read or HTTP-200-wrapped errors.' Write-Output '- Replace both protected exports from one backend version; do not hand-edit APP.openapi.json or APP.openapi.yaml.' Write-Output '- Release still requires two-account deployment tests for revocation, cross-account isolation, deletion, rate limits, malformed identifiers, and service failure.' exit 1 } Write-Output 'GENEALOGY-WORKSPACE-OPENAPI-CONTRACT PASS'