Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions .config/LocalizationValidationAllowlist.json
Original file line number Diff line number Diff line change
@@ -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",
Comment thread
cheenamalhotra marked this conversation as resolved.
Comment thread
cheenamalhotra marked this conversation as resolved.
"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"
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions eng/pipelines/onebranch/jobs/build-buildproj-job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cheenamalhotra marked this conversation as resolved.
Comment thread
cheenamalhotra marked this conversation as resolved.

- ${{ 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
Expand Down
1 change: 1 addition & 0 deletions eng/pipelines/onebranch/scripts/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <data> 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*'
}
}
Loading
Loading