Skip to content

fix: Resolve localized strings from the shipped file on every host - #187

Merged
tablackburn merged 3 commits into
mainfrom
bugfix/185-localized-string-fallback-drift
Aug 27, 2026
Merged

fix: Resolve localized strings from the shipped file on every host#187
tablackburn merged 3 commits into
mainfrom
bugfix/185-localized-string-fallback-drift

Conversation

@tablackburn

Copy link
Copy Markdown
Contributor

Summary

  • Deletes the hand-written copy of every user-facing string that PowerShellBuild.psm1 kept in an
    inline data LocalizedData { ConvertFrom-StringData ... } block, leaving en-US/Messages.psd1
    as the only place a string is written.
  • Asks Import-LocalizedData for en-US by name when the UI-culture lookup binds nothing, with
    -ErrorAction Stop on that fallback so a module genuinely missing its strings fails at import
    instead of blanking every message.
  • Adds tests/LocalizedData.tests.ps1, which pins both the single source of truth and the
    behavior under a non-English UI culture on each supported host.

Closes #185.

The copy was reachable, not dead

Import-LocalizedData runs with -ErrorAction SilentlyContinue, so a lookup that resolves nothing
binds the inline copy and says nothing about it. The two supported hosts do not agree about when
that lookup misses. Probe module with en-US/Messages.psd1 (Source=en-US-file) and
de-DE/Messages.psd1 (Source=de-DE-file) beside a script that seeds $LocalizedData with
Source=inline-fallback and then runs the same splat the module used:

pwsh 7.5                               powershell.exe 5.1.26100.9168
  en-US -> en-US-file                    en-US -> en-US-file
  de-DE -> de-DE-file                    de-DE -> de-DE-file
  de-AT -> en-US-file                    de-AT -> inline-fallback
  fr-FR -> en-US-file                    fr-FR -> inline-fallback
  ja-JP -> en-US-file                    ja-JP -> inline-fallback

PowerShell 7 has a final en-US fallback: hide the en-US directory and fr-FR lands on
inline-fallback there too. Windows PowerShell 5.1 has no such fallback -- it walks the culture's
own parent chain and stops.

Against the real module on Windows PowerShell 5.1:

en-US: Keys=41 HasModuleLandingPageNotFound=True  requires Pester 6.0.0 or newer.
fr-FR: Keys=25 HasModuleLandingPageNotFound=False requires Pester 5.0.0 or newer.
ja-JP: Keys=25 HasModuleLandingPageNotFound=False requires Pester 5.0.0 or newer.

Sixteen of forty-one strings missing, and PesterVersionNotSupported still naming the pre-#182
floor. PowerShellVersion is '5.1' with CompatiblePSEditions = @('Desktop', 'Core'), and
non-English Windows installations are common, so this is reachable rather than theoretical.

One correction to the original report: a missing key does not throw. $null -f $arguments returns
an empty string on both hosts. The symptom was quieter and harder to diagnose -- a blank
WARNING: line from Build-PSBuildUpdatableHelp, and a bare ScriptHalted where
throw $LocalizedData.NoCertificateFound in IB.tasks.ps1 meant to explain that no signing
certificate was found.

en-US/Messages.psd1 itself was never the problem: the root Build task copies the whole source
tree, and Output/PowerShellBuild/0.8.2/en-US/Messages.psd1 is present after a build. Only the
lookup missed.

Why delete rather than resynchronize

Syncing the inline block and adding a text-comparison test would leave the duplication in place,
and a duplicate that a test keeps honest still has to be edited twice for every new string. The
comment it was written under -- "in case Import-LocalizedData is not available" -- guards against
nothing: Import-LocalizedData is in Microsoft.PowerShell.Utility and has shipped since
PowerShell 3.0, well below this module's 5.1 floor.

Deleting it alone would have left Windows PowerShell 5.1 on a non-English machine with no strings
at all, so the explicit en-US fallback replaces it. That fallback carries -ErrorAction Stop,
which is the "fail loudly" half: a module whose strings really are missing now fails at import
rather than emitting empty messages for the rest of the build.

Red before green

Invoke-Pester -Path ./tests/LocalizedData.tests.ps1 against the unfixed module:

Describing Localized string resolution
  [-] defines its user-facing strings in exactly one place
 Context Imported by pwsh.exe under a non-English UI culture
   [+] reports a string table from the probe
   [+] binds every string the module ships
   [+] binds each string to its shipped text
 Context Imported by powershell.exe under a non-English UI culture
   [+] reports a string table from the probe
   [-] binds every string the module ships
       Expected a collection ... with length 41, but got a collection ... with length 25.
   [-] binds each string to its shipped text
       Expected 'The resolved certificate has expired (NotAfter: {0}). ...',
       because [CertificateExpired] must match en-US/Messages.psd1, but got $null.
Tests Passed: 4, Failed: 3

After the fix, same command:

Describing Localized string resolution
  [+] defines its user-facing strings in exactly one place
 Context Imported by pwsh.exe under a non-English UI culture
   [+] reports a string table from the probe
   [+] binds every string the module ships
   [+] binds each string to its shipped text
 Context Imported by powershell.exe under a non-English UI culture
   [+] reports a string table from the probe
   [+] binds every string the module ships
   [+] binds each string to its shipped text
Tests Passed: 7, Failed: 0

