diff --git a/.config/LocalizationValidationAllowlist.json b/.config/LocalizationValidationAllowlist.json new file mode 100644 index 0000000000..0a3cbfea6b --- /dev/null +++ b/.config/LocalizationValidationAllowlist.json @@ -0,0 +1,110 @@ +{ + "_comment": [ + "These culture/key pairs intentionally match the English source text.", + "Each pair was verified in the internal LCL source as localized (Stat=Loc, Orig=New). Remove an entry when its localized value changes." + ], + "AllowedEnglishValueMatches": { + "Strings.cs.resx": [ + "ADP_InvalidMultipartName", + "DataCategory_Data", + "DataCategory_InfoMessage", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SqlMisc_NullString" + ], + "Strings.de.resx": [ + "ADP_InvalidMultipartName", + "DataCategory_Pooling", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId" + ], + "Strings.es.resx": [ + "ADP_InvalidMultipartName", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SQL_ExErrorNumberStateClass" + ], + "Strings.fr.resx": [ + "DataCategory_InfoMessage", + "DataCategory_Notification", + "DataCategory_Source", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SqlMisc_NullString" + ], + "Strings.it.resx": [ + "ADP_InvalidMultipartName", + "DataCategory_InfoMessage", + "DataCategory_Pooling", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SQL_ExErrorNumberStateClass", + "SqlMisc_NullString" + ], + "Strings.ja.resx": [ + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId" + ], + "Strings.ko.resx": [ + "ADP_InvalidMultipartName", + "DataCategory_InfoMessage", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SQL_ExErrorNumberStateClass", + "SqlMisc_NullString" + ], + "Strings.pl.resx": [ + "DataCategory_InfoMessage", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SqlMisc_NullString" + ], + "Strings.pt-BR.resx": [ + "ADP_InvalidMultipartName", + "DataCategory_InfoMessage", + "DataCategory_Pooling", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SQL_ExErrorNumberStateClass" + ], + "Strings.ru.resx": [ + "ADP_InvalidMultipartName", + "DataCategory_InfoMessage", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SQL_ExOriginalClientConnectionId" + ], + "Strings.tr.resx": [ + "ADP_InvalidMultipartName", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SqlMisc_NullString" + ], + "Strings.zh-Hans.resx": [ + "DataCategory_InfoMessage", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SQL_ExErrorNumberStateClass", + "SqlMisc_NullString" + ], + "Strings.zh-Hant.resx": [ + "DataCategory_InfoMessage", + "DataCategory_StatementCompleted", + "DataCategory_Xml", + "SQL_ExClientConnectionId", + "SQL_ExErrorNumberStateClass", + "SqlMisc_NullString" + ] + } +} diff --git a/.github/instructions/onebranch-pipeline-design.instructions.md b/.github/instructions/onebranch-pipeline-design.instructions.md index 0b647b6be0..7b83aea6da 100644 --- a/.github/instructions/onebranch-pipeline-design.instructions.md +++ b/.github/instructions/onebranch-pipeline-design.instructions.md @@ -26,9 +26,13 @@ Respect this graph when modifying build stages: 5. `Microsoft.Data.SqlClient.Extensions.Azure` — depends on Abstractions + Logging 6. `Microsoft.Data.SqlClient.AlwaysEncrypted.AzureKeyVaultProvider` — depends on SqlClient + Abstractions + Logging +## Localization Validation + +The SqlClient build job runs `steps/validate-localization-step.yml` before building the driver. Validation always fails the build for missing or obsolete keys, empty localized values whose English value is non-empty, and untranslated resources. Approved identical translations are listed by culture and resource key in `.config/LocalizationValidationAllowlist.json`. + ## Build Stages -Defined in `stages/build-stages.yml`. Four build stages plus validation, ordered by dependency: +Defined in `stages/build-stages.yml`. Four build stages plus package validation are ordered by dependency: - **`build_independent`** (Stage 1) — Logging and SqlServer.Server in parallel; no inter-package dependencies - **`build_abstractions`** (Stage 2) — Abstractions; `dependsOn: build_independent`; downloads Logging artifact diff --git a/eng/pipelines/onebranch/jobs/build-buildproj-job.yml b/eng/pipelines/onebranch/jobs/build-buildproj-job.yml index f773ec5e7e..4db0d113d9 100644 --- a/eng/pipelines/onebranch/jobs/build-buildproj-job.yml +++ b/eng/pipelines/onebranch/jobs/build-buildproj-job.yml @@ -119,6 +119,11 @@ jobs: steps: - template: /eng/pipelines/onebranch/steps/script-output-environment-variables-step.yml@self + # Localized resources ship with the SqlClient driver. Validate them before analysis and + # building so missing or untranslated strings fail every SqlClient build. + - ${{ if eq(parameters.packageShortName, 'SqlClient') }}: + - template: /eng/pipelines/onebranch/steps/validate-localization-step.yml@self + - ${{ each package in parameters.dependencies }}: # Build the dependency version arguments passed to the build/pack/analysis steps. The # SqlClient family shares a single version via Central Package Management, and those steps diff --git a/eng/pipelines/onebranch/scripts/tests/README.md b/eng/pipelines/onebranch/scripts/tests/README.md index bda88d58a1..6392b49241 100644 --- a/eng/pipelines/onebranch/scripts/tests/README.md +++ b/eng/pipelines/onebranch/scripts/tests/README.md @@ -32,6 +32,7 @@ Invoke-Pester ./publish-symbols.Tests.ps1 -Output Detailed | Area | What's tested | | --------------------- | ---------------------------------------------------------------- | | Version computation | Canonical output parsing, revisions, wrapping, effective package selection, and failures | +| Localization validation | Missing, obsolete, or empty strings, English-value matches, and culture-specific allowlisting | | Parameter validation | Empty strings rejected for all mandatory parameters | | URL construction | Base URL, register URL, request URL built from parameters | | Request bodies | Registration body, default publish flags, flag overrides | diff --git a/eng/pipelines/onebranch/scripts/tests/validate-localization.Tests.ps1 b/eng/pipelines/onebranch/scripts/tests/validate-localization.Tests.ps1 new file mode 100644 index 0000000000..5f27b883d3 --- /dev/null +++ b/eng/pipelines/onebranch/scripts/tests/validate-localization.Tests.ps1 @@ -0,0 +1,190 @@ +<# +.SYNOPSIS + Pester tests for validate-localization.ps1. +#> + +BeforeAll { + $scriptPath = Join-Path $PSScriptRoot '..' 'validate-localization.ps1' + + function Set-ResourceFile { + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][hashtable]$Strings + ) + + $document = [System.Xml.XmlDocument]::new() + $root = $document.CreateElement('root') + $null = $document.AppendChild($root) + foreach ($entry in $Strings.GetEnumerator()) { + $data = $document.CreateElement('data') + $data.SetAttribute('name', $entry.Key) + + $value = $document.CreateElement('value') + $value.InnerText = $entry.Value + $null = $data.AppendChild($value) + $null = $root.AppendChild($data) + } + + $document.Save($Path) + } + + function New-ResourcesDirectory { + $path = Join-Path $TestDrive ([guid]::NewGuid().ToString('n')) + New-Item -ItemType Directory -Path $path | Out-Null + return $path + } +} + +Describe 'validate-localization.ps1' { + It 'accepts complete localized files with translated values' { + $resources = New-ResourcesDirectory + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello'; Farewell = 'Goodbye' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Greeting = 'Bonjour'; Farewell = 'Au revoir' } + + { & $scriptPath -ResourcesDirectory $resources } | Should -Not -Throw + } + + It 'fails when a localized file is missing an English key' { + $resources = New-ResourcesDirectory + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello'; Farewell = 'Goodbye' } + Set-ResourceFile (Join-Path $resources 'Strings.de.resx') @{ Greeting = 'Hallo' } + + { & $scriptPath -ResourcesDirectory $resources } | + Should -Throw '*Localization validation failed with 1 error. Review the preceding errors.*' + } + + It 'fails when a localized value matches a non-empty English value' { + $resources = New-ResourcesDirectory + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello'; Unused = '' } + Set-ResourceFile (Join-Path $resources 'Strings.ja.resx') @{ Greeting = 'Hello'; Unused = '' } + + { & $scriptPath -ResourcesDirectory $resources } | + Should -Throw '*Localization validation failed with 1 error. Review the preceding errors.*' + } + + It 'fails when no localized resource files exist' { + $resources = New-ResourcesDirectory + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello' } + + { & $scriptPath -ResourcesDirectory $resources } | + Should -Throw '*No localized Strings.*.resx files were found*' + } + + It 'fails when a non-empty English string has an empty localized value' { + $resources = New-ResourcesDirectory + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello' } + Set-ResourceFile (Join-Path $resources 'Strings.es.resx') @{ Greeting = ' ' } + + { & $scriptPath -ResourcesDirectory $resources } | + Should -Throw '*Localization validation failed with 1 error. Review the preceding errors.*' + } + + It 'accepts empty localized values when the English value is also empty' { + $resources = New-ResourcesDirectory + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Unused = '' } + Set-ResourceFile (Join-Path $resources 'Strings.ko.resx') @{ Unused = '' } + + { & $scriptPath -ResourcesDirectory $resources } | Should -Not -Throw + } + + It 'fails when a resource data element has no value' { + $resources = New-ResourcesDirectory + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Greeting = 'Bonjour' } + [xml]$localized = Get-Content -LiteralPath (Join-Path $resources 'Strings.fr.resx') + $valueNode = $localized.SelectSingleNode('/root/data/value') + $null = $valueNode.ParentNode.RemoveChild($valueNode) + $localized.Save((Join-Path $resources 'Strings.fr.resx')) + + { & $scriptPath -ResourcesDirectory $resources } | + Should -Throw '*contains a element without a name or value*' + } + + It 'accepts an approved English-value match from the allowlist' { + $resources = New-ResourcesDirectory + $allowlist = Join-Path $resources 'allowlist.json' + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Greeting = 'Hello' } + @{ AllowedEnglishValueMatches = @{ 'Strings.fr.resx' = @('Greeting') } } | + ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $allowlist + + { & $scriptPath -ResourcesDirectory $resources -AllowlistPath $allowlist } | + Should -Not -Throw + } + + It 'does not allowlist a missing localized key' { + $resources = New-ResourcesDirectory + $allowlist = Join-Path $resources 'allowlist.json' + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello'; Farewell = 'Goodbye' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Greeting = 'Bonjour' } + @{ AllowedEnglishValueMatches = @{ 'Strings.fr.resx' = @('Farewell') } } | + ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $allowlist + + { & $scriptPath -ResourcesDirectory $resources -AllowlistPath $allowlist } | + Should -Throw '*Localization validation failed with 1 error. Review the preceding errors.*' + } + + It 'rejects allowlist for unknown resource keys' { + $resources = New-ResourcesDirectory + $allowlist = Join-Path $resources 'allowlist.json' + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Greeting = 'Bonjour' } + @{ AllowedEnglishValueMatches = @{ 'Strings.fr.resx' = @('Unknown') } } | + ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $allowlist + + { & $scriptPath -ResourcesDirectory $resources -AllowlistPath $allowlist } | + Should -Throw '*Localization validation failed with 1 error. Review the preceding errors.*' + } + + It 'scopes approved English-value matches to one localized file' { + $resources = New-ResourcesDirectory + $allowlist = Join-Path $resources 'allowlist.json' + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello' } + Set-ResourceFile (Join-Path $resources 'Strings.de.resx') @{ Greeting = 'Hello' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Greeting = 'Hello' } + @{ AllowedEnglishValueMatches = @{ 'Strings.de.resx' = @('Greeting') } } | + ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $allowlist + + { & $scriptPath -ResourcesDirectory $resources -AllowlistPath $allowlist } | + Should -Throw '*Localization validation failed with 1 error. Review the preceding errors.*' + } + + It 'fails when a localized file contains a key absent from Strings.resx' { + $resources = New-ResourcesDirectory + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Greeting = 'Bonjour'; Obsolete = 'Ancien' } + + { & $scriptPath -ResourcesDirectory $resources } | + Should -Throw '*Localization validation failed with 1 error. Review the preceding errors.*' + } + + It 'rejects an allowlist entry after the localized value is translated' { + $resources = New-ResourcesDirectory + $allowlist = Join-Path $resources 'allowlist.json' + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Greeting = 'Hello' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Greeting = 'Bonjour' } + @{ AllowedEnglishValueMatches = @{ 'Strings.fr.resx' = @('Greeting') } } | + ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $allowlist + + { & $scriptPath -ResourcesDirectory $resources -AllowlistPath $allowlist } | + Should -Throw '*Localization validation failed with 1 error. Review the preceding errors.*' + } + + It 'rejects allowlist entries for empty English values' { + $resources = New-ResourcesDirectory + $allowlist = Join-Path $resources 'allowlist.json' + Set-ResourceFile (Join-Path $resources 'Strings.resx') @{ Unused = '' } + Set-ResourceFile (Join-Path $resources 'Strings.fr.resx') @{ Unused = '' } + @{ AllowedEnglishValueMatches = @{ 'Strings.fr.resx' = @('Unused') } } | + ConvertTo-Json -Depth 5 | + Set-Content -LiteralPath $allowlist + + { & $scriptPath -ResourcesDirectory $resources -AllowlistPath $allowlist } | + Should -Throw '*does not have a non-empty English value*' + } +} diff --git a/eng/pipelines/onebranch/scripts/validate-localization.ps1 b/eng/pipelines/onebranch/scripts/validate-localization.ps1 new file mode 100644 index 0000000000..1922c78c5b --- /dev/null +++ b/eng/pipelines/onebranch/scripts/validate-localization.ps1 @@ -0,0 +1,180 @@ +<# +.SYNOPSIS + Validates localized Strings.*.resx files against Strings.resx. + +.PARAMETER ResourcesDirectory + Directory containing the English and localized Strings.resx files. + +.PARAMETER AllowlistPath + Optional JSON file containing approved English-value matches grouped by localized filename. +#> + +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. +# See the LICENSE file in the project root for more information. + +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$ResourcesDirectory, + + [string]$AllowlistPath +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-ResourceStrings { + param([Parameter(Mandatory)][string]$Path) + + $document = [System.Xml.Linq.XDocument]::Load($Path) + $strings = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::Ordinal) + foreach ($data in $document.Root.Elements('data')) { + $name = $data.Attribute('name') + $value = $data.Element('value') + if ($null -eq $name -or $null -eq $value) { + throw "Resource file '$Path' contains a element without a name or value." + } + + if ($strings.ContainsKey($name.Value)) { + throw "Resource file '$Path' contains duplicate key '$($name.Value)'." + } + + $strings.Add($name.Value, $value.Value) + } + + return $strings +} + +# Discover the neutral English resource and every culture-specific resource. The English file is +# the source of truth for the required key set and value-comparison checks below. +$resourcesPath = (Resolve-Path -LiteralPath $ResourcesDirectory).Path +$englishPath = Join-Path $resourcesPath 'Strings.resx' +if (-not (Test-Path -LiteralPath $englishPath -PathType Leaf)) { + throw "English resource file '$englishPath' was not found." +} + +$localizedFiles = @(Get-ChildItem -LiteralPath $resourcesPath -Filter 'Strings.*.resx' -File | Sort-Object Name) +if ($localizedFiles.Count -eq 0) { + throw "No localized Strings.*.resx files were found in '$resourcesPath'." +} + +$englishStrings = Get-ResourceStrings -Path $englishPath +$localizedFilesByName = [System.Collections.Generic.Dictionary[string, System.IO.FileInfo]]::new([System.StringComparer]::Ordinal) +foreach ($localizedFile in $localizedFiles) { + $localizedFilesByName.Add($localizedFile.Name, $localizedFile) +} + +# Load culture/key-specific exceptions for translations intentionally identical to English. The +# configuration is validated against the current resources so stale filenames and keys fail the +# build instead of silently weakening future validation. +$failures = [System.Collections.Generic.List[string]]::new() +$allowedEnglishMatches = [System.Collections.Generic.Dictionary[string, System.Collections.Generic.HashSet[string]]]::new([System.StringComparer]::Ordinal) +if (-not [string]::IsNullOrWhiteSpace($AllowlistPath)) { + if (-not (Test-Path -LiteralPath $AllowlistPath -PathType Leaf)) { + throw "Localization allowlist file '$AllowlistPath' was not found." + } + + $configuration = Get-Content -LiteralPath $AllowlistPath -Raw | ConvertFrom-Json + $englishValueMatchesProperty = $configuration.PSObject.Properties['AllowedEnglishValueMatches'] + if ($null -eq $englishValueMatchesProperty) { + throw "Localization allowlist file '$AllowlistPath' must define 'AllowedEnglishValueMatches'." + } + + foreach ($fileProperty in $englishValueMatchesProperty.Value.PSObject.Properties) { + if (-not $localizedFilesByName.ContainsKey($fileProperty.Name)) { + $failures.Add("Localization allowlist file references unknown resource file '$($fileProperty.Name)'.") + continue + } + + $keys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal) + foreach ($key in @($fileProperty.Value)) { + if ([string]::IsNullOrWhiteSpace($key)) { + throw "Localization allowlist file contains an empty resource key for '$($fileProperty.Name)'." + } + if (-not $englishStrings.ContainsKey($key)) { + $failures.Add("Localization allowlist file references unknown resource key '$key' for '$($fileProperty.Name)'.") + continue + } + if ([string]::IsNullOrEmpty($englishStrings[$key])) { + throw "Localization allowlist key '$key' for '$($fileProperty.Name)' does not have a non-empty English value." + } + if (-not $keys.Add($key)) { + throw "Localization allowlist file contains duplicate key '$key' for '$($fileProperty.Name)'." + } + } + $allowedEnglishMatches.Add($fileProperty.Name, $keys) + } +} + +$allowedMatchCount = 0 +foreach ($localizedFile in $localizedFiles) { + $localizedStrings = Get-ResourceStrings -Path $localizedFile.FullName + + # Validate the key sets in both directions, then compare every non-empty English value with + # its localized value. Empty neutral values may remain empty because they contain no text to + # translate. + $missingOrEmptyKeys = [System.Collections.Generic.List[string]]::new() + $localizedOnlyKeys = [System.Collections.Generic.List[string]]::new() + $englishMatches = [System.Collections.Generic.List[string]]::new() + $staleAllowlistKeys = [System.Collections.Generic.List[string]]::new() + + foreach ($localizedKey in $localizedStrings.Keys) { + if (-not $englishStrings.ContainsKey($localizedKey)) { + $localizedOnlyKeys.Add($localizedKey) + } + } + + foreach ($entry in $englishStrings.GetEnumerator()) { + if (-not $localizedStrings.ContainsKey($entry.Key) -or + (-not [string]::IsNullOrEmpty($entry.Value) -and + [string]::IsNullOrWhiteSpace($localizedStrings[$entry.Key]))) { + $missingOrEmptyKeys.Add($entry.Key) + } + elseif (-not [string]::IsNullOrEmpty($entry.Value) -and + [System.StringComparer]::Ordinal.Equals($entry.Value, $localizedStrings[$entry.Key])) { + if ($allowedEnglishMatches.ContainsKey($localizedFile.Name) -and + $allowedEnglishMatches[$localizedFile.Name].Contains($entry.Key)) { + $allowedMatchCount++ + } + else { + $englishMatches.Add($entry.Key) + } + } + # An allowlist entry must be removed after its localized value changes; otherwise a future + # regression to the English value could be hidden by an obsolete exception. + elseif ($allowedEnglishMatches.ContainsKey($localizedFile.Name) -and + $allowedEnglishMatches[$localizedFile.Name].Contains($entry.Key)) { + $staleAllowlistKeys.Add($entry.Key) + } + } + + if ($missingOrEmptyKeys.Count -gt 0) { + $missingOrEmptyKeys.Sort([System.StringComparer]::Ordinal) + $failures.Add("$($localizedFile.Name): missing keys or empty values: $($missingOrEmptyKeys -join ', ')") + } + if ($localizedOnlyKeys.Count -gt 0) { + $localizedOnlyKeys.Sort([System.StringComparer]::Ordinal) + $failures.Add("$($localizedFile.Name): keys not found in Strings.resx: $($localizedOnlyKeys -join ', ')") + } + if ($englishMatches.Count -gt 0) { + $englishMatches.Sort([System.StringComparer]::Ordinal) + $failures.Add("$($localizedFile.Name): untranslated values match Strings.resx: $($englishMatches -join ', ')") + } + if ($staleAllowlistKeys.Count -gt 0) { + $staleAllowlistKeys.Sort([System.StringComparer]::Ordinal) + $failures.Add("$($localizedFile.Name): allowlist entries no longer match Strings.resx: $($staleAllowlistKeys -join ', ')") + } +} + +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { + Write-Host "##vso[task.logissue type=error]$failure" + } + $errorNoun = if ($failures.Count -eq 1) { 'error' } else { 'errors' } + throw "Localization validation failed with $($failures.Count) $errorNoun. Review the preceding errors." +} + +$fileNoun = if ($localizedFiles.Count -eq 1) { 'file' } else { 'files' } +Write-Host "Localization validation passed for $($localizedFiles.Count) localized $fileNoun. Resource keys checked: $($englishStrings.Count); approved English-value matches allowlisted: $allowedMatchCount." diff --git a/eng/pipelines/onebranch/steps/validate-localization-step.yml b/eng/pipelines/onebranch/steps/validate-localization-step.yml new file mode 100644 index 0000000000..8cb590f6d0 --- /dev/null +++ b/eng/pipelines/onebranch/steps/validate-localization-step.yml @@ -0,0 +1,16 @@ +################################################################################# +# Licensed to the .NET Foundation under one or more agreements. # +# The .NET Foundation licenses this file to you under the MIT license. # +# See the LICENSE file in the project root for more information. # +################################################################################# + +steps: + - task: PowerShell@2 + displayName: 'Validate localized resources' + inputs: + targetType: filePath + pwsh: true + filePath: $(Build.SourcesDirectory)/eng/pipelines/onebranch/scripts/validate-localization.ps1 + arguments: >- + -ResourcesDirectory "$(Build.SourcesDirectory)/src/Microsoft.Data.SqlClient/src/Resources" + -AllowlistPath "$(Build.SourcesDirectory)/.config/LocalizationValidationAllowlist.json"