Repository navigation
The localization guard only watched the side that never changes - #984
Conversation
The client-side catalog drift guard and both Blazor-parity ratchets read files under src/, but .github/workflows/clients.yml only fired on "clients/**". So the guards were blind to the exact side that moves: a server-only PR adds a key, the job never runs, CI is green, and the gap sits latent until an unrelated PR happens to touch the client tree and inherits a red it did not cause. Not hypothetical. c34bd01 and 18b3e2e each added localization keys and touched ZERO files under clients/. Both merged green, and the 5 orphan keys they left survived on main until this commit — the second recurrence within hours of PR #959 resyncing the previous 12. The trigger now lists every input these jobs actually read: the localization catalog, Locales.cs, BlazorViewRegistry.cs, and the canonical mesh.proto. That puts the cost back on the PR that creates it. Widening a hand-maintained list once fixes today's holes, so ciTrigger.test.ts scrapes the client tree for cross-tree reads and fails if the filter misses one (or if the pull_request and push lists drift — Actions cannot share them via a YAML anchor). Adding such a read is itself a clients/** change, so the guard that catches it is reached by definition. Fixes #964
There was a problem hiding this comment.
Pull request overview
This PR closes a CI blind spot where the “Clients” workflow only triggered on clients/** even though multiple client-side parity/drift tests intentionally read canonical inputs from src/. It also resyncs missing localization keys so the React client no longer renders raw localization keys for several user-visible error states.
Changes:
- Widen
.github/workflows/clients.ymlpaths:filters (for bothpull_requestandpush) to include server-side inputs that client parity/drift jobs read. - Add a “guard-the-guard” Vitest test (
clients/react/src/ciTrigger.test.ts) that scrapes cross-tree reads and asserts the workflow trigger covers them. - Sync 5 missing localization keys into
clients/react/src/i18n/strings.{en,de}.jsonand add a matching What’s New entry.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-09-client-strings-no-longer-lag-the-server.md |
Documents the localization parity/trigger fix and the user-visible string corrections. |
clients/react/src/i18n/strings.en.json |
Adds the 5 missing English localization strings used by the React client. |
clients/react/src/i18n/strings.de.json |
Adds the 5 missing German localization strings used by the React client. |
clients/react/src/ciTrigger.test.ts |
Adds a test that enforces the workflow trigger covers any src/ files the client packages read at runtime. |
.github/workflows/clients.yml |
Expands trigger paths to include server-side canonical inputs used by client parity/drift checks. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 0)685 tests 685 ✅ 5m 8s ⏱️ Results for commit 89f84da. ♻️ This comment has been updated with latest results. |
Test Results (shard 2)1 887 tests 1 885 ✅ 6m 1s ⏱️ Results for commit 89f84da. ♻️ This comment has been updated with latest results. |
Test Results (shard 3) 11 files 11 suites 5m 19s ⏱️ Results for commit 89f84da. ♻️ This comment has been updated with latest results. |
Test Results (shard 4)1 216 tests 1 208 ✅ 7m 19s ⏱️ Results for commit 89f84da. ♻️ This comment has been updated with latest results. |
Test Results (shard 5)1 717 tests 1 526 ✅ 6m 0s ⏱️ Results for commit 89f84da. ♻️ This comment has been updated with latest results. |
Test Results (shard 1)1 188 tests 1 188 ✅ 7m 11s ⏱️ Results for commit 89f84da. ♻️ This comment has been updated with latest results. |
Test Results 65 files 65 suites 37m 0s ⏱️ Results for commit 89f84da. ♻️ This comment has been updated with latest results. |
…X form statSync follows links, so the node_modules skip was by name only — an lstat + isSymbolicLink check makes the code match what the comment claimed and keeps the walk from leaving the repo. And resolve() yields \ on Windows, where the /-based startsWith would have matched nothing and reported every input as uncovered.
CI note: shard 1 is red on a leak guard this PR cannot have caused
Why not this PR: the diff is 5 files — a workflow YAML, two client JSON catalogues, a client Why it is pre-existing: What the guard actually found — it is not vacuous; ClrMD named the whole chain: A pending single-shot Filed separately so the chain is not lost when these logs expire — this PR should not be the place it gets fixed. |
… again The parity sweep this PR shipped was measured before #985 and #992 landed. Merging main re-opened the exact gap the PR exists to close: 21 keys authored server-side with no file under clients/ (about.updateStatus/upToDate/updateAvailable, menu.modelTiers*, and the aiCatalog.tab/search family), plus a value drift on about.intro where the server sentence grew a clause the client copy never got. Both catalogs are byte-identical to the server's again, which is what localize.test.ts asserts. A stale sync is worse than none — it reads as done. Once this PR's trigger is on main, this catch-up stops being a recurring chore: the workflow fires on src/MeshWeaver.Messaging.Hub/Localization/**, so the PR that authors the key is the one that reds.
Two corrections to the note. It claimed five error messages "showed up as raw codes in the React client". They did not. chat.modelRateLimited and its neighbours are resolved SERVER-side, by LocalizationCatalog.Get(key, accessContext.Locale, …) in ThreadExecution — off the server catalog, which always had them. The client's copy being stale could not have produced a raw code for those keys, because no client resolves them. The real defect is narrower and duller: the bundled copy was out of date. And "five" is already wrong — merging main took the gap to twenty-six. Any fixed number here is only true until the next server-side string lands, so the note now describes the mechanism, which stays true, instead of a count that does not. What is worth saying to a reader is the shape of the failure: the check that would have caught this was gated on the one tree that does not change when the fault is introduced.
#983 merged while this was open and re-synced the client catalogs itself, so origin/main already has them byte-identical to the server — my re-sync commit is now a no-op against main and the catalog files have dropped out of this PR's diff entirely. Both PRs converged on the same content, which is what you would expect: the correct client catalog is definitionally a verbatim copy of the server's, so there is only one right answer to converge on. What is left in this PR is its actual substance — the trigger and the meta-guard — so the note now describes that and stops taking credit for the copy being current. Kept the sharper fact instead: this regenerated three times. That is the argument for the trigger, and it is the part a reader should take away.
The client catalogs are verbatim copies of the server's, and I added five keys server- side without syncing them — plus four that arrived from main the same way. 840 keys against 849 is exactly the drift the guard exists to catch, and it caught mine. I had also mis-attributed this failure to main. It was not: #984 widened the Clients workflow triggers to include the server Localization folder precisely so that touching the catalog runs this check, instead of the drift piling up until it ambushed whoever next touched clients/. The workflow ran on this PR because the change is mine. Copied verbatim — a mirror has exactly one right answer, and the guard compares values as well as keys, in both locales. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The React client keeps a BYTE-IDENTICAL copy of the server catalog, asserted by
`localize.test.ts`'s "catalog drift guard" (key sets and every value). Adding
`about.lastDeployed` server-side only broke it:
AssertionError: expected [ 'about.buildCommit', …(966) ]
to deeply equal [ 'about.buildCommit', …(967) ]
- "about.lastDeployed"
Both values are taken FROM the server file rather than retyped, because the guard compares values
as well as keys — a hand-copied translation that drifted by a character would fail identically.
This is the guard #984 repaired so it actually fires on `src/**` changes rather than only on
`clients/**`. It caught this exactly as intended.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #964.
The defect
.github/workflows/clients.ymlfired only onclients/**. But several of the jobs it runs aredeliberately cross-tree — they read a file under
src/and assert the client copy matches it:src/MeshWeaver.Messaging.Hub/Localization/**clients/react/src/i18n/localize.test.ts— byte-identical catalog drift guardsrc/MeshWeaver.Messaging.Contract/Localization/Locales.csLocales.Supportedsrc/MeshWeaver.Blazor/BlazorViewRegistry.csclients/react/src/render/parity.test.ts+clients/react-native/src/parity.test.tssrc/MeshWeaver.Hosting.Grpc/Protos/**mesh.protothe python / grpc-web / typescript clients generate fromSo every one of those guards was blind to the side that actually moves. A server-only PR adds a key
(or a control, or a proto field), the job never runs, CI is green, and the gap sits latent until an
unrelated PR happens to touch the client tree and inherits a red it did not cause.
The mechanism, proven rather than inferred
The two commits that introduced the currently-unmatched keys touched ZERO files under
clients/:Both merged green. This is the second occurrence within hours of PR #959 resyncing the previous 12
plugins.discovery.*keys — the guard gap regenerated the same defect immediately, which is whatdistinguishes it from a one-off sync miss.
Remaining gaps the widened guard catches on current
main— 5 keysVerified by running the real test, not by construction.
npx vitest run src/i18n/localize.test.tson unmodified
mainfails 2 of its tests (enandde), naming 5 keys present server-side with noclient counterpart:
chat.modelProviderErrorchat.modelRateLimitedchat.modelSubstitutionNoteerror.identityUnavailableerror.identityUnavailableHintFixed here — all five copied verbatim into
clients/react/src/i18n/strings.{en,de}.json, in theserver's key order, values byte-identical.
Do the other ratchets share the blind spot?
Checked each rather than assuming:
clients/react/src/render/parity.test.tsandclients/react-native/src/parity.test.ts—yes, same blind spot. Both scrape
src/MeshWeaver.Blazor/BlazorViewRegistry.csyet werereachable only through a
clients/**-filtered job. Both happen to pass onmaintoday(react: green; RN: 9/9 green), so there is no latent control gap right now — but that is luck, not
guarding:
993282677modifiedBlazorViewRegistry.cswith 0 files underclients/. Fixed by thesame trigger widening.
test/MeshWeaver.Maui.Abstractions.Test/MauiControlCoverageTest.cs— the third parity ratchet.Does NOT share the flaw, and is deliberately left alone. It is a .NET test in
dotnet-test.yml,which has no
paths:filter at all, and it reflects overUiControlsubclasses rather thanscraping a file. It already runs unconditionally on every PR.
How this relates to the server-side
LocalizationTestThey guard different invariants and cannot disagree:
LocalizationTest(test/MeshWeaver.Messaging.Hub.Test/) is intra-catalogue: every English keyhas a
detranslation, no orphandekeys, plural pairs complete. It never looks at the clients.It runs on every PR via
dotnet-test.yml.localize.test.tsis cross-tree: the client copy is byte-identical to the server catalogue.So the server test says "the server catalogue is internally complete" and the client test says "the
client copy equals the server catalogue". Neither constrains the other's subject; widening the
trigger adds no second opinion about what "in sync" means. Notably
LocalizationTestwas greenthroughout — it is not the mechanism that could have caught these 5 keys, which is precisely why
the client-side guard needed to run.
Preventing recurrence
Widening a hand-maintained list fixes today's holes and nothing more — the list duplicates knowledge
that lives in the test files, so it will drift again.
clients/react/src/ciTrigger.test.tsclosesthat loop: it walks every client package for cross-tree reads (resolving each reference against both
the file's directory and the package root, keeping only those that land on a real file under
src/)and fails if the workflow's
paths:filter does not cover one. It also asserts thepull_requestand
pushlists stay identical, since the Actions parser cannot share them through a YAML anchor.Adding a new cross-tree read is itself a change under
clients/**, so the job that catches it isreached by definition — the guard-the-guard has no blind spot of its own.
Verification
src/MeshWeaver.Blazor/BlazorViewRegistry.csfrom thepull_requestlist makes
ciTrigger.test.tsfail with both assertions, naming the exact missing path. Restoredand re-verified green.
clients/react:npm run typecheckclean,npm test34 files / 253 tests passed (after themerge with
origin/main).clients/react-native:npx vitest run src/parity.test.ts9/9 passed.origin/main: server 797 / client 797, no missing, no extra, novalue drift, both languages. (Superseded — main moved again; see "Post-merge re-verification".)
paths:lists identical; all 8 jobs intact..cs/.csprojtouched, so nodotnet build -c Release -warnaserrorwas applicable.Note on the committed
node_modulessymlink — RESOLVED by #983clients/react/node_modulesandclients/react-native/node_moduleswere committed symlinks toabsolute paths (
/Users/roland/code/MeshWeaver/..., added in69edd029d). They dangled anywherebut that one machine — and on that machine they were self-referential: the target path is the
same committed symlink, so resolving it gave
Too many levels of symbolic links. On CI the targetsimply did not exist and
npm installreplaced the dead link, which is why it never went red.#983 has since merged and deletes both (
delete mode 120000 clients/react/node_modules) — theright home for it, since that PR is the
/Users/roland/...scrub. Nothing left to do here.The catalogs themselves are NOT symlinks, and that is the whole point of the guard. Worth
stating explicitly, because "copy" vs "link" changes what drift even means here:
strings.{en,de}.jsonare mode100644regular files — genuine byte copies of the server catalog,duplicated so the clients can resolve text synchronously without a round trip. Nothing keeps them in
step automatically. If they were links, drift would be impossible and
localize.test.tswould bepinning nothing; because they are copies, drift is not only possible but is what happened, twice.
The only symlink in the area is
node_modules, which is dependency-install state and unrelated tothe catalogs.
Post-merge re-verification (
20b9f1981)The parity figures above were measured before
origin/mainmoved. Re-checking rather thanassuming was the right call: merging main re-opened the very gap this PR exists to close.
#985and#992landed 21 further server-side keys with no file underclients/, plus a rewordedabout.introthe copy never received — i.e. the defect regenerated a third time, while this PRsat open, which is the strongest possible argument for the trigger change:
origin/main20b9f1981)about.intro)Re-synced in
622e72e92; the client catalogs are byte-identical to the server's again (diffonboth files is empty, both languages). The 21:
about.updateStatus/upToDate/updateAvailable,menu.modelTiers+menu.modelTiersTooltip, and the 16-keyaiCatalog.tab.*/aiCatalog.search.*family. Both languages complete — no key ships English-only.
Guards re-proven by mutation at this HEAD, not assumed:
src/MeshWeaver.Messaging.Hub/Localization/**from bothpaths:listsciTrigger.test.ts✗ — names the exact uncovered pathsrc/MeshWeaver.Blazor/BlazorViewRegistry.csfrom both listsciTrigger.test.ts✗ — names the exact uncovered pathciTrigger.test.ts✗ — "keeps the pull_request and push filters identical"menu.modelTiersfrom the clientencataloglocalize.test.ts✗ — 817 vs 818 keysAll restored; both files green afterwards (19/19). The meta-guard is pinning something real.
Trigger ownership vs #983 — #983 has dropped its incidental
clients.ymledit (its file list nolonger contains the workflow), so #984 owns it uniquely. Verified the result is coherent, not
duplicated: exactly two
paths:blocks, each with the identical 6 entries, no repeats. #983 stilledits the same two catalog files, so whichever lands second will conflict there — the resolution is
mechanical, since the server catalog is the single source of truth and the correct client content is
a verbatim copy of it.
Full local run at this HEAD:
clients/reactnpm test34 files / 253 tests green,npm run typecheckclean,npm run buildclean;LocalizationTest33/33;DocumentationLinkIntegrityTest1/1;
MeshWeaver.Documentationbuilds clean under-c Release -warnaserror(0 warnings, 0 errors).After merging #983 — the catalog change is gone, and that is the correct outcome
#983 merged mid-review and re-synced the client catalogs itself (
54c0ffe75, "The client stringcatalogs had fallen 8 keys behind the server").
origin/maintherefore already has thembyte-identical to the server, so the merge dropped the catalog files out of this PR's diff:
Both PRs converged on identical content without a conflict, which is what you would expect: the
correct client catalog is definitionally a verbatim copy of the server's, so there is exactly one
right answer to converge on. Re-verified post-merge: 818 / 818 keys, both languages, zero missing,
zero extra, zero value drift, and
diffagainst the server catalog is empty for both files (so noduplicated keys or reordering slipped in —
JSON.parsewould have silently hidden either). Reactsuite re-run on the merged tree: 34 files / 253 tests green.
What remains is exactly this PR's own substance — the trigger widening, the meta-guard, and the
note. The What's New was updated to stop claiming the sync.
Trigger ownership confirmed after the merge: #983's incidental workflow edit (
77f2955ec) isNOT in
origin/main'sclients.yml— it was dropped before merge, as coordinated. #984 owns thetrigger uniquely, and the merged result is coherent: exactly two
paths:blocks, six identicalentries, no duplication.
🤖 Generated with Claude Code
Counterfactual: replaying the three commits that slipped through
Applying both filters to each commit's actual file list — the direct answer to "would the widened
guard have caught this?":
c34bd01e6chat.model*keys (8 files).../Localization/strings.{en,de}.json18b3e2ee3error.identityUnavailable*keys (8 files).../Localization/strings.{en,de}.json993282677BlazorViewRegistry.cs(11 files)src/MeshWeaver.Blazor/BlazorViewRegistry.csAll three are server-only diffs with zero files under
clients/. Under the old filter none of themran the guard; under the new one all three do — on the PR that created the change.