The PowerShell 7 leg passes either way, which is exactly why the Windows PowerShell leg is there.
The drift guard is the part that fails on every platform, so a future inline copy is caught on
Linux and macOS runners too.

Test Plan

  • ./build.ps1 -Task Test on Windows: 490 passed, 0 failed, 3 skipped (483/0/3 on main,
    plus the 7 new tests; the Windows PowerShell leg contributes 3 of them and is absent on
    Linux and macOS, where the file contributes 4).
  • Analyze clean against the built module.
  • The probe imports the built .psm1 rather than the manifest, so it does not depend on
    RequiredModules resolving against a child process's inherited PSModulePath -- which is
    what a Windows PowerShell child spawned from PowerShell 7 gets, and has nothing to do with
    which strings were bound.

Breaking Changes

None. Consumers on an English UI culture see no change; consumers on a non-English Windows
PowerShell 5.1 host stop getting stale and missing messages.

🤖 Generated with Claude Code

https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE

PowerShellBuild.psm1 carried a hand-written second copy of every user-facing
string in an inline data block, introduced with the comment "Load here in case
Import-LocalizedData is not available". The copy had drifted sixteen strings
behind en-US/Messages.psd1 -- every certificate and signing message was absent,
along with HelpInfoUriRequired, ModuleLandingPageNotFound and
ScriptAnalyzerRuleErrorRetry, and PesterVersionNotSupported still named the
pre-#182 floor of 5.0.0.

The copy was reachable, not dead. Import-LocalizedData runs with
-ErrorAction SilentlyContinue, so a lookup that resolves nothing binds the
inline copy silently. Probing a module with en-US and de-DE string files from
both supported hosts shows they do not agree:

  pwsh 7.5                       powershell.exe 5.1.26100.9168
    de-AT -> en-US file            de-AT -> inline copy
    fr-FR -> en-US file            fr-FR -> inline copy
    ja-JP -> en-US file            ja-JP -> inline copy

PowerShell 7 has a final en-US fallback -- hiding the en-US directory sends
fr-FR to the inline copy there too -- and Windows PowerShell 5.1 has none.
Against the real module on 5.1 under fr-FR, $LocalizedData held 25 of the 41
shipped strings and reported a Pester floor of 5.0.0. Windows PowerShell 5.1 is
a supported host and non-English Windows installations are common.

A missing key does not throw: $null -f $arguments returns an empty string. So
the symptom was Build-PSBuildUpdatableHelp emitting blank WARNING lines and the
signing tasks raising a bare ScriptHalted instead of saying no certificate was
found.

The inline copy is deleted rather than resynchronized, because a hand-maintained
duplicate will drift again, and the comment it was written under guards against
nothing: Import-LocalizedData ships in Microsoft.PowerShell.Utility and has
since PowerShell 3.0. en-US is now requested by name when the culture lookup
binds nothing, with -ErrorAction Stop on that fallback so a module genuinely
missing its strings fails at import instead of blanking every message.

tests/LocalizedData.tests.ps1 pins both halves. The drift guard asserts the
module file defines no string table of its own, and a child-process probe per
supported host asserts that importing under a fr-FR UI culture binds every
shipped string, key for key. Against the unfixed module the drift guard failed
and the Windows PowerShell leg reported 25 keys against 41 expected; the
PowerShell 7 leg passed either way, which is exactly why the 5.1 leg is there.

Closes #185

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE
Copilot AI lite review requested due to automatic review settings August 27, 2026 18:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

Test Results

    4 files  ± 0    769 suites  +9   2m 35s ⏱️ +16s
  496 tests +11    493 ✅ +11   3 💤 ±0  0 ❌ ±0 
1 967 runs  +23  1 900 ✅ +23  67 💤 ±0  0 ❌ ±0 

Results for commit 0d3a66a. ± Comparison against base commit a64ebc8.

♻️ This comment has been updated with latest results.

…PowerShell

Select-Object -Unique compares case-sensitively, so the CI leg that runs the
suite under Windows PowerShell 5.1 probed powershell.EXE and powershell.exe as
two hosts -- the same executable spelled two ways by the process path and the
PATH lookup. Sort-Object -Unique collapses them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE
Publish-PSBuildModule validates -Path against $LocalizedData.PathDoesNotExist
in a ValidateScript on a mandatory parameter. en-US/Messages.psd1 never
defined that key, so the lookup returned $null, $null -f $argument is an
empty string rather than an error, and passing a path that does not exist
failed validation with no text at all -- while the very next check in the
same script block reported itself properly.

Unlike the drift this branch already fixes, that one was not confined to
Windows PowerShell on a non-English machine. A key the file never defines
is missing on every host and every culture.

The new assertion is the other half of the promise this branch makes. The
existing guard keeps a second copy of the strings from existing; this one
keeps the single copy complete, by scanning the module source for every
$LocalizedData reference and requiring Messages.psd1 to define it. The
sweep found exactly one missing key and no unused ones, and it is worth
having standing: #194 is adding a new string right now.

Caught in review of #187.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U1Jhu7fgTRJq7LK5MuKteE
@tablackburn
tablackburn merged commit 64862e3 into main Aug 27, 2026
9 checks passed
@tablackburn
tablackburn deleted the bugfix/185-localized-string-fallback-drift branch August 27, 2026 22:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: Stale inline localized-string fallback shadows Messages.psd1 on Windows PowerShell 5.1

2 participants