# 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):**
```bash
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

### Recommended Project Structure Changes

```
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

```xml
<!-- 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.

```csharp
// 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

```csharp
// 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

```xml
<!-- 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

```bash
# 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

```csharp
// 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

```csharp
// 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)
- Microsoft Q&A: https://learn.microsoft.com/en-us/answers/questions/990342/wpf-publishing-application-into-single-exe-file — confirmed WPF native DLL bundling behavior
- MSAL WAM docs: https://learn.microsoft.com/en-us/entra/msal/dotnet/acquiring-tokens/desktop-mobile/wam — msalruntime.dll behavior with broker

### 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)
