diff --git a/.squad/decisions/inbox/cyclops-shim-first.md b/.squad/decisions/inbox/cyclops-shim-first.md new file mode 100644 index 00000000..3ff67775 --- /dev/null +++ b/.squad/decisions/inbox/cyclops-shim-first.md @@ -0,0 +1,30 @@ +# Decision: CLI Transforms Adopt Shim-First Strategy + +**Date:** 2026-07-30 +**Author:** Cyclops +**Status:** Implemented + +## Context + +WebFormsPageBase already provides protected shim properties (Request, Response, Server, Session, Cache, ClientScript) that migrated pages inherit. CLI transforms were redundantly injecting `[Inject]` properties or rewriting calls that already compile against these shims. + +## Decision + +Refactored 5 CLI transforms to be shim-first: + +1. **ResponseRedirectTransform** — No longer rewrites `Response.Redirect()` → `NavigationManager.NavigateTo()`. Calls compile against ResponseShim which handles ~/prefix and .aspx stripping. Only strips `Page.`/`this.` prefix. No `[Inject] NavigationManager`. +2. **SessionDetectTransform** — Removed `[Inject] SessionShim Session` and `[Inject] CacheShim Cache` injection. WebFormsPageBase already provides these. +3. **ClientScriptTransform** — Guidance now says "works via WebFormsPageBase — no injection needed" instead of suggesting `@inject ClientScriptShim`. +4. **RequestFormTransform** — Guidance leads with "Request.Form calls work automatically via RequestShim on WebFormsPageBase." +5. **ServerShimTransform** — Guidance leads with "Server.* calls work automatically via ServerShim on WebFormsPageBase." + +## Rationale + +- Eliminates redundant `[Inject]` properties that shadow base class members +- `Response.Redirect()` calls now preserved as-is — ResponseShim handles URL normalization +- Guidance comments accurately reflect the runtime architecture +- Non-page classes still directed to inject shims via DI + +## Build Verification + +0 errors across net8.0/net9.0/net10.0. diff --git a/.squad/decisions/inbox/forge-shim-review.md b/.squad/decisions/inbox/forge-shim-review.md new file mode 100644 index 00000000..2f30a141 --- /dev/null +++ b/.squad/decisions/inbox/forge-shim-review.md @@ -0,0 +1,28 @@ +# Shim-First Transform Review — Forge's Verdict + +**Date:** 2026-07-30 +**By:** Forge (Lead / Web Forms Reviewer) +**Status:** Advisory +**Requested by:** Jeffrey T. Fritz + +## Decision + +The shim-first refactoring of 5 CLI transforms (ResponseRedirect, SessionDetect, ClientScript, RequestForm, ServerShim) is a **NET IMPROVEMENT** for page-class migrations. Recommend merging with two follow-up items tracked. + +## Rationale + +1. **Eliminating unnecessary [Inject] lines** — WebFormsPageBase already provides Session, Cache, Response, Request, Server, ClientScript as protected properties. The old transforms injected redundant `[Inject] SessionShim` / `[Inject] CacheShim` that would conflict or shadow. +2. **Preserving familiar API surface** — Developers see `Response.Redirect()` and `Session["key"]` in migrated code, matching their mental model and the original source. +3. **All 5 transforms correctly warn about non-page classes** — each emits "For non-page classes, inject via DI" guidance. + +## Gaps Identified (for follow-up) + +1. **Server.Transfer / Server.GetLastError / Server.ClearError** — ServerShim only covers MapPath + Encode/Decode. WingtipToys uses all three missing methods. ServerShimTransform should detect and emit specific TODO guidance for these. +2. **IdentityHelper.RedirectToReturnUrl(string, HttpResponse)** — Takes `System.Web.HttpResponse`, not `ResponseShim`. Type mismatch requires manual rewrite regardless of transform approach. Neither old nor new transforms handle this. +3. **HttpContext.Current.Session in non-page classes** — ShoppingCartActions uses `HttpContext.Current.Session[key]` (5 occurrences). SessionDetectTransform won't match this pattern because it only looks for bare `Session[`. + +## Impact + +- **Bishop**: Consider adding `Server.Transfer` / `Server.GetLastError` / `Server.ClearError` detection to ServerShimTransform with TODO guidance. +- **Bishop**: Consider adding `HttpContext.Current.Session[` pattern detection to SessionDetectTransform. +- **All agents**: When reviewing migrated non-page classes, verify shim DI is manually wired. diff --git a/.squad/decisions/inbox/forge-toolkit-improvements.md b/.squad/decisions/inbox/forge-toolkit-improvements.md new file mode 100644 index 00000000..c62f942f --- /dev/null +++ b/.squad/decisions/inbox/forge-toolkit-improvements.md @@ -0,0 +1,283 @@ +# Shim-First Toolkit Improvements — Forge's Proposal + +**Date:** 2026-07-30 +**By:** Forge (Lead / Web Forms Reviewer) +**Status:** Proposed +**Requested by:** Jeffrey T. Fritz +**Builds on:** forge-shim-review.md (same date) + +--- + +## Executive Summary + +The shim-first approach is proven (~95% compile-as-is for page classes), but three detection gaps and one silent risk leave non-trivial migration failures at compile time and runtime. This proposal adds **4 CLI transform enhancements**, **2 skill documentation patches**, and **1 new non-page class detection strategy** — all surgical changes that extend the existing architecture without introducing new base classes or breaking changes. + +--- + +## 1. Proposed Changes — CLI Transforms + +### 1.1 ServerShimTransform: Detect Transfer/GetLastError/ClearError + +**File:** `src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ServerShimTransform.cs` +**Gap:** Server.Transfer(), Server.GetLastError(), Server.ClearError() are undetected; WingtipToys ErrorPage.aspx.cs, Default.aspx.cs, Global.asax.cs won't compile +**Priority:** P0 (must-have — compile failure) +**Complexity:** Small + +**What to change:** + +Add three new regex patterns after line 28: + +```csharp +// Server.Transfer("page.aspx") — no Blazor equivalent; must become NavigationManager.NavigateTo +private static readonly Regex ServerTransferRegex = new( + @"\bServer\.Transfer\s*\(", + RegexOptions.Compiled); + +// Server.GetLastError() — no direct equivalent; use IExceptionHandlerFeature or middleware +private static readonly Regex ServerGetLastErrorRegex = new( + @"\bServer\.GetLastError\s*\(\s*\)", + RegexOptions.Compiled); + +// Server.ClearError() — no direct equivalent; errors flow through middleware pipeline +private static readonly Regex ServerClearErrorRegex = new( + @"\bServer\.ClearError\s*\(\s*\)", + RegexOptions.Compiled); +``` + +Update `Apply()` to detect these and emit specific TODO guidance: + +``` +// TODO(bwfc-server): Server.Transfer() has NO shim — replace with Response.Redirect() or NavigationManager.NavigateTo(). +// Server.Transfer preserves URL; for same effect use forceLoad:true on NavigateTo. +// TODO(bwfc-server): Server.GetLastError() has NO shim — use IExceptionHandlerFeature in error-handling middleware. +// TODO(bwfc-server): Server.ClearError() has NO shim — error handling uses middleware pipeline, remove this call. +``` + +**Key detail:** These are NOT shimmable. The guidance must clearly say "manual rewrite required" — not "works via shim." + +--- + +### 1.2 SessionDetectTransform: Detect HttpContext.Current.Session + +**File:** `src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/SessionDetectTransform.cs` +**Gap:** `HttpContext.Current.Session["key"]` pattern (5 occurrences in ShoppingCartActions.cs) not caught by existing `\bSession\s*\[` regex +**Priority:** P0 (must-have — compile failure) +**Complexity:** Small + +**What to change:** + +Add new regex after line 22: + +```csharp +// HttpContext.Current.Session["key"] — common in non-page helper classes +private static readonly Regex HttpContextSessionRegex = new( + @"\bHttpContext\.Current\.Session\s*\[", + RegexOptions.Compiled); +``` + +Update `Apply()`: +1. Check `HttpContextSessionRegex.IsMatch(content)` alongside existing `hasSession` +2. When matched, **replace** `HttpContext.Current.Session[` → `Session[` (safe textual transform) +3. Emit specific TODO: `// TODO(bwfc-session-state): HttpContext.Current.Session replaced with Session. This class needs [Inject] SessionShim or constructor injection — see non-page class guidance below.` + +Also detect `HttpContext.Current.` broadly for awareness: + +```csharp +private static readonly Regex HttpContextCurrentRegex = new( + @"\bHttpContext\.Current\b", + RegexOptions.Compiled); +``` + +--- + +### 1.3 ResponseRedirectTransform: Detect ThreadAbortException dead code + +**File:** `src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ResponseRedirectTransform.cs` +**Gap:** `Response.Redirect(url, true)` silently ignores `endResponse` param; `catch (ThreadAbortException)` blocks become unreachable dead code +**Priority:** P1 (should-have — runtime risk, not compile failure) +**Complexity:** Small + +**What to change:** + +Add regex after line 28: + +```csharp +// catch (ThreadAbortException) — dead code in Blazor; Web Forms threw this on Redirect(url, true) +private static readonly Regex ThreadAbortCatchRegex = new( + @"catch\s*\(\s*ThreadAbortException\b", + RegexOptions.Compiled); + +// Response.Redirect(url, true) — endResponse parameter silently ignored +private static readonly Regex RedirectEndResponseRegex = new( + @"Response\.Redirect\s*\([^,]+,\s*true\s*\)", + RegexOptions.Compiled); +``` + +Emit warnings: +``` +// TODO(bwfc-navigation): Response.Redirect(url, true) — the endResponse parameter is silently ignored. +// In Web Forms, true caused ThreadAbortException to end page processing. +// In Blazor, code after Redirect() always continues. Review control flow. +// TODO(bwfc-navigation): catch (ThreadAbortException) is dead code — Blazor never throws this. Remove the catch block. +``` + +--- + +### 1.4 New: NonPageClassDetectTransform + +**File:** `src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/NonPageClassDetectTransform.cs` (NEW) +**Gap:** Classes like ShoppingCartActions.cs that use Session/Response/Server but don't inherit WebFormsPageBase get shim guidance that says "works via WebFormsPageBase" — which is wrong for them +**Priority:** P1 (should-have) +**Complexity:** Medium + +**What to change:** + +Create a new transform (Order: 50, runs early) that detects whether a class inherits from a page/component base: + +```csharp +// Detect if class inherits WebFormsPageBase, ComponentBase, LayoutComponentBase, etc. +private static readonly Regex PageBaseClassRegex = new( + @"class\s+\w+\s*:\s*(?:.*?)(?:WebFormsPageBase|ComponentBase|LayoutComponentBase|Page)\b", + RegexOptions.Compiled); +``` + +When a file uses shim-relevant APIs (`Session[`, `Response.Redirect`, `Server.MapPath`, `Cache[`) but does NOT inherit a page base class, inject a **different** guidance block: + +``` +// --- Non-Page Class: Manual DI Required --- +// TODO(bwfc-non-page): This class uses Web Forms APIs but does NOT inherit WebFormsPageBase. +// Shims are NOT automatically available. You must inject them: +// +// Option A: Constructor injection (recommended for services) +// public class ShoppingCartActions +// { +// private readonly SessionShim _session; +// public ShoppingCartActions(SessionShim session) => _session = session; +// } +// +// Option B: [Inject] attribute (for Blazor components only) +// [Inject] SessionShim Session { get; set; } +// +// Register in Program.cs: builder.Services.AddBlazorWebFormsComponents(); +``` + +Store the `IsPageClass` result in `FileMetadata` (add a bool property) so downstream transforms can adjust their guidance between "works via WebFormsPageBase" vs. "needs manual DI." + +--- + +## 2. Proposed Changes — Migration Skills + +### 2.1 bwfc-migration SKILL.md: Add gap patterns + +**File:** `migration-toolkit/skills/bwfc-migration/SKILL.md` +**Priority:** P1 +**Complexity:** Small + +Add a new section after the shim table (after line 57): + +```markdown +### ⚠️ Server Methods NOT Covered by ServerShim + +These `Server.*` methods have NO shim equivalent and require manual rewrite: + +| Pattern | Replacement | +|---------|------------| +| `Server.Transfer("page.aspx")` | `Response.Redirect("/page")` or `NavigationManager.NavigateTo("/page", forceLoad: true)` | +| `Server.GetLastError()` | Error-handling middleware with `IExceptionHandlerFeature` | +| `Server.ClearError()` | Remove — error handling is middleware-based | +| `Server.Execute("page.aspx")` | Not applicable in Blazor — decompose into shared components | + +### ⚠️ Non-Page Classes Need Manual DI Wiring + +Classes that are not Blazor components (services, helpers, utilities) do NOT inherit WebFormsPageBase. They cannot use `Session["key"]` or `Response.Redirect()` directly. + +**Pattern to detect:** Any `.cs` file that uses `HttpContext.Current.Session`, `HttpContext.Current.Response`, or `HttpContext.Current.Server` — these must be refactored to accept shims via constructor injection. + +**Example (ShoppingCartActions.cs):** +```csharp +// Before: HttpContext.Current.Session["CartId"] +// After: +public class ShoppingCartActions +{ + private readonly SessionShim _session; + public ShoppingCartActions(SessionShim session) => _session = session; + + public string GetCartId() => _session["CartId"]?.ToString(); +} +``` +``` + +### 2.2 bwfc-data-migration SKILL.md: Add ThreadAbortException warning + +**File:** `migration-toolkit/skills/bwfc-data-migration/SKILL.md` +**Priority:** P2 +**Complexity:** Small + +In the "Static Helpers with HttpContext" section (line 492), add: + +```markdown +### ThreadAbortException Is Dead Code + +In Web Forms, `Response.Redirect(url, true)` threw `ThreadAbortException` to halt page processing. Any `catch (ThreadAbortException)` blocks are dead code in Blazor — remove them. Review control flow after `Response.Redirect()` calls, as code after the redirect WILL execute in Blazor (it doesn't halt like Web Forms). +``` + +--- + +## 3. Proposed Changes — Copilot Instructions Template + +### 3.1 copilot-instructions-template.md: Add gap warnings + +**File:** `migration-toolkit/copilot-instructions-template.md` +**Priority:** P1 +**Complexity:** Small + +Add to the "Common Gotchas" section (after item 10, line 216): + +```markdown +11. **Server.Transfer has no shim** — Replace with `Response.Redirect()` or `NavigationManager.NavigateTo()`. Server.Transfer preserved the URL; use `forceLoad: true` for similar behavior. +12. **Non-page classes need DI** — Service/helper classes that used `HttpContext.Current.Session` cannot use shims directly. Inject `SessionShim`, `ResponseShim`, etc. via constructor. +13. **ThreadAbortException is dead code** — `catch (ThreadAbortException)` blocks after `Response.Redirect(url, true)` never execute in Blazor. Remove them and review control flow. +14. **IdentityHelper.RedirectToReturnUrl** — This helper takes `System.Web.HttpResponse`, not `ResponseShim`. Rewrite to accept `NavigationManager` or `ResponseShim` parameter. +``` + +--- + +## 4. What NOT to Do + +1. **❌ Do NOT create a new base class for non-page classes** (e.g., `WebFormsServiceBase`). Constructor DI is the correct pattern for services. A base class would create artificial coupling and fight against ASP.NET Core's DI model. + +2. **❌ Do NOT add Server.Transfer to ServerShim as a method.** Transfer implies server-side URL rewriting without browser redirect — a fundamentally incompatible concept in Blazor's component model. A `Transfer()` method on ServerShim would give false confidence. + +3. **❌ Do NOT auto-remove ThreadAbortException catch blocks.** The CLI should emit TODO guidance only — auto-removal could delete meaningful error-handling logic that the developer wrapped inside the catch block. + +4. **❌ Do NOT auto-inject `[Inject] SessionShim` into non-page classes.** The CLI cannot determine the correct DI pattern (constructor vs. property injection, scoping, lifetime). Emit guidance and let the developer or L2 Copilot decide. + +5. **❌ Do NOT add `HttpContext.Current` as a shim.** `HttpContext.Current` is a static accessor pattern that fundamentally doesn't work in async/Blazor contexts. The correct fix is to replace it with DI, not to shim it. + +--- + +## 5. Implementation Order + +| Order | Item | Priority | Depends On | Est. Effort | +|-------|------|----------|------------|-------------| +| 1 | SessionDetectTransform: `HttpContext.Current.Session` regex | P0 | None | 1 hour | +| 2 | ServerShimTransform: Transfer/GetLastError/ClearError detection | P0 | None | 1 hour | +| 3 | ResponseRedirectTransform: ThreadAbortException detection | P1 | None | 1 hour | +| 4 | NonPageClassDetectTransform: new transform + FileMetadata.IsPageClass | P1 | #1-3 for full benefit | 3 hours | +| 5 | bwfc-migration SKILL.md: gap patterns section | P1 | None | 30 min | +| 6 | copilot-instructions-template.md: gotcha items | P1 | None | 15 min | +| 7 | bwfc-data-migration SKILL.md: ThreadAbort warning | P2 | None | 15 min | + +Items 1-3 are independent and can be done in parallel. Item 4 benefits from 1-3 being done first (so the downstream transforms can check `IsPageClass`). Items 5-7 are documentation-only and can ship independently. + +--- + +## 6. Verification Plan + +- **Unit tests:** Add test cases to existing CLI transform test suite for each new regex pattern: + - `Server.Transfer("ErrorPage.aspx")` → detected, TODO emitted + - `HttpContext.Current.Session["CartId"]` → detected, rewritten, TODO emitted + - `catch (ThreadAbortException)` → detected, TODO emitted + - Non-page class with `Session[` → different guidance than page class +- **Integration test:** Run CLI against WingtipToys source, verify all 3 gaps produce TODO comments +- **Regression:** Existing 373 tests must continue passing diff --git a/migration-toolkit/copilot-instructions-template.md b/migration-toolkit/copilot-instructions-template.md index 64490391..fc8a0b83 100644 --- a/migration-toolkit/copilot-instructions-template.md +++ b/migration-toolkit/copilot-instructions-template.md @@ -214,6 +214,10 @@ When Copilot encounters these attributes during migration, remove them without c 8. **Visibility** — `Visible="false"` works in BWFC, but prefer `@if (condition)` for dynamic visibility. 9. **ClientScript migration** — `Page.ClientScript.RegisterStartupScript()` works via `ClientScriptShim`. For new code, prefer `IJSRuntime`. 10. **Form POST data** — Use `` to enable `Request.Form["key"]` in interactive mode. +11. **Server.Transfer has NO shim** — There is no BWFC shim for `Server.Transfer()`. Use `NavigationManager.NavigateTo()` instead. Server.Transfer does server-side URL rewriting which doesn't exist in Blazor. +12. **Server.GetLastError/ClearError have NO shim** — Use `ILogger` and middleware-based error handling (`app.UseExceptionHandler`). +13. **ThreadAbortException is dead code** — Web Forms throws `ThreadAbortException` on `Response.Redirect(url, true)`. Blazor does not. Any `catch (ThreadAbortException)` blocks are dead code after migration — review and remove. +14. **HttpContext.Current doesn't work in Blazor** — Static `HttpContext.Current.Session["key"]` must be replaced with `Session["key"]` (on pages) or constructor-injected `SessionShim` (in non-page classes). The CLI tool handles this replacement automatically. --- diff --git a/migration-toolkit/skills/bwfc-data-migration/SKILL.md b/migration-toolkit/skills/bwfc-data-migration/SKILL.md index 9f984ad0..593f2b5b 100644 --- a/migration-toolkit/skills/bwfc-data-migration/SKILL.md +++ b/migration-toolkit/skills/bwfc-data-migration/SKILL.md @@ -492,6 +492,9 @@ Web Forms `SelectMethod` returns `IQueryable` synchronously. Blazor services sho ### Static Helpers with HttpContext Web Forms often has static helper classes that access `HttpContext.Current`. These must be refactored to accept dependencies via constructor injection. +### ThreadAbortException Dead Code Warning +Web Forms throws `ThreadAbortException` when `Response.Redirect(url, true)` is called with `endResponse=true`. Blazor does **not** throw this exception — `ResponseShim.Redirect()` silently ignores the `endResponse` parameter. Any `catch (ThreadAbortException)` blocks become dead code after migration. Review and remove them. Code that runs AFTER `Response.Redirect(url, true)` **will execute** in Blazor (unlike Web Forms where execution stopped). + --- ## ❌ Common Anti-Patterns to Avoid diff --git a/migration-toolkit/skills/bwfc-migration/SKILL.md b/migration-toolkit/skills/bwfc-migration/SKILL.md index 29617667..7bf21437 100644 --- a/migration-toolkit/skills/bwfc-migration/SKILL.md +++ b/migration-toolkit/skills/bwfc-migration/SKILL.md @@ -56,6 +56,35 @@ The CLI tool emits structured `// TODO(bwfc-*)` comments and a JSON migration re | `ClientScript.RegisterStartupScript()` | ClientScriptShim | ✅ Yes | Injects JavaScript via JSRuntime | | `ViewState["key"]` | WebFormsPageBase | ✅ Yes | In-memory dictionary per component instance | +### ⚠️ Server Methods WITHOUT Shims + +These Server.* methods have **no BWFC shim** and require manual rewriting: + +| Web Forms Pattern | Shim? | Migration Action | +|-------------------|-------|-----------------| +| `Server.Transfer("page.aspx")` | ❌ None | Replace with `NavigationManager.NavigateTo()`. Server.Transfer does server-side URL rewriting which doesn't exist in Blazor. | +| `Server.GetLastError()` | ❌ None | Use `ILogger` and middleware-based error handling (`app.UseExceptionHandler`). | +| `Server.ClearError()` | ❌ None | Error clearing is handled by middleware in ASP.NET Core. | +| `HttpContext.Current.Session["key"]` | ❌ None | Replace with `Session["key"]` (on pages) or inject `SessionShim` via constructor DI (non-page classes). The CLI tool handles this automatically. | + +### ⚠️ Non-Page Classes + +Classes that use `Session["key"]`, `Response.Redirect()`, etc. but do **NOT** inherit from `WebFormsPageBase` must receive shims via **constructor DI**, not the base class: + +```csharp +// Non-page class — inject shims via DI +public class CartHelper +{ + private readonly SessionShim _session; + public CartHelper(SessionShim session) => _session = session; + public string GetCartId() => _session["CartId"]?.ToString(); +} +``` + +### ⚠️ ThreadAbortException Dead Code + +Web Forms throws `ThreadAbortException` when `Response.Redirect(url, true)` is called with `endResponse=true`. Blazor does **not** throw this exception. Any `catch (ThreadAbortException)` blocks become **dead code** after migration — review and remove them. + ### Key Benefits of Shims 1. **Minimal Code Changes** — Original Web Forms code works with ZERO changes in most cases diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ResponseRedirectTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ResponseRedirectTransform.cs index 2ce2bc95..7189d6fd 100644 --- a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ResponseRedirectTransform.cs +++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ResponseRedirectTransform.cs @@ -37,10 +37,26 @@ public class ResponseRedirectTransform : ICodeBehindTransform @"((?:public|internal|private)\s+(?:partial\s+)?class\s+\w+[^{]*\{)", RegexOptions.Compiled); + // catch (ThreadAbortException) — dead code after migration (Blazor doesn't throw) + private static readonly Regex ThreadAbortCatchRegex = new( + @"catch\s*\(\s*ThreadAbortException\b", + RegexOptions.Compiled); + + // Response.Redirect(url, true) — endResponse=true silently ignored by ResponseShim + private static readonly Regex RedirectEndResponseTrueRegex = new( + @"Response\.Redirect\s*\([^,)]+,\s*true\s*\)", + RegexOptions.Compiled); + + private const string ThreadAbortMarker = "// TODO(bwfc-navigation): DEAD CODE"; + private const string EndResponseMarker = "// TODO(bwfc-navigation): endResponse=true"; + public string Apply(string content, FileMetadata metadata) { var hasRedirectConversion = false; + // Pre-detect endResponse=true BEFORE conversion replaces it + var hasEndResponseTrue = RedirectEndResponseTrueRegex.IsMatch(content); + // Pattern 1: literal URL with endResponse bool if (RedirectLitBoolRegex.IsMatch(content)) { @@ -84,6 +100,25 @@ public string Apply(string content, FileMetadata metadata) content = ClassOpenRegex.Replace(content, "$1" + injectLine, 1); } + // Detect ThreadAbortException catch blocks — dead code after migration + if (ThreadAbortCatchRegex.IsMatch(content) && !content.Contains(ThreadAbortMarker)) + { + content = ThreadAbortCatchRegex.Replace(content, m => + m.Value + $"\n {ThreadAbortMarker} — Blazor does not throw ThreadAbortException on redirect. " + + "This catch block is dead code after migration. Review and remove if safe."); + } + + // Warn about Response.Redirect(url, true) endResponse behavior + if (hasEndResponseTrue && !content.Contains(EndResponseMarker)) + { + if (ClassOpenRegex.IsMatch(content)) + { + var warning = $"\n {EndResponseMarker} is silently ignored by ResponseShim. " + + "Code after redirect calls WILL execute (unlike Web Forms where it threw ThreadAbortException).\n"; + content = ClassOpenRegex.Replace(content, "$1" + warning, 1); + } + } + return content; } } diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ServerShimTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ServerShimTransform.cs index 6fe92d64..a85d438c 100644 --- a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ServerShimTransform.cs +++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/ServerShimTransform.cs @@ -29,6 +29,21 @@ public class ServerShimTransform : ICodeBehindTransform @"\bHttpServerUtility\b", RegexOptions.Compiled); + // Server.Transfer("page.aspx") — no shim, architecturally incompatible with Blazor + private static readonly Regex ServerTransferRegex = new( + @"\bServer\.Transfer\s*\(", + RegexOptions.Compiled); + + // Server.GetLastError() — no shim, use middleware-based error handling + private static readonly Regex ServerGetLastErrorRegex = new( + @"\bServer\.GetLastError\s*\(", + RegexOptions.Compiled); + + // Server.ClearError() — no shim, use middleware-based error handling + private static readonly Regex ServerClearErrorRegex = new( + @"\bServer\.ClearError\s*\(", + RegexOptions.Compiled); + private static readonly Regex ClassOpenRegex = new( @"((?:public|internal|private)\s+(?:partial\s+)?class\s+\w+[^{]*\{)", RegexOptions.Compiled); @@ -40,8 +55,11 @@ public string Apply(string content, FileMetadata metadata) var hasMapPath = ServerMapPathRegex.IsMatch(content); var hasEncode = ServerEncodeRegex.IsMatch(content); var hasUtility = HttpServerUtilityRegex.IsMatch(content); + var hasTransfer = ServerTransferRegex.IsMatch(content); + var hasGetLastError = ServerGetLastErrorRegex.IsMatch(content); + var hasClearError = ServerClearErrorRegex.IsMatch(content); - if (!hasMapPath && !hasEncode && !hasUtility) return content; + if (!hasMapPath && !hasEncode && !hasUtility && !hasTransfer && !hasGetLastError && !hasClearError) return content; // Build guidance if (!content.Contains(GuidanceMarker) && ClassOpenRegex.IsMatch(content)) @@ -59,13 +77,35 @@ public string Apply(string content, FileMetadata metadata) var guidanceBlock = "\n " + GuidanceMarker + "\n" + " // TODO(bwfc-server): Server.* calls work via ServerShim on WebFormsPageBase.\n" - + $" // Methods found: {string.Join(", ", methods)}\n" + + (methods.Count > 0 ? $" // Methods found: {string.Join(", ", methods)}\n" : "") + " // For non-page classes, inject ServerShim via DI.\n" + (hasMapPath ? " // MapPath(\"~/path\") maps to IWebHostEnvironment.WebRootPath.\n" : ""); content = ClassOpenRegex.Replace(content, "$1" + guidanceBlock, 1); } + // Emit manual-rewrite TODOs for unsupported Server methods + if (hasTransfer && !content.Contains("TODO(bwfc-server): Server.Transfer")) + { + content = ServerTransferRegex.Replace(content, m => + "Server.Transfer( /* TODO(bwfc-server): Server.Transfer has NO SHIM — architecturally incompatible with Blazor. " + + "Use NavigationManager.NavigateTo() instead. Server.Transfer does server-side URL rewriting which doesn't exist in Blazor. */ "); + } + + if (hasGetLastError && !content.Contains("TODO(bwfc-server): Server.GetLastError")) + { + content = ServerGetLastErrorRegex.Replace(content, m => + "Server.GetLastError( /* TODO(bwfc-server): Server.GetLastError has NO SHIM. " + + "Use ILogger and middleware-based error handling (app.UseExceptionHandler). */ "); + } + + if (hasClearError && !content.Contains("TODO(bwfc-server): Server.ClearError")) + { + content = ServerClearErrorRegex.Replace(content, m => + "Server.ClearError( /* TODO(bwfc-server): Server.ClearError has NO SHIM. " + + "Error clearing is handled by middleware in ASP.NET Core. */ "); + } + return content; } } diff --git a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/SessionDetectTransform.cs b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/SessionDetectTransform.cs index af74542c..6d026324 100644 --- a/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/SessionDetectTransform.cs +++ b/src/BlazorWebFormsComponents.Cli/Transforms/CodeBehind/SessionDetectTransform.cs @@ -22,6 +22,11 @@ public class SessionDetectTransform : ICodeBehindTransform @"\bSession\s*\[", RegexOptions.Compiled); + // HttpContext.Current.Session[ — static accessor doesn't work in Blazor + private static readonly Regex HttpContextCurrentSessionRegex = new( + @"HttpContext\.Current\.Session\s*\[", + RegexOptions.Compiled); + // Literal string key: Cache["ProductList"] private static readonly Regex CacheKeyRegex = new( @"\bCache\[""([^""]*)""\]", @@ -42,6 +47,23 @@ public class SessionDetectTransform : ICodeBehindTransform public string Apply(string content, FileMetadata metadata) { + // Replace HttpContext.Current.Session[ with Session[ BEFORE detection + if (HttpContextCurrentSessionRegex.IsMatch(content)) + { + content = HttpContextCurrentSessionRegex.Replace(content, "Session["); + // Add guidance about static accessor removal + if (!content.Contains("TODO(bwfc-session-state): HttpContext.Current.Session")) + { + var classMatch = ClassOpenRegex.Match(content); + if (classMatch.Success) + { + var httpContextNote = "\n // TODO(bwfc-session-state): HttpContext.Current.Session was replaced with Session[].\n" + + " // For non-page classes, inject SessionShim via constructor DI instead of using HttpContext.Current.\n"; + content = ClassOpenRegex.Replace(content, "$1" + httpContextNote, 1); + } + } + } + // Detect patterns before any modifications var sessionLiteralMatches = SessionKeyRegex.Matches(content); var hasSession = SessionAccessRegex.IsMatch(content); diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ResponseRedirectTransformTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ResponseRedirectTransformTests.cs new file mode 100644 index 00000000..8423d13b --- /dev/null +++ b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ResponseRedirectTransformTests.cs @@ -0,0 +1,135 @@ +using BlazorWebFormsComponents.Cli.Pipeline; +using BlazorWebFormsComponents.Cli.Transforms.CodeBehind; + +namespace BlazorWebFormsComponents.Cli.Tests.TransformUnit; + +/// +/// Unit tests for ResponseRedirectTransform — ThreadAbortException dead code +/// detection and endResponse=true warning. +/// +public class ResponseRedirectTransformTests +{ + private readonly ResponseRedirectTransform _transform = new(); + + private static FileMetadata TestMetadata(string content) => new() + { + SourceFilePath = "Default.aspx.cs", + OutputFilePath = "Default.razor.cs", + FileType = FileType.Page, + OriginalContent = content + }; + + [Fact] + public void DetectsThreadAbortCatch_EmitsDeadCodeWarning() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + try + { + Response.Redirect(""~/Login.aspx""); + } + catch (ThreadAbortException) + { + // Web Forms pattern — absorb redirect exception + } + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("TODO(bwfc-navigation): DEAD CODE", result); + Assert.Contains("dead code after migration", result); + } + + [Fact] + public void DetectsEndResponseTrue_EmitsWarning() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + Response.Redirect(""~/Login.aspx"", true); + // This code would NOT execute in Web Forms + DoSomething(); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("TODO(bwfc-navigation): endResponse=true", result); + Assert.Contains("silently ignored by ResponseShim", result); + } + + [Fact] + public void NoRedirect_NoChanges() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() { var x = 42; } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Equal(input, result); + } + + [Fact] + public void ThreadAbortCatch_Idempotent() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + try + { + Response.Redirect(""~/Login.aspx""); + } + catch (ThreadAbortException) + { + } + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + result = _transform.Apply(result, TestMetadata(result)); + + var count = result.Split("DEAD CODE").Length - 1; + Assert.Equal(1, count); + } + + [Fact] + public void EndResponseTrue_Idempotent() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + Response.Redirect(""~/Login.aspx"", true); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + result = _transform.Apply(result, TestMetadata(result)); + + var count = result.Split("endResponse=true").Length - 1; + Assert.Equal(1, count); + } + + [Fact] + public void OrderIs300() + { + Assert.Equal(300, _transform.Order); + } +} diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ServerShimTransformTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ServerShimTransformTests.cs index 15ee6bcf..f28819b5 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ServerShimTransformTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/ServerShimTransformTests.cs @@ -134,6 +134,104 @@ void Process() Assert.Equal(1, count); } + [Fact] + public void DetectsServerTransfer_AddsNoShimTodo() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + Server.Transfer(""~/OtherPage.aspx""); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("TODO(bwfc-server): Server.Transfer has NO SHIM", result); + Assert.Contains("NavigationManager.NavigateTo()", result); + } + + [Fact] + public void DetectsServerGetLastError_AddsNoShimTodo() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + var ex = Server.GetLastError(); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("TODO(bwfc-server): Server.GetLastError has NO SHIM", result); + Assert.Contains("ILogger", result); + } + + [Fact] + public void DetectsServerClearError_AddsNoShimTodo() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + Server.ClearError(); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("TODO(bwfc-server): Server.ClearError has NO SHIM", result); + Assert.Contains("middleware", result); + } + + [Fact] + public void DetectsTransferAndMapPath_BothHandled() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + var path = Server.MapPath(""~/uploads""); + Server.Transfer(""~/Other.aspx""); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("MapPath", result); + Assert.Contains("TODO(bwfc-server): Server.Transfer has NO SHIM", result); + Assert.Contains("TODO(bwfc-server): Server.* calls work via ServerShim", result); + } + + [Fact] + public void ServerTransfer_Idempotent() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Process() + { + Server.Transfer(""~/Other.aspx""); + } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + result = _transform.Apply(result, TestMetadata(result)); + + var count = result.Split("Server.Transfer has NO SHIM").Length - 1; + Assert.Equal(1, count); + } + [Fact] public void OrderIs330() { diff --git a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/SessionDetectTransformTests.cs b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/SessionDetectTransformTests.cs index 88709982..d0230019 100644 --- a/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/SessionDetectTransformTests.cs +++ b/tests/BlazorWebFormsComponents.Cli.Tests/TransformUnit/SessionDetectTransformTests.cs @@ -266,6 +266,72 @@ void Load() Assert.Contains(@"Session[""UserName""] = ""test""", result); } + [Fact] + public void DetectsHttpContextCurrentSession_ReplacesWithSession() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Load() { var x = HttpContext.Current.Session[""CartId""]; } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + // The actual code line should have HttpContext.Current.Session replaced + Assert.DoesNotContain(@"var x = HttpContext.Current.Session[""CartId""]", result); + Assert.Contains(@"var x = Session[""CartId""]", result); + Assert.Contains("TODO(bwfc-session-state): HttpContext.Current.Session was replaced", result); + } + + [Fact] + public void HttpContextCurrentSession_StillInjectsSessionShim() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Load() { var x = HttpContext.Current.Session[""CartId""]; } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + // After replacement, Session[ is detected → [Inject] is added + Assert.Contains("[Inject] private SessionShim Session { get; set; }", result); + } + + [Fact] + public void HttpContextCurrentSession_EmitsDiGuidanceForNonPage() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Load() { var x = HttpContext.Current.Session[""CartId""]; } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + + Assert.Contains("non-page classes, inject SessionShim via constructor DI", result); + } + + [Fact] + public void HttpContextCurrentSession_Idempotent() + { + var input = @"namespace MyApp +{ + public partial class MyPage + { + void Load() { var x = HttpContext.Current.Session[""CartId""]; } + } +}"; + var result = _transform.Apply(input, TestMetadata(input)); + result = _transform.Apply(result, TestMetadata(result)); + + var count = result.Split("HttpContext.Current.Session was replaced").Length - 1; + Assert.Equal(1, count); + } + [Fact] public void OrderIs400() {