Files
Sharepoint-Toolbox/.planning/phases/05-distribution-and-hardening/05-RESEARCH.md
Dev af2177046f docs(phase-05): research distribution and hardening phase
Verified single-file publish works with IncludeNativeLibrariesForSelfExtract,
documented 25+ FR diacritic gaps, and mapped retry/pagination test coverage gaps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 14:27:51 +02:00

26 KiB

Phase 5: Distribution and Hardening - Research

Researched: 2026-04-03 Domain: .NET 10 WPF single-file publishing, localization completeness, reliability verification Confidence: HIGH


<phase_requirements>

Phase Requirements

ID Description Research Support
FOUND-11 Self-contained single EXE distribution — no .NET runtime dependency for end users dotnet publish -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true produces single EXE
</phase_requirements>

Summary

Phase 5 delivers the final shipping artifact: a self-contained Windows EXE that runs without any pre-installed .NET runtime, a verified reliable behavior against SharePoint's 5,000-item threshold and throttling, and a complete French locale with proper diacritics.

The core publish mechanism is already proven to work: dotnet publish -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true was tested against the current codebase and produces exactly two output files — SharepointToolbox.exe (~201 MB, self-extracting) and SharepointToolbox.pdb. The EXE bundles all managed assemblies and the WPF native runtime DLLs (D3DCompiler, PresentationNative, wpfgfx, etc.) by extraction into %TEMP%/.net on first run. The msalruntime.dll WAM broker is also bundled. No additional flags beyond PublishSingleFile + IncludeNativeLibrariesForSelfExtract are needed.

The French locale has a documented quality gap: approximately 25 Phase 4 strings in Strings.fr.resx are missing required French diacritics (e.g., "Bibliotheque" instead of "Bibliothèque", "Creer" instead of "Créer", "echoue" instead of "échoué"). These strings display in-app when the user switches to French — they are not missing keys but incorrect values. All 177 keys exist in both EN and FR files (parity is 100%), so the task is correction not addition.

The retry helper (ExecuteQueryRetryHelper) and pagination helper (SharePointPaginationHelper) are implemented but have zero unit tests. The throttling behavior and 5,000-item pagination are covered only by live CSOM context tests marked Skip. Phase 5 must add tests that exercise these code paths without live SharePoint — using a fake that throws a 429-like exception for retry, and an in-memory list of items for pagination.

Primary recommendation: Implement as three parallel workstreams — (1) add PublishSingleFile + IncludeNativeLibrariesForSelfExtract to csproj and create a publish profile, (2) fix the 25 diacritic-missing FR strings and add a locale completeness test, (3) add unit tests for ExecuteQueryRetryHelper and SharePointPaginationHelper.


Standard Stack

Core

Library Version Purpose Why Standard
.NET SDK publish CLI 10.0.200 Self-contained single-file packaging Built-in, no extra tool
xUnit 2.9.3 Unit tests for retry/pagination helpers Already in test project
Moq 4.20.72 Mock ClientContext for retry test isolation Already in test project

No New Dependencies

Phase 5 adds zero NuGet packages. All capability is already in the SDK and test infrastructure.

Publish command (verified working):

dotnet publish SharepointToolbox/SharepointToolbox.csproj \
  -c Release \
  -r win-x64 \
  --self-contained true \
  -p:PublishSingleFile=true \
  -p:IncludeNativeLibrariesForSelfExtract=true \
  -o ./publish

Output (verified):

  • SharepointToolbox.exe — ~201 MB self-contained EXE
  • SharepointToolbox.pdb — symbols (can be excluded in release by setting <DebugType>none</DebugType>)

Architecture Patterns

SharepointToolbox/
├── SharepointToolbox.csproj   # Add publish properties
└── Properties/
    └── PublishProfiles/
        └── win-x64.pubxml     # Reusable publish profile (optional but clean)
SharepointToolbox.Tests/
└── Services/
    ├── ExecuteQueryRetryHelperTests.cs   # NEW — retry/throttling tests
    └── SharePointPaginationHelperTests.cs # NEW — pagination tests

Pattern 1: Self-Contained Single-File Publish Configuration

What: Set publish properties in .csproj so dotnet publish requires no extra flags When to use: Preferred over CLI-only flags — reproducible builds, CI compatibility

<!-- Source: https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview -->
<PropertyGroup>
  <PublishSingleFile>true</PublishSingleFile>
  <SelfContained>true</SelfContained>
  <RuntimeIdentifier>win-x64</RuntimeIdentifier>
  <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
  <!-- Optional: embed PDB into EXE to keep publish dir clean -->
  <!-- <DebugType>embedded</DebugType> -->
</PropertyGroup>

Important: PublishSingleFile must NOT be combined with PublishTrimmed=true. The project already has <PublishTrimmed>false</PublishTrimmed> — this is correct and must remain false (PnP.Framework and MSAL use reflection).

Pattern 2: Testing ExecuteQueryRetryHelper Without Live CSOM

What: Verify retry behavior by injecting a fake exception into ClientContext.ExecuteQueryAsync When to use: ClientContext cannot be instantiated without a live SharePoint URL

The existing ExecuteQueryRetryHelper.ExecuteQueryRetryAsync takes a ClientContext parameter — it cannot be directly unit tested without either (a) a live context or (b) an abstraction layer. The cleanest approach without redesigning the helper is to extract an ISharePointExecutor interface that wraps ctx.ExecuteQueryAsync() and inject a fake that throws then succeeds.

// Proposed thin abstraction — no CSOM dependency in tests
public interface ISharePointExecutor
{
    Task ExecuteAsync(CancellationToken ct = default);
}

// Production adapter
public class ClientContextExecutor : ISharePointExecutor
{
    private readonly ClientContext _ctx;
    public ClientContextExecutor(ClientContext ctx) => _ctx = ctx;
    public Task ExecuteAsync(CancellationToken ct) => _ctx.ExecuteQueryAsync();
}

Then ExecuteQueryRetryHelper.ExecuteQueryRetryAsync gains an overload accepting ISharePointExecutor — the ClientContext overload becomes a convenience wrapper.

Alternatively (simpler, avoids interface): test IsThrottleException directly (it is private static — make it internal static + InternalsVisibleTo the test project), and test the retry loop indirectly via a stub subclass approach.

Simplest path requiring minimal refactoring: Change IsThrottleException to internal static and add InternalsVisibleTo (established project pattern from Phase 2 DeriveAdminUrl). Test the exception classification directly. For retry loop coverage, add an integration test that constructs a real exception with "429" in the message.

Pattern 3: Testing SharePointPaginationHelper Without Live CSOM

What: The GetAllItemsAsync method uses ctx.ExecuteQueryAsync() internally — cannot be unit tested without a live context When to use: Pagination logic must be verified without a live tenant

The pagination helper's core correctness is in BuildPagedViewXml (private static). The approach:

  1. Make BuildPagedViewXml internal static (parallel to DeriveAdminUrl precedent)
  2. Unit test the XML injection logic directly
  3. Mark the live pagination test as Skip with a clear comment explaining the 5,000-item guarantee comes from the ListItemCollectionPosition loop

For the success criterion "scan against a library with more than 5,000 items returns complete, correct results", the verification is a manual smoke test against a real tenant — it cannot be automated without a live SharePoint environment.

Pattern 4: Locale Completeness Automated Test

What: Assert that every key in Strings.resx has a non-empty, non-bracketed value in Strings.fr.resx When to use: Prevents future regressions where new EN keys are added without FR equivalents

// Source: .NET ResourceManager pattern — verified working in test suite
[Fact]
public void AllEnKeys_HaveNonEmptyFrTranslation()
{
    var enManager = new ResourceManager("SharepointToolbox.Localization.Strings",
        typeof(Strings).Assembly);
    var frCulture = new CultureInfo("fr-FR");

    // Get all EN keys by switching to invariant and enumerating
    var resourceSet = enManager.GetResourceSet(CultureInfo.InvariantCulture, true, true);
    foreach (DictionaryEntry entry in resourceSet!)
    {
        var key = (string)entry.Key;
        var frValue = enManager.GetString(key, frCulture);
        Assert.False(string.IsNullOrWhiteSpace(frValue),
            $"Key '{key}' has no French translation.");
        Assert.DoesNotContain("[", frValue!,
            $"Key '{key}' returns bracketed fallback in French.");
    }
}

Note: The existing TranslationSourceTests.Indexer_ReturnsFrOrFallback_AfterSwitchToFrFR only checks one key (app.title). This new test checks all 177 keys exhaustively.

Anti-Patterns to Avoid

  • Adding PublishTrimmed=true: PnP.Framework and MSAL both use reflection; trimming will silently break authentication and CSOM calls at runtime. The project already has <PublishTrimmed>false</PublishTrimmed> — keep it.
  • Framework-dependent publish: --self-contained false requires the target machine to have .NET 10 installed. FOUND-11 requires no runtime dependency.
  • Omitting IncludeNativeLibrariesForSelfExtract=true: Without this flag, 6 WPF native DLLs (~8 MB total) land alongside the EXE, violating the "single file" contract. The publish is otherwise identical.
  • Using win-x86 RID: The app targets 64-bit Windows. Using win-x86 would build but produce a 32-bit EXE that cannot use more than 4 GB RAM during large library scans.
  • Correcting FR strings via code string concatenation: Fix diacritics in the .resx XML file directly. Do not work around them in C# code. The ResourceManager handles UTF-8 correctly.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Self-contained EXE packaging Custom bundler / NSIS installer dotnet publish --self-contained -p:PublishSingleFile=true SDK-native, zero extra tooling
Native library bundling Script to ZIP DLLs IncludeNativeLibrariesForSelfExtract=true SDK handles extraction to %TEMP%/.net
Key enumeration for locale completeness Parsing resx XML manually ResourceManager.GetResourceSet(InvariantCulture, true, true) Returns all keys as DictionaryEntry
Retry/backoff logic Custom retry loops per call site ExecuteQueryRetryHelper.ExecuteQueryRetryAsync (already exists) Already implemented with exponential back-off
Pagination loop Custom CAML page iteration per feature SharePointPaginationHelper.GetAllItemsAsync (already exists) Already handles ListItemCollectionPosition

Key insight: Every mechanism needed for Phase 5 already exists in the codebase. This phase is verification + correction + packaging, not feature construction.


Common Pitfalls

Pitfall 1: WPF Native DLLs Left Outside the Bundle

What goes wrong: Running dotnet publish -p:PublishSingleFile=true without IncludeNativeLibrariesForSelfExtract=true leaves D3DCompiler_47_cor3.dll, PenImc_cor3.dll, PresentationNative_cor3.dll, vcruntime140_cor3.dll, and wpfgfx_cor3.dll as loose files next to the EXE. Why it happens: By design — the SDK bundles managed DLLs but not native runtime DLLs by default. How to avoid: Always set <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> in the csproj or publish command. Warning signs: Publish output folder contains any .dll files besides msalruntime.dll (WAM broker — bundled with IncludeNativeLibraries).

Verified: In test publish without the flag: 6 loose DLLs. With the flag: zero loose DLLs (only EXE + PDB).

Pitfall 2: First-Run Extraction Delay and Antivirus False Positives

What goes wrong: On the first run on a clean machine, IncludeNativeLibrariesForSelfExtract causes the EXE to extract ~8 MB of native DLLs into %TEMP%\.net\<hash>\. This can trigger antivirus software and cause a 2-5 second startup delay. Why it happens: The single-file extraction mechanism writes files to disk before the CLR can load them. How to avoid: This is expected behavior. No mitigation needed for this tool. If needed: DOTNET_BUNDLE_EXTRACT_BASE_DIR environment variable can redirect extraction. Warning signs: App appears to hang for 5+ seconds on first launch on a clean machine — this is the extraction, not a bug.

Pitfall 3: PublishTrimmed=true Silent Runtime Failures

What goes wrong: PnP.Framework and MSAL use reflection to resolve types at runtime. Trimming removes types the linker cannot statically trace, causing FileNotFoundException or MissingMethodException at runtime on a clean machine — not during publish. Why it happens: Trimming is an optimization that removes dead code but cannot analyze reflection-based type loading. How to avoid: The project already has <PublishTrimmed>false</PublishTrimmed>. Never change this. Warning signs: App works in Debug but crashes on clean machine after trimmed publish.

Pitfall 4: French Strings Display as Unaccented Text

What goes wrong: Phase 4 introduced ~25 FR strings with missing diacritics. When the user switches to French, strings like "Bibliothèque source" display as "Bibliotheque source". This is not a fallback — the wrong French text IS returned from the ResourceManager. Why it happens: The strings were written without diacritics during Phase 4 localization work. How to avoid: Fix each affected string in Strings.fr.resx and add the exhaustive locale completeness test. Warning signs: Any FR string containing e where é, è, ê is expected. Full list documented in Code Examples section.

Pitfall 5: Retry Helper Has No Unit Test Coverage

What goes wrong: If IsThrottleException has a bug (wrong string detection), throttled requests will fail without retry. This is invisible without a test. Why it happens: The helper was implemented in Phase 1 but no retry-specific test was created (test scaffolds focused on services, not helpers). How to avoid: Add ExecuteQueryRetryHelperTests.cs with exception classification tests and a live-stub retry loop test.

Pitfall 6: Assembly.GetExecutingAssembly().Location Returns Empty String in Single-File

What goes wrong: If any code uses Assembly.Location to build a file path, it returns "" in a single-file EXE. Why it happens: Official .NET single-file limitation. How to avoid: Code audit confirmed — the codebase uses GetManifestResourceStream (compatible) and AppContext.BaseDirectory (compatible). No Assembly.Location usage found. Warning signs: Any path construction using Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location).


Code Examples

Single-File Publish csproj Configuration

<!-- Source: https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview -->
<!-- Add to SharepointToolbox.csproj PropertyGroup — verified working 2026-04-03 -->
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>

Verified Publish Command

# Run from SharepointToolbox/ project directory
# Produces: ./publish/SharepointToolbox.exe (~201 MB) + SharepointToolbox.pdb
dotnet publish -c Release -r win-x64 --self-contained true \
  -p:PublishSingleFile=true \
  -p:IncludeNativeLibrariesForSelfExtract=true \
  -o ./publish

FR Strings Requiring Diacritic Correction

The following strings in Strings.fr.resx have incorrect values (missing accents). Each line shows: key → current wrong value → correct French value:

transfer.sourcelibrary  "Bibliotheque source"        → "Bibliothèque source"
transfer.destlibrary    "Bibliotheque destination"   → "Bibliothèque destination"
transfer.mode.move      "Deplacer"                   → "Déplacer"
transfer.conflict.overwrite "Ecraser"               → "Écraser"
transfer.start          "Demarrer le transfert"      → "Démarrer le transfert"
transfer.nofiles        "Aucun fichier a transferer" → "Aucun fichier à transférer."
bulkmembers.preview     "Apercu (...)"               → "Aperçu (...)"
bulksites.execute       "Creer les sites"            → "Créer les sites"
bulksites.preview       "Apercu (...)"               → "Aperçu (...)"
bulksites.owners        "Proprietaires"              → "Propriétaires"
folderstruct.execute    "Creer les dossiers"         → "Créer les dossiers"
folderstruct.preview    "Apercu ({0} dossiers a creer)" → "Aperçu ({0} dossiers à créer)"
folderstruct.library    "Bibliotheque cible"         → "Bibliothèque cible"
templates.list          "Modeles enregistres"        → "Modèles enregistrés"
templates.opt.libraries "Bibliotheques"              → "Bibliothèques"
templates.opt.folders   (ok: "Dossiers")
templates.opt.permissions (ok: "Groupes de permissions")
bulk.confirm.proceed    "Continuer"                  → OK (no diacritic needed)
bulk.result.success     "Termine : {0} reussis, {1} echoues" → "Terminé : {0} réussis, {1} échoués"
bulk.result.allfailed   "Les {0} elements ont echoue." → "Les {0} éléments ont échoué."
bulk.result.allsuccess  "Les {0} elements ont ete traites avec succes." → "Les {0} éléments ont été traités avec succès."
bulk.exportfailed       "Exporter les elements echoues" → "Exporter les éléments échoués"
bulk.retryfailed        "Reessayer les echecs"       → "Réessayer les échecs"
bulk.validation.invalid "{0} lignes contiennent des erreurs. Corrigez et reimportez." → "...réimportez."
bulk.csvimport.title    "Selectionner un fichier CSV" → "Sélectionner un fichier CSV"
folderbrowser.title     "Selectionner un dossier"    → "Sélectionner un dossier"
folderbrowser.select    "Selectionner"               → "Sélectionner"

Note on templates.* keys: Several templates keys also lack accents: templates.capture "Capturer un modele""Capturer un modèle", templates.apply "Appliquer le modele""Appliquer le modèle", templates.name "Nom du modele""Nom du modèle", etc.

ExecuteQueryRetryHelper — IsThrottleException Unit Test Pattern

// Source: established project test pattern (InternalsVisibleTo + internal static)
// Make IsThrottleException internal static in ExecuteQueryRetryHelper
[Theory]
[InlineData("The request has been throttled — 429")]
[InlineData("Service unavailable 503")]
[InlineData("SharePoint has throttled your request")]
public void IsThrottleException_ReturnsTrueForThrottleMessages(string message)
{
    var ex = new Exception(message);
    Assert.True(ExecuteQueryRetryHelper.IsThrottleException(ex));
}

[Fact]
public void IsThrottleException_ReturnsFalseForNonThrottleException()
{
    var ex = new Exception("File not found");
    Assert.False(ExecuteQueryRetryHelper.IsThrottleException(ex));
}

SharePointPaginationHelper — BuildPagedViewXml Unit Test Pattern

// Make BuildPagedViewXml internal static
[Fact]
public void BuildPagedViewXml_EmptyInput_ReturnsViewWithRowLimit()
{
    var result = SharePointPaginationHelper.BuildPagedViewXml(null, rowLimit: 2000);
    Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
}

[Fact]
public void BuildPagedViewXml_ExistingRowLimit_Replaces()
{
    var input = "<View><RowLimit>100</RowLimit></View>";
    var result = SharePointPaginationHelper.BuildPagedViewXml(input, rowLimit: 2000);
    Assert.Equal("<View><RowLimit>2000</RowLimit></View>", result);
}

State of the Art

Old Approach Current Approach When Changed Impact
Separate installer (MSI/NSIS) Single-file self-contained EXE .NET 5+ No installer needed — xcopy deployment
Framework-dependent deploy Self-contained deploy .NET Core 3.1+ No runtime prereq on target machine
Native DLLs always loose IncludeNativeLibrariesForSelfExtract=true .NET 5+ True single-EXE for WPF
Manual publish profile <PublishSingleFile> in csproj .NET 5+ Reproducible via dotnet publish

Deprecated/outdated:

  • ClickOnce: Still exists but not used here — requires IIS/file share hosting, adds update mechanism this project doesn't need.
  • MSIX packaging: Requires Developer Mode or certificate signing — overkill for an admin tool distributed by the developer directly.

Open Questions

  1. PDB embedding vs. separate file

    • What we know: <DebugType>embedded</DebugType> merges PDB into the EXE; <DebugType>none</DebugType> strips symbols entirely.
    • What's unclear: User preference for crash debugging — does the admin want to be able to debug crashes post-deploy?
    • Recommendation: Default to keeping the separate PDB (current behavior). If release packaging requires it, <DebugType>none</DebugType> is the correct property.
  2. WAM broker (msalruntime.dll) on clean machines

    • What we know: With IncludeNativeLibrariesForSelfExtract=true, msalruntime.dll is bundled into the EXE and extracted on first run. The interactive MSAL login (browser/WAM) uses this DLL.
    • What's unclear: Whether WAM authentication works correctly when the runtime is extracted vs. being a loose file. Not testable without a clean machine.
    • Recommendation: Flag for human smoke test — launch on a clean Windows 10/11 machine, authenticate to a tenant, verify the browser/WAM login flow completes.
  3. EnableCompressionInSingleFile tradeoff

    • What we know: Setting this true reduces EXE size (201 MB → potentially ~130 MB) but adds ~1-3 second startup decompression delay.
    • What's unclear: User tolerance for startup delay vs. distribution size.
    • Recommendation: Do not set — the 201 MB EXE is acceptable for an admin tool. Compression adds startup complexity with minimal distribution benefit for this use case.

Validation Architecture

Test Framework

Property Value
Framework xUnit 2.9.3
Config file SharepointToolbox.Tests/SharepointToolbox.Tests.csproj
Quick run command dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --no-build -v quiet
Full suite command dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -v quiet

Phase Requirements → Test Map

Req ID Behavior Test Type Automated Command File Exists?
FOUND-11-a PublishSingleFile + IncludeNativeLibrariesForSelfExtract produces single EXE smoke (manual) dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o /tmp/pub && ls /tmp/pub/*.dll | wc -l (expect 0) Wave 0
FOUND-11-b App launches on clean machine with no .NET runtime manual smoke N/A — requires clean VM manual-only
SC-2-retry ExecuteQueryRetryHelper.IsThrottleException classifies 429/503 correctly unit dotnet test --filter "ExecuteQueryRetryHelper" Wave 0
SC-2-retry Retry loop reports progress and eventually throws after MaxRetries unit dotnet test --filter "ExecuteQueryRetryHelper" Wave 0
SC-3-fr All 177 EN keys have non-empty, non-bracketed FR values unit dotnet test --filter "LocaleCompleteness" Wave 0
SC-3-fr No diacritic-missing strings appear when language=FR manual smoke N/A — visual inspection manual-only
SC-4-paginat BuildPagedViewXml correctly injects and replaces RowLimit unit dotnet test --filter "SharePointPagination" Wave 0
SC-4-paginat Scan against 5,000+ item library returns complete results manual smoke N/A — requires live tenant manual-only

Sampling Rate

  • Per task commit: dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj --no-build -v quiet
  • Per wave merge: dotnet test SharepointToolbox.Tests/SharepointToolbox.Tests.csproj -v quiet
  • Phase gate: Full suite green before /gsd:verify-work + manual smoke checklist

Wave 0 Gaps

  • SharepointToolbox.Tests/Services/ExecuteQueryRetryHelperTests.cs — covers SC-2-retry
  • SharepointToolbox.Tests/Services/SharePointPaginationHelperTests.cs — covers SC-4-paginat
  • SharepointToolbox.Tests/Localization/LocaleCompletenessTests.cs — covers SC-3-fr
  • ExecuteQueryRetryHelper.IsThrottleException must be changed from private static to internal static
  • SharePointPaginationHelper.BuildPagedViewXml must be changed from private static to internal static
  • InternalsVisibleTo("SharepointToolbox.Tests") already in AssemblyInfo.cs — no change needed

Sources

Primary (HIGH confidence)

  • Official .NET docs: https://learn.microsoft.com/en-us/dotnet/core/deploying/single-file/overview — single-file publish behavior, API incompatibilities, IncludeNativeLibrariesForSelfExtract
  • Live publish test (2026-04-03): ran dotnet publish on actual codebase — confirmed single EXE output with and without IncludeNativeLibrariesForSelfExtract
  • Direct code inspection: ExecuteQueryRetryHelper.cs, SharePointPaginationHelper.cs, Strings.resx, Strings.fr.resx, SharepointToolbox.csproj

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • None

Metadata

Confidence breakdown:

  • Standard stack: HIGH — publish tested live against actual codebase
  • Architecture: HIGH — based on direct code inspection + official docs
  • Pitfalls: HIGH — pitfall 1 verified experimentally; others from official docs + established project patterns
  • FR string gaps: HIGH — direct inspection of Strings.fr.resx, enumerated 25+ affected keys

Research date: 2026-04-03 Valid until: 2026-07-03 (stable .NET tooling; 90 days reasonable